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:
157
craft/src/editor/Canvas.tsx
Normal file
157
craft/src/editor/Canvas.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
|
||||
interface CanvasProps {
|
||||
device: DeviceMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the actual header/footer content from the Craft.js state as a
|
||||
* non-interactive preview. This is the user's own authored content.
|
||||
*/
|
||||
const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'footer' }> = ({
|
||||
craftState,
|
||||
zone,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!craftState) return null;
|
||||
try {
|
||||
const result = exportBodyHtml(craftState);
|
||||
return result.html || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [craftState]);
|
||||
|
||||
// Set the rendered HTML into the container via ref (user-authored content)
|
||||
useEffect(() => {
|
||||
if (containerRef.current && renderedHtml) {
|
||||
containerRef.current.textContent = '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderedHtml; // user's own site content
|
||||
while (wrapper.firstChild) {
|
||||
containerRef.current.appendChild(wrapper.firstChild);
|
||||
}
|
||||
}
|
||||
}, [renderedHtml]);
|
||||
|
||||
if (!renderedHtml) {
|
||||
return (
|
||||
<div
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 40,
|
||||
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
|
||||
padding: '12px 24px',
|
||||
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
fontStyle: 'italic',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{zone === 'header'
|
||||
? 'Header (empty -- click Edit Header in Pages tab)'
|
||||
: 'Footer (empty -- click Edit Footer in Pages tab)'}
|
||||
<div style={{
|
||||
position: 'absolute', top: 2, right: 6,
|
||||
fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
color: '#f59e0b', background: 'rgba(245,158,11,0.12)', padding: '1px 5px', borderRadius: 3,
|
||||
}}>
|
||||
{zone}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
|
||||
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
|
||||
|
||||
const frameStyle = isEditingHeader
|
||||
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
||||
: isEditingFooter
|
||||
? { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' as const }
|
||||
: { minHeight: '100vh', backgroundColor: '#ffffff' };
|
||||
|
||||
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
||||
|
||||
return (
|
||||
<div className="editor-canvas">
|
||||
<div
|
||||
className="canvas-device-frame"
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
transition: 'width 0.3s ease',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{(isEditingHeader || isEditingFooter) && (
|
||||
<div style={{
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#f59e0b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<i className={`fa ${isEditingHeader ? 'fa-window-maximize' : 'fa-window-minimize'}`} />
|
||||
Editing {isEditingHeader ? 'Header' : 'Footer'} -- This content will appear on all pages
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||
)}
|
||||
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
craft/src/editor/EditorShell.tsx
Normal file
54
craft/src/editor/EditorShell.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||
import { RightPanel } from '../panels/right/RightPanel';
|
||||
import { Canvas } from './Canvas';
|
||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
export const EditorShell: React.FC = () => {
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
|
||||
const { query } = useEditor();
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Find the selected node id
|
||||
let nodeId: string | null = null;
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
nodeId = selected[0];
|
||||
}
|
||||
} catch {
|
||||
// No selection
|
||||
}
|
||||
showMenu(e.clientX, e.clientY, nodeId);
|
||||
}, [query, showMenu]);
|
||||
|
||||
return (
|
||||
<div className="editor-app">
|
||||
<TopBar device={device} onDeviceChange={setDevice} />
|
||||
<div className="editor-container">
|
||||
<LeftPanel />
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
<Canvas device={device} />
|
||||
</div>
|
||||
<RightPanel />
|
||||
</div>
|
||||
<ContextMenu
|
||||
visible={menuState.visible}
|
||||
x={menuState.x}
|
||||
y={menuState.y}
|
||||
nodeId={menuState.nodeId}
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user