From 82975f9bd2817439317dd7c33b08229e2a39cc00 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 19 May 2026 19:26:28 +0200 Subject: [PATCH] feat(comments): reply action with prefilled quote and jump-to-original chevron Each rendered comment gets a "Reply" action (shown whenever the viewer has write access, regardless of authorship). Clicking it prefills the comment editor with a
wrapping the parent body so the canonical reply marker is the blockquote itself. A Vue NodeView on the blockquote extension renders an author header + chevron when an injected commentReplyContext can resolve the parent. The chevron scrolls to and briefly highlights the original. Quotes whose parent isn't in the in-memory list (deleted, on another page) render a degraded header with the chevron hidden. --- .../input/editor/BlockquoteCommentView.vue | 161 ++++++++++++++++++ .../src/components/input/editor/TipTap.vue | 18 ++ .../input/editor/blockquoteWithCommentId.ts | 12 ++ .../components/tasks/partials/Comments.vue | 89 ++++++++-- .../tasks/partials/commentReplyContext.ts | 26 +++ frontend/src/i18n/lang/en.json | 5 +- 6 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/input/editor/BlockquoteCommentView.vue create mode 100644 frontend/src/components/tasks/partials/commentReplyContext.ts diff --git a/frontend/src/components/input/editor/BlockquoteCommentView.vue b/frontend/src/components/input/editor/BlockquoteCommentView.vue new file mode 100644 index 000000000..587a71144 --- /dev/null +++ b/frontend/src/components/input/editor/BlockquoteCommentView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index 1c8ddbfd4..70ac31e91 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -778,6 +778,24 @@ function setModeAndValue(value: string) { }) } +// Replace the editor content with a reply draft (prefilled blockquote + empty +// paragraph) and enter edit mode immediately so the user can start typing. +// Returns synchronously after the next tick to let DOM updates settle. +async function setReplyContent(value: string) { + if (!editor.value) return + editor.value.commands.setContent(value, { + ...defaultSetContentOptions, + emitUpdate: false, + }) + internalMode.value = 'edit' + modelValue.value = editor.value.getHTML() + contentHasChanged.value = true + await nextTick() + editor.value.commands.focus('end') +} + +defineExpose({setReplyContent}) + // See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660 function setFocusToEditor(event: KeyboardEvent) { diff --git a/frontend/src/components/input/editor/blockquoteWithCommentId.ts b/frontend/src/components/input/editor/blockquoteWithCommentId.ts index 9875f4bd5..87e88ac28 100644 --- a/frontend/src/components/input/editor/blockquoteWithCommentId.ts +++ b/frontend/src/components/input/editor/blockquoteWithCommentId.ts @@ -1,4 +1,7 @@ import Blockquote from '@tiptap/extension-blockquote' +import {VueNodeViewRenderer} from '@tiptap/vue-3' + +import BlockquoteCommentView from './BlockquoteCommentView.vue' /** * Blockquote extension that preserves `data-comment-id` across parse/serialize. @@ -6,6 +9,11 @@ import Blockquote from '@tiptap/extension-blockquote' * stores the referenced comment's id on the wrapping blockquote, so both the * backend (for implicit-mention notifications) and the frontend (for the * jump-to-original chevron) can find it without a separate schema field. + * + * A Vue NodeView renders the in-app header + chevron when the surrounding + * component (Comments.vue) provides a `commentReplyContext`. Outside that + * context (task descriptions, etc.) the NodeView falls back to a plain + * blockquote. */ export const BlockquoteWithCommentId = Blockquote.extend({ addAttributes() { @@ -35,4 +43,8 @@ export const BlockquoteWithCommentId = Blockquote.extend({ }, } }, + + addNodeView() { + return VueNodeViewRenderer(BlockquoteCommentView) + }, }) diff --git a/frontend/src/components/tasks/partials/Comments.vue b/frontend/src/components/tasks/partials/Comments.vue index 11066e6f8..626e8cb76 100644 --- a/frontend/src/components/tasks/partials/Comments.vue +++ b/frontend/src/components/tasks/partials/Comments.vue @@ -173,6 +173,7 @@
-import {ref, reactive, computed, shallowReactive, watch} from 'vue' +import {ref, reactive, computed, nextTick, provide, shallowReactive, watch} from 'vue' import {useI18n} from 'vue-i18n' import BaseButton from '@/components/base/BaseButton.vue' @@ -246,6 +247,7 @@ import {useConfigStore} from '@/stores/config' import {useAuthStore} from '@/stores/auth' import Reactions from '@/components/input/Reactions.vue' import {useCopyToClipboard} from '@/composables/useCopyToClipboard' +import {commentReplyContextKey, scrollAndHighlightComment} from '@/components/tasks/partials/commentReplyContext' const props = withDefaults(defineProps<{ taskId: number, @@ -304,15 +306,19 @@ const actions = computed(() => { if (!props.canWrite) { return {} } - return Object.fromEntries(comments.value.map((comment) => ([ - comment.id, - comment.author.id === currentUserId.value - ? [{ + return Object.fromEntries(comments.value.map((comment) => { + const list: {action: () => void, title: string}[] = [{ + action: () => startReplyTo(comment), + title: t('task.comment.reply'), + }] + if (comment.author.id === currentUserId.value) { + list.push({ action: () => toggleDelete(comment.id), title: t('misc.delete'), - }] - : [], - ]))) + }) + } + return [comment.id, list] + })) }) const frontendUrl = computed(() => configStore.frontendUrl) @@ -321,6 +327,55 @@ const commentStorageKey = computed(() => `task-comment-${props.taskId}`) const currentPage = ref(1) const commentsRef = ref(null) +const newCommentEditor = ref<{setReplyContent: (html: string) => Promise} | null>(null) + +provide(commentReplyContextKey, { + findComment: (id: number) => comments.value.find(c => c.id === id), + scrollToComment: scrollAndHighlightComment, +}) + +// Strip elements from a reply quote so reposting the parent +// body doesn't trigger fresh notifications for users mentioned in the +// original. The inner text is kept so the quote still reads correctly. +function stripMentionsForQuote(html: string): string { + if (!html) { + return '' + } + const doc = new DOMParser().parseFromString(`
${html}
`, 'text/html') + doc.querySelectorAll('mention-user').forEach((el) => { + const label = (el.getAttribute('data-label') ?? el.textContent ?? '').trim() + el.replaceWith(label ? `@${label.replace(/^@+/, '')}` : '') + }) + return doc.body.firstElementChild?.innerHTML ?? '' +} + +async function startReplyTo(parent: ITaskComment) { + const body = stripMentionsForQuote(parent.comment ?? '') + const draft = `
${body}

` + if (!editorActive.value) { + editorActive.value = true + } + // Editor mounts asynchronously through defineAsyncComponent; wait until + // the ref is populated before pushing content in. Bail with a warning + // rather than fall back to `newCommentText = draft` — the modelValue + // watcher in TipTap.vue would land the editor in preview mode, leaving + // the user unable to type without clicking the editor first. + const editor = await waitForEditorRef() + if (!editor) { + console.warn('Reply editor did not mount in time; aborting reply prefill.') + return + } + await editor.setReplyContent(draft) +} + +async function waitForEditorRef() { + const start = performance.now() + while (!newCommentEditor.value && performance.now() - start < 2000) { + + await nextTick() + } + return newCommentEditor.value +} async function attachmentUpload(files: File[] | FileList): (Promise) { @@ -519,9 +574,8 @@ function getCommentUrl(commentId: string) { text-align: inherit; & + .media { - border-block-start: 1px solid rgba(var(--border-rgb), 0.5); - margin-block-start: 1rem; - padding-block-start: 1rem; + margin-block-start: .5rem; + padding-block-start: .5rem; } } @@ -529,7 +583,7 @@ function getCommentUrl(commentId: string) { flex-basis: auto; flex-grow: 0; flex-shrink: 0; - margin: 0 1rem !important; + margin: 0 .5rem !important; } .comment-info { @@ -605,4 +659,15 @@ function getCommentUrl(commentId: string) { .comments-container { scroll-margin-block-start: 4rem; } + +.media.comment { + scroll-margin-block-start: 4rem; + transition: background-color .3s ease-out; + border-radius: $radius; +} + +.media.comment.comment-highlight { + background-color: hsla(var(--primary-hsl), 0.18); + transition: background-color .15s ease-in; +} diff --git a/frontend/src/components/tasks/partials/commentReplyContext.ts b/frontend/src/components/tasks/partials/commentReplyContext.ts new file mode 100644 index 000000000..01468cf30 --- /dev/null +++ b/frontend/src/components/tasks/partials/commentReplyContext.ts @@ -0,0 +1,26 @@ +import type {InjectionKey} from 'vue' +import type {ITaskComment} from '@/modelTypes/ITaskComment' + +export interface CommentReplyContext { + findComment: (id: number) => ITaskComment | undefined + scrollToComment: (id: number) => void +} + +export const commentReplyContextKey: InjectionKey = Symbol('commentReplyContext') + +const HIGHLIGHT_CLASS = 'comment-highlight' +const HIGHLIGHT_DURATION_MS = 1500 + +export function scrollAndHighlightComment(id: number): void { + const el = document.getElementById(`comment-${id}`) + if (!el) { + return + } + el.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'nearest'}) + el.classList.remove(HIGHLIGHT_CLASS) + // Re-apply on next frame so the animation restarts even if already running. + requestAnimationFrame(() => { + el.classList.add(HIGHLIGHT_CLASS) + window.setTimeout(() => el.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_DURATION_MS) + }) +} diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 334e780a5..3b90d69ed 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -1066,7 +1066,10 @@ "addedSuccess": "The comment was added successfully.", "permalink": "Copy permalink to this comment", "sortNewestFirst": "Newest first", - "sortOldestFirst": "Oldest first" + "sortOldestFirst": "Oldest first", + "reply": "Reply", + "jumpToOriginal": "Jump to original comment", + "deletedComment": "deleted comment" }, "mention": { "noUsersFound": "No users found"