Add verse node.

This commit is contained in:
corsacca 2026-04-23 12:27:06 +01:00
parent fe23b6a91b
commit cdac21ebda
5 changed files with 254 additions and 1 deletions

View 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;
}
}

View file

@ -15,6 +15,7 @@
@use 'base/reset';
@use 'base/globals';
@use 'base/themes';
@use 'base/tiptap';
@use 'base/print';
// Composition Layer (Everylayout)

View file

@ -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>

View file

@ -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
View 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)
}
}
}
}
})