Files
site-builder/craft/src/panels/right/styles/ImageStylePanel.tsx
T
shadowdaoandClaude Opus 5.5 bbae73c132 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>
2026-10-07 12:54:13 -07:00

127 lines
4.9 KiB
TypeScript

import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
IMAGE_RADIUS_PRESETS,
OBJECT_FIT,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
PresetButtonGrid,
TextInputField,
SizeControl,
AspectRatioControl,
FocalPointGrid,
useNodeProp,
continuousSetProp,
} from './shared';
import { useResolvedStyle } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
/* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { setProp, setPropStyle, setProps, setPropStyleContinuous, setPropStyles } = useNodeProp(selectedId);
// Applying an aspect-ratio crop should FILL the frame by default (object-fit:
// cover) rather than leave letterboxed empty bands, and width + ratio + cover
// fully determine the box -- so a stale `height` can't linger and conflict
// (kills the %-height no-op that made resize look like it wasn't working).
// Clearing the ratio (back to 'Original') leaves objectFit as the user left it.
const applyAspectRatio = (v: string) => {
// One setProp call so the whole ratio change is a single undo step.
setPropStyles({
aspectRatio: v,
...(v ? { ...(!style.objectFit && { objectFit: 'cover' }), height: '' } : {}),
});
};
return (
<>
{/* Image source */}
<div className="guided-section">
<SectionLabel>Image Source</SectionLabel>
<AssetPicker
value={nodeProps.src || ''}
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url || PLACEHOLDER_SRC; })}
variant="full"
// #55: a stock photo carries its own alt/credit; only fill an EMPTY alt.
onPick={(a) => {
if (!nodeProps.alt && a.alt) actions.setProp(selectedId, (props: any) => { props.alt = a.alt; });
}}
/>
</div>
{/* Alt Text */}
<TextInputField
label="Alt Text"
value={nodeProps.alt || ''}
placeholder="Describe the image..."
onChange={(v) => {
continuousSetProp(actions, selectedId, 'alt', (props: any) => { props.alt = v; });
}}
/>
{/* Size -- Width absorbs the historical maxWidth presets (25/50/75/100%
are a subset of SizeControl's default preset row), Height is new. */}
<SizeControl
label={<>Width{dot('maxWidth')}</>}
value={(style.maxWidth as string) || ''}
onChange={(v) => setPropStyleContinuous('maxWidth', v)}
/>
{/* Height is only meaningful when there's no aspect-ratio crop -- once a
ratio is set, Width + ratio + cover fully determine the box, so a
separate Height control would only conflict/mislead (see
applyAspectRatio, which clears any stale height at that moment). */}
{!style.aspectRatio && (
<SizeControl
label={<>Height{dot('height')}</>}
value={(style.height as string) || ''}
onChange={(v) => setPropStyleContinuous('height', v)}
/>
)}
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
<img> itself is a CSS framing crop (no server-side image processing
needed). */}
<AspectRatioControl
value={(style.aspectRatio as string) || ''}
onChange={applyAspectRatio}
/>
<div className="guided-section">
<SectionLabel>Object Fit</SectionLabel>
<PresetButtonGrid
presets={OBJECT_FIT}
activeValue={(style.objectFit as string) || ''}
onSelect={(v) => setPropStyle('objectFit', v)}
/>
</div>
<FocalPointGrid
value={(style.objectPosition as string) || ''}
onChange={(v) => setPropStyle('objectPosition', v)}
/>
{/* Border Radius */}
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid
presets={IMAGE_RADIUS_PRESETS}
activeValue={style.borderRadius as string}
onSelect={(v) => setPropStyle('borderRadius', v)}
/>
</div>
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
};