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"