UI polish Phase 1: quick wins (empty-canvas state, selection badge, contrast, FA icons, preset grid, assets empty state) #6
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useRef, useEffect } from 'react';
|
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 { Container } from '../components/layout/Container';
|
||||||
import { usePages } from '../state/PageContext';
|
import { usePages } from '../state/PageContext';
|
||||||
import { DeviceMode } from '../types';
|
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 (
|
||||||
|
<div className="empty-canvas-hint">
|
||||||
|
<i className="fa fa-cubes" aria-hidden />
|
||||||
|
<span>Drag blocks from the left panel, or pick a Template to start.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||||
const width = DEVICE_WIDTHS[device];
|
const width = DEVICE_WIDTHS[device];
|
||||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||||
@@ -140,6 +167,7 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
|||||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
<Frame>
|
<Frame>
|
||||||
<Element
|
<Element
|
||||||
is={Container}
|
is={Container}
|
||||||
@@ -148,6 +176,8 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
|||||||
style={frameStyle}
|
style={frameStyle}
|
||||||
/>
|
/>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
{isEditingRegularPage && <EmptyCanvasHint />}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEditingRegularPage && (
|
{isEditingRegularPage && (
|
||||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||||
|
|||||||
@@ -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 <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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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('hides once the page has content', () => {
|
||||||
|
mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
|
||||||
|
render(<EmptyCanvasHint />);
|
||||||
|
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(<EmptyCanvasHint />);
|
||||||
|
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1225,6 +1225,8 @@ body {
|
|||||||
Empty Canvas State
|
Empty Canvas State
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.empty-canvas-hint {
|
.empty-canvas-hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1234,6 +1236,9 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
/* Overlays the drop area without intercepting drags/clicks meant for the
|
||||||
|
underlying (empty) canvas root -- see Canvas.tsx. */
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-canvas-hint i {
|
.empty-canvas-hint i {
|
||||||
|
|||||||
Reference in New Issue
Block a user