From 46dbeb5784a70dcb81d057f2c8de345bbbb03008 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 19 May 2026 19:18:35 +0200 Subject: [PATCH] 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. --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 13 ++-- .../src/components/input/editor/TipTap.vue | 3 + .../editor/blockquoteWithCommentId.test.ts | 65 +++++++++++++++++++ .../input/editor/blockquoteWithCommentId.ts | 38 +++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/input/editor/blockquoteWithCommentId.test.ts create mode 100644 frontend/src/components/input/editor/blockquoteWithCommentId.ts diff --git a/frontend/package.json b/frontend/package.json index 4b82c47c9..25d086508 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 04021da40..fcef694c7 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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)) diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index d233b3e1a..1c8ddbfd4 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -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), diff --git a/frontend/src/components/input/editor/blockquoteWithCommentId.test.ts b/frontend/src/components/input/editor/blockquoteWithCommentId.test.ts new file mode 100644 index 000000000..fd7eda06a --- /dev/null +++ b/frontend/src/components/input/editor/blockquoteWithCommentId.test.ts @@ -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('

hi

') + + const html = editor.getHTML() + expect(html).toContain('data-comment-id="42"') + + editor.destroy() + }) + + it('renders a plain blockquote (no attribute) unchanged', () => { + const editor = createEditor('

just a quote

') + + const html = editor.getHTML() + expect(html).toContain('
') + expect(html).not.toContain('data-comment-id') + + editor.destroy() + }) + + it('preserves nested rich content inside the blockquote', () => { + const editor = createEditor( + '

this is bold text

', + ) + + const html = editor.getHTML() + expect(html).toContain('data-comment-id="7"') + expect(html).toContain('bold') + + editor.destroy() + }) + + it('drops a malformed data-comment-id (non-integer)', () => { + const editor = createEditor('

x

') + + const html = editor.getHTML() + expect(html).not.toContain('data-comment-id') + + editor.destroy() + }) + + it('drops a non-positive data-comment-id', () => { + const editor = createEditor('

x

') + + const html = editor.getHTML() + expect(html).not.toContain('data-comment-id') + + editor.destroy() + }) +}) diff --git a/frontend/src/components/input/editor/blockquoteWithCommentId.ts b/frontend/src/components/input/editor/blockquoteWithCommentId.ts new file mode 100644 index 000000000..9875f4bd5 --- /dev/null +++ b/frontend/src/components/input/editor/blockquoteWithCommentId.ts @@ -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), + } + }, + }, + } + }, +})