diff --git a/craft/src/panels/context-menu/ContextMenu.test.tsx b/craft/src/panels/context-menu/ContextMenu.test.tsx new file mode 100644 index 0000000..771ee1c --- /dev/null +++ b/craft/src/panels/context-menu/ContextMenu.test.tsx @@ -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 | 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( + + + + {}} /> + , + ); +} +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('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'); + }); +}); diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index 83fdd4c..face91c 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -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 = ({ const { actions, query } = useEditor(); const { open: openSitesmith } = useSitesmithModal(); const menuRef = useRef(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 = ({ 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 = ({ 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 = ({ const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10); return ( + <>
= ({ ))}
+ {popover && popover.mode === 'save' && ( + setPopover(null)} onSave={saveAsPreset} /> + )} + {popover && popover.mode === 'apply' && ( + setPopover(null)} + presets={listPresets(extras, targetType)} + onApply={applyPreset} + onDelete={(id) => { deletePreset(extras, id); setPopover({ ...popover }); }} + /> + )} + ); }; diff --git a/craft/src/panels/context-menu/PresetsPopover.test.tsx b/craft/src/panels/context-menu/PresetsPopover.test.tsx new file mode 100644 index 0000000..5cd9ab5 --- /dev/null +++ b/craft/src/panels/context-menu/PresetsPopover.test.tsx @@ -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( {}} 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( {}} />); + 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( {}} />); + 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( {}} 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(); + }); +}); diff --git a/craft/src/panels/context-menu/PresetsPopover.tsx b/craft/src/panels/context-menu/PresetsPopover.tsx new file mode 100644 index 0000000..e39f1a9 --- /dev/null +++ b/craft/src/panels/context-menu/PresetsPopover.tsx @@ -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 = (props) => { + const { x, y, onClose } = props; + const ref = useRef(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 ( +
+ {props.mode === 'save' ? ( + <> + + setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} + /> + + + ) : props.presets.length === 0 ? ( +
No saved presets for this element type yet.
+ ) : ( +
    + {props.presets.map((p) => ( +
  • + + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 17969df..e87c626 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; +} diff --git a/craft/src/utils/style-clipboard.test.ts b/craft/src/utils/style-clipboard.test.ts new file mode 100644 index 0000000..2425651 --- /dev/null +++ b/craft/src/utils/style-clipboard.test.ts @@ -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 } { + const data: Record = {}; + 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', 'x', 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); + }); +}); diff --git a/craft/src/utils/style-clipboard.ts b/craft/src/utils/style-clipboard.ts new file mode 100644 index 0000000..016a03e --- /dev/null +++ b/craft/src/utils/style-clipboard.ts @@ -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; + +export interface StyleSnapshot { + type: string; + style: Rec; + styleTablet?: Rec; + styleMobile?: Rec; + props: Record; +} + +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(key: string, fallback: T): T; + update(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(PRESETS_KEY, (prev) => [...(Array.isArray(prev) ? prev : []), preset], []); + return preset; +} + +export function listPresets(extras: ExtrasStore, type: string | null): StylePreset[] { + const all = extras.get(PRESETS_KEY, []); + return (Array.isArray(all) ? all : []).filter((p) => p && p.type === type); +} + +export function deletePreset(extras: ExtrasStore, id: string): void { + extras.update(PRESETS_KEY, (prev) => (Array.isArray(prev) ? prev.filter((p) => p.id !== id) : []), []); +}