Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
3d03dd3
feat(markdownit): add comments plugin
mejo- Jul 15, 2026
0d3087e
feat(editor): add Tiptap nodes for comments support
mejo- Jul 16, 2026
5e22c91
fix(FloatingButtons): don't display for comment nodes
mejo- Jul 20, 2026
547c386
chore(footnotes): move helper functions into a separate file
mejo- Jul 20, 2026
aadcec2
chore(referenceHelpers): rework functions to be usable with comments
mejo- Jul 20, 2026
8c64363
feat(comments): add `insertComment` Tiptap command
mejo- Jul 20, 2026
98761c5
feat(HelpModal): document comments syntax and shortcut
mejo- Jul 20, 2026
fb85219
feat(comments): add plugins and views to display comment threads
mejo- Jul 20, 2026
5e386eb
feat(comments): add menu entry and allow to hide annotations
mejo- Jul 20, 2026
57f8d0f
feat(comments): add support for inserting comment item body
mejo- Jul 21, 2026
b5ca6a1
fix(CommentBubbleView): improve layout with long comment threads
mejo- Jul 21, 2026
01748aa
fix(CommentBubble): place bubble to the right of the editor container
mejo- Jul 21, 2026
8cd9773
feat(CommentBubble): support rich editing in comments
mejo- Jul 21, 2026
ffb3f77
feat(comments): allow to edit comment replies
mejo- Jul 22, 2026
f271c78
feat(comments): prompt for nick before guest can comment
mejo- Jul 22, 2026
098815b
fix(comments): add " (guest)" to displayed author if a guest comment
mejo- Jul 22, 2026
8b11030
fix(comments): hide comments editing UI when editor is readonly
mejo- Jul 22, 2026
8711de4
test(playwright): test comments feature
mejo- Jul 22, 2026
8edf76d
fix(comments): persist reply draft across bubble close+open
mejo- Jul 22, 2026
e6f1f25
feat(comments): allow to delete comment replies
mejo- Jul 22, 2026
f9a52c3
fix(comments): fix editor.can() with insertComment command
mejo- Jul 22, 2026
749560d
fix(CommentBubble): focus input fields on open
mejo- Jul 22, 2026
791e67d
fix(CommentReference): don't use openCommentBubble command
mejo- Jul 23, 2026
1ba800c
fix(CommentBubbleView): fix detecting empty single comment
mejo- Jul 30, 2026
c19b84c
chore(comment): remove old commented out code line
mejo- Jul 30, 2026
e7357c5
fix(CommentBubbleView): don't let input field grow infinitively
mejo- Jul 30, 2026
80658a3
fix(CommentBubble): use `left-start` placement
mejo- Jul 30, 2026
d9dd08e
feat(comments): highlight reference of open comment bubble
mejo- Jul 30, 2026
74fc07d
feat(comments): add navigation to jump to prev/next comment
mejo- Jul 30, 2026
571c67f
fix(CommentBubbleView): allow deleting only for guests with name
mejo- Jul 30, 2026
ec6671c
fix(comments): navigate comment references via keyboard
mejo- Jul 30, 2026
7e1e491
fix(css): prevent references from extending line height
mejo- Jul 30, 2026
d76d03d
fix(comments): close bubble with Esc and refocus reference
mejo- Jul 30, 2026
751a8f6
fix(guestName): add useGuestName composable and use it
mejo- Jul 30, 2026
51f99c5
fix(comments): don't let references change line height
mejo- Jul 30, 2026
b7398f4
chore(CommentBubbleView): move edit and delete into three-dot-menu
mejo- Jul 30, 2026
a304c5b
chore(CommentBubbleView): limit default height of input field
mejo- Jul 30, 2026
4eb5247
chore(CommentBubbleView): animate adding new comment reply
mejo- Jul 30, 2026
cd5a9f2
fix(comments): cleanup stale drafts from session storage
mejo- Jul 30, 2026
44942b8
fix(comments): idempotent markdown on unexpected syntax
mejo- Aug 3, 2026
4f8e407
fix(CommentBubblePluginView): return early if state didn't change
mejo- Aug 3, 2026
77bfff6
fix(annotations): move annotations visibility state into editor
mejo- Aug 10, 2026
bf427f2
fix(CommentBubbleView): fix "invalid prop" error with NcActions
mejo- Aug 10, 2026
7cb621b
chore(comments): pass comment node to comment Tiptap commands
mejo- Aug 10, 2026
63ec251
chore(CommentBubbleView): use reactive comment nodes map instead of e…
mejo- Aug 10, 2026
b9c3c70
chore(CommentBubbleView): restructure code order
mejo- Aug 10, 2026
a914ec5
fix(references): make annotationsVisibility extension storage optional
mejo- Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"extends @nextcloud/browserslist-config"
],
"dependencies": {
"@floating-ui/dom": "^1.8.0",
"@mdi/svg": "^7.4.47",
"@mdit/plugin-tex": "^1.0.2",
"@nextcloud/auth": "^2.6.0",
Expand Down
152 changes: 152 additions & 0 deletions playwright/e2e/comments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, mergeTests } from '@playwright/test'
import { test as editorTest } from '../support/fixtures/editor.ts'
import { test as uploadFileTest } from '../support/fixtures/upload-file.ts'

