Merge PR #6: UI polish Phase 1
This commit was merged in pull request #6.
This commit is contained in:
+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 />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.props?.aiName as string) || 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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,8 @@ interface ContextMenuProps {
|
|||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
|
||||||
|
icon?: string;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
@@ -189,7 +191,8 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{
|
{
|
||||||
label: '✨ Ask Sitesmith',
|
label: 'Ask Sitesmith',
|
||||||
|
icon: 'magic',
|
||||||
action: askSitesmith,
|
action: askSitesmith,
|
||||||
disabled: isRoot,
|
disabled: isRoot,
|
||||||
dividerAfter: true,
|
dividerAfter: true,
|
||||||
@@ -291,7 +294,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
(e.target as HTMLElement).style.background = 'transparent';
|
(e.target as HTMLElement).style.background = 'transparent';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>
|
||||||
|
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
{item.shortcut && (
|
{item.shortcut && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -107,23 +107,39 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
{loading ? 'Uploading...' : 'Upload File'}
|
{loading ? 'Uploading...' : 'Upload File'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 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. */}
|
||||||
<div
|
<div
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
|
||||||
style={{
|
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)'}`,
|
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
|
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
cursor: assets.length === 0 ? 'pointer' : 'default',
|
||||||
transition: 'all var(--transition-fast)',
|
transition: 'all var(--transition-fast)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Drop files here to upload
|
{assets.length === 0 && (
|
||||||
|
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
|
||||||
|
)}
|
||||||
|
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
@@ -143,20 +159,6 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Asset grid */}
|
{/* Asset grid */}
|
||||||
{assets.length === 0 && !loading && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: 20,
|
|
||||||
color: 'var(--color-text-dim)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No assets uploaded yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -296,7 +298,7 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✕
|
<i className="fa fa-times" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -328,7 +330,7 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
||||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
||||||
>
|
>
|
||||||
✕
|
<i className="fa fa-times" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✕
|
<i className="fa fa-trash" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { defaultPresetGridColumns } from './shared';
|
||||||
|
import { RADIUS_PRESETS, SPACING_PRESETS, IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, TEXT_SIZES, FONT_FAMILIES } from '../../../constants/presets';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Regression: 5-item preset sets (RADIUS_PRESETS, SPACING_PRESETS,
|
||||||
|
* IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, and NavStylePanel's ad-hoc
|
||||||
|
* GAP_PRESETS) left a lone orphan button on its own row under the old
|
||||||
|
* fixed 4-column .preset-grid. PresetButtonGrid now derives a column
|
||||||
|
* count from the preset length instead.
|
||||||
|
*/
|
||||||
|
describe('defaultPresetGridColumns', () => {
|
||||||
|
it('gives 5-item sets their own single row (no orphan)', () => {
|
||||||
|
expect(defaultPresetGridColumns(RADIUS_PRESETS.length)).toBe(5);
|
||||||
|
expect(defaultPresetGridColumns(SPACING_PRESETS.length)).toBe(5);
|
||||||
|
expect(defaultPresetGridColumns(IMAGE_RADIUS_PRESETS.length)).toBe(5);
|
||||||
|
expect(defaultPresetGridColumns(FONT_WEIGHTS.length)).toBe(5);
|
||||||
|
expect(defaultPresetGridColumns(5)).toBe(5); // NavStylePanel's GAP_PRESETS
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits 6-item sets into two even rows of 3 (was 4+2 uneven)', () => {
|
||||||
|
expect(defaultPresetGridColumns(TEXT_SIZES.length)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the classic 4-column grid for sets that already divide evenly', () => {
|
||||||
|
expect(defaultPresetGridColumns(FONT_FAMILIES.length)).toBe(4); // 8 items
|
||||||
|
expect(defaultPresetGridColumns(4)).toBe(4);
|
||||||
|
expect(defaultPresetGridColumns(3)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaves a single orphan on the final row for any count 1-12', () => {
|
||||||
|
for (let n = 1; n <= 12; n++) {
|
||||||
|
const cols = defaultPresetGridColumns(n);
|
||||||
|
const isLoneOrphan = n > cols && n % cols === 1;
|
||||||
|
expect(isLoneOrphan).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,9 +84,30 @@ interface PresetButtonGridProps {
|
|||||||
presets: { label: string; value: string }[];
|
presets: { label: string; value: string }[];
|
||||||
activeValue: string | undefined;
|
activeValue: string | undefined;
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
|
/** Explicit column count. When omitted, a column count is derived from
|
||||||
|
* `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset
|
||||||
|
* sets (5, 6, ...) don't leave a lone orphan button dangling on its own
|
||||||
|
* row under the fixed 4-column grid. */
|
||||||
|
columns?: number;
|
||||||
}
|
}
|
||||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
|
|
||||||
<div className="preset-grid">
|
/** Picks a column count that avoids a single orphan on the last row.
|
||||||
|
* 4-or-fewer presets keep the classic single row of 4. 5 gets its own
|
||||||
|
* row (5 cols). 6 splits into two even rows of 3. Anything else falls
|
||||||
|
* back to a 4- or 3-column grid depending on which divides evenly. */
|
||||||
|
export function defaultPresetGridColumns(count: number): number {
|
||||||
|
if (count <= 4) return 4;
|
||||||
|
if (count === 5) return 5;
|
||||||
|
if (count === 6) return 3;
|
||||||
|
if (count % 4 === 0) return 4;
|
||||||
|
if (count % 3 === 0) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect, columns }) => {
|
||||||
|
const cols = columns ?? defaultPresetGridColumns(presets.length);
|
||||||
|
return (
|
||||||
|
<div className="preset-grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.value}
|
key={p.value}
|
||||||
@@ -98,6 +119,7 @@ export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, act
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface GradientSwatchGridProps {
|
interface GradientSwatchGridProps {
|
||||||
activeValue: string | undefined;
|
activeValue: string | undefined;
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
|||||||
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
|
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✨ Sitesmith
|
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} /> Sitesmith
|
||||||
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
|
{locked && <i className="fa fa-lock" aria-hidden style={{ marginLeft: 6, fontSize: 12 }} />}
|
||||||
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
|
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
|||||||
>
|
>
|
||||||
<div style={panel}>
|
<div style={panel}>
|
||||||
<div style={header}>
|
<div style={header}>
|
||||||
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
|
<div style={{ fontWeight: 600, color: '#fff' }}>
|
||||||
|
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} />
|
||||||
|
Sitesmith
|
||||||
|
</div>
|
||||||
{summary && summary.enabled && (
|
{summary && summary.enabled && (
|
||||||
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
|
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
|
||||||
{summary.monthly_used} / {summary.monthly_cap} this month
|
{summary.monthly_used} / {summary.monthly_cap} this month
|
||||||
@@ -136,7 +139,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
<button onClick={onClose} aria-label="Close" style={closeBtn}><i className="fa fa-times" aria-hidden /></button>
|
||||||
</div>
|
</div>
|
||||||
<div style={body}>
|
<div style={body}>
|
||||||
<UpgradeBanner summary={summary} />
|
<UpgradeBanner summary={summary} />
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} style={closeButtonStyle} title="Close">
|
<button onClick={onClose} style={closeButtonStyle} title="Close">
|
||||||
✕
|
<i className="fa fa-times" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
--color-border: #2d2d3a;
|
--color-border: #2d2d3a;
|
||||||
--color-border-light: #3f3f46;
|
--color-border-light: #3f3f46;
|
||||||
--color-text: #e4e4e7;
|
--color-text: #e4e4e7;
|
||||||
--color-text-muted: #71717a;
|
--color-text-muted: #8b8b96;
|
||||||
--color-text-dim: #52525b;
|
--color-text-dim: #6e6e78;
|
||||||
--color-accent: #3b82f6;
|
--color-accent: #3b82f6;
|
||||||
--color-accent-hover: #2563eb;
|
--color-accent-hover: #2563eb;
|
||||||
--color-accent-subtle: rgba(59, 130, 246, 0.12);
|
--color-accent-subtle: rgba(59, 130, 246, 0.12);
|
||||||
@@ -704,7 +704,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-item-icon {
|
.block-item-icon {
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
transition: color var(--transition-fast);
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
@@ -714,7 +714,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-item-label {
|
.block-item-label {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -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
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
@@ -1225,6 +1251,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 +1262,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