Merge issue-48 (Refs #48)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 12:06:59 -07:00
commit eb87a39619
7 files changed
+824 -1

No files matched your search

@@ -0,0 +1,124 @@
import React from 'react';
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { ContextMenu } from './ContextMenu';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Heading } from '../../components/basic/Heading';
import { NoticeProvider, useNotices } from '../../state/NoticeContext';
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
import { SaveGuardProvider } from '../../state/SaveGuardContext';
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
import { setStyleClipboard, getStyleClipboard, extractStyle } from '../../utils/style-clipboard';
let h: EditorHarness;
beforeEach(() => setStyleClipboard(null));
afterEach(() => { h?.unmount(); document.body.innerHTML = ''; });
const extrasRef: { current: ReturnType<typeof useProjectExtras> | null } = { current: null };
const designRef: { current: SiteDesignContextValue | null } = { current: null };
let notices: string[] = [];
const Probe: React.FC = () => {
extrasRef.current = useProjectExtras();
designRef.current = useSiteDesign();
notices = useNotices().notices.map((n) => n.message);
return null;
};
function add(el: React.ReactElement): string {
let id = '';
h.act(() => {
const tree = h.query.parseReactElement(el).toNodeTree();
h.actions.addNodeTree(tree, 'ROOT');
id = tree.rootNodeId;
});
return id;
}
const props = (id: string) => h.query.node(id).get().data.props;
function mountMenu(nodeId: string | null) {
h.mountChild(
<NoticeProvider><SaveGuardProvider><ProjectExtrasProvider><SiteDesignProvider>
<Probe />
<CanvasTokenStyle />
<ContextMenu visible x={10} y={10} nodeId={nodeId} onClose={() => {}} />
</SiteDesignProvider></ProjectExtrasProvider></SaveGuardProvider></NoticeProvider>,
);
}
const btn = (label: string) =>
Array.from(h.container.querySelectorAll('button')).find((b) => b.textContent?.includes(label)) as HTMLButtonElement;
const click = (label: string) => h.act(() => { btn(label).click(); });
describe('ContextMenu style items', () => {
test('present; on ROOT all style items are disabled', () => {
h = renderEditorHarness();
mountMenu('ROOT');
for (const l of ['Copy style', 'Paste style', 'Paste style (merge)', 'Save style as preset', 'Apply preset']) {
expect(btn(l), l).toBeTruthy();
expect(btn(l).disabled, l).toBe(true);
}
});
test('Paste style disabled with empty clipboard, enabled after Copy style; notifies', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, { style: { backgroundColor: 'var(--wsb-primary)', borderRadius: '999px' } }));
const b = add(React.createElement(ButtonLink, { text: 'B' }));
mountMenu(a);
expect(btn('Copy style').disabled).toBe(false);
expect(btn('Paste style').disabled).toBe(true);
click('Copy style');
expect(getStyleClipboard()?.type).toBe('ButtonLink');
expect(notices).toContain('Style copied');
mountMenu(b);
expect(btn('Paste style').disabled).toBe(false);
click('Paste style (merge)');
expect(props(b).style).toMatchObject({ backgroundColor: 'var(--wsb-primary)', borderRadius: '999px' });
expect(props(b).text).toBe('B');
});
test('Paste style disabled for an incompatible target', () => {
h = renderEditorHarness();
setStyleClipboard(extractStyle('Container', { style: { display: 'flex' } }));
const hd = add(React.createElement(Heading, { text: 'T' }));
mountMenu(hd);
expect(btn('Paste style').disabled).toBe(true);
});
test('save preset stores token refs; apply to a new Button; palette change re-themes', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, { style: { backgroundColor: 'var(--wsb-primary)', borderRadius: '999px' } }));
const b = add(React.createElement(ButtonLink, { text: 'New' }));
mountMenu(a);
click('Save style as preset');
const input = h.container.ownerDocument.querySelector('.wsb-presets-input') as HTMLInputElement;
h.act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Pill');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
h.act(() => { (document.querySelector('.wsb-presets-btn') as HTMLButtonElement).click(); });
const saved = (extrasRef.current!.get<any[]>('stylePresets', []))[0];
expect(saved.name).toBe('Pill');
expect(saved.snapshot.style.backgroundColor).toBe('var(--wsb-primary)');
mountMenu(b);
click('Apply preset');
expect(document.querySelector('.wsb-presets-popover')!.textContent).toContain('Pill');
h.act(() => { (document.querySelector('.wsb-presets-apply') as HTMLButtonElement).click(); });
expect(props(b).style.backgroundColor).toBe('var(--wsb-primary)');
expect(props(b).text).toBe('New');
h.act(() => { designRef.current!.updateDesign({ primaryColor: '#ff0000' }); });
expect(document.querySelector('style[data-wsb-canvas-tokens]')!.textContent).toContain('--wsb-primary:#ff0000');
// Still a reference, so the canvas resolves it to the new colour.
expect(props(b).style.backgroundColor).toBe('var(--wsb-primary)');
});
test('Apply preset shows the empty state when none match', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, {}));
mountMenu(a);
click('Apply preset');
expect(document.querySelector('.wsb-presets-popover')!.textContent).toContain('No saved presets');
});
});
+108 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useCallback, useRef } from 'react';
import React, { useEffect, useCallback, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
@@ -7,6 +7,14 @@ import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { getCommand } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useNotices } from '../../state/NoticeContext';
import { useProjectExtras } from '../../state/ProjectExtrasContext';
import { useDevice } from '../../state/DeviceContext';
import {
applyStyleSnapshot, deletePreset, extractStyle, getStyleClipboard, isCompatible,
listPresets, nodeTypeOf, savePreset, setStyleClipboard, type StylePreset,
} from '../../utils/style-clipboard';
import { PresetsPopover, PRESETS_POPOVER_ATTR } from './PresetsPopover';
interface ContextMenuProps {
visible: boolean;
@@ -43,11 +51,18 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null);
const { notify } = useNotices();
const extras = useProjectExtras();
const { device } = useDevice();
const [popover, setPopover] = useState<{ mode: 'save' | 'apply'; x: number; y: number } | null>(null);
useEffect(() => { if (!visible) setPopover(null); }, [visible]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handleClick = (e: MouseEvent) => {
const t = e.target as Element;
if (t.closest?.(`[${PRESETS_POPOVER_ATTR}]`)) return;
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
@@ -141,6 +156,54 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose();
}, [nodeId, actions, query, onClose]);
const targetType = nodeId && nodeId !== 'ROOT' ? nodeTypeOf(query, nodeId) : null;
const copyStyle = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const data = query.node(nodeId).get().data;
setStyleClipboard(extractStyle(data.name, data.props));
notify({ kind: 'success', message: 'Style copied' });
} catch (e) {
console.error('Copy style failed:', e);
}
onClose();
}, [nodeId, query, notify, onClose]);
const pasteStyle = useCallback((mode: 'replace' | 'merge') => {
const snap = getStyleClipboard();
if (snap && nodeId && nodeId !== 'ROOT') {
const n = applyStyleSnapshot(actions, [nodeId], snap, mode, { query, device });
notify(n ? { kind: 'success', message: mode === 'merge' ? 'Style merged' : 'Style pasted' } : { kind: 'info', message: 'That style does not fit this element' });
}
onClose();
}, [nodeId, actions, query, device, notify, onClose]);
const openPopover = (mode: 'save' | 'apply') => {
// Beside the menu so the menu itself stays readable.
setPopover({ mode, x: Math.min(x, window.innerWidth - 200) + 190, y: Math.min(y, window.innerHeight - 300) });
};
const saveAsPreset = (name: string) => {
if (!nodeId) return;
try {
const data = query.node(nodeId).get().data;
const saved = savePreset(extras, { name, snapshot: extractStyle(data.name, data.props) });
if (saved) notify({ kind: 'success', message: `Preset "${saved.name}" saved` });
} catch (e) {
console.error('Save preset failed:', e);
}
onClose();
};
const applyPreset = (p: StylePreset) => {
if (nodeId) {
applyStyleSnapshot(actions, [nodeId], p.snapshot, 'replace', { query, device });
notify({ kind: 'success', message: `Preset "${p.name}" applied` });
}
onClose();
};
const moveUp = useCallback(() => {
nodeActions.moveUp();
onClose();
@@ -204,6 +267,37 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
shortcut: shortcutLabel('edit.paste'),
action: pasteNode,
disabled: !getClipboardTree(),
},
{
label: 'Copy style',
icon: 'paint-brush',
action: copyStyle,
disabled: isRoot,
},
{
label: 'Paste style',
icon: 'magic',
action: () => pasteStyle('replace'),
disabled: isRoot || !isCompatible(getStyleClipboard(), targetType),
},
{
label: 'Paste style (merge)',
icon: 'object-group',
action: () => pasteStyle('merge'),
disabled: isRoot || !isCompatible(getStyleClipboard(), targetType),
dividerAfter: true,
},
{
label: 'Save style as preset\u2026',
icon: 'bookmark',
action: () => openPopover('save'),
disabled: isRoot,
},
{
label: 'Apply preset \u25B8',
icon: 'bookmark-o',
action: () => openPopover('apply'),
disabled: isRoot,
dividerAfter: true,
},
{
@@ -247,6 +341,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10);
return (
<>
<div
ref={menuRef}
style={{
@@ -323,5 +418,17 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
</React.Fragment>
))}
</div>
{popover && popover.mode === 'save' && (
<PresetsPopover mode="save" x={popover.x} y={popover.y} onClose={() => setPopover(null)} onSave={saveAsPreset} />
)}
{popover && popover.mode === 'apply' && (
<PresetsPopover
mode="apply" x={popover.x} y={popover.y} onClose={() => setPopover(null)}
presets={listPresets(extras, targetType)}
onApply={applyPreset}
onDelete={(id) => { deletePreset(extras, id); setPopover({ ...popover }); }}
/>
)}
</>
);
};
@@ -0,0 +1,68 @@
import React from 'react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { PresetsPopover } from './PresetsPopover';
import type { StylePreset } from '../../utils/style-clipboard';
let host: HTMLDivElement;
function mount(el: React.ReactElement) {
host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
act(() => { root.render(el); });
return () => { act(() => root.unmount()); host.remove(); };
}
afterEach(() => { document.body.innerHTML = ''; });
const preset = (id: string, name: string): StylePreset => ({
id, name, type: 'ButtonLink', createdAt: 1,
snapshot: { type: 'ButtonLink', style: {}, props: {} },
});
describe('PresetsPopover', () => {
test('apply mode: empty state text', () => {
const un = mount(<PresetsPopover mode="apply" x={0} y={0} presets={[]} onApply={() => {}} onDelete={() => {}} onClose={() => {}} />);
expect(host.textContent).toContain('No saved presets');
un();
});
test('apply mode: click applies, trash deletes', () => {
const onApply = vi.fn(); const onDelete = vi.fn();
const un = mount(<PresetsPopover mode="apply" x={0} y={0} presets={[preset('1', 'Pill')]} onApply={onApply} onDelete={onDelete} onClose={() => {}} />);
act(() => { (host.querySelector('.wsb-presets-apply') as HTMLElement).click(); });
expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ id: '1' }));
act(() => { (host.querySelector('[aria-label="Delete preset Pill"]') as HTMLElement).click(); });
expect(onDelete).toHaveBeenCalledWith('1');
un();
});
test('save mode: Save disabled until named; Enter submits trimmed name', () => {
const onSave = vi.fn();
const un = mount(<PresetsPopover mode="save" x={0} y={0} onSave={onSave} onClose={() => {}} />);
const btn = host.querySelector('.wsb-presets-btn') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
const input = host.querySelector('input') as HTMLInputElement;
act(() => {
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
set.call(input, ' Pill ');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(btn.disabled).toBe(false);
act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
expect(onSave).toHaveBeenCalledWith('Pill');
un();
});
test('Escape and outside mousedown close; inside does not', () => {
const onClose = vi.fn();
const un = mount(<PresetsPopover mode="apply" x={0} y={0} presets={[]} onApply={() => {}} onDelete={() => {}} onClose={onClose} />);
act(() => { host.querySelector('.wsb-presets-popover')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); });
expect(onClose).not.toHaveBeenCalled();
act(() => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); });
expect(onClose).toHaveBeenCalledTimes(1);
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
expect(onClose).toHaveBeenCalledTimes(2);
un();
});
});
@@ -0,0 +1,87 @@
import React, { useEffect, useRef, useState } from 'react';
import type { StylePreset } from '../../utils/style-clipboard';
/** Marker so the context menu's outside-click handler leaves the popover alone. */
export const PRESETS_POPOVER_ATTR = 'data-wsb-presets-popover';
/** Keeps a w x h box inside the viewport (same clamp the menu uses). */
export function clampToViewport(x: number, y: number, w: number, h: number) {
return {
left: Math.max(4, Math.min(x, window.innerWidth - w - 4)),
top: Math.max(4, Math.min(y, window.innerHeight - h - 4)),
};
}
export type PresetsPopoverProps = {
x: number;
y: number;
onClose: () => void;
} & (
| { mode: 'save'; onSave: (name: string) => void }
| { mode: 'apply'; presets: StylePreset[]; onApply: (p: StylePreset) => void; onDelete: (id: string) => void }
);
export const PresetsPopover: React.FC<PresetsPopoverProps> = (props) => {
const { x, y, onClose } = props;
const ref = useRef<HTMLDivElement>(null);
const [name, setName] = useState('');
useEffect(() => {
const down = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('mousedown', down);
document.removeEventListener('keydown', key);
};
}, [onClose]);
const pos = clampToViewport(x, y, 220, 160);
const submit = () => {
if (props.mode === 'save' && name.trim()) props.onSave(name.trim());
};
return (
<div
ref={ref}
{...{ [PRESETS_POPOVER_ATTR]: 'true' }}
className="wsb-presets-popover"
role="dialog"
aria-label={props.mode === 'save' ? 'Save style as preset' : 'Apply preset'}
style={{ position: 'fixed', ...pos, zIndex: 10001 }}
>
{props.mode === 'save' ? (
<>
<label className="wsb-presets-label" htmlFor="wsb-preset-name">Preset name</label>
<input
id="wsb-preset-name"
className="wsb-presets-input"
autoFocus
value={name}
maxLength={60}
placeholder="e.g. Pill"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
/>
<button type="button" className="wsb-presets-btn" disabled={!name.trim()} onClick={submit}>Save preset</button>
</>
) : props.presets.length === 0 ? (
<div className="wsb-presets-empty">No saved presets for this element type yet.</div>
) : (
<ul className="wsb-presets-list">
{props.presets.map((p) => (
<li key={p.id} className="wsb-presets-row">
<button type="button" className="wsb-presets-apply" onClick={() => props.onApply(p)}>{p.name}</button>
<button type="button" className="wsb-presets-delete" aria-label={`Delete preset ${p.name}`} onClick={() => props.onDelete(p.id)}>
<i className="fa fa-trash" />
</button>
</li>
))}
</ul>
)}
</div>
);
};
+57
View File
@@ -2753,3 +2753,60 @@ body {
cursor: pointer;
}
.multi-select-btn:hover { border-color: var(--color-accent); }
/* #48 presets */
.wsb-presets-popover {
min-width: 200px;
max-width: 260px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--color-text);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.wsb-presets-label { font-size: 11px; color: var(--color-text-dim); }
.wsb-presets-input {
padding: 5px 8px;
font-size: 12px;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
}
.wsb-presets-btn {
padding: 5px 8px;
font-size: 12px;
color: var(--color-text);
background: var(--color-bg-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.wsb-presets-btn:disabled { opacity: 0.5; cursor: default; }
.wsb-presets-empty { padding: 4px; color: var(--color-text-dim); }
.wsb-presets-list { margin: 0; padding: 0; list-style: none; }
.wsb-presets-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.wsb-presets-apply {
flex: 1;
padding: 6px 8px;
text-align: left;
font-size: 12px;
color: var(--color-text);
background: transparent;
border: 0;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.wsb-presets-apply:hover { background: var(--color-bg-hover); }
.wsb-presets-delete {
padding: 4px 6px;
color: var(--color-danger);
background: transparent;
border: 0;
cursor: pointer;
}
+180
View File
@@ -0,0 +1,180 @@
import React from 'react';
import { describe, test, expect, afterEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { ButtonLink } from '../components/basic/ButtonLink';
import { Heading } from '../components/basic/Heading';
import { Container } from '../components/layout/Container';
import {
extractStyle, isCompatible, isStyleProp, applyStyleSnapshot, savePreset, listPresets, deletePreset,
getStyleClipboard, setStyleClipboard, type ExtrasStore, type StyleSnapshot,
} from './style-clipboard';
let h: EditorHarness;
afterEach(() => {
h?.unmount();
document.body.innerHTML = '';
});
function add(el: React.ReactElement): string {
let id = '';
h.act(() => {
const tree = h.query.parseReactElement(el).toNodeTree();
h.actions.addNodeTree(tree, 'ROOT');
id = tree.rootNodeId;
});
return id;
}
const props = (id: string) => h.query.node(id).get().data.props;
const ctx = (device?: 'desktop' | 'tablet' | 'mobile') => ({ query: h.query, device });
function memExtras(): ExtrasStore & { data: Record<string, unknown> } {
const data: Record<string, unknown> = {};
return {
data,
get: (k: string, fb: any) => (k in data ? (data[k] as any) : fb),
update: (k: string, fn: any, fb: any) => { data[k] = fn(k in data ? data[k] : fb); },
};
}
describe('isStyleProp allowlist / denylist', () => {
test.each([
['hoverBg', '#fff', true], ['hoverColor', 'red', true], ['buttonBgColor', 'var(--wsb-primary)', true],
['gap', 12, true], ['borderRadius', '8px', true], ['featuredBg', '#000', true], ['linkColor', '#00f', true],
['text', 'Hello', false], ['html', '<b>x</b>', false], ['href', '/a', false], ['src', 'a.png', false],
['links', 'x', false], ['items', 'x', false], ['features', 'x', false], ['ctas', 'x', false], ['slides', 'x', false],
['aiName', 'x', false], ['node_id', 'x', false], ['anchorId', 'x', false], ['cssId', 'x', false], ['cssClass', 'x', false],
['hideOnMobile', 'true', false], ['hideOnDesktop', 'true', false],
['gap', ['a'], false], ['hoverBg', { a: 1 }, false], ['level', 2, false],
])('%s=%j -> %s', (name, value, expected) => {
expect(isStyleProp(name as string, value)).toBe(expected);
});
test('extractStyle keeps look, drops content', () => {
const snap = extractStyle('ButtonLink', {
text: 'Buy', href: '/buy', hoverBg: '#111', style: { backgroundColor: 'var(--wsb-primary)' },
styleTablet: { fontSize: '14px' }, items: [1],
});
expect(snap.props).toEqual({ hoverBg: '#111' });
expect(snap.style.backgroundColor).toBe('var(--wsb-primary)');
expect(snap.styleTablet).toEqual({ fontSize: '14px' });
expect(snap.styleMobile).toBeUndefined();
});
});
describe('isCompatible matrix', () => {
const btn = extractStyle('ButtonLink', { style: { backgroundColor: 'red', borderRadius: '9px' } });
const layoutOnly = extractStyle('Container', { style: { display: 'flex', width: '100%' } });
test.each([
[btn, 'ButtonLink', true],
[btn, 'Heading', true], // backgroundColor + border are generic
[layoutOnly, 'Container', true], // same type
[layoutOnly, 'Heading', false], // nothing generic to carry
[null, 'Heading', false],
[btn, null, false],
] as [StyleSnapshot | null, string | null, boolean][])('%#', (snap, target, expected) => {
expect(isCompatible(snap, target)).toBe(expected);
});
});
describe('applyStyleSnapshot (real editor)', () => {
test('Button -> Button: style + props apply, content untouched, one undo restores', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, {
text: 'A', href: '/a', hoverBg: '#123456',
style: { backgroundColor: 'var(--wsb-primary)', borderRadius: '999px' },
}));
const b = add(React.createElement(ButtonLink, { text: 'B', href: '/b', style: { color: 'white' } }));
const before = JSON.stringify(props(b));
const snap = extractStyle('ButtonLink', props(a));
h.act(() => { applyStyleSnapshot(h.actions, [b], snap, 'merge', ctx()); });
expect(props(b).style).toMatchObject({ backgroundColor: 'var(--wsb-primary)', borderRadius: '999px', color: 'white' });
expect(props(b).hoverBg).toBe('#123456');
expect(props(b).text).toBe('B');
expect(props(b).href).toBe('/b');
h.act(() => { h.actions.history.undo(); });
expect(JSON.stringify(props(b))).toBe(before);
});
test('Button -> Heading: only generic keys apply', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, {
hoverBg: '#123456', style: { color: 'red', fontSize: '20px', display: 'inline-block', cursor: 'pointer' },
}));
const hd = add(React.createElement(Heading, { text: 'T' }));
h.act(() => { applyStyleSnapshot(h.actions, [hd], extractStyle('ButtonLink', props(a)), 'replace', ctx()); });
expect(props(hd).style.fontSize).toBe('20px');
expect(props(hd).style.color).toBe('red');
expect(props(hd).style.display).toBeUndefined();
expect(props(hd).style.cursor).toBeUndefined();
expect(props(hd).hoverBg).toBeUndefined();
expect(props(hd).text).toBe('T');
});
test('replace keeps layout keys for a Container; merge overlays', () => {
h = renderEditorHarness();
const src = add(React.createElement(Container, { style: { backgroundColor: 'blue', display: 'block', width: '50%', padding: '8px' } }));
const dst = add(React.createElement(Container, { style: { display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '900px', minHeight: '40px', backgroundColor: 'red', margin: '4px' } }));
h.act(() => { applyStyleSnapshot(h.actions, [dst], extractStyle('Container', props(src)), 'replace', ctx()); });
expect(props(dst).style).toEqual({
backgroundColor: 'blue', padding: '8px',
display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '900px', minHeight: '40px',
});
expect(props(dst).style.margin).toBeUndefined();
});
test('device-aware: on tablet, responsive keys go to styleTablet and desktop is untouched', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, { style: { color: 'red', fontSize: '30px' } }));
const b = add(React.createElement(ButtonLink, { style: { fontSize: '16px' } }));
h.act(() => { applyStyleSnapshot(h.actions, [b], extractStyle('ButtonLink', props(a)), 'merge', ctx('tablet')); });
expect(props(b).style).toEqual({ fontSize: '16px', color: 'red' });
expect(props(b).styleTablet).toEqual({ fontSize: '30px' });
});
test('desktop paste carries the snapshot responsive partials', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, { style: { fontSize: '30px' }, styleMobile: { fontSize: '12px', color: 'x' } } as any));
const b = add(React.createElement(ButtonLink, {}));
h.act(() => { applyStyleSnapshot(h.actions, [b], extractStyle('ButtonLink', props(a)), 'merge', ctx()); });
// color is not a responsive key: stays out of the partial
expect(props(b).styleMobile).toEqual({ fontSize: '12px' });
});
test('skips incompatible targets and applies to many ids', () => {
h = renderEditorHarness();
const a = add(React.createElement(ButtonLink, { style: { backgroundColor: 'red' } }));
const b1 = add(React.createElement(ButtonLink, {}));
const b2 = add(React.createElement(ButtonLink, {}));
let n = 0;
h.act(() => { n = applyStyleSnapshot(h.actions, [b1, b2, 'nope'], extractStyle('ButtonLink', props(a)), 'merge', ctx()); });
expect(n).toBe(2);
expect(props(b2).style.backgroundColor).toBe('red');
});
});
describe('clipboard + presets', () => {
test('clipboard stores a detached copy', () => {
const s = extractStyle('ButtonLink', { style: { color: 'red' } });
setStyleClipboard(s);
s.style.color = 'blue';
expect(getStyleClipboard()!.style.color).toBe('red');
setStyleClipboard(null);
expect(getStyleClipboard()).toBeNull();
});
test('presets keep token refs verbatim, filter by type, delete', () => {
const ex = memExtras();
const snap = extractStyle('ButtonLink', { style: { backgroundColor: 'var(--wsb-primary)' } });
const p = savePreset(ex, { name: ' Pill ', snapshot: snap })!;
expect(p.name).toBe('Pill');
expect((ex.data.stylePresets as any)[0].snapshot.style.backgroundColor).toBe('var(--wsb-primary)');
expect(savePreset(ex, { name: ' ', snapshot: snap })).toBeNull();
savePreset(ex, { name: 'H', snapshot: extractStyle('Heading', { style: {} }) });
expect(listPresets(ex, 'ButtonLink').map((x) => x.name)).toEqual(['Pill']);
deletePreset(ex, p.id);
expect(listPresets(ex, 'ButtonLink')).toEqual([]);
expect(listPresets(ex, 'Heading')).toHaveLength(1);
});
});
+200
View File
@@ -0,0 +1,200 @@
import { RESPONSIVE_STYLE_KEYS } from './responsive-style';
import { discreteSetProp } from '../panels/right/styles/shared';
import type { DeviceMode } from '../types';
/**
* Copy / paste style and saved style presets (site-builder #48).
*
* A StyleSnapshot is the LOOK of a node, never its content: `style` (+ the
* tablet/mobile partials) and the component-specific visual props (`hoverBg`,
* `buttonBgColor`, `gap`...). Values are stored verbatim, so `var(--wsb-*)`
* token references stay references and a palette change re-themes presets.
*
* `type` is the Craft resolved component name (`node.data.name`, e.g.
* `ButtonLink`), not the human displayName ("Button"), which is not unique.
*/
type Rec = Record<string, unknown>;
export interface StyleSnapshot {
type: string;
style: Rec;
styleTablet?: Rec;
styleMobile?: Rec;
props: Record<string, string | number>;
}
export interface StylePreset {
id: string;
name: string;
type: string;
snapshot: StyleSnapshot;
createdAt: number;
}
/** Prop names that are content / identity / visibility, never style. Wins over
* the visual-name rule below (e.g. `hideOnMobile`, `cssClass`). */
const CONTENT_PROP = /^(text|html|href|src|links|items|features|ctas|slides|aiName|node_id|anchorId|cssId|cssClass|style|styleTablet|styleMobile)$|^hideOn/;
/** Prop names that read as visual. */
const VISUAL_PROP = /color|bg|background|font|radius|gap|padding|margin|align|shadow|opacity|animation|hover|border|size|width|height/i;
/** Style keys that make sense on ANY element type (cross-type paste). */
const GENERIC_STYLE_KEY = /^(margin|padding|font|lineHeight|letterSpacing|textAlign|textTransform|textDecoration|textShadow|color$|backgroundColor$|border|boxShadow|opacity$)/;
/** Container types: replace-paste keeps their structural keys. */
const CONTAINER_TYPES = new Set(['Container', 'Section', 'ColumnLayout', 'BackgroundSection']);
export const LAYOUT_KEYS = ['display', 'flexDirection', 'width', 'maxWidth', 'minHeight'] as const;
const asRec = (o: unknown): Rec => (o && typeof o === 'object' && !Array.isArray(o) ? (o as Rec) : {});
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
/** True for a prop that carries look, not content. */
export function isStyleProp(name: string, value: unknown): boolean {
if (CONTENT_PROP.test(name)) return false;
if (typeof value !== 'string' && typeof value !== 'number') return false;
return VISUAL_PROP.test(name);
}
export function extractStyle(type: string, props: Rec): StyleSnapshot {
const snap: StyleSnapshot = { type, style: structuredClone(asRec(props.style)), props: {} };
if (props.styleTablet && Object.keys(asRec(props.styleTablet)).length) snap.styleTablet = structuredClone(asRec(props.styleTablet));
if (props.styleMobile && Object.keys(asRec(props.styleMobile)).length) snap.styleMobile = structuredClone(asRec(props.styleMobile));
for (const [k, v] of Object.entries(props)) {
if (isStyleProp(k, v)) snap.props[k] = v as string | number;
}
return snap;
}
export function isCompatible(snapshot: StyleSnapshot | null, targetType: string | null): boolean {
if (!snapshot || !targetType) return false;
if (snapshot.type === targetType) return true;
// Different type: compatible when at least one generic key can carry over.
return Object.keys(snapshot.style).some((k) => GENERIC_STYLE_KEY.test(k) && isSet(snapshot.style[k]));
}
function pick(src: Rec, test: (k: string) => boolean): Rec {
const out: Rec = {};
for (const [k, v] of Object.entries(src)) if (isSet(v) && test(k)) out[k] = v;
return out;
}
/** What a snapshot contributes to a target of `targetType`. */
function contribution(snapshot: StyleSnapshot, same: boolean) {
const all = (k: string) => (same ? true : GENERIC_STYLE_KEY.test(k));
return {
style: pick(snapshot.style, all),
tablet: pick(asRec(snapshot.styleTablet), (k) => RESPONSIVE_STYLE_KEYS.has(k) && all(k)),
mobile: pick(asRec(snapshot.styleMobile), (k) => RESPONSIVE_STYLE_KEYS.has(k) && all(k)),
props: same ? snapshot.props : {},
};
}
export interface ApplyContext {
/** Craft `query`, to resolve each target's component type. */
query: any;
/** Current device (#45). Default desktop. */
device?: DeviceMode;
}
export const nodeTypeOf = (query: any, id: string): string | null => {
try { return query.node(id).get()?.data?.name ?? null; } catch { return null; }
};
/**
* Applies `snapshot` to every id: ONE `discreteSetProp` (= one undo step) per
* node. Same type → everything; different type → generic style keys only.
* merge overlays keys; replace swaps `style` wholesale but a container keeps
* its own LAYOUT_KEYS. Device-aware: on tablet/mobile only the non-responsive
* keys land in `style` and the responsive keys land in that device's partial
* (value cascaded from the snapshot), so desktop is never touched.
*/
export function applyStyleSnapshot(
actions: any,
nodeIds: string[],
snapshot: StyleSnapshot,
mode: 'replace' | 'merge',
ctx: ApplyContext,
): number {
const device = ctx.device ?? 'desktop';
let applied = 0;
for (const id of nodeIds) {
const type = nodeTypeOf(ctx.query, id);
if (!isCompatible(snapshot, type)) continue;
const same = snapshot.type === type;
const c = contribution(snapshot, same);
const isContainer = !!type && CONTAINER_TYPES.has(type);
const replace = mode === 'replace' && same;
discreteSetProp(actions, id, (p: any) => {
const cur = asRec(p.style);
const keep = (o: Rec) => (replace && isContainer ? pick(o, (k) => (LAYOUT_KEYS as readonly string[]).includes(k)) : {});
if (device === 'desktop') {
p.style = replace ? { ...c.style, ...keep(cur) } : { ...cur, ...c.style };
for (const [field, add] of [['styleTablet', c.tablet], ['styleMobile', c.mobile]] as const) {
if (replace) {
const next = { ...add, ...keep(asRec(p[field])) };
if (Object.keys(next).length || p[field] !== undefined) p[field] = next;
} else if (Object.keys(add).length) {
p[field] = { ...asRec(p[field]), ...add };
}
}
} else {
// Effective look of the snapshot on this device (cascade).
const eff: Rec = { ...c.style, ...c.tablet, ...(device === 'mobile' ? c.mobile : {}) };
const nonResp = pick(eff, (k) => !RESPONSIVE_STYLE_KEYS.has(k));
const resp = pick(eff, (k) => RESPONSIVE_STYLE_KEYS.has(k));
const field = device === 'tablet' ? 'styleTablet' : 'styleMobile';
if (replace) {
const kept = pick(cur, (k) => RESPONSIVE_STYLE_KEYS.has(k));
p.style = { ...kept, ...nonResp };
p[field] = { ...resp, ...keep(asRec(p[field])) };
} else {
p.style = { ...cur, ...nonResp };
if (Object.keys(resp).length) p[field] = { ...asRec(p[field]), ...resp };
}
}
for (const [k, v] of Object.entries(c.props)) p[k] = v;
});
applied++;
}
return applied;
}
/* ---------- module-level clipboard (same pattern as hooks/clipboard.ts) ---------- */
let styleClipboard: StyleSnapshot | null = null;
export const getStyleClipboard = (): StyleSnapshot | null => styleClipboard;
export const setStyleClipboard = (s: StyleSnapshot | null): void => {
styleClipboard = s ? structuredClone(s) : null;
};
/* ---------- presets (persisted in project extras) ---------- */
export const PRESETS_KEY = 'stylePresets';
/** The subset of `useProjectExtras()` the preset helpers need. */
export interface ExtrasStore {
get<T>(key: string, fallback: T): T;
update<T>(key: string, fn: (prev: T) => T, fallback: T): void;
}
let presetSeq = 0;
const newPresetId = () => `sp_${Date.now().toString(36)}${(presetSeq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
export function savePreset(extras: ExtrasStore, input: { name: string; snapshot: StyleSnapshot }): StylePreset | null {
const name = input.name.trim();
if (!name) return null;
const preset: StylePreset = {
id: newPresetId(),
name,
type: input.snapshot.type,
snapshot: structuredClone(input.snapshot),
createdAt: Date.now(),
};
extras.update<StylePreset[]>(PRESETS_KEY, (prev) => [...(Array.isArray(prev) ? prev : []), preset], []);
return preset;
}
export function listPresets(extras: ExtrasStore, type: string | null): StylePreset[] {
const all = extras.get<StylePreset[]>(PRESETS_KEY, []);
return (Array.isArray(all) ? all : []).filter((p) => p && p.type === type);
}
export function deletePreset(extras: ExtrasStore, id: string): void {
extras.update<StylePreset[]>(PRESETS_KEY, (prev) => (Array.isArray(prev) ? prev.filter((p) => p.id !== id) : []), []);
}