const test = mergeTests(editorTest, uploadFileTest)

test.describe('renders comments from Markdown', () => {
test.use({
fileContent: 'The quick[^comment-1] brown fox.\n\n'
+ '[^comment-1]:\n'
+ ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n'
+ ' Comment by Jane\n',
})

test('shows reference from Markdown', async ({ editor, open }) => {
await open()
await expect(editor.getCommentReference('comment-1')).toBeVisible()
})

test('opens bubble with thread content on click', async ({ editor, open }) => {
await open()
await editor.getCommentReference('comment-1').click()
await expect(editor.commentBubble).toBeVisible()
await expect(editor.commentBubble).toContainText('Comment by Jane')
})

test('closes bubble when close button is clicked', async ({ editor, open }) => {
await open()
await editor.getCommentReference('comment-1').click()
await expect(editor.commentBubble).toBeVisible()
await editor.commentBubble.getByRole('button', { name: 'Close' }).click({ force: true })
await expect(editor.commentBubble).not.toBeVisible()
})
})

test('inserts comment via keyboard shortcut', async ({ editor, open }) => {
await open()
await editor.type('Some text')
await editor.press('ControlOrMeta+Alt+m')
await expect(editor.commentReferences.first()).toBeVisible()
await expect(editor.commentBubble).toBeVisible()
})

test('inserts comment via [?] input rule', async ({ editor, open }) => {
await open()
await editor.type('hello[?]')
await expect(editor.commentReferences.first()).toBeVisible()
await expect(editor.commentBubble).toBeVisible()
})

test('adds a reply to a comment', async ({ editor, open }) => {
await open()
await editor.type('Test[?]')
await expect(editor.commentBubble).toBeVisible()

const composerInput = editor.commentBubble
.locator('.comment-bubble__composer-input [contenteditable]')
await composerInput.fill('My first reply')
await editor.commentBubble.getByRole('button', { name: 'Comment' }).click()
await expect(editor.commentBubble).toContainText('My first reply')
})

test('edits an existing comment', async ({ editor, open }) => {
await open()
await editor.type('Test[?]')
await expect(editor.commentBubble).toBeVisible()

// Submit initial reply
const composerInput = editor.commentBubble
.locator('.comment-bubble__composer-input [contenteditable]')
await composerInput.fill('Original text')
await editor.commentBubble.getByRole('button', { name: 'Comment' }).click()
await expect(editor.commentBubble).toContainText('Original text')

// Edit the reply
await editor.commentBubble.getByLabel('Actions', { exact: true }).click()
await editor.commentBubble.getByRole('menuitem', { name: 'Edit' }).click()
const editInput = editor.commentBubble
.locator('.comment-bubble__body-edit [contenteditable]')
await editInput.clear()
await editInput.fill('Updated text')
await editor.commentBubble.getByRole('button', { name: 'Save' }).click()
await expect(editor.commentBubble).toContainText('Updated text')
await expect(editor.commentBubble).not.toContainText('Original text')
})

test.describe('deletes a comment reply', () => {
test.use({
fileContent: 'Test[^comment-1]\n\n'
+ '[^comment-1]:\n'
+ ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n'
+ ' First reply\n'
+ ' - @[bob](mention://user/bob) *(2026-07-17T11:11Z)*\n'
+ ' Second reply\n',
})

test('deletes one reply from a multi-reply thread', async ({ editor, open }) => {
await open()
await editor.getCommentReference('comment-1').click()
await expect(editor.commentBubble).toContainText('First reply')
await expect(editor.commentBubble).toContainText('Second reply')

// Delete the first reply
await editor.commentBubble.getByLabel('Actions', { exact: true }).first().click()
await editor.commentBubble.getByRole('menuitem', { name: 'Delete' }).click()

await expect(editor.commentBubble).not.toContainText('First reply')
await expect(editor.commentBubble).toContainText('Second reply')

// Reference still in the editor (thread still has one reply)
await expect(editor.getCommentReference('comment-1')).toBeVisible()
})
})

test.describe('deletes last comment reply', () => {
test.use({
fileContent: 'Test[^comment-1]\n\n'
+ '[^comment-1]:\n'
+ ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n'
+ ' Only reply\n',
})

test('removes reference when last reply is deleted', async ({ editor, open }) => {
await open()
await editor.getCommentReference('comment-1').click()
await expect(editor.commentBubble).toContainText('Only reply')

await editor.commentBubble.getByLabel('Actions', { exact: true }).click()
await editor.commentBubble.getByRole('menuitem', { name: 'Delete' }).click()

// Bubble should close and reference should be gone
await expect(editor.commentBubble).not.toBeVisible()
await expect(editor.commentReferences.first()).not.toBeVisible()
})
})

test('hides and shows comment references via annotations toggle', async ({ editor, open }) => {
await open()
await editor.type('Test[?]')
await expect(editor.commentReferences.first()).toBeVisible()

await editor.clickMenu('Annotations', 'Hide annotations')
await expect(editor.commentReferences.first()).toBeHidden()

await editor.clickMenu('Annotations', 'Show annotations')
await expect(editor.commentReferences.first()).toBeVisible()
})
6 changes: 6 additions & 0 deletions playwright/support/sections/EditorSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export class EditorSection {
public readonly details: Locator
public readonly footnoteReferences: Locator
public readonly footnotesSection: Locator
public readonly commentReferences: Locator
public readonly commentBubble: Locator

constructor(public readonly page: Page) {
this.el = this.page.locator('.editor').first()
Expand All @@ -40,6 +42,8 @@ export class EditorSection {
this.details = this.el.locator('div[data-text-el="details"]')
this.footnoteReferences = this.el.locator('sup[data-type="footnote-reference"]')
this.footnotesSection = this.el.locator('section[data-type="footnotes"]')
this.commentReferences = this.el.locator('sup[data-type="comment-reference"]')
this.commentBubble = this.page.locator('.comment-bubble')
}

public async type(keys: string): Promise<void> {
Expand Down Expand Up @@ -85,4 +89,6 @@ export class EditorSection {

getFootnoteReference = (id: string) => this.footnoteReferences.locator(`:scope[data-reference-id="${id}"]`)
getFootnote = (id: string) => this.footnotesSection.locator(`[data-reference-id="${id}"]`)

getCommentReference = (id: string) => this.commentReferences.locator(`:scope[data-reference-id="${id}"]`)
}
Loading
Loading