import { useEditor } from '@craftjs/core'; import { regenerateTreeIds } from '../utils/craft-tree'; import { findDeletableTarget } from '../utils/craft-helpers'; export interface NodeActions { moveUp: () => void; moveDown: () => void; /** Duplicates `nodeId`, inserting the copy immediately after the source in * the parent's children and selecting it. Returns the new node's id (so * callers -- e.g. `MobileSelectionToolbar` -- can scroll it into view), * or null if the duplicate could not be performed (no-op for ROOT/null, * or a caught error). */ duplicate: () => string | null; deleteNode: () => void; selectParent: () => void; /** True if `nodeId` has an earlier sibling under the same parent (so * `moveUp` would actually move it). False for ROOT/null/no-parent. */ canMoveUp: boolean; /** True if `nodeId` has a later sibling under the same parent (so * `moveDown` would actually move it). False for ROOT/null/no-parent. */ canMoveDown: boolean; /** True if `findDeletableTarget` resolves to a real, deletable node (either * `nodeId` itself, or an ancestor when `nodeId` is an empty linked-node * slot whose siblings are all also empty -- see `craft-helpers.ts`). */ canDelete: boolean; /** True if `nodeId` has a real parent to select -- i.e. the parent is * neither ROOT nor missing. `selectParent` on a top-level section (whose * parent IS 'ROOT') would only select the page-wide ROOT node, which has * no on-canvas outline and no toolbar of its own -- a dead end for a * mobile user with no way back. False for ROOT/null/no-parent too. */ canSelectParent: boolean; } /** * Shared node-action logic (move up/down, duplicate, delete, select parent) * extracted from `ContextMenu.tsx` (Phase B) so both the desktop right-click * menu AND the mobile on-canvas selection toolbar (`MobileSelectionToolbar`) * drive the exact same behavior from one place instead of two independent * copies drifting apart. * * IMPORTANT gotcha this hook works around: `@craftjs/core`'s `useEditor` * collector (`@craftjs/utils`' `useCollector`) only synchronously computes * the collector function ONCE, on this hook's very first mount. After that, * it updates purely in reaction to the underlying store's OWN change * notifications, invoking whatever collector closure is current AT THAT * NOTIFICATION -- so a collector that closes over `nodeId` (an argument that * changes across renders of the SAME mounted hook instance, e.g. every time * `MobileSelectionToolbar` re-renders with a newly-selected node) goes stale * for exactly one render: the cached value from the last store notification * (computed against the PREVIOUS nodeId) is what gets returned, until some * unrelated store event happens to trigger a fresh computation. An earlier * version of this hook computed `canMoveUp`/`canMoveDown`/`canDelete` inside * such a collector and was caught showing the PREVIOUS selection's move * boundaries in the mobile toolbar for one render after tapping a new node * (verified with real Playwright touch taps against a real Craft.js * document -- a plain mocked `useEditor` in a unit test doesn't reproduce * this, since a hand-rolled mock has no reason to replicate the real * library's caching). * * The fix: get `actions`/`query` from a collector-FREE `useEditor()` call * (an always-live reference, same pattern `ContextMenu.tsx` used before this * extraction) and compute `canMoveUp`/`canMoveDown`/`canDelete` as plain * synchronous code during render using that live `query` -- never cached. * * That still leaves one gap: `nodeId` staying the SAME across renders while * its position changes (e.g. tapping "Move Up" repeatedly on the same * still-selected node) needs SOMETHING to trigger a re-render so the boundary * flags below get recomputed. An earlier version of this hook forced that * via a second `useEditor((state) => ({ _: state.nodes }))` subscription -- * but subscribing to the WHOLE node map reacts to every `dom` ref * assignment too (Craft's `connectors.connect(ref)` calls `actions.setDOM` * synchronously from a React ref callback during COMMIT, i.e. while some * OTHER component is still mounting), which trips React's "Cannot update a * component while rendering a different component" warning the moment a * freshly-added container with children mounts. Callers that need * `canMoveUp`/`canMoveDown` to refresh after a move they themselves * triggered (`MobileSelectionToolbar`) should instead bump their OWN local * state right after calling `moveUp`/`moveDown` -- a plain, local, * event-handler-triggered re-render, not a store-wide subscription. */ export function useNodeActions(nodeId: string | null | undefined): NodeActions { const { actions, query } = useEditor(); const isRootOrNull = !nodeId || nodeId === 'ROOT'; let canMoveUp = false; let canMoveDown = false; let canSelectParent = false; if (!isRootOrNull) { try { const node = query.node(nodeId).get(); const parentId: string | null | undefined = node?.data?.parent; if (parentId) { canSelectParent = parentId !== 'ROOT'; const siblings: string[] = query.node(parentId).get()?.data?.nodes || []; const idx = siblings.indexOf(nodeId); canMoveUp = idx > 0; canMoveDown = idx !== -1 && idx < siblings.length - 1; } } catch { // Node no longer exists (e.g. deleted out from under a stale // reference) -- leave both false. } } const canDelete = !isRootOrNull && !!findDeletableTarget(query, nodeId); const getParentId = (): string | null => { if (!nodeId) return null; try { const node = query.node(nodeId).get(); return node?.data?.parent || null; } catch { return null; } }; const duplicate = (): string | null => { if (!nodeId || nodeId === 'ROOT') return null; try { const parentId = getParentId(); if (!parentId) return null; const tree = regenerateTreeIds(query.node(nodeId).toNodeTree()); // Insert the copy IMMEDIATELY AFTER the source node, not appended at // the end of the parent -- on a top-level section, "append at end" // landed the copy at the bottom of the page, off-screen, with the // ORIGINAL still selected, so a duplicate looked like nothing had // happened at all. Resolve the source's index among its siblings so // the copy lands right next to what it was copied from; if that can't // be resolved for any reason, fall back to the old append behavior // rather than guessing at an index (or throwing). let insertIndex: number | undefined; try { const siblings: string[] = query.node(parentId).get()?.data?.nodes || []; const sourceIndex = siblings.indexOf(nodeId); if (sourceIndex !== -1) insertIndex = sourceIndex + 1; } catch { // Leave insertIndex undefined -- addNodeTree appends when omitted. } if (insertIndex !== undefined) { actions.addNodeTree(tree, parentId, insertIndex); } else { actions.addNodeTree(tree, parentId); } // Select the new copy (not the original) so the toolbar/context menu // and any on-canvas outline immediately reflect what was just created. actions.selectNode(tree.rootNodeId); return tree.rootNodeId; } catch (e) { console.error('Duplicate failed:', e); return null; } }; const moveUp = () => { 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); } }; const moveDown = () => { 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); } }; const selectParent = () => { if (!nodeId || nodeId === 'ROOT') return; const parentId = getParentId(); if (parentId) { actions.selectNode(parentId); } }; const deleteNode = () => { const target = findDeletableTarget(query, nodeId); if (!target) return; try { actions.delete(target); } catch (e) { console.error('Delete failed:', e); } }; return { moveUp, moveDown, duplicate, deleteNode, selectParent, canMoveUp, canMoveDown, canDelete, canSelectParent, }; }