UI polish Phase 1: quick wins (empty-canvas state, selection badge, contrast, FA icons, preset grid, assets empty state) #6
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Editor } from '@craftjs/core';
|
import { Editor } from '@craftjs/core';
|
||||||
import { EditorShell } from './editor/EditorShell';
|
import { EditorShell } from './editor/EditorShell';
|
||||||
|
import { RenderNode } from './editor/RenderNode';
|
||||||
import { componentResolver } from './components/resolver';
|
import { componentResolver } from './components/resolver';
|
||||||
import { WhpConfig } from './types';
|
import { WhpConfig } from './types';
|
||||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||||
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
|||||||
return (
|
return (
|
||||||
<EditorConfigProvider config={whpConfig}>
|
<EditorConfigProvider config={whpConfig}>
|
||||||
<SiteDesignProvider>
|
<SiteDesignProvider>
|
||||||
<Editor resolver={componentResolver} enabled={true}>
|
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||||
<PageProvider>
|
<PageProvider>
|
||||||
<SitesmithProvider>
|
<SitesmithProvider>
|
||||||
<EditorShell />
|
<EditorShell />
|
||||||
|
|||||||
@@ -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 <Editor onRender> 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 = <span data-testid="inner">hello</span>;
|
||||||
|
|
||||||
|
describe('RenderNode (Editor onRender override)', () => {
|
||||||
|
test('passes render through untouched when not selected', () => {
|
||||||
|
render(<RenderNode render={rendered} />);
|
||||||
|
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(<RenderNode render={rendered} />);
|
||||||
|
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(<RenderNode render={rendered} />);
|
||||||
|
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(<RenderNode render={rendered} />);
|
||||||
|
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(<RenderNode render={rendered} />);
|
||||||
|
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
|
||||||
|
container.remove();
|
||||||
|
nodeDom.remove();
|
||||||
|
document.querySelector('.component-indicator')?.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<Editor onRender>` 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<RenderNodeProps> = ({ render }) => {
|
||||||
|
const { actions } = useEditor();
|
||||||
|
const { id, isSelected, dom, name, parent } = useNode((node) => ({
|
||||||
|
isSelected: node.events.selected,
|
||||||
|
dom: node.dom,
|
||||||
|
name: node.data.custom?.displayName || node.data.displayName,
|
||||||
|
parent: node.data.parent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const badgeRef = useRef<HTMLDivElement>(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(
|
||||||
|
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
||||||
|
<span>{name}</span>
|
||||||
|
{parent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="component-indicator-parent-btn"
|
||||||
|
title="Select parent"
|
||||||
|
aria-label={`Select parent of ${name}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
actions.selectNode(parent);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-chevron-up" aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1074,12 +1074,16 @@ body {
|
|||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Component indicator badge */
|
/* Component indicator badge (floats over the selected node via a portal --
|
||||||
|
position/top/left are set inline per-node, see RenderNode.tsx) */
|
||||||
.component-indicator {
|
.component-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -22px;
|
top: -22px;
|
||||||
left: 0;
|
left: 0;
|
||||||
padding: 2px 8px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 6px 2px 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -1090,6 +1094,28 @@ body {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.component-indicator-parent-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-indicator-parent-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Scrollbar Styling
|
Scrollbar Styling
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user