diff --git a/web/app/components/editor/drag-handle/BlockHandle.vue b/web/app/components/editor/drag-handle/BlockHandle.vue index 11aabaed..e9f407eb 100644 --- a/web/app/components/editor/drag-handle/BlockHandle.vue +++ b/web/app/components/editor/drag-handle/BlockHandle.vue @@ -188,7 +188,7 @@ function dragEnd() { :provider="props.dataSyncProvider" :locked="isLocked" :on-drag-cancel="dragEnd" - class="z-drag-handle pr-1.5" + class="z-drag-handle pr-1.5 transition-[opacity,visibility] duration-100" @node-change="handleNodeHover" @mouseenter="handleDragHoverEnter" @mouseleave="handleDragHoverLeave" diff --git a/web/app/components/editor/drag-handle/handle-plugin.browser.test.ts b/web/app/components/editor/drag-handle/handle-plugin.browser.test.ts index 55bbdb93..30401709 100644 --- a/web/app/components/editor/drag-handle/handle-plugin.browser.test.ts +++ b/web/app/components/editor/drag-handle/handle-plugin.browser.test.ts @@ -834,6 +834,7 @@ describe("DragHandlePlugin", { concurrent: false }, () => { const { editor, element, handle, onNodeChange } = setup({ content: [paragraph("one"), paragraph("two")], }) + element.style.transition = "opacity 100ms" hover(editor, handle.plugin, 0) onNodeChange.mockClear() vi.mocked(computePosition).mockClear() @@ -859,6 +860,23 @@ describe("DragHandlePlugin", { concurrent: false }, () => { expect(computePosition).toHaveBeenCalledTimes(1) expect(element.style.visibility).toBe("") expect(element.style.pointerEvents).toBe("auto") + // the handle left the first paragraph, so a copy fades out there + expect(element.parentElement?.children).toHaveLength(2) + }) + + it("leaves no copy when the drop lands back on the same node", async ({ + expect, + }) => { + const { editor, element, handle } = setup() + element.style.transition = "opacity 100ms" + hover(editor, handle.plugin, 0) + + endDrag(element, 11, 13) + vi.advanceTimersByTime(20) + await flushMicrotasks() + + expect(element.style.visibility).toBe("") + expect(element.parentElement?.children).toHaveLength(1) }) }) @@ -1447,6 +1465,54 @@ describe("DragHandlePlugin", { concurrent: false }, () => { expect(onNodeChange).toHaveBeenCalledTimes(1) }) + it("leaves a fading copy on the old node when the pointer moves to another", ({ + expect, + }) => { + const { editor, element, handle } = setup({ + content: [paragraph("one"), paragraph("two")], + }) + element.style.transition = "opacity 100ms" + hover(editor, handle.plugin, 0) + + hover(editor, handle.plugin, 5) + + const copy = must(element.nextElementSibling, "a copy") as HTMLElement + expect(element.parentElement?.children).toHaveLength(2) + expect(element.style.opacity).toBe("") + expect(element.style.transition).toBe("opacity 100ms") + expect(copy.inert).toBe(true) + expect(copy.getAttribute("aria-hidden")).toBe("true") + expect(copy.style.pointerEvents).toBe("none") + expect(copy.style.opacity).toBe("0") + }) + + it("removes the copy once its fade has run", async ({ expect }) => { + const { editor, element, handle } = setup({ + content: [paragraph("one"), paragraph("two")], + }) + element.style.transition = "opacity 100ms" + hover(editor, handle.plugin, 0) + hover(editor, handle.plugin, 5) + const copy = must(element.nextElementSibling, "a copy") as HTMLElement + + copy.getAnimations().forEach((animation) => { + animation.finish() + }) + await flushMicrotasks() + + expect(copy.isConnected).toBe(false) + expect(element.parentElement?.children).toHaveLength(1) + }) + + it("leaves no copy when the handle was hidden", ({ expect }) => { + const { editor, element, handle } = setup() + element.style.transition = "opacity 100ms" + + hover(editor, handle.plugin, 0) + + expect(element.parentElement?.children).toHaveLength(1) + }) + it("does nothing while the handle is locked", ({ expect }) => { const { editor, handle, onNodeChange } = setup({ locked: true }) diff --git a/web/app/components/editor/drag-handle/handle-plugin.ts b/web/app/components/editor/drag-handle/handle-plugin.ts index d8408714..08936a27 100644 --- a/web/app/components/editor/drag-handle/handle-plugin.ts +++ b/web/app/components/editor/drag-handle/handle-plugin.ts @@ -383,16 +383,56 @@ export const DragHandlePlugin = ({ const resolvedPluginKey: PluginKey = typeof pluginKey === "string" ? new PluginKey(pluginKey) : pluginKey + // the opacity is what fades. Visibility only flips once the fade has + // run, if the handle's class transitions it too function hideHandle() { element.style.visibility = "hidden" + element.style.opacity = "0" element.style.pointerEvents = "none" } function showHandle() { element.style.visibility = "" + element.style.opacity = "" element.style.pointerEvents = "auto" } + // the handle is a single element, so moving it to another node would + // jump. A copy stays behind on the old node and fades out, and the + // handle starts from nothing so that showHandle fades it in + function leaveFadingCopy() { + if (element.style.visibility === "hidden") { + return + } + + const copy = element.cloneNode(true) as HTMLElement + copy.inert = true + copy.setAttribute("aria-hidden", "true") + copy.style.pointerEvents = "none" + // a quick move can catch the handle still fading in + copy.style.opacity = getComputedStyle(element).opacity + wrapper.appendChild(copy) + + // the handle drops to 0 at once, with its transition off, and + // showHandle fades it back in from there + const transition = element.style.transition + element.style.transition = "none" + element.style.opacity = "0" + // the browser only fades from a style it has already applied. + // Measuring makes it apply the styles set so far: the handle at 0 + // and the copy at its starting opacity + element.getBoundingClientRect() + element.style.transition = transition + + copy.style.opacity = "0" + // the copy goes once its fade has run, or at once without one + void Promise.allSettled( + copy.getAnimations().map((animation) => animation.finished), + ).then(() => { + copy.remove() + }) + } + function repositionDragHandle(dom: Element, currentNodePos: number) { const virtualElement = getReferencedVirtualElement?.() ?? { getBoundingClientRect: () => dom.getBoundingClientRect(), @@ -515,6 +555,10 @@ export const DragHandlePlugin = ({ return } + if (result.pos !== currentNodePos) { + leaveFadingCopy() + } + currentNode = result.node currentNodePos = result.pos currentNodeRelPos = findRelativePos(editor.view.state, currentNodePos) @@ -804,6 +848,8 @@ export const DragHandlePlugin = ({ // false positives when the same logical node gets a new object // reference after document updates if (nodePos !== currentNodePos) { + leaveFadingCopy() + currentNode = node currentNodePos = nodePos currentNodeRelPos = findRelativePos(view.state, currentNodePos)