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 { 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<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true}>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
left: 0;
|
||||
padding: 2px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
@@ -1090,6 +1094,28 @@ body {
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user