From d126d875cba92a1ab027fe54ae6362f696b4aec6 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 16 Sep 2026 12:33:45 +0200 Subject: [PATCH 1/9] fix(comments): fix background color for selected comment reference Signed-off-by: Jonas --- src/css/prosemirror.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 061454e361f..1fdad505fb2 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -479,7 +479,7 @@ div.ProseMirror { } &.is-active { - background-color: var(--color-primary-element-light-text); + background-color: var(--color-primary-element-light); border-radius: var(--border-radius); } } From 273cea0c5b54a8ff7815fa051d9b4f4670603d21 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 16 Sep 2026 12:58:24 +0200 Subject: [PATCH 2/9] fix(comments): delete empty comment when closing bubble Insert comment and then escape now removes the empty comment reference. Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- playwright/e2e/comments.spec.ts | 24 +++++++---- src/extensions/CommentBubble.ts | 23 ++++++++--- src/nodes/CommentReference.ts | 4 +- src/plugins/referenceHelpers.ts | 29 +++++++++++++ src/tests/nodes/Comments.spec.ts | 70 +++++++++++++++++++++++++++++++- 5 files changed, 134 insertions(+), 16 deletions(-) diff --git a/playwright/e2e/comments.spec.ts b/playwright/e2e/comments.spec.ts index 74b37ca25f0..752da720d77 100644 --- a/playwright/e2e/comments.spec.ts +++ b/playwright/e2e/comments.spec.ts @@ -139,14 +139,22 @@ test.describe('deletes last comment reply', () => { }) }) -test('hides and shows comment references via annotations toggle', async ({ editor, open }) => { - await open() - await editor.type('Test[?]') - await expect(editor.commentReferences.first()).toBeVisible() +test.describe('toggles annotations', () => { + test.use({ + fileContent: 'Test[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-0716T13:12Z)*\n' + + ' Comment by Jane\n', + }) + test('hides and shows comment references via annotations toggle', async ({ editor, open }) => { + await open() + await expect(editor.commentReferences.first()).toBeVisible() - await editor.clickMenu('Annotations', 'Hide annotations') - await expect(editor.commentReferences.first()).toBeHidden() + await editor.clickMenu('Annotations', 'Hide annotations') + await expect(editor.commentReferences).toHaveCount(1) + await expect(editor.commentReferences.first()).toBeHidden() - await editor.clickMenu('Annotations', 'Show annotations') - await expect(editor.commentReferences.first()).toBeVisible() + await editor.clickMenu('Annotations', 'Show annotations') + await expect(editor.commentReferences.first()).toBeVisible() + }) }) diff --git a/src/extensions/CommentBubble.ts b/src/extensions/CommentBubble.ts index 6a031157f51..4848b336abf 100644 --- a/src/extensions/CommentBubble.ts +++ b/src/extensions/CommentBubble.ts @@ -5,8 +5,10 @@ import type { CommandProps } from '@tiptap/core' +import { getCurrentUser } from '@nextcloud/auth' import { Extension } from '@tiptap/core' import { commentBubble, commentBubbleKey, hideCommentBubble, navigateCommentBubble, openCommentBubble } from '../plugins/commentBubble.ts' +import { findComment, isEmptyComment } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -28,16 +30,27 @@ const CommentBubble = Extension.create({ return openCommentBubble(referenceId)(state, dispatch) } }, - hideCommentBubble: (options?: { refocus?: boolean }) => ({ state, dispatch, chain }) => { + hideCommentBubble: (options?: { refocus?: boolean }) => ({ state, dispatch, chain, commands }) => { const pluginState = commentBubbleKey.getState(state) const active = pluginState?.active const result = hideCommentBubble(state, dispatch) - if (!result) { + if (!result || !active) { return result } - if (options?.refocus && active) { - const node = state.doc.nodeAt(active.nodeStart) - const cursorPos = active.nodeStart + (node?.nodeSize ?? 1) + + const refNode = state.doc.nodeAt(active.nodeStart) + let cursorPos = active.nodeStart + (refNode?.nodeSize ?? 1) + + // Discard a comment taht was created but never submitted + const comment = findComment(state.doc, active.referenceId) + const currentUserId = getCurrentUser()?.uid ?? '' + const hasDraft = !!sessionStorage.getItem('text-comment-draft-' + active.referenceId) + if (comment && isEmptyComment(comment) && comment.firstChild!.attrs.author === currentUserId && !hasDraft) { + commands.deleteCommentReply(comment, 0) + cursorPos = active.nodeStart + } + + if (options?.refocus) { chain().setTextSelection(cursorPos).focus().run() } return true diff --git a/src/nodes/CommentReference.ts b/src/nodes/CommentReference.ts index a2c292dce68..19f58509a6b 100644 --- a/src/nodes/CommentReference.ts +++ b/src/nodes/CommentReference.ts @@ -11,7 +11,7 @@ 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' +import { generateReferenceId, isEmptyComment, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -231,7 +231,7 @@ const CommentReference = Node.create({ } const tr = state.tr - const shouldAppendNewReply = itemIndex === undefined + const shouldAppendNewReply = itemIndex === undefined && !isEmptyComment(comment) && !(comment.childCount === 1 && item.textContent === '') if (shouldAppendNewReply) { // Append a new reply item diff --git a/src/plugins/referenceHelpers.ts b/src/plugins/referenceHelpers.ts index 93d7641e8e2..ef61339c8c7 100644 --- a/src/plugins/referenceHelpers.ts +++ b/src/plugins/referenceHelpers.ts @@ -93,3 +93,32 @@ export function footnoteExists(doc: Node, id: string): boolean { }) return found } + +/** + * Find the comment node with the given reference id + * + * @param doc - the ProseMirror node + * @param referenceId - the searched reference id + */ +export function findComment(doc: Node, referenceId: string): Node | null { + let found: Node | null = null + doc.descendants((node) => { + if (found) { + return false + } + if (node.type.name === 'comment' && node.attrs.referenceId === referenceId) { + found = node + return false + } + }) + return found +} + +/** + * Check if a comment thread contains only empty placeholder + * + * @param comment - the comment node + */ +export function isEmptyComment(comment: Node): boolean { + return comment.childCount === 1 && comment.firstChild!.textContent === '' +} diff --git a/src/tests/nodes/Comments.spec.ts b/src/tests/nodes/Comments.spec.ts index 4686820293c..a4fc80d220a 100644 --- a/src/tests/nodes/Comments.spec.ts +++ b/src/tests/nodes/Comments.spec.ts @@ -7,17 +7,27 @@ import type { Editor } from '@tiptap/core' import { Document } from '@tiptap/extension-document' import { ListItem } from '@tiptap/extension-list' -import { describe, expect } from 'vitest' +import { describe, expect, vi } from 'vitest' +import CommentBubble from '../../extensions/CommentBubble.ts' 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 { commentBubbleKey } from '../../plugins/commentBubble.ts' import testEditor from '../testHelpers/testEditor.ts' +vi.mock('../../plugins/CommentBubblePluginView.ts', () => ({ + default: class { + update() {} + destroy() {} + }, +})) + const test = testEditor.override('extensions', [ Document.extend({ content: 'block+ comments? footnotes?' }), Comments, + CommentBubble, Footnotes, BulletList, KeepSyntax, @@ -256,6 +266,64 @@ describe('insertComment command', () => { }) }) +describe('hideCommentBubble command', () => { + function hasNode(editor: Editor, typeName: string): boolean { + let found = false + editor.state.doc.descendants((node) => { + if (node.type.name === typeName) { + found = true + } + }) + return found + } + + test('removes an empty comment when the bubble is closed', ({ editor }) => { + editor.commands.setContent('

Foo

') + editor.commands.focus('end') + editor.commands.insertComment() + expect(commentBubbleKey.getState(editor.state).active?.referenceId).toBe('comment-1') + + editor.commands.hideCommentBubble({ refocus: true }) + + expect(commentBubbleKey.getState(editor.state).active).toBeNull() + expect(hasNode(editor, 'commentReference')).toBe(false) + expect(hasNode(editor, 'comments')).toBe(false) + expect(editor.state.doc.textContent).toBe('Foo') + expect(editor.state.selection.from).toBe(4) + }) + + test('keeps an empty comment with an unsent draft', ({ editor }) => { + editor.commands.setContent('

Foo

') + editor.commands.focus('end') + editor.commands.insertComment() + sessionStorage.setItem('text-comment-draft-comment-1', 'work in progress') + + editor.commands.hideCommentBubble() + sessionStorage.removeItem('text-comment-draft-comment-1') + + expect(commentBubbleKey.getState(editor.state).active).toBeNull() + expect(hasNode(editor, 'commentReference')).toBe(true) + expect(hasNode(editor, 'comment')).toBe(true) + }) + + test('keeps a comment with content when the bubble is closed', ({ editor }) => { + editor.commands.setContent('

Foo

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

x

' + + '
' + + '
') + editor.commands.openCommentBubble('comment-1') + expect(commentBubbleKey.getState(editor.state).active?.referenceId).toBe('comment-1') + + editor.commands.hideCommentBubble() + + expect(commentBubbleKey.getState(editor.state).active).toBeNull() + expect(hasNode(editor, 'commentReference')).toBe(true) + expect(hasNode(editor, 'comment')).toBe(true) + }) +}) + describe('Comments Markdown roundtrip', () => { test('single-reply comment', ({ markdownThroughEditor }) => { const test = 'Foo[^comment-1]\n\n' From e97659127242ce8433c889d48b080ff611c44575 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 16 Sep 2026 13:02:13 +0200 Subject: [PATCH 3/9] chore(comments): move reused string into const Signed-off-by: Jonas --- src/components/Comment/CommentBubbleView.vue | 13 ++++++------- src/extensions/CommentBubble.ts | 6 +++--- src/nodes/CommentReference.ts | 5 ++--- src/plugins/referenceHelpers.ts | 2 ++ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/Comment/CommentBubbleView.vue b/src/components/Comment/CommentBubbleView.vue index 341780d4702..a98af0c2fcf 100644 --- a/src/components/Comment/CommentBubbleView.vue +++ b/src/components/Comment/CommentBubbleView.vue @@ -176,17 +176,16 @@ import DeleteIcon from 'vue-material-design-icons/Delete.vue' import PencilIcon from 'vue-material-design-icons/Pencil.vue' import { useGuestName } from '../../composables/useGuestName.ts' import { createMarkdownSerializer } from '../../extensions/Markdown.ts' +import { commentDraftPrefix } from '../../plugins/referenceHelpers.ts' const props = defineProps<{ editor: Editor referenceId: string }>() -const DRAFT_KEY_PREFIX = 'text-comment-draft-' - // References used in template const itemsContainer = ref(null) -const replyText = ref(sessionStorage.getItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`) ?? '') +const replyText = ref(sessionStorage.getItem(`${commentDraftPrefix}${props.referenceId}`) ?? '') const editInput = ref[] | null>(null) const replyInput = ref | null>(null) const userData = ref>({}) @@ -240,7 +239,7 @@ const { setGuestName } = useGuestName(props.editor) // Persist draft as user types watch(replyText, (val) => { - const key = `${DRAFT_KEY_PREFIX}${props.referenceId}` + const key = `${commentDraftPrefix}${props.referenceId}` if (val.trim()) { sessionStorage.setItem(key, val) } else { @@ -250,7 +249,7 @@ watch(replyText, (val) => { // Restore draft when bubble opens or switches to a different comment watch(() => props.referenceId, (id) => { - replyText.value = sessionStorage.getItem(`${DRAFT_KEY_PREFIX}${id}`) ?? '' + replyText.value = sessionStorage.getItem(`${commentDraftPrefix}${id}`) ?? '' }) // Focus input field when switching between comment references. @@ -315,7 +314,7 @@ function submitReply() { return } props.editor.commands.addOrUpdateCommentReply(commentNode.value, replyText.value.trim()) - sessionStorage.removeItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`) + sessionStorage.removeItem(`${commentDraftPrefix}${props.referenceId}`) replyText.value = '' nextTick(() => { if (itemsContainer.value) { @@ -374,7 +373,7 @@ function deleteItem(index: number) { return } if (items.value.length === 1) { - sessionStorage.removeItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`) + sessionStorage.removeItem(`${commentDraftPrefix}${props.referenceId}`) } props.editor.commands.deleteCommentReply(commentNode.value, index) } diff --git a/src/extensions/CommentBubble.ts b/src/extensions/CommentBubble.ts index 4848b336abf..65c43fadcf7 100644 --- a/src/extensions/CommentBubble.ts +++ b/src/extensions/CommentBubble.ts @@ -8,7 +8,7 @@ import type { CommandProps } from '@tiptap/core' import { getCurrentUser } from '@nextcloud/auth' import { Extension } from '@tiptap/core' import { commentBubble, commentBubbleKey, hideCommentBubble, navigateCommentBubble, openCommentBubble } from '../plugins/commentBubble.ts' -import { findComment, isEmptyComment } from '../plugins/referenceHelpers.ts' +import { commentDraftPrefix, findComment, isEmptyComment } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -41,10 +41,10 @@ const CommentBubble = Extension.create({ const refNode = state.doc.nodeAt(active.nodeStart) let cursorPos = active.nodeStart + (refNode?.nodeSize ?? 1) - // Discard a comment taht was created but never submitted + // Discard a comment that was created but never submitted const comment = findComment(state.doc, active.referenceId) const currentUserId = getCurrentUser()?.uid ?? '' - const hasDraft = !!sessionStorage.getItem('text-comment-draft-' + active.referenceId) + const hasDraft = !!sessionStorage.getItem(commentDraftPrefix + active.referenceId) if (comment && isEmptyComment(comment) && comment.firstChild!.attrs.author === currentUserId && !hasDraft) { commands.deleteCommentReply(comment, 0) cursorPos = active.nodeStart diff --git a/src/nodes/CommentReference.ts b/src/nodes/CommentReference.ts index 19f58509a6b..606e51a0dfd 100644 --- a/src/nodes/CommentReference.ts +++ b/src/nodes/CommentReference.ts @@ -11,7 +11,7 @@ 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, isEmptyComment, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' +import { commentDraftPrefix, generateReferenceId, isEmptyComment, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -97,7 +97,7 @@ const CommentReference = Node.create({ } // Clear any stale draft from a previous comment that used this ID - sessionStorage.removeItem('text-comment-draft-' + referenceId) + sessionStorage.removeItem(commentDraftPrefix + referenceId) // In can-check mode, the above guards are sufficient if (!dispatch) { @@ -232,7 +232,6 @@ const CommentReference = Node.create({ const tr = state.tr const shouldAppendNewReply = itemIndex === undefined && !isEmptyComment(comment) - && !(comment.childCount === 1 && item.textContent === '') if (shouldAppendNewReply) { // Append a new reply item const commentItemType = state.schema.nodes.commentItem diff --git a/src/plugins/referenceHelpers.ts b/src/plugins/referenceHelpers.ts index ef61339c8c7..47875fb611e 100644 --- a/src/plugins/referenceHelpers.ts +++ b/src/plugins/referenceHelpers.ts @@ -6,6 +6,8 @@ import type { Node } from '@tiptap/pm/model' import type { EditorState } from '@tiptap/pm/state' +export const commentDraftPrefix = 'text-comment-draft-' + /** * Check if selection is inside a node type * From ae5545593db869c40d4430381cb31f8a0ae1a4d0 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 16 Sep 2026 15:50:44 +0200 Subject: [PATCH 4/9] fix(comments): add comment/footnote at end of selection range Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- src/nodes/CommentReference.ts | 10 +++--- src/nodes/FootnoteReference.ts | 10 ++++-- src/tests/nodes/Comments.spec.ts | 41 ++++++++++++++++++++++++ src/tests/nodes/Footnotes.spec.ts | 52 ++++++++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/nodes/CommentReference.ts b/src/nodes/CommentReference.ts index 606e51a0dfd..fdb446c703e 100644 --- a/src/nodes/CommentReference.ts +++ b/src/nodes/CommentReference.ts @@ -119,10 +119,8 @@ const CommentReference = Node.create({ ) const newComment = commentType.create({ referenceId }, newCommentItem) - let c = chain() - .insertContent({ type: 'commentReference', attrs: { referenceId } }) - - // Find positions of existing containers in the original doc + // Insert the comment before the reference: the container positions are read + // from the document as it is now and would shift once the reference is added. let commentsInsidePos = -1 let footnotesStartPos = -1 state.doc.forEach((child, offset) => { @@ -134,6 +132,7 @@ const CommentReference = Node.create({ } }) + let c = chain() if (commentsInsidePos !== -1) { c = c.insertContentAt(commentsInsidePos, newComment.toJSON()) } else if (footnotesStartPos !== -1) { @@ -148,6 +147,9 @@ const CommentReference = Node.create({ }) } + // Insert the reference at the end of the selection + c = c.insertContentAt(state.selection.to, { type: 'commentReference', attrs: { referenceId } }) + // Move selection/cursor to reference to avoid it being inside the hidden comments container c = c.command(({ state, dispatch }) => { let nodeStart: number | null = null diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index d135eebd221..4701fed1089 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -88,10 +88,10 @@ const FootnoteReference = Node.create({ const paragraphType = state.schema.nodes.paragraph let c = chain() - .insertContent({ type: 'footnoteReference', attrs: { referenceId } }) if (!existingFootnote) { - // Create footnote + // Create the footnote before the reference: the container positions are read + // from the document as it is now and would shift once the reference is added. const newFootnote = footnoteType.create({ referenceId }, paragraphType.create()) const lastChild = state.doc.lastChild const hasFootnotesBlock = lastChild?.type === footnotesType @@ -107,7 +107,11 @@ const FootnoteReference = Node.create({ content: [newFootnote.toJSON()], }) } - } else { + } + + c = c.insertContentAt(state.selection.to, { type: 'footnoteReference', attrs: { referenceId } }, { updateSelection: false }) + + if (existingFootnote) { // Jump cursor into existing footnote c = c.command(({ tr }) => { let target: number | null = null diff --git a/src/tests/nodes/Comments.spec.ts b/src/tests/nodes/Comments.spec.ts index a4fc80d220a..64d2f895e02 100644 --- a/src/tests/nodes/Comments.spec.ts +++ b/src/tests/nodes/Comments.spec.ts @@ -264,6 +264,47 @@ describe('insertComment command', () => { editor.state.doc.forEach((child) => childNames.push(child.type.name)) expect(childNames.indexOf('comments')).toBeLessThan(childNames.indexOf('footnotes')) }) + + test('inserts the reference after a selected range and keeps the text', ({ editor }) => { + editor.commands.setContent('

Hello world

') + editor.commands.setTextSelection({ from: 1, to: 6 }) + + const result = editor.commands.insertComment() + expect(result).toBe(true) + + const paragraph = editor.state.doc.firstChild! + expect(paragraph.textContent).toBe('Hello world') + expect(paragraph.child(0).text).toBe('Hello') + expect(paragraph.child(1).type.name).toBe('commentReference') + expect(paragraph.child(2).text).toBe(' world') + + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.lastChild!.type.name).toBe('comments') + expect(editor.state.doc.lastChild!.firstChild!.attrs.referenceId).toBe('comment-1') + }) + + test('appends into existing comments container with a selected range', ({ editor }) => { + editor.commands.setContent('

Hello world

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

x

' + + '
' + + '
') + editor.commands.setTextSelection({ from: 1, to: 6 }) + + editor.commands.insertComment() + + const paragraph = editor.state.doc.firstChild! + expect(paragraph.textContent).toBe('Hello world') + expect(paragraph.child(1).type.name).toBe('commentReference') + expect(paragraph.child(1).attrs.referenceId).toBe('comment-2') + + expect(editor.state.doc.childCount).toBe(2) + const comments = editor.state.doc.lastChild! + expect(comments.type.name).toBe('comments') + expect(comments.childCount).toBe(2) + expect(comments.lastChild!.attrs.referenceId).toBe('comment-2') + }) }) describe('hideCommentBubble command', () => { diff --git a/src/tests/nodes/Footnotes.spec.ts b/src/tests/nodes/Footnotes.spec.ts index 90836ec1d28..267071b54c8 100644 --- a/src/tests/nodes/Footnotes.spec.ts +++ b/src/tests/nodes/Footnotes.spec.ts @@ -120,6 +120,56 @@ describe('insertFootnote command', () => { const footnote = editor.state.doc.lastChild!.firstChild! expect(footnote.attrs.referenceId).toBe('custom') }) + + test('inserts the reference after a selected range and keeps the text', ({ editor }) => { + editor.commands.setContent('

Hello world

') + editor.commands.setTextSelection({ from: 1, to: 6 }) + + const result = editor.commands.insertFootnote() + expect(result).toBe(true) + + const paragraph = editor.state.doc.firstChild! + expect(paragraph.textContent).toBe('Hello world') + expect(paragraph.child(0).text).toBe('Hello') + expect(paragraph.child(1).type.name).toBe('footnoteReference') + expect(paragraph.child(2).text).toBe(' world') + + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.lastChild!.type.name).toBe('footnotes') + }) + + test('places the cursor inside the new footnote', ({ editor }) => { + editor.commands.setContent('

Hello world

') + editor.commands.setTextSelection({ from: 1, to: 6 }) + + editor.commands.insertFootnote() + + const $from = editor.state.selection.$from + expect(editor.state.selection.empty).toBe(true) + expect($from.node(1).type.name).toBe('footnotes') + expect($from.node(2).type.name).toBe('footnote') + expect($from.node(2).attrs.referenceId).toBe('1') + }) + + test('appends into existing footnotes container with a selected range', ({ editor }) => { + editor.commands.setContent('

Hello world

' + + '
' + + '

x

' + + '
') + editor.commands.setTextSelection({ from: 1, to: 6 }) + + editor.commands.insertFootnote() + + const paragraph = editor.state.doc.firstChild! + expect(paragraph.textContent).toBe('Hello world') + expect(paragraph.child(1).attrs.referenceId).toBe('2') + + expect(editor.state.doc.childCount).toBe(2) + const footnotes = editor.state.doc.lastChild! + expect(footnotes.type.name).toBe('footnotes') + expect(footnotes.childCount).toBe(2) + expect(footnotes.lastChild!.attrs.referenceId).toBe('2') + }) }) describe('Footnotes cleanup', () => { @@ -157,7 +207,7 @@ describe('Footnotes cleanup', () => { test('removes footnotes when last reference is deleted', ({ editor }) => { editor.commands.setContent('

Foo

' - + '
data-type="footnotes">' + + '
' + '

x

' + '
') deleteFirstReference(editor) From a26b8e9c11518b8b891294df18da56d52b6cff67 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 16 Sep 2026 16:45:34 +0200 Subject: [PATCH 5/9] fix(comments): fix guest user extraction from Markdown Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- src/markdownit/comments.ts | 9 +++------ src/tests/markdownit/comments.spec.ts | 15 +++++++++++++++ src/tests/nodes/Comments.spec.ts | 19 ++++++++++--------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/markdownit/comments.ts b/src/markdownit/comments.ts index d76a3b27205..f8a8ee880dd 100644 --- a/src/markdownit/comments.ts +++ b/src/markdownit/comments.ts @@ -305,14 +305,11 @@ function extractMetadata(inline: Token): Metadata { authorLabel = mention.label || '' children.shift() } else if (first?.type === 'text') { - // Guest mention - const match = first.content.match(/^@([^\s*]+)/) + // Guest mention: the text token runs up to the timestamp or the end of the line + const match = first.content.match(/^@(\S.*?)\s*$/) if (match) { authorLabel = match[1] - first.content = first.content.slice(match[0].length) - if (!first.content) { - children.shift() - } + children.shift() } } diff --git a/src/tests/markdownit/comments.spec.ts b/src/tests/markdownit/comments.spec.ts index 03cec48d5aa..d57fb774c91 100644 --- a/src/tests/markdownit/comments.spec.ts +++ b/src/tests/markdownit/comments.spec.ts @@ -56,6 +56,21 @@ describe('comments (markdown-it)', () => { + '
\n') }) + it('guest comment with multi-word name', () => { + const md = 'The quick[^comment-1] brown fox.\n\n' + + '[^comment-1]:\n' + + ' - @Alma Lauer *(2026-09-14T12:51:39.978Z)*\n' + + ' Guest reply\n' + expect(markdownit.render(md)).to.eq('

The quick brown fox.

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

Guest reply

\n' + + '
\n' + + '
\n' + + '
\n') + }) + it('comment without metadata', () => { const md = 'Foo[^comment-1] bar\n\n' + '[^comment-1]:\n' diff --git a/src/tests/nodes/Comments.spec.ts b/src/tests/nodes/Comments.spec.ts index 64d2f895e02..f156a20b614 100644 --- a/src/tests/nodes/Comments.spec.ts +++ b/src/tests/nodes/Comments.spec.ts @@ -430,17 +430,11 @@ describe('Comments Markdown roundtrip', () => { 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' + const test = 'Foo[^comment-1]\n\n' + '[^comment-1]:\n' - + ' - @jane\n' - + ' xyz\n' + + ' - @jane doe\n' + ' Hello there' - expect(markdownThroughEditor(testIn)).toBe(testOut) - expect(markdownThroughEditor(testOut)).toBe(testOut) + expect(markdownThroughEditor(test)).toBe(test) }) test('idempotent through round-trip with broken metadata #3', ({ markdownThroughEditor }) => { const testIn = 'Foo[^comment-1]\n\n' @@ -469,4 +463,11 @@ describe('Comments Markdown roundtrip', () => { expect(markdownThroughEditor(testIn2)).toBe(testOut) expect(markdownThroughEditor(testOut)).toBe(testOut) }) + test('guest comment with multi-word name', ({ markdownThroughEditor }) => { + const test = 'Foo[^comment-1]\n\n' + + '[^comment-1]:\n' + + ' - @Alma Lauer *(2026-09-14T12:51:39.978Z)*\n' + + ' Guest reply' + expect(markdownThroughEditor(test)).toBe(test) + }) }) From e3abf42262dec6876bdade5547dc61efb671f324 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 24 Sep 2026 15:57:18 +0200 Subject: [PATCH 6/9] fix(annotations): keep undo history intact on cleanup The cleanup plugins for comments and footnotes appended transactions marked as `addToHistory: alse`. ProseeMirror applies the user's transaction and the appended ones as one batch and y-prosemirror trakes the `addToHistory` flag of the last transaction in that batch. This led to broken undo history. Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5.1 --- src/nodes/Comments.ts | 1 - src/nodes/Footnotes.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/nodes/Comments.ts b/src/nodes/Comments.ts index 0804d548e49..4c934020e24 100644 --- a/src/nodes/Comments.ts +++ b/src/nodes/Comments.ts @@ -96,7 +96,6 @@ const Comments = 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/nodes/Footnotes.ts b/src/nodes/Footnotes.ts index e8dfbb06687..32d34309302 100644 --- a/src/nodes/Footnotes.ts +++ b/src/nodes/Footnotes.ts @@ -72,7 +72,6 @@ const Footnotes = Node.create({ if (!hasChange) { return null } - tr.setMeta('addToHistory', false) return tr }, }), @@ -138,7 +137,6 @@ const Footnotes = Node.create({ for (const del of deletions) { tr.delete(del.pos, del.pos + del.size) } - tr.setMeta('addToHistory', false) return tr }, }), From 29d9e912f463339365e55bb5bccda0330fed6840 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 24 Sep 2026 16:29:20 +0200 Subject: [PATCH 7/9] fix(comments): better comments navigation UX * Header: "< Comment 3 of 4 >" instead of "Comments ^ 3 / 4 v" * Allow to navigate comments with keyboard shortcuts Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5.1 --- playwright/e2e/comments.spec.ts | 26 +++++++++ src/components/Comment/CommentBubbleView.vue | 59 ++++++++++++++------ src/extensions/CommentBubble.ts | 7 +++ 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/playwright/e2e/comments.spec.ts b/playwright/e2e/comments.spec.ts index 752da720d77..07d387c0570 100644 --- a/playwright/e2e/comments.spec.ts +++ b/playwright/e2e/comments.spec.ts @@ -158,3 +158,29 @@ test.describe('toggles annotations', () => { await expect(editor.commentReferences.first()).toBeVisible() }) }) + +test.describe('navigates between comments', () => { + test.use({ + fileContent: 'The quick[^comment-1] brown[^comment-2] fox.\n\n' + + '[^comment-1]:\n' + + ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n' + + ' First comment\n\n' + + '[^comment-2]:\n' + + ' - @[bob](mention://user/bob) *(2026-07-16T13:13Z)*\n' + + ' Second comment\n', + }) + + test('shows the position and moves with buttons and shortcut', async ({ open, editor }) => { + await open() + await editor.getCommentReference('comment-1').click() + await expect(editor.commentBubble).toContainText('Comment 1 of 2') + await expect(editor.commentBubble).toContainText('First comment') + + await editor.commentBubble.getByRole('button', { name: 'Next comment' }).click() + await expect(editor.commentBubble).toContainText('Comment 2 of 2') + await expect(editor.commentBubble).toContainText('Second comment') + + await editor.commentBubble.press('ControlOrMeta+Alt+ArrowLeft') + await expect(editor.commentBubble).toContainText('Comment 1 of 2') + }) +}) diff --git a/src/components/Comment/CommentBubbleView.vue b/src/components/Comment/CommentBubbleView.vue index a98af0c2fcf..a3f97c290e3 100644 --- a/src/components/Comment/CommentBubbleView.vue +++ b/src/components/Comment/CommentBubbleView.vue @@ -7,27 +7,30 @@
+ @keydown="onKeyDown">
- {{ t('text', 'Comments') }} -
+
- {{ commentPosition }} / {{ commentCount }} + {{ title }}
@@ -169,14 +172,15 @@ import NcDateTime from '@nextcloud/vue/components/NcDateTime' import NcRichContenteditable from '@nextcloud/vue/components/NcRichContenteditable' import NcTextField from '@nextcloud/vue/components/NcTextField' import CheckIcon from 'vue-material-design-icons/Check.vue' -import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue' -import ChevronUpIcon from 'vue-material-design-icons/ChevronUp.vue' +import ChevronLeftIcon from 'vue-material-design-icons/ChevronLeft.vue' +import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue' import CloseIcon from 'vue-material-design-icons/Close.vue' import DeleteIcon from 'vue-material-design-icons/Delete.vue' import PencilIcon from 'vue-material-design-icons/Pencil.vue' import { useGuestName } from '../../composables/useGuestName.ts' import { createMarkdownSerializer } from '../../extensions/Markdown.ts' import { commentDraftPrefix } from '../../plugins/referenceHelpers.ts' +import { MODIFIERS, TRANSLATIONS } from '../Menu/keys.js' const props = defineProps<{ editor: Editor @@ -200,6 +204,14 @@ const commentCount = computed(() => commentRefIds.value.length) const commentPosition = computed(() => commentRefIds.value.indexOf(props.referenceId) + 1) const commentNode = computed(() => commentNodesMap.value[props.referenceId] ?? null) +const title = computed(() => (commentCount.value > 1 + ? t('text', 'Comment {position} of {count}', { position: commentPosition.value, count: commentCount.value }) + : t('text', 'Comment'))) + +const shortcutPrefix = `${TRANSLATIONS[MODIFIERS.Mod]} + ${TRANSLATIONS[MODIFIERS.Alt]} + ` +const previousTitle = t('text', 'Previous comment ({shortcut})', { shortcut: shortcutPrefix + '←' }) +const nextTitle = t('text', 'Next comment ({shortcut})', { shortcut: shortcutPrefix + '→' }) + const editingItemIndex = ref(null) const editText = ref('') @@ -397,6 +409,25 @@ async function submitGuestName() { function closeAndRefocus() { props.editor.commands.hideCommentBubble({ refocus: true }) } + +function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + closeAndRefocus() + return + } + if (!(event.ctrlKey || event.metaKey) || !event.altKey) { + return + } + if (event.key === 'ArrowLeft') { + event.preventDefault() + navigate('prev') + } else if (event.key === 'ArrowRight') { + event.preventDefault() + navigate('next') + } +}