Add verse node.
This commit is contained in:
parent
fe23b6a91b
commit
cdac21ebda
5 changed files with 254 additions and 1 deletions
45
app/assets/styles/base/_tiptap.scss
Normal file
45
app/assets/styles/base/_tiptap.scss
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Public rendering for custom Tiptap block nodes. The editor emits
|
||||
// these tags/classes via renderHTML (see app/utils/tiptapVerse.ts,
|
||||
// tiptapUupgsList.ts). On the public page the serialized HTML is
|
||||
// dropped in via v-html, so these styles need to match the element
|
||||
// shape generated server-side — the node views in the editor are a
|
||||
// separate story (see app/components/admin/RichTextEditor.vue).
|
||||
|
||||
// _globals.scss sets `.wp-block-group { padding: var(--spacing-4xl); }`
|
||||
// for the layout groups scraped in from WordPress. Scraped pages often
|
||||
// wrap or nest verses inside these groups, compounding the group's
|
||||
// padding with the verse's own. Neutralize the group padding both when
|
||||
// it wraps a verse and when it's nested inside one — the verse handles
|
||||
// its own spacing.
|
||||
.wp-block-group:has(> .doxa-verse),
|
||||
.doxa-verse .wp-block-group {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
// Verse block
|
||||
.doxa-verse {
|
||||
background-color: var(--color-surface-brand);
|
||||
color: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin: var(--spacing-lg) 0;
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
text-align: center;
|
||||
color: var(--color-white);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
> :first-child { margin-top: 0; }
|
||||
> :last-child { margin-bottom: 0; }
|
||||
|
||||
&[data-reference]::after {
|
||||
content: attr(data-reference);
|
||||
display: block;
|
||||
text-align: right;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-style: italic;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
@use 'base/reset';
|
||||
@use 'base/globals';
|
||||
@use 'base/themes';
|
||||
@use 'base/tiptap';
|
||||
@use 'base/print';
|
||||
|
||||
// Composition Layer (Everylayout)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,13 @@ function insertUupgsList() {
|
|||
editor.value?.chain().focus().insertContent({ type: 'uupgsList' }).run()
|
||||
}
|
||||
|
||||
function insertVerse() {
|
||||
editor.value?.chain().focus().insertContent({
|
||||
type: 'verse',
|
||||
content: [{ type: 'paragraph' }]
|
||||
}).run()
|
||||
}
|
||||
|
||||
// Clicks on the padding area around the ProseMirror content don't move
|
||||
// the cursor by default. Forward those clicks to the editor and focus
|
||||
// at end-of-doc so the click anywhere inside the body area starts editing.
|
||||
|
|
@ -156,6 +163,7 @@ function onBodyClick(e: MouseEvent) {
|
|||
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-image" aria-label="Image" :loading="uploading" @click="insertImage" />
|
||||
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-youtube" aria-label="YouTube" @click="insertYoutube" />
|
||||
<UButton size="xs" variant="ghost" :color="isActive('uupgsList') ? 'primary' : 'neutral'" icon="i-lucide-globe" label="UUPG list" aria-label="Insert UUPG list" @click="insertUupgsList" />
|
||||
<UButton size="xs" variant="ghost" :color="isActive('verse') ? 'primary' : 'neutral'" icon="i-lucide-book-open" label="Verse" aria-label="Insert verse" @click="insertVerse" />
|
||||
<div class="w-px bg-(--ui-border) mx-1" />
|
||||
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-undo-2" aria-label="Undo" @click="cmd(e => e.chain().focus().undo().run())" />
|
||||
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-redo-2" aria-label="Redo" @click="cmd(e => e.chain().focus().redo().run())" />
|
||||
|
|
@ -220,4 +228,51 @@ function onBodyClick(e: MouseEvent) {
|
|||
color: var(--ui-text-muted);
|
||||
font-family: var(--ui-font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.tiptap-body :deep(.doxa-verse--editor) {
|
||||
background-color: var(--color-surface-brand, var(--ui-primary));
|
||||
color: #fff;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.tiptap-body :deep(.doxa-verse__ref-bar) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.tiptap-body :deep(.doxa-verse__ref-input) {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.tiptap-body :deep(.doxa-verse__ref-input::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.tiptap-body :deep(.doxa-verse__ref-input:focus) {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.tiptap-body :deep(.doxa-verse__content p) {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.tiptap-body :deep(.doxa-verse__content p:first-child) { margin-top: 0; }
|
||||
.tiptap-body :deep(.doxa-verse__content p:last-child) { margin-bottom: 0; }
|
||||
.tiptap-body :deep(.doxa-verse__citation) {
|
||||
text-align: right;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import Superscript from '@tiptap/extension-superscript'
|
|||
import Youtube from '@tiptap/extension-youtube'
|
||||
import { Div } from './tiptapDiv'
|
||||
import { UupgsListNode } from './tiptapUupgsList'
|
||||
import { Verse } from './tiptapVerse'
|
||||
|
||||
export function buildTiptapExtensions() {
|
||||
return [
|
||||
|
|
@ -31,6 +32,7 @@ export function buildTiptapExtensions() {
|
|||
Superscript,
|
||||
Youtube,
|
||||
Div,
|
||||
UupgsListNode
|
||||
UupgsListNode,
|
||||
Verse
|
||||
]
|
||||
}
|
||||
|
|
|
|||
150
app/utils/tiptapVerse.ts
Normal file
150
app/utils/tiptapVerse.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Verse block — styled container for a scripture quotation with an
|
||||
// editable reference ("e.g. John 3:16") shown at the top of the block
|
||||
// in the editor and rendered as a citation below the text on the
|
||||
// public page. Adapted from campaigns-sever's Verse extension, minus
|
||||
// the Bible API lookup (authors type the verse text themselves) and
|
||||
// the `translation` attribute.
|
||||
//
|
||||
// Pure-DOM node view so the shared extension list stays
|
||||
// framework-agnostic and `generateHTML` on the server stays Vue-free.
|
||||
// The view never runs during `generateHTML`; only the editor mounts it.
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
export const VERSE_CLASS = 'doxa-verse'
|
||||
|
||||
export const Verse = Node.create({
|
||||
name: 'verse',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
draggable: true,
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
reference: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-reference'),
|
||||
renderHTML: (attrs: { reference?: string | null }) => {
|
||||
if (!attrs.reference) return {}
|
||||
return { 'data-reference': attrs.reference }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-type="verse"]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, { 'data-type': 'verse', class: VERSE_CLASS }),
|
||||
0
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
let currentAttrs = { ...node.attrs }
|
||||
|
||||
const dom = document.createElement('div')
|
||||
dom.className = `${VERSE_CLASS} ${VERSE_CLASS}--editor`
|
||||
dom.setAttribute('data-type', 'verse')
|
||||
dom.setAttribute('data-drag-handle', '')
|
||||
|
||||
// Reference bar — contentEditable=false so PM treats it as a widget
|
||||
const bar = document.createElement('div')
|
||||
bar.className = `${VERSE_CLASS}__ref-bar`
|
||||
bar.contentEditable = 'false'
|
||||
|
||||
const refInput = document.createElement('input')
|
||||
refInput.type = 'text'
|
||||
refInput.placeholder = 'e.g. John 3:16'
|
||||
refInput.className = `${VERSE_CLASS}__ref-input`
|
||||
refInput.value = currentAttrs.reference ?? ''
|
||||
bar.appendChild(refInput)
|
||||
|
||||
// Content host — PM manages children inside this element
|
||||
const content = document.createElement('div')
|
||||
content.className = `${VERSE_CLASS}__content`
|
||||
|
||||
// Citation line — shown only when reference is set
|
||||
const citation = document.createElement('div')
|
||||
citation.className = `${VERSE_CLASS}__citation`
|
||||
citation.contentEditable = 'false'
|
||||
|
||||
const paintCitation = (ref: string | null | undefined) => {
|
||||
if (ref) {
|
||||
citation.textContent = ref
|
||||
citation.style.display = ''
|
||||
} else {
|
||||
citation.textContent = ''
|
||||
citation.style.display = 'none'
|
||||
}
|
||||
}
|
||||
paintCitation(currentAttrs.reference)
|
||||
|
||||
dom.appendChild(bar)
|
||||
dom.appendChild(content)
|
||||
dom.appendChild(citation)
|
||||
|
||||
const commitReference = () => {
|
||||
if (typeof getPos !== 'function') return
|
||||
const pos = getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
const next = refInput.value.trim() || null
|
||||
if (next === currentAttrs.reference) return
|
||||
const { state, view } = editor
|
||||
const nodeAtPos = state.doc.nodeAt(pos)
|
||||
if (!nodeAtPos || nodeAtPos.type.name !== 'verse') return
|
||||
view.dispatch(state.tr.setNodeMarkup(pos, undefined, { ...nodeAtPos.attrs, reference: next }))
|
||||
}
|
||||
|
||||
refInput.addEventListener('blur', commitReference)
|
||||
refInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
refInput.blur()
|
||||
}
|
||||
})
|
||||
|
||||
// ProseMirror installs paste/copy/cut/drop/keydown listeners on the
|
||||
// editor root. Events inside the node view bubble up to those
|
||||
// handlers and PM will try to insert into the doc selection — which
|
||||
// steals pasted text away from this input. Stopping propagation at
|
||||
// the input keeps clipboard + key events local to the input.
|
||||
const LOCAL_EVENTS = ['paste', 'copy', 'cut', 'drop', 'dragstart', 'beforeinput', 'input', 'keydown', 'keypress', 'keyup', 'mousedown', 'click'] as const
|
||||
for (const ev of LOCAL_EVENTS) {
|
||||
refInput.addEventListener(ev, (e) => e.stopPropagation())
|
||||
}
|
||||
|
||||
return {
|
||||
dom,
|
||||
contentDOM: content,
|
||||
update(updatedNode) {
|
||||
if (updatedNode.type.name !== 'verse') return false
|
||||
currentAttrs = { ...updatedNode.attrs }
|
||||
// Don't stomp the input while the user is typing in it
|
||||
if (document.activeElement !== refInput) {
|
||||
refInput.value = currentAttrs.reference ?? ''
|
||||
}
|
||||
paintCitation(currentAttrs.reference)
|
||||
return true
|
||||
},
|
||||
// Keep PM from handling events that happen inside the reference
|
||||
// bar (typing, clicks, key events) so the input stays interactive.
|
||||
stopEvent(event) {
|
||||
const target = event.target
|
||||
return target instanceof Node && bar.contains(target)
|
||||
},
|
||||
// The input's value changing shouldn't invalidate the node view.
|
||||
ignoreMutation(mutation) {
|
||||
if (mutation.type === 'selection') return false
|
||||
return bar.contains(mutation.target as Node) || citation.contains(mutation.target as Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue