Skip to content
50 changes: 42 additions & 8 deletions playwright/e2e/comments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,48 @@ test.describe('deletes last comment reply', () => {
})
})

test('hides and shows comment references via annotations toggle', async ({ editor, open }) => {
await open()
await editor.type('Test[?]')
await expect(editor.commentReferences.first()).toBeVisible()
test.describe('toggles annotations', () => {
test.use({
fileContent: 'Test[^comment-1]\n\n'
+ '[^comment-1]:\n'
+ ' - @[jane](mention://user/jane) *(2026-0716T13:12Z)*\n'
+ ' Comment by Jane\n',
})
test('hides and shows comment references via annotations toggle', async ({ editor, open }) => {
await open()
await expect(editor.commentReferences.first()).toBeVisible()

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

await editor.clickMenu('Annotations', 'Show annotations')
await expect(editor.commentReferences.first()).toBeVisible()
await editor.clickMenu('Annotations', 'Show annotations')
await expect(editor.commentReferences.first()).toBeVisible()
})
})

test.describe('navigates between comments', () => {
test.use({
fileContent: 'The quick[^comment-1] brown[^comment-2] fox.\n\n'
+ '[^comment-1]:\n'
+ ' - @[jane](mention://user/jane) *(2026-07-16T13:12Z)*\n'
+ ' First comment\n\n'
+ '[^comment-2]:\n'
+ ' - @[bob](mention://user/bob) *(2026-07-16T13:13Z)*\n'
+ ' Second comment\n',
})

test('shows the position and moves with buttons and shortcut', async ({ open, editor }) => {
await open()
await editor.getCommentReference('comment-1').click()
await expect(editor.commentBubble).toContainText('Comment 1 of 2')
await expect(editor.commentBubble).toContainText('First comment')

await editor.commentBubble.getByRole('button', { name: 'Next comment' }).click()
await expect(editor.commentBubble).toContainText('Comment 2 of 2')
await expect(editor.commentBubble).toContainText('Second comment')

await editor.commentBubble.press('ControlOrMeta+Alt+ArrowLeft')
await expect(editor.commentBubble).toContainText('Comment 1 of 2')
})
})
77 changes: 48 additions & 29 deletions src/components/Comment/CommentBubbleView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,30 @@
<div
:id="`comment-bubble-${referenceId}`"
class="comment-bubble"
@keydown.escape.prevent.stop="closeAndRefocus">
@keydown="onKeyDown">
<div class="comment-bubble__header">
<span class="comment-bubble__title">{{ t('text', 'Comments') }}</span>
<div v-if="commentCount > 1" class="comment-bubble__nav">
<div class="comment-bubble__nav">
<NcButton
v-if="commentCount > 1"
variant="tertiary"
size="small"
:title="t('text', 'Previous comment')"
:aria-label="t('text', 'Previous comment')"
:title="previousTitle"
@click="navigate('prev')">
<template #icon>
<ChevronUpIcon :size="16" />
<ChevronLeftIcon :size="16" />
</template>
</NcButton>
<span class="comment-bubble__nav-position">{{ commentPosition }} / {{ commentCount }}</span>
<span class="comment-bubble__title">{{ title }}</span>
<NcButton
v-if="commentCount > 1"
variant="tertiary"
size="small"
:title="t('text', 'Next comment')"
:aria-label="t('text', 'Next comment')"
:title="nextTitle"
@click="navigate('next')">
<template #icon>
<ChevronDownIcon :size="16" />
<ChevronRightIcon :size="16" />
</template>
</NcButton>
</div>
Expand Down Expand Up @@ -169,24 +172,24 @@ import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcRichContenteditable from '@nextcloud/vue/components/NcRichContenteditable'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import CheckIcon from 'vue-material-design-icons/Check.vue'
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUpIcon from 'vue-material-design-icons/ChevronUp.vue'
import ChevronLeftIcon from 'vue-material-design-icons/ChevronLeft.vue'
import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue'
import CloseIcon from 'vue-material-design-icons/Close.vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
import PencilIcon from 'vue-material-design-icons/Pencil.vue'
import { useGuestName } from '../../composables/useGuestName.ts'
import { createMarkdownSerializer } from '../../extensions/Markdown.ts'
import { getCommentDraft, removeCommentDraft, setCommentDraft } from '../../plugins/referenceHelpers.ts'
import { MODIFIERS, TRANSLATIONS } from '../Menu/keys.js'

const props = defineProps<{
editor: Editor
referenceId: string
}>()

const DRAFT_KEY_PREFIX = 'text-comment-draft-'

// References used in template
const itemsContainer = ref<HTMLElement | null>(null)
const replyText = ref(sessionStorage.getItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`) ?? '')
const replyText = ref(getCommentDraft(props.referenceId))
const editInput = ref<InstanceType<typeof NcRichContenteditable>[] | null>(null)
const replyInput = ref<InstanceType<typeof NcRichContenteditable> | null>(null)
const userData = ref<Record<string, object>>({})
Expand All @@ -201,6 +204,14 @@ const commentCount = computed(() => commentRefIds.value.length)
const commentPosition = computed(() => commentRefIds.value.indexOf(props.referenceId) + 1)
const commentNode = computed<Node | null>(() => commentNodesMap.value[props.referenceId] ?? null)

const title = computed(() => (commentCount.value > 1
? t('text', 'Comment {position} of {count}', { position: commentPosition.value, count: commentCount.value })
: t('text', 'Comment')))

const shortcutPrefix = `${TRANSLATIONS[MODIFIERS.Mod]} + ${TRANSLATIONS[MODIFIERS.Alt]} + `
const previousTitle = t('text', 'Previous comment ({shortcut})', { shortcut: shortcutPrefix + '←' })
const nextTitle = t('text', 'Next comment ({shortcut})', { shortcut: shortcutPrefix + '→' })

const editingItemIndex = ref<number | null>(null)
const editText = ref('')

Expand Down Expand Up @@ -240,17 +251,12 @@ const { setGuestName } = useGuestName(props.editor)

// Persist draft as user types
watch(replyText, (val) => {
const key = `${DRAFT_KEY_PREFIX}${props.referenceId}`
if (val.trim()) {
sessionStorage.setItem(key, val)
} else {
sessionStorage.removeItem(key)
}
setCommentDraft(props.referenceId, val)
})

// Restore draft when bubble opens or switches to a different comment
watch(() => props.referenceId, (id) => {
replyText.value = sessionStorage.getItem(`${DRAFT_KEY_PREFIX}${id}`) ?? ''
replyText.value = getCommentDraft(id)
})

// Focus input field when switching between comment references.
Expand Down Expand Up @@ -315,7 +321,7 @@ function submitReply() {
return
}
props.editor.commands.addOrUpdateCommentReply(commentNode.value, replyText.value.trim())
sessionStorage.removeItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`)
removeCommentDraft(props.referenceId)
replyText.value = ''
nextTick(() => {
if (itemsContainer.value) {
Expand Down Expand Up @@ -374,7 +380,7 @@ function deleteItem(index: number) {
return
}
if (items.value.length === 1) {
sessionStorage.removeItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`)
removeCommentDraft(props.referenceId)
}
props.editor.commands.deleteCommentReply(commentNode.value, index)
}
Expand All @@ -398,6 +404,25 @@ async function submitGuestName() {
function closeAndRefocus() {
props.editor.commands.hideCommentBubble({ refocus: true })
}

function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeAndRefocus()
return
}
if (!(event.ctrlKey || event.metaKey) || !event.altKey) {
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
navigate('prev')
} else if (event.key === 'ArrowRight') {
event.preventDefault()
navigate('next')
}
}
</script>

<style scoped lang="scss">
Expand Down Expand Up @@ -426,19 +451,13 @@ function closeAndRefocus() {
font-weight: bold;
font-size: 0.9em;
color: var(--color-text-maxcontrast);
white-space: nowrap;
}

&__nav {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
margin-inline-start: auto;
}

&__nav-position {
font-size: 0.8em;
color: var(--color-text-maxcontrast);
white-space: nowrap;
}

&__items {
Expand Down
2 changes: 1 addition & 1 deletion src/css/prosemirror.scss
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ div.ProseMirror {
}

&.is-active {
background-color: var(--color-primary-element-light-text);
background-color: var(--color-primary-element-light);
border-radius: var(--border-radius);
}
}
Expand Down
30 changes: 25 additions & 5 deletions src/extensions/CommentBubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

import type { CommandProps } from '@tiptap/core'

import { getCurrentUser } from '@nextcloud/auth'
import { Extension } from '@tiptap/core'
import { commentBubble, commentBubbleKey, hideCommentBubble, navigateCommentBubble, openCommentBubble } from '../plugins/commentBubble.ts'
import { findComment, getCommentDraft, isEmptyComment } from '../plugins/referenceHelpers.ts'

declare module '@tiptap/core' {
interface Commands<ReturnType> {
Expand All @@ -28,16 +30,27 @@ const CommentBubble = Extension.create({
return openCommentBubble(referenceId)(state, dispatch)
}
},
hideCommentBubble: (options?: { refocus?: boolean }) => ({ state, dispatch, chain }) => {
hideCommentBubble: (options?: { refocus?: boolean }) => ({ state, dispatch, chain, commands }) => {
const pluginState = commentBubbleKey.getState(state)
const active = pluginState?.active
const result = hideCommentBubble(state, dispatch)
if (!result) {
if (!result || !active) {
return result
}
if (options?.refocus && active) {
const node = state.doc.nodeAt(active.nodeStart)
const cursorPos = active.nodeStart + (node?.nodeSize ?? 1)

const refNode = state.doc.nodeAt(active.nodeStart)
let cursorPos = active.nodeStart + (refNode?.nodeSize ?? 1)

// Discard a comment that was created but never submitted
const comment = findComment(state.doc, active.referenceId)
const currentUserId = getCurrentUser()?.uid ?? ''
const hasDraft = !!getCommentDraft(active.referenceId)
if (comment && isEmptyComment(comment) && comment.firstChild!.attrs.author === currentUserId && !hasDraft) {
commands.deleteCommentReply(comment, 0)
cursorPos = active.nodeStart
}

if (options?.refocus) {
chain().setTextSelection(cursorPos).focus().run()
}
return true
Expand All @@ -50,6 +63,13 @@ const CommentBubble = Extension.create({
}
},

addKeyboardShortcuts() {
return {
'Mod-Alt-ArrowLeft': () => this.editor.commands.navigateCommentBubble('prev'),
'Mod-Alt-ArrowRight': () => this.editor.commands.navigateCommentBubble('next'),
}
},

addProseMirrorPlugins() {
return [commentBubble({ editor: this.editor })]
},
Expand Down
9 changes: 3 additions & 6 deletions src/markdownit/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,11 @@ function extractMetadata(inline: Token): Metadata {
authorLabel = mention.label || ''
children.shift()
} else if (first?.type === 'text') {
// Guest mention
const match = first.content.match(/^@([^\s*]+)/)
// Guest mention: the text token runs up to the timestamp or the end of the line
const match = first.content.match(/^@(\S.*?)\s*$/)
if (match) {
authorLabel = match[1]
first.content = first.content.slice(match[0].length)
if (!first.content) {
children.shift()
}
children.shift()
}
}

Expand Down
Loading
Loading