diff --git a/craft/src/App.tsx b/craft/src/App.tsx index 323ba61..bd6ec11 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Editor } from '@craftjs/core'; import { EditorShell } from './editor/EditorShell'; +import { RenderNode } from './editor/RenderNode'; import { componentResolver } from './components/resolver'; import { WhpConfig } from './types'; import { EditorConfigProvider } from './state/EditorConfigContext'; @@ -23,7 +24,7 @@ export const App: React.FC = ({ whpConfig }) => { return ( - + diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index a4a2ca5..25a46af 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useRef, useEffect } from 'react'; -import { Frame, Element } from '@craftjs/core'; +import { Frame, Element, useEditor } from '@craftjs/core'; import { Container } from '../components/layout/Container'; import { usePages } from '../state/PageContext'; import { DeviceMode } from '../types'; @@ -93,6 +93,33 @@ 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. + */ +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, + }; + }); + + if (!isEmpty || isDragging) return null; + + return ( +
+ + Drag blocks from the left panel, or pick a Template to start. +
+ ); +}; + export const Canvas: React.FC = ({ device }) => { const width = DEVICE_WIDTHS[device]; const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); @@ -140,14 +167,17 @@ export const Canvas: React.FC = ({ device }) => { )} - - - +
+ + + + {isEditingRegularPage && } +
{isEditingRegularPage && ( diff --git a/craft/src/editor/EmptyCanvasHint.test.tsx b/craft/src/editor/EmptyCanvasHint.test.tsx new file mode 100644 index 0000000..e1c28b8 --- /dev/null +++ b/craft/src/editor/EmptyCanvasHint.test.tsx @@ -0,0 +1,67 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* 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; +}); + +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 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('hides once the page has content', () => { + mockNodes = { ROOT: { data: { nodes: ['node-1'] } } }; + render(); + expect(container.querySelector('.empty-canvas-hint')).toBeNull(); + container.remove(); + }); + + 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(); + }); +}); diff --git a/craft/src/editor/RenderNode.test.tsx b/craft/src/editor/RenderNode.test.tsx new file mode 100644 index 0000000..6984d4d --- /dev/null +++ b/craft/src/editor/RenderNode.test.tsx @@ -0,0 +1,108 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core + so RenderNode (the override) can be driven without a + real Editor tree. document.body doubles as the portal target, same as + the component itself uses. */ +let mockNode: { + id: string; + selected: boolean; + dom: HTMLElement | null; + displayName: string; + parent: string | null; +}; +const selectNodeSpy = vi.fn(); + +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ actions: { selectNode: selectNodeSpy } }), + useNode: (collect?: (node: any) => any) => { + const node = { + events: { selected: mockNode.selected }, + dom: mockNode.dom, + data: { custom: {}, displayName: mockNode.displayName, parent: mockNode.parent }, + }; + return { id: mockNode.id, ...(collect ? collect(node) : {}) }; + }, +})); + +import { RenderNode } from './RenderNode'; + +let container: HTMLDivElement; +let root: Root; +let nodeDom: HTMLElement; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +beforeEach(() => { + nodeDom = document.createElement('div'); + document.body.appendChild(nodeDom); + mockNode = { id: 'node-1', selected: false, dom: nodeDom, displayName: 'Heading', parent: 'ROOT' }; + selectNodeSpy.mockClear(); +}); + +const rendered = hello; + +describe('RenderNode (Editor onRender override)', () => { + test('passes render through untouched when not selected', () => { + render(); + expect(container.querySelector('[data-testid="inner"]')).not.toBeNull(); + expect(document.querySelector('.component-indicator')).toBeNull(); + container.remove(); + nodeDom.remove(); + }); + + test('shows the badge with the displayName when selected', () => { + mockNode.selected = true; + render(); + const badge = document.querySelector('.component-indicator'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toContain('Heading'); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); + + test('never shows a badge for ROOT even if "selected"', () => { + mockNode.selected = true; + mockNode.id = 'ROOT'; + render(); + expect(document.querySelector('.component-indicator')).toBeNull(); + container.remove(); + nodeDom.remove(); + }); + + test('chevron click selects the parent node', () => { + mockNode.selected = true; + mockNode.parent = 'parent-42'; + render(); + const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement; + expect(chevron).not.toBeNull(); + act(() => { + chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + expect(selectNodeSpy).toHaveBeenCalledWith('parent-42'); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); + + test('no chevron when there is no parent', () => { + mockNode.selected = true; + mockNode.parent = null; + render(); + expect(document.querySelector('.component-indicator-parent-btn')).toBeNull(); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx new file mode 100644 index 0000000..df567cc --- /dev/null +++ b/craft/src/editor/RenderNode.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useEditor, useNode } from '@craftjs/core'; + +interface RenderNodeProps { + render: React.ReactElement; +} + +/** + * Craft.js `` override -- wraps every node's render output. + * For the currently-selected node it portals a floating badge (component + * displayName + a "select parent" chevron) positioned over the node's real + * DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass + * straight through as a Fragment, so this never touches layout, never + * appears in `toHtml` export (that walks the Craft node tree, not this + * portal), and doesn't wrap every node in extra DOM. + */ +export const RenderNode: React.FC = ({ render }) => { + const { actions } = useEditor(); + const { id, isSelected, dom, name, parent } = useNode((node) => ({ + isSelected: node.events.selected, + dom: node.dom, + name: (node.data.props?.aiName as string) || node.data.displayName, + parent: node.data.parent, + })); + + const badgeRef = useRef(null); + const active = isSelected && id !== 'ROOT' && !!dom; + + const updatePosition = useCallback(() => { + if (!dom || !badgeRef.current) return; + const rect = dom.getBoundingClientRect(); + const badgeHeight = 22; + badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`; + badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`; + }, [dom]); + + useEffect(() => { + if (!active) return; + updatePosition(); + window.addEventListener('resize', updatePosition); + document.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + document.removeEventListener('scroll', updatePosition, true); + }; + }, [active, updatePosition]); + + if (!active) return <>{render}; + + return ( + <> + {render} + {createPortal( +
+ {name} + {parent && ( + + )} +
, + document.body + )} + + ); +}; diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index b1eb0b8..fa9371d 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -16,6 +16,8 @@ interface ContextMenuProps { interface MenuItem { label: string; + /** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */ + icon?: string; shortcut?: string; action: () => void; danger?: boolean; @@ -189,7 +191,8 @@ export const ContextMenu: React.FC = ({ const items: MenuItem[] = [ { - label: '✨ Ask Sitesmith', + label: 'Ask Sitesmith', + icon: 'magic', action: askSitesmith, disabled: isRoot, dividerAfter: true, @@ -291,7 +294,10 @@ export const ContextMenu: React.FC = ({ (e.target as HTMLElement).style.background = 'transparent'; }} > - {item.label} + + {item.icon && } + {item.label} + {item.shortcut && ( { {loading ? 'Uploading...' : 'Upload File'} - {/* Drop zone */} + {/* Drop zone -- a single element that doubles as the empty state. + Previously this was a small always-visible dropzone PLUS a + separate italic "No assets uploaded yet" line stacked underneath + it when empty; merged into one tall dropzone (icon + copy, + click-or-drag) so the empty state isn't two redundant messages. + Once assets exist it collapses back to a slim persistent drop + target above the grid. */}
fileInputRef.current?.click()) : {})} style={{ - padding: 20, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + padding: assets.length === 0 ? '36px 20px' : 16, border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`, borderRadius: 'var(--radius-md)', background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent', textAlign: 'center', color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)', fontSize: 11, + cursor: assets.length === 0 ? 'pointer' : 'default', transition: 'all var(--transition-fast)', }} > - Drop files here to upload + {assets.length === 0 && ( + + )} + {assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
{/* Error message */} @@ -143,20 +159,6 @@ export const AssetsPanel: React.FC = () => { )} {/* Asset grid */} - {assets.length === 0 && !loading && ( -
- No assets uploaded yet -
- )} -
{ cursor: 'pointer', }} > - ✕ +
) : ( @@ -328,7 +330,7 @@ export const AssetsPanel: React.FC = () => { onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }} onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }} > - ✕ + )} diff --git a/craft/src/panels/left/PagesPanel.tsx b/craft/src/panels/left/PagesPanel.tsx index a7e90a4..02f1abe 100644 --- a/craft/src/panels/left/PagesPanel.tsx +++ b/craft/src/panels/left/PagesPanel.tsx @@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => { cursor: 'pointer', }} > - ✕ +