Ship-blocking fix (Fable consult): useNodeActions.duplicate() appended the regenerated tree at the end of the parent while leaving the ORIGINAL selected, so duplicating a top-level section landed the copy off-screen at the bottom of the page with no visible change -- shared by both the mobile selection toolbar and the desktop right-click ContextMenu. Now inserts the copy immediately after the source (actions.addNodeTree(tree, parentId, sourceIndex + 1)) and selects it (actions.selectNode(tree.rootNodeId)), falling back to append-at-end if the source's index can't be resolved. Mobile also scrolls the new node into view. Three cheap fast-follows: - canSelectParent on useNodeActions (false when the node's parent is ROOT or missing); MobileSelectionToolbar disables "Select Parent" instead of dead-ending on a page-wide ROOT outline with no toolbar of its own. - Opening the Styles sheet on mobile now scrolls the selected node above the 65dvh sheet; a temporary generous bottom-padding class handles the case where the node is the last thing on the page and there'd otherwise be no room left to scroll it into view. - Canvas gets bottom padding equal to the fixed selection toolbar's height while it's visible, so the last section of a short page isn't stuck permanently underneath it. Desktop duplicate behavior improves (inserts after + selects) via the shared hook; no toHtml changes. Verified live via Playwright at 375px and 1280px (screenshots in craft/scratchpad/mobileB2/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
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 };
|
|
});
|
|
// Collector-free -- an always-live reference (same pattern as
|
|
// GuidedStyles.tsx/BlocksPanel.tsx), used only for imperative DOM lookups
|
|
// below (scroll-into-view), never for anything that needs to react to
|
|
// store changes on its own.
|
|
const { query } = useEditor();
|
|
|
|
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]);
|
|
|
|
// Fast-follow item 3: the Styles bottom sheet covers the bottom ~65dvh of
|
|
// the canvas, so a selected node sitting anywhere in that band goes
|
|
// invisible the instant the sheet opens -- live style edits then have no
|
|
// visible effect until the user closes the sheet to check. Whenever the
|
|
// sheet becomes 'styles' (however it got opened -- currently only this
|
|
// toolbar's own "Style" button, but this is keyed on the sheet/selection
|
|
// state rather than the button tap so it stays correct if another entry
|
|
// point is added later) scroll the selected node to the top of the
|
|
// canvas's scroll area, into the sliver of screen the sheet leaves clear.
|
|
useEffect(() => {
|
|
if (activeSheet !== 'styles' || !selectedId) return;
|
|
try {
|
|
query.node(selectedId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
} catch {
|
|
// Best-effort -- not fatal if the node/DOM isn't found.
|
|
}
|
|
}, [activeSheet, selectedId, query]);
|
|
|
|
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(() => {
|
|
const newId = nodeActions.duplicate();
|
|
bumpTick();
|
|
if (!newId) return;
|
|
// Mirror BlocksPanel's tap-to-add pattern: the new node's real DOM
|
|
// element isn't attached yet the instant `duplicate()` returns (Craft.js
|
|
// mounts it asynchronously after this handler's synchronous state
|
|
// update), so defer two animation frames before reading `.dom` off the
|
|
// query, same as `handleTapToAdd` does.
|
|
requestAnimationFrame(() => {
|
|
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.
|
|
}
|
|
});
|
|
});
|
|
}, [nodeActions, bumpTick, query]);
|
|
|
|
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}
|
|
disabled={!nodeActions.canSelectParent}
|
|
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>
|
|
);
|
|
};
|