diff --git a/playwright/e2e/comments.spec.ts b/playwright/e2e/comments.spec.ts index 74b37ca25f0..07d387c0570 100644 --- a/playwright/e2e/comments.spec.ts +++ b/playwright/e2e/comments.spec.ts @@ -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') + }) }) diff --git a/src/components/Comment/CommentBubbleView.vue b/src/components/Comment/CommentBubbleView.vue index 341780d4702..1967c4440e0 100644 --- a/src/components/Comment/CommentBubbleView.vue +++ b/src/components/Comment/CommentBubbleView.vue @@ -7,27 +7,30 @@
+ @keydown="onKeyDown">
- {{ t('text', 'Comments') }} -
+
- {{ commentPosition }} / {{ commentCount }} + {{ title }}
@@ -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(null) -const replyText = ref(sessionStorage.getItem(`${DRAFT_KEY_PREFIX}${props.referenceId}`) ?? '') +const replyText = ref(getCommentDraft(props.referenceId)) const editInput = ref[] | null>(null) const replyInput = ref | null>(null) const userData = ref>({}) @@ -201,6 +204,14 @@ const commentCount = computed(() => commentRefIds.value.length) const commentPosition = computed(() => commentRefIds.value.indexOf(props.referenceId) + 1) const commentNode = computed(() => 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(null) const editText = ref('') @@ -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. @@ -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) { @@ -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) } @@ -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') + } +}