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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user