diff --git a/web/app/components/editor/comments/CommentRenderer.nuxt.test.ts b/web/app/components/editor/comments/CommentRenderer.nuxt.test.ts
index 6198ed02..b09fcb29 100644
--- a/web/app/components/editor/comments/CommentRenderer.nuxt.test.ts
+++ b/web/app/components/editor/comments/CommentRenderer.nuxt.test.ts
@@ -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: `
${text}
`,
+ content: paragraphs.map((text) => `${text}
`).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("", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
+ clearTeleportedOverlays()
stubThemeColorContext()
clearQueryCache()
useEditorMeta().setEditable(true)
@@ -332,6 +355,242 @@ describe("", { 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,
}) => {
diff --git a/web/app/components/editor/comments/CommentRenderer.vue b/web/app/components/editor/comments/CommentRenderer.vue
index 4b14c3dc..e720021c 100644
--- a/web/app/components/editor/comments/CommentRenderer.vue
+++ b/web/app/components/editor/comments/CommentRenderer.vue
@@ -134,6 +134,7 @@ const popoverPosition = ref({
placement: "bottom-start",
})
const containerElem = useTemplateRef("editor-container")
+const linkBubbleMenuElem = useTemplateRef("link-bubble-menu")
const scrollerElem = useTemplateRef("comment-scroller")
const canScrollToBottom = ref(false)
const anchorElem = ref(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() {
diff --git a/web/app/components/editor/link/LinkBubbleMenu.vue b/web/app/components/editor/link/LinkBubbleMenu.vue
index 06c626be..098d0472 100644
--- a/web/app/components/editor/link/LinkBubbleMenu.vue
+++ b/web/app/components/editor/link/LinkBubbleMenu.vue
@@ -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,
})