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:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions

View 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>
);
};