From cdac21ebda873ba0e3224579d924bd65fe5a4088 Mon Sep 17 00:00:00 2001 From: corsacca Date: Thu, 23 Apr 2026 12:27:06 +0100 Subject: [PATCH] Add verse node. --- app/assets/styles/base/_tiptap.scss | 45 +++++++ app/assets/styles/main.scss | 1 + app/components/admin/RichTextEditor.vue | 55 +++++++++ app/utils/tiptapExtensions.ts | 4 +- app/utils/tiptapVerse.ts | 150 ++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 app/assets/styles/base/_tiptap.scss create mode 100644 app/utils/tiptapVerse.ts diff --git a/app/assets/styles/base/_tiptap.scss b/app/assets/styles/base/_tiptap.scss new file mode 100644 index 00000000..ac8853ea --- /dev/null +++ b/app/assets/styles/base/_tiptap.scss @@ -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; + } +} diff --git a/app/assets/styles/main.scss b/app/assets/styles/main.scss index 831b7bad..91fc238a 100644 --- a/app/assets/styles/main.scss +++ b/app/assets/styles/main.scss @@ -15,6 +15,7 @@ @use 'base/reset'; @use 'base/globals'; @use 'base/themes'; +@use 'base/tiptap'; @use 'base/print'; // Composition Layer (Everylayout) diff --git a/app/components/admin/RichTextEditor.vue b/app/components/admin/RichTextEditor.vue index 58867958..8eb461e0 100644 --- a/app/components/admin/RichTextEditor.vue +++ b/app/components/admin/RichTextEditor.vue @@ -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) { +
@@ -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; +} diff --git a/app/utils/tiptapExtensions.ts b/app/utils/tiptapExtensions.ts index 40fdf518..416949ae 100644 --- a/app/utils/tiptapExtensions.ts +++ b/app/utils/tiptapExtensions.ts @@ -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 ] } diff --git a/app/utils/tiptapVerse.ts b/app/utils/tiptapVerse.ts new file mode 100644 index 00000000..0837ea2b --- /dev/null +++ b/app/utils/tiptapVerse.ts @@ -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) + } + } + } + } +})