fix(styles): box-model and shadow presets are discrete undo steps

Preset buttons in BoxModelSection / BorderEffectsSection now write through
the discrete setters (setPropStyles / setPropStyle), so a keyboard-activated
preset (no pointerup or focusout to close the burst) never merges into the
previous edit of the same key. SpacingControl gains an optional onPreset
for this; margin/padding presets are also one undo step for all four sides
in the media panels.

Refs #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:54:13 -07:00
1 parent 7d25bf181c
commit bbae73c132
7 files changed
+150 -15

No files matched your search

@@ -38,7 +38,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { device } = useDevice();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropStyles, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
@@ -153,8 +153,8 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
)}
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -118,8 +118,8 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div>
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -279,8 +279,8 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -0,0 +1,96 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { DeviceProvider } from '../../../state/DeviceContext';
import { ContainerStylePanel } from './ContainerStylePanel';
import { ImageStylePanel } from './ImageStylePanel';
import { closeContinuousBurst } from './shared';
/**
* #7 deferred minor: preset buttons in BoxModelSection / BorderEffectsSection
* are discrete edits. A keyboard-activated preset (Enter/Space: no pointerup,
* no focusout) must never merge into the previous edit of the same key.
* `.click()` here is exactly that: a click with no pointer events around it.
*/
let h: EditorHarness | null = null;
afterEach(() => { closeContinuousBurst(); h?.unmount(); h = null; });
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type === 'ImageBlock' ? 'Image' : type,
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const state = () => JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['box', 'img']),
box: node('Container', { tag: 'div', style: { paddingTop: '4px', paddingRight: '4px', paddingBottom: '4px', paddingLeft: '4px', marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0' } }, [], { parent: 'ROOT' }),
img: node('ImageBlock', { src: 'a.png', alt: 'a', style: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0' } }, [], { parent: 'ROOT' }),
});
const Host: React.FC<{ id: string; Panel: React.FC<any> }> = ({ id, Panel }) => {
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} }));
return <Panel selectedId={id} nodeProps={nodeProps} />;
};
function mount(id: string, Panel: React.FC<any>) {
h = renderEditorHarness({ initialState: state(), wrap: (t) => <DeviceProvider initialDevice="desktop">{t}</DeviceProvider> });
h.mountChild(<Host id={id} Panel={Panel} />);
h.act(() => { h!.actions.history.clear(); });
}
const style = (id: string) => h!.query.node(id).get().data.props.style;
const control = (label: string) =>
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith(label)) as HTMLElement;
const preset = (root: Element, text: string) =>
Array.from(root.querySelectorAll('button.preset-btn')).find((b) => b.textContent === text) as HTMLButtonElement;
const shadowSection = () => {
if (!Array.from(h!.container.querySelectorAll('.guided-section-label')).some((l) => l.textContent === 'Shadow')) {
const toggle = Array.from(h!.container.querySelectorAll('button')).find((b) => b.textContent?.includes('Border & Effects'))!;
h!.act(() => { toggle.click(); });
}
const label = Array.from(h!.container.querySelectorAll('.guided-section-label')).find((l) => l.textContent === 'Shadow')!;
return label.closest('.guided-section') as HTMLElement;
};
const press = (b: HTMLButtonElement) => h!.act(() => { b.click(); });
const undo = () => h!.act(() => { h!.actions.history.undo(); });
describe('box-model / border-effects presets are discrete undo steps', () => {
test('Container: two padding presets in a row are two undo steps', () => {
mount('box', ContainerStylePanel);
press(preset(control('Padding'), 'S'));
press(preset(control('Padding'), 'L'));
expect(style('box').paddingTop).toBe('24px');
undo();
expect(style('box').paddingTop).toBe('8px');
expect(style('box').paddingLeft).toBe('8px');
});
test('Container: two margin presets in a row are two undo steps, each one step for all sides', () => {
mount('box', ContainerStylePanel);
press(preset(control('Margin'), 'S'));
press(preset(control('Margin'), 'M'));
undo();
expect([style('box').marginTop, style('box').marginRight, style('box').marginBottom, style('box').marginLeft]).toEqual(['8px', '8px', '8px', '8px']);
undo();
expect(style('box').marginTop).toBe('0');
expect(h!.query.history.canUndo()).toBe(false);
});
test('Image: a margin preset is ONE undo step for all four sides', () => {
mount('img', ImageStylePanel);
press(preset(control('Margin'), 'M'));
expect(style('img').marginLeft).toBe('16px');
undo();
expect([style('img').marginTop, style('img').marginRight, style('img').marginBottom, style('img').marginLeft]).toEqual(['0', '0', '0', '0']);
expect(h!.query.history.canUndo()).toBe(false);
});
test('shadow presets in a row are separate undo steps (Container and Image)', () => {
for (const [id, Panel] of [['box', ContainerStylePanel], ['img', ImageStylePanel]] as const) {
mount(id, Panel);
press(preset(shadowSection(), 'S'));
press(preset(shadowSection(), 'L'));
undo();
expect(style(id).boxShadow).toBe('0 1px 2px rgba(0,0,0,0.08)');
h!.unmount(); h = null; closeContinuousBurst();
}
});
});
@@ -81,13 +81,23 @@ export interface BoxModelSectionProps {
setPropStyles: (patch: Record<string, string>, controlKey: string) => void;
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
labelAddon?: (keys: readonly string[]) => React.ReactNode;
/** DISCRETE multi-key write (one undo step, closes any open burst) for the
* preset buttons, so a keyboard-activated preset never merges into the
* previous edit of the same key. Falls back to the continuous setters. */
setPropStylesDiscrete?: (patch: Record<string, string>) => void;
}
const sidesPatch = (kind: 'margin' | 'padding', v: SpacingSideValues): Record<string, string> => {
const patch: Record<string, string> = {};
SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = v[s] ?? ''; });
return patch;
};
/** Margin + Padding, per-side, via the shared SpacingControl. Padding is the
* only padding control: a legacy shorthand `style.padding` (with no
* longhands yet) is shown expanded and migrated to the four longhands on the
* first edit, in the same history step. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon }) => {
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon, setPropStylesDiscrete }) => {
const marginSetter = (side: SpacingSide, value: string) => setPropStyle(`margin${capitalize(side)}`, value);
const legacyShorthand = hasValue(style.padding) && PADDING_KEYS.every((k) => !hasValue(style[k]));
@@ -111,12 +121,14 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={marginSetter}
onPreset={setPropStylesDiscrete ? (all) => setPropStylesDiscrete(sidesPatch('margin', all)) : undefined}
/>
<SpacingControl
label={<>Padding{labelAddon?.(PADDING_GROUP)}</>}
value={padding}
onChange={(side, value) => setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)}
onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')}
onPreset={setPropStylesDiscrete ? (all) => setPropStylesDiscrete(paddingPatch(all)) : undefined}
/>
</CollapsibleSection>
);
@@ -134,10 +146,12 @@ function opacityPercent(v: unknown): number {
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
/** DISCRETE single-key write for the shadow preset buttons (see BoxModelSection). */
setPropStyleDiscrete?: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle, setPropStyleDiscrete }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
@@ -145,7 +159,7 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
@@ -45,12 +45,25 @@ export interface BoxModelSectionProps {
setPropStyle: (prop: string, value: string) => void;
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
labelAddon?: (keys: readonly string[]) => React.ReactNode;
/** DISCRETE multi-key write (one undo step, closes any open burst) for the
* preset buttons, so a keyboard-activated preset never merges into the
* previous edit of the same key. Falls back to per-side setPropStyle. */
setPropStylesDiscrete?: (patch: Record<string, string>) => void;
}
const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon }) => {
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon, setPropStylesDiscrete }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
const presetSetter = (kind: 'margin' | 'padding') => (setPropStylesDiscrete
? (all: Record<SpacingSide, string>) => {
const patch: Record<string, string> = {};
SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = all[s]; });
setPropStylesDiscrete(patch);
}
: undefined);
return (
<CollapsibleSection title="Spacing">
@@ -58,11 +71,13 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
onPreset={presetSetter('margin')}
/>
<SpacingControl
label={<>Padding{labelAddon?.(PADDING_KEYS)}</>}
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
onPreset={presetSetter('padding')}
/>
</CollapsibleSection>
);
@@ -80,10 +95,12 @@ function opacityPercent(v: unknown): number {
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
/** DISCRETE single-key write for the shadow preset buttons (see BoxModelSection). */
setPropStyleDiscrete?: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle, setPropStyleDiscrete }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
@@ -91,7 +108,7 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
+10 -2
View File
@@ -925,10 +925,14 @@ export interface SpacingControlProps {
/** Optional: linked/preset edits call this once with all four sides instead
* of calling `onChange` four times, so a consumer can make them one undo step. */
onChangeAll?: (values: Required<SpacingSideValues>) => void;
/** Optional: a preset button pick, all four sides. Wire to a DISCRETE setter
* so a keyboard-activated preset is always its own undo step; falls back
* to the same path as the linked input when omitted. */
onPreset?: (values: Required<SpacingSideValues>) => void;
presets?: { label: string; value: string }[];
units?: string[];
}
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, onPreset, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
const [linked, setLinked] = useState(true);
const applyToAllSides = (v: string) => (onChangeAll
@@ -950,7 +954,11 @@ export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, on
<i className={`fa fa-link${linked ? '' : '-slash'}`} />
</button>
</div>
<PresetButtonGrid presets={presets} activeValue={isAllEqual ? linkedValue : undefined} onSelect={applyToAllSides} />
<PresetButtonGrid
presets={presets}
activeValue={isAllEqual ? linkedValue : undefined}
onSelect={onPreset ? (v) => onPreset({ top: v, right: v, bottom: v, left: v }) : applyToAllSides}
/>
{linked ? (
<div style={{ marginTop: 6 }}>
<NumericUnitInput value={linkedValue} onChange={applyToAllSides} units={units} testId="spacing-linked" />