fix comment box autoclose

This commit is contained in:
swithek 2026-09-19 17:16:46 +02:00
parent 39a89232fa
commit 556ca8fa01
3 changed files with 336 additions and 28 deletions

View File

@ -1,6 +1,11 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { Editor as TiptapEditor, type Editor } from "@tiptap/core"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { ySyncPluginKey } from "@tiptap/y-tiptap"
import {
enableAutoUnmount,
flushPromises,
type VueWrapper,
} from "@vue/test-utils"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import CommentRenderer from "./CommentRenderer.vue"
@ -17,6 +22,8 @@ import {
seedQueryData,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
menuItem,
seedAuthOrganization,
seedAuthSession,
t,
@ -64,7 +71,7 @@ let contentEditor: Editor | null = null
// the renderer anchors comments in a live document, so the suite drives a
// real editor holding the same mark and node comment extensions the app
// gives its content editor
function textDoc(text: string): Editor {
function textDoc(...paragraphs: string[]): Editor {
const element = document.createElement("div")
document.body.appendChild(element)
@ -76,7 +83,7 @@ function textDoc(text: string): Editor {
CommentMark,
NodeComment.configure({ types: ["paragraph"] }),
],
content: `<p>${text}</p>`,
content: paragraphs.map((text) => `<p>${text}</p>`).join(""),
})
return contentEditor
@ -130,6 +137,21 @@ function api(wrapper: VueWrapper): CommentRendererApi {
return wrapper.vm as unknown as CommentRendererApi
}
function press(target: Element) {
target.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))
}
// tiptap hangs its editor instance off the editable element
function commentEditor(wrapper: VueWrapper): Editor {
const element = wrapper.find(".z-popover .ProseMirror").element as
(Element & { editor?: Editor }) | undefined
if (!element?.editor) {
throw new Error("no comment editor in the popover")
}
return element.editor
}
function popoverOpen(wrapper: VueWrapper): boolean {
return wrapper.find(".z-popover").exists()
}
@ -151,6 +173,7 @@ describe("<CommentRenderer>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
clearTeleportedOverlays()
stubThemeColorContext()
clearQueryCache()
useEditorMeta().setEditable(true)
@ -332,6 +355,242 @@ describe("<CommentRenderer>", { concurrent: false }, () => {
expect(popoverOpen(wrapper)).toBe(false)
})
it("closes a text thread when a press lands outside the editor", async ({
expect,
}) => {
const editor = textDoc("hello world")
editor.commands.setTextSelection({ from: 1, to: 6 })
editor.commands.addCommentMark({ commentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: true, id: COMMENT_ID })
await nextTick()
press(document.body)
await nextTick()
expect(popoverOpen(wrapper)).toBe(false)
})
it("keeps a text thread open on a press in the editor until the click lands", async ({
expect,
}) => {
const editor = textDoc("hello world")
editor.commands.setTextSelection({ from: 1, to: 6 })
editor.commands.addCommentMark({ commentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: true, id: COMMENT_ID })
await nextTick()
press(editor.view.dom)
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
})
it("closes a block thread when a press lands on another block", async ({
expect,
}) => {
const editor = textDoc("first", "second")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
press(editor.view.nodeDOM(7) as Element)
await nextTick()
expect(popoverOpen(wrapper)).toBe(false)
})
it("keeps a block thread open while presses land inside its block", async ({
expect,
}) => {
const editor = textDoc("first", "second")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
press(editor.view.nodeDOM(0) as Element)
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
})
it("drops a draft block comment when a press lands beside the editor", async ({
expect,
}) => {
const editor = textDoc("hello world")
const handle = document.createElement("div")
editor.view.dom.parentElement?.appendChild(handle)
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
press(handle)
await nextTick()
expect.soft(popoverOpen(wrapper)).toBe(false)
expect.soft(editor.state.doc.firstChild?.attrs.nodeCommentId).toBeNull()
})
it("keeps the thread open while presses land inside it", async ({
expect,
}) => {
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
press(wrapper.find(".z-popover").element)
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
})
it("keeps the thread open while presses land on a comment indicator", async ({
expect,
}) => {
const editor = textDoc("hello world")
const indicator = document.createElement("button")
editor.view.dom.parentElement
?.querySelector(".node-comment-overlay-container")
?.appendChild(indicator)
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
press(indicator)
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
})
it("keeps the thread open while presses land on its actions menu", async ({
expect,
}) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await vi.waitFor(() => {
expect(wrapper.find("[data-slot='dropdown-menu-trigger']").exists()).toBe(
true,
)
}, WAIT_FOR_OPTIONS)
const trigger = wrapper.find("[data-slot='dropdown-menu-trigger']")
await trigger.trigger("pointerdown", { button: 0 })
await trigger.trigger("click")
await nextTick()
press(menuItem(t("editor.comment-thread.edit-start-button")))
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
})
it("closes a block thread when its block is deleted", async ({ expect }) => {
const editor = textDoc("first", "second")
editor.commands.addNodeComment(7, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
editor.commands.setTextSelection(3)
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
editor.chain().setTextSelection(6).deleteRange({ from: 7, to: 15 }).run()
await flushPromises()
expect.soft(popoverOpen(wrapper)).toBe(false)
// the cursor stays where the edit left it
expect.soft(editor.state.selection.from).toBe(6)
})
it("closes a text thread when its text is deleted", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.setTextSelection({ from: 1, to: 6 })
editor.commands.addCommentMark({ commentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: true, id: COMMENT_ID })
await nextTick()
editor.commands.deleteRange({ from: 1, to: 6 })
await flushPromises()
expect(popoverOpen(wrapper)).toBe(false)
})
it("closes a block thread and restores the cursor when a collaborator deletes its block", async ({
expect,
}) => {
const editor = textDoc("first", "second")
editor.commands.addNodeComment(7, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
editor.commands.setTextSelection(3)
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
editor.view.dispatch(
editor.state.tr.delete(7, 15).setMeta(ySyncPluginKey, {}),
)
await flushPromises()
expect.soft(popoverOpen(wrapper)).toBe(false)
expect.soft(editor.state.selection.from).toBe(3)
})
it("keeps the thread open while the rest of the document changes", async ({
expect,
}) => {
const editor = textDoc("first", "second")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
editor.commands.deleteRange({ from: 7, to: 15 })
await flushPromises()
expect(popoverOpen(wrapper)).toBe(true)
})
it("keeps a new block thread open once the server gives it an id", async ({
expect,
}) => {
const editor = textDoc("hello world")
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/comments`, () =>
serverComment(),
)
// saving refetches the thread list
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/comments`, () => [
serverComment(),
])
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
commentEditor(wrapper).commands.setContent(commentBody("looks good"))
await flushPromises()
await popoverButton(
wrapper,
t("editor.comment-thread.comment-button"),
).trigger("click")
await vi.waitFor(() => {
expect(editor.state.doc.firstChild?.attrs.nodeCommentId).toBe(COMMENT_ID)
}, WAIT_FOR_OPTIONS)
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
})
it("keeps the comment button out of reach while the draft is empty", async ({
expect,
}) => {

View File

@ -134,6 +134,7 @@ const popoverPosition = ref({
placement: "bottom-start",
})
const containerElem = useTemplateRef("editor-container")
const linkBubbleMenuElem = useTemplateRef("link-bubble-menu")
const scrollerElem = useTemplateRef<DynamicScrollerType>("comment-scroller")
const canScrollToBottom = ref(false)
const anchorElem = ref<Element | null>(null)
@ -272,10 +273,11 @@ import.meta.hot?.dispose(() => {
onMounted(() => {
if (!isDiffMode.value) {
activeEditor.value.on("transaction", handleRemoteDocChange)
activeEditor.value.on("transaction", handleDocChange)
}
activeEditor.value.view.dom.addEventListener("click", handleEditorClick)
document.addEventListener("pointerdown", handleDocumentPointerDown, true)
window.addEventListener("scroll", handleScrollOrResize, true)
window.addEventListener("resize", handleScrollOrResize)
})
@ -412,7 +414,7 @@ function removeEditorListeners() {
// unmounts — in that case listeners are already cleaned up
try {
if (!isDiffMode.value) {
activeEditor.value.off("transaction", handleRemoteDocChange)
activeEditor.value.off("transaction", handleDocChange)
}
activeEditor.value.view.dom.removeEventListener("click", handleEditorClick)
@ -420,6 +422,7 @@ function removeEditorListeners() {
// editor already destroyed, listeners are gone
}
document.removeEventListener("pointerdown", handleDocumentPointerDown, true)
window.removeEventListener("scroll", handleScrollOrResize, true)
window.removeEventListener("resize", handleScrollOrResize)
}
@ -435,38 +438,79 @@ function handleEditorClick() {
})
}
/**
* Handle remote document changes that may delete the currently viewed comment.
*/
function handleRemoteDocChange({ transaction }: { transaction: Transaction }) {
// bail early if no comment is open
if (!selectedComment.value) {
// closes the popover when a press lands outside it. Its own menus, its
// link bubble and the comment indicators keep it open. Inside the editor
// a text comment is left to handleEditorClick, and a node comment stays
// open while the press is inside its node.
function handleDocumentPointerDown(event: PointerEvent) {
const target = event.target
if (!selectedComment.value || !(target instanceof Element)) {
return
}
// bail if doc didn't change
if (!transaction.docChanged) {
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this
popoverElem.value?.contains(target) ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this
linkBubbleMenuElem.value?.containsTarget(target) ||
target.closest(
'[role="menu"], .node-comment-overlay-container, .text-comment-indicator-container',
)
) {
return
}
// bail if this is a local change (user's own edit)
if (!isChangeOrigin(transaction)) {
return
}
// check if the comment's DOM element still exists (fast querySelector)
const id = selectedComment.value.id
const editorDom = activeEditor.value.view.dom
if (selectedComment.value.textComment) {
if (!editorDom.querySelector(`[data-comment-id="${id}"]`)) {
closePopover()
const { view, state } = activeEditor.value
if (view.dom.contains(target)) {
if (selectedComment.value.textComment) {
return
}
} else if (selectedComment.value.nodeComment) {
if (!editorDom.querySelector(`[data-node-comment-id="${id}"]`)) {
closePopover()
const match = findNodeCommentById(state, selectedComment.value.id)
if (match && view.nodeDOM(match.pos)?.contains(target)) {
return
}
}
// the press moves focus and the selection itself, so neither is
// restored
savedContentEditorSelection.value = null
closePopover(true)
}
// closes the popover once its comment is gone from the document, whether
// a collaborator or the user removed it. The check waits a tick because
// swapping a pending id for the server one changes the document before
// selectedComment follows.
function handleDocChange({ transaction }: { transaction: Transaction }) {
if (!selectedComment.value || !transaction.docChanged) {
return
}
const remote = isChangeOrigin(transaction)
void nextTick(() => {
const selected = selectedComment.value
if (!selected) {
return
}
const { state } = activeEditor.value
const found = selected.textComment
? findCommentMarkById(state, selected.id)
: findNodeCommentById(state, selected.id)
if (found) {
return
}
// the user's own edit has placed the cursor, so the selection saved
// when the popover opened is stale
if (!remote) {
savedContentEditorSelection.value = null
}
closePopover()
})
}
function handleScrollOrResize() {

View File

@ -255,6 +255,10 @@ function handlePopoverFocusOut(e: FocusEvent) {
handlePopoverMouseLeave()
}
function containsTarget(target: Node) {
return !!popoverElem.value?.contains(target)
}
function handleClickOutside(event?: MouseEvent) {
if (!isEditing.value || !popoverElem.value) {
return
@ -510,6 +514,7 @@ onKeyStroke("Escape", () => {
defineExpose({
editSelection,
containsTarget,
})
</script>
<template>