Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f64ecb4
feat(markdownit): add comments plugin
mejo- Jul 15, 2026
f65d6e6
feat(editor): add Tiptap nodes for comments support
mejo- Jul 16, 2026
04c17ba
fix(FloatingButtons): don't display for comment nodes
mejo- Jul 20, 2026
bd8d045
chore(footnotes): move helper functions into a separate file
mejo- Jul 20, 2026
b530956
chore(referenceHelpers): rework functions to be usable with comments
mejo- Jul 20, 2026
3edd0b3
feat(comments): add `insertComment` Tiptap command
mejo- Jul 20, 2026
2db4a93
feat(HelpModal): document comments syntax and shortcut
mejo- Jul 20, 2026
cac1118
feat(comments): add plugins and views to display comment threads
mejo- Jul 20, 2026
4c74af2
feat(comments): add menu entry and allow to hide annotations
mejo- Jul 20, 2026
17a0b6a
feat(comments): add support for inserting comment item body
mejo- Jul 21, 2026
d0c3516
fix(CommentBubbleView): improve layout with long comment threads
mejo- Jul 21, 2026
a5b8dd0
fix(CommentBubble): place bubble to the right of the editor container
mejo- Jul 21, 2026
d7970b7
feat(CommentBubble): support rich editing in comments
mejo- Jul 21, 2026
4e3d97a
feat(comments): allow to edit comment replies
mejo- Jul 22, 2026
aea3775
feat(comments): prompt for nick before guest can comment
mejo- Jul 22, 2026
ea0f10e
fix(comments): add " (guest)" to displayed author if a guest comment
mejo- Jul 22, 2026
e25146b
fix(comments): hide comments editing UI when editor is readonly
mejo- Jul 22, 2026
21ef544
test(playwright): test comments feature
mejo- Jul 22, 2026
22da11d
fix(comments): persist reply draft across bubble close+open
mejo- Jul 22, 2026
be55ec2
feat(comments): allow to delete comment replies
mejo- Jul 22, 2026
e520608
fix(comments): fix editor.can() with insertComment command
mejo- Jul 22, 2026
439e450
fix(CommentBubble): focus input fields on open
mejo- Jul 22, 2026
d2cd070
fix(CommentReference): don't use openCommentBubble command
mejo- Jul 23, 2026
d2b9938
fix(CommentBubbleView): fix detecting empty single comment
mejo- Jul 30, 2026
cc85ff7
chore(comment): remove old commented out code line
mejo- Jul 30, 2026
08679f7
fix(CommentBubbleView): don't let input field grow infinitively
mejo- Jul 30, 2026
b4afc1e
fix(CommentBubble): use `left-start` placement
mejo- Jul 30, 2026
3d9647e
feat(comments): highlight reference of open comment bubble
mejo- Jul 30, 2026
353755d
feat(comments): add navigation to jump to prev/next comment
mejo- Jul 30, 2026
1f26d16
fix(CommentBubbleView): allow deleting only for guests with name
mejo- Jul 30, 2026
8863069
fix(comments): navigate comment references via keyboard
mejo- Jul 30, 2026
0e77b34
fix(css): prevent references from extending line height
mejo- Jul 30, 2026
b44c049
fix(comments): close bubble with Esc and refocus reference
mejo- Jul 30, 2026
b3a9bb5
fix(guestName): add useGuestName composable and use it
mejo- Jul 30, 2026
d64ea4d
fix(comments): don't let references change line height
mejo- Jul 30, 2026
543e364
chore(CommentBubbleView): move edit and delete into three-dot-menu
mejo- Jul 30, 2026
c13c4d2
chore(CommentBubbleView): limit default height of input field
mejo- Jul 30, 2026
f294ec7
chore(CommentBubbleView): animate adding new comment reply
mejo- Jul 30, 2026
70e5ead
fix(comments): cleanup stale drafts from session storage
mejo- Jul 30, 2026
4360704
fix(comments): idempotent markdown on unexpected syntax
mejo- Aug 3, 2026
8677de6
fix(CommentBubblePluginView): return early if state didn't change
mejo- Aug 3, 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