2026-04-05 18:31:16 -07:00
|
|
|
import React, { useEffect, useCallback, useRef } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
2026-04-26 20:42:17 -07:00
|
|
|
import { findDeletableTarget } from '../../utils/craft-helpers';
|
2026-05-25 12:43:28 -07:00
|
|
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
|
|
|
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
2026-07-12 12:18:44 -07:00
|
|
|
import { regenerateTreeIds } from '../../utils/craft-tree';
|
2026-07-12 15:01:30 -07:00
|
|
|
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
interface ContextMenuProps {
|
|
|
|
|
visible: boolean;
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
nodeId: string | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MenuItem {
|
|
|
|
|
label: string;
|
2026-07-12 20:14:29 -07:00
|
|
|
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
|
|
|
|
|
icon?: string;
|
2026-04-05 18:31:16 -07:00
|
|
|
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();
|
2026-05-25 12:43:28 -07:00
|
|
|
const { open: openSitesmith } = useSitesmithModal();
|
2026-04-05 18:31:16 -07:00
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
const duplicate = useCallback(() => {
|
|
|
|
|
if (!nodeId || nodeId === 'ROOT') return;
|
|
|
|
|
try {
|
|
|
|
|
const parentId = getParentId();
|
|
|
|
|
if (!parentId) return;
|
|
|
|
|
|
2026-07-12 12:18:44 -07:00
|
|
|
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
|
|
|
|
actions.addNodeTree(tree, parentId);
|
2026-04-05 18:31:16 -07:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Duplicate failed:', e);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
|
|
|
|
}, [nodeId, actions, query, getParentId, onClose]);
|
|
|
|
|
|
|
|
|
|
const copyNode = useCallback(() => {
|
|
|
|
|
if (!nodeId || nodeId === 'ROOT') return;
|
|
|
|
|
try {
|
2026-07-12 15:01:30 -07:00
|
|
|
setClipboardNodeId(nodeId);
|
2026-04-05 18:31:16 -07:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Copy failed:', e);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
|
|
|
|
}, [nodeId, onClose]);
|
|
|
|
|
|
|
|
|
|
const pasteNode = useCallback(() => {
|
2026-07-12 15:01:30 -07:00
|
|
|
const sourceId = getClipboardNodeId();
|
2026-07-12 12:18:44 -07:00
|
|
|
if (!sourceId) {
|
|
|
|
|
onClose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-05 18:31:16 -07:00
|
|
|
try {
|
2026-07-12 12:18:44 -07:00
|
|
|
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());
|
2026-04-05 18:31:16 -07:00
|
|
|
actions.addNodeTree(tree, targetParent);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Paste failed:', e);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
|
|
|
|
}, [nodeId, actions, query, onClose]);
|
|
|
|
|
|
|
|
|
|
const moveUp = useCallback(() => {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
|
|
|
|
}, [nodeId, actions, query, getParentId, onClose]);
|
|
|
|
|
|
|
|
|
|
const moveDown = useCallback(() => {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
|
|
|
|
}, [nodeId, actions, query, getParentId, onClose]);
|
|
|
|
|
|
|
|
|
|
const selectParent = useCallback(() => {
|
|
|
|
|
if (!nodeId || nodeId === 'ROOT') return;
|
|
|
|
|
const parentId = getParentId();
|
|
|
|
|
if (parentId) {
|
|
|
|
|
actions.selectNode(parentId);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
|
|
|
|
}, [nodeId, actions, getParentId, onClose]);
|
|
|
|
|
|
2026-05-25 12:43:28 -07:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const deleteNode = useCallback(() => {
|
2026-04-26 20:42:17 -07:00
|
|
|
const target = findDeletableTarget(query, nodeId);
|
|
|
|
|
if (!target) {
|
|
|
|
|
onClose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-05 18:31:16 -07:00
|
|
|
try {
|
2026-04-26 20:42:17 -07:00
|
|
|
actions.delete(target);
|
2026-04-05 18:31:16 -07:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Delete failed:', e);
|
|
|
|
|
}
|
|
|
|
|
onClose();
|
2026-04-26 20:42:17 -07:00
|
|
|
}, [nodeId, actions, query, onClose]);
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
if (!visible) return null;
|
|
|
|
|
|
|
|
|
|
const isRoot = nodeId === 'ROOT' || !nodeId;
|
|
|
|
|
|
|
|
|
|
const items: MenuItem[] = [
|
2026-05-25 12:43:28 -07:00
|
|
|
{
|
2026-07-12 20:14:29 -07:00
|
|
|
label: 'Ask Sitesmith',
|
|
|
|
|
icon: 'magic',
|
2026-05-25 12:43:28 -07:00
|
|
|
action: askSitesmith,
|
|
|
|
|
disabled: isRoot,
|
|
|
|
|
dividerAfter: true,
|
|
|
|
|
},
|
2026-04-05 18:31:16 -07:00
|
|
|
{
|
|
|
|
|
label: 'Duplicate',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'clone',
|
2026-04-05 18:31:16 -07:00
|
|
|
shortcut: 'Ctrl+D',
|
|
|
|
|
action: duplicate,
|
|
|
|
|
disabled: isRoot,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Copy',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'files-o',
|
2026-04-05 18:31:16 -07:00
|
|
|
shortcut: 'Ctrl+C',
|
|
|
|
|
action: copyNode,
|
|
|
|
|
disabled: isRoot,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Paste',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'clipboard',
|
2026-04-05 18:31:16 -07:00
|
|
|
shortcut: 'Ctrl+V',
|
|
|
|
|
action: pasteNode,
|
2026-07-12 15:01:30 -07:00
|
|
|
disabled: !getClipboardNodeId(),
|
2026-04-05 18:31:16 -07:00
|
|
|
dividerAfter: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Move Up',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'arrow-up',
|
2026-04-05 18:31:16 -07:00
|
|
|
action: moveUp,
|
|
|
|
|
disabled: isRoot,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Move Down',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'arrow-down',
|
2026-04-05 18:31:16 -07:00
|
|
|
action: moveDown,
|
|
|
|
|
disabled: isRoot,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Select Parent',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'level-up',
|
2026-04-05 18:31:16 -07:00
|
|
|
action: selectParent,
|
|
|
|
|
disabled: isRoot,
|
|
|
|
|
dividerAfter: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Delete',
|
2026-07-12 20:56:21 -07:00
|
|
|
icon: 'trash',
|
2026-04-05 18:31:16 -07:00
|
|
|
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';
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-07-12 20:14:29 -07:00
|
|
|
<span>
|
|
|
|
|
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
|
|
|
|
|
{item.label}
|
|
|
|
|
</span>
|
2026-04-05 18:31:16 -07:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
};
|