Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
39cc262
feat(markdownit): add comments plugin
mejo- Jul 15, 2026
9c235b4
feat(editor): add Tiptap nodes for comments support
mejo- Jul 16, 2026
af7eb4a
fix(FloatingButtons): don't display for comment nodes
mejo- Jul 20, 2026
075f21c
chore(footnotes): move helper functions into a separate file
mejo- Jul 20, 2026
2911ef4
chore(referenceHelpers): rework functions to be usable with comments
mejo- Jul 20, 2026
dcb029f
feat(comments): add `insertComment` Tiptap command
mejo- Jul 20, 2026
20624c5
feat(HelpModal): document comments syntax and shortcut
mejo- Jul 20, 2026
3d83c6c
feat(comments): add plugins and views to display comment threads
mejo- Jul 20, 2026
d63568a
feat(comments): add menu entry and allow to hide annotations
mejo- Jul 20, 2026
e7d06f4
feat(comments): add support for inserting comment item body
mejo- Jul 21, 2026
1eb22a4
fix(CommentBubbleView): improve layout with long comment threads
mejo- Jul 21, 2026
fa4a060
fix(CommentBubble): place bubble to the right of the editor container
mejo- Jul 21, 2026
7970986
feat(CommentBubble): support rich editing in comments
mejo- Jul 21, 2026
330afe0
feat(comments): allow to edit comment replies
mejo- Jul 22, 2026
29cdf9f
feat(comments): prompt for nick before guest can comment
mejo- Jul 22, 2026
85cc0ad
fix(comments): add " (guest)" to displayed author if a guest comment
mejo- Jul 22, 2026
25dfd61
fix(comments): hide comments editing UI when editor is readonly
mejo- Jul 22, 2026
95ec7b5
test(playwright): test comments feature
mejo- Jul 22, 2026
83715f0
fix(comments): persist reply draft across bubble close+open
mejo- Jul 22, 2026
8c77e39
feat(comments): allow to delete comment replies
mejo- Jul 22, 2026
0faba92
fix(comments): fix editor.can() with insertComment command
mejo- Jul 22, 2026
3707609
fix(CommentBubble): focus input fields on open
mejo- Jul 22, 2026
d31f077
fix(CommentReference): don't use openCommentBubble command
mejo- Jul 23, 2026
bdfb8a7
fix(CommentBubbleView): fix detecting empty single comment
mejo- Jul 30, 2026
d9bb7b6
chore(comment): remove old commented out code line
mejo- Jul 30, 2026
3959f64
fix(CommentBubbleView): don't let input field grow infinitively
mejo- Jul 30, 2026
adc41c1
fix(CommentBubble): use `left-start` placement
mejo- Jul 30, 2026
2d62703
feat(comments): highlight reference of open comment bubble
mejo- Jul 30, 2026
ad35908
feat(comments): add navigation to jump to prev/next comment
mejo- Jul 30, 2026
d1f5d72
fix(CommentBubbleView): allow deleting only for guests with name
mejo- Jul 30, 2026
8345d26
fix(comments): navigate comment references via keyboard
mejo- Jul 30, 2026
1b1ae11
fix(css): prevent references from extending line height
mejo- Jul 30, 2026
4a80dc4
fix(comments): close bubble with Esc and refocus reference
mejo- Jul 30, 2026
c7d30d1
fix(guestName): add useGuestName composable and use it
mejo- Jul 30, 2026
1bf0074
fix(comments): don't let references change line height
mejo- Jul 30, 2026
05fe5a5
chore(CommentBubbleView): move edit and delete into three-dot-menu
mejo- Jul 30, 2026
04bc910
chore(CommentBubbleView): limit default height of input field
mejo- Jul 30, 2026
eb767bf
chore(CommentBubbleView): animate adding new comment reply
mejo- Jul 30, 2026
04b5da4
fix(comments): cleanup stale drafts from session storage
mejo- Jul 30, 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
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