diff --git a/craft/src/panels/right/styles/ContainerStylePanel.tsx b/craft/src/panels/right/styles/ContainerStylePanel.tsx index a82987f..03fc37d 100644 --- a/craft/src/panels/right/styles/ContainerStylePanel.tsx +++ b/craft/src/panels/right/styles/ContainerStylePanel.tsx @@ -38,7 +38,7 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod const dot = (key: string | readonly string[]) => ; 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 = ({ selectedId, nod )} {/* Box model + border/effects + animation/visibility rollout */} - - + + ); diff --git a/craft/src/panels/right/styles/ImageStylePanel.tsx b/craft/src/panels/right/styles/ImageStylePanel.tsx index b2bd593..941e9f0 100644 --- a/craft/src/panels/right/styles/ImageStylePanel.tsx +++ b/craft/src/panels/right/styles/ImageStylePanel.tsx @@ -118,8 +118,8 @@ export const ImageStylePanel: React.FC = ({ selectedId, nodePro {/* Box model + animation/visibility rollout */} - - + + ); diff --git a/craft/src/panels/right/styles/MediaStylePanel.tsx b/craft/src/panels/right/styles/MediaStylePanel.tsx index edc98ff..e470585 100644 --- a/craft/src/panels/right/styles/MediaStylePanel.tsx +++ b/craft/src/panels/right/styles/MediaStylePanel.tsx @@ -279,8 +279,8 @@ export const MediaStylePanel: React.FC = ({ selectedId, nodePro {/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider all carry these props now, so it's safe to render unconditionally). */} - - + + ); diff --git a/craft/src/panels/right/styles/boxModel.presets.test.tsx b/craft/src/panels/right/styles/boxModel.presets.test.tsx new file mode 100644 index 0000000..e6f2098 --- /dev/null +++ b/craft/src/panels/right/styles/boxModel.presets.test.tsx @@ -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, nodes: string[] = [], extra: Record = {}) => ({ + 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 }> = ({ id, Panel }) => { + const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} })); + return ; +}; +function mount(id: string, Panel: React.FC) { + h = renderEditorHarness({ initialState: state(), wrap: (t) => {t} }); + h.mountChild(); + 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(); + } + }); +}); diff --git a/craft/src/panels/right/styles/containerBoxModel.tsx b/craft/src/panels/right/styles/containerBoxModel.tsx index 743d6c3..5504795 100644 --- a/craft/src/panels/right/styles/containerBoxModel.tsx +++ b/craft/src/panels/right/styles/containerBoxModel.tsx @@ -81,13 +81,23 @@ export interface BoxModelSectionProps { setPropStyles: (patch: Record, 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) => void; } +const sidesPatch = (kind: 'margin' | 'padding', v: SpacingSideValues): Record => { + const patch: Record = {}; + 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 = ({ style, setPropStyle, setPropStyles, labelAddon }) => { +export const BoxModelSection: React.FC = ({ 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 = ({ 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} /> 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} /> ); @@ -134,10 +146,12 @@ function opacityPercent(v: unknown): number { export interface BorderEffectsSectionProps { style: Record; 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 = ({ style, setPropStyle }) => ( +export const BorderEffectsSection: React.FC = ({ style, setPropStyle, setPropStyleDiscrete }) => ( = ({ styl />
Shadow - setPropStyle('boxShadow', v)} /> + (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
diff --git a/craft/src/panels/right/styles/mediaBoxModel.tsx b/craft/src/panels/right/styles/mediaBoxModel.tsx index 8c19a6f..b3c6fa5 100644 --- a/craft/src/panels/right/styles/mediaBoxModel.tsx +++ b/craft/src/panels/right/styles/mediaBoxModel.tsx @@ -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) => void; } +const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left']; + /** Margin + Padding, per-side, via the shared SpacingControl. */ -export const BoxModelSection: React.FC = ({ style, setPropStyle, labelAddon }) => { +export const BoxModelSection: React.FC = ({ 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) => { + const patch: Record = {}; + SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = all[s]; }); + setPropStylesDiscrete(patch); + } + : undefined); return ( @@ -58,11 +71,13 @@ export const BoxModelSection: React.FC = ({ 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')} /> Padding{labelAddon?.(PADDING_KEYS)}} value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }} onChange={sideSetter('padding')} + onPreset={presetSetter('padding')} /> ); @@ -80,10 +95,12 @@ function opacityPercent(v: unknown): number { export interface BorderEffectsSectionProps { style: Record; 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 = ({ style, setPropStyle }) => ( +export const BorderEffectsSection: React.FC = ({ style, setPropStyle, setPropStyleDiscrete }) => ( = ({ styl />
Shadow - setPropStyle('boxShadow', v)} /> + (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 620b85c..1e3b8d5 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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) => 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) => void; presets?: { label: string; value: string }[]; units?: string[]; } -export const SpacingControl: React.FC = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => { +export const SpacingControl: React.FC = ({ 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 = ({ label, value, on
- + onPreset({ top: v, right: v, bottom: v, left: v }) : applyToAllSides} + /> {linked ? (