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:
2026-07-13 08:07:51 -07:00
co-authored by Claude Opus 4.8
parent 2a071bc7ab
commit 2c8425ffb0
13 changed files with 1023 additions and 93 deletions
+17 -57
View File
@@ -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<ContextMenuProps> = ({
}
}, [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<ContextMenuProps> = ({
}, [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<ContextMenuProps> = ({
}, [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;