fix(site-builder): continuous-edit bursts via history.merge; plain setters are single steps

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 10:00:29 -07:00
1 parent ac490215ce
commit 2c85e33b38
22 files changed
+316 -211

No files matched your search

@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle, TYPING_THROTTLE_MS } from './shared';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle, continuousSetProp } 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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}`, (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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
@@ -20,7 +20,7 @@ import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- BACKGROUND SECTION ---------- */
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setPropContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -33,16 +33,16 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
{/* Background Color */}
<CollapsibleSection title="Background Color">
<ColorPickerField label="Background" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
<ColorPickerField label="Background" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setPropContinuous('bgColor', v)} />
</CollapsibleSection>
{/* Overlay */}
<CollapsibleSection title="Overlay">
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setPropContinuous('overlayColor', v)} />
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}%</label>
<input type="range" min={0} max={100} value={Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}
onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value) / 100)} style={{ width: '100%' }} />
onChange={(e) => setPropContinuous('overlayOpacity', parseInt(e.target.value) / 100)} style={{ width: '100%' }} />
</div>
</CollapsibleSection>
@@ -51,7 +51,7 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
<div style={sectionGap}>
<label style={labelStyle}>Inner Max Width</label>
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
onChange={(e) => setProp('innerMaxWidth', e.target.value)} style={inputStyle} />
onChange={(e) => setPropContinuous('innerMaxWidth', e.target.value)} style={inputStyle} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
@@ -25,7 +25,7 @@ import {
labelStyle,
autoTextColor,
useNodeProp,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
function capitalize(s: string): string {
@@ -55,7 +55,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const setButtonColor = useCallback(
(bgColor: string) => {
@@ -85,7 +85,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
value={nodeProps.text || ''}
placeholder="Click Me"
onChange={(v) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => { props.text = v; });
continuousSetProp(actions, selectedId, 'text', (props: any) => { props.text = v; });
}}
/>
<TextInputField
@@ -93,7 +93,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
value={nodeProps.href || ''}
placeholder="https://..."
onChange={(v) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => { props.href = v; });
continuousSetProp(actions, selectedId, 'href', (props: any) => { props.href = v; });
}}
/>
<div className="guided-section">
@@ -130,12 +130,12 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<ColorPickerField
label="Hover Background"
value={nodeProps.hoverBg || ''}
onChange={(v) => setProp('hoverBg', v)}
onChange={(v) => setPropContinuous('hoverBg', v)}
/>
<ColorPickerField
label="Hover Text Color"
value={nodeProps.hoverColor || ''}
onChange={(v) => setProp('hoverColor', v)}
onChange={(v) => setPropContinuous('hoverColor', v)}
/>
</CollapsibleSection>
@@ -144,13 +144,13 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
onChange={(side: SpacingSide, v: string) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
/>
</CollapsibleSection>
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
@@ -163,7 +163,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
@@ -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, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
@@ -52,7 +52,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
<input
type="text"
value={nodeProps.cssId || ''}
onChange={(e) => setProp('cssId', e.target.value)}
onChange={(e) => setPropContinuous('cssId', e.target.value)}
placeholder="my-element-id"
style={inputStyle}
/>
@@ -64,7 +64,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
<input
type="text"
value={nodeProps.cssClass || ''}
onChange={(e) => setProp('cssClass', e.target.value)}
onChange={(e) => setPropContinuous('cssClass', e.target.value)}
placeholder="my-custom-class"
style={inputStyle}
/>
@@ -129,7 +129,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
<SectionLabel>Min Height</SectionLabel>
<NumericUnitInput
value={(style.minHeight as string) || ''}
onChange={(v) => setPropStyle('minHeight', v)}
onChange={(v) => setPropStyleContinuous('minHeight', v)}
units={['px', 'vh', '%']}
placeholder="auto"
/>
@@ -137,8 +137,8 @@ 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} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { labelStyle, inputStyle, sectionGap, TYPING_THROTTLE_MS } from './shared';
import { labelStyle, inputStyle, sectionGap, continuousSetProp, discreteSetProp } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { useLayerFocus } from '../../left/LayerFocusContext';
@@ -20,11 +20,13 @@ 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[], 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 mutate = (fn: (arr: Feature[]) => Feature[]) =>
discreteSetProp(actions, selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
// Field edits are typing bursts: one undo step per field per burst.
const update = (i: number, field: keyof Feature, value: string) =>
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; }, true);
continuousSetProp(actions, selectedId, `${i}:${field}`, (p: any) => {
const arr = [...(p.features || [])]; arr[i] = { ...arr[i], [field]: value }; p.features = arr;
});
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,7 +26,7 @@ import {
btnActiveStyle,
sectionGap,
useNodeProp,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
@@ -173,7 +173,7 @@ export const WebhookSecretField: React.FC<{
/* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
@@ -181,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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `field:${index}:${Object.keys(patch).join(',')}`, (props: any) => {
const updated = [...(props.fields || [])];
updated[index] = { ...updated[index], ...patch };
props.fields = updated;
@@ -303,7 +303,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
type="text"
data-testid="webhook-url"
value={nodeProps.webhookUrl || ''}
onChange={(e) => setProp('webhookUrl', e.target.value)}
onChange={(e) => setPropContinuous('webhookUrl', e.target.value)}
placeholder="https://hooks.example.com/..."
style={inputStyle}
/>
@@ -356,7 +356,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.recipientEmail !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>{isWebhook ? 'Fallback email (if the webhook fails)' : 'Send submissions to (email)'}</label>
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setPropContinuous('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
<p style={hintStyle}>
{isWebhook
? 'Emailed here if the webhook keeps failing after retries. Leave blank to skip the fallback.'
@@ -367,7 +367,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.thankYouUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Thank-you page URL (optional)</label>
<input type="text" value={nodeProps.thankYouUrl || ''} onChange={(e) => setProp('thankYouUrl', e.target.value)} placeholder="/thank-you (blank = hosted page)" style={inputStyle} />
<input type="text" value={nodeProps.thankYouUrl || ''} onChange={(e) => setPropContinuous('thankYouUrl', e.target.value)} placeholder="/thank-you (blank = hosted page)" style={inputStyle} />
</div>
)}
@@ -377,7 +377,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.action !== undefined && nodeProps.showButton === undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Form Action URL</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
<input type="text" value={nodeProps.action || ''} onChange={(e) => setPropContinuous('action', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
)}
@@ -385,7 +385,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.showButton !== undefined && nodeProps.action !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Search Results Page</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="/ (site root) or /search" style={inputStyle} />
<input type="text" value={nodeProps.action || ''} onChange={(e) => setPropContinuous('action', e.target.value)} placeholder="/ (site root) or /search" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Submits a GET request with the query as ?q=... to this URL.
</p>
@@ -415,19 +415,19 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.label !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Label</label>
<input type="text" value={nodeProps.label || ''} onChange={(e) => setProp('label', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.label || ''} onChange={(e) => setPropContinuous('label', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.placeholder !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Placeholder</label>
<input type="text" value={nodeProps.placeholder || ''} onChange={(e) => setProp('placeholder', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.placeholder || ''} onChange={(e) => setPropContinuous('placeholder', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.name !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Field Name</label>
<input type="text" value={nodeProps.name || ''} onChange={(e) => setProp('name', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.name || ''} onChange={(e) => setPropContinuous('name', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.type !== undefined && typeof nodeProps.type === 'string' && (
@@ -453,7 +453,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.text !== undefined && nodeProps.label === undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Button Text</label>
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.text || ''} onChange={(e) => setPropContinuous('text', e.target.value)} style={inputStyle} />
</div>
)}
@@ -461,19 +461,19 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{nodeProps.buttonText !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Button Text</label>
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setPropContinuous('buttonText', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.placeholderText !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Placeholder Text</label>
<input type="text" value={nodeProps.placeholderText || ''} onChange={(e) => setProp('placeholderText', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.placeholderText || ''} onChange={(e) => setPropContinuous('placeholderText', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.successMessage !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Success Message</label>
<input type="text" value={nodeProps.successMessage || ''} onChange={(e) => setProp('successMessage', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.successMessage || ''} onChange={(e) => setPropContinuous('successMessage', e.target.value)} style={inputStyle} />
</div>
)}
@@ -500,16 +500,16 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyle(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
onChange={(side, v) => setPropStyleContinuous(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<SpacingControl
label="Padding (per side)"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyle(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
onChange={(side, v) => setPropStyleContinuous(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<BorderControl
value={parseBorderValue(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Box Shadow</SectionLabel>
@@ -522,7 +522,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
min={0}
max={100}
value={style.opacity !== undefined && style.opacity !== '' ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
@@ -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, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
}));
import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';
@@ -25,7 +25,7 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
}) => {
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass', 'code']);
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
const { setProp: setPropValue, setPropStyle: setStyleValue, setPropContinuous: setPropValueContinuous } = useNodeProp(selectedId);
// Categorize all props
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
@@ -42,7 +42,7 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
<>
{/* Raw HTML (HtmlBlock's `code` prop) -- dedicated CodeEditor modal */}
{hasCodeProp && (
<HtmlCodeField value={nodeProps.code} onChange={(v) => setPropValue('code', v)} />
<HtmlCodeField value={nodeProps.code} onChange={(v) => setPropValueContinuous('code', v)} />
)}
{/* String props */}
@@ -55,9 +55,9 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
<div key={key} style={sectionGap}>
<label style={labelStyle}>{humanLabel}</label>
{isLong ? (
<textarea value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
<textarea value={String(val)} onChange={(e) => setPropValueContinuous(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} style={inputStyle} />
<input type="text" value={String(val)} onChange={(e) => setPropValueContinuous(key, e.target.value)} style={inputStyle} />
)}
</div>
);
@@ -71,7 +71,7 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
{numberProps.map(([key, val]) => (
<div key={key} style={sectionGap}>
<label style={labelStyle}>{key.replace(/([A-Z])/g, ' $1').trim()}</label>
<input type="number" value={val as number} onChange={(e) => setPropValue(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
<input type="number" value={val as number} onChange={(e) => setPropValueContinuous(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
</div>
))}
</CollapsibleSection>
@@ -95,7 +95,7 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
{colorProps.length > 0 && (
<CollapsibleSection title="Colors">
{colorProps.map(([key, val]) => (
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValue(key, v)} />
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValueContinuous(key, v)} />
))}
</CollapsibleSection>
)}
@@ -13,7 +13,7 @@ import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- HERO STYLE PANEL ---------- */
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { setProp } = useNodeProp(selectedId);
const { setProp, setPropContinuous } = useNodeProp(selectedId);
const bgType = nodeProps.bgType || 'color';
@@ -22,23 +22,23 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<CollapsibleSection title="Content">
<div style={sectionGap}>
<label style={labelStyle}>Heading</label>
<input type="text" value={nodeProps.heading || ''} onChange={(e) => setProp('heading', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.heading || ''} onChange={(e) => setPropContinuous('heading', e.target.value)} style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Subtitle</label>
<textarea value={nodeProps.subtitle || ''} onChange={(e) => setProp('subtitle', e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
<textarea value={nodeProps.subtitle || ''} onChange={(e) => setPropContinuous('subtitle', e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Button Text</label>
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setPropContinuous('buttonText', e.target.value)} style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Button URL</label>
<input type="text" value={nodeProps.buttonHref || ''} onChange={(e) => setProp('buttonHref', e.target.value)} placeholder="#" style={inputStyle} />
<input type="text" value={nodeProps.buttonHref || ''} onChange={(e) => setPropContinuous('buttonHref', e.target.value)} placeholder="#" style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Secondary Button</label>
<input type="text" value={nodeProps.secondaryButtonText || ''} onChange={(e) => setProp('secondaryButtonText', e.target.value)} placeholder="Leave blank to hide" style={inputStyle} />
<input type="text" value={nodeProps.secondaryButtonText || ''} onChange={(e) => setPropContinuous('secondaryButtonText', e.target.value)} placeholder="Leave blank to hide" style={inputStyle} />
</div>
</CollapsibleSection>
@@ -55,22 +55,22 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</div>
{bgType === 'color' && (
<ColorPickerField label="Color" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
<ColorPickerField label="Color" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setPropContinuous('bgColor', v)} />
)}
{bgType === 'gradient' && (
<>
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
<div style={{ flex: 1 }}>
<ColorPickerField label="From" value={nodeProps.bgGradientFrom || '#667eea'} onChange={(v) => setProp('bgGradientFrom', v)} />
<ColorPickerField label="From" value={nodeProps.bgGradientFrom || '#667eea'} onChange={(v) => setPropContinuous('bgGradientFrom', v)} />
</div>
<div style={{ flex: 1 }}>
<ColorPickerField label="To" value={nodeProps.bgGradientTo || '#764ba2'} onChange={(v) => setProp('bgGradientTo', v)} />
<ColorPickerField label="To" value={nodeProps.bgGradientTo || '#764ba2'} onChange={(v) => setPropContinuous('bgGradientTo', v)} />
</div>
</div>
<div style={sectionGap}>
<label style={labelStyle}>Angle: {nodeProps.bgGradientAngle || 135}°</label>
<input type="range" min={0} max={360} value={nodeProps.bgGradientAngle || 135} onChange={(e) => setProp('bgGradientAngle', parseInt(e.target.value))} style={{ width: '100%' }} />
<input type="range" min={0} max={360} value={nodeProps.bgGradientAngle || 135} onChange={(e) => setPropContinuous('bgGradientAngle', parseInt(e.target.value))} style={{ width: '100%' }} />
</div>
{/* Gradient preview */}
<div style={{ height: 24, borderRadius: 4, background: `linear-gradient(${nodeProps.bgGradientAngle || 135}deg, ${nodeProps.bgGradientFrom || '#667eea'}, ${nodeProps.bgGradientTo || '#764ba2'})`, border: '1px solid #3f3f46' }} />
@@ -100,20 +100,20 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<div style={sectionGap}>
<label style={labelStyle}>Overlay ({nodeProps.overlayOpacity || 0}%)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setProp('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setPropContinuous('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setPropContinuous('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Colors">
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#ffffff'} onChange={(v) => setProp('textColor', v)} />
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#ffffff'} onChange={(v) => setPropContinuous('textColor', v)} />
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
<div style={{ flex: 1 }}>
<ColorPickerField label="Button BG" value={nodeProps.buttonBgColor || '#3b82f6'} onChange={(v) => setProp('buttonBgColor', v)} />
<ColorPickerField label="Button BG" value={nodeProps.buttonBgColor || '#3b82f6'} onChange={(v) => setPropContinuous('buttonBgColor', v)} />
</div>
<div style={{ flex: 1 }}>
<ColorPickerField label="Button Text" value={nodeProps.buttonTextColor || '#ffffff'} onChange={(v) => setProp('buttonTextColor', v)} />
<ColorPickerField label="Button Text" value={nodeProps.buttonTextColor || '#ffffff'} onChange={(v) => setPropContinuous('buttonTextColor', v)} />
</div>
</div>
</CollapsibleSection>
@@ -14,12 +14,12 @@ export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<st
selectedId,
nodeProps,
}) => {
const { setProp: setPropValue } = useNodeProp(selectedId);
const { setProp: setPropValue, setPropContinuous: setPropValueContinuous } = useNodeProp(selectedId);
return (
<>
<HtmlCodeField
value={typeof nodeProps.code === 'string' ? nodeProps.code : ''}
onChange={(v) => setPropValue('code', v)}
onChange={(v) => setPropValueContinuous('code', v)}
/>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
Style this block inside your own markup — a wrapper set here would show
@@ -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, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
}));
vi.mock('../../../utils/assets', () => ({
@@ -13,7 +13,7 @@ import {
AspectRatioControl,
FocalPointGrid,
useNodeProp,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
@@ -24,7 +24,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
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
@@ -32,11 +32,11 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
// (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) => {
setPropStyle('aspectRatio', v);
if (v) {
if (!style.objectFit) setPropStyle('objectFit', 'cover');
setPropStyle('height', '');
}
// One setProp call so the whole ratio change is a single undo step.
setPropStyles({
aspectRatio: v,
...(v ? { ...(!style.objectFit && { objectFit: 'cover' }), height: '' } : {}),
});
};
return (
@@ -57,7 +57,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
value={nodeProps.alt || ''}
placeholder="Describe the image..."
onChange={(v) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => { props.alt = v; });
continuousSetProp(actions, selectedId, 'alt', (props: any) => { props.alt = v; });
}}
/>
@@ -66,7 +66,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
<SizeControl
label="Width"
value={(style.maxWidth as string) || ''}
onChange={(v) => setPropStyle('maxWidth', v)}
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
@@ -76,7 +76,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
<SizeControl
label="Height"
value={(style.height as string) || ''}
onChange={(v) => setPropStyle('height', v)}
onChange={(v) => setPropStyleContinuous('height', v)}
/>
)}
@@ -111,8 +111,8 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div>
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -19,7 +19,7 @@ import {
smallInputStyle,
sectionGap,
useNodeProp,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
@@ -34,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, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous, setPropStyles } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -43,11 +43,11 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
// defaults objectFit to 'cover' when unset, and clears height so Width +
// ratio + cover is the single source of truth for the box.
const applyAspectRatio = (v: string) => {
setPropStyle('aspectRatio', v);
if (v) {
if (!style.objectFit) setPropStyle('objectFit', 'cover');
setPropStyle('height', '');
}
// One setProp call so the whole ratio change is a single undo step.
setPropStyles({
aspectRatio: v,
...(v ? { ...(!style.objectFit && { objectFit: 'cover' }), height: '' } : {}),
});
};
return (
@@ -74,7 +74,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
<SizeControl
label="Width"
value={(style.width as string) || ''}
onChange={(v) => setPropStyle('width', v)}
onChange={(v) => setPropStyleContinuous('width', v)}
/>
{/* NOTE: unlike ImageStylePanel, there is no separate Height control
here to gate on aspect-ratio -- VideoBlock's <video>/iframe size
@@ -106,7 +106,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{nodeProps.embedUrl !== undefined && nodeProps.videoUrl === undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Embed URL</label>
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setProp('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setPropContinuous('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
</div>
)}
@@ -114,7 +114,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{nodeProps.address !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Address</label>
<input type="text" value={nodeProps.address || ''} onChange={(e) => setProp('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
<input type="text" value={nodeProps.address || ''} onChange={(e) => setPropContinuous('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
</div>
)}
@@ -188,7 +188,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
)}
{item.caption !== undefined && (
<input type="text" value={item.caption || ''} onChange={(e) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `images:${index}:caption`, (props: any) => {
const updated = [...(props.images || [])];
updated[index] = { ...updated[index], caption: e.target.value };
props.images = updated;
@@ -213,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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `slides:${index}:heading`, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], heading: e.target.value };
props.slides = updated;
@@ -222,7 +222,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
)}
{item.text !== undefined && (
<textarea value={item.text || ''} onChange={(e) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `slides:${index}:text`, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], text: e.target.value };
props.slides = updated;
@@ -252,11 +252,11 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Overlay */}
{nodeProps.overlayColor !== undefined && (
<CollapsibleSection title="Overlay" defaultOpen={false}>
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setPropContinuous('overlayColor', v)} />
{nodeProps.overlayOpacity !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {nodeProps.overlayOpacity ?? 0}%</label>
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setPropContinuous('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
</div>
)}
</CollapsibleSection>
@@ -276,8 +276,8 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<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, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ 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, history: { throttle: () => ({ setProp: setPropSpy }) } } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
+14 -14
View File
@@ -23,7 +23,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
@@ -155,13 +155,13 @@ function capitalize(s: string): string {
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const { setProp, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const { pages } = usePages();
const links: any[] = nodeProps.links || [];
const updateLink = useCallback((index: number, field: string, value: any) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `link:${index}:${field}`, (props: any) => {
const updated = [...(props.links || [])];
updated[index] = { ...updated[index], [field]: value };
props.links = updated;
@@ -249,11 +249,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<>
<div style={sectionGap}>
<label style={labelStyle}>Logo Text</label>
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.text || ''} onChange={(e) => setPropContinuous('text', e.target.value)} style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Font Size</label>
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setPropContinuous('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Font Weight</label>
@@ -266,7 +266,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<option value="800">Extra Bold</option>
</select>
</div>
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setProp('color', v)} />
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setPropContinuous('color', v)} />
</>
)}
{nodeProps.type === 'image' && (
@@ -277,7 +277,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</div>
<div style={sectionGap}>
<label style={labelStyle}>Image Width</label>
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setPropContinuous('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
</div>
</>
)}
@@ -294,7 +294,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<CollapsibleSection title="Logo">
<div style={sectionGap}>
<label style={labelStyle}>Logo Text</label>
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setProp('logoText', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setPropContinuous('logoText', e.target.value)} style={inputStyle} />
</div>
{nodeProps.logoImage !== undefined && (
<div style={sectionGap}>
@@ -353,7 +353,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
key={f.key}
label={f.label}
value={nodeProps[f.key] || f.fallback}
onChange={(v) => setProp(f.key, v)}
onChange={(v) => setPropContinuous(f.key, v)}
/>
))}
</CollapsibleSection>
@@ -395,7 +395,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
{nodeProps.fontSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Font Size</label>
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setPropContinuous('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
</div>
)}
</CollapsibleSection>
@@ -409,12 +409,12 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyle(`margin${capitalize(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyle(`padding${capitalize(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`padding${capitalize(side)}`, v)}
presets={SPACING_PRESETS}
/>
</CollapsibleSection>
@@ -423,7 +423,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Box Shadow</SectionLabel>
@@ -436,7 +436,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(parseFloat(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
@@ -19,7 +19,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
/** Parses a border shorthand string ("2px solid #hex") produced by
@@ -49,7 +49,7 @@ const bulletChar: Record<string, string> = {
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const [expandedPlan, setExpandedPlan] = useState<number>(0);
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
@@ -57,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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `plan:${planIndex}:${field}`, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
updated[planIndex] = { ...updated[planIndex], [field]: value };
props.plans = updated;
@@ -99,7 +99,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
}, [actions, selectedId]);
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `feature:${planIndex}:${featureIndex}`, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
@@ -228,17 +228,17 @@ 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.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => continuousSetProp(actions, selectedId, 'featuredBg', (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. */}
<ColorPickerField label="Card Background" value={nodeProps.cardBg || '#ffffff'} onChange={(v) => setProp('cardBg', v)} />
<ColorPickerField label="Heading / Price Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setProp('subColor', v)} />
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setProp('featColor', v)} />
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setProp('checkColor', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || '#3b82f6'} onChange={(v) => setProp('btnBg', v)} />
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setProp('btnColor', v)} />
<ColorPickerField label="Card Background" value={nodeProps.cardBg || '#ffffff'} onChange={(v) => setPropContinuous('cardBg', v)} />
<ColorPickerField label="Heading / Price Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setPropContinuous('textColor', v)} />
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setPropContinuous('subColor', v)} />
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setPropContinuous('featColor', v)} />
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setPropContinuous('checkColor', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || '#3b82f6'} onChange={(v) => setPropContinuous('btnBg', v)} />
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setPropContinuous('btnColor', v)} />
</CollapsibleSection>
{/* Box model: margin, padding, border, shadow, opacity */}
@@ -249,7 +249,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
top: style.marginTop as string, right: style.marginRight as string,
bottom: style.marginBottom as string, left: style.marginLeft as string,
}}
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
@@ -257,11 +257,11 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
}}
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`padding${capSide(side)}`, v)}
/>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
@@ -274,7 +274,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
@@ -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, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -82,7 +82,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
return (
<div key={key} style={sectionGap}>
<label style={labelStyle}>{humanLabel}</label>
<input type="number" value={val} onChange={(e) => setProp(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
<input type="number" value={val} onChange={(e) => setPropContinuous(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
</div>
);
}
@@ -92,9 +92,9 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
<div key={key} style={sectionGap}>
<label style={labelStyle}>{humanLabel}</label>
{isLong ? (
<textarea value={String(val)} onChange={(e) => setProp(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
<textarea value={String(val)} onChange={(e) => setPropContinuous(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={String(val)} onChange={(e) => setProp(key, e.target.value)} style={inputStyle} />
<input type="text" value={String(val)} onChange={(e) => setPropContinuous(key, e.target.value)} style={inputStyle} />
)}
</div>
);
@@ -106,7 +106,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
{colorProps.length > 0 && (
<CollapsibleSection title="Colors">
{colorProps.map(([key, val]) => (
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setProp(key, v)} />
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropContinuous(key, v)} />
))}
</CollapsibleSection>
)}
@@ -162,7 +162,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
top: style.marginTop as string, right: style.marginRight as string,
bottom: style.marginBottom as string, left: style.marginLeft as string,
}}
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
@@ -170,11 +170,11 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
}}
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`padding${capSide(side)}`, v)}
/>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
@@ -187,7 +187,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
@@ -24,7 +24,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
TYPING_THROTTLE_MS,
continuousSetProp,
} from './shared';
/** Parses a border shorthand string ("2px solid #hex") produced by
@@ -49,7 +49,7 @@ const ICON_SHAPES = [
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -66,7 +66,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
type="text"
value={link.url || ''}
onChange={(e) => {
actions.history.throttle(TYPING_THROTTLE_MS).setProp(selectedId, (props: any) => {
continuousSetProp(actions, selectedId, `link:${i}`, (props: any) => {
const updated = [...(props.links || [])];
updated[i] = { ...updated[i], url: e.target.value };
props.links = updated;
@@ -114,13 +114,13 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
{nodeProps.iconName !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Icon Name</label>
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setProp('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setPropContinuous('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
</div>
)}
{nodeProps.icon !== undefined && typeof nodeProps.icon === 'string' && (
<div style={sectionGap}>
<label style={labelStyle}>Icon</label>
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setProp('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setPropContinuous('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
</div>
)}
@@ -128,13 +128,13 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
{nodeProps.rating !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Rating: {nodeProps.rating || 0}</label>
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setProp('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setPropContinuous('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
</div>
)}
{nodeProps.maxStars !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Max Stars</label>
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setProp('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setPropContinuous('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
</div>
)}
@@ -158,7 +158,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
{nodeProps.gap !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Gap</label>
<input type="text" value={nodeProps.gap || '10px'} onChange={(e) => setProp('gap', e.target.value)} placeholder="10px" style={inputStyle} />
<input type="text" value={nodeProps.gap || '10px'} onChange={(e) => setPropContinuous('gap', e.target.value)} placeholder="10px" style={inputStyle} />
</div>
)}
@@ -179,18 +179,18 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
</div>
)}
{nodeProps.bgColor !== undefined && (
<ColorPickerField label="Background Color" value={nodeProps.bgColor === 'transparent' ? '' : nodeProps.bgColor} onChange={(v) => setProp('bgColor', v)} />
<ColorPickerField label="Background Color" value={nodeProps.bgColor === 'transparent' ? '' : nodeProps.bgColor} onChange={(v) => setPropContinuous('bgColor', v)} />
)}
{nodeProps.bgSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Background Size</label>
<input type="text" value={nodeProps.bgSize || '56px'} onChange={(e) => setProp('bgSize', e.target.value)} placeholder="56px" style={inputStyle} />
<input type="text" value={nodeProps.bgSize || '56px'} onChange={(e) => setPropContinuous('bgSize', e.target.value)} placeholder="56px" style={inputStyle} />
</div>
)}
{nodeProps.link !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Link URL</label>
<input type="text" value={nodeProps.link || ''} onChange={(e) => setProp('link', e.target.value)} placeholder="https://..." style={inputStyle} />
<input type="text" value={nodeProps.link || ''} onChange={(e) => setPropContinuous('link', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
)}
@@ -198,37 +198,37 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
emptyColor but had no control (the generic `starColor` check above
never matched StarRating's actual prop names). */}
{nodeProps.filledColor !== undefined && (
<ColorPickerField label="Filled Star Color" value={nodeProps.filledColor || '#f59e0b'} onChange={(v) => setProp('filledColor', v)} />
<ColorPickerField label="Filled Star Color" value={nodeProps.filledColor || '#f59e0b'} onChange={(v) => setPropContinuous('filledColor', v)} />
)}
{nodeProps.emptyColor !== undefined && (
<ColorPickerField label="Empty Star Color" value={nodeProps.emptyColor || '#d1d5db'} onChange={(v) => setProp('emptyColor', v)} />
<ColorPickerField label="Empty Star Color" value={nodeProps.emptyColor || '#d1d5db'} onChange={(v) => setPropContinuous('emptyColor', v)} />
)}
{/* Colors */}
{nodeProps.iconColor !== undefined && (
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setPropContinuous('iconColor', v)} />
)}
{nodeProps.iconBgColor !== undefined && (
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setProp('iconBgColor', v)} />
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setPropContinuous('iconBgColor', v)} />
)}
{nodeProps.starColor !== undefined && (
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setProp('starColor', v)} />
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setPropContinuous('starColor', v)} />
)}
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setProp('color', v)} />
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setPropContinuous('color', v)} />
)}
{/* Size */}
{nodeProps.iconSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Icon Size</label>
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setProp('iconSize', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setPropContinuous('iconSize', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.size !== undefined && typeof nodeProps.size === 'string' && (
<div style={sectionGap}>
<label style={labelStyle}>Size</label>
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setProp('size', e.target.value)} style={inputStyle} />
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setPropContinuous('size', e.target.value)} style={inputStyle} />
</div>
)}
@@ -266,7 +266,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
top: style.marginTop as string, right: style.marginRight as string,
bottom: style.marginBottom as string, left: style.marginLeft as string,
}}
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
@@ -274,11 +274,11 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
}}
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
onChange={(side, v) => setPropStyleContinuous(`padding${capSide(side)}`, v)}
/>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
@@ -291,7 +291,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
@@ -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, setProps } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropStyleContinuous } = useNodeProp(selectedId);
const isItalic = style.fontStyle === 'italic';
const isUnderline = style.textDecoration === 'underline';
@@ -96,7 +96,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<div style={{ marginTop: 6 }}>
<NumericUnitInput
value={TEXT_SIZES.some((p) => p.value === style.fontSize) ? '' : ((style.fontSize as string) || '')}
onChange={(v) => setPropStyle('fontSize', v)}
onChange={(v) => setPropStyleContinuous('fontSize', v)}
units={['px', 'em', 'rem', '%']}
placeholder="custom"
testId="text-fontsize-custom"
@@ -179,18 +179,18 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
onChange={(side: SpacingSide, v: string) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop as string, right: style.paddingRight as string, bottom: style.paddingBottom as string, left: style.paddingLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`padding${capitalize(side)}`, v)}
onChange={(side: SpacingSide, v: string) => setPropStyleContinuous(`padding${capitalize(side)}`, v)}
/>
</CollapsibleSection>
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
onChange={(v) => setPropStyleContinuous('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
@@ -203,7 +203,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
onChange={(e) => setPropStyleContinuous('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
+50 -12
View File
@@ -314,25 +314,63 @@ export interface StylePanelProps {
nodeProps: Record<string, any>;
}
/** A continuous edit (colour drag, slider, typing) is one undo "burst": the
* first change is a normal history entry, later changes from the SAME control
* fold into it via `history.merge()`. The burst closes after this many ms with
* no change from that control (the timer restarts on every change, so a long
* drag stays one step), or on pointerup / focusout, or when anything else
* edits through this module. */
export const CONTINUOUS_EDIT_IDLE_MS = 500;
let openBurst: { key: string; timer: ReturnType<typeof setTimeout> } | null = null;
export function closeContinuousBurst() {
if (openBurst) clearTimeout(openBurst.timer);
openBurst = null;
}
if (typeof document !== 'undefined') {
document.addEventListener('pointerup', closeContinuousBurst, true);
document.addEventListener('focusout', closeContinuousBurst, true);
// Ctrl/Cmd shortcuts (undo/redo) must never be followed by a merge into a stale entry.
document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) closeContinuousBurst(); }, true);
}
/** setProp for a continuous input. `controlKey` identifies the control within
* the node (e.g. "style.color"); a change from any other control closes the
* open burst and starts its own history entry. */
export function continuousSetProp(actions: any, nodeId: string, controlKey: string, updater: (p: any) => void) {
const key = `${nodeId}:${controlKey}`;
const continuing = openBurst?.key === key;
if (!continuing) closeContinuousBurst();
else clearTimeout(openBurst!.timer);
(continuing ? actions.history.merge() : actions).setProp(nodeId, updater);
openBurst = { key, timer: setTimeout(closeContinuousBurst, CONTINUOUS_EDIT_IDLE_MS) };
}
/** Plain setProp for a discrete edit: always its own undo step. */
export function discreteSetProp(actions: any, nodeId: string, updater: (p: any) => void) {
closeContinuousBurst();
actions.setProp(nodeId, updater);
}
/* ---------- useNodeProp ----------
Shared setProp/setPropStyle boilerplate repeated across many *StylePanel.tsx
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();
// 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 };
// Default setters are plain: one call = one undo step. Continuous inputs
// (colour pickers, sliders, typing) use the *Continuous variants.
const setProp = useCallback((key: string, value: any) => discreteSetProp(actions, selectedId, (p: any) => { p[key] = value; }), [actions, selectedId]);
const setPropStyle = useCallback((prop: string, value: string) => discreteSetProp(actions, selectedId, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
// Several props / style keys in ONE setProp call = exactly one undo step.
const setProps = useCallback((patch: Record<string, any>) => discreteSetProp(actions, selectedId, (p: any) => { Object.assign(p, patch); }), [actions, selectedId]);
const setPropStyles = useCallback((patch: Record<string, string>) => discreteSetProp(actions, selectedId, (p: any) => { p.style = { ...p.style, ...patch }; }), [actions, selectedId]);
const setPropContinuous = useCallback((key: string, value: any) => continuousSetProp(actions, selectedId, key, (p: any) => { p[key] = value; }), [actions, selectedId]);
const setPropStyleContinuous = useCallback((prop: string, value: string) => continuousSetProp(actions, selectedId, `style.${prop}`, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
return { setProp, setPropStyle, setProps, setPropStyles, setPropContinuous, setPropStyleContinuous };
}
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
@@ -1,7 +1,7 @@
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 { useNodeProp, ColorPickerField, TextInputField, closeContinuousBurst, CONTINUOUS_EDIT_IDLE_MS } from '../../panels/right/styles/shared';
import { AnimVisSection } from '../../panels/right/styles/containerBoxModel';
import { AnimVisSection as MediaAnimVisSection } from '../../panels/right/styles/mediaBoxModel';
@@ -37,8 +37,9 @@ const INITIAL_STATE = JSON.stringify({
let harness: EditorHarness | null = null;
beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(1_000_000); });
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => {
closeContinuousBurst();
vi.useRealTimers();
harness?.unmount();
harness = null;
@@ -46,31 +47,43 @@ afterEach(() => {
const props = () => harness!.query.node('h-1').get().data.props;
/** How many undos until nothing is left (ignores any pre-existing history). */
/** How many undos until nothing is left (history is cleared after mount). */
function undoDepth(): number {
let n = 0;
while (harness!.query.history.canUndo() && n < 50) {
while (harness!.query.history.canUndo() && n < 100) {
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);
function setNative(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
const proto = input instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const tick = (ms: number) => harness!.act(() => { vi.advanceTimersByTime(ms); });
const edit = (el: HTMLInputElement, v: string) => harness!.act(() => { setNative(el, v); });
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 Probe: React.FC = () => {
const { setPropStyleContinuous, setPropContinuous, setPropStyle, setPropStyles } = useNodeProp('h-1');
const st = props().style;
return (
<div>
<ColorPickerField label="Color" value={st.color} onChange={(v) => setPropStyleContinuous('color', v)} />
<input data-testid="slider" type="range" min={0} max={100} value={50}
onChange={(e) => setPropStyleContinuous('opacity', e.target.value)} />
<TextInputField label="Text" value={props().text} onChange={(v) => setPropContinuous('text', v)} />
<button data-testid="preset" onClick={() => setPropStyle('fontWeight', '700')}>preset</button>
<button data-testid="ratio" onClick={() => setPropStyles({ aspectRatio: '16/9', objectFit: 'cover', height: '' })}>ratio</button>
</div>
);
};
const AnimVisProbe: React.FC<{ media?: boolean }> = ({ media }) => {
@@ -85,35 +98,87 @@ function openSection() {
}
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 })); });
const color = () => harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
const textField = () => Array.from(harness!.container.querySelectorAll('input[type="text"]')).pop() as HTMLInputElement; // last: ColorPickerField has its own hex text input
const hex = (i: number) => `#00${(i % 256).toString(16).padStart(2, '0')}00`;
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');
test('a 2-second colour drag (50ms ticks) is one undo step restoring the original', () => {
mountFresh(<Probe />);
for (let i = 1; i <= 40; i++) { edit(color(), hex(i)); tick(50); }
expect(props().style.color).toBe(hex(40));
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'); });
test('typing 30 characters at 100ms intervals is one undo step', () => {
mountFresh(<Probe />);
let t = '';
for (let i = 0; i < 30; i++) { t += 'x'; edit(textField(), t); tick(100); }
expect(props().text).toBe(t);
expect(undoDepth()).toBe(1);
expect(props().text).toBe('Hi');
});
test('a slider drag is one undo step', () => {
mountFresh(<Probe />);
const slider = q('slider') as HTMLInputElement;
for (let i = 1; i <= 30; i++) { edit(slider, String(i)); tick(40); }
expect(undoDepth()).toBe(1);
expect(props().style.opacity).toBeUndefined();
});
test('pointerup closes the burst even when the next edit follows immediately', () => {
mountFresh(<Probe />);
edit(color(), '#111111');
edit(color(), '#222222');
harness!.act(() => { document.dispatchEvent(new Event('pointerup', { bubbles: true })); });
edit(color(), '#333333');
expect(undoDepth()).toBe(2);
});
test('after the idle window the same control starts a new step', () => {
mountFresh(<Probe />);
edit(color(), '#111111');
tick(CONTINUOUS_EDIT_IDLE_MS + 1);
edit(color(), '#222222');
expect(undoDepth()).toBe(2);
});
test('a discrete preset click 100ms after a colour drag is its own step', () => {
mountFresh(<Probe />);
for (let i = 1; i <= 10; i++) { edit(color(), hex(i)); tick(20); }
tick(100);
clickEl(q('preset'));
expect(props().style.fontWeight).toBe('700');
harness!.act(() => { harness!.actions.history.undo(); });
expect(props().style.fontWeight).toBeUndefined();
expect(props().style.color).toBe(hex(10)); // drag still intact
expect(undoDepth()).toBe(1);
});
test('two different controls edited back-to-back are two steps', () => {
mountFresh(<Probe />);
edit(color(), '#111111');
edit(color(), '#121212');
edit(textField(), 'abc');
edit(textField(), 'abcd');
expect(undoDepth()).toBe(2);
});
test('applying an aspect ratio (3 style keys) is one undo step', () => {
mountFresh(<Probe />);
clickEl(q('ratio'));
expect(props().style.aspectRatio).toBe('16/9');
expect(undoDepth()).toBe(1);
expect(props().style.aspectRatio).toBeUndefined();
});
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();
});