feat(site-builder): multi-select with bulk styling and mixed-type actions

Shift/cmd/ctrl-click multi-select via DefaultEventHandlers, same-type bulk
styling as one undo step, mixed-type Delete/Duplicate/Move block, grouped
undo for array-aware edit.* commands.

Refs #50

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 12:03:26 -07:00
1 parent 5430f13877
commit a80667dfe6
7 files changed
+373 -23

No files matched your search

+10 -1
View File
@@ -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 <SitesmithModal target={target} onClose={close} />;
};
// #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 <Editor>, 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',
+17 -4
View File
@@ -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);
}
@@ -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) => <DeviceProvider initialDevice={device}>{tree}</DeviceProvider>,
});
h.mountChild(
<PageProvider><SiteDesignProvider><SitesmithProvider>
<GuidedStyles />
</SitesmithProvider></SiteDesignProvider></PageProvider>,
);
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(<ButtonLink />);
const b2 = add(<ButtonLink />);
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(<ButtonLink />);
const b2 = add(<ButtonLink />);
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(<Heading />);
const b = add(<Heading />);
const Probe: React.FC<{ ids: string[] }> = ({ ids }) => {
const { setPropStyle } = useNodeProp(ids);
return <button data-testid="go" onClick={() => setPropStyle('width', '50%')} />;
};
h.mountChild(<DeviceProvider initialDevice="tablet"><Probe ids={[a, b]} /></DeviceProvider>);
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(<ButtonLink />);
const t = add(<Heading />);
const keep = add(<Heading />);
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(<ButtonLink />);
const t = add(<Heading />);
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);
});
});
+68 -7
View File
@@ -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 (
<div className="guided-section multi-select-actions" data-testid="multi-select-actions">
<p className="multi-select-hint">{ids.length} elements of different types selected. Styles are edited one type at a time.</p>
<div className="multi-select-buttons">
{MULTI_ACTIONS.map((a) => (
<button key={a.id} type="button" className="multi-select-btn" data-testid={`multi-${a.id}`} onClick={() => run(a.id)}>
<i className={`fa ${a.icon}`} /> {a.label}
</button>
))}
</div>
</div>
);
};
/* ================================================================
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<string, any> = {};
@@ -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 <SiteDesignPanel />;
}
if (ids.length > 1 && mixedTypes) {
return (
<div className="guided-styles">
<div className="guided-section guided-type-header">
<span className="guided-type-badge" data-testid="multi-select-header">
<i className="fa fa-clone" /> {ids.length} elements selected
</span>
</div>
<MultiSelectActions ids={ids} />
</div>
);
}
// Determine which panel to show based on displayName
const typeName = selectedType || '';
@@ -123,9 +184,9 @@ export const GuidedStyles: React.FC<{
<div className="guided-styles">
{/* Component type badge + Sitesmith shortcut */}
<div className="guided-section guided-type-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="guided-type-badge" style={{ flex: 1 }}>
<span className="guided-type-badge" data-testid="type-header" style={{ flex: 1 }}>
<i className={`fa ${typeIcon}`} />
{' '}{typeName}
{' '}{bulk ? `${ids.length} ${pluralType(typeName, ids.length)} selected` : typeName}
</span>
<button
onClick={handleAskSitesmith}
@@ -0,0 +1,64 @@
import { describe, test, expect, afterEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { ButtonLink } from '../../../components/basic/ButtonLink';
import { discreteSetProp, continuousSetProp, closeContinuousBurst, setBulkSelection } from './shared';
let h: EditorHarness;
afterEach(() => { 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(<ButtonLink />).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
});
});
+51 -11
View File
@@ -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.<key>`). */
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
+22
View File
@@ -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); }