From fad34f3e71e5ee68e8bae6265766861e07cdef19 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 11:39:34 -0700 Subject: [PATCH] feat(site-builder): actionable empty canvas and inline add for empty containers Refs #41 Co-Authored-By: Claude Sonnet 5.5 --- craft/src/editor/Canvas.tsx | 39 ++++-- craft/src/editor/CanvasOverlays.tsx | 2 + craft/src/editor/EmptyCanvasHint.test.tsx | 137 ++++++++++++-------- craft/src/editor/EmptyContainerAdd.test.tsx | 88 +++++++++++++ craft/src/editor/EmptyContainerAdd.tsx | 128 ++++++++++++++++++ craft/src/styles/editor.css | 90 ++++++++++++- 6 files changed, 423 insertions(+), 61 deletions(-) create mode 100644 craft/src/editor/EmptyContainerAdd.test.tsx create mode 100644 craft/src/editor/EmptyContainerAdd.tsx diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index 1188cfd..a65c2b4 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 ( +
+ + {open && ( +
+ {blocks.map((b) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 9b67aa5..8d97578 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; +}