feat(builder): mobile-B touch editing -- selection toolbar, tap-to-add, swipe-dismiss
Phase B makes the Craft.js editor genuinely usable by touch on top of Phase A's responsive shell, gated entirely behind useIsMobile()/<=768px: - Extract useNodeActions(nodeId) out of ContextMenu.tsx (move/duplicate/ delete/select-parent), shared by the desktop right-click menu (behavior unchanged) and the new mobile MobileSelectionToolbar. - MobileSelectionToolbar: bottom-fixed selection toolbar (Move Up/Down, Duplicate, Select Parent, Edit Styles, two-tap Delete confirm), hidden while a sheet is open. - BlocksPanel: tap-to-add on mobile (insert after selection, close sheet, select + scroll the new node into view); desktop drag/double-click unchanged. - LayersPanel rows >=44px on mobile; HeadCodeModal portaled to document.body (same fix TemplateModal already had); BottomSheet gets swipe-to-dismiss and on-screen-keyboard clearance via a new useVisualViewportInsets hook. Also fixes two pre-existing bugs surfaced only by driving a real Craft.js document with Playwright touch input (masked by tests that mock @craftjs/core): regenerateTreeIds structuredClone'd a live node's whole data object, including the component function reference in data.type, throwing DataCloneError and silently breaking Duplicate/Paste for every node type; and an earlier useNodeActions draft cached canMoveUp/canMoveDown inside a useEditor collector closed over nodeId, which goes stale for one render whenever the selection changes without an unrelated store event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,37 @@ describe('regenerateTreeIds', () => {
|
||||
|
||||
expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' });
|
||||
});
|
||||
|
||||
// Regression test (found via real-browser Playwright touch verification,
|
||||
// Phase B): a LIVE Craft.js node's `data.type` is the actual component
|
||||
// function reference (not the `{resolvedName}` string wrapper used only
|
||||
// in SERIALIZED state) -- `query.node(id).toNodeTree()` returns nodes
|
||||
// shaped exactly like this. The previous implementation ran
|
||||
// `structuredClone(oldNode.data)` on the WHOLE data object, which throws
|
||||
// `DataCloneError` the instant `data.type` is a function, silently
|
||||
// breaking Duplicate (ContextMenu.tsx / useNodeActions.ts) and Ctrl+V
|
||||
// Paste (useKeyboardShortcuts.ts) for every real node in the app --
|
||||
// masked by every other test in this suite using a plain string `type`
|
||||
// ('div'), and by unit tests elsewhere that mock `@craftjs/core` entirely
|
||||
// (so `toNodeTree()` never actually returns a function there).
|
||||
test('does not throw when data.type is a live component function reference', () => {
|
||||
const ButtonLikeComponent = (props: { text?: string }) => props.text;
|
||||
const input = makeTree();
|
||||
input.nodes['root-1'].data.type = ButtonLikeComponent as unknown as Node['data']['type'];
|
||||
input.nodes['root-1'].data.props = { text: 'Click Me' };
|
||||
|
||||
expect(() => regenerateTreeIds(input)).not.toThrow();
|
||||
|
||||
const output = regenerateTreeIds(input);
|
||||
const outputRoot = output.nodes[output.rootNodeId];
|
||||
// The component reference itself is preserved (shared, not cloned --
|
||||
// it's a stable reference, not mutable data).
|
||||
expect(outputRoot.data.type).toBe(ButtonLikeComponent);
|
||||
// Props are still genuinely deep-cloned (the actual bug fix's point).
|
||||
expect(outputRoot.data.props).toEqual({ text: 'Click Me' });
|
||||
(outputRoot.data.props as { text: string }).text = 'changed';
|
||||
expect(input.nodes['root-1'].data.props).toEqual({ text: 'Click Me' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenTreeForCraft', () => {
|
||||
|
||||
@@ -366,12 +366,27 @@ export function regenerateTreeIds(tree: NodeTree): NodeTree {
|
||||
newLinkedNodes[slot] = remapId(linkedId);
|
||||
}
|
||||
|
||||
// Deep-clone data so mutable sub-objects (props, custom, etc.) are never
|
||||
// shared by reference between the original node and its regenerated
|
||||
// copy. Craft.js's setProp mutates data.props in place, so a shallow
|
||||
// copy here would let edits to the duplicate silently corrupt the
|
||||
// original.
|
||||
const clonedData = structuredClone(oldNode.data);
|
||||
// Deep-clone only the mutable sub-objects (props, custom) so they're
|
||||
// never shared by reference between the original node and its
|
||||
// regenerated copy -- Craft.js's setProp mutates data.props in place, so
|
||||
// a shallow copy here would let edits to the duplicate silently corrupt
|
||||
// the original.
|
||||
//
|
||||
// Deliberately NOT `structuredClone(oldNode.data)` as a whole: for a
|
||||
// LIVE Craft.js node (as opposed to a plain serialized one), `data.type`
|
||||
// is the actual component function/class reference (see the long
|
||||
// comment on `buildNodeTree` above) -- `structuredClone` cannot clone a
|
||||
// function and throws `DataCloneError`, which silently broke EVERY
|
||||
// duplicate/paste in the real app (masked in unit tests that mock
|
||||
// `@craftjs/core` with plain-data fake nodes, so `toNodeTree()` never
|
||||
// actually returns a function there). `type` is a stable reference that
|
||||
// both the original and the duplicate should point at unchanged, so it
|
||||
// never needed cloning in the first place.
|
||||
const clonedData = {
|
||||
...oldNode.data,
|
||||
props: structuredClone(oldNode.data.props),
|
||||
custom: structuredClone(oldNode.data.custom),
|
||||
};
|
||||
|
||||
const newNode: Node = {
|
||||
...oldNode,
|
||||
|
||||
Reference in New Issue
Block a user