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
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="mobile-selection-toolbar" role="toolbar" aria-label="Selected element actions">
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveUp}
disabled={!nodeActions.canMoveUp}
aria-label="Move up"
>
<i className="fa fa-arrow-up" aria-hidden="true" />
<span>Up</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveDown}
disabled={!nodeActions.canMoveDown}
aria-label="Move down"
>
<i className="fa fa-arrow-down" aria-hidden="true" />
<span>Down</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleDuplicate}
aria-label="Duplicate"
>
<i className="fa fa-clone" aria-hidden="true" />
<span>Copy</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={nodeActions.selectParent}
aria-label="Select parent"
>
<i className="fa fa-level-up" aria-hidden="true" />
<span>Parent</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleEditStyles}
aria-label="Edit styles"
>
<i className="fa fa-paint-brush" aria-hidden="true" />
<span>Style</span>
</button>
<button
type="button"
className={`mobile-selection-toolbar-btn danger${confirmingDelete ? ' confirming' : ''}`}
onClick={handleDeleteTap}
disabled={!nodeActions.canDelete}
aria-label={confirmingDelete ? 'Tap again to confirm delete' : 'Delete'}
>
<i className="fa fa-trash" aria-hidden="true" />
<span>{confirmingDelete ? 'Confirm?' : 'Delete'}</span>
</button>
</div>
);
};