feat(editor): preserve comment-id on blockquotes

Extend the default Blockquote with a `commentId` attribute that
round-trips through HTML as `data-comment-id`. This single attribute
is the canonical record of a reply: it survives TipTap serialize /
parse so the backend listener and the in-app renderer can both find
the parent comment without a separate schema field.
This commit is contained in:
kolaente 2026-05-19 19:18:35 +02:00 committed by kolaente
parent 6fc36cb700
commit 46dbeb5784
5 changed files with 115 additions and 5 deletions

View File

@ -60,6 +60,7 @@
"@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.36.0",
"@tiptap/core": "3.17.0",
"@tiptap/extension-blockquote": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-image": "3.17.0",

View File

@ -44,6 +44,9 @@ importers:
'@tiptap/core':
specifier: 3.17.0
version: 3.17.0(@tiptap/pm@3.17.0)
'@tiptap/extension-blockquote':
specifier: 3.17.0
version: 3.17.0(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))
'@tiptap/extension-code-block-lowlight':
specifier: 3.17.0
version: 3.17.0(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))(@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))(@tiptap/pm@3.17.0))(@tiptap/pm@3.17.0)(highlight.js@11.11.1)(lowlight@3.3.0)
@ -2650,10 +2653,10 @@ packages:
peerDependencies:
'@tiptap/pm': ^3.17.0
'@tiptap/extension-blockquote@3.17.1':
resolution: {integrity: sha512-X4jU/fllJQ8QbjCHUafU4QIHBobyXP3yGBoOcXxUaKlWbLvUs0SQTREM3n6/86m2YyAxwTPG1cn3Xypf42DMAQ==}
'@tiptap/extension-blockquote@3.17.0':
resolution: {integrity: sha512-TVslb79JVoZUFO+O4lAHveu38asi1OEqNpLdnQr+SIijIi8WgvJv3VwQwZfkja91WUAHbOHGbnYN0QySOcVCtA==}
peerDependencies:
'@tiptap/core': ^3.17.1
'@tiptap/core': ^3.17.0
'@tiptap/extension-bold@3.17.1':
resolution: {integrity: sha512-PZmrljcVBziJkQDXT/QJv4ESxVVQ0iRH+ruTzPda56Kk4h2310cSXGjI33W7rlCikGPoBAAjY/inujm46YB4bw==}
@ -9441,7 +9444,7 @@ snapshots:
dependencies:
'@tiptap/pm': 3.17.0
'@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))':
'@tiptap/extension-blockquote@3.17.0(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))':
dependencies:
'@tiptap/core': 3.17.0(@tiptap/pm@3.17.0)
@ -9600,7 +9603,7 @@ snapshots:
'@tiptap/starter-kit@3.17.0':
dependencies:
'@tiptap/core': 3.17.0(@tiptap/pm@3.17.0)
'@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))
'@tiptap/extension-blockquote': 3.17.0(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))
'@tiptap/extension-bold': 3.17.1(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))
'@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.0(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))(@tiptap/pm@3.17.0))
'@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))

View File

@ -166,6 +166,7 @@ import Mention from '@tiptap/extension-mention'
import {TaskList} from '@tiptap/extension-list'
import {TaskItemWithId} from './taskItemWithId'
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
import HardBreak from '@tiptap/extension-hard-break'
import Commands from './commands'
@ -417,7 +418,9 @@ const extensions : Extensions = [
StarterKit.configure({
codeBlock: false,
hardBreak: false,
blockquote: false,
}),
BlockquoteWithCommentId,
CodeBlockLowlight.configure({
lowlight: createLowlight(common),

View File

@ -0,0 +1,65 @@
import {describe, it, expect} from 'vitest'
import {Editor} from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
describe('BlockquoteWithCommentId extension', () => {
const createEditor = (content: string = '') => {
return new Editor({
extensions: [
StarterKit.configure({blockquote: false}),
BlockquoteWithCommentId,
],
content,
})
}
it('preserves data-comment-id through setContent → getHTML round-trip', () => {
const editor = createEditor('<blockquote data-comment-id="42"><p>hi</p></blockquote>')
const html = editor.getHTML()
expect(html).toContain('data-comment-id="42"')
editor.destroy()
})
it('renders a plain blockquote (no attribute) unchanged', () => {
const editor = createEditor('<blockquote><p>just a quote</p></blockquote>')
const html = editor.getHTML()
expect(html).toContain('<blockquote>')
expect(html).not.toContain('data-comment-id')
editor.destroy()
})
it('preserves nested rich content inside the blockquote', () => {
const editor = createEditor(
'<blockquote data-comment-id="7"><p>this is <strong>bold</strong> text</p></blockquote>',
)
const html = editor.getHTML()
expect(html).toContain('data-comment-id="7"')
expect(html).toContain('<strong>bold</strong>')
editor.destroy()
})
it('drops a malformed data-comment-id (non-integer)', () => {
const editor = createEditor('<blockquote data-comment-id="abc"><p>x</p></blockquote>')
const html = editor.getHTML()
expect(html).not.toContain('data-comment-id')
editor.destroy()
})
it('drops a non-positive data-comment-id', () => {
const editor = createEditor('<blockquote data-comment-id="0"><p>x</p></blockquote>')
const html = editor.getHTML()
expect(html).not.toContain('data-comment-id')
editor.destroy()
})
})

View File

@ -0,0 +1,38 @@
import Blockquote from '@tiptap/extension-blockquote'
/**
* Blockquote extension that preserves `data-comment-id` across parse/serialize.
* Used as the canonical reply marker: a comment that quotes another comment
* stores the referenced comment's id on the wrapping blockquote, so both the
* backend (for implicit-mention notifications) and the frontend (for the
* jump-to-original chevron) can find it without a separate schema field.
*/
export const BlockquoteWithCommentId = Blockquote.extend({
addAttributes() {
return {
...this.parent?.(),
commentId: {
default: null,
parseHTML: (element: HTMLElement) => {
const raw = element.getAttribute('data-comment-id')
if (raw === null) {
return null
}
const id = Number(raw)
if (!Number.isInteger(id) || id <= 0) {
return null
}
return id
},
renderHTML: (attributes) => {
if (attributes.commentId === null || attributes.commentId === undefined) {
return {}
}
return {
'data-comment-id': String(attributes.commentId),
}
},
},
}
},
})