2026-04-05 18:31:16 -07:00
|
|
|
import React, { useMemo, useRef, useEffect } from 'react';
|
2026-07-12 20:14:58 -07:00
|
|
|
import { Frame, Element, useEditor } from '@craftjs/core';
|
2026-04-05 18:31:16 -07:00
|
|
|
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';
|
2026-07-13 06:54:15 -07:00
|
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
interface CanvasProps {
|
|
|
|
|
device: DeviceMode;
|
2026-07-12 20:55:52 -07:00
|
|
|
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
|
|
|
|
|
showGuides: boolean;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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%',
|
2026-07-06 17:53:22 -07:00
|
|
|
// 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',
|
2026-04-05 18:31:16 -07:00
|
|
|
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}
|
2026-07-12 21:12:09 -07:00
|
|
|
className="zone-preview-sep"
|
2026-04-05 18:31:16 -07:00
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
position: 'relative',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
userSelect: 'none',
|
2026-07-12 21:12:09 -07:00
|
|
|
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
|
|
|
|
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
2026-04-05 18:31:16 -07:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-12 20:14:58 -07:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-07-13 06:54:15 -07:00
|
|
|
const isMobile = useIsMobile();
|
2026-07-12 20:14:58 -07:00
|
|
|
|
|
|
|
|
if (!isEmpty || isDragging) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="empty-canvas-hint">
|
|
|
|
|
<i className="fa fa-cubes" aria-hidden />
|
2026-07-13 06:54:15 -07:00
|
|
|
<span>
|
|
|
|
|
{isMobile
|
|
|
|
|
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
|
|
|
|
|
: 'Drag blocks from the left panel, or pick a Template to start.'}
|
|
|
|
|
</span>
|
2026-07-12 20:14:58 -07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
2026-04-05 18:31:16 -07:00
|
|
|
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
|
2026-07-12 20:55:52 -07:00
|
|
|
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
|
2026-04-05 18:31:16 -07:00
|
|
|
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" />
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-12 20:14:58 -07:00
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<Frame>
|
|
|
|
|
<Element
|
|
|
|
|
is={Container}
|
|
|
|
|
canvas
|
|
|
|
|
tag={frameTag}
|
|
|
|
|
style={frameStyle}
|
|
|
|
|
/>
|
|
|
|
|
</Frame>
|
|
|
|
|
{isEditingRegularPage && <EmptyCanvasHint />}
|
|
|
|
|
</div>
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
{isEditingRegularPage && (
|
|
|
|
|
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|