fix(site-builder): one undo step per edit (throttle continuous inputs, single setProp for multi-prop toggles)

Refs #32

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 09:56:12 -07:00
1 parent 9ab01021be
commit ac490215ce
20 files changed
+209 -77

No files matched your search

@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle, TYPING_THROTTLE_MS } from './shared';
import { useLayerFocus } from '../../left/LayerFocusContext';
/* ---------- Shared array-item field editor ----------
@@ -52,7 +52,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
data-array-item={`${propKey}:${index}`}
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = e.target.value;
props[propKey] = updated;
@@ -85,7 +85,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[propKey] = updated;
@@ -100,7 +100,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
@@ -117,7 +117,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
@@ -125,7 +125,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
@@ -25,6 +25,7 @@ import {
labelStyle,
autoTextColor,
useNodeProp,
TYPING_THROTTLE_MS,
} from './shared';
function capitalize(s: string): string {
@@ -54,7 +55,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const setButtonColor = useCallback(
(bgColor: string) => {
@@ -84,7 +85,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
value={nodeProps.text || ''}
placeholder="Click Me"
onChange={(v) => {
actions.setProp(selectedId, (props: any) => { props.text = v; });
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => { props.text = v; });
}}
/>
<TextInputField
@@ -92,7 +93,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
value={nodeProps.href || ''}
placeholder="https://..."
onChange={(v) => {
actions.setProp(selectedId, (props: any) => { props.href = v; });
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => { props.href = v; });
}}
/>
<div className="guided-section">
@@ -170,7 +171,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
<VisibilityControl
value={{
@@ -179,9 +180,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -33,7 +33,7 @@ const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
@@ -139,7 +139,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
};
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { labelStyle, inputStyle, sectionGap } from './shared';
import { labelStyle, inputStyle, sectionGap, TYPING_THROTTLE_MS } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { useLayerFocus } from '../../left/LayerFocusContext';
@@ -20,10 +20,11 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
const { actions } = useEditor();
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
const mutate = (fn: (arr: Feature[]) => Feature[], typing = false) =>
(typing ? actions.history.throttle(TYPING_THROTTLE_MS) : actions).setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
// Field edits are typing bursts: coalesce into one undo step.
const update = (i: number, field: keyof Feature, value: string) =>
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; });
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; }, true);
const add = () =>
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
@@ -26,6 +26,7 @@ import {
btnActiveStyle,
sectionGap,
useNodeProp,
TYPING_THROTTLE_MS,
} from './shared';
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
@@ -172,7 +173,7 @@ export const WebhookSecretField: React.FC<{
/* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const style = nodeProps.style || {};
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
@@ -180,7 +181,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
const isWebhook = nodeProps.destinationType === 'webhook';
const updateField = (index: number, patch: Record<string, any>) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props.fields || [])];
updated[index] = { ...updated[index], ...patch };
props.fields = updated;
@@ -537,14 +538,12 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
<VisibilityControl
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
onChange={(v) => {
setProp('hideOnDesktop', v.hideOnDesktop);
setProp('hideOnTablet', v.hideOnTablet);
setProp('hideOnMobile', v.hideOnMobile);
setProps({ hideOnDesktop: v.hideOnDesktop, hideOnTablet: v.hideOnTablet, hideOnMobile: v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -14,7 +14,7 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
}));
import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';
@@ -13,7 +13,7 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
}));
vi.mock('../../../utils/assets', () => ({
@@ -13,6 +13,7 @@ import {
AspectRatioControl,
FocalPointGrid,
useNodeProp,
TYPING_THROTTLE_MS,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
@@ -23,7 +24,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = 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
@@ -56,7 +57,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
value={nodeProps.alt || ''}
placeholder="Describe the image..."
onChange={(v) => {
actions.setProp(selectedId, (props: any) => { props.alt = v; });
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => { props.alt = v; });
}}
/>
@@ -112,7 +113,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
};
@@ -19,6 +19,7 @@ import {
smallInputStyle,
sectionGap,
useNodeProp,
TYPING_THROTTLE_MS,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
@@ -33,7 +34,7 @@ const GALLERY_COLUMN_PRESETS = [
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -187,7 +188,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
)}
{item.caption !== undefined && (
<input type="text" value={item.caption || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props.images || [])];
updated[index] = { ...updated[index], caption: e.target.value };
props.images = updated;
@@ -212,7 +213,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{item.heading !== undefined && (
<input type="text" value={item.heading || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], heading: e.target.value };
props.slides = updated;
@@ -221,7 +222,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
)}
{item.text !== undefined && (
<textarea value={item.text || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], text: e.target.value };
props.slides = updated;
@@ -277,7 +278,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
};
@@ -13,7 +13,7 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
}));
vi.mock('../../../utils/assets', () => ({
@@ -15,7 +15,7 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
@@ -23,6 +23,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
TYPING_THROTTLE_MS,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
@@ -160,7 +161,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
const links: any[] = nodeProps.links || [];
const updateLink = useCallback((index: number, field: string, value: any) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated[index] = { ...updated[index], [field]: value };
props.links = updated;
@@ -19,6 +19,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
TYPING_THROTTLE_MS,
} from './shared';
/** Parses a border shorthand string ("2px solid #hex") produced by
@@ -48,7 +49,7 @@ const bulletChar: Record<string, string> = {
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const [expandedPlan, setExpandedPlan] = useState<number>(0);
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
@@ -56,7 +57,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
const style = nodeProps.style || {};
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
updated[planIndex] = { ...updated[planIndex], [field]: value };
props.plans = updated;
@@ -98,7 +99,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
}, [actions, selectedId]);
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
@@ -227,7 +228,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
{/* Colors */}
<CollapsibleSection title="Colors" defaultOpen={false}>
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
{/* Regular (non-featured) card colors -- built into PricingTable's
render/toHtml but previously hard-coded literals with no control
surfacing them. Each falls back to the prior literal when blank. */}
@@ -283,7 +284,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
</CollapsibleSection>
@@ -296,9 +297,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
hideOnMobile: !!nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -43,7 +43,7 @@ const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -197,7 +197,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
</CollapsibleSection>
@@ -210,9 +210,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
hideOnMobile: !!nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -24,6 +24,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
TYPING_THROTTLE_MS,
} from './shared';
/** Parses a border shorthand string ("2px solid #hex") produced by
@@ -48,7 +49,7 @@ const ICON_SHAPES = [
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -65,7 +66,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
type="text"
value={link.url || ''}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated[i] = { ...updated[i], url: e.target.value };
props.links = updated;
@@ -300,7 +301,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
</CollapsibleSection>
@@ -313,9 +314,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
hideOnMobile: !!nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -63,7 +63,7 @@ function opacityPercent(v: unknown): number {
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const isItalic = style.fontStyle === 'italic';
const isUnderline = style.textDecoration === 'underline';
@@ -211,7 +211,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
<VisibilityControl
value={{
@@ -220,9 +220,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -108,16 +108,16 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
setProps: (patch: Record<string, any>) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProps }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
<VisibilityControl
value={{
@@ -126,9 +126,7 @@ export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setPr
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
@@ -106,16 +106,16 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
setProps: (patch: Record<string, any>) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProps }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
onChange={(v) => { setProps({ animation: v.animation, animationDelay: v.animationDelay }); }}
/>
<VisibilityControl
value={{
@@ -124,9 +124,7 @@ export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setPr
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile });
}}
/>
</CollapsibleSection>
+12 -3
View File
@@ -319,11 +319,20 @@ export interface StylePanelProps {
files. Only adopted where a panel's inline definitions were byte-equivalent
to this (some panels use different param names or extra logic and keep
their own local versions). */
/** Undo coalescing window for continuous inputs (color drags, sliders,
* typing): edits landing within this many ms of the previous history entry
* are folded into it, so one burst = one undo step. Craft's default rate. */
export const TYPING_THROTTLE_MS = 500;
export function useNodeProp(selectedId: string) {
const { actions } = useEditor();
const setProp = useCallback((key: string, value: any) => actions.setProp(selectedId, (p: any) => { p[key] = value; }), [actions, selectedId]);
const setPropStyle = useCallback((prop: string, value: string) => actions.setProp(selectedId, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
return { setProp, setPropStyle };
// setProp/setPropStyle serve both continuous inputs and discrete clicks, so
// they throttle: a drag/typing burst is one undo step.
const setProp = useCallback((key: string, value: any) => actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (p: any) => { p[key] = value; }), [actions, selectedId]);
const setPropStyle = useCallback((prop: string, value: string) => actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
// Several props in ONE setProp call = exactly one undo step (never throttled).
const setProps = useCallback((patch: Record<string, any>) => actions.setProp(selectedId, (p: any) => { Object.assign(p, patch); }), [actions, selectedId]);
return { setProp, setPropStyle, setProps };
}
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
@@ -0,0 +1,131 @@
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../editorHarness';
import { useNodeProp, ColorPickerField } from '../../panels/right/styles/shared';
import { AnimVisSection } from '../../panels/right/styles/containerBoxModel';
import { AnimVisSection as MediaAnimVisSection } from '../../panels/right/styles/mediaBoxModel';
/**
* Undo granularity against a REAL Craft.js history: one user edit = one undo
* step. Uses the real style-panel controls (ColorPickerField, AnimVisSection)
* wired through the real `useNodeProp`, so a regression to per-tick or
* per-prop `setProp` calls shows up as extra undo steps here.
*/
const INITIAL_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: {}, tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: ['h-1'],
linkedNodes: {},
},
'h-1': {
type: { resolvedName: 'Heading' },
isCanvas: false,
props: { text: 'Hi', level: 'h2', style: { color: '#000000' } },
displayName: 'Heading',
custom: {},
hidden: false,
parent: 'ROOT',
nodes: [],
linkedNodes: {},
},
});
let harness: EditorHarness | null = null;
beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(1_000_000); });
afterEach(() => {
vi.useRealTimers();
harness?.unmount();
harness = null;
});
const props = () => harness!.query.node('h-1').get().data.props;
/** How many undos until nothing is left (ignores any pre-existing history). */
function undoDepth(): number {
let n = 0;
while (harness!.query.history.canUndo() && n < 50) {
harness!.act(() => { harness!.actions.history.undo(); });
n++;
}
return n;
}
function setNative(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function mountFresh(ui: React.ReactNode) {
harness = renderEditorHarness({ initialState: INITIAL_STATE });
harness.mountChild(ui);
harness.act(() => { harness!.actions.history.clear(); });
vi.setSystemTime(2_000_000); // well past the throttle window of any earlier edit
}
const ColorProbe: React.FC = () => {
const { setPropStyle } = useNodeProp('h-1');
return <ColorPickerField label="Color" value={props().style.color} onChange={(v) => setPropStyle('color', v)} />;
};
const AnimVisProbe: React.FC<{ media?: boolean }> = ({ media }) => {
const { setProps } = useNodeProp('h-1');
const Section = media ? MediaAnimVisSection : AnimVisSection;
return <Section nodeProps={props()} setProps={setProps} />;
};
function openSection() {
const toggle = Array.from(harness!.container.querySelectorAll('button')).find((b) => /Animation & Visibility/.test(b.textContent || ''));
harness!.act(() => { toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
const q = (id: string) => harness!.container.querySelector(`[data-testid="${id}"]`) as HTMLElement;
const clickEl = (el: Element) => harness!.act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
describe('undo granularity (real Craft history)', () => {
test('a drag of 10 color changes is one undo step restoring the original', () => {
mountFresh(<ColorProbe />);
const colorInput = harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
for (let i = 1; i <= 10; i++) {
harness!.act(() => { setNative(colorInput, `#00${i.toString(16).padStart(2, '0')}00`); });
vi.setSystemTime(2_000_000 + i * 20); // ~20ms ticks, all inside the 500ms window
}
expect(props().style.color).toBe('#000a00');
expect(undoDepth()).toBe(1);
expect(props().style.color).toBe('#000000');
});
test('edits separated by more than the throttle window stay separate steps', () => {
mountFresh(<ColorProbe />);
const colorInput = harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
harness!.act(() => { setNative(colorInput, '#111111'); });
vi.setSystemTime(2_001_000);
harness!.act(() => { setNative(colorInput, '#222222'); });
expect(undoDepth()).toBe(2);
});
test.each([false, true])('visibility toggle is one undo step (media=%s)', (media) => {
mountFresh(<AnimVisProbe media={media} />);
openSection();
clickEl(q('visibility-hideOnDesktop'));
expect(props().hideOnDesktop).toBe(true);
expect(props().hideOnTablet).toBe(false);
expect(undoDepth()).toBe(1);
expect(props().hideOnDesktop).toBeFalsy();
});
test.each([false, true])('animation change is one undo step (media=%s)', (media) => {
mountFresh(<AnimVisProbe media={media} />);
openSection();
const btn = Array.from(harness!.container.querySelectorAll('[data-testid="animation-control"] .preset-btn'))
.find((b) => /fade/i.test(b.textContent || ''))!;
clickEl(btn);
expect(props().animation).not.toBe('none');
expect(undoDepth()).toBe(1);
expect(props().animation).toBeFalsy();
});
});