From edccf06658bca91e202d3347beef02b02a270603 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 10:49:33 +0530 Subject: [PATCH] fix(builder): correct move and sortable behavior --- packages/block-editor/package.json | 2 +- packages/block-editor/src/runtime.ts | 7 ++++-- packages/blocks/package.json | 2 +- packages/blocks/src/commands.ts | 32 +++++++++++++++++++++++++++- packages/blocks/test/engine.test.ts | 32 ++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index bae31410..4577e243 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/block-editor", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "description": "Hydrated accessible editor runtime for WRNexus block documents.", "exports": { diff --git a/packages/block-editor/src/runtime.ts b/packages/block-editor/src/runtime.ts index 5ea0a8ae..03c94919 100644 --- a/packages/block-editor/src/runtime.ts +++ b/packages/block-editor/src/runtime.ts @@ -49,8 +49,11 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions) const parent = element.dataset.blockParent!; const slot = element.dataset.blockSlot!; const siblings = [ - ...element.parentElement!.querySelectorAll(":scope > [data-block-id]"), - ]; + ...root.querySelectorAll("[data-block-id][data-block-parent][data-block-slot]"), + ].filter( + (candidate) => + candidate.dataset.blockParent === parent && candidate.dataset.blockSlot === slot, + ); const handle = element.querySelector("[data-block-handle]") ?? undefined; cleanups.push( manager.registerSortable({ diff --git a/packages/blocks/package.json b/packages/blocks/package.json index c18457f2..e460ace7 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/blocks", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "description": "Pure dynamic block document engine for WRNexus builders.", "exports": { diff --git a/packages/blocks/src/commands.ts b/packages/blocks/src/commands.ts index 97fe38fa..24ea4238 100644 --- a/packages/blocks/src/commands.ts +++ b/packages/blocks/src/commands.ts @@ -17,6 +17,7 @@ export type BlockCommand = | { type: "insert"; location: BlockLocation; nodes: BlockNode[] } | { type: "remove"; nodeIds: string[] } | { type: "move"; intent: MoveIntent } + | { type: "duplicate"; nodeIds: string[]; ids: string[] } | { type: "updateProps"; nodeId: string; patch: JsonObject } | { type: "updateStyles"; nodeId: string; patch: BlockStyles } | { 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( 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[], ); 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") { const previous = findBlock(root, command.nodeId); if (!previous) diff --git a/packages/blocks/test/engine.test.ts b/packages/blocks/test/engine.test.ts index d1141513..157e74e5 100644 --- a/packages/blocks/test/engine.test.ts +++ b/packages/blocks/test/engine.test.ts @@ -137,4 +137,36 @@ describe("dynamic blocks engine", () => { expect(decoded.ok && decoded.value.roots[0]?.id).not.toBe(node.id); 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", + ]); + }); });