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:
parent
6fc36cb700
commit
46dbeb5784
|
|
@ -60,6 +60,7 @@
|
||||||
"@kyvg/vue3-notification": "3.4.2",
|
"@kyvg/vue3-notification": "3.4.2",
|
||||||
"@sentry/vue": "10.36.0",
|
"@sentry/vue": "10.36.0",
|
||||||
"@tiptap/core": "3.17.0",
|
"@tiptap/core": "3.17.0",
|
||||||
|
"@tiptap/extension-blockquote": "3.17.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
||||||
"@tiptap/extension-hard-break": "3.17.0",
|
"@tiptap/extension-hard-break": "3.17.0",
|
||||||
"@tiptap/extension-image": "3.17.0",
|
"@tiptap/extension-image": "3.17.0",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ importers:
|
||||||
'@tiptap/core':
|
'@tiptap/core':
|
||||||
specifier: 3.17.0
|
specifier: 3.17.0
|
||||||
version: 3.17.0(@tiptap/pm@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':
|
'@tiptap/extension-code-block-lowlight':
|
||||||
specifier: 3.17.0
|
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)
|
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:
|
peerDependencies:
|
||||||
'@tiptap/pm': ^3.17.0
|
'@tiptap/pm': ^3.17.0
|
||||||
|
|
||||||
'@tiptap/extension-blockquote@3.17.1':
|
'@tiptap/extension-blockquote@3.17.0':
|
||||||
resolution: {integrity: sha512-X4jU/fllJQ8QbjCHUafU4QIHBobyXP3yGBoOcXxUaKlWbLvUs0SQTREM3n6/86m2YyAxwTPG1cn3Xypf42DMAQ==}
|
resolution: {integrity: sha512-TVslb79JVoZUFO+O4lAHveu38asi1OEqNpLdnQr+SIijIi8WgvJv3VwQwZfkja91WUAHbOHGbnYN0QySOcVCtA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@tiptap/core': ^3.17.1
|
'@tiptap/core': ^3.17.0
|
||||||
|
|
||||||
'@tiptap/extension-bold@3.17.1':
|
'@tiptap/extension-bold@3.17.1':
|
||||||
resolution: {integrity: sha512-PZmrljcVBziJkQDXT/QJv4ESxVVQ0iRH+ruTzPda56Kk4h2310cSXGjI33W7rlCikGPoBAAjY/inujm46YB4bw==}
|
resolution: {integrity: sha512-PZmrljcVBziJkQDXT/QJv4ESxVVQ0iRH+ruTzPda56Kk4h2310cSXGjI33W7rlCikGPoBAAjY/inujm46YB4bw==}
|
||||||
|
|
@ -9441,7 +9444,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/pm': 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))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.0(@tiptap/pm@3.17.0)
|
'@tiptap/core': 3.17.0(@tiptap/pm@3.17.0)
|
||||||
|
|
||||||
|
|
@ -9600,7 +9603,7 @@ snapshots:
|
||||||
'@tiptap/starter-kit@3.17.0':
|
'@tiptap/starter-kit@3.17.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.17.0(@tiptap/pm@3.17.0)
|
'@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-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-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))
|
'@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.0(@tiptap/pm@3.17.0))
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ import Mention from '@tiptap/extension-mention'
|
||||||
|
|
||||||
import {TaskList} from '@tiptap/extension-list'
|
import {TaskList} from '@tiptap/extension-list'
|
||||||
import {TaskItemWithId} from './taskItemWithId'
|
import {TaskItemWithId} from './taskItemWithId'
|
||||||
|
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
|
||||||
import HardBreak from '@tiptap/extension-hard-break'
|
import HardBreak from '@tiptap/extension-hard-break'
|
||||||
|
|
||||||
import Commands from './commands'
|
import Commands from './commands'
|
||||||
|
|
@ -417,7 +418,9 @@ const extensions : Extensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
hardBreak: false,
|
hardBreak: false,
|
||||||
|
blockquote: false,
|
||||||
}),
|
}),
|
||||||
|
BlockquoteWithCommentId,
|
||||||
|
|
||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight: createLowlight(common),
|
lowlight: createLowlight(common),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue