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) {
+
e.chain().focus().undo().run())" />
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;
+}
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)
+ }
+ }
+ }
+ }
+})