add fade animations to the drag handle

This commit is contained in:
swithek 2026-10-01 14:20:25 +02:00
parent 99cd7cf646
commit 766bef1cab
3 changed files with 113 additions and 1 deletions

View File

@ -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"

View File

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

View File

@ -383,16 +383,56 @@ export const DragHandlePlugin = ({
const resolvedPluginKey: PluginKey<DragHandlePluginState> =
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)