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 + ))} + + + ); +}; /* ================================================================ 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}