fix(block-editor): restore DOM order after rejected moves
This commit is contained in:
1 parent
4ac31d750e
commit
6b35f522b9
3 files changed
+176
-9
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/block-editor",
|
"name": "@wrnexus/block-editor",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export interface MountEditorOptions {
|
|||||||
export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions): MountedEditor {
|
export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions): MountedEditor {
|
||||||
let manager: DndManager | undefined;
|
let manager: DndManager | undefined;
|
||||||
let cleanups: Array<() => void> = [];
|
let cleanups: Array<() => void> = [];
|
||||||
const live = document.createElement("div");
|
let destroyed = false;
|
||||||
|
let generation = 0;
|
||||||
|
const owner = root.ownerDocument;
|
||||||
|
const view = owner.defaultView!;
|
||||||
|
const live = owner.createElement("div");
|
||||||
live.setAttribute("role", "status");
|
live.setAttribute("role", "status");
|
||||||
live.setAttribute("aria-live", "polite");
|
live.setAttribute("aria-live", "polite");
|
||||||
live.setAttribute("aria-atomic", "true");
|
live.setAttribute("aria-atomic", "true");
|
||||||
@@ -24,21 +28,65 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
|
|||||||
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
|
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
|
||||||
root.append(live);
|
root.append(live);
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
const currentGeneration = ++generation;
|
||||||
cleanups.forEach((cleanup) => cleanup());
|
cleanups.forEach((cleanup) => cleanup());
|
||||||
cleanups = [];
|
cleanups = [];
|
||||||
manager?.destroy();
|
manager?.destroy();
|
||||||
|
let originalChildren = new Map<Node, Node[]>();
|
||||||
|
let gesture = 0;
|
||||||
|
const groups = new Map<string, { parentId: string; slot: string }>();
|
||||||
manager = createDndManager({
|
manager = createDndManager({
|
||||||
direction: options.direction ?? (getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"),
|
direction:
|
||||||
reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches,
|
options.direction ?? (view.getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"),
|
||||||
|
reducedMotion: view.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||||
announce(announcement) {
|
announce(announcement) {
|
||||||
|
if (destroyed || generation !== currentGeneration) return;
|
||||||
|
if (announcement.type === "pickedUp") {
|
||||||
|
gesture++;
|
||||||
|
originalChildren = new Map();
|
||||||
|
for (const element of root.querySelectorAll(
|
||||||
|
"[data-block-id][data-block-parent][data-block-slot]",
|
||||||
|
)) {
|
||||||
|
const parent = element.parentNode;
|
||||||
|
if (parent && !originalChildren.has(parent))
|
||||||
|
originalChildren.set(parent, [...parent.childNodes]);
|
||||||
|
}
|
||||||
|
} else if (announcement.type === "cancelled") {
|
||||||
|
// Let the backend finish its own cancellation work before reconciliation.
|
||||||
|
// Keep the actual nodes (and their listeners), not a stale innerHTML copy.
|
||||||
|
const snapshot = originalChildren;
|
||||||
|
const cancelledGesture = gesture;
|
||||||
|
view.setTimeout(() => {
|
||||||
|
if (
|
||||||
|
destroyed ||
|
||||||
|
generation !== currentGeneration ||
|
||||||
|
gesture !== cancelledGesture ||
|
||||||
|
!root.isConnected
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
for (const [parent, children] of snapshot) {
|
||||||
|
if (parent !== root && !root.contains(parent)) continue;
|
||||||
|
let anchor: Node | null = null;
|
||||||
|
for (const child of [...children].reverse()) {
|
||||||
|
// Never resurrect nodes removed by an application rerender.
|
||||||
|
if (!root.contains(child)) continue;
|
||||||
|
parent.insertBefore(child, anchor);
|
||||||
|
anchor = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}, 0);
|
||||||
|
} else if (announcement.type === "dropped") originalChildren.clear();
|
||||||
live.textContent = options.announce?.(announcement) ?? announcement.type;
|
live.textContent = options.announce?.(announcement) ?? announcement.type;
|
||||||
},
|
},
|
||||||
async onMoveIntent(intent) {
|
async onMoveIntent(intent) {
|
||||||
const [parentId, slot] = String(intent.to.group).split(":", 2);
|
if (destroyed || generation !== currentGeneration) return false;
|
||||||
if (!parentId || !slot) return false;
|
const destination = groups.get(String(intent.to.group));
|
||||||
|
if (!destination) return false;
|
||||||
return options.onMove({
|
return options.onMove({
|
||||||
nodeIds: intent.ids.map(String),
|
nodeIds: intent.ids.map(String),
|
||||||
to: { parentId, slot, index: intent.to.index },
|
to: { ...destination, index: intent.to.index },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,8 +96,12 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
|
|||||||
const id = element.dataset.blockId!;
|
const id = element.dataset.blockId!;
|
||||||
const parent = element.dataset.blockParent!;
|
const parent = element.dataset.blockParent!;
|
||||||
const slot = element.dataset.blockSlot!;
|
const slot = element.dataset.blockSlot!;
|
||||||
|
const group = JSON.stringify([parent, slot]);
|
||||||
|
groups.set(group, { parentId: parent, slot });
|
||||||
const siblings = [
|
const siblings = [
|
||||||
...root.querySelectorAll<HTMLElement>("[data-block-id][data-block-parent][data-block-slot]"),
|
...root.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-block-id][data-block-parent][data-block-slot]",
|
||||||
|
),
|
||||||
].filter(
|
].filter(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
candidate.dataset.blockParent === parent && candidate.dataset.blockSlot === slot,
|
candidate.dataset.blockParent === parent && candidate.dataset.blockSlot === slot,
|
||||||
@@ -58,7 +110,7 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
|
|||||||
cleanups.push(
|
cleanups.push(
|
||||||
manager.registerSortable({
|
manager.registerSortable({
|
||||||
id,
|
id,
|
||||||
group: `${parent}:${slot}`,
|
group,
|
||||||
index: siblings.indexOf(element),
|
index: siblings.indexOf(element),
|
||||||
element,
|
element,
|
||||||
handle,
|
handle,
|
||||||
@@ -71,7 +123,11 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
|
|||||||
return {
|
return {
|
||||||
refresh,
|
refresh,
|
||||||
destroy() {
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
generation++;
|
||||||
cleanups.forEach((cleanup) => cleanup());
|
cleanups.forEach((cleanup) => cleanup());
|
||||||
|
cleanups = [];
|
||||||
manager?.destroy();
|
manager?.destroy();
|
||||||
live.remove();
|
live.remove();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
|
import type { DndManagerOptions, SortableRegistration } from "@wrnexus/dnd";
|
||||||
|
import type { MountedEditor, MountEditorOptions } from "../src/runtime.ts";
|
||||||
|
|
||||||
|
const code = new Bun.Transpiler({ loader: "ts" })
|
||||||
|
.transformSync(readFileSync(new URL("../src/runtime.ts", import.meta.url), "utf8"))
|
||||||
|
.replace(/^import .*;\s*$/gm, "")
|
||||||
|
.replace("export function mountBlockEditor", "function mountBlockEditor");
|
||||||
|
|
||||||
|
function fixture(onMove: MountEditorOptions["onMove"] = () => false) {
|
||||||
|
const window = new Window();
|
||||||
|
const document = window.document;
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
root.innerHTML =
|
||||||
|
'<div id="left"><button data-block-id="a" data-block-parent="parent:one" data-block-slot="content:main">A</button><span>Keep</span><button data-block-id="b" data-block-parent="parent:one" data-block-slot="content:main">B</button></div><div id="right"><button data-block-id="c" data-block-parent="parent:two" data-block-slot="content">C</button></div>';
|
||||||
|
const backends: Array<{
|
||||||
|
options: DndManagerOptions;
|
||||||
|
registrations: SortableRegistration[];
|
||||||
|
destroyed: number;
|
||||||
|
}> = [];
|
||||||
|
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(
|
||||||
|
(options: DndManagerOptions) => {
|
||||||
|
const backend = { options, registrations: [] as SortableRegistration[], destroyed: 0 };
|
||||||
|
backends.push(backend);
|
||||||
|
return {
|
||||||
|
registerSortable: (registration: SortableRegistration) => {
|
||||||
|
backend.registrations.push(registration);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
destroy: () => backend.destroyed++,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const editor: MountedEditor = mount(root, { onMove });
|
||||||
|
const emit = (type: "pickedUp" | "cancelled" | "dropped") =>
|
||||||
|
backends.at(-1)!.options.announce!({ type, ids: ["a"] });
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
document,
|
||||||
|
editor,
|
||||||
|
backends,
|
||||||
|
emit,
|
||||||
|
flush: () => window.happyDOM.whenAsyncComplete(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cancelled optimistic moves restore original DOM nodes across containers", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
const a = f.root.querySelector('[data-block-id="a"]')!;
|
||||||
|
let clicks = 0;
|
||||||
|
a.addEventListener("click", () => clicks++);
|
||||||
|
f.emit("pickedUp");
|
||||||
|
f.root.querySelector("#right")!.append(a);
|
||||||
|
f.emit("cancelled");
|
||||||
|
await f.flush();
|
||||||
|
expect(f.root.querySelector("#left")!.textContent).toBe("AKeepB");
|
||||||
|
expect(f.root.querySelector("#right")!.textContent).toBe("C");
|
||||||
|
expect(f.root.querySelector('[data-block-id="a"]')).toBe(a);
|
||||||
|
(a as any).click();
|
||||||
|
expect(clicks).toBe(1);
|
||||||
|
expect(f.backends).toHaveLength(2);
|
||||||
|
expect(f.backends[0]!.destroyed).toBe(1);
|
||||||
|
f.editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slot and parent identifiers containing colons round-trip without collisions", async () => {
|
||||||
|
const moves: unknown[] = [];
|
||||||
|
const f = fixture((intent) => {
|
||||||
|
moves.push(intent);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const backend = f.backends[0]!;
|
||||||
|
await backend.options.onMoveIntent({
|
||||||
|
ids: ["b"],
|
||||||
|
from: [],
|
||||||
|
to: { group: backend.registrations[0]!.group, index: 0 },
|
||||||
|
});
|
||||||
|
expect(moves).toEqual([
|
||||||
|
{ nodeIds: ["b"], to: { parentId: "parent:one", slot: "content:main", index: 0 } },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
await backend.options.onMoveIntent({
|
||||||
|
ids: ["b"],
|
||||||
|
from: [],
|
||||||
|
to: { group: "unknown", index: 0 },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
f.editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending rollback cannot resurrect removed nodes or run after destruction", async () => {
|
||||||
|
for (const destroyed of [false, true]) {
|
||||||
|
const f = fixture();
|
||||||
|
f.emit("pickedUp");
|
||||||
|
f.root.querySelector('[data-block-id="a"]')!.remove();
|
||||||
|
f.emit("cancelled");
|
||||||
|
if (destroyed) {
|
||||||
|
f.editor.destroy();
|
||||||
|
f.editor.destroy();
|
||||||
|
f.editor.refresh();
|
||||||
|
}
|
||||||
|
await f.flush();
|
||||||
|
expect(f.root.querySelector('[data-block-id="a"]')).toBeNull();
|
||||||
|
if (destroyed) expect(f.backends).toHaveLength(1);
|
||||||
|
f.editor.destroy();
|
||||||
|
expect(f.backends.every((backend) => backend.destroyed === 1)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user