fix(builder): correct move and sortable behavior
This commit is contained in:
1 parent
0bd2a6860d
commit
edccf06658
5 files changed
+70
-5
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/block-editor",
|
"name": "@wrnexus/block-editor",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
|
|||||||
const parent = element.dataset.blockParent!;
|
const parent = element.dataset.blockParent!;
|
||||||
const slot = element.dataset.blockSlot!;
|
const slot = element.dataset.blockSlot!;
|
||||||
const siblings = [
|
const siblings = [
|
||||||
...element.parentElement!.querySelectorAll<HTMLElement>(":scope > [data-block-id]"),
|
...root.querySelectorAll<HTMLElement>("[data-block-id][data-block-parent][data-block-slot]"),
|
||||||
];
|
].filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.dataset.blockParent === parent && candidate.dataset.blockSlot === slot,
|
||||||
|
);
|
||||||
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
|
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
|
||||||
cleanups.push(
|
cleanups.push(
|
||||||
manager.registerSortable({
|
manager.registerSortable({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/blocks",
|
"name": "@wrnexus/blocks",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Pure dynamic block document engine for WRNexus builders.",
|
"description": "Pure dynamic block document engine for WRNexus builders.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type BlockCommand =
|
|||||||
| { type: "insert"; location: BlockLocation; nodes: BlockNode[] }
|
| { type: "insert"; location: BlockLocation; nodes: BlockNode[] }
|
||||||
| { type: "remove"; nodeIds: string[] }
|
| { type: "remove"; nodeIds: string[] }
|
||||||
| { type: "move"; intent: MoveIntent }
|
| { type: "move"; intent: MoveIntent }
|
||||||
|
| { type: "duplicate"; nodeIds: string[]; ids: string[] }
|
||||||
| { type: "updateProps"; nodeId: string; patch: JsonObject }
|
| { type: "updateProps"; nodeId: string; patch: JsonObject }
|
||||||
| { type: "updateStyles"; nodeId: string; patch: BlockStyles }
|
| { type: "updateStyles"; nodeId: string; patch: BlockStyles }
|
||||||
| { type: "replace"; nodeId: string; node: BlockNode }
|
| { type: "replace"; nodeId: string; node: BlockNode }
|
||||||
@@ -152,9 +153,15 @@ export function applyCommand(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
const removedBeforeDestination = command.intent.from.filter(
|
||||||
|
(location) =>
|
||||||
|
location.parentId === command.intent.to.parentId &&
|
||||||
|
location.slot === command.intent.to.slot &&
|
||||||
|
location.index < command.intent.to.index,
|
||||||
|
).length;
|
||||||
root = insert(
|
root = insert(
|
||||||
remove(root, new Set(command.intent.nodeIds)),
|
remove(root, new Set(command.intent.nodeIds)),
|
||||||
command.intent.to,
|
{ ...command.intent.to, index: Math.max(0, command.intent.to.index - removedBeforeDestination) },
|
||||||
moving as BlockNode[],
|
moving as BlockNode[],
|
||||||
);
|
);
|
||||||
affected = command.intent.nodeIds;
|
affected = command.intent.nodeIds;
|
||||||
@@ -169,6 +176,29 @@ export function applyCommand(
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
} else if (command.type === "duplicate") {
|
||||||
|
const originals = command.nodeIds.map((id) => findBlock(root, id));
|
||||||
|
const locations = command.nodeIds.map((id) => locationOf(root, id));
|
||||||
|
if (originals.some((node) => !node) || locations.some((location) => !location))
|
||||||
|
return { ok: false, errors: [{ code: "command.missing", message: "A duplicated block does not exist.", severity: "error" }] };
|
||||||
|
const countNodes = (node: BlockNode): number =>
|
||||||
|
1 + Object.values(node.slots).flat().reduce((total, child) => total + countNodes(child), 0);
|
||||||
|
const requiredIds = (originals as BlockNode[]).reduce(
|
||||||
|
(total, node) => total + countNodes(node),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (command.ids.length !== requiredIds || new Set(command.ids).size !== command.ids.length)
|
||||||
|
return { ok: false, errors: [{ code: "command.ids", message: "Duplicate requires one unique id for every copied node.", severity: "error" }] };
|
||||||
|
const copies = duplicateNodes(originals as BlockNode[], (() => {
|
||||||
|
let index = 0;
|
||||||
|
return () => command.ids[index++]!;
|
||||||
|
})());
|
||||||
|
const commands = copies.map((node, index) => ({
|
||||||
|
type: "insert" as const,
|
||||||
|
location: { ...locations[index]!, index: locations[index]!.index + 1 },
|
||||||
|
nodes: [node],
|
||||||
|
}));
|
||||||
|
return applyCommand(document, { type: "batch", commands }, kind, registry);
|
||||||
} else if (command.type === "updateProps") {
|
} else if (command.type === "updateProps") {
|
||||||
const previous = findBlock(root, command.nodeId);
|
const previous = findBlock(root, command.nodeId);
|
||||||
if (!previous)
|
if (!previous)
|
||||||
|
|||||||
@@ -137,4 +137,36 @@ describe("dynamic blocks engine", () => {
|
|||||||
expect(decoded.ok && decoded.value.roots[0]?.id).not.toBe(node.id);
|
expect(decoded.ok && decoded.value.roots[0]?.id).not.toBe(node.id);
|
||||||
expect(duplicateNodes([node], id)[0]?.props).toEqual(node.props);
|
expect(duplicateNodes([node], id)[0]?.props).toEqual(node.props);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("moves forward within one slot without an off-by-one error", () => {
|
||||||
|
const document = doc();
|
||||||
|
document.root.slots.content = ["a", "b", "c"].map((nodeId) => ({
|
||||||
|
...text.create({ id: () => nodeId }),
|
||||||
|
props: { text: nodeId },
|
||||||
|
}));
|
||||||
|
const moved = applyCommand(document, {
|
||||||
|
type: "move",
|
||||||
|
intent: {
|
||||||
|
nodeIds: ["a"],
|
||||||
|
from: [{ parentId: "root", slot: "content", index: 0 }],
|
||||||
|
to: { parentId: "root", slot: "content", index: 3 },
|
||||||
|
},
|
||||||
|
}, kind, registry);
|
||||||
|
expect(moved.ok && moved.value.document.root.slots.content.map((node) => node.id)).toEqual([
|
||||||
|
"b", "c", "a",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicates blocks with deterministic injected ids", () => {
|
||||||
|
const document = doc();
|
||||||
|
document.root.slots.content = [{ ...text.create({ id: () => "a" }), props: { text: "A" } }];
|
||||||
|
const duplicated = applyCommand(document, {
|
||||||
|
type: "duplicate",
|
||||||
|
nodeIds: ["a"],
|
||||||
|
ids: ["a-copy"],
|
||||||
|
}, kind, registry);
|
||||||
|
expect(duplicated.ok && duplicated.value.document.root.slots.content.map((node) => node.id)).toEqual([
|
||||||
|
"a", "a-copy",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user