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
+87 -4
View File
@@ -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