diff --git a/craft/src/App.tsx b/craft/src/App.tsx index 95563cf..31c8b21 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Editor } from '@craftjs/core'; +import { Editor, DefaultEventHandlers } from '@craftjs/core'; import { EditorShell } from './editor/EditorShell'; import { RenderNode } from './editor/RenderNode'; import { componentResolver } from './components/resolver'; @@ -25,6 +25,14 @@ const SitesmithModalMount: React.FC = () => { return ; }; +// #50: shift / cmd / ctrl-click adds to the selection (Craft's default is cmd only). +// In Craft 0.2.12 `isMultiSelectEnabled` is an option of DefaultEventHandlers, not +// of , so it is set by supplying the handlers; the other options keep +// Craft's own defaults. +export const isMultiSelectClick = (e: MouseEvent) => e.shiftKey || e.metaKey || e.ctrlKey; +export const craftHandlers = (store: any) => + new DefaultEventHandlers({ store, removeHoverOnMouseleave: false, isMultiSelectEnabled: isMultiSelectClick }); + // Craft fires onNodesChange only when the serialized node tree changes (not on // selection, hover or DOM registration), which is exactly "the canvas was edited". export const SaveAwareEditor: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -34,6 +42,7 @@ export const SaveAwareEditor: React.FC<{ children: React.ReactNode }> = ({ child resolver={componentResolver} enabled={true} onRender={RenderNode} + handlers={craftHandlers} onNodesChange={markDirty} indicator={{ className: 'wsb-drop-line', diff --git a/craft/src/editor/core-commands.ts b/craft/src/editor/core-commands.ts index 266c939..2bdfdfb 100644 --- a/craft/src/editor/core-commands.ts +++ b/craft/src/editor/core-commands.ts @@ -45,8 +45,19 @@ function insertTree(actions: any, tree: any, parentId: string, index?: number): return tree.rootNodeId; } +/** Applies several structural edits as ONE undo step: the first is a normal + * history entry, the rest fold into it via `history.merge()` (#50). */ +function groupedActions(actions: any) { + let first = true; + return () => { + if (first) { first = false; return actions; } + return actions.history.merge(); + }; +} + function duplicateAll({ query, actions, selectedIds }: CommandContext): void { const created: string[] = []; + const step = groupedActions(actions); for (const id of topLevelSelection(query, selectedIds)) { try { const sib = siblingsOf(query, id); @@ -54,7 +65,7 @@ function duplicateAll({ query, actions, selectedIds }: CommandContext): void { // Linked nodes (columns, section inner) are not in `nodes` and cannot be copied alone. if (!sib || idx === -1) continue; const tree = regenerateTreeIds(query.node(id).toNodeTree()); - created.push(insertTree(actions, tree, sib.parentId, idx + 1)); + created.push(insertTree(step(), tree, sib.parentId, idx + 1)); } catch (err) { console.error('Duplicate failed:', err); } @@ -68,9 +79,10 @@ function deleteAll({ query, actions, selectedIds }: CommandContext): void { const t = findDeletableTarget(query, id); if (t) targets.add(t); } + const step = groupedActions(actions); targets.forEach((t) => { try { - actions.delete(t); + step().delete(t); } catch (err) { console.error('Delete failed:', err); } @@ -100,15 +112,16 @@ function pasteClipboard({ query, actions, selectedIds }: CommandContext): void { * adjacent nodes keeps its order; a node already at the edge stays put. */ function moveAll({ query, actions, selectedIds }: CommandContext, dir: -1 | 1): void { const ids = topLevelSelection(query, selectedIds); + const step = groupedActions(actions); for (const id of dir === 1 ? [...ids].reverse() : ids) { try { const sib = siblingsOf(query, id); if (!sib) continue; const idx = sib.siblings.indexOf(id); if (idx === -1) continue; - if (dir === -1 && idx > 0) actions.move(id, sib.parentId, idx - 1); + if (dir === -1 && idx > 0) step().move(id, sib.parentId, idx - 1); // Craft's move index is the slot BEFORE removal, hence idx + 2. - if (dir === 1 && idx < sib.siblings.length - 1) actions.move(id, sib.parentId, idx + 2); + if (dir === 1 && idx < sib.siblings.length - 1) step().move(id, sib.parentId, idx + 2); } catch (err) { console.error('Move failed:', err); } diff --git a/craft/src/panels/right/GuidedStyles.multi.test.tsx b/craft/src/panels/right/GuidedStyles.multi.test.tsx new file mode 100644 index 0000000..06986a8 --- /dev/null +++ b/craft/src/panels/right/GuidedStyles.multi.test.tsx @@ -0,0 +1,141 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { SiteDesignProvider } from '../../state/SiteDesignContext'; +import { PageProvider } from '../../state/PageContext'; +import { SitesmithProvider } from '../../state/SitesmithContext'; +import { DeviceProvider } from '../../state/DeviceContext'; +import { ButtonLink } from '../../components/basic/ButtonLink'; +import { Heading } from '../../components/basic/Heading'; +import { isMultiSelectClick, craftHandlers } from '../../App'; +import { GuidedStyles } from './GuidedStyles'; +import { useNodeProp } from './styles/shared'; + +let h: EditorHarness; +afterEach(() => h?.unmount()); + +function mount(device: 'desktop' | 'tablet' = 'desktop') { + h = renderEditorHarness({ + wrap: (tree) => {tree}, + }); + h.mountChild( + + + , + ); + return h; +} + +const 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 kids = () => h.query.node('ROOT').get().data.nodes as string[]; +const props = (id: string) => h.query.node(id).get().data.props; +const select = (ids: string[]) => h.act(() => h.actions.selectNode(ids)); +const header = () => h.container.querySelector('[data-testid="type-header"]')?.textContent?.trim(); + +describe('multi-select: same type', () => { + test('header counts the group; Button colour writes both; ONE undo reverts both', () => { + mount(); + const b1 = add(); + const b2 = add(); + select([b1, b2]); + expect(header()).toContain('2 Buttons selected'); + + const before1 = props(b1).style?.backgroundColor; + const swatch = h.container.querySelector('.preset-swatch') as HTMLButtonElement; + h.act(() => swatch.click()); + const color = props(b1).style.backgroundColor; + expect(color).toBeTruthy(); + expect(color).not.toBe(before1); + expect(props(b2).style.backgroundColor).toBe(color); + + h.act(() => h.actions.history.undo()); + expect(props(b1).style?.backgroundColor).toBe(before1); + expect(props(b2).style?.backgroundColor).toBe(before1); + }); + + test('single selection is unchanged: only that node is written, header is the plain type', () => { + mount(); + const b1 = add(); + const b2 = add(); + select([b1]); + expect(header()).toBe('Button'); + const b2Before = props(b2).style?.backgroundColor; + const b1Before = props(b1).style?.backgroundColor; + h.act(() => (h.container.querySelector('.preset-swatch') as HTMLButtonElement).click()); + expect(props(b1).style.backgroundColor).not.toBe(b1Before); + expect(props(b2).style?.backgroundColor).toBe(b2Before); + }); + + test('tablet device: bulk write goes to styleTablet on every node, never to style', () => { + mount('tablet'); + const a = add(); + const b = add(); + const Probe: React.FC<{ ids: string[] }> = ({ ids }) => { + const { setPropStyle } = useNodeProp(ids); + return setPropStyle('width', '50%')} />; + }; + h.mountChild(); + h.act(() => (h.container.querySelector('[data-testid="go"]') as HTMLButtonElement).click()); + for (const id of [a, b]) { + expect(props(id).styleTablet?.width).toBe('50%'); + expect(props(id).style?.width).toBeUndefined(); + } + h.act(() => h.actions.history.undo()); + for (const id of [a, b]) expect(props(id).styleTablet?.width).toBeUndefined(); + }); +}); + +describe('multi-select: mixed types', () => { + test('shows the actions block; Delete removes both in ONE undo step', () => { + mount(); + const b = add(); + const t = add(); + const keep = add(); + select([b, t]); + expect(h.container.querySelector('[data-testid="multi-select-actions"]')).not.toBeNull(); + h.act(() => (h.container.querySelector('[data-testid="multi-edit.delete"]') as HTMLButtonElement).click()); + expect(kids()).toEqual([keep]); + h.act(() => h.actions.history.undo()); + expect(kids()).toEqual([b, t, keep]); + }); + + test('Duplicate puts a copy after each original, in one undo step', () => { + mount(); + const b = add(); + const t = add(); + select([b, t]); + h.act(() => (h.container.querySelector('[data-testid="multi-edit.duplicate"]') as HTMLButtonElement).click()); + const k = kids(); + expect(k).toHaveLength(4); + expect(k[0]).toBe(b); + expect(k[2]).toBe(t); + expect(h.query.node(k[1]).get().data.displayName).toBe('Button'); + expect(h.query.node(k[3]).get().data.displayName).toBe('Heading'); + h.act(() => h.actions.history.undo()); + expect(kids()).toEqual([b, t]); + }); +}); + +describe('Editor multi-select option', () => { + test('shift / cmd / ctrl enable multi-select, a plain click does not', () => { + const ev = (init: MouseEventInit) => new MouseEvent('mousedown', init); + expect(isMultiSelectClick(ev({ shiftKey: true }))).toBe(true); + expect(isMultiSelectClick(ev({ metaKey: true }))).toBe(true); + expect(isMultiSelectClick(ev({ ctrlKey: true }))).toBe(true); + expect(isMultiSelectClick(ev({}))).toBe(false); + }); + + test('the Editor handlers carry the option', () => { + const handlers: any = craftHandlers({} as any); + expect(handlers.options.isMultiSelectEnabled(new MouseEvent('mousedown', { shiftKey: true }))).toBe(true); + expect(handlers.options.isMultiSelectEnabled(new MouseEvent('mousedown'))).toBe(false); + }); +}); diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx index d307fa3..39bcec3 100644 --- a/craft/src/panels/right/GuidedStyles.tsx +++ b/craft/src/panels/right/GuidedStyles.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useLayoutEffect } from 'react'; import { useEditor } from '@craftjs/core'; import { SiteDesignPanel } from './SiteDesignPanel'; import { useSitesmithModal } from '../../state/SitesmithContext'; @@ -20,6 +20,37 @@ import { HtmlStylePanel, } from './styles'; import { DeviceBanner } from './styles/responsive-panel'; +import { setBulkSelection } from './styles/shared'; +import { CORE_COMMANDS } from '../../editor/core-commands'; + +const MULTI_ACTIONS = [ + { id: 'edit.delete', label: 'Delete', icon: 'fa-trash-o' }, + { id: 'edit.duplicate', label: 'Duplicate', icon: 'fa-clone' }, + { id: 'edit.move-up', label: 'Move up', icon: 'fa-arrow-up' }, + { id: 'edit.move-down', label: 'Move down', icon: 'fa-arrow-down' }, +] as const; + +/** Mixed-type multi-selection: no shared styles, only structural actions. + * Runs the core `edit.*` commands (array-aware, one undo step each). */ +export const MultiSelectActions: React.FC<{ ids: string[] }> = ({ ids }) => { + const { query, actions } = useEditor(); + const run = (id: string) => { + const cmd = CORE_COMMANDS.find((c) => c.id === id); + cmd?.run({ query, actions, selectedIds: ids } as any); + }; + return ( + + {ids.length} elements of different types selected. Styles are edited one type at a time. + + {MULTI_ACTIONS.map((a) => ( + run(a.id)}> + {a.label} + + ))} + + + ); +}; /* ================================================================ IMPORTANT: None of these panels use useNode(). @@ -31,6 +62,11 @@ import { DeviceBanner } from './styles/responsive-panel'; Main GuidedStyles component ================================================================ */ +function pluralType(name: string, n: number): string { + if (n === 1) return name; + return /(s|x)$/i.test(name) ? `${name}es` : `${name}s`; +} + export const GuidedStyles: React.FC<{ /** What to render with nothing selected: the Site Design panel (default, * used by the mobile sheet) or a placeholder (desktop RightPanel, which @@ -40,10 +76,11 @@ export const GuidedStyles: React.FC<{ const { open: openSitesmith } = useSitesmithModal(); const { query } = useEditor(); - const { selected, selectedType, nodeProps } = useEditor((state) => { - const currentNodeId = state.events.selected - ? Array.from(state.events.selected)[0] - : undefined; + const { selected, selectedType, nodeProps, selectedIds, mixedTypes } = useEditor((state) => { + const all = state.events.selected ? Array.from(state.events.selected) : []; + const currentNodeId = all[0]; + const typeOf = (id: string) => state.nodes[id]?.data.displayName || state.nodes[id]?.data.name || null; + const mixedTypes = all.length > 1 && all.some((id) => typeOf(id) !== typeOf(all[0])); let selectedType: string | null = null; let nodeProps: Record = {}; @@ -59,8 +96,19 @@ export const GuidedStyles: React.FC<{ selected: currentNodeId || null, selectedType, nodeProps, + // string key so the collector only changes when the selection does + selectedIds: all.join('\n'), + mixedTypes, }; }); + const ids = selectedIds ? selectedIds.split('\n') : []; + const bulk = ids.length > 1 && !mixedTypes; + + // Same-type group: writes addressed to the primary id fan out to every id (see shared.tsx). + useLayoutEffect(() => { + setBulkSelection(bulk ? ids : []); + return () => setBulkSelection([]); + }, [bulk, selectedIds]); // eslint-disable-line react-hooks/exhaustive-deps if (!selected) { if (emptyFallback === 'placeholder') { @@ -76,6 +124,19 @@ export const GuidedStyles: React.FC<{ return ; } + if (ids.length > 1 && mixedTypes) { + return ( + + + + {ids.length} elements selected + + + + + ); + } + // Determine which panel to show based on displayName const typeName = selectedType || ''; @@ -123,9 +184,9 @@ export const GuidedStyles: React.FC<{ {/* Component type badge + Sitesmith shortcut */} - + - {' '}{typeName} + {' '}{bulk ? `${ids.length} ${pluralType(typeName, ids.length)} selected` : typeName} { closeContinuousBurst(); setBulkSelection([]); h?.unmount(); }); + +function setup() { + h = renderEditorHarness(); + const ids: string[] = []; + for (let i = 0; i < 3; i++) { + h.act(() => { + const tree = h.query.parseReactElement().toNodeTree(); + h.actions.addNodeTree(tree, 'ROOT'); + ids.push(tree.rootNodeId); + }); + } + return ids; +} +const text = (id: string) => h.query.node(id).get().data.props.text; + +describe('discreteSetProp / continuousSetProp with several ids', () => { + test('explicit id array: all written, ONE undo step', () => { + const [a, b, c] = setup(); + const orig = text(a); + h.act(() => discreteSetProp(h.actions, [a, b], (p: any) => { p.text = 'X'; })); + expect([text(a), text(b), text(c)]).toEqual(['X', 'X', orig]); + h.act(() => h.actions.history.undo()); + expect([text(a), text(b)]).toEqual([orig, orig]); + }); + + test('a burst of continuous edits over a group is ONE undo step', () => { + const [a, b] = setup(); + const orig = text(a); + for (const v of ['1', '12', '123']) { + h.act(() => continuousSetProp(h.actions, [a, b], 'text', (p: any) => { p.text = v; })); + } + expect([text(a), text(b)]).toEqual(['123', '123']); + h.act(() => h.actions.history.undo()); + expect([text(a), text(b)]).toEqual([orig, orig]); + }); + + test('registered bulk selection expands the primary id only', () => { + const [a, b, c] = setup(); + setBulkSelection([a, b]); + h.act(() => discreteSetProp(h.actions, a, (p: any) => { p.text = 'Y'; })); + expect([text(a), text(b)]).toEqual(['Y', 'Y']); + // a write to a non-primary id is never fanned out + h.act(() => discreteSetProp(h.actions, c, (p: any) => { p.text = 'Z'; })); + expect([text(a), text(b), text(c)]).toEqual(['Y', 'Y', 'Z']); + }); + + test('a no-op on the first node does not make the next write merge into an earlier edit', () => { + const [a, b] = setup(); + h.act(() => discreteSetProp(h.actions, a, (p: any) => { p.text = 'Same'; })); + const origB = text(b); + // a already says 'Same' (no change, no history entry); b changes + h.act(() => discreteSetProp(h.actions, [a, b], (p: any) => { p.text = 'Same'; })); + h.act(() => h.actions.history.undo()); + expect(text(b)).toBe(origB); + expect(text(a)).toBe('Same'); // the earlier edit survived: b's write was its own step + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 1b0aed4..31ce0bf 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useRef, CSSProperties } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef, CSSProperties } from 'react'; import { useEditor } from '@craftjs/core'; import { GRADIENTS, @@ -489,11 +489,46 @@ if (typeof document !== 'undefined') { document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) closeContinuousBurst(); }, true); } +/* ---------- Bulk (multi-select) writes (#50) ---------- + GuidedStyles registers the multi-selection here while a same-type group is + selected. A write addressed to the PRIMARY id (what every panel receives as + `selectedId`) fans out to all ids, so panels need no change. Writes to any + other node are never expanded. */ +let bulkIds: string[] = []; + +export function setBulkSelection(ids: string[]) { + bulkIds = ids.length > 1 ? [...ids] : []; +} + +function expandIds(nodeId: string | string[]): string[] { + if (Array.isArray(nodeId)) return nodeId; + return bulkIds.length > 1 && bulkIds[0] === nodeId ? bulkIds : [nodeId]; +} + +/** Runs `updater` on every id as ONE undo step: the first id that records a + * history entry is a plain setProp, later ones fold into it via + * `history.merge()`. `mergeFirst` makes even the first fold into the + * previous entry (a continuing burst). */ +function setPropAll(actions: any, ids: string[], updater: (p: any) => void, mergeFirst = false) { + let merge = mergeFirst; + for (const id of ids) { + let changed = false; + const tracked = (p: any) => { + const before = JSON.stringify(p); + updater(p); + changed = JSON.stringify(p) !== before; + }; + (merge ? actions.history.merge() : actions).setProp(id, tracked); + if (changed) merge = true; + } +} + /** setProp for a continuous input. `controlKey` identifies the control within * the node (e.g. "style.color"); a change from any other control closes the * open burst and starts its own history entry. */ -export function continuousSetProp(actions: any, nodeId: string, controlKey: string, updater: (p: any) => void) { - const key = `${nodeId}:${controlKey}`; +export function continuousSetProp(actions: any, nodeId: string | string[], controlKey: string, updater: (p: any) => void) { + const ids = expandIds(nodeId); + const key = `${ids.join(',')}:${controlKey}`; const continuing = openBurst?.key === key; if (!continuing) closeContinuousBurst(); else clearTimeout(openBurst!.timer); @@ -505,18 +540,19 @@ export function continuousSetProp(actions: any, nodeId: string, controlKey: stri const tracked = (p: any) => { const before = JSON.stringify(p); updater(p); - changed = JSON.stringify(p) !== before; + if (JSON.stringify(p) !== before) changed = true; }; - (continuing ? actions.history.merge() : actions).setProp(nodeId, tracked); + setPropAll(actions, ids, tracked, continuing); if (continuing || changed) { openBurst = { key, timer: setTimeout(closeContinuousBurst, CONTINUOUS_EDIT_IDLE_MS) }; } } -/** Plain setProp for a discrete edit: always its own undo step. */ -export function discreteSetProp(actions: any, nodeId: string, updater: (p: any) => void) { +/** Plain setProp for a discrete edit: always its own undo step (one step for + * the whole group when a bulk selection is active). */ +export function discreteSetProp(actions: any, nodeId: string | string[], updater: (p: any) => void) { closeContinuousBurst(); - actions.setProp(nodeId, updater); + setPropAll(actions, expandIds(nodeId), updater); } /* ---------- Per-device style writes (#45) ---------- @@ -563,7 +599,7 @@ const styleControlKey = (device: DeviceMode, controlKey: string) => `${device}:$ * (default `style.`). */ export function setResponsiveStyleProp( actions: any, - nodeId: string, + nodeId: string | string[], device: DeviceMode, key: string, value: unknown, @@ -577,7 +613,7 @@ export function setResponsiveStyleProp( /** Removes `device`'s override of `key` (or of every key in a group, e.g. the * four padding sides) in ONE discrete setProp = one undo step. No-op on * desktop: the desktop value is the base, there is nothing to reset to. */ -export function clearResponsiveStyleProp(actions: any, nodeId: string, device: DeviceMode, key: string | readonly string[]) { +export function clearResponsiveStyleProp(actions: any, nodeId: string | string[], device: DeviceMode, key: string | readonly string[]) { if (device === 'desktop') return; const field = OVERRIDE_FIELD[device]; const keys = typeof key === 'string' ? [key] : key; @@ -594,7 +630,11 @@ export function clearResponsiveStyleProp(actions: any, nodeId: string, device: D to this (some panels use different param names or extra logic and keep their own local versions). The style setters are device-aware (#45): see applyStylePatch above. */ -export function useNodeProp(selectedId: string) { +export function useNodeProp(selectedIdArg: string | string[]) { + // Stable dependency for the useCallbacks below (an array literal changes identity per render). + const idKey = Array.isArray(selectedIdArg) ? selectedIdArg.join('\u0000') : selectedIdArg; + // eslint-disable-next-line react-hooks/exhaustive-deps + const selectedId = useMemo(() => selectedIdArg, [idKey]); const { actions } = useEditor(); const { device } = useDevice(); // Default setters are plain: one call = one undo step. Continuous inputs diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 926a411..17969df 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -2731,3 +2731,25 @@ body { color: var(--color-text); cursor: pointer; } + +/* #50 multi-select: mixed-type selection actions */ +.multi-select-hint { + margin: 0 0 8px; + font-size: 11px; + color: var(--color-text-muted, #a1a1aa); +} +.multi-select-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.multi-select-btn { + padding: 6px 8px; + font-size: 11px; + color: var(--color-text); + background: var(--color-surface-2, #27272a); + border: 1px solid var(--color-border, #3f3f46); + border-radius: var(--radius-sm); + cursor: pointer; +} +.multi-select-btn:hover { border-color: var(--color-accent); }
{ids.length} elements of different types selected. Styles are edited one type at a time.