Merge issue-50 (Refs #50)
This commit is contained in:
commit
f75958caea
7 files changed
+373
-23
No files matched your search
+10
-1
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
Reference in new issue
Block a user