feat(site-builder): copy/paste style and saved style presets
Refs #48 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5430f13877
commit
0bd6b74ba5
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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -2731,3 +2731,60 @@ body {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* #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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) : []), []);
|
||||
}
|
||||
Reference in new issue
Block a user