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:
1 parent
ce3db23506
commit
fad34f3e71
6 files changed
+423
-61
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user