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 | 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 (