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 <blockquote data-comment-id="X"> 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.
This commit is contained in:
parent
46dbeb5784
commit
82975f9bd2
|
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<NodeViewWrapper
|
||||
as="blockquote"
|
||||
class="comment-quote"
|
||||
:class="{'comment-quote--has-parent': hasParent}"
|
||||
:data-comment-id="commentId === null ? null : String(commentId)"
|
||||
>
|
||||
<div
|
||||
v-if="commentId !== null && ctx"
|
||||
contenteditable="false"
|
||||
class="comment-quote__header"
|
||||
>
|
||||
<template v-if="parent">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
alt=""
|
||||
class="comment-quote__avatar"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<span class="comment-quote__author">{{ authorName }}</span>
|
||||
<BaseButton
|
||||
v-tooltip="t('task.comment.jumpToOriginal')"
|
||||
class="comment-quote__jump"
|
||||
:aria-label="t('task.comment.jumpToOriginal')"
|
||||
@click="onJump"
|
||||
>
|
||||
<Icon icon="angle-right" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="comment-quote__author comment-quote__author--missing"
|
||||
>
|
||||
{{ t('task.comment.deletedComment') }}
|
||||
</span>
|
||||
</div>
|
||||
<NodeViewContent class="comment-quote__body" />
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, inject, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {nodeViewProps, NodeViewWrapper, NodeViewContent} from '@tiptap/vue-3'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
|
||||
import {commentReplyContextKey} from '@/components/tasks/partials/commentReplyContext'
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const ctx = inject(commentReplyContextKey, null)
|
||||
|
||||
const commentId = computed<number | null>(() => {
|
||||
const raw = props.node.attrs.commentId
|
||||
if (raw === null || raw === undefined) {
|
||||
return null
|
||||
}
|
||||
const id = Number(raw)
|
||||
return Number.isInteger(id) && id > 0 ? id : null
|
||||
})
|
||||
|
||||
const parent = computed(() => {
|
||||
if (commentId.value === null || !ctx) {
|
||||
return undefined
|
||||
}
|
||||
return ctx.findComment(commentId.value)
|
||||
})
|
||||
|
||||
const hasParent = computed(() => parent.value !== undefined)
|
||||
|
||||
const authorName = computed(() => {
|
||||
const p = parent.value
|
||||
return p ? getDisplayName(p.author) : ''
|
||||
})
|
||||
|
||||
const avatarUrl = ref('')
|
||||
|
||||
// Bumped on every parent change so stale avatar fetches (older parent)
|
||||
// don't overwrite a newer one if the user navigates between comments
|
||||
// while fetches are still in flight.
|
||||
let avatarFetchToken = 0
|
||||
|
||||
watch(parent, (p) => {
|
||||
avatarUrl.value = ''
|
||||
const token = ++avatarFetchToken
|
||||
if (!p?.author) {
|
||||
return
|
||||
}
|
||||
fetchAvatarBlobUrl(p.author, 20)
|
||||
.then((url) => {
|
||||
if (token === avatarFetchToken) {
|
||||
avatarUrl.value = (url as string) ?? ''
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Swallow — a missing avatar isn't worth a user-visible error;
|
||||
// the header still renders with the author name.
|
||||
})
|
||||
}, {immediate: true})
|
||||
|
||||
function onJump() {
|
||||
if (commentId.value !== null && ctx) {
|
||||
ctx.scrollToComment(commentId.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.tiptap blockquote.comment-quote {
|
||||
margin-block: .5rem;
|
||||
|
||||
.comment-quote__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding-block-end: .25rem;
|
||||
font-size: .85rem;
|
||||
color: var(--grey-600);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.comment-quote__avatar {
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.comment-quote__author {
|
||||
font-weight: 600;
|
||||
color: var(--grey-700);
|
||||
|
||||
&--missing {
|
||||
font-style: italic;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-quote__jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--grey-500);
|
||||
padding: .15rem .25rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color $transition, color $transition;
|
||||
|
||||
&:hover {
|
||||
color: var(--grey-800);
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-quote__body > :first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@
|
|||
<div class="field">
|
||||
<Editor
|
||||
v-if="editorActive"
|
||||
ref="newCommentEditor"
|
||||
v-model="newCommentText"
|
||||
:class="{
|
||||
'is-loading':
|
||||
|
|
@ -222,7 +223,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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<HTMLElement | null>(null)
|
||||
const newCommentEditor = ref<{setReplyContent: (html: string) => Promise<void>} | null>(null)
|
||||
|
||||
provide(commentReplyContextKey, {
|
||||
findComment: (id: number) => comments.value.find(c => c.id === id),
|
||||
scrollToComment: scrollAndHighlightComment,
|
||||
})
|
||||
|
||||
// Strip <mention-user> 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(`<div>${html}</div>`, '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 = `<blockquote data-comment-id="${parent.id}">${body}</blockquote><p></p>`
|
||||
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<string[]>) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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<CommentReplyContext> = 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue