Add Craft.js site builder (v2) - complete rebuild from GrapesJS
Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
283
craft/src/panels/context-menu/ContextMenu.tsx
Normal file
283
craft/src/panels/context-menu/ContextMenu.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
label: 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 menuRef = useRef<HTMLDivElement>(null);
|
||||
const clipboardRef = useRef<string | null>(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 tree = query.node(nodeId).toSerializedNode();
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
|
||||
// Get the full subtree
|
||||
const freshTree = query.node(nodeId).toNodeTree();
|
||||
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
|
||||
|
||||
actions.addNodeTree(freshTree, parentId);
|
||||
} catch (e) {
|
||||
console.error('Duplicate failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
|
||||
const copyNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
clipboardRef.current = nodeId;
|
||||
} catch (e) {
|
||||
console.error('Copy failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, onClose]);
|
||||
|
||||
const pasteNode = useCallback(() => {
|
||||
const sourceId = clipboardRef.current;
|
||||
if (!sourceId) return;
|
||||
try {
|
||||
const targetParent = nodeId || 'ROOT';
|
||||
const tree = query.node(sourceId).toNodeTree();
|
||||
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]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
actions.delete(nodeId);
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, actions, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const isRoot = nodeId === 'ROOT' || !nodeId;
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: 'Duplicate',
|
||||
shortcut: 'Ctrl+D',
|
||||
action: duplicate,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
shortcut: 'Ctrl+C',
|
||||
action: copyNode,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
shortcut: 'Ctrl+V',
|
||||
action: pasteNode,
|
||||
disabled: !clipboardRef.current,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Move Up',
|
||||
action: moveUp,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Move Down',
|
||||
action: moveDown,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Select Parent',
|
||||
action: selectParent,
|
||||
disabled: isRoot,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
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.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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user