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,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;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||
import { Container } from '../../components/layout/Container';
|
||||
import { Section } from '../../components/layout/Section';
|
||||
import { ColumnLayout } from '../../components/layout/ColumnLayout';
|
||||
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
|
||||
|
||||
export const BlocksPanel: React.FC = () => {
|
||||
const { connectors, actions, query } = useEditor();
|
||||
const isMobile = useIsMobile();
|
||||
const { closeSheet } = useMobileChrome();
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
categories.forEach((cat, index) => {
|
||||
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
|
||||
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
|
||||
};
|
||||
|
||||
/** Default insertion target when there's no usable selection to anchor
|
||||
* to: the first real Craft.js canvas in the document (falls back to
|
||||
* ROOT). Extracted from the pre-existing onDoubleClick handler so
|
||||
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
|
||||
* the exact same fallback. */
|
||||
const findDefaultCanvasId = useCallback((): string => {
|
||||
try {
|
||||
const serialized = JSON.parse(query.serialize());
|
||||
const nodeIds = Object.keys(serialized);
|
||||
for (const id of nodeIds) {
|
||||
if (serialized[id].isCanvas && id !== 'ROOT') return id;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to ROOT below.
|
||||
}
|
||||
return 'ROOT';
|
||||
}, [query]);
|
||||
|
||||
/**
|
||||
* Builds a fresh node tree for `block` and inserts it into the canvas,
|
||||
* returning the new node's id (or null on failure). Shared by desktop's
|
||||
* double-click (unchanged behavior/position: always appended to
|
||||
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
|
||||
* prefers inserting as a sibling right after the current selection --
|
||||
* `insertAfterSelection: true` only from the mobile tap handler below.
|
||||
*/
|
||||
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
|
||||
try {
|
||||
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
|
||||
|
||||
let inserted = false;
|
||||
if (insertAfterSelection) {
|
||||
try {
|
||||
const selectedIds = query.getEvent('selected').all();
|
||||
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
|
||||
if (selectedId && selectedId !== 'ROOT') {
|
||||
const selectedNode = query.node(selectedId).get();
|
||||
const parentId = selectedNode?.data?.parent;
|
||||
if (parentId) {
|
||||
const parent = query.node(parentId).get();
|
||||
const siblings: string[] = parent?.data?.nodes || [];
|
||||
const idx = siblings.indexOf(selectedId);
|
||||
if (idx !== -1) {
|
||||
actions.addNodeTree(tree, parentId, idx + 1);
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Selection isn't a valid sibling target (e.g. lives in a
|
||||
// linkedNodes slot) -- fall through to the default canvas below.
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
actions.addNodeTree(tree, findDefaultCanvasId());
|
||||
}
|
||||
|
||||
return tree.rootNodeId;
|
||||
} catch (e) {
|
||||
console.error('Failed to add block:', e);
|
||||
return null;
|
||||
}
|
||||
}, [query, actions, findDefaultCanvasId]);
|
||||
|
||||
/**
|
||||
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
|
||||
* closes the Blocks sheet, then selects the new node and scrolls it into
|
||||
* view so the user immediately sees it (and gets the selection toolbar).
|
||||
* The select/scroll step is deferred two animation frames past the
|
||||
* `addNodeTree` call -- Craft.js's own state update (and thus the new
|
||||
* node's real DOM element) lands asynchronously after this handler
|
||||
* returns, so `query.node(id).get().dom` isn't populated yet if read
|
||||
* synchronously here.
|
||||
*/
|
||||
const handleTapToAdd = useCallback((block: BlockDef) => {
|
||||
const newId = addBlockNode(block, true);
|
||||
closeSheet();
|
||||
if (!newId) return;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
actions.selectNode(newId);
|
||||
} catch {
|
||||
// Node may have failed to mount -- nothing to select.
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} catch {
|
||||
// Best-effort scroll -- not fatal if the node/DOM isn't found.
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [addBlockNode, closeSheet, actions, query]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{categories.map((category) => {
|
||||
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
|
||||
key={block.id}
|
||||
className="block-item"
|
||||
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
|
||||
onDoubleClick={() => {
|
||||
try {
|
||||
const serialized = JSON.parse(query.serialize());
|
||||
const nodeIds = Object.keys(serialized);
|
||||
let canvasId = 'ROOT';
|
||||
for (const nodeId of nodeIds) {
|
||||
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
|
||||
canvasId = nodeId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
|
||||
actions.addNodeTree(tree, canvasId);
|
||||
} catch (e) {
|
||||
console.error('Failed to add block:', e);
|
||||
}
|
||||
}}
|
||||
title={`Drag or double-click to add ${block.label}`}
|
||||
onDoubleClick={() => addBlockNode(block, false)}
|
||||
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
|
||||
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
|
||||
>
|
||||
<i className={`fa ${block.icon} block-item-icon`} />
|
||||
<span className="block-item-label">{block.label}</span>
|
||||
|
||||
@@ -146,6 +146,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
{...clickableProps(handleActivate)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="layer-node-row"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
|
||||
@@ -10,7 +11,15 @@ interface HeadCodeModalProps {
|
||||
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
return (
|
||||
// Mobile-A2/Phase B: portaled to `document.body` -- same fix TemplateModal
|
||||
// already got (see its comment). TopBar.tsx mounts this modal as a child
|
||||
// of `.topbar`, which (as a flex item with its own z-index) forms its own
|
||||
// stacking context; that trapped the modal's fixed-position backdrop
|
||||
// underneath the mobile tab bar (z-index: var(--z-tabbar)) no matter how
|
||||
// high the modal's own z-index was set. Portaling escapes that stacking
|
||||
// context entirely so `--z-modal` (editor.css) is evaluated at the
|
||||
// document root, same as TemplateModal/SitesmithModal.
|
||||
return createPortal(
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
@@ -82,7 +91,8 @@ export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) =
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user