feat(site-builder): actionable empty canvas and inline add for empty containers

Refs #41

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 11:39:34 -07:00
1 parent ce3db23506
commit fad34f3e71
6 files changed
+423 -61

No files matched your search

+31 -8
View File
@@ -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 = () => {
<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.'}
: 'Drag blocks from the left panel, or start with one of these.'}
</span>
<div className="empty-canvas-actions">
<button type="button" className="empty-canvas-btn" onClick={() => setTemplateModalOpen(true)}>
<i className="fa fa-th-large" aria-hidden /> Choose a template
</button>
<button type="button" className="empty-canvas-btn" onClick={() => addSectionToRoot(query, actions)}>
<i className="fa fa-plus" aria-hidden /> Add a section
</button>
<button type="button" className="empty-canvas-btn" onClick={() => openSitesmith()}>
<i className="fa fa-magic" aria-hidden /> Generate with Sitesmith
</button>
</div>
</div>
);
};
+2
View File
@@ -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 = () => (
<>
<NoticeHost />
<EmptyContainerAdd />
</>
);
+85 -52
View File
@@ -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 <Editor> tree. */
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
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 <Editor>. */
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) => (
<SitesmithProvider>
<MobileChromeProvider>{tree}<Probe /></MobileChromeProvider>
</SitesmithProvider>
),
});
h.mountChild(<EmptyCanvasHint />);
}
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(<EmptyCanvasHint />);
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(<EmptyCanvasHint />);
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(<EmptyCanvasHint />);
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(<EmptyCanvasHint />);
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();
});
});
@@ -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(<EmptyContainerAdd />);
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<string, string>)[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<string, string>)[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();
});
});
+128
View File
@@ -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<string, string> = { '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<string | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(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<typeof b> => !!b);
return (
<div ref={rootRef}>
<button
type="button"
className="empty-container-add"
style={{ left: pos.x, top: pos.y }}
aria-label="Add block here"
title="Add block here"
onMouseEnter={() => setKeepId(nodeId)}
onMouseLeave={() => { if (!open) setKeepId(null); }}
onClick={() => { setKeepId(nodeId); setOpen((o) => !o); }}
>
<i className="fa fa-plus" aria-hidden />
</button>
{open && (
<div className="empty-container-add-pop" role="menu" style={{ left: pos.x - 120, top: pos.y + 22 }}>
{blocks.map((b) => (
<button
key={b.id}
type="button"
role="menuitem"
className="empty-container-add-item"
onClick={() => choose(b.id)}
>
<i className={`fa ${b.icon}`} aria-hidden />
{QUICK_LABELS[b.id] ?? b.label}
</button>
))}
</div>
)}
</div>
);
};
+89 -1
View File
@@ -1348,11 +1348,43 @@ body {
pointer-events: none;
}
.empty-canvas-hint i {
.empty-canvas-hint i.fa-cubes {
font-size: 32px;
opacity: 0.2;
}
.empty-canvas-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.empty-canvas-btn {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
color: var(--color-text);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
cursor: pointer;
}
.empty-canvas-btn:hover {
border-color: var(--color-accent);
}
@media (max-width: 767px) {
.empty-canvas-actions {
flex-direction: column;
align-items: stretch;
}
}
/* --------------------------------------------------------------------------
Settings Tabs (General / Style / Advanced)
-------------------------------------------------------------------------- */
@@ -2232,3 +2264,59 @@ body {
color: var(--color-text-muted);
text-align: center;
}
/* #41 empty container add */
.empty-container-add {
position: fixed;
z-index: 45;
width: 28px;
height: 28px;
margin: -14px 0 0 -14px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 13px;
color: #fff;
background: var(--color-accent);
border: 0;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
cursor: pointer;
/* Only the button is interactive; it never sits in Craft's drop path. */
pointer-events: auto;
}
.empty-container-add-pop {
position: fixed;
z-index: 46;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 6px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.empty-container-add-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 64px;
padding: 8px 6px;
font-size: 11px;
color: var(--color-text);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.empty-container-add-item:hover,
.empty-container-add-item:focus-visible {
border-color: var(--color-accent);
outline: none;
}