diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx
index 59b2866..d09b81f 100644
--- a/craft/src/editor/EditorShell.tsx
+++ b/craft/src/editor/EditorShell.tsx
@@ -4,6 +4,7 @@ import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel';
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
+import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu';
@@ -97,6 +98,7 @@ export const EditorShell: React.FC = () => {
{!isMobile && }
{isMobile && }
+ {isMobile && }
= {};
+
+const COPIED_TREE: NodeTree = {
+ rootNodeId: 'child-1',
+ nodes: {
+ 'child-1': {
+ id: 'child-1',
+ data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false },
+ info: {},
+ events: { selected: false, dragged: false, hovered: false },
+ dom: null,
+ related: {},
+ rules: {},
+ _hydrationTimestamp: 0,
+ } as unknown as Node,
+ },
+};
+
+function makeQuery() {
+ return {
+ node: (id: string) => ({
+ get: () => fakeNodes[id] ?? null,
+ toNodeTree: () => {
+ if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`);
+ return COPIED_TREE;
+ },
+ }),
+ };
+}
+
+vi.mock('@craftjs/core', () => ({
+ useEditor: (collector?: (state: any, query: any) => any) => {
+ const state = {
+ nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])),
+ };
+ const query = makeQuery();
+ const collected = collector ? collector(state, query) : {};
+ return {
+ ...collected,
+ actions: {
+ move: moveMock,
+ addNodeTree: addNodeTreeMock,
+ selectNode: selectNodeMock,
+ delete: deleteMock,
+ },
+ query,
+ };
+ },
+}));
+
+vi.mock('../utils/craft-tree', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, regenerateTreeIds: vi.fn(actual.regenerateTreeIds) };
+});
+
+import { regenerateTreeIds } from '../utils/craft-tree';
+const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
+
+let container: HTMLDivElement;
+let root: Root;
+let captured: NodeActions | null = null;
+
+const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => {
+ captured = useNodeActions(nodeId);
+ return null;
+};
+
+function render(nodeId: string | null) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => {
+ root = createRoot(container);
+ root.render();
+ });
+}
+
+function rerender(nodeId: string | null) {
+ act(() => {
+ root.render();
+ });
+}
+
+function unmount() {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+}
+
+beforeEach(() => {
+ moveMock.mockClear();
+ addNodeTreeMock.mockClear();
+ selectNodeMock.mockClear();
+ deleteMock.mockClear();
+ regenerateTreeIdsMock.mockClear();
+ captured = null;
+ fakeNodes = {
+ ROOT: { data: { parent: null, nodes: ['parent-1'] } },
+ 'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } },
+ 'child-0': { data: { parent: 'parent-1', nodes: [] } },
+ 'child-1': { data: { parent: 'parent-1', nodes: [] } },
+ 'child-2': { data: { parent: 'parent-1', nodes: [] } },
+ };
+});
+
+afterEach(() => {
+ if (root) unmount();
+});
+
+describe('useNodeActions', () => {
+ test('a middle child can move both up and down', () => {
+ render('child-1');
+ expect(captured!.canMoveUp).toBe(true);
+ expect(captured!.canMoveDown).toBe(true);
+ });
+
+ test('the first child cannot move up, but can move down', () => {
+ render('child-0');
+ expect(captured!.canMoveUp).toBe(false);
+ expect(captured!.canMoveDown).toBe(true);
+ });
+
+ test('the last child can move up, but not down', () => {
+ render('child-2');
+ expect(captured!.canMoveUp).toBe(true);
+ expect(captured!.canMoveDown).toBe(false);
+ });
+
+ test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => {
+ render('child-1');
+ act(() => captured!.moveUp());
+ expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0);
+
+ moveMock.mockClear();
+ act(() => captured!.moveDown());
+ expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3);
+ });
+
+ test('moveUp/moveDown at a boundary are no-ops', () => {
+ render('child-0');
+ act(() => captured!.moveUp());
+ expect(moveMock).not.toHaveBeenCalled();
+
+ rerender('child-2');
+ act(() => captured!.moveDown());
+ expect(moveMock).not.toHaveBeenCalled();
+ });
+
+ test('duplicate regenerates ids before calling actions.addNodeTree with the parent id', () => {
+ render('child-1');
+ act(() => captured!.duplicate());
+
+ expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
+ expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
+ const [tree, parentId] = addNodeTreeMock.mock.calls[0];
+ expect(parentId).toBe('parent-1');
+ expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
+ });
+
+ test('selectParent calls actions.selectNode with the parent id', () => {
+ render('child-1');
+ act(() => captured!.selectParent());
+ expect(selectNodeMock).toHaveBeenCalledWith('parent-1');
+ });
+
+ test('deleteNode calls actions.delete with the node id when deletable', () => {
+ render('child-1');
+ expect(captured!.canDelete).toBe(true);
+ act(() => captured!.deleteNode());
+ expect(deleteMock).toHaveBeenCalledWith('child-1');
+ });
+
+ test('ROOT nodeId disables every action and is a safe no-op', () => {
+ render('ROOT');
+ expect(captured!.canMoveUp).toBe(false);
+ expect(captured!.canMoveDown).toBe(false);
+ expect(captured!.canDelete).toBe(false);
+
+ act(() => {
+ captured!.moveUp();
+ captured!.moveDown();
+ captured!.duplicate();
+ captured!.selectParent();
+ captured!.deleteNode();
+ });
+ expect(moveMock).not.toHaveBeenCalled();
+ expect(addNodeTreeMock).not.toHaveBeenCalled();
+ expect(selectNodeMock).not.toHaveBeenCalled();
+ expect(deleteMock).not.toHaveBeenCalled();
+ });
+
+ test('null nodeId disables every action and is a safe no-op', () => {
+ render(null);
+ expect(captured!.canMoveUp).toBe(false);
+ expect(captured!.canMoveDown).toBe(false);
+ expect(captured!.canDelete).toBe(false);
+ });
+});
diff --git a/craft/src/hooks/useNodeActions.ts b/craft/src/hooks/useNodeActions.ts
new file mode 100644
index 0000000..63b3869
--- /dev/null
+++ b/craft/src/hooks/useNodeActions.ts
@@ -0,0 +1,169 @@
+import { useEditor } from '@craftjs/core';
+import { regenerateTreeIds } from '../utils/craft-tree';
+import { findDeletableTarget } from '../utils/craft-helpers';
+
+export interface NodeActions {
+ moveUp: () => void;
+ moveDown: () => void;
+ duplicate: () => void;
+ deleteNode: () => void;
+ selectParent: () => void;
+ /** True if `nodeId` has an earlier sibling under the same parent (so
+ * `moveUp` would actually move it). False for ROOT/null/no-parent. */
+ canMoveUp: boolean;
+ /** True if `nodeId` has a later sibling under the same parent (so
+ * `moveDown` would actually move it). False for ROOT/null/no-parent. */
+ canMoveDown: boolean;
+ /** True if `findDeletableTarget` resolves to a real, deletable node (either
+ * `nodeId` itself, or an ancestor when `nodeId` is an empty linked-node
+ * slot whose siblings are all also empty -- see `craft-helpers.ts`). */
+ canDelete: boolean;
+}
+
+/**
+ * Shared node-action logic (move up/down, duplicate, delete, select parent)
+ * extracted from `ContextMenu.tsx` (Phase B) so both the desktop right-click
+ * menu AND the mobile on-canvas selection toolbar (`MobileSelectionToolbar`)
+ * drive the exact same behavior from one place instead of two independent
+ * copies drifting apart.
+ *
+ * IMPORTANT gotcha this hook works around: `@craftjs/core`'s `useEditor`
+ * collector (`@craftjs/utils`' `useCollector`) only synchronously computes
+ * the collector function ONCE, on this hook's very first mount. After that,
+ * it updates purely in reaction to the underlying store's OWN change
+ * notifications, invoking whatever collector closure is current AT THAT
+ * NOTIFICATION -- so a collector that closes over `nodeId` (an argument that
+ * changes across renders of the SAME mounted hook instance, e.g. every time
+ * `MobileSelectionToolbar` re-renders with a newly-selected node) goes stale
+ * for exactly one render: the cached value from the last store notification
+ * (computed against the PREVIOUS nodeId) is what gets returned, until some
+ * unrelated store event happens to trigger a fresh computation. An earlier
+ * version of this hook computed `canMoveUp`/`canMoveDown`/`canDelete` inside
+ * such a collector and was caught showing the PREVIOUS selection's move
+ * boundaries in the mobile toolbar for one render after tapping a new node
+ * (verified with real Playwright touch taps against a real Craft.js
+ * document -- a plain mocked `useEditor` in a unit test doesn't reproduce
+ * this, since a hand-rolled mock has no reason to replicate the real
+ * library's caching).
+ *
+ * The fix: get `actions`/`query` from a collector-FREE `useEditor()` call
+ * (an always-live reference, same pattern `ContextMenu.tsx` used before this
+ * extraction) and compute `canMoveUp`/`canMoveDown`/`canDelete` as plain
+ * synchronous code during render using that live `query` -- never cached.
+ *
+ * That still leaves one gap: `nodeId` staying the SAME across renders while
+ * its position changes (e.g. tapping "Move Up" repeatedly on the same
+ * still-selected node) needs SOMETHING to trigger a re-render so the boundary
+ * flags below get recomputed. An earlier version of this hook forced that
+ * via a second `useEditor((state) => ({ _: state.nodes }))` subscription --
+ * but subscribing to the WHOLE node map reacts to every `dom` ref
+ * assignment too (Craft's `connectors.connect(ref)` calls `actions.setDOM`
+ * synchronously from a React ref callback during COMMIT, i.e. while some
+ * OTHER component is still mounting), which trips React's "Cannot update a
+ * component while rendering a different component" warning the moment a
+ * freshly-added container with children mounts. Callers that need
+ * `canMoveUp`/`canMoveDown` to refresh after a move they themselves
+ * triggered (`MobileSelectionToolbar`) should instead bump their OWN local
+ * state right after calling `moveUp`/`moveDown` -- a plain, local,
+ * event-handler-triggered re-render, not a store-wide subscription.
+ */
+export function useNodeActions(nodeId: string | null | undefined): NodeActions {
+ const { actions, query } = useEditor();
+
+ const isRootOrNull = !nodeId || nodeId === 'ROOT';
+
+ let canMoveUp = false;
+ let canMoveDown = false;
+ if (!isRootOrNull) {
+ try {
+ const node = query.node(nodeId).get();
+ const parentId: string | null | undefined = node?.data?.parent;
+ if (parentId) {
+ const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
+ const idx = siblings.indexOf(nodeId);
+ canMoveUp = idx > 0;
+ canMoveDown = idx !== -1 && idx < siblings.length - 1;
+ }
+ } catch {
+ // Node no longer exists (e.g. deleted out from under a stale
+ // reference) -- leave both false.
+ }
+ }
+
+ const canDelete = !isRootOrNull && !!findDeletableTarget(query, nodeId);
+
+ const getParentId = (): string | null => {
+ if (!nodeId) return null;
+ try {
+ const node = query.node(nodeId).get();
+ return node?.data?.parent || null;
+ } catch {
+ return null;
+ }
+ };
+
+ const duplicate = () => {
+ if (!nodeId || nodeId === 'ROOT') return;
+ try {
+ const parentId = getParentId();
+ if (!parentId) return;
+
+ const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
+ actions.addNodeTree(tree, parentId);
+ } catch (e) {
+ console.error('Duplicate failed:', e);
+ }
+ };
+
+ const moveUp = () => {
+ if (!nodeId || nodeId === 'ROOT') return;
+ try {
+ const parentId = getParentId();
+ if (!parentId) return;
+ const parent = query.node(parentId).get();
+ const children = parent.data.nodes || [];
+ const idx = children.indexOf(nodeId);
+ if (idx > 0) {
+ actions.move(nodeId, parentId, idx - 1);
+ }
+ } catch (e) {
+ console.error('Move up failed:', e);
+ }
+ };
+
+ const moveDown = () => {
+ if (!nodeId || nodeId === 'ROOT') return;
+ try {
+ const parentId = getParentId();
+ if (!parentId) return;
+ const parent = query.node(parentId).get();
+ const children = parent.data.nodes || [];
+ const idx = children.indexOf(nodeId);
+ if (idx < children.length - 1) {
+ actions.move(nodeId, parentId, idx + 2);
+ }
+ } catch (e) {
+ console.error('Move down failed:', e);
+ }
+ };
+
+ const selectParent = () => {
+ if (!nodeId || nodeId === 'ROOT') return;
+ const parentId = getParentId();
+ if (parentId) {
+ actions.selectNode(parentId);
+ }
+ };
+
+ const deleteNode = () => {
+ const target = findDeletableTarget(query, nodeId);
+ if (!target) return;
+ try {
+ actions.delete(target);
+ } catch (e) {
+ console.error('Delete failed:', e);
+ }
+ };
+
+ return { moveUp, moveDown, duplicate, deleteNode, selectParent, canMoveUp, canMoveDown, canDelete };
+}
diff --git a/craft/src/hooks/useVisualViewport.ts b/craft/src/hooks/useVisualViewport.ts
new file mode 100644
index 0000000..013660e
--- /dev/null
+++ b/craft/src/hooks/useVisualViewport.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react';
+
+export interface VisualViewportInsets {
+ /** The visual viewport's current height (shrinks when the on-screen
+ * keyboard opens). Falls back to `window.innerHeight` when
+ * `visualViewport` isn't supported. */
+ height: number;
+ /** Extra inset a `position: fixed`, bottom-anchored element should add to
+ * its own `bottom` offset to stay clear of the on-screen keyboard --
+ * `window.innerHeight` minus the visual viewport's bottom edge (its
+ * height + offsetTop). Zero whenever no keyboard is open, or
+ * `visualViewport` isn't supported (a safe no-op fallback). */
+ keyboardInset: number;
+}
+
+const ZERO_INSETS: VisualViewportInsets = { height: 0, keyboardInset: 0 };
+
+function computeInsets(): VisualViewportInsets {
+ if (typeof window === 'undefined') return ZERO_INSETS;
+ const vv = window.visualViewport;
+ if (!vv) return { height: window.innerHeight, keyboardInset: 0 };
+ const keyboardInset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
+ return { height: vv.height, keyboardInset };
+}
+
+/**
+ * Tracks `window.visualViewport`'s height/offset (item 5, Phase B) so
+ * `BottomSheet` can stay clear of the on-screen keyboard. `position: fixed`
+ * elements are positioned against the LAYOUT viewport, which does NOT shrink
+ * when a mobile keyboard opens -- only the visual viewport does -- so a
+ * bottom-anchored sheet's inputs can otherwise end up hidden underneath the
+ * keyboard with no visual indication anything is wrong.
+ *
+ * Guards for browsers without `visualViewport` (older WebViews): falls back
+ * to `{ height: window.innerHeight, keyboardInset: 0 }`, i.e. a no-op, so
+ * the sheet just keeps its existing (keyboard-unaware) sizing there.
+ */
+export function useVisualViewportInsets(): VisualViewportInsets {
+ const [insets, setInsets] = useState(computeInsets);
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || !window.visualViewport) return;
+ const vv = window.visualViewport;
+ const handleChange = () => setInsets(computeInsets());
+ handleChange();
+ vv.addEventListener('resize', handleChange);
+ vv.addEventListener('scroll', handleChange);
+ return () => {
+ vv.removeEventListener('resize', handleChange);
+ vv.removeEventListener('scroll', handleChange);
+ };
+ }, []);
+
+ return insets;
+}
diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx
index 7e89445..1d31e0a 100644
--- a/craft/src/panels/context-menu/ContextMenu.tsx
+++ b/craft/src/panels/context-menu/ContextMenu.tsx
@@ -1,10 +1,10 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
-import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
+import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps {
visible: boolean;
@@ -65,19 +65,16 @@ export const ContextMenu: React.FC = ({
}
}, [nodeId, query]);
- const duplicate = useCallback(() => {
- if (!nodeId || nodeId === 'ROOT') return;
- try {
- const parentId = getParentId();
- if (!parentId) return;
+ // Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
+ // extracted into `useNodeActions` so the mobile selection toolbar drives
+ // the exact same behavior. Wrapped here purely to also `onClose()` the
+ // menu after each action, same as before the extraction.
+ const nodeActions = useNodeActions(nodeId);
- const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
- actions.addNodeTree(tree, parentId);
- } catch (e) {
- console.error('Duplicate failed:', e);
- }
+ const duplicate = useCallback(() => {
+ nodeActions.duplicate();
onClose();
- }, [nodeId, actions, query, getParentId, onClose]);
+ }, [nodeActions, onClose]);
const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
@@ -118,47 +115,19 @@ export const ContextMenu: React.FC = ({
}, [nodeId, actions, query, onClose]);
const moveUp = useCallback(() => {
- if (!nodeId || nodeId === 'ROOT') return;
- try {
- const parentId = getParentId();
- if (!parentId) return;
- const parent = query.node(parentId).get();
- const children = parent.data.nodes || [];
- const idx = children.indexOf(nodeId);
- if (idx > 0) {
- actions.move(nodeId, parentId, idx - 1);
- }
- } catch (e) {
- console.error('Move up failed:', e);
- }
+ nodeActions.moveUp();
onClose();
- }, [nodeId, actions, query, getParentId, onClose]);
+ }, [nodeActions, onClose]);
const moveDown = useCallback(() => {
- if (!nodeId || nodeId === 'ROOT') return;
- try {
- const parentId = getParentId();
- if (!parentId) return;
- const parent = query.node(parentId).get();
- const children = parent.data.nodes || [];
- const idx = children.indexOf(nodeId);
- if (idx < children.length - 1) {
- actions.move(nodeId, parentId, idx + 2);
- }
- } catch (e) {
- console.error('Move down failed:', e);
- }
+ nodeActions.moveDown();
onClose();
- }, [nodeId, actions, query, getParentId, onClose]);
+ }, [nodeActions, onClose]);
const selectParent = useCallback(() => {
- if (!nodeId || nodeId === 'ROOT') return;
- const parentId = getParentId();
- if (parentId) {
- actions.selectNode(parentId);
- }
+ nodeActions.selectParent();
onClose();
- }, [nodeId, actions, getParentId, onClose]);
+ }, [nodeActions, onClose]);
const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
@@ -172,18 +141,9 @@ export const ContextMenu: React.FC = ({
}, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => {
- const target = findDeletableTarget(query, nodeId);
- if (!target) {
- onClose();
- return;
- }
- try {
- actions.delete(target);
- } catch (e) {
- console.error('Delete failed:', e);
- }
+ nodeActions.deleteNode();
onClose();
- }, [nodeId, actions, query, onClose]);
+ }, [nodeActions, onClose]);
if (!visible) return null;
diff --git a/craft/src/panels/left/BlocksPanel.tsx b/craft/src/panels/left/BlocksPanel.tsx
index 4ab34f6..d4351b4 100644
--- a/craft/src/panels/left/BlocksPanel.tsx
+++ b/craft/src/panels/left/BlocksPanel.tsx
@@ -1,5 +1,7 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core';
+import { useIsMobile } from '../../hooks/useIsMobile';
+import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
+ const isMobile = useIsMobile();
+ const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState>(() => {
const initial: Record = {};
categories.forEach((cat, index) => {
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
};
+ /** Default insertion target when there's no usable selection to anchor
+ * to: the first real Craft.js canvas in the document (falls back to
+ * ROOT). Extracted from the pre-existing onDoubleClick handler so
+ * tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
+ * the exact same fallback. */
+ const findDefaultCanvasId = useCallback((): string => {
+ try {
+ const serialized = JSON.parse(query.serialize());
+ const nodeIds = Object.keys(serialized);
+ for (const id of nodeIds) {
+ if (serialized[id].isCanvas && id !== 'ROOT') return id;
+ }
+ } catch {
+ // Fall through to ROOT below.
+ }
+ return 'ROOT';
+ }, [query]);
+
+ /**
+ * Builds a fresh node tree for `block` and inserts it into the canvas,
+ * returning the new node's id (or null on failure). Shared by desktop's
+ * double-click (unchanged behavior/position: always appended to
+ * `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
+ * prefers inserting as a sibling right after the current selection --
+ * `insertAfterSelection: true` only from the mobile tap handler below.
+ */
+ const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
+ try {
+ const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
+
+ let inserted = false;
+ if (insertAfterSelection) {
+ try {
+ const selectedIds = query.getEvent('selected').all();
+ const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
+ if (selectedId && selectedId !== 'ROOT') {
+ const selectedNode = query.node(selectedId).get();
+ const parentId = selectedNode?.data?.parent;
+ if (parentId) {
+ const parent = query.node(parentId).get();
+ const siblings: string[] = parent?.data?.nodes || [];
+ const idx = siblings.indexOf(selectedId);
+ if (idx !== -1) {
+ actions.addNodeTree(tree, parentId, idx + 1);
+ inserted = true;
+ }
+ }
+ }
+ } catch {
+ // Selection isn't a valid sibling target (e.g. lives in a
+ // linkedNodes slot) -- fall through to the default canvas below.
+ }
+ }
+
+ if (!inserted) {
+ actions.addNodeTree(tree, findDefaultCanvasId());
+ }
+
+ return tree.rootNodeId;
+ } catch (e) {
+ console.error('Failed to add block:', e);
+ return null;
+ }
+ }, [query, actions, findDefaultCanvasId]);
+
+ /**
+ * Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
+ * closes the Blocks sheet, then selects the new node and scrolls it into
+ * view so the user immediately sees it (and gets the selection toolbar).
+ * The select/scroll step is deferred two animation frames past the
+ * `addNodeTree` call -- Craft.js's own state update (and thus the new
+ * node's real DOM element) lands asynchronously after this handler
+ * returns, so `query.node(id).get().dom` isn't populated yet if read
+ * synchronously here.
+ */
+ const handleTapToAdd = useCallback((block: BlockDef) => {
+ const newId = addBlockNode(block, true);
+ closeSheet();
+ if (!newId) return;
+ requestAnimationFrame(() => {
+ try {
+ actions.selectNode(newId);
+ } catch {
+ // Node may have failed to mount -- nothing to select.
+ }
+ requestAnimationFrame(() => {
+ try {
+ query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ } catch {
+ // Best-effort scroll -- not fatal if the node/DOM isn't found.
+ }
+ });
+ });
+ }, [addBlockNode, closeSheet, actions, query]);
+
return (
{categories.map((category) => {
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
key={block.id}
className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
- onDoubleClick={() => {
- try {
- const serialized = JSON.parse(query.serialize());
- const nodeIds = Object.keys(serialized);
- let canvasId = 'ROOT';
- for (const nodeId of nodeIds) {
- if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
- canvasId = nodeId;
- break;
- }
- }
- const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
- actions.addNodeTree(tree, canvasId);
- } catch (e) {
- console.error('Failed to add block:', e);
- }
- }}
- title={`Drag or double-click to add ${block.label}`}
+ onDoubleClick={() => addBlockNode(block, false)}
+ onClick={isMobile ? () => handleTapToAdd(block) : undefined}
+ title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
>
{block.label}
diff --git a/craft/src/panels/left/LayersPanel.tsx b/craft/src/panels/left/LayersPanel.tsx
index ad23cfe..b057af6 100644
--- a/craft/src/panels/left/LayersPanel.tsx
+++ b/craft/src/panels/left/LayersPanel.tsx
@@ -146,6 +146,7 @@ const LayerNode: React.FC = ({ nodeId, depth }) => {
{...clickableProps(handleActivate)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
+ className="layer-node-row"
style={{
position: 'relative',
display: 'flex',
diff --git a/craft/src/panels/mobile/BottomSheet.tsx b/craft/src/panels/mobile/BottomSheet.tsx
index aed4f59..bf191d6 100644
--- a/craft/src/panels/mobile/BottomSheet.tsx
+++ b/craft/src/panels/mobile/BottomSheet.tsx
@@ -1,4 +1,5 @@
-import React, { useEffect } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useVisualViewportInsets } from '../../hooks/useVisualViewport';
export interface BottomSheetProps {
open: boolean;
@@ -7,6 +8,10 @@ export interface BottomSheetProps {
children: React.ReactNode;
}
+/** Vertical drag distance (px) past which releasing the handle/header
+ * commits to closing the sheet, rather than snapping back open. */
+const SWIPE_DISMISS_THRESHOLD_PX = 80;
+
/**
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components
@@ -18,6 +23,22 @@ export interface BottomSheetProps {
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases.
+ *
+ * Phase B adds two more deferred behaviors on top of Phase A's static sheet:
+ * - **Swipe-to-dismiss**: a touchstart/move/end drag tracked on the handle
+ * row + header (not the scrollable body, so it never fights a panel's own
+ * vertical scroll) that follows the finger while dragging down and either
+ * commits to `onClose()` past `SWIPE_DISMISS_THRESHOLD_PX` or snaps back
+ * with a short transition otherwise.
+ * - **On-screen-keyboard clearance**: `useVisualViewportInsets` reports how
+ * much the visual viewport has shrunk from the bottom (i.e. the
+ * keyboard's height); that's surfaced as a `--keyboard-inset` CSS custom
+ * property (see editor.css) which both lifts the whole backdrop/sheet
+ * clear of the keyboard AND caps the sheet's own max-height so it never
+ * extends into the space the keyboard occupies. A `focusin` listener also
+ * scrolls the newly-focused input into view once the keyboard has had a
+ * moment to animate in, as a second line of defense for a field that's
+ * still off-screen after the resize alone (e.g. deep in a long panel).
*/
export const BottomSheet: React.FC = ({ open, onClose, title, children }) => {
useEffect(() => {
@@ -35,6 +56,78 @@ export const BottomSheet: React.FC = ({ open, onClose, title,
};
}, [open, onClose]);
+ const { keyboardInset } = useVisualViewportInsets();
+
+ const bodyRef = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const bodyEl = bodyRef.current;
+ if (!bodyEl) return;
+ const handleFocusIn = (e: FocusEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (!target) return;
+ const tag = target.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) {
+ // Give the on-screen keyboard a moment to finish animating in
+ // before scrolling -- doing it immediately measures the pre-keyboard
+ // layout and can undershoot.
+ window.setTimeout(() => {
+ target.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ }, 300);
+ }
+ };
+ bodyEl.addEventListener('focusin', handleFocusIn);
+ return () => bodyEl.removeEventListener('focusin', handleFocusIn);
+ }, [open]);
+
+ // Swipe-to-dismiss: tracked only on the handle row + header (never the
+ // scrollable `.mobile-sheet-body`, which needs its own vertical touch
+ // scrolling to keep working unimpeded).
+ //
+ // The authoritative drag distance lives in a REF (`dragOffsetRef`),
+ // updated synchronously and imperatively on every touchmove -- NOT solely
+ // in the `dragOffset` state used for rendering the drag transform. Two
+ // touchmoves can fire back-to-back inside the same synchronous event
+ // dispatch (e.g. a fast real swipe, or synthetic touch events fired
+ // without a yield between them), and React batches their `setDragOffset`
+ // calls into a single pending update that hasn't committed yet by the
+ // time `touchend` runs in that same tick -- reading the `dragOffset`
+ // STATE value directly in `handleTouchEnd` would then see a stale
+ // (pre-drag) value and wrongly decide the swipe didn't clear the
+ // threshold. The ref sidesteps that entirely (plain synchronous mutation,
+ // no batching). Relatedly, `onClose()` mutates an ANCESTOR component's
+ // state (MobileChromeContext's `closeSheet`) -- it must be called as a
+ // plain statement in the event handler, never from inside a `setState`
+ // functional updater (that runs during this component's own render phase
+ // and trips React's "Cannot update a component while rendering a
+ // different component" warning).
+ const dragStartYRef = useRef(null);
+ const dragOffsetRef = useRef(0);
+ const [dragOffset, setDragOffset] = useState(0);
+
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
+ if (e.touches.length !== 1) return;
+ dragStartYRef.current = e.touches[0].clientY;
+ }, []);
+
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
+ if (dragStartYRef.current === null) return;
+ const delta = e.touches[0].clientY - dragStartYRef.current;
+ // Only follow downward drags -- dragging up shouldn't do anything (the
+ // sheet is already fully open; there's no "expand further" state).
+ const next = Math.max(0, delta);
+ dragOffsetRef.current = next;
+ setDragOffset(next);
+ }, []);
+
+ const handleTouchEnd = useCallback(() => {
+ dragStartYRef.current = null;
+ const finalOffset = dragOffsetRef.current;
+ dragOffsetRef.current = 0;
+ setDragOffset(0);
+ if (finalOffset > SWIPE_DISMISS_THRESHOLD_PX) onClose();
+ }, [onClose]);
+
if (!open) return null;
return (
@@ -43,16 +136,37 @@ export const BottomSheet: React.FC = ({ open, onClose, title,
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
+ style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
>
{/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
while a sheet is open (tapping another tab swaps sheets directly),
so the rest of the screen must not be marked inert to assistive
tech the way a true modal dialog would be. */}
-
-
+
+
-
+
{title}
-
{children}
+
{children}
);
diff --git a/craft/src/panels/mobile/MobileSelectionToolbar.tsx b/craft/src/panels/mobile/MobileSelectionToolbar.tsx
new file mode 100644
index 0000000..ab21db3
--- /dev/null
+++ b/craft/src/panels/mobile/MobileSelectionToolbar.tsx
@@ -0,0 +1,174 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useEditor } from '@craftjs/core';
+import { useNodeActions } from '../../hooks/useNodeActions';
+import { useMobileChrome } from '../../state/MobileChromeContext';
+
+/**
+ * Phase B mobile selection toolbar -- the on-canvas action bar that makes a
+ * tap-selected node actually EDITABLE by touch (reorder/duplicate/delete/
+ * select-parent/edit-styles), mirroring the desktop right-click ContextMenu.
+ * Both share the exact same behavior via `useNodeActions` (item 1).
+ *
+ * Mounted ONCE (in `EditorShell`'s mobile branch), reading the current
+ * selection from `useEditor` directly -- NOT per-node -- and positioned
+ * bottom-fixed just above the tab bar (simplest, robust; a node-anchored
+ * floating toolbar would have to constantly reposition as the canvas
+ * scrolls, and risks being clipped at the viewport edge for a node near the
+ * top or bottom of a tall page). This is a deliberate design call: see the
+ * report for the alternative (floating over the node) that was passed over.
+ *
+ * Hidden whenever a bottom sheet is open (`activeSheet !== null`) -- the
+ * sheet supersedes it, and the two must never visually stack.
+ *
+ * Delete is a two-tap in-app confirm (tap once -> the button becomes
+ * "Confirm?" for a few seconds; tap again within that window commits the
+ * delete; selecting something else cancels it) rather than a native
+ * `confirm()`, matching the project's no-native-dialogs rule.
+ */
+const DELETE_CONFIRM_TIMEOUT_MS = 3000;
+
+export const MobileSelectionToolbar: React.FC = () => {
+ const { activeSheet, openSheet } = useMobileChrome();
+ const { selectedId } = useEditor((state) => {
+ const selected = state.events.selected;
+ const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
+ return { selectedId: id && id !== 'ROOT' ? id : null };
+ });
+
+ const nodeActions = useNodeActions(selectedId);
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
+ const confirmTimeoutRef = useRef | null>(null);
+
+ // `useNodeActions` computes canMoveUp/canMoveDown/canDelete synchronously
+ // from a live (never-cached) Craft.js query -- see that hook's doc comment
+ // for why it deliberately does NOT hold its own store subscription to force
+ // a refresh. Moving/duplicating/deleting via THIS toolbar changes the
+ // selected node's position/siblings WITHOUT changing `selectedId` itself
+ // (the node stays selected), so nothing else would otherwise trigger a
+ // re-render of this component to pick up the new boundary flags after,
+ // say, tapping "Move Up" until the node reaches the top. Bumping a plain
+ // local tick from these buttons' own click handlers -- an ordinary,
+ // locally-owned re-render -- covers exactly that, without the broader
+ // store-wide subscription that was found to trip React's "setState while
+ // rendering a different component" warning whenever some unrelated
+ // container mounts (see useNodeActions.ts).
+ const [, forceRefresh] = useState(0);
+ const bumpTick = useCallback(() => forceRefresh((n) => n + 1), []);
+
+ const clearConfirmTimeout = useCallback(() => {
+ if (confirmTimeoutRef.current) {
+ clearTimeout(confirmTimeoutRef.current);
+ confirmTimeoutRef.current = null;
+ }
+ }, []);
+
+ // Reset the two-tap delete confirmation whenever the selection changes
+ // (including being cleared) so a stale "Confirm?" state never lingers onto
+ // a different node.
+ const prevSelectedRef = useRef(selectedId);
+ useEffect(() => {
+ if (prevSelectedRef.current !== selectedId) {
+ prevSelectedRef.current = selectedId;
+ clearConfirmTimeout();
+ setConfirmingDelete(false);
+ }
+ }, [selectedId, clearConfirmTimeout]);
+
+ useEffect(() => clearConfirmTimeout, [clearConfirmTimeout]);
+
+ const handleDeleteTap = useCallback(() => {
+ if (confirmingDelete) {
+ clearConfirmTimeout();
+ setConfirmingDelete(false);
+ nodeActions.deleteNode();
+ bumpTick();
+ return;
+ }
+ setConfirmingDelete(true);
+ clearConfirmTimeout();
+ confirmTimeoutRef.current = setTimeout(() => setConfirmingDelete(false), DELETE_CONFIRM_TIMEOUT_MS);
+ }, [confirmingDelete, clearConfirmTimeout, nodeActions, bumpTick]);
+
+ const handleEditStyles = useCallback(() => {
+ openSheet('styles');
+ }, [openSheet]);
+
+ const handleMoveUp = useCallback(() => {
+ nodeActions.moveUp();
+ bumpTick();
+ }, [nodeActions, bumpTick]);
+
+ const handleMoveDown = useCallback(() => {
+ nodeActions.moveDown();
+ bumpTick();
+ }, [nodeActions, bumpTick]);
+
+ const handleDuplicate = useCallback(() => {
+ nodeActions.duplicate();
+ bumpTick();
+ }, [nodeActions, bumpTick]);
+
+ if (!selectedId || activeSheet !== null) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/craft/src/panels/topbar/HeadCodeModal.tsx b/craft/src/panels/topbar/HeadCodeModal.tsx
index d84f528..b73d2c7 100644
--- a/craft/src/panels/topbar/HeadCodeModal.tsx
+++ b/craft/src/panels/topbar/HeadCodeModal.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { createPortal } from 'react-dom';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { Modal } from '../../ui/Modal';
@@ -10,7 +11,15 @@ interface HeadCodeModalProps {
export const HeadCodeModal: React.FC = ({ open, onClose }) => {
const { design, updateDesign } = useSiteDesign();
- return (
+ // Mobile-A2/Phase B: portaled to `document.body` -- same fix TemplateModal
+ // already got (see its comment). TopBar.tsx mounts this modal as a child
+ // of `.topbar`, which (as a flex item with its own z-index) forms its own
+ // stacking context; that trapped the modal's fixed-position backdrop
+ // underneath the mobile tab bar (z-index: var(--z-tabbar)) no matter how
+ // high the modal's own z-index was set. Portaling escapes that stacking
+ // context entirely so `--z-modal` (editor.css) is evaluated at the
+ // document root, same as TemplateModal/SitesmithModal.
+ return createPortal(
-
+ ,
+ document.body,
);
};
diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css
index c1a22ea..4fb41e5 100644
--- a/craft/src/styles/editor.css
+++ b/craft/src/styles/editor.css
@@ -1626,6 +1626,65 @@ body {
color: var(--color-accent);
}
+ /* --------------------------------------------------------------------
+ Mobile selection toolbar (MobileSelectionToolbar.tsx, Phase B item 2)
+ Bottom-fixed just above the tab bar -- simplest, robust positioning
+ that never has to track a scrolling/resizing node's DOM rect. Sits at
+ --z-selection-toolbar (600), above the tab bar (500) and sheet backdrop
+ (400) so it's never occluded by either, but well below --z-modal
+ (5000). Hidden entirely (component returns null) whenever a sheet is
+ open, so it never has to fight the sheet for the same screen space.
+ -------------------------------------------------------------------- */
+ .mobile-selection-toolbar {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
+ z-index: var(--z-selection-toolbar);
+ display: flex;
+ background: var(--color-bg-elevated);
+ border-top: 1px solid var(--color-border);
+ box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
+ padding: 4px calc(2px + env(safe-area-inset-right, 0px)) 4px calc(2px + env(safe-area-inset-left, 0px));
+ }
+
+ .mobile-selection-toolbar-btn {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ min-height: 44px;
+ padding: 4px 2px;
+ font-size: 9px;
+ font-weight: 600;
+ line-height: 1;
+ color: var(--color-text);
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ touch-action: manipulation;
+ }
+
+ .mobile-selection-toolbar-btn i {
+ font-size: 15px;
+ }
+
+ .mobile-selection-toolbar-btn:disabled {
+ color: var(--color-text-dim);
+ opacity: 0.4;
+ }
+
+ .mobile-selection-toolbar-btn.danger {
+ color: var(--color-danger);
+ }
+
+ .mobile-selection-toolbar-btn.danger.confirming {
+ background: rgba(239, 68, 68, 0.15);
+ }
+
/* --------------------------------------------------------------------
Bottom sheet (BottomSheet.tsx)
-------------------------------------------------------------------- */
@@ -1637,8 +1696,13 @@ body {
/* Stop above the bottom tab bar (rather than a full-viewport `inset: 0`)
so the tab bar is never covered/blocked by the backdrop -- tapping a
different tab while a sheet is open swaps sheets directly instead of
- being swallowed by the overlay sitting on top of it. */
- bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
+ being swallowed by the overlay sitting on top of it.
+ `--keyboard-inset` (item 5, Phase B) is set inline by BottomSheet.tsx
+ from `useVisualViewportInsets` -- 0px when no on-screen keyboard is
+ open (the default below is the no-JS/unsupported-browser fallback),
+ otherwise the keyboard's height, so the whole sheet lifts clear of it
+ instead of the keyboard covering its lower portion. */
+ bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px) + var(--keyboard-inset, 0px));
background: rgba(0, 0, 0, 0.5);
z-index: var(--z-sheet-backdrop);
display: flex;
@@ -1647,8 +1711,12 @@ body {
.mobile-sheet {
width: 100%;
- max-height: 65vh;
- max-height: 65dvh;
+ /* Capped a second time by the keyboard inset so the sheet's own
+ max-height shrinks along with the lifted backdrop -- without this the
+ sheet could still try to be 65dvh tall from its new (higher) bottom
+ edge and overflow off the top of the screen. */
+ max-height: calc(65vh - var(--keyboard-inset, 0px));
+ max-height: calc(65dvh - var(--keyboard-inset, 0px));
background: var(--color-bg-surface);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
@@ -1664,6 +1732,10 @@ body {
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
+ /* Swipe-to-dismiss (item 4, Phase B) tracks its own touchmove here --
+ `none` stops the browser's native scroll/rubber-band gesture from
+ competing with it. */
+ touch-action: none;
}
.mobile-sheet-handle {
@@ -1680,6 +1752,8 @@ body {
padding: 4px 12px 10px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
+ /* Swipe-to-dismiss (item 4, Phase B) -- see .mobile-sheet-handle-row. */
+ touch-action: none;
}
.mobile-sheet-title {
@@ -1749,6 +1823,15 @@ body {
height: 40px;
}
+ /* Layers panel rows (item 4) -- the reliable fallback selection path when
+ a node is hard to hit precisely on the canvas by touch. Bumped to the
+ 44px touch-target minimum; tapping a row already selects (LayerNode's
+ `handleActivate`), unchanged. */
+ .mobile-sheet-body .layer-node-row {
+ min-height: 44px;
+ touch-action: manipulation;
+ }
+
/* --------------------------------------------------------------------
Templates modal: category tabs stay horizontally scrollable and never
shrink below a comfortable touch target; close button enlarged to the
diff --git a/craft/src/utils/craft-tree.test.ts b/craft/src/utils/craft-tree.test.ts
index 8d7df6f..f4df9b3 100644
--- a/craft/src/utils/craft-tree.test.ts
+++ b/craft/src/utils/craft-tree.test.ts
@@ -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', () => {
diff --git a/craft/src/utils/craft-tree.ts b/craft/src/utils/craft-tree.ts
index e201920..9db61ea 100644
--- a/craft/src/utils/craft-tree.ts
+++ b/craft/src/utils/craft-tree.ts
@@ -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,