Files
site-builder/craft/src/panels/context-menu/ContextMenu.tsx
T
shadowdaoandClaude Opus 4.8 f00e4db3dc test(builder): cover useVisualViewport; fix: disable Select-Parent at ROOT in desktop context menu
- useVisualViewport.test.tsx: covers the visualViewport-undefined fallback,
  the keyboardInset math on a mocked visualViewport resize, and
  listener add/remove (resize + scroll) across mount/unmount.
- ContextMenu.tsx: Select Parent was only disabled at isRoot, so selecting
  a top-level section and choosing Select Parent silently landed on the
  un-editable ROOT (no outline, no toolbar) -- a dead end. Switched the
  guard to useNodeActions' canSelectParent (false whenever the node's
  parent is ROOT or missing), matching the mobile selection toolbar's
  existing identical guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:19:00 -07:00

299 lines
8.2 KiB
TypeScript

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<ContextMenuProps> = ({
visible,
x,
y,
nodeId,
onClose,
}) => {
const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(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 (
<div
ref={menuRef}
style={{
position: 'fixed',
top: adjustedY,
left: adjustedX,
zIndex: 10000,
minWidth: 180,
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
padding: '4px 0',
overflow: 'hidden',
}}
>
{items.map((item, i) => (
<React.Fragment key={item.label}>
<button
onClick={item.disabled ? undefined : item.action}
disabled={item.disabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 12px',
fontSize: 12,
color: item.disabled
? 'var(--color-text-dim)'
: item.danger
? 'var(--color-danger)'
: 'var(--color-text)',
background: 'transparent',
border: 'none',
cursor: item.disabled ? 'default' : 'pointer',
textAlign: 'left',
transition: 'background var(--transition-fast)',
}}
onMouseEnter={(e) => {
if (!item.disabled) {
(e.target as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'transparent';
}}
>
<span>
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
{item.label}
</span>
{item.shortcut && (
<span
style={{
fontSize: 10,
color: 'var(--color-text-dim)',
marginLeft: 16,
}}
>
{item.shortcut}
</span>
)}
</button>
{item.dividerAfter && (
<div
style={{
height: 1,
background: 'var(--color-border)',
margin: '4px 0',
}}
/>
)}
</React.Fragment>
))}
</div>
);
};