diff --git a/package-lock.json b/package-lock.json index a5aba05c00e..a9ce0eb24b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "9.0.0-dev.0", "license": "AGPL-3.0-or-later", "dependencies": { + "@floating-ui/dom": "^1.8.0", "@mdi/svg": "^7.4.47", "@mdit/plugin-tex": "^1.0.2", "@nextcloud/auth": "^2.6.0", diff --git a/package.json b/package.json index 0075f39d273..e52ac7476bc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "extends @nextcloud/browserslist-config" ], "dependencies": { + "@floating-ui/dom": "^1.8.0", "@mdi/svg": "^7.4.47", "@mdit/plugin-tex": "^1.0.2", "@nextcloud/auth": "^2.6.0", diff --git a/playwright/e2e/comments.spec.ts b/playwright/e2e/comments.spec.ts new file mode 100644 index 00000000000..74b37ca25f0 --- /dev/null +++ b/playwright/e2e/comments.spec.ts @@ -0,0 +1,152 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { test as uploadFileTest } from '../support/fixtures/upload-file.ts' + +const test = mergeTests(editorTest, uploadFileTest) + +test.describe('renders comments from Markdown', () => { + test.use({ + fileContent: 'The quick[^comment-1] brown fox.\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Comment by Jane\n', + }) + + test('shows reference from Markdown', async ({ editor, open }) => { + await open() + await expect(editor.getCommentReference('comment-1')).toBeVisible() + }) + + test('opens bubble with thread content on click', async ({ editor, open }) => { + await open() + await editor.getCommentReference('comment-1').click() + await expect(editor.commentBubble).toBeVisible() + await expect(editor.commentBubble).toContainText('Comment by Jane') + }) + + test('closes bubble when close button is clicked', async ({ editor, open }) => { + await open() + await editor.getCommentReference('comment-1').click() + await expect(editor.commentBubble).toBeVisible() + await editor.commentBubble.getByRole('button', { name: 'Close' }).click({ force: true }) + await expect(editor.commentBubble).not.toBeVisible() + }) +}) + +test('inserts comment via keyboard shortcut', async ({ editor, open }) => { + await open() + await editor.type('Some text') + await editor.press('ControlOrMeta+Alt+m') + await expect(editor.commentReferences.first()).toBeVisible() + await expect(editor.commentBubble).toBeVisible() +}) + +test('inserts comment via [?] input rule', async ({ editor, open }) => { + await open() + await editor.type('hello[?]') + await expect(editor.commentReferences.first()).toBeVisible() + await expect(editor.commentBubble).toBeVisible() +}) + +test('adds a reply to a comment', async ({ editor, open }) => { + await open() + await editor.type('Test[?]') + await expect(editor.commentBubble).toBeVisible() + + const composerInput = editor.commentBubble + .locator('.comment-bubble__composer-input [contenteditable]') + await composerInput.fill('My first reply') + await editor.commentBubble.getByRole('button', { name: 'Comment' }).click() + await expect(editor.commentBubble).toContainText('My first reply') +}) + +test('edits an existing comment', async ({ editor, open }) => { + await open() + await editor.type('Test[?]') + await expect(editor.commentBubble).toBeVisible() + + // Submit initial reply + const composerInput = editor.commentBubble + .locator('.comment-bubble__composer-input [contenteditable]') + await composerInput.fill('Original text') + await editor.commentBubble.getByRole('button', { name: 'Comment' }).click() + await expect(editor.commentBubble).toContainText('Original text') + + // Edit the reply + await editor.commentBubble.getByLabel('Actions', { exact: true }).click() + await editor.commentBubble.getByRole('menuitem', { name: 'Edit' }).click() + const editInput = editor.commentBubble + .locator('.comment-bubble__body-edit [contenteditable]') + await editInput.clear() + await editInput.fill('Updated text') + await editor.commentBubble.getByRole('button', { name: 'Save' }).click() + await expect(editor.commentBubble).toContainText('Updated text') + await expect(editor.commentBubble).not.toContainText('Original text') +}) + +test.describe('deletes a comment reply', () => { + test.use({ + fileContent: 'Test[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' First reply\n' + + ' - @[bob](mention://user/bob) *(2026-07-17T11:11Z)*\n' + + ' Second reply\n', + }) + + test('deletes one reply from a multi-reply thread', async ({ editor, open }) => { + await open() + await editor.getCommentReference('comment-1').click() + await expect(editor.commentBubble).toContainText('First reply') + await expect(editor.commentBubble).toContainText('Second reply') + + // Delete the first reply + await editor.commentBubble.getByLabel('Actions', { exact: true }).first().click() + await editor.commentBubble.getByRole('menuitem', { name: 'Delete' }).click() + + await expect(editor.commentBubble).not.toContainText('First reply') + await expect(editor.commentBubble).toContainText('Second reply') + + // Reference still in the editor (thread still has one reply) + await expect(editor.getCommentReference('comment-1')).toBeVisible() + }) +}) + +test.describe('deletes last comment reply', () => { + test.use({ + fileContent: 'Test[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Only reply\n', + }) + + test('removes reference when last reply is deleted', async ({ editor, open }) => { + await open() + await editor.getCommentReference('comment-1').click() + await expect(editor.commentBubble).toContainText('Only reply') + + await editor.commentBubble.getByLabel('Actions', { exact: true }).click() + await editor.commentBubble.getByRole('menuitem', { name: 'Delete' }).click() + + // Bubble should close and reference should be gone + await expect(editor.commentBubble).not.toBeVisible() + await expect(editor.commentReferences.first()).not.toBeVisible() + }) +}) + +test('hides and shows comment references via annotations toggle', async ({ editor, open }) => { + await open() + await editor.type('Test[?]') + await expect(editor.commentReferences.first()).toBeVisible() + + await editor.clickMenu('Annotations', 'Hide annotations') + await expect(editor.commentReferences.first()).toBeHidden() + + await editor.clickMenu('Annotations', 'Show annotations') + await expect(editor.commentReferences.first()).toBeVisible() +}) diff --git a/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts index f6dfe4847a6..bcfea5d7d51 100644 --- a/playwright/support/sections/EditorSection.ts +++ b/playwright/support/sections/EditorSection.ts @@ -22,6 +22,8 @@ export class EditorSection { public readonly details: Locator public readonly footnoteReferences: Locator public readonly footnotesSection: Locator + public readonly commentReferences: Locator + public readonly commentBubble: Locator constructor(public readonly page: Page) { this.el = this.page.locator('.editor').first() @@ -40,6 +42,8 @@ export class EditorSection { this.details = this.el.locator('div[data-text-el="details"]') this.footnoteReferences = this.el.locator('sup[data-type="footnote-reference"]') this.footnotesSection = this.el.locator('section[data-type="footnotes"]') + this.commentReferences = this.el.locator('sup[data-type="comment-reference"]') + this.commentBubble = this.page.locator('.comment-bubble') } public async type(keys: string): Promise { @@ -85,4 +89,6 @@ export class EditorSection { getFootnoteReference = (id: string) => this.footnoteReferences.locator(`:scope[data-reference-id="${id}"]`) getFootnote = (id: string) => this.footnotesSection.locator(`[data-reference-id="${id}"]`) + + getCommentReference = (id: string) => this.commentReferences.locator(`:scope[data-reference-id="${id}"]`) } diff --git a/src/components/Comment/CommentBubbleView.vue b/src/components/Comment/CommentBubbleView.vue new file mode 100644 index 00000000000..ed4fbf2fc78 --- /dev/null +++ b/src/components/Comment/CommentBubbleView.vue @@ -0,0 +1,537 @@ + + + + + + + diff --git a/src/components/Editor/ContentContainer.vue b/src/components/Editor/ContentContainer.vue index ff1439fdbf0..2ff9982dab4 100644 --- a/src/components/Editor/ContentContainer.vue +++ b/src/components/Editor/ContentContainer.vue @@ -4,7 +4,10 @@ --> diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index e15a22f5817..b06baf345ee 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -223,6 +223,19 @@ K + + {{ t('text', 'Comment') }} + + [?] + + + {{ ctrlOrModKey }} + + + {{ t('text', 'Alt') }} + + + M + + {{ t('text', 'Footnote') }} diff --git a/src/components/Menu/entries.ts b/src/components/Menu/entries.ts index 94a2292f050..3f63be5dac1 100644 --- a/src/components/Menu/entries.ts +++ b/src/components/Menu/entries.ts @@ -12,13 +12,18 @@ import ActionAttachmentUpload from './ActionAttachmentUpload.vue' import ActionInsertLink from './ActionInsertLink.vue' import AssistantAction from './AssistantAction.vue' import EmojiPickerAction from './EmojiPickerAction.vue' +import { useAnnotationsVisibility } from '../../composables/useAnnotationsVisibility.js' import { isMobileDevice } from '../../helpers/isMobileDevice.js' import { Asterisk, CodeBrackets, CodeTags, + CommentOffOutline, + CommentOutline, Danger, Emoticon, + Eye, + EyeOff, FormatBold, FormatColorHighlight, FormatHeader1, @@ -78,6 +83,7 @@ type MenuEntry visible?: boolean children?: MenuEntry[] isSeparator?: boolean + isAnnotation?: boolean } | undefined @@ -120,7 +126,7 @@ export function getAssistantMenuEntries(): MenuEntry[] { key: 'assistant', label: t('text', 'Nextcloud Assistant'), component: markRaw(AssistantAction), - priority: 7, + priority: 8, } const hasAssistantTaskTypes = loadState('text', 'taskprocessing', []).length > 0 @@ -133,6 +139,7 @@ export function getAssistantMenuEntries(): MenuEntry[] { * @param isRichWorkspace is the editor a folder description */ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { + const { annotationsHidden } = useAnnotationsVisibility() const menuEntries: MenuEntry[] = [ { key: 'undo', @@ -141,7 +148,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { keyModifiers: [MODIFIERS.Mod], icon: Undo, action: (command) => command.undo(), - priority: 8, + priority: 9, }, { key: 'redo', @@ -150,7 +157,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { keyModifiers: [MODIFIERS.Mod], icon: Redo, action: (command) => command.redo(), - priority: 11, + priority: 12, }, { key: 'headings', @@ -255,7 +262,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command) => { return command.toggleBold() }, - priority: 9, + priority: 10, }, { key: 'italic', @@ -267,7 +274,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command) => { return command.toggleItalic() }, - priority: 10, + priority: 11, }, { key: 'underline', @@ -279,7 +286,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command) => { return command.toggleUnderline() }, - priority: 12, + priority: 13, }, { key: 'strikethrough', @@ -291,7 +298,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command) => { return command.toggleStrike() }, - priority: 13, + priority: 14, }, { key: 'highlight', @@ -303,7 +310,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command) => { return command.toggleHighlight() }, - priority: 14, + priority: 15, }, { key: 'lists', @@ -513,19 +520,61 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command) => { return command.insertTable() }, - priority: 15, + priority: 16, }, { - key: 'footnote', - label: t('text', 'Footnote'), - keyChar: 'f', - keyModifiers: [MODIFIERS.Mod, MODIFIERS.Shift], - isActive: 'footnote', - icon: Asterisk, - action: (command) => { - return command.insertFootnote() + key: 'annotations', + label: t('text', 'Annotations'), + get icon() { + return annotationsHidden.value + ? CommentOffOutline + : CommentOutline }, - priority: 16, + priority: 4, + children: [ + { + key: 'comment', + label: t('text', 'Comment'), + keyChar: 'm', + keyModifiers: [MODIFIERS.Mod, MODIFIERS.Alt], + isActive: 'comment', + icon: CommentOutline, + isAnnotation: true, + action: (command) => { + return command.insertComment() + }, + }, + { + key: 'footnote', + label: t('text', 'Footnote'), + keyChar: 'f', + keyModifiers: [MODIFIERS.Mod, MODIFIERS.Shift], + isActive: 'footnote', + icon: Asterisk, + isAnnotation: true, + action: (command) => { + return command.insertFootnote() + }, + }, + { + key: 'annotation-separator', + isSeparator: true, + }, + { + key: 'annotations-hide', + get icon() { + return annotationsHidden.value + ? Eye + : EyeOff + }, + get label() { + return annotationsHidden.value + ? t('text', 'Show annotations') + : t('text', 'Hide annotations') + }, + click: () => emit('text:annotations:toggle-visibility', undefined), + }, + ], }, { key: 'insert-link', @@ -535,14 +584,14 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { isActive: 'link', icon: LinkIcon, component: markRaw(ActionInsertLink), - priority: 4, + priority: 5, }, { key: 'insert-attachment', label: t('text', 'Insert attachment'), icon: Paperclip, component: markRaw(ActionAttachmentUpload), - priority: 5, + priority: 6, }, ] @@ -556,7 +605,7 @@ export function getMenuEntries(isRichWorkspace: boolean): MenuEntry[] { action: (command, emojiObject = {}) => { return command.emoji(emojiObject) }, - priority: 6, + priority: 7, }) } diff --git a/src/components/Menu/utils.js b/src/components/Menu/utils.js index 5cc0b3c71d9..00b0679129c 100644 --- a/src/components/Menu/utils.js +++ b/src/components/Menu/utils.js @@ -5,6 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { annotationsHidden } from '../../composables/useAnnotationsVisibility.js' import { MODIFIERS, TRANSLATIONS } from './keys.js' /** @@ -61,7 +62,8 @@ function getKeys(isMobile, { keyChar, keyModifiers }) { * @param editor */ function isDisabled(actionEntry, editor) { - return actionEntry.action && !actionEntry.action(editor.can(), editor) + return (actionEntry.action && !actionEntry.action(editor.can(), editor)) + || (actionEntry.isAnnotation && annotationsHidden.value) } /** diff --git a/src/components/icons.js b/src/components/icons.js index 4e31e3d7a9e..3c9ef1680ad 100644 --- a/src/components/icons.js +++ b/src/components/icons.js @@ -21,8 +21,12 @@ import MDI_CircleMedium from 'vue-material-design-icons/CircleMedium.vue' import MDI_Close from 'vue-material-design-icons/Close.vue' import MDI_CodeBrackets from 'vue-material-design-icons/CodeBrackets.vue' import MDI_CodeTags from 'vue-material-design-icons/CodeTags.vue' +import MDI_CommentOffOutline from 'vue-material-design-icons/CommentOffOutline.vue' +import MDI_CommentOutline from 'vue-material-design-icons/CommentOutline.vue' import MDI_DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' import MDI_Emoticon from 'vue-material-design-icons/EmoticonOutline.vue' +import MDI_Eye from 'vue-material-design-icons/Eye.vue' +import MDI_EyeOff from 'vue-material-design-icons/EyeOff.vue' import MDI_Document from 'vue-material-design-icons/FileDocument.vue' import MDI_Folder from 'vue-material-design-icons/FolderOutline.vue' import MDI_FormatBold from 'vue-material-design-icons/FormatBold.vue' @@ -107,11 +111,15 @@ export const Close = makeIcon(MDI_Close) export const Check = makeIcon(MDI_Check) export const CodeBrackets = makeIcon(MDI_CodeBrackets) export const CodeTags = makeIcon(MDI_CodeTags) +export const CommentOffOutline = makeIcon(MDI_CommentOffOutline) +export const CommentOutline = makeIcon(MDI_CommentOutline) export const CircleMedium = makeIcon(MDI_CircleMedium) export const Danger = makeIcon(MDI_Danger) export const Document = makeIcon(MDI_Document) export const DotsHorizontal = makeIcon(MDI_DotsHorizontal) export const Emoticon = makeIcon(MDI_Emoticon) +export const Eye = makeIcon(MDI_Eye) +export const EyeOff = makeIcon(MDI_EyeOff) export const Folder = makeIcon(MDI_Folder) export const FormatBold = makeIcon(MDI_FormatBold) export const FormatColorHighlight = makeIcon(MDI_FormatColorHighlight) diff --git a/src/composables/useAnnotationsVisibility.ts b/src/composables/useAnnotationsVisibility.ts new file mode 100644 index 00000000000..d15b65cf35b --- /dev/null +++ b/src/composables/useAnnotationsVisibility.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { subscribe } from '@nextcloud/event-bus' +import { ref } from 'vue' + +export const annotationsHidden = ref(false) + +subscribe('text:annotations:toggle-visibility', () => { + annotationsHidden.value = !annotationsHidden.value +}) + +/** + * the useAnnotationsVisibility composable function + */ +export function useAnnotationsVisibility() { + return { annotationsHidden } +} diff --git a/src/composables/useGuestName.ts b/src/composables/useGuestName.ts new file mode 100644 index 00000000000..17560a8ffe6 --- /dev/null +++ b/src/composables/useGuestName.ts @@ -0,0 +1,42 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Editor } from '@tiptap/core' +import type { MaybeRef } from 'vue' + +import { toValue } from 'vue' +import { update } from '../apis/connect.ts' +import { logger } from '../helpers/logger.ts' +import { useConnection } from './useConnection.ts' + +/** + * @param editor - the Tiptap editor ref + */ +export function useGuestName(editor: MaybeRef) { + const { connection } = useConnection() + + /** + * @param name - guest user nick name + */ + async function setGuestName(name: string) { + if (!name.trim() || !connection?.value) { + return null + } + + const session = await update(name.trim(), connection.value) + try { + localStorage.setItem('nick', session.guestName) + } catch (e) { + logger.warn('Could not store guest name in local storage.', { error: e }) + } + toValue(editor)?.commands.updateUser({ + name: session.guestName, + color: session.color, + }) + return session + } + + return { setGuestName } +} diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index ce5d70342ea..d3d607f57c7 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -424,16 +424,37 @@ div.ProseMirror { } } - sup[data-type="footnote-reference"] { - a.footnote-ref { + section[data-type="comments"] { + display: none; + } + + sup[data-type="footnote-reference"], sup[data-type="comment-reference"] { + line-height: 0; // prevent references from extending line height + scroll-margin-top: calc(var(--default-clickable-area) + 4 * var(--default-grid-baseline)); + + a.footnote-ref, button.comment-ref { + line-height: 1; + font-size: 0.9em; color: var(--color-primary-element); text-decoration: none; + min-height: unset; &:hover, &:focus { text-decoration: underline; } } + + button.comment-ref { + width: unset; + padding: 0; + border-radius: var(--border-radius-small); + } + + &.is-active { + background-color: var(--color-primary-element-light-text); + border-radius: var(--border-radius); + } } section[data-type="footnotes"] { @@ -502,6 +523,14 @@ div.ProseMirror { visibility: visible !important; } +.editor--annotations-hidden div.ProseMirror { + sup[data-type="comment-reference"], + sup[data-type="footnote-reference"], + section[data-type="footnotes"] { + display: none; + } +} + .footnote-highlight { animation: highlight-animation 5s 1; border-radius: var(--border-radius-small); diff --git a/src/extensions/CommentBubble.ts b/src/extensions/CommentBubble.ts new file mode 100644 index 00000000000..6a031157f51 --- /dev/null +++ b/src/extensions/CommentBubble.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { CommandProps } from '@tiptap/core' + +import { Extension } from '@tiptap/core' +import { commentBubble, commentBubbleKey, hideCommentBubble, navigateCommentBubble, openCommentBubble } from '../plugins/commentBubble.ts' + +declare module '@tiptap/core' { + interface Commands { + commentBubble: { + openCommentBubble: (referenceId: string) => ReturnType + hideCommentBubble: (options?: { refocus?: boolean }) => ReturnType + navigateCommentBubble: (direction: 'prev' | 'next') => ReturnType + } + } +} + +const CommentBubble = Extension.create({ + name: 'commentBubble', + + addCommands() { + return { + openCommentBubble(referenceId: string) { + return ({ state, dispatch }: CommandProps) => { + return openCommentBubble(referenceId)(state, dispatch) + } + }, + hideCommentBubble: (options?: { refocus?: boolean }) => ({ state, dispatch, chain }) => { + const pluginState = commentBubbleKey.getState(state) + const active = pluginState?.active + const result = hideCommentBubble(state, dispatch) + if (!result) { + return result + } + if (options?.refocus && active) { + const node = state.doc.nodeAt(active.nodeStart) + const cursorPos = active.nodeStart + (node?.nodeSize ?? 1) + chain().setTextSelection(cursorPos).focus().run() + } + return true + }, + navigateCommentBubble(direction: 'prev' | 'next') { + return ({ state, dispatch }: CommandProps) => { + return navigateCommentBubble(direction)(state, dispatch) + } + }, + } + }, + + addProseMirrorPlugins() { + return [commentBubble({ editor: this.editor })] + }, +}) + +export default CommentBubble diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index 85ce6054b04..7bb8b81cbab 100644 --- a/src/extensions/RichText.ts +++ b/src/extensions/RichText.ts @@ -28,6 +28,7 @@ import { import BulletList from '../nodes/BulletList.ts' import Callouts from '../nodes/Callout.js' import CodeBlock from '../nodes/CodeBlock.js' +import Comments from '../nodes/Comments.ts' import Details from '../nodes/Details.js' import EditableTable from '../nodes/EditableTable.js' import Footnotes from '../nodes/Footnotes.ts' @@ -45,6 +46,7 @@ import Table from '../nodes/Table.js' import TaskItem from '../nodes/TaskItem.ts' import TaskList from '../nodes/TaskList.ts' import TrailingNode from '../nodes/TrailingNode.js' +import CommentBubble from './CommentBubble.ts' import Emoji from './Emoji.js' import KeepSyntax from './KeepSyntax.js' import Keymap from './Keymap.js' @@ -86,7 +88,7 @@ export default Extension.create({ const defaultExtensions = [ Markdown, Document.extend({ - content: 'block+ footnotes?', + content: 'block+ comments? footnotes?', }), Text, Paragraph, @@ -104,6 +106,7 @@ export default Extension.create({ defaultLanguage: 'plaintext', }), Details, + Comments, Footnotes, BulletList, HorizontalRule, @@ -147,18 +150,21 @@ export default Extension.create({ openLink: this.options.openLink, }), LinkBubble, + CommentBubble, ...(this.options.editing ? [ Placeholder.configure({ placeholder: t('text', "Start writing or type '/' to add…"), })] : []), TrailingNode.configure({ - notAfter: ['paragraph', 'footnotes'], + notAfter: ['paragraph', 'comments', 'footnotes'], }), TextDirection.configure({ types: [ 'blockquote', 'callout', + 'comment', + 'commentItem', 'detailsSummary', 'footnote', 'heading', diff --git a/src/markdownit/comments.ts b/src/markdownit/comments.ts new file mode 100644 index 00000000000..d76a3b27205 --- /dev/null +++ b/src/markdownit/comments.ts @@ -0,0 +1,379 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type MarkdownIt from 'markdown-it' +import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' +import type Token from 'markdown-it/lib/token.mjs' + +import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' + +const COMMENT_REF_PREFIX = 'comment-' + +/** + * Return the reference label. + * + * @param token markdown-it token + */ +function labelOf(token: Token): string { + return token.meta?.label || String(token.meta?.id ?? '') +} + +/** + * Check whether token belongs to a comment + * + * @param token markdown-it token + */ +function isCommentToken(token: Token): boolean { + return labelOf(token).startsWith(COMMENT_REF_PREFIX) +} + +/** + * Split the footnote block into a preceding comment block and the + * remaining footnote block. Iterate right-to-left so we can splice + * in place without invalidating indices. + * + * @param state markdown-it state + */ +function splitComments(state: StateCore): void { + // Rename inline `footnote_ref` to `comment_ref` for comment labels. + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) { + continue + } + for (const child of token.children) { + if (child.type === 'footnote_ref' && isCommentToken(child)) { + child.type = 'comment_ref' + } + } + } + + // Split blocks + for (let i = state.tokens.length - 1; i >= 0; i--) { + if (state.tokens[i].type !== 'footnote_block_open') { + continue + } + const blockOpenIdx = i + let blockCloseIdx = -1 + for (let j = blockOpenIdx + 1; j < state.tokens.length; j++) { + if (state.tokens[j].type === 'footnote_block_close') { + blockCloseIdx = j + break + } + } + if (blockCloseIdx < 0) { + continue + } + + const inner = state.tokens.slice(blockOpenIdx + 1, blockCloseIdx) + const commentTokens: Token[] = [] + const footnoteTokens: Token[] = [] + + // Group inner tokens into footnote_open..footnote_close units + // and push each unit into the appropriate bucket. + let unitStart = -1 + for (let k = 0; k < inner.length; k++) { + const token = inner[k] + if (token.type === 'footnote_open') { + unitStart = k + } else if (token.type === 'footnote_close' && unitStart >= 0) { + const unit = inner.slice(unitStart, k + 1) + if (isCommentToken(unit[0])) { + unit[0].type = 'comment_open' + unit[unit.length - 1].type = 'comment_close' + commentTokens.push(...unit) + } else { + footnoteTokens.push(...unit) + } + unitStart = -1 + } + } + + // Build the replacement token list. + const replacement: Token[] = [] + if (commentTokens.length > 0) { + const commentOpen = new state.Token('comment_block_open', '', 1) + commentOpen.block = true + const commentClose = new state.Token('comment_block_close', '', -1) + commentClose.block = true + replacement.push(commentOpen, ...commentTokens, commentClose) + } + if (footnoteTokens.length > 0) { + replacement.push(state.tokens[blockOpenIdx], ...footnoteTokens, state.tokens[blockCloseIdx]) + } + + state.tokens.splice(blockOpenIdx, blockCloseIdx - blockOpenIdx + 1, ...replacement) + } +} + +/** + * Extract author/timestamp metadata from comment definitions and rewrite + * `list_item_open/close` inside comments to `comment_item_open/close`, + * dropping the surrounding `bullet_list_open/close` + * + * @param state markdown-it core state + */ +function extractCommentMetadata(state: StateCore): void { + for (let i = 0; i < state.tokens.length; i++) { + if (state.tokens[i].type !== 'comment_open') { + continue + } + let closeIdx = findMatching(state.tokens, i, 'comment_open', 'comment_close') + if (closeIdx < 0) { + continue + } + closeIdx = processCommentBody(state, i, closeIdx) + i = closeIdx + } +} + +/** + * Process a comment body. If the body is exactly one bullet list, + * transform its items to comment items; otherwise wrap the body in a + * single comment item to account for broken syntax. + * Returns the (possibly updated) close index. + * + * @param state markdown-it core state + * @param openIdx index of the comment_open token + * @param closeIdx index of the matching comment_close token + */ +function processCommentBody(state: StateCore, openIdx: number, closeIdx: number): number { + const removals: number[] = [] + let hasItems = false + let listDepth = 0 + + for (let i = openIdx + 1; i < closeIdx; i++) { + const token = state.tokens[i] + if (token.type === 'bullet_list_open') { + listDepth++ + if (listDepth === 1) { + removals.push(i) + } + continue + } + if (token.type === 'bullet_list_close') { + if (listDepth === 1) { + removals.push(i) + } + listDepth-- + continue + } + + // Only rewrite tokens that live directly inside the outer bullet list. + // Nested lists (listDepth > 1) are left alone so their items stay. + if (listDepth !== 1) { + continue + } + + if (token.type === 'list_item_open') { + token.type = 'comment_item_open' + const inline = findFirstInline(state.tokens, i + 1, closeIdx) + const meta = inline ? extractMetadata(inline) : emptyMeta() + token.attrSet('data-author', meta.author) + token.attrSet('data-author-label', meta.authorLabel) + token.attrSet('data-timestamp', meta.timestamp) + hasItems = true + } else if (token.type === 'list_item_close') { + token.type = 'comment_item_close' + } else if (token.type === 'paragraph_open' || token.type === 'paragraph_close') { + // Un-hide so tight-list items still emit

around body content. + token.hidden = false + } + } + + if (!hasItems) { + return wrapAsSingleItem(state, openIdx, closeIdx) + } + + // Apply removals right-to-left so earlier indices remain valid. + for (const idx of removals.reverse()) { + state.tokens.splice(idx, 1) + } + return closeIdx - removals.length +} + +/** + * Wrap the entire body between comment_open and comment_close in a single + * comment_item pair with empty metadata attributes. + * + * @param state markdown-it core state + * @param openIdx index of comment_open + * @param closeIdx index of comment_close + */ +function wrapAsSingleItem(state: StateCore, openIdx: number, closeIdx: number): number { + const wrapOpen = new state.Token('comment_item_open', '', 1) + wrapOpen.block = true + wrapOpen.attrSet('data-author', '') + wrapOpen.attrSet('data-author-label', '') + wrapOpen.attrSet('data-timestamp', '') + + const wrapClose = new state.Token('comment_item_close', '', -1) + wrapClose.block = true + + state.tokens.splice(closeIdx, 0, wrapClose) + state.tokens.splice(openIdx + 1, 0, wrapOpen) + return closeIdx + 2 +} + +/** + * Find matching close token for an open token, respecting nesting. + * + * @param tokens token stream + * @param openIdx index of the open token + * @param openType open token type + * @param closeType close token type + */ +function findMatching(tokens: Token[], openIdx: number, openType: string, closeType: string): number { + let depth = 1 + for (let i = openIdx + 1; i < tokens.length; i++) { + if (tokens[i].type === openType) { + depth++ + } else if (tokens[i].type === closeType) { + depth-- + if (depth === 0) { + return i + } + } + } + return -1 +} + +/** + * Find the first `inline` token within a scope. Used to get comments metadata string. + * Stops at the first item close or the scope end. + * + * @param tokens token stream + * @param startIdx inclusive start + * @param endIdx exclusive end + */ +function findFirstInline(tokens: Token[], startIdx: number, endIdx: number): Token | null { + for (let i = startIdx; i < endIdx; i++) { + const token = tokens[i] + if (token.type === 'inline') { + return token + } + if (token.type === 'list_item_close' || token.type === 'comment_item_close') { + return null + } + } + return null +} + +interface Metadata { + author: string + authorLabel: string + timestamp: string +} + +/** + * Empty metadata object + */ +function emptyMeta(): Metadata { + return { author: '', authorLabel: '', timestamp: '' } +} + +/** + * Extract leading `@mention *(timestamp)*` metadata from an inline token, + * mutating its `children` array to remove the extracted tokens and any + * whitespace between the metadata and the body content. + * + * @param inline the `inline` token whose children will be inspected + */ +function extractMetadata(inline: Token): Metadata { + const children = inline.children + if (!children) { + return emptyMeta() + } + + let author = '' + let authorLabel = '' + let timestamp = '' + + // Skip leading empty text tokens (mention plugin leaves one after stripping `@`) + while (children.length > 0 && children[0].type === 'text' && /^\s*$/.test(children[0].content)) { + children.shift() + } + + const first = children[0] + if (first?.type === 'mention') { + const mention = (first as Token & { mention: { id: string, label: string, type: string } }).mention + if (!mention) { + return emptyMeta() + } + author = decodeURIComponent(mention.id || '') + authorLabel = mention.label || '' + children.shift() + } else if (first?.type === 'text') { + // Guest mention + const match = first.content.match(/^@([^\s*]+)/) + if (match) { + authorLabel = match[1] + first.content = first.content.slice(match[0].length) + if (!first.content) { + children.shift() + } + } + } + + // Strip whitespaces between mention and timestamp + while (children.length > 0 && children[0].type === 'text' && /^\s*$/.test(children[0].content)) { + children.shift() + } + + if (children.length >= 3 && children[0].type === 'em_open' && children[1].type === 'text' && children[2].type === 'em_close') { + const tsMatch = children[1].content.match(/^\(([^)]+)\)$/) + if (tsMatch) { + timestamp = tsMatch[1] + children.splice(0, 3) + } + } + + while (children.length > 0) { + const child = children[0] + if (child.type === 'softbreak' || child.type === 'hardbreak') { + children.shift() + } else if (child.type === 'text' && /^\s*$/.test(child.content)) { + children.shift() + } else if (child.type === 'text') { + child.content = child.content.replace(/^\s+/, '') + break + } else { + break + } + } + + return { author, authorLabel, timestamp } +} + +/** + * Register the comments markdown-it plugin. + * - splits footnote block into comment and footnote sections + * - extracts author/timestamp metadata and adds renderer rules + * + * @param md markdown-it Markdown object + */ +export default function comments(md: MarkdownIt): void { + md.core.ruler.after('footnote_tail', 'split_comments', splitComments) + md.core.ruler.after('split_comments', 'extract_comment_metadata', extractCommentMetadata) + + md.renderer.rules.comment_ref = (tokens, idx) => { + const label = labelOf(tokens[idx]) + return `` + } + md.renderer.rules.comment_block_open = () => '

\n' + md.renderer.rules.comment_block_close = () => '
\n' + md.renderer.rules.comment_open = (tokens, idx) => { + const label = labelOf(tokens[idx]) + return `
\n` + } + md.renderer.rules.comment_close = () => '
\n' + md.renderer.rules.comment_item_open = (tokens, idx) => { + const token = tokens[idx] + const author = token.attrGet('data-author') || '' + const authorLabel = token.attrGet('data-author-label') || '' + const timestamp = token.attrGet('data-timestamp') || '' + return `
\n` + } + md.renderer.rules.comment_item_close = () => '
\n' +} diff --git a/src/markdownit/index.js b/src/markdownit/index.js index c894399a239..537ea5e491b 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -11,6 +11,7 @@ import mark from 'markdown-it-mark' import multimdTable from 'markdown-it-multimd-table' import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' import callouts from './callouts.js' +import comments from './comments.ts' import details from './details.ts' import footnotes from './footnotes.ts' import hardbreak from './hardbreak.js' @@ -28,12 +29,13 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .enable('table') .use(taskLists, { enable: true, labelAfter: true }) .use(frontMatter, () => {}) - .use(splitMixedLists) // needs task Lists to be used first. + .use(splitMixedLists) // needs task Lists to be used first .use(underline) .use(hardbreak) .use(callouts) .use(details) .use(footnotes) + .use(comments) // needs to come after footnotes and before markdownitMentions .use(preview) .use(keepSyntax) .use(markdownitMentions) diff --git a/src/nodes/Comment.ts b/src/nodes/Comment.ts new file mode 100644 index 00000000000..70b42ee1212 --- /dev/null +++ b/src/nodes/Comment.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' + +declare module 'prosemirror-markdown' { + interface MarkdownSerializerState { + delim: string + } +} + +const Comment = Node.create({ + name: 'comment', + content: 'commentItem+', + defining: true, + isolating: true, + + addAttributes() { + return { + referenceId: { + default: '', + parseHTML: (el) => el.getAttribute('data-reference-id') ?? '', + renderHTML: (attrs) => ({ + 'data-reference-id': attrs.referenceId, + }), + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="comment"]' }] + }, + + renderHTML({ node, HTMLAttributes }) { + const id = node.attrs.referenceId + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'comment', + id: `c-${id}`, + }), + 0, + ] + }, + + toMarkdown(state, node) { + state.write(`[^${node.attrs.referenceId}]:\n`) + const savedDelim = state.delim + state.delim += ' ' + state.renderList(node, ' ', (i) => { + const item = node.child(i) + const { author, authorLabel, timestamp } = item.attrs + const authorMarkdown = author + ? `@[${authorLabel}](mention://user/${encodeURIComponent(author)})` + : authorLabel ? `@${authorLabel}` : '' + const ts = timestamp ? ` *(${timestamp})*` : '' + return `- ${authorMarkdown}${ts}\n` + }) + state.delim = savedDelim + state.closeBlock(node) + }, +}) + +export default Comment diff --git a/src/nodes/CommentItem.ts b/src/nodes/CommentItem.ts new file mode 100644 index 00000000000..2204ebe437f --- /dev/null +++ b/src/nodes/CommentItem.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' + +const CommentItem = Node.create({ + name: 'commentItem', + content: 'block+', + defining: true, + + addAttributes() { + return { + author: { + default: '', + parseHTML: (el) => el.getAttribute('data-author') ?? '', + renderHTML: (attrs) => ({ 'data-author': attrs.author }), + }, + authorLabel: { + default: '', + parseHTML: (el) => el.getAttribute('data-author-label') ?? '', + renderHTML: (attrs) => ({ 'data-author-label': attrs.authorLabel }), + }, + timestamp: { + default: '', + parseHTML: (el) => el.getAttribute('data-timestamp') ?? '', + renderHTML: (attrs) => ({ 'data-timestamp': attrs.timestamp }), + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="comment-item"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { 'data-type': 'comment-item' }), + 0, + ] + }, + + toMarkdown(state, node) { + state.renderContent(node) + }, +}) + +export default CommentItem diff --git a/src/nodes/CommentReference.ts b/src/nodes/CommentReference.ts new file mode 100644 index 00000000000..057da38880f --- /dev/null +++ b/src/nodes/CommentReference.ts @@ -0,0 +1,336 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +import { getCurrentUser } from '@nextcloud/auth' +import { InputRule, mergeAttributes, Node } from '@tiptap/core' +import { DOMParser } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import markdownit from '../markdownit/index.js' +import { commentBubbleKey } from '../plugins/commentBubble.ts' +import { generateReferenceId, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' + +declare module '@tiptap/core' { + interface Commands { + commentReference: { + insertComment: () => ReturnType + addOrUpdateCommentReply: (referenceId: string, markdownText: string, itemIndex?: number) => ReturnType + deleteCommentReply: (referenceId: string, itemIndex: number) => ReturnType + } + } +} + +const CommentReference = Node.create({ + name: 'commentReference', + group: 'inline', + inline: true, + atom: true, + + addAttributes() { + return { + referenceId: { + default: '', + parseHTML: (el) => el.getAttribute('data-reference-id') ?? '', + renderHTML: (attrs) => ({ + 'data-reference-id': attrs.referenceId, + }), + }, + } + }, + + parseHTML() { + return [{ tag: 'sup[data-type="comment-reference"]' }] + }, + + renderHTML({ node, HTMLAttributes }) { + const id = node.attrs.referenceId + return [ + 'sup', + mergeAttributes(HTMLAttributes, { + 'data-type': 'comment-reference', + id: `cref-${id}`, + }), + [ + 'a', + { href: `#c-${id}`, class: 'comment-ref', role: 'doc-noteref', title: id }, + '💬', + ], + ] + }, + + addNodeView() { + return ({ node, editor }) => { + const id = node.attrs.referenceId + const sup = document.createElement('sup') + sup.dataset.type = 'comment-reference' + sup.dataset.referenceId = id + sup.id = `cref-${id}` + + const button = document.createElement('button') + button.type = 'button' + button.className = 'comment-ref' + button.setAttribute('contenteditable', 'false') + button.textContent = '💬' + button.addEventListener('click', () => editor.commands.openCommentBubble(id)) + sup.appendChild(button) + return { dom: sup, contentDOM: null } + } + }, + + toMarkdown(state, node) { + state.write(`[^${node.attrs.referenceId}]`) + }, + + addCommands() { + return { + insertComment: () => ({ state, chain, dispatch }) => { + if (isInsideCommentOrFootnote(state)) { + return false + } + + const referenceId = generateReferenceId(state.doc, 'comment') + if (!referenceId) { + return false + } + + // Clear any stale draft from a previous comment that used this ID + sessionStorage.removeItem('text-comment-draft-' + referenceId) + + // In can-check mode, the above guards are sufficient + if (!dispatch) { + return true + } + + const currentUser = getCurrentUser() + const author = currentUser?.uid ?? '' + const authorLabel = currentUser?.displayName ?? localStorage.getItem('nick') ?? '' + const timestamp = new Date().toISOString() + + const commentType = state.schema.nodes.comment + const commentItemType = state.schema.nodes.commentItem + const paragraphType = state.schema.nodes.paragraph + + const newCommentItem = commentItemType.create( + { author, authorLabel, timestamp }, + paragraphType.create(), + ) + const newComment = commentType.create({ referenceId }, newCommentItem) + + let c = chain() + .insertContent({ type: 'commentReference', attrs: { referenceId } }) + + // Find positions of existing containers in the original doc + let commentsInsidePos = -1 + let footnotesStartPos = -1 + state.doc.forEach((child, offset) => { + if (child.type.name === 'comments') { + commentsInsidePos = offset + child.nodeSize - 1 + } + if (child.type.name === 'footnotes') { + footnotesStartPos = offset + } + }) + + if (commentsInsidePos !== -1) { + c = c.insertContentAt(commentsInsidePos, newComment.toJSON()) + } else if (footnotesStartPos !== -1) { + c = c.insertContentAt(footnotesStartPos, { + type: 'comments', + content: [newComment.toJSON()], + }) + } else { + c = c.insertContentAt(state.doc.content.size, { + type: 'comments', + content: [newComment.toJSON()], + }) + } + + // Move selection/cursor to reference to avoid it being inside the hidden comments container + c = c.command(({ state, dispatch }) => { + let nodeStart: number | null = null + let refEnd: number | null = null + state.doc.descendants((node, pos) => { + if (refEnd !== null) { + return false + } + if (node.type.name === 'commentReference' && node.attrs.referenceId === referenceId) { + nodeStart = pos + refEnd = pos + node.nodeSize + return false + } + }) + if (nodeStart === null || refEnd === null) { + return false + } + if (dispatch) { + dispatch(state.tr + .setSelection(TextSelection.near(state.doc.resolve(refEnd))) + // Open comment bubble + .setMeta(commentBubbleKey, { active: { referenceId, nodeStart } })) + } + return true + }) + + return c + .scrollIntoView() + .run() + }, + addOrUpdateCommentReply: (referenceId: string, markdownText: string, itemIndex?: number) => ({ state, dispatch }) => { + if (!markdownText) { + return false + } + + // serialize Markdown from content to a ProseMirror Fragment + const html = markdownit.render(markdownText) + const dom = document.createElement('div') + dom.innerHTML = html + const fragment = DOMParser.fromSchema(this.editor.schema).parse(dom) + const content = fragment.content + + if (content.textBetween(0, content.size, ' ').trim() === '') { + return false + } + + const currentUser = getCurrentUser() + const author = currentUser?.uid ?? '' + const authorLabel = currentUser?.displayName ?? localStorage.getItem('nick') ?? '' + const timestamp = new Date().toISOString() + + let commentPos = -1 + let targetComment: ProseMirrorNode | null = null + state.doc.descendants((node, pos) => { + if (targetComment) { + return false + } + if (node.type.name === 'comment' && node.attrs.referenceId === referenceId) { + commentPos = pos + targetComment = node + return false + } + }) + if (!targetComment || commentPos === -1) { + return false + } + const comment = targetComment as ProseMirrorNode + + if (itemIndex !== undefined && itemIndex >= comment.childCount) { + // Given itemIndex does not exist + return false + } + + // Get item to update if itemIndex is provided + let itemPos = commentPos + 1 + let item: ProseMirrorNode + if (itemIndex !== undefined) { + for (let i = 0; i < itemIndex; i++) { + itemPos += comment.child(i).nodeSize + } + item = comment.child(itemIndex) + } else { + item = comment.firstChild! + } + + const tr = state.tr + const shouldAppendNewReply = itemIndex === undefined + && !(comment.childCount === 1 && item.textContent === '') + if (shouldAppendNewReply) { + // Append a new reply item + const commentItemType = state.schema.nodes.commentItem + const newItem = commentItemType.create({ author, authorLabel, timestamp }, content) + tr.insert(commentPos + comment.nodeSize - 1, newItem) + } else { + // Replace existing item if itemIndex is given or if only one empty item + // exists (i.e. straight after addComment() command was called) + tr.setNodeMarkup(itemPos, null, { ...item.attrs, timestamp }) + tr.replaceWith(itemPos + 1, itemPos + item.nodeSize - 1, content) + } + + if (dispatch) { + dispatch(tr) + } + return true + }, + deleteCommentReply: (referenceId: string, itemIndex: number) => ({ state, dispatch }) => { + let commentPos = -1 + let targetComment: ProseMirrorNode | null = null + state.doc.descendants((node, pos) => { + if (targetComment) { + return false + } + if (node.type.name === 'comment' && node.attrs.referenceId === referenceId) { + commentPos = pos + targetComment = node + return false + } + }) + + if (!targetComment || commentPos === -1) { + return false + } + + const comment = targetComment as ProseMirrorNode + if (itemIndex >= comment.childCount) { + return false + } + + const tr = state.tr + + if (comment.childCount === 1) { + // Deleting the last item - remove the reference instead; + // commentsCleanup will remove the orphaned comment node + let refPos = -1, refSize = 0 + state.doc.descendants((node, pos) => { + if (node.type.name === 'commentReference' && node.attrs.referenceId === referenceId) { + refPos = pos + refSize = node.nodeSize + return false + } + }) + if (refPos !== -1) { + tr.delete(refPos, refPos + refSize) + } + } else { + let itemPos = commentPos + 1 + for (let i = 0; i < itemIndex; i++) { + itemPos += comment.child(i).nodeSize + } + const item = comment.child(itemIndex) + tr.delete(itemPos, itemPos + item.nodeSize) + } + + if (dispatch) { + dispatch(tr) + } + return true + }, + } + }, + + addKeyboardShortcuts() { + return { + 'Mod-Alt-m': () => this.editor.commands.insertComment(), + } + }, + + addInputRules() { + return [ + new InputRule({ + find: /\[\?\]$/, + handler: ({ state, range, chain }) => { + if (isInsideCommentOrFootnote(state)) { + return null + } + chain() + .deleteRange({ from: range.from, to: range.to }) + .insertComment() + .run() + }, + }), + ] + }, +}) + +export default CommentReference diff --git a/src/nodes/Comments.ts b/src/nodes/Comments.ts new file mode 100644 index 00000000000..0804d548e49 --- /dev/null +++ b/src/nodes/Comments.ts @@ -0,0 +1,107 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import Comment from './Comment.ts' +import CommentItem from './CommentItem.ts' +import CommentReference from './CommentReference.ts' + +const Comments = Node.create({ + name: 'comments', + content: 'comment+', + defining: true, + isolating: true, + allowGapCursor: false, + + addExtensions() { + return [Comment, CommentItem, CommentReference] + }, + + parseHTML() { + return [{ tag: 'section[data-type="comments"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'section', + mergeAttributes(HTMLAttributes, { 'data-type': 'comments' }), + 0, + ] + }, + + toMarkdown(state, node) { + state.renderContent(node) + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('commentsCleanup'), + appendTransaction(transactions, _oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) { + return null + } + + const deletions: { pos: number, size: number }[] = [] + newState.doc.forEach((child, offset) => { + if (child.type.name !== 'comments') { + return + } + + const referencedLabels = new Set() + newState.doc.descendants((node) => { + if (node.type.name === 'commentReference') { + referencedLabels.add(node.attrs.referenceId) + } + }) + + const containerPos = offset + const orphans: { pos: number, size: number }[] = [] + let remainingNodeCount = 0 + let inner = 0 + + child.forEach((node) => { + if (node.type.name !== 'comment') { + return + } + if (!referencedLabels.has(node.attrs.referenceId)) { + orphans.push({ pos: containerPos + 1 + inner, size: node.nodeSize }) + } else { + remainingNodeCount++ + } + inner += node.nodeSize + }) + + if (orphans.length === 0) { + return + } + + if (remainingNodeCount === 0) { + deletions.push({ pos: containerPos, size: child.nodeSize }) + } else { + deletions.push(...orphans) + } + }) + + if (deletions.length === 0) { + return null + } + + // Delete right-to-left so earlier positions remain valid + deletions.sort((a, b) => b.pos - a.pos) + const tr = newState.tr + for (const del of deletions) { + tr.delete(del.pos, del.pos + del.size) + } + tr.setMeta('addToHistory', false) + return tr + }, + }), + ] + }, +}) + +export default Comments diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 6f55581401c..100bfccbb61 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Node as ProseMirrorNode } from '@tiptap/pm/model' -import type { EditorState } from '@tiptap/pm/state' - import { InputRule, mergeAttributes, Node } from '@tiptap/core' import { Plugin, TextSelection } from '@tiptap/pm/state' +import { footnoteExists, generateReferenceId, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -72,13 +70,13 @@ const FootnoteReference = Node.create({ addCommands() { return { insertFootnote: (options?: { referenceId?: string }) => ({ state, chain }) => { - if (isInsideFootnote(state)) { + if (isInsideCommentOrFootnote(state)) { return false } const referenceId = options?.referenceId ? String(options.referenceId) - : generateFootnoteId(state.doc) + : generateReferenceId(state.doc, 'footnote') if (!referenceId) { return false } @@ -148,7 +146,7 @@ const FootnoteReference = Node.create({ new InputRule({ find: /\[\^\]$/, handler: ({ state, range, chain }) => { - if (isInsideFootnote(state)) { + if (isInsideCommentOrFootnote(state)) { return null } @@ -192,63 +190,4 @@ const FootnoteReference = Node.create({ }, }) -/** - * Check if selection is inside a footnote - * - * @param state the editor state - */ -function isInsideFootnote(state: EditorState): boolean { - const { $from } = state.selection - for (let d = $from.depth; d > 0; d--) { - if ($from.node(d).type.name === 'footnote') { - return true - } - } - return false -} - -/** - * Get first unused numeric footnote id - * - * @param doc the document node - */ -function generateFootnoteId(doc: ProseMirrorNode): string { - const existing = new Set() - doc.descendants((node) => { - if (node.type.name === 'footnoteReference' || node.type.name === 'footnote') { - const id = node.attrs.referenceId - if (id) { - existing.add(String(id)) - } - } - }) - for (let i = 1; i < 10_000; i++) { - const candidate = String(i) - if (!existing.has(candidate)) { - return candidate - } - } - return '' -} - -/** - * Check if footnote with reference id exists - * - * @param doc - the ProseMirror node - * @param id - the searched reference id - */ -function footnoteExists(doc: ProseMirrorNode, id: string): boolean { - let found = false - doc.descendants((node) => { - if (found) { - return false - } - if (node.type.name === 'footnote' && node.attrs.referenceId === id) { - found = true - return false - } - }) - return found -} - export default FootnoteReference diff --git a/src/nodes/Footnotes.ts b/src/nodes/Footnotes.ts index 4787e09d4c0..e8dfbb06687 100644 --- a/src/nodes/Footnotes.ts +++ b/src/nodes/Footnotes.ts @@ -138,6 +138,7 @@ const Footnotes = Node.create({ for (const del of deletions) { tr.delete(del.pos, del.pos + del.size) } + tr.setMeta('addToHistory', false) return tr }, }), diff --git a/src/plugins/CommentBubblePluginView.ts b/src/plugins/CommentBubblePluginView.ts new file mode 100644 index 00000000000..2513914812a --- /dev/null +++ b/src/plugins/CommentBubblePluginView.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Editor } from '@tiptap/core' +import type { EditorState, Plugin } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' + +import { autoUpdate, computePosition, offset, shift } from '@floating-ui/dom' +import { VueRenderer } from '@tiptap/vue-3' +import CommentBubbleView from '../components/Comment/CommentBubbleView.vue' + +class CommentBubblePluginView { + #component: VueRenderer | null = null + #floatingEl: HTMLElement | null = null + #cleanupAutoUpdate: (() => void) | null = null + #activeReferenceEl: Element | null = null + #editor: Editor + plugin: Plugin + view: EditorView + + constructor({ view, options, plugin }: { view: EditorView, options: { editor: Editor }, plugin: Plugin }) { + this.view = view + this.#editor = options.editor + this.plugin = plugin + } + + #closeOnOutsideClick = (event: MouseEvent) => { + if (this.#floatingEl?.contains(event.target as Node)) { + return + } + // Don't close if clicking a comment-ref (that opens a different thread) + if ((event.target as HTMLElement).closest('.comment-ref')) { + return + } + this.#editor.commands.hideCommentBubble() + } + + #createFloating(referenceId: string) { + if (this.#floatingEl) { + return + } + this.#floatingEl = document.createElement('div') + this.#floatingEl.style.cssText = 'position: fixed; z-index: 100; visibility: hidden;' + + this.#component = new VueRenderer(CommentBubbleView, { + props: { editor: this.#editor, referenceId }, + editor: this.#editor, + }) + this.#floatingEl.appendChild(this.#component.element!) + } + + #destroyFloating() { + this.#cleanupAutoUpdate?.() + this.#cleanupAutoUpdate = null + this.#component?.destroy() + this.#component = null + this.#floatingEl?.remove() + this.#floatingEl = null + this.#activeReferenceEl?.classList.remove('is-active') + this.#activeReferenceEl = null + document.removeEventListener('mousedown', this.#closeOnOutsideClick) + } + + async #position(referenceEl: Element) { + if (!this.#floatingEl) { + return + } + const floating = this.#floatingEl + const contentWrapper = this.view.dom.closest('[data-text-el="editor-content-wrapper"]') + const container = contentWrapper ?? this.view.dom.parentNode as HTMLElement + if (!container.contains(floating)) { + container.appendChild(floating) + } + + const update = async () => { + const wrapperRect = (contentWrapper ?? this.view.dom.closest('div')!).getBoundingClientRect() + const refRect = referenceEl.getBoundingClientRect() + + const { x, y } = await computePosition({ + getBoundingClientRect: () => new DOMRect(wrapperRect.right, refRect.top, 0, refRect.height), + } as Element, floating, { + placement: 'left-start', + strategy: 'fixed', + middleware: [offset(8), shift({ padding: { top: 50, right: 8, bottom: 8, left: 8 } })], + }) + floating.style.left = `${x}px` + floating.style.top = `${y}px` + floating.style.visibility = 'visible' + } + + this.#cleanupAutoUpdate?.() + this.#cleanupAutoUpdate = autoUpdate(referenceEl, floating, update) + } + + update(view: EditorView, prevState: EditorState) { + const prev = this.plugin.getState(prevState) + const cur = this.plugin.getState(view.state) + if (prev === cur) { + return + } + const { active } = cur + if (!active) { + this.#destroyFloating() + return + } + + this.#createFloating(active.referenceId) + this.#component?.updateProps({ + referenceId: active.referenceId, + }) + + let referenceEl: Element | null + try { + const domNode = view.nodeDOM(active.nodeStart) + referenceEl = domNode instanceof Element + ? domNode + : (domNode as ChildNode | null)?.parentElement ?? null + } catch { + return + } + + if (referenceEl) { + if (this.#activeReferenceEl !== referenceEl) { + this.#activeReferenceEl?.classList.remove('is-active') + referenceEl.classList.add('is-active') + this.#activeReferenceEl = referenceEl + + // Scroll into view + const rect = referenceEl.getBoundingClientRect() + const inView = rect.top >= 0 && rect.bottom <= window.innerHeight + if (!inView) { + referenceEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + this.#position(referenceEl) + document.addEventListener('mousedown', this.#closeOnOutsideClick) + } + } + + destroy() { + this.#destroyFloating() + } +} + +export default CommentBubblePluginView diff --git a/src/plugins/commentBubble.ts b/src/plugins/commentBubble.ts new file mode 100644 index 00000000000..ab18886c6a7 --- /dev/null +++ b/src/plugins/commentBubble.ts @@ -0,0 +1,152 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Editor } from '@tiptap/core' +import type { Command } from '@tiptap/pm/state' + +import { Plugin, PluginKey } from '@tiptap/pm/state' +import CommentBubblePluginView from './CommentBubblePluginView.ts' + +export const commentBubbleKey = new PluginKey('commentBubble') + +export const hideCommentBubble: Command = (state, dispatch) => { + const pluginState = commentBubbleKey.getState(state) + if (!pluginState?.active) { + return false + } + if (dispatch) { + dispatch(state.tr.setMeta(commentBubbleKey, { active: null })) + } + return true +} + +/** + * Open the bubble for a comment + * + * @param referenceId - the comment reference ID + */ +export function openCommentBubble(referenceId: string): Command { + return (state, dispatch) => { + let nodeStart = -1 + state.doc.descendants((node, pos) => { + if (nodeStart !== -1) { + return false + } + if (node.type.name === 'commentReference' && node.attrs.referenceId === referenceId) { + nodeStart = pos + return false + } + }) + if (nodeStart === -1) { + return false + } + if (dispatch) { + dispatch(state.tr.setMeta(commentBubbleKey, { active: { referenceId, nodeStart } })) + } + return true + } +} + +/** + * Navigate to prev or next comment + * + * @param direction - the navigation direction + */ +export function navigateCommentBubble(direction: 'prev' | 'next'): Command { + return (state, dispatch) => { + const pluginState = commentBubbleKey.getState(state) + if (!pluginState?.active) { + return false + } + + const refs: { referenceId: string, nodeStart: number }[] = [] + state.doc.descendants((node, pos) => { + if (node.type.name === 'commentReference') { + refs.push({ referenceId: node.attrs.referenceId, nodeStart: pos }) + } + }) + + if (refs.length <= 1) { + return false + } + + const currentIndex = refs.findIndex((ref) => ref.referenceId === pluginState.active.referenceId) + if (currentIndex === -1) { + return false + } + + const nextIndex = direction === 'next' + ? (currentIndex + 1) % refs.length + : (currentIndex - 1 + refs.length) % refs.length + + if (dispatch) { + dispatch(state.tr.setMeta(commentBubbleKey, { active: refs[nextIndex] })) + } + return true + } +} + +/** + * Comment plugin function + * + * @param options - the plugin options object + * @param options.editor - the editor object + */ +export function commentBubble(options: { editor: Editor }) { + const plugin: Plugin = new Plugin({ + key: commentBubbleKey, + + state: { + init: () => ({ active: null }), + apply: (tr, cur) => { + const meta = tr.getMeta(commentBubbleKey) + if (meta) { + return { ...cur, active: meta.active } + } + return cur + }, + }, + + appendTransaction: (transactions, _oldState, state) => { + if (!transactions.some((tr) => tr.docChanged)) { + return null + } + + const pluginState = commentBubbleKey.getState(state) + if (!pluginState?.active) { + return null + } + + const { referenceId, nodeStart } = pluginState.active + + // If reference node no longer exists at the stored position, close the bubble + const node = state.doc.nodeAt(nodeStart) + if (node?.type.name !== 'commentReference' || node.attrs.referenceId !== referenceId) { + return state.tr.setMeta(commentBubbleKey, { active: null }) + } + + return null + }, + + view: (view) => new CommentBubblePluginView({ view, options, plugin }), + + props: { + handleDOMEvents: { + keydown: (view, event) => { + if (event.key === 'Enter') { + const focused = document.activeElement + if (focused?.classList.contains('comment-ref')) { + event.preventDefault() + ;(focused as HTMLElement).click() + return true + } + } + return false + }, + }, + }, + }) + return plugin +} diff --git a/src/plugins/referenceHelpers.ts b/src/plugins/referenceHelpers.ts new file mode 100644 index 00000000000..93d7641e8e2 --- /dev/null +++ b/src/plugins/referenceHelpers.ts @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { EditorState } from '@tiptap/pm/state' + +/** + * Check if selection is inside a node type + * + * @param state the editor state + * @param nodeTypeName the node type name + */ +function isInside(state: EditorState, nodeTypeName: string): boolean { + const { $from } = state.selection + for (let d = $from.depth; d > 0; d--) { + if ($from.node(d).type.name === nodeTypeName) { + return true + } + } + return false +} + +/** + * Check if selection is inside a comment + * + * @param state the editor state + */ +export function isInsideComment(state: EditorState): boolean { + return isInside(state, 'comment') +} + +/** + * Check if selection is inside a footnote + * + * @param state the editor state + */ +export function isInsideFootnote(state: EditorState): boolean { + return isInside(state, 'footnote') +} + +/** + * Check if selection is inside a comment or footnote + * + * @param state the editor state + */ +export function isInsideCommentOrFootnote(state: EditorState): boolean { + return isInsideComment(state) || isInsideFootnote(state) +} + +/** + * Get first unused numeric id + * + * @param doc the document node + * @param type comment or footnote + */ +export function generateReferenceId(doc: Node, type: 'comment' | 'footnote'): string { + const existing = new Set() + doc.descendants((node) => { + if (node.type.name === type + 'Reference' || node.type.name === type) { + const id = node.attrs.referenceId + if (id) { + existing.add(String(id)) + } + } + }) + for (let i = 1; i < 10_000; i++) { + const candidate = type === 'comment' ? 'comment-' + String(i) : String(i) + if (!existing.has(candidate)) { + return candidate + } + } + return '' +} + +/** + * Check if footnote with reference id exists + * + * @param doc - the ProseMirror node + * @param id - the searched reference id + */ +export function footnoteExists(doc: Node, id: string): boolean { + let found = false + doc.descendants((node) => { + if (found) { + return false + } + if (node.type.name === 'footnote' && node.attrs.referenceId === id) { + found = true + return false + } + }) + return found +} diff --git a/src/tests/markdownit/comments.spec.ts b/src/tests/markdownit/comments.spec.ts new file mode 100644 index 00000000000..03cec48d5aa --- /dev/null +++ b/src/tests/markdownit/comments.spec.ts @@ -0,0 +1,118 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import markdownit from '../../markdownit/index.js' + +describe('comments (markdown-it)', () => { + it('standard comment', () => { + const md = 'The quick[^comment-1] brown fox.\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-15T13:12Z)*\n' + + ' Comment by Jane\n' + expect(markdownit.render(md)).to.eq('

The quick brown fox.

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '
\n') + }) + + it('multiple comment items', () => { + const md = 'The quick[^comment-1] brown fox.\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-15T13:12Z)*\n' + + ' Comment by Jane\n' + + ' - @[bob](mention://user/bob) *(2026-07-15T15:11Z)*\n' + + ' Comment by Bob\n' + expect(markdownit.render(md)).to.eq('

The quick brown fox.

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '

Comment by Bob

\n' + + '
\n' + + '
\n' + + '
\n') + }) + + it('guest comment', () => { + const md = 'The quick[^comment-1] brown fox.\n\n' + + '[^comment-1]:\n' + + ' - @guestname *(2026-07-15T13:12Z)*\n' + + ' Comment from guest\n' + expect(markdownit.render(md)).to.eq('

The quick brown fox.

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment from guest

\n' + + '
\n' + + '
\n' + + '
\n') + }) + + it('comment without metadata', () => { + const md = 'Foo[^comment-1] bar\n\n' + + '[^comment-1]:\n' + + ' - first reply\n' + + ' - second reply' + expect(markdownit.render(md)).to.eq('

Foo bar

\n' + + '
\n' + + '
\n' + + '
\n' + + '

first reply

\n' + + '
\n' + + '
\n' + + '

second reply

\n' + + '
\n' + + '
\n' + + '
\n') + }) + + it('plain comment without metadata and without bullet list', () => { + const md = 'The quick[^comment-1] brown fox.\n\n' + + '[^comment-1]: some comment' + expect(markdownit.render(md)).to.eq('

The quick brown fox.

\n' + + '
\n' + + '
\n' + + '
\n' + + '

some comment

\n' + + '
\n' + + '
\n' + + '
\n') + }) + + it('comments and footnotes in the same document', () => { + const md = 'A[^comment-1] B[^1] C[^comment-2]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-15T13:12Z)*\n' + + ' Comment by Jane\n\n' + + '[^1]: footnote\n\n' + + '[^comment-2]:\n' + + ' - @[bob](mention://user/bob) *(2026-07-15T15:11Z)*\n' + + ' Comment by Bob' + expect(markdownit.render(md)).to.eq('

A B C

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Bob

\n' + + '
\n' + + '
\n' + + '
\n' + + '
\n' + + '
\n' + + '

footnote

\n' + + '
\n' + + '
\n') + }) +}) diff --git a/src/tests/nodes/Comments.spec.ts b/src/tests/nodes/Comments.spec.ts new file mode 100644 index 00000000000..4686820293c --- /dev/null +++ b/src/tests/nodes/Comments.spec.ts @@ -0,0 +1,363 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Editor } from '@tiptap/core' + +import { Document } from '@tiptap/extension-document' +import { ListItem } from '@tiptap/extension-list' +import { describe, expect } from 'vitest' +import KeepSyntax from '../../extensions/KeepSyntax.js' +import Mention from '../../extensions/Mention.js' +import BulletList from '../../nodes/BulletList.ts' +import Comments from '../../nodes/Comments.ts' +import Footnotes from '../../nodes/Footnotes.ts' +import testEditor from '../testHelpers/testEditor.ts' + +const test = testEditor.override('extensions', [ + Document.extend({ content: 'block+ comments? footnotes?' }), + Comments, + Footnotes, + BulletList, + KeepSyntax, + Mention, + ListItem, +]) + +describe('Comments extension', () => { + test('registers comments, comment, commentItem, and commentReference nodes', ({ editor }) => { + expect(editor.schema.nodes.comments).toBeDefined() + expect(editor.schema.nodes.comment).toBeDefined() + expect(editor.schema.nodes.commentItem).toBeDefined() + expect(editor.schema.nodes.commentReference).toBeDefined() + }) + + test('parses comment from HTML', ({ editor }) => { + editor.commands + .setContent('

Foo

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '
\n') + + const ref = editor.state.doc.firstChild!.child(1) + const comments = editor.state.doc.lastChild! + const comment = comments.firstChild! + const item = comment.firstChild! + + expect(ref.type.name).toBe('commentReference') + expect(ref.attrs.referenceId).toBe('comment-1') + expect(comments.type.name).toBe('comments') + expect(comment.type.name).toBe('comment') + expect(comment.attrs.referenceId).toBe('comment-1') + expect(item.type.name).toBe('commentItem') + expect(item.attrs.author).toBe('jane') + expect(item.attrs.authorLabel).toBe('jane') + expect(item.attrs.timestamp).toBe('2026-07-15T13:12Z') + expect(item.textContent).toBe('Comment by Jane') + }) + + test('parses multiple comment items in a thread', ({ editor }) => { + editor.commands + .setContent('

Foo

\n' + + '
\n' + + '
\n' + + '

First

\n' + + '

Second

\n' + + '
\n' + + '
\n') + const comment = editor.state.doc.lastChild!.firstChild! + expect(comment.childCount).toBe(2) + expect(comment.firstChild!.attrs.author).toBe('jane') + expect(comment.lastChild!.attrs.author).toBe('bob') + }) + + test('preserves data attributes across HTML round-trip', ({ editor }) => { + editor.commands + .setContent('

Foo

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '
\n') + const html = editor.getHTML() + expect(html).toContain('data-type="comment-reference"') + expect(html).toContain('data-reference-id="comment-1"') + expect(html).toContain('id="cref-comment-1"') + expect(html).toContain('data-type="comments"') + expect(html).toContain('data-type="comment"') + expect(html).toContain('id="c-comment-1"') + expect(html).toContain('data-type="comment-item"') + expect(html).toContain('data-author="jane"') + expect(html).toContain('data-timestamp="2026-07-15T13:12Z"') + }) +}) + +describe('Comments cleanup', () => { + function deleteFirstReference(editor: Editor) { + // Delete reference node + let refPos = -1 + let refSize = 0 + editor.state.doc.descendants((node, pos) => { + if (refPos !== -1) { + return false + } + if (node.type.name === 'commentReference') { + refPos = pos + refSize = node.nodeSize + return false + } + }) + editor.commands.setTextSelection({ from: refPos, to: refPos + refSize }) + editor.commands.deleteSelection() + } + + test('removes comments container when last reference is deleted', ({ editor }) => { + editor.commands + .setContent('

Foo

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '
\n') + + deleteFirstReference(editor) + + let commentsCount = 0, commentCount = 0 + editor.state.doc.descendants((n) => { + if (n.type.name === 'comments') { + commentsCount++ + } + if (n.type.name === 'comment') { + commentCount++ + } + }) + expect(commentsCount).toBe(0) + expect(commentCount).toBe(0) + }) + + test('removes only orphaned comment when other comments remain', ({ editor }) => { + editor.commands + .setContent('

Foo

\n' + + '

Bar

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Jane

\n' + + '
\n' + + '
\n' + + '
\n' + + '

Comment by Bob

\n' + + '
\n' + + '
\n' + + '
\n') + + deleteFirstReference(editor) + + let commentsCount = 0, comment1Count = 0, comment2Count = 0 + editor.state.doc.descendants((n) => { + if (n.type.name === 'comments') { + commentsCount++ + } + if (n.type.name === 'comment') { + if (n.attrs.referenceId === 'comment-1') { + comment1Count++ + } else if (n.attrs.referenceId === 'comment-2') { + comment2Count++ + } + } + }) + expect(commentsCount).toBe(1) + expect(comment1Count).toBe(0) + expect(comment2Count).toBe(1) + }) +}) + +describe('insertComment command', () => { + test('inserts a reference and matching comment thread', ({ editor }) => { + editor.commands.setContent('

Foo

') + editor.commands.focus('end') + + const result = editor.commands.insertComment() + expect(result).toBe(true) + + expect(editor.state.doc.firstChild!.lastChild!.type.name).toBe('commentReference') + + const comments = editor.state.doc.lastChild! + expect(comments.type.name).toBe('comments') + expect(comments.firstChild!.type.name).toBe('comment') + expect(comments.firstChild!.firstChild!.type.name).toBe('commentItem') + }) + + test('generates comment-1 for the first comment', ({ editor }) => { + editor.commands.setContent('

Foo

') + editor.commands.focus('end') + editor.commands.insertComment() + + const ref = editor.state.doc.firstChild!.lastChild! + expect(ref.attrs.referenceId).toBe('comment-1') + expect(editor.state.doc.lastChild!.firstChild!.attrs.referenceId).toBe('comment-1') + }) + + test('generates lowest unused comment-N id', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
' + + '
' + + '

x

' + + '
' + + '
') + editor.commands.setTextSelection(1) + editor.commands.insertComment() + + const refs: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'commentReference') { + refs.push(node.attrs.referenceId) + } + }) + expect(refs).toContain('comment-1') + expect(refs).toContain('comment-2') + }) + + test('appends into existing comments container', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
' + + '
' + + '

x

' + + '
' + + '
') + editor.commands.focus('start') + editor.commands.insertComment() + + const comments = editor.state.doc.lastChild! + expect(comments.type.name).toBe('comments') + expect(comments.childCount).toBe(2) + }) + + test('inserts comments container before footnotes', ({ editor }) => { + editor.commands.setContent('

Foo

' + + '
' + + '

fn

' + + '
') + editor.commands.setTextSelection(1) + editor.commands.insertComment() + + const childNames: string[] = [] + editor.state.doc.forEach((child) => childNames.push(child.type.name)) + expect(childNames.indexOf('comments')).toBeLessThan(childNames.indexOf('footnotes')) + }) +}) + +describe('Comments Markdown roundtrip', () => { + test('single-reply comment', ({ markdownThroughEditor }) => { + const test = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Hello there' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('multi-reply comment', ({ markdownThroughEditor }) => { + const test = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Hello there\n' + + ' - @[bob](mention://user/bob) *(2026-07-17T11:11Z)*\n' + + ' Second comment' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('comment with complex content', ({ markdownThroughEditor }) => { + const test = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Check this @[bob](mention://user/bob):\n' + + ' * first item\n' + + ' * second item' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('multiple comments', ({ markdownThroughEditor }) => { + const test = 'Foo[^comment-1] bar[^comment-2]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Hello there\n\n' + + '[^comment-2]:\n' + + ' - @[bob](mention://user/bob) *(2026-07-17T11:11Z)*\n' + + ' Second comment' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('comments and footnotes', ({ markdownThroughEditor }) => { + const test = 'Foo[^comment-1] bar[^1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Hello there\n\n' + + '[^1]: footnote body' + expect(markdownThroughEditor(test)).toBe(test) + }) + test('cleanup dangling comments', ({ markdownThroughEditor }) => { + const test = 'Foo\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' Hello there\n' + expect(markdownThroughEditor(test)).toBe('Foo') + }) + test('idempotent through round-trip with broken metadata #1', ({ markdownThroughEditor }) => { + const testIn = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - whatever\n' + + ' Hello there' + const testOut = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - \n' + + ' whatever\n' + + ' Hello there' + expect(markdownThroughEditor(testIn)).toBe(testOut) + expect(markdownThroughEditor(testOut)).toBe(testOut) + }) + test('idempotent through round-trip with broken metadata #2', ({ markdownThroughEditor }) => { + const testIn = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @jane xyz\n' + + ' Hello there' + const testOut = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @jane\n' + + ' xyz\n' + + ' Hello there' + expect(markdownThroughEditor(testIn)).toBe(testOut) + expect(markdownThroughEditor(testOut)).toBe(testOut) + }) + test('idempotent through round-trip with broken metadata #3', ({ markdownThroughEditor }) => { + const testIn = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @jane *(2026-01-01x*\n' + + ' Hello there' + const testOut = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @jane\n' + + ' (2026-01-01x\n' + + ' Hello there' + expect(markdownThroughEditor(testIn)).toBe(testOut) + expect(markdownThroughEditor(testOut)).toBe(testOut) + }) + test('idempotent through round-trips when comment has no list', ({ markdownThroughEditor }) => { + const testIn1 = 'Foo[^comment-1]\n\n' + + '[^comment-1]: hello' + const testIn2 = 'Foo[^comment-1]\n\n' + + '[^comment-1]: \n' + + ' - hello' + const testOut = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - \n' + + ' hello' + expect(markdownThroughEditor(testIn1)).toBe(testOut) + expect(markdownThroughEditor(testIn2)).toBe(testOut) + expect(markdownThroughEditor(testOut)).toBe(testOut) + }) +})