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, })