diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx
index 2a8f059..087ed88 100644
--- a/craft/src/editor/Canvas.tsx
+++ b/craft/src/editor/Canvas.tsx
@@ -7,6 +7,8 @@ import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile';
import { useMobileChrome } from '../state/MobileChromeContext';
+import { useSitesmithModal } from '../state/SitesmithContext';
+import { insertBlock } from '../panels/left/blocks-registry';
interface CanvasProps {
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
@@ -98,15 +100,23 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
};
/**
- * 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.
+ * First-run state shown over the canvas drop area once the current page's
+ * root node exists and has no children yet: three real actions (template,
+ * section, Sitesmith). Hidden the instant something is dropped in, and while
+ * a drag is in progress (so it never fights the drop-target UI). The wrapper
+ * stays `pointer-events: none` (see .empty-canvas-hint in editor.css) so
+ * drops meant for the underlying empty canvas still land; only the buttons
+ * opt back in.
*/
+
+/** "Add a section" action. Kept as one named function so #52 can point it at
+ * the section library with a one-line swap. */
+export function addSectionToRoot(query: any, actions: any): string | null {
+ return insertBlock(query, actions, 'section', { parentId: 'ROOT', select: true });
+}
+
export const EmptyCanvasHint: React.FC = () => {
- const { isEmpty, isDragging } = useEditor((state) => {
+ const { query, actions, isEmpty, isDragging } = useEditor((state) => {
const root = state.nodes['ROOT'];
return {
isEmpty: !!root && root.data.nodes.length === 0,
@@ -114,6 +124,8 @@ export const EmptyCanvasHint: React.FC = () => {
};
});
const isMobile = useIsMobile();
+ const { setTemplateModalOpen } = useMobileChrome();
+ const { open: openSitesmith } = useSitesmithModal();
if (!isEmpty || isDragging) return null;
@@ -123,8 +135,19 @@ export const EmptyCanvasHint: React.FC = () => {
{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.'}
+ : 'Drag blocks from the left panel, or start with one of these.'}
+
+
+
+
+
);
};
diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx
index eb99ed7..6e255b2 100644
--- a/craft/src/editor/CanvasOverlays.tsx
+++ b/craft/src/editor/CanvasOverlays.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { NoticeHost } from './NoticeHost';
+import { EmptyContainerAdd } from './EmptyContainerAdd';
/**
* Single mount point for editor-wide overlays rendered next to the shell.
@@ -9,5 +10,6 @@ import { NoticeHost } from './NoticeHost';
export const CanvasOverlays: React.FC = () => (
<>
+
>
);
diff --git a/craft/src/editor/EmptyCanvasHint.test.tsx b/craft/src/editor/EmptyCanvasHint.test.tsx
index e1c28b8..d11195c 100644
--- a/craft/src/editor/EmptyCanvasHint.test.tsx
+++ b/craft/src/editor/EmptyCanvasHint.test.tsx
@@ -1,67 +1,100 @@
-import { describe, test, expect, vi, beforeEach } from 'vitest';
+import { describe, test, expect, afterEach, vi } from 'vitest';
import React from 'react';
-import { createRoot, Root } from 'react-dom/client';
-import { act } from 'react-dom/test-utils';
+import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
+import { EmptyCanvasHint, addSectionToRoot } from './Canvas';
+import { SitesmithProvider, useSitesmithModal } from '../state/SitesmithContext';
+import { MobileChromeProvider, useMobileChrome } from '../state/MobileChromeContext';
-/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's
- useEditor so we can drive editor state without a real tree. */
-let mockNodes: Record = {};
-let mockDraggedSize = 0;
-
-vi.mock('@craftjs/core', () => ({
- useEditor: (collect: (state: any) => any) =>
- collect({
- nodes: mockNodes,
- events: { dragged: { size: mockDraggedSize } },
- }),
-}));
-
-import { EmptyCanvasHint } from './Canvas';
-
-let container: HTMLDivElement;
-let root: Root;
-
-function render(ui: React.ReactElement) {
- container = document.createElement('div');
- document.body.appendChild(container);
- act(() => {
- root = createRoot(container);
- root.render(ui);
- });
-}
-
-beforeEach(() => {
- mockNodes = {};
- mockDraggedSize = 0;
+const forceDragging = vi.hoisted(() => ({ on: false }));
+// Real editor, but let one test force the `dragged` flag the collector reads.
+vi.mock('@craftjs/core', async (orig) => {
+ const real: any = await orig();
+ return {
+ ...real,
+ useEditor: (collect?: any) =>
+ real.useEditor(collect ? (state: any) => {
+ const out = collect(state);
+ return forceDragging.on && 'isDragging' in out ? { ...out, isDragging: true } : out;
+ } : undefined),
+ };
});
+/* Real @craftjs/core editor (no mocks); providers wrap the whole . */
+let h: EditorHarness;
+let seen: { sitesmithOpen: boolean; templateOpen: boolean };
+
+const Probe: React.FC = () => {
+ seen = { sitesmithOpen: useSitesmithModal().isOpen, templateOpen: useMobileChrome().templateModalOpen };
+ return null;
+};
+
+function mount() {
+ seen = { sitesmithOpen: false, templateOpen: false };
+ h = renderEditorHarness({
+ wrap: (tree) => (
+
+ {tree}
+
+ ),
+ });
+ h.mountChild();
+}
+const btn = (label: string) =>
+ [...h.container.querySelectorAll('button')].find((b) => b.textContent?.includes(label)) as HTMLButtonElement;
+const nodeTypes = () => Object.values(JSON.parse(h.getSerialized())).map((n: any) => n.type?.resolvedName);
+
+afterEach(() => h?.unmount());
+
describe('EmptyCanvasHint', () => {
- test('renders nothing before ROOT has mounted (no root node yet)', () => {
- render();
- expect(container.querySelector('.empty-canvas-hint')).toBeNull();
- container.remove();
+ test('renders three action buttons on an empty ROOT', () => {
+ mount();
+ expect(h.container.querySelector('.empty-canvas-hint')).not.toBeNull();
+ expect(btn('Choose a template')).toBeTruthy();
+ expect(btn('Add a section')).toBeTruthy();
+ expect(btn('Generate with Sitesmith')).toBeTruthy();
});
- test('renders the hint once ROOT exists with zero children', () => {
- mockNodes = { ROOT: { data: { nodes: [] } } };
- render();
- expect(container.querySelector('.empty-canvas-hint')).not.toBeNull();
- expect(container.textContent).toContain('Drag blocks from the left panel');
- container.remove();
+ test('"Add a section" adds a Section under ROOT, selects it, and hides the hint', () => {
+ mount();
+ h.act(() => { btn('Add a section').click(); });
+ const rootNodes: string[] = h.query.node('ROOT').get().data.nodes;
+ expect(rootNodes).toHaveLength(1);
+ expect(h.query.node(rootNodes[0]).get().data.displayName).toBe('Section');
+ expect(h.query.getEvent('selected').all()).toEqual([rootNodes[0]]);
+ expect(h.container.querySelector('.empty-canvas-hint')).toBeNull();
+ });
+
+ test('Sitesmith button opens the Sitesmith modal', () => {
+ mount();
+ expect(seen.sitesmithOpen).toBe(false);
+ h.act(() => { btn('Generate with Sitesmith').click(); });
+ expect(seen.sitesmithOpen).toBe(true);
+ });
+
+ test('template button flips templateModalOpen', () => {
+ mount();
+ expect(seen.templateOpen).toBe(false);
+ h.act(() => { btn('Choose a template').click(); });
+ expect(seen.templateOpen).toBe(true);
});
test('hides once the page has content', () => {
- mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
- render();
- expect(container.querySelector('.empty-canvas-hint')).toBeNull();
- container.remove();
+ mount();
+ h.act(() => { btn('Add a section').click(); });
+ expect(nodeTypes()).toContain('Section');
+ expect(h.container.querySelector('.empty-canvas-hint')).toBeNull();
});
test('hides while a drag is in progress, even on an empty root', () => {
- mockNodes = { ROOT: { data: { nodes: [] } } };
- mockDraggedSize = 1;
- render();
- expect(container.querySelector('.empty-canvas-hint')).toBeNull();
- container.remove();
+ mount();
+ expect(h.container.querySelector('.empty-canvas-hint')).not.toBeNull();
+ // events.dragged is only set by dragging an existing node (which implies a
+ // non-empty root) and setNodeEvent is private, so force the collector flag.
+ forceDragging.on = true;
+ h.act(() => { h.actions.selectNode('ROOT'); }); // any store change re-runs collectors
+ expect(h.container.querySelector('.empty-canvas-hint')).toBeNull();
+ forceDragging.on = false;
+ h.act(() => { h.actions.selectNode(undefined as any); });
+ expect(h.container.querySelector('.empty-canvas-hint')).not.toBeNull();
});
});
diff --git a/craft/src/editor/EmptyContainerAdd.test.tsx b/craft/src/editor/EmptyContainerAdd.test.tsx
new file mode 100644
index 0000000..17cefd0
--- /dev/null
+++ b/craft/src/editor/EmptyContainerAdd.test.tsx
@@ -0,0 +1,88 @@
+import { describe, test, expect, afterEach } from 'vitest';
+import React from 'react';
+import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
+import { EmptyContainerAdd } from './EmptyContainerAdd';
+import { insertBlock } from '../panels/left/blocks-registry';
+
+let h: EditorHarness;
+afterEach(() => h?.unmount());
+
+/** ROOT -> Section -> inner canvas (empty, or holding a Heading); returns ids. */
+function seed(withChild = false) {
+ h = renderEditorHarness();
+ h.mountChild();
+ let sectionId = '';
+ let innerId = '';
+ // Separate acts: Craft must commit each insert before it is used as a parent.
+ h.act(() => { sectionId = insertBlock(h.query, h.actions, 'section', { parentId: 'ROOT', select: false })!; });
+ // A Section's content lives in its linked inner canvas (Element canvas), not in data.nodes.
+ innerId = Object.values(h.query.node(sectionId).get().data.linkedNodes as Record)[0];
+ if (withChild) h.act(() => { insertBlock(h.query, h.actions, 'heading', { parentId: innerId, select: false }); });
+ return { sectionId, innerId };
+}
+const dragStart = () => {
+ const e = new Event('dragstart', { bubbles: true }) as any;
+ e.dataTransfer = { setDragImage() {}, setData() {}, effectAllowed: '' };
+ return e;
+};
+const plus = () => document.querySelector('.empty-container-add') as HTMLButtonElement | null;
+// jsdom has no layout: give nodes a rect so the overlay can position.
+const withRect = (id: string) => {
+ const dom = h.query.node(id).get().dom as HTMLElement;
+ dom.getBoundingClientRect = () => ({ left: 10, top: 10, width: 200, height: 100, right: 210, bottom: 110, x: 10, y: 10, toJSON() {} }) as DOMRect;
+};
+
+describe('EmptyContainerAdd', () => {
+ test('shows "+" for a selected empty inner container; Heading inserts into THAT node and selects it', () => {
+ seed();
+ // A second section: insertBlock's default canvas is the first one, so only a
+ // correct explicit parentId lands the Heading in this (second) inner canvas.
+ let secondId = '';
+ h.act(() => { secondId = insertBlock(h.query, h.actions, 'section', { parentId: 'ROOT', select: false })!; });
+ const innerId = Object.values(h.query.node(secondId).get().data.linkedNodes as Record)[0];
+ withRect(innerId);
+ h.act(() => { h.actions.selectNode(innerId); });
+ expect(plus()).not.toBeNull();
+
+ h.act(() => { plus()!.click(); });
+ const item = [...document.querySelectorAll('.empty-container-add-item')]
+ .find((b) => b.textContent === 'Heading') as HTMLButtonElement;
+ expect(item).toBeTruthy();
+ expect(document.querySelectorAll('.empty-container-add-item')).toHaveLength(6);
+ h.act(() => { item.click(); });
+
+ const kids: string[] = h.query.node(innerId).get().data.nodes;
+ expect(kids).toHaveLength(1);
+ expect(h.query.node(kids[0]).get().data.displayName).toBe('Heading');
+ expect(h.query.getEvent('selected').all()).toEqual([kids[0]]);
+ expect(plus()).toBeNull();
+ });
+
+ test('not rendered for ROOT', () => {
+ seed();
+ h.act(() => { h.actions.selectNode('ROOT'); });
+ expect(plus()).toBeNull();
+ });
+
+ test('not rendered for a container that has children', () => {
+ const { innerId } = seed(true);
+ withRect(innerId);
+ h.act(() => { h.actions.selectNode(innerId); });
+ expect(plus()).toBeNull();
+ });
+
+ test('hidden while dragging; Escape closes the popover', () => {
+ const { sectionId, innerId } = seed();
+ withRect(innerId);
+ h.act(() => { h.actions.selectNode(innerId); });
+ h.act(() => { plus()!.click(); });
+ expect(document.querySelector('.empty-container-add-pop')).not.toBeNull();
+ h.act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
+ expect(document.querySelector('.empty-container-add-pop')).toBeNull();
+ // setNodeEvent is private; start a real Craft drag via the DOM instead.
+ const sectionDom = h.query.node(sectionId).get().dom as HTMLElement;
+ h.act(() => { sectionDom.dispatchEvent(dragStart()); });
+ expect(h.query.getEvent('dragged').all()).not.toEqual([]);
+ expect(plus()).toBeNull();
+ });
+});
diff --git a/craft/src/editor/EmptyContainerAdd.tsx b/craft/src/editor/EmptyContainerAdd.tsx
new file mode 100644
index 0000000..ca88eb0
--- /dev/null
+++ b/craft/src/editor/EmptyContainerAdd.tsx
@@ -0,0 +1,128 @@
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { useEditor } from '@craftjs/core';
+import { BLOCKS, insertBlock } from '../panels/left/blocks-registry';
+
+/** Block ids offered in the inline popover (resolved through the registry). */
+const QUICK_BLOCK_IDS = ['heading', 'text', 'button', 'image', 'columns-2', 'spacer'];
+const QUICK_LABELS: Record = { 'columns-2': 'Columns' };
+
+/**
+ * Inline "+" for an empty container/section/column: shown for the hovered or
+ * selected canvas node that has zero children (never ROOT). Click opens a
+ * small block picker that inserts into THAT node. Mounted from CanvasOverlays.
+ */
+export const EmptyContainerAdd: React.FC = () => {
+ // Primitives only: ids / booleans, so unrelated store churn doesn't re-render.
+ const { query, actions, targetId, isDragging } = useEditor((state) => {
+ const eligible = (id: string | undefined): id is string => {
+ if (!id || id === 'ROOT') return false;
+ const n = state.nodes[id];
+ return !!n && !!n.data.isCanvas && n.data.nodes.length === 0;
+ };
+ const hovered = [...state.events.hovered][0];
+ const selected = [...state.events.selected][0];
+ return {
+ targetId: eligible(hovered) ? hovered : eligible(selected) ? selected : null,
+ isDragging: state.events.dragged.size > 0,
+ };
+ });
+
+ // The "+" lives outside the node's DOM, so moving the pointer onto it ends
+ // Craft's hover; keep the last target alive while the button/popover is in use.
+ const [keepId, setKeepId] = useState(null);
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ const stillEmpty = (id: string | null): id is string => {
+ if (!id) return false;
+ try {
+ const n = query.node(id).get();
+ return !!n && !!n.data.isCanvas && n.data.nodes.length === 0;
+ } catch {
+ return false;
+ }
+ };
+ const nodeId = isDragging ? null : targetId ?? (stillEmpty(keepId) ? keepId : null);
+ const dom: HTMLElement | null = nodeId ? query.node(nodeId).get()?.dom ?? null : null;
+
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
+ const update = useCallback(() => {
+ if (!dom) return setPos(null);
+ const r = dom.getBoundingClientRect();
+ setPos({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
+ }, [dom]);
+
+ useLayoutEffect(() => {
+ update();
+ if (!dom) return;
+ window.addEventListener('resize', update);
+ document.addEventListener('scroll', update, true);
+ return () => {
+ window.removeEventListener('resize', update);
+ document.removeEventListener('scroll', update, true);
+ };
+ }, [dom, update]);
+
+ const close = useCallback(() => { setOpen(false); setKeepId(null); }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
+ };
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
+ document.addEventListener('mousedown', onDown);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDown);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open, close]);
+
+ // Target vanished (filled, dragged, deselected): drop popover state.
+ useEffect(() => { if (!nodeId && open) setOpen(false); }, [nodeId, open]);
+
+ if (!nodeId || !pos) return null;
+
+ const choose = (blockId: string) => {
+ insertBlock(query, actions, blockId, { parentId: nodeId, select: true });
+ close();
+ };
+
+ const blocks = QUICK_BLOCK_IDS
+ .map((id) => BLOCKS.find((b) => b.id === id))
+ .filter((b): b is NonNullable => !!b);
+
+ return (
+