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:
@@ -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<BottomSheetProps> = ({ open, onClose, title, children }) => {
|
||||
useEffect(() => {
|
||||
@@ -35,6 +56,78 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const { keyboardInset } = useVisualViewportInsets();
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(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<number | null>(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<BottomSheetProps> = ({ 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. */}
|
||||
<div className="mobile-sheet" role="dialog" aria-label={title}>
|
||||
<div className="mobile-sheet-handle-row">
|
||||
<div
|
||||
className="mobile-sheet"
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
style={{
|
||||
transform: dragOffset ? `translateY(${dragOffset}px)` : undefined,
|
||||
transition: dragOffset ? 'none' : 'transform 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mobile-sheet-handle-row"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
<span className="mobile-sheet-handle" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mobile-sheet-header">
|
||||
<div
|
||||
className="mobile-sheet-header"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
<span className="mobile-sheet-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -63,7 +177,7 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
|
||||
<i className="fa fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-sheet-body">{children}</div>
|
||||
<div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user