Merge polish/undo-merging: one undo step per edit (Refs #32)

This commit is contained in:
shadowdao committed 2026-10-07 10:01:59 -07:00
commit 8537cacd01
24 files changed
+444 -207

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, 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.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.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.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.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.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,6 +25,7 @@ import {
labelStyle,
autoTextColor,
useNodeProp,
continuousSetProp,
} 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, setPropContinuous, setPropStyleContinuous } = 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; });
continuousSetProp(actions, selectedId, 'text', (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; });
continuousSetProp(actions, selectedId, 'href', (props: any) => { props.href = v; });
}}
/>
<div className="guided-section">
@@ -129,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>
@@ -143,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>
@@ -162,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>
@@ -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, 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,9 +137,9 @@ 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} />
<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 } from './shared';
import { labelStyle, inputStyle, sectionGap, continuousSetProp, discreteSetProp } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { useLayerFocus } from '../../left/LayerFocusContext';
@@ -21,9 +21,12 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
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 || [])]); });
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; });
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,6 +26,7 @@ import {
btnActiveStyle,
sectionGap,
useNodeProp,
continuousSetProp,
} 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, setPropContinuous, setPropStyleContinuous } = 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) => {
continuousSetProp(actions, selectedId, `field:${index}:${Object.keys(patch).join(',')}`, (props: any) => {
const updated = [...(props.fields || [])];
updated[index] = { ...updated[index], ...patch };
props.fields = updated;
@@ -302,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}
/>
@@ -355,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.'
@@ -366,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>
)}
@@ -376,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>
)}
@@ -384,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>
@@ -414,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' && (
@@ -452,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>
)}
@@ -460,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>
)}
@@ -499,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>
@@ -521,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>
@@ -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: { 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 } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
}));
vi.mock('../../../utils/assets', () => ({
@@ -13,6 +13,7 @@ import {
AspectRatioControl,
FocalPointGrid,
useNodeProp,
continuousSetProp,
} 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, 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
@@ -31,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 (
@@ -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; });
continuousSetProp(actions, selectedId, 'alt', (props: any) => { props.alt = v; });
}}
/>
@@ -65,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
@@ -75,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)}
/>
)}
@@ -110,9 +111,9 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div>
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
};
@@ -19,6 +19,7 @@ import {
smallInputStyle,
sectionGap,
useNodeProp,
continuousSetProp,
} 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, setPropContinuous, setPropStyleContinuous, setPropStyles } = useNodeProp(selectedId);
const style = nodeProps.style || {};
@@ -42,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 (
@@ -73,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
@@ -105,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>
)}
@@ -113,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>
)}
@@ -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) => {
continuousSetProp(actions, selectedId, `images:${index}:caption`, (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) => {
continuousSetProp(actions, selectedId, `slides:${index}:heading`, (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) => {
continuousSetProp(actions, selectedId, `slides:${index}:text`, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], text: e.target.value };
props.slides = updated;
@@ -251,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>
@@ -275,9 +276,9 @@ 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} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
<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 } }),
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 } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
+14 -13
View File
@@ -23,6 +23,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
continuousSetProp,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
@@ -154,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.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;
@@ -248,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>
@@ -265,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' && (
@@ -276,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>
</>
)}
@@ -293,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}>
@@ -352,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>
@@ -394,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>
@@ -408,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>
@@ -422,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>
@@ -435,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,6 +19,7 @@ import {
buildBorderShorthand,
AnimationControl,
VisibilityControl,
continuousSetProp,
} 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, setPropContinuous, setPropStyleContinuous } = 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) => {
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;
@@ -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) => {
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;
@@ -227,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.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 */}
@@ -248,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"
@@ -256,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>
@@ -273,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>
@@ -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, 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>
@@ -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,
continuousSetProp,
} 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, setPropContinuous, setPropStyleContinuous } = 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) => {
continuousSetProp(actions, selectedId, `link:${i}`, (props: any) => {
const updated = [...(props.links || [])];
updated[i] = { ...updated[i], url: e.target.value };
props.links = updated;
@@ -113,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>
)}
@@ -127,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>
)}
@@ -157,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>
)}
@@ -178,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>
)}
@@ -197,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>
)}
@@ -265,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"
@@ -273,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>
@@ -290,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>
@@ -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, 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>
@@ -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>
+50 -3
View File
@@ -314,6 +314,46 @@ 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
@@ -321,9 +361,16 @@ export interface StylePanelProps {
their own local versions). */
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 };
// 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.) ---------- */
@@ -0,0 +1,196 @@
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../editorHarness';
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';
/**
* 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(); });
afterEach(() => {
closeContinuousBurst();
vi.useRealTimers();
harness?.unmount();
harness = null;
});
const props = () => harness!.query.node('h-1').get().data.props;
/** How many undos until nothing is left (history is cleared after mount). */
function undoDepth(): number {
let n = 0;
while (harness!.query.history.canUndo() && n < 100) {
harness!.act(() => { harness!.actions.history.undo(); });
n++;
}
return n;
}
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(); });
}
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 }) => {
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 })); });
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 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('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(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();
});
});