import React, { useEffect, useCallback, useRef } from 'react'; import { useEditor } from '@craftjs/core'; 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; x: number; y: number; nodeId: string | null; onClose: () => void; } interface MenuItem { label: string; /** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */ icon?: string; shortcut?: string; action: () => void; danger?: boolean; disabled?: boolean; dividerAfter?: boolean; } export const ContextMenu: React.FC = ({ visible, x, y, nodeId, onClose, }) => { const { actions, query } = useEditor(); const { open: openSitesmith } = useSitesmithModal(); const menuRef = useRef(null); // Close on click outside useEffect(() => { if (!visible) return; const handleClick = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); } }; const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleEsc); return () => { document.removeEventListener('mousedown', handleClick); document.removeEventListener('keydown', handleEsc); }; }, [visible, onClose]); const getParentId = useCallback((): string | null => { if (!nodeId) return null; try { const node = query.node(nodeId).get(); return node?.data?.parent || null; } catch { return null; } }, [nodeId, query]); // 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 duplicate = useCallback(() => { nodeActions.duplicate(); onClose(); }, [nodeActions, onClose]); const copyNode = useCallback(() => { if (!nodeId || nodeId === 'ROOT') return; try { setClipboardNodeId(nodeId); } catch (e) { console.error('Copy failed:', e); } onClose(); }, [nodeId, onClose]); const pasteNode = useCallback(() => { const sourceId = getClipboardNodeId(); if (!sourceId) { onClose(); return; } try { if (!query.node(sourceId).get()) { onClose(); return; } // Paste as a SIBLING of the right-clicked node, not as its child -- // using the clicked node itself as the parent throws when it's a leaf. let targetParent = 'ROOT'; if (nodeId && nodeId !== 'ROOT') { const clickedNode = query.node(nodeId).get(); targetParent = clickedNode?.data?.parent || 'ROOT'; } const tree = regenerateTreeIds(query.node(sourceId).toNodeTree()); actions.addNodeTree(tree, targetParent); } catch (e) { console.error('Paste failed:', e); } onClose(); }, [nodeId, actions, query, onClose]); const moveUp = useCallback(() => { nodeActions.moveUp(); onClose(); }, [nodeActions, onClose]); const moveDown = useCallback(() => { nodeActions.moveDown(); onClose(); }, [nodeActions, onClose]); const selectParent = useCallback(() => { nodeActions.selectParent(); onClose(); }, [nodeActions, onClose]); const askSitesmith = useCallback(() => { if (!nodeId || nodeId === 'ROOT') return; try { const target = buildSitesmithTarget(query, nodeId); if (target) openSitesmith(target); } catch (e) { console.error('Ask Sitesmith failed:', e); } onClose(); }, [nodeId, query, openSitesmith, onClose]); const deleteNode = useCallback(() => { nodeActions.deleteNode(); onClose(); }, [nodeActions, onClose]); if (!visible) return null; const isRoot = nodeId === 'ROOT' || !nodeId; const items: MenuItem[] = [ { label: 'Ask Sitesmith', icon: 'magic', action: askSitesmith, disabled: isRoot, dividerAfter: true, }, { label: 'Duplicate', icon: 'clone', shortcut: 'Ctrl+D', action: duplicate, disabled: isRoot, }, { label: 'Copy', icon: 'files-o', shortcut: 'Ctrl+C', action: copyNode, disabled: isRoot, }, { label: 'Paste', icon: 'clipboard', shortcut: 'Ctrl+V', action: pasteNode, disabled: !getClipboardNodeId(), dividerAfter: true, }, { label: 'Move Up', icon: 'arrow-up', action: moveUp, disabled: isRoot, }, { label: 'Move Down', icon: 'arrow-down', action: moveDown, disabled: isRoot, }, { label: 'Select Parent', icon: 'level-up', action: selectParent, // Not just `isRoot`: the real dead-end is when nodeId's PARENT is // ROOT (selecting a top-level section -> Select Parent would only // select the page-wide, un-editable ROOT -- no outline, no toolbar). // `canSelectParent` (useNodeActions) already encodes that and matches // the mobile selection toolbar's identical guard. disabled: !nodeActions.canSelectParent, dividerAfter: true, }, { label: 'Delete', icon: 'trash', shortcut: 'Del', action: deleteNode, danger: true, disabled: isRoot, }, ]; // Adjust position to stay within viewport const adjustedX = Math.min(x, window.innerWidth - 200); const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10); return (
{items.map((item, i) => ( {item.dividerAfter && (
)} ))}
); };