Files
site-builder/craft/src/editor/Canvas.tsx
T

189 lines
6.1 KiB
TypeScript
Raw Normal View History

import React, { useMemo, useRef, useEffect } from 'react';
import { Frame, Element, useEditor } 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%',
// Slim hint bar, not a content-height band — an empty zone should not
// read as a stray spacer between the page and the header/footer.
padding: '5px 12px',
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
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',
}}
/>
);
};
/**
* First-run hint shown over the canvas drop area once the current page's
* root node exists and has no children yet. Hidden the instant something
* is dropped in, and while a drag is in progress (so it never fights the
* drop-target UI). `pointer-events: none` (see .empty-canvas-hint in
* editor.css) keeps it from intercepting clicks/drops meant for the
* underlying empty canvas.
*/
export const EmptyCanvasHint: React.FC = () => {
const { isEmpty, isDragging } = useEditor((state) => {
const root = state.nodes['ROOT'];
return {
isEmpty: !!root && root.data.nodes.length === 0,
isDragging: state.events.dragged.size > 0,
};
});
if (!isEmpty || isDragging) return null;
return (
<div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden />
<span>Drag blocks from the left panel, or pick a Template to start.</span>
</div>
);
};
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" />
)}
<div style={{ position: 'relative' }}>
<Frame>
<Element
is={Container}
canvas
tag={frameTag}
style={frameStyle}
/>
</Frame>
{isEditingRegularPage && <EmptyCanvasHint />}
</div>
{isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" />
)}
</div>
</div>
);
};