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:
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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user