mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
fix comment box autoclose
This commit is contained in:
parent
39a89232fa
commit
556ca8fa01
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user