From 12b428cc255628e02cbca23382fe001d5d967f6e Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 15 Jul 2026 15:47:11 +0200 Subject: [PATCH 01/23] feat(markdownit): add comments plugin The plugin post-processes data from footnotes plugin. As we cannot change the label parsing rules of the footnotes plugin, this seemed to be the most sensible thing to do. Signed-off-by: Jonas Assisted-by: OpenCode:claude-opus-4-8 --- src/markdownit/comments.ts | 379 ++++++++++++++++++++++++++ src/markdownit/index.js | 4 +- src/tests/markdownit/comments.spec.ts | 118 ++++++++ 3 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 src/markdownit/comments.ts create mode 100644 src/tests/markdownit/comments.spec.ts 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/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') + }) +}) From fea01d6ff9c58756846fe75fa4cc1062fcf74752 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 16 Jul 2026 09:36:07 +0200 Subject: [PATCH 02/23] feat(editor): add Tiptap nodes for comments support The comments container is hidden in UI for now. Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- src/css/prosemirror.scss | 9 +- src/extensions/RichText.ts | 8 +- src/nodes/Comment.ts | 67 +++++++++ src/nodes/CommentItem.ts | 50 +++++++ src/nodes/CommentReference.ts | 51 +++++++ src/nodes/Comments.ts | 107 ++++++++++++++ src/nodes/Footnotes.ts | 1 + src/tests/nodes/Comments.spec.ts | 235 +++++++++++++++++++++++++++++++ 8 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 src/nodes/Comment.ts create mode 100644 src/nodes/CommentItem.ts create mode 100644 src/nodes/CommentReference.ts create mode 100644 src/nodes/Comments.ts create mode 100644 src/tests/nodes/Comments.spec.ts diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index ce5d70342ea..d0c2d324dd2 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -424,8 +424,13 @@ 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"] { + a.footnote-ref, a.comment-ref { + font-size: 0.9em; color: var(--color-primary-element); text-decoration: none; diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index 85ce6054b04..93e3f97b2bc 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' @@ -86,7 +87,7 @@ export default Extension.create({ const defaultExtensions = [ Markdown, Document.extend({ - content: 'block+ footnotes?', + content: 'block+ comments? footnotes?', }), Text, Paragraph, @@ -104,6 +105,7 @@ export default Extension.create({ defaultLanguage: 'plaintext', }), Details, + Comments, Footnotes, BulletList, HorizontalRule, @@ -153,12 +155,14 @@ export default Extension.create({ })] : []), TrailingNode.configure({ - notAfter: ['paragraph', 'footnotes'], + notAfter: ['paragraph', 'comments', 'footnotes'], }), TextDirection.configure({ types: [ 'blockquote', 'callout', + 'comment', + 'commentItem', 'detailsSummary', 'footnote', 'heading', diff --git a/src/nodes/Comment.ts b/src/nodes/Comment.ts new file mode 100644 index 00000000000..3d0e98b1d34 --- /dev/null +++ b/src/nodes/Comment.ts @@ -0,0 +1,67 @@ +/** + * 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 || ''}` + const ts = timestamp ? ` *(${timestamp})*` : '' + return `- ${authorMarkdown}${ts}\n` + }) + state.delim = savedDelim + // state.wrapBlock(' ', marker, node, () => state.renderContent(node)) + 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..ff045faf00a --- /dev/null +++ b/src/nodes/CommentReference.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' + +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 }, + 'đź’¬', + ], + ] + }, + + toMarkdown(state, node) { + state.write(`[^${node.attrs.referenceId}]`) + }, +}) + +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/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/tests/nodes/Comments.spec.ts b/src/tests/nodes/Comments.spec.ts new file mode 100644 index 00000000000..74ff27d31d4 --- /dev/null +++ b/src/tests/nodes/Comments.spec.ts @@ -0,0 +1,235 @@ +/** + * 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('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') + }) +}) From c8bc2443f18b3c15a8fa00ae69703cd2f5943478 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 10:15:29 +0200 Subject: [PATCH 03/23] fix(FloatingButtons): don't display for comment nodes Signed-off-by: Jonas --- src/components/Editor/FloatingButtons.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Editor/FloatingButtons.vue b/src/components/Editor/FloatingButtons.vue index 3ddd3756b63..ccc9c20e371 100644 --- a/src/components/Editor/FloatingButtons.vue +++ b/src/components/Editor/FloatingButtons.vue @@ -43,7 +43,7 @@ import DragVerticalIcon from 'vue-material-design-icons/DragVertical.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue' import { useEditor } from '../../composables/useEditor.ts' -const IGNORE_NODES = ['footnote', 'footnotes'] +const IGNORE_NODES = ['comment', 'commentItem', 'comments', 'footnote', 'footnotes'] export default { name: 'FloatingButtons', From 98c96405a7d0b7c4bc9f02cf616bcb1ec1244b2b Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 10:45:45 +0200 Subject: [PATCH 04/23] chore(footnotes): move helper functions into a separate file Signed-off-by: Jonas --- src/nodes/FootnoteReference.ts | 63 +------------------------------ src/plugins/referenceHelpers.ts | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 62 deletions(-) create mode 100644 src/plugins/referenceHelpers.ts diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 6f55581401c..08538a20ae9 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, generateFootnoteId, isInsideFootnote } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -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/plugins/referenceHelpers.ts b/src/plugins/referenceHelpers.ts new file mode 100644 index 00000000000..190b252f862 --- /dev/null +++ b/src/plugins/referenceHelpers.ts @@ -0,0 +1,66 @@ +/** + * 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 footnote + * + * @param state the editor state + */ +export 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 + */ +export function generateFootnoteId(doc: Node): 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 + */ +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 +} From 154cdca0a1e7d0506905e19e7ef9f5c5a70a2ffa Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 11:02:55 +0200 Subject: [PATCH 05/23] chore(referenceHelpers): rework functions to be usable with comments Signed-off-by: Jonas --- src/nodes/FootnoteReference.ts | 4 +-- src/plugins/referenceHelpers.ts | 43 +++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/nodes/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index 08538a20ae9..c1ebba4e3ae 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -5,7 +5,7 @@ import { InputRule, mergeAttributes, Node } from '@tiptap/core' import { Plugin, TextSelection } from '@tiptap/pm/state' -import { footnoteExists, generateFootnoteId, isInsideFootnote } from '../plugins/referenceHelpers.ts' +import { footnoteExists, generateReferenceId, isInsideFootnote } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -76,7 +76,7 @@ const FootnoteReference = Node.create({ const referenceId = options?.referenceId ? String(options.referenceId) - : generateFootnoteId(state.doc) + : generateReferenceId(state.doc, 'footnote') if (!referenceId) { return false } diff --git a/src/plugins/referenceHelpers.ts b/src/plugins/referenceHelpers.ts index 190b252f862..93d7641e8e2 100644 --- a/src/plugins/referenceHelpers.ts +++ b/src/plugins/referenceHelpers.ts @@ -7,14 +7,15 @@ import type { Node } from '@tiptap/pm/model' import type { EditorState } from '@tiptap/pm/state' /** - * Check if selection is inside a footnote + * Check if selection is inside a node type * * @param state the editor state + * @param nodeTypeName the node type name */ -export function isInsideFootnote(state: EditorState): boolean { +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 === 'footnote') { + if ($from.node(d).type.name === nodeTypeName) { return true } } @@ -22,14 +23,42 @@ export function isInsideFootnote(state: EditorState): boolean { } /** - * Get first unused numeric footnote id + * 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 generateFootnoteId(doc: Node): string { +export function generateReferenceId(doc: Node, type: 'comment' | 'footnote'): string { const existing = new Set() doc.descendants((node) => { - if (node.type.name === 'footnoteReference' || node.type.name === 'footnote') { + if (node.type.name === type + 'Reference' || node.type.name === type) { const id = node.attrs.referenceId if (id) { existing.add(String(id)) @@ -37,7 +66,7 @@ export function generateFootnoteId(doc: Node): string { } }) for (let i = 1; i < 10_000; i++) { - const candidate = String(i) + const candidate = type === 'comment' ? 'comment-' + String(i) : String(i) if (!existing.has(candidate)) { return candidate } From d8d02c8ebff921dbc8c6864ca804e65840fad77d Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 11:04:27 +0200 Subject: [PATCH 06/23] feat(comments): add `insertComment` Tiptap command Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- src/nodes/CommentReference.ts | 99 +++++++++++++++++++++++++++++++- src/nodes/FootnoteReference.ts | 6 +- src/tests/nodes/Comments.spec.ts | 75 ++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/nodes/CommentReference.ts b/src/nodes/CommentReference.ts index ff045faf00a..e1326df174a 100644 --- a/src/nodes/CommentReference.ts +++ b/src/nodes/CommentReference.ts @@ -3,7 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { mergeAttributes, Node } from '@tiptap/core' +import { getCurrentUser } from '@nextcloud/auth' +import { InputRule, mergeAttributes, Node } from '@tiptap/core' +import { generateReferenceId, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' + +declare module '@tiptap/core' { + interface Commands { + commentReference: { + insertComment: () => ReturnType + } + } +} const CommentReference = Node.create({ name: 'commentReference', @@ -46,6 +56,93 @@ const CommentReference = Node.create({ toMarkdown(state, node) { state.write(`[^${node.attrs.referenceId}]`) }, + + addCommands() { + return { + insertComment: () => ({ state, chain }) => { + if (isInsideCommentOrFootnote(state)) { + return false + } + + const referenceId = generateReferenceId(state.doc, 'comment') + if (!referenceId) { + return false + } + + 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()], + }) + } + + return c + .focus() + .scrollIntoView() + .run() + }, + } + }, + + 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/FootnoteReference.ts b/src/nodes/FootnoteReference.ts index c1ebba4e3ae..100bfccbb61 100644 --- a/src/nodes/FootnoteReference.ts +++ b/src/nodes/FootnoteReference.ts @@ -5,7 +5,7 @@ import { InputRule, mergeAttributes, Node } from '@tiptap/core' import { Plugin, TextSelection } from '@tiptap/pm/state' -import { footnoteExists, generateReferenceId, isInsideFootnote } from '../plugins/referenceHelpers.ts' +import { footnoteExists, generateReferenceId, isInsideCommentOrFootnote } from '../plugins/referenceHelpers.ts' declare module '@tiptap/core' { interface Commands { @@ -70,7 +70,7 @@ const FootnoteReference = Node.create({ addCommands() { return { insertFootnote: (options?: { referenceId?: string }) => ({ state, chain }) => { - if (isInsideFootnote(state)) { + if (isInsideCommentOrFootnote(state)) { return false } @@ -146,7 +146,7 @@ const FootnoteReference = Node.create({ new InputRule({ find: /\[\^\]$/, handler: ({ state, range, chain }) => { - if (isInsideFootnote(state)) { + if (isInsideCommentOrFootnote(state)) { return null } diff --git a/src/tests/nodes/Comments.spec.ts b/src/tests/nodes/Comments.spec.ts index 74ff27d31d4..a26bd338b49 100644 --- a/src/tests/nodes/Comments.spec.ts +++ b/src/tests/nodes/Comments.spec.ts @@ -181,6 +181,81 @@ describe('Comments cleanup', () => { }) }) +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' From 34b8c3b3c44aa878986db17f3efb7a0d31c55924 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 15:32:24 +0200 Subject: [PATCH 07/23] feat(HelpModal): document comments syntax and shortcut Signed-off-by: Jonas --- src/components/HelpModal.vue | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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') }} From 5451f1a1f82357ab087bea351eb07e429905ed92 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 15:33:33 +0200 Subject: [PATCH 08/23] feat(comments): add plugins and views to display comment threads Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- package-lock.json | 1 + package.json | 1 + src/components/Comment/CommentBubbleView.vue | 154 +++++++++++++++++++ src/extensions/CommentBubble.ts | 37 +++++ src/extensions/RichText.ts | 2 + src/plugins/CommentBubblePluginView.ts | 124 +++++++++++++++ src/plugins/commentBubble.ts | 104 +++++++++++++ 7 files changed, 423 insertions(+) create mode 100644 src/components/Comment/CommentBubbleView.vue create mode 100644 src/extensions/CommentBubble.ts create mode 100644 src/plugins/CommentBubblePluginView.ts create mode 100644 src/plugins/commentBubble.ts diff --git a/package-lock.json b/package-lock.json index 52fd55eafa6..5faae7b34c5 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.1", "@nextcloud/auth": "^2.6.0", diff --git a/package.json b/package.json index 9f484089d7b..5e0d57200ef 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.1", "@nextcloud/auth": "^2.6.0", diff --git a/src/components/Comment/CommentBubbleView.vue b/src/components/Comment/CommentBubbleView.vue new file mode 100644 index 00000000000..670b0934e52 --- /dev/null +++ b/src/components/Comment/CommentBubbleView.vue @@ -0,0 +1,154 @@ + + + + + + + diff --git a/src/extensions/CommentBubble.ts b/src/extensions/CommentBubble.ts new file mode 100644 index 00000000000..d3ba7d67690 --- /dev/null +++ b/src/extensions/CommentBubble.ts @@ -0,0 +1,37 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Extension } from '@tiptap/core' +import { commentBubble, hideCommentBubble, openCommentBubble } from '../plugins/commentBubble.ts' + +declare module '@tiptap/core' { + interface Commands { + commentBubble: { + openCommentBubble: (referenceId: string, nodeStart: number) => ReturnType + hideCommentBubble: () => ReturnType + } + } +} + +const CommentBubble = Extension.create({ + name: 'commentBubble', + + addCommands() { + return { + openCommentBubble: (referenceId: string, nodeStart: number) => ({ state, dispatch }) => { + return openCommentBubble(referenceId, nodeStart)(state, dispatch) + }, + hideCommentBubble: () => ({ state, dispatch }) => { + return hideCommentBubble(state, dispatch) + }, + } + }, + + addProseMirrorPlugins() { + return [commentBubble({ editor: this.editor })] + }, +}) + +export default CommentBubble diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index 93e3f97b2bc..7bb8b81cbab 100644 --- a/src/extensions/RichText.ts +++ b/src/extensions/RichText.ts @@ -46,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' @@ -149,6 +150,7 @@ export default Extension.create({ openLink: this.options.openLink, }), LinkBubble, + CommentBubble, ...(this.options.editing ? [ Placeholder.configure({ placeholder: t('text', "Start writing or type '/' to add…"), diff --git a/src/plugins/CommentBubblePluginView.ts b/src/plugins/CommentBubblePluginView.ts new file mode 100644 index 00000000000..a106150dde0 --- /dev/null +++ b/src/plugins/CommentBubblePluginView.ts @@ -0,0 +1,124 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Editor } from '@tiptap/core' +import type { Plugin } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' + +import { autoUpdate, computePosition, flip, 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 + #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: absolute; 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 + document.removeEventListener('mousedown', this.#closeOnOutsideClick) + } + + async #position(referenceEl: Element) { + if (!this.#floatingEl) { + return + } + const floating = this.#floatingEl + const container = this.view.dom.parentNode as HTMLElement + if (!container.contains(floating)) { + container.appendChild(floating) + } + + const update = async () => { + const { x, y } = await computePosition(referenceEl, floating, { + placement: 'right', + middleware: [ + offset(8), + flip({ fallbackPlacements: ['top', 'bottom'] }), + shift({ padding: 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) { + const { active } = this.plugin.getState(view.state) + 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) { + 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..60e25486e04 --- /dev/null +++ b/src/plugins/commentBubble.ts @@ -0,0 +1,104 @@ +/** + * 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 + * @param nodeStart - the node start position + */ +export function openCommentBubble(referenceId: string, nodeStart: number): Command { + return (state, dispatch) => { + const active = { referenceId, nodeStart } + if (dispatch) { + dispatch(state.tr.setMeta(commentBubbleKey, { active })) + } + 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 + }, + }, + + view: (view) => new CommentBubblePluginView({ view, options, plugin }), + + props: { + handleClickOn: (view, _pos, _node, _nodePos, event) => { + const link = (event.target as HTMLElement).closest('.comment-ref') + if (!link) { + return false + } + const sup = link.closest('sup[data-type="comment-reference"]') + if (!sup) { + return false + } + const referenceId = (sup as HTMLElement).dataset.referenceId ?? '' + if (!referenceId) { + return false + } + // Find the node position + let nodeStart = -1 + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'commentReference' && node.attrs.referenceId === referenceId) { + nodeStart = pos + return false + } + }) + if (nodeStart === -1) { + return false + } + event.preventDefault() + openCommentBubble(referenceId, nodeStart)(view.state, view.dispatch) + return true + }, + + handleDOMEvents: { + keydown: (view, event) => { + if (event.key === 'Escape') { + return hideCommentBubble(view.state, view.dispatch) + } + }, + }, + }, + }) + return plugin +} From e1fca1aa685ea020a6be38d4e817c51c184ed5fc Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 20 Jul 2026 16:00:32 +0200 Subject: [PATCH 09/23] feat(comments): add menu entry and allow to hide annotations Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- src/components/Editor/ContentContainer.vue | 8 +- src/components/Menu/entries.ts | 91 ++++++++++++++++----- src/components/Menu/utils.js | 4 +- src/components/icons.js | 8 ++ src/composables/useAnnotationsVisibility.ts | 20 +++++ src/css/prosemirror.scss | 8 ++ 6 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 src/composables/useAnnotationsVisibility.ts 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 @@ -->