Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
12b428c
feat(markdownit): add comments plugin
mejo- Jul 15, 2026
fea01d6
feat(editor): add Tiptap nodes for comments support
mejo- Jul 16, 2026
c8bc244
fix(FloatingButtons): don't display for comment nodes
mejo- Jul 20, 2026
98c9640
chore(footnotes): move helper functions into a separate file
mejo- Jul 20, 2026
154cdca
chore(referenceHelpers): rework functions to be usable with comments
mejo- Jul 20, 2026
d8d02c8
feat(comments): add `insertComment` Tiptap command
mejo- Jul 20, 2026
34b8c3b
feat(HelpModal): document comments syntax and shortcut
mejo- Jul 20, 2026
5451f1a
feat(comments): add plugins and views to display comment threads
mejo- Jul 20, 2026
e1fca1a
feat(comments): add menu entry and allow to hide annotations
mejo- Jul 20, 2026
d807ac9
feat(comments): add support for inserting comment item body
mejo- Jul 21, 2026
b4d7c66
fix(CommentBubbleView): improve layout with long comment threads
mejo- Jul 21, 2026
6879845
fix(CommentBubble): place bubble to the right of the editor container
mejo- Jul 21, 2026
cc22faf
feat(CommentBubble): support rich editing in comments
mejo- Jul 21, 2026
bf5f459
feat(comments): allow to edit comment replies
mejo- Jul 22, 2026
5d90b51
feat(comments): prompt for nick before guest can comment
mejo- Jul 22, 2026
85ccf22
fix(comments): add " (guest)" to displayed author if a guest comment
mejo- Jul 22, 2026
ab3170d
fix(comments): hide comments editing UI when editor is readonly
mejo- Jul 22, 2026
35d7b00
test(playwright): test comments feature
mejo- Jul 22, 2026
266ec75
fix(comments): persist reply draft across bubble close+open
mejo- Jul 22, 2026
ab90bcd
feat(comments): allow to delete comment replies
mejo- Jul 22, 2026
9fc72c3
fix(comments): fix editor.can() with insertComment command
mejo- Jul 22, 2026
7b32665
fix(CommentBubble): focus input fields on open
mejo- Jul 22, 2026
8eda11e
fix(CommentReference): don't use openCommentBubble command
mejo- Jul 23, 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.1",
"@nextcloud/auth": "^2.6.0",
Expand Down
149 changes: 149 additions & 0 deletions playwright/e2e/comments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* 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: 'Reply' }).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: 'Reply' }).click()
await expect(editor.commentBubble).toContainText('Original text')

// Edit the reply
await editor.commentBubble.getByRole('button', { 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.getByRole('button', { name: 'Delete' }).first().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.getByRole('button', { 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