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'; import { useIsMobile } from '../hooks/useIsMobile'; import { useMobileChrome } from '../state/MobileChromeContext'; interface CanvasProps { device: DeviceMode; /** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */ showGuides: boolean; } /** * 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(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 (
{zone === 'header' ? 'Header (empty -- click Edit Header in Pages tab)' : 'Footer (empty -- click Edit Footer in Pages tab)'}
{zone}
); } return (
); }; /** * 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, }; }); const isMobile = useIsMobile(); if (!isEmpty || isDragging) return null; return (
{isMobile ? <>Tap Blocks below to add content, or pick a Template to start. : 'Drag blocks from the left panel, or pick a Template to start.'}
); }; export const Canvas: React.FC = ({ device, showGuides }) => { const width = DEVICE_WIDTHS[device]; const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); const isEditingRegularPage = !isEditingHeader && !isEditingFooter; // Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px // tall, just above the tab bar), it covers whatever content was at the // very bottom of the canvas's own scroll area -- on a short page, the // last section could sit permanently under the toolbar with no way to // scroll past it. Mirrors that toolbar's own visibility condition // (`selectedId && activeSheet === null`) exactly, computed independently // here since Canvas has no other reason to depend on the toolbar // component itself. const isMobile = useIsMobile(); const { activeSheet } = useMobileChrome(); const { selectedId } = useEditor((state) => { const selected = state.events.selected; const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null; return { selectedId: id && id !== 'ROOT' ? id : null }; }); const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null; // Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the // selected node up towards the top of the canvas so it stays visible // above the Styles sheet (~65dvh tall) -- but that scroll is still bound // by the canvas's own natural scroll range. For a selected node near the // END of the content (very plausibly the last section on the page -- // exactly the kind of node someone just added/duplicated and wants to // style), there may not be enough scrollable distance below it to bring // its top all the way up to the visible band above the sheet; the browser // simply clamps at its existing max scrollTop, leaving the node's top // stuck behind the sheet with nothing anyone can do about it (verified // live: the last section of a page landed under the sheet even after the // scroll-into-view ran). Pad the canvas with a full extra viewport's // worth of scroll room while the sheet is open with a selection, exactly // as `has-mobile-selection-toolbar` (item 4) already pads it for the // fixed toolbar -- generous enough that ANY node, including the very // last one, can always be scrolled with its top reaching the very top of // the canvas (comfortably within the "top ~30%" target). const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles'; 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 (
{(isEditingHeader || isEditingFooter) && (
Editing {isEditingHeader ? 'Header' : 'Footer'} -- This content will appear on all pages
)} {isEditingRegularPage && ( )}
{isEditingRegularPage && }
{isEditingRegularPage && ( )}
); };