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:
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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user