feat(builder): add reusable StylePanel controls (foundation for enh batch)
Adds NumericUnitInput, SizeControl, AspectRatioControl, FocalPointGrid, SpacingControl, BorderControl, AnimationControl, and VisibilityControl to src/panels/right/styles/shared.tsx -- presentational value/onChange controls that upcoming feature panels will import instead of reinventing size, spacing, border, and animation/visibility UI per panel. AnimationControl/VisibilityControl emit the exact prop names (animation/animationDelay, hideOnDesktop/hideOnTablet/hideOnMobile) already consumed by html-export.ts's buildDataAttrs(), verified by reading that file directly. ANIMATIONS matches the export's actual data-animation set (fade-in/slide-up/slide-left/slide-right/zoom-in/bounce) rather than the slide-down variant that doesn't exist in the export. No existing shared.tsx exports were touched, and nothing is wired into any feature panel yet -- that's the downstream feature branches' job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,10 @@ import React, { useState, useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
GRADIENTS,
|
||||
SIZE_PRESETS,
|
||||
ASPECT_RATIOS,
|
||||
SPACING_PRESETS,
|
||||
BORDER_STYLES,
|
||||
} from '../../../constants/presets';
|
||||
import { uploadAsset } from '../../../utils/assets';
|
||||
|
||||
@@ -372,3 +376,379 @@ export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, pr
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
ENH-Foundation: reusable StylePanel controls
|
||||
---------------------------------------------------------------------
|
||||
Presentational (value + onChange) controls shared by upcoming feature
|
||||
panels (size/spacing/border/typography/animation/visibility rollout).
|
||||
Nothing below is wired into any feature panel yet -- that's the job of
|
||||
the downstream feature branches. Each control's onChange API is
|
||||
documented on its props interface; see
|
||||
.superpowers/sdd/task-enh-foundation-report.md for the consolidated list.
|
||||
========================================================================= */
|
||||
|
||||
/* ---------- NumericUnitInput ----------
|
||||
A number field + unit dropdown that parses/holds a CSS length string
|
||||
("16px", "50%", "auto", "") and calls onChange(nextString).
|
||||
- Clearing the number field emits '' (not '0px') -- lets the consumer
|
||||
treat '' as "unset/inherit" instead of forcing a 0 value.
|
||||
- value === 'auto' or '' both display as a blank number field (with the
|
||||
`placeholder` -- default "auto" -- shown as a hint).
|
||||
- Changing the unit re-emits with the current number and the new unit;
|
||||
if the number is blank, changing the unit is a no-op (still emits '').
|
||||
- `units` is configurable (default ['px','%']); the unit <select> is
|
||||
omitted entirely when only one unit is passed. */
|
||||
export function parseCssLength(value: string | undefined, units: string[] = ['px', '%']): { num: string; unit: string } {
|
||||
if (!value || value === 'auto') return { num: '', unit: units[0] };
|
||||
const m = String(value).trim().match(/^(-?\d*\.?\d+)\s*([a-zA-Z%]*)$/);
|
||||
if (!m) return { num: '', unit: units[0] };
|
||||
const unit = m[2] || units[0];
|
||||
return { num: m[1], unit: units.includes(unit) ? unit : units[0] };
|
||||
}
|
||||
|
||||
export interface NumericUnitInputProps {
|
||||
/** CSS length string, e.g. "16px" / "50%" / "auto" / "". */
|
||||
value: string;
|
||||
/** Called with the recombined string, e.g. "16px". Called with '' when the
|
||||
* number field is cleared. */
|
||||
onChange: (value: string) => void;
|
||||
/** Allowed unit list; default ['px', '%']. Unit <select> is hidden when
|
||||
* this has length 1. */
|
||||
units?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
/** Base for the data-testid attrs (`${testId}`, `${testId}-number`,
|
||||
* `${testId}-unit`); default 'numeric-unit'. */
|
||||
testId?: string;
|
||||
}
|
||||
export const NumericUnitInput: React.FC<NumericUnitInputProps> = ({
|
||||
value, onChange, units = ['px', '%'], min, max, step, placeholder = 'auto', testId = 'numeric-unit',
|
||||
}) => {
|
||||
const { num, unit } = parseCssLength(value, units);
|
||||
const emit = (nextNum: string, nextUnit: string) => {
|
||||
if (nextNum === '') { onChange(''); return; }
|
||||
onChange(`${nextNum}${nextUnit}`);
|
||||
};
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4 }} data-testid={testId}>
|
||||
<input
|
||||
type="number"
|
||||
data-testid={`${testId}-number`}
|
||||
value={num}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => emit(e.target.value, unit)}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
{units.length > 1 && (
|
||||
<select
|
||||
data-testid={`${testId}-unit`}
|
||||
value={unit}
|
||||
onChange={(e) => emit(num, e.target.value)}
|
||||
style={{ ...inputStyle, width: 60, flex: 'none' }}
|
||||
>
|
||||
{units.map((u) => <option key={u} value={u}>{u}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- SizeControl ----------
|
||||
Width (or height) editor: preset buttons (25/50/75/100%, Auto, Full) +
|
||||
a custom NumericUnitInput. One dimension per instance -- render two
|
||||
(label="Width" / label="Height") for components needing both, e.g.
|
||||
ImageBlock / VideoBlock sizing. */
|
||||
export interface SizeControlProps {
|
||||
label: string;
|
||||
/** CSS value for this one dimension, e.g. "100%" / "400px" / "auto" / "". */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
units?: string[];
|
||||
}
|
||||
export const SizeControl: React.FC<SizeControlProps> = ({ label, value, onChange, presets = SIZE_PRESETS, units = ['px', '%'] }) => (
|
||||
<div className="guided-section" data-testid="size-control">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<PresetButtonGrid presets={presets} activeValue={value} onSelect={onChange} />
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<NumericUnitInput
|
||||
value={presets.some((p) => p.value === value) ? '' : value}
|
||||
onChange={onChange}
|
||||
units={units}
|
||||
testId="size-control-custom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- AspectRatioControl ----------
|
||||
Preset buttons + a custom "W:H" text entry. Emits a CSS `aspect-ratio`
|
||||
value, e.g. "16 / 9". 'Original' emits ''. Custom entry accepts
|
||||
"16:9" / "16/9" / "16x9" and is parsed on blur or Enter. */
|
||||
export function parseAspectRatioInput(raw: string): string {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$/);
|
||||
if (!m) return '';
|
||||
return `${m[1]} / ${m[2]}`;
|
||||
}
|
||||
function formatAspectRatioForInput(value: string): string {
|
||||
const m = value.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
|
||||
return m ? `${m[1]}:${m[2]}` : '';
|
||||
}
|
||||
export interface AspectRatioControlProps {
|
||||
/** CSS `aspect-ratio` value, e.g. "16 / 9", or '' for Original/unset. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
}
|
||||
export const AspectRatioControl: React.FC<AspectRatioControlProps> = ({ value, onChange, presets = ASPECT_RATIOS }) => {
|
||||
const [custom, setCustom] = useState(() => formatAspectRatioForInput(value));
|
||||
|
||||
const applyCustom = () => onChange(parseAspectRatioInput(custom));
|
||||
|
||||
return (
|
||||
<div className="guided-section" data-testid="aspect-ratio-control">
|
||||
<SectionLabel>Aspect Ratio</SectionLabel>
|
||||
<PresetButtonGrid presets={presets} activeValue={value} onSelect={(v) => { onChange(v); setCustom(''); }} />
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="aspect-ratio-custom-input"
|
||||
value={custom}
|
||||
placeholder="W:H e.g. 16:9"
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
onBlur={applyCustom}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') applyCustom(); }}
|
||||
style={{ ...inputStyle }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- FocalPointGrid ----------
|
||||
3x3 grid of buttons mapping to CSS `object-position` keyword pairs.
|
||||
Used for image crop framing (object-position on an ImageBlock/VideoBlock
|
||||
with object-fit: cover). Highlights the active cell; defaults the
|
||||
highlight to 'center' when value is empty. */
|
||||
export const FOCAL_POINTS: { label: string; value: string }[] = [
|
||||
{ label: '↖', value: 'left top' }, { label: '↑', value: 'center top' }, { label: '↗', value: 'right top' },
|
||||
{ label: '←', value: 'left center' }, { label: '•', value: 'center' }, { label: '→', value: 'right center' },
|
||||
{ label: '↙', value: 'left bottom' }, { label: '↓', value: 'center bottom' }, { label: '↘', value: 'right bottom' },
|
||||
];
|
||||
export interface FocalPointGridProps {
|
||||
/** CSS `object-position` value, e.g. "center" / "left top". */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const FocalPointGrid: React.FC<FocalPointGridProps> = ({ value, onChange }) => {
|
||||
const active = value || 'center';
|
||||
return (
|
||||
<div className="guided-section" data-testid="focal-point-grid">
|
||||
<SectionLabel>Focal Point</SectionLabel>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, width: 96 }}>
|
||||
{FOCAL_POINTS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
data-testid={`focal-point-${p.value.replace(/\s+/g, '-')}`}
|
||||
onClick={() => onChange(p.value)}
|
||||
title={p.value}
|
||||
style={{
|
||||
aspectRatio: '1', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
background: active === p.value ? '#3b82f6' : '#27272a',
|
||||
color: active === p.value ? '#fff' : '#71717a', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- SpacingControl ----------
|
||||
Margin OR padding, per-side, with a link/unlink toggle.
|
||||
API: onChange(side, value) -- called once per side. In linked mode
|
||||
(default), editing the single shared field or clicking a preset calls
|
||||
onChange for all four sides ('top','right','bottom','left') with the
|
||||
same value so the consumer can setPropStyle each side independently
|
||||
(e.g. marginTop/marginRight/marginBottom/marginLeft). In unlinked mode
|
||||
each side's NumericUnitInput calls onChange for just that side. */
|
||||
export type SpacingSide = 'top' | 'right' | 'bottom' | 'left';
|
||||
export interface SpacingSideValues { top?: string; right?: string; bottom?: string; left?: string; }
|
||||
const SPACING_SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
|
||||
export interface SpacingControlProps {
|
||||
label: string;
|
||||
value: SpacingSideValues;
|
||||
/** Called per-side. Linked edits/presets call this once for each of the
|
||||
* 4 sides (in top/right/bottom/left order) with the same value. */
|
||||
onChange: (side: SpacingSide, value: string) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
units?: string[];
|
||||
}
|
||||
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
||||
const [linked, setLinked] = useState(true);
|
||||
|
||||
const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v));
|
||||
const linkedValue = value.top ?? '';
|
||||
const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue);
|
||||
|
||||
return (
|
||||
<div className="guided-section" data-testid="spacing-control">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<button
|
||||
data-testid="spacing-link-toggle"
|
||||
onClick={() => setLinked(!linked)}
|
||||
title={linked ? 'Unlink sides' : 'Link sides'}
|
||||
style={{ background: 'none', border: 'none', color: linked ? '#3b82f6' : '#71717a', cursor: 'pointer', padding: 2 }}
|
||||
>
|
||||
<i className={`fa fa-link${linked ? '' : '-slash'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<PresetButtonGrid presets={presets} activeValue={isAllEqual ? linkedValue : undefined} onSelect={applyToAllSides} />
|
||||
{linked ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<NumericUnitInput value={linkedValue} onChange={applyToAllSides} units={units} testId="spacing-linked" />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||||
{SPACING_SIDES.map((s) => (
|
||||
<div key={s}>
|
||||
<label style={{ ...labelStyle, fontSize: 9 }}>{s}</label>
|
||||
<NumericUnitInput value={value[s] ?? ''} onChange={(v) => onChange(s, v)} units={units} testId={`spacing-${s}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- BorderControl ----------
|
||||
Width (px) + style (none/solid/dashed/dotted) + color. Single onChange
|
||||
receives the merged { width, style, color } object so the consumer can
|
||||
either setProp each part or build a shorthand via buildBorderShorthand()
|
||||
(returns 'none' when width is falsy or style is 'none'). */
|
||||
export interface BorderValue { width: string; style: string; color: string; }
|
||||
export interface BorderControlProps {
|
||||
value: BorderValue;
|
||||
onChange: (value: BorderValue) => void;
|
||||
label?: string;
|
||||
}
|
||||
export const BorderControl: React.FC<BorderControlProps> = ({ value, onChange, label = 'Border' }) => {
|
||||
const set = (patch: Partial<BorderValue>) => onChange({ ...value, ...patch });
|
||||
return (
|
||||
<div className="guided-section" data-testid="border-control">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumericUnitInput value={value.width} onChange={(v) => set({ width: v })} units={['px']} placeholder="0" testId="border-width" />
|
||||
</div>
|
||||
<select
|
||||
data-testid="border-style-select"
|
||||
value={value.style}
|
||||
onChange={(e) => set({ style: e.target.value })}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
>
|
||||
{BORDER_STYLES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<ColorPickerField label="Border Color" value={value.color} onChange={(v) => set({ color: v })} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export function buildBorderShorthand(value: BorderValue): string {
|
||||
if (!value.width || !value.style || value.style === 'none') return 'none';
|
||||
return `${value.width} ${value.style} ${value.color || '#000000'}`;
|
||||
}
|
||||
|
||||
/* ---------- AnimationControl ----------
|
||||
"Entrance animation" picker + optional delay. Emits the EXACT prop names
|
||||
the export machinery already consumes (verified against buildDataAttrs()
|
||||
in src/utils/html-export.ts): `animation` and `animationDelay`.
|
||||
NOTE: the export supports fade-in / slide-up / slide-left / slide-right /
|
||||
zoom-in / bounce (no "slide-down" -- html-export.ts has no such variant;
|
||||
slide-left/slide-right exist instead), so ANIMATIONS reflects that exact
|
||||
set rather than guessing. animationDelay is a plain seconds string (e.g.
|
||||
"0.3") -- html-export.ts's inline script assigns it straight to
|
||||
el.style.animationDelay, so consumers should suffix/accept a unit-bearing
|
||||
string like "0.3s" if that's what they choose to store; this control
|
||||
stores/edits the raw numeric string and leaves unit formatting to the
|
||||
caller (kept as the simplest contract; see report for the exact
|
||||
behavior verified against html-export.ts). */
|
||||
export const ANIMATIONS: { label: string; value: string }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Fade In', value: 'fade-in' },
|
||||
{ label: 'Slide Up', value: 'slide-up' },
|
||||
{ label: 'Slide Left', value: 'slide-left' },
|
||||
{ label: 'Slide Right', value: 'slide-right' },
|
||||
{ label: 'Zoom In', value: 'zoom-in' },
|
||||
{ label: 'Bounce', value: 'bounce' },
|
||||
];
|
||||
export interface AnimationValue { animation: string; animationDelay?: string; }
|
||||
export interface AnimationControlProps {
|
||||
value: AnimationValue;
|
||||
onChange: (value: AnimationValue) => void;
|
||||
}
|
||||
export const AnimationControl: React.FC<AnimationControlProps> = ({ value, onChange }) => {
|
||||
const animation = value.animation || 'none';
|
||||
const animationDelay = value.animationDelay ?? '0';
|
||||
return (
|
||||
<div className="guided-section" data-testid="animation-control">
|
||||
<SectionLabel>Entrance Animation</SectionLabel>
|
||||
<PresetButtonGrid presets={ANIMATIONS} activeValue={animation} onSelect={(v) => onChange({ animation: v, animationDelay })} />
|
||||
{animation !== 'none' && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={labelStyle}>Delay (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="animation-delay-input"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={animationDelay}
|
||||
onChange={(e) => onChange({ animation, animationDelay: e.target.value })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- VisibilityControl ----------
|
||||
Hide on Desktop / Tablet / Mobile toggles. Emits the EXACT prop names
|
||||
the export machinery already consumes (verified against buildDataAttrs()
|
||||
in src/utils/html-export.ts): `hideOnDesktop`, `hideOnTablet`,
|
||||
`hideOnMobile` (each rendered as data-hide-desktop/tablet/mobile when
|
||||
truthy). Single onChange receives the merged object. */
|
||||
export interface VisibilityValue { hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; }
|
||||
export interface VisibilityControlProps {
|
||||
value: VisibilityValue;
|
||||
onChange: (value: VisibilityValue) => void;
|
||||
}
|
||||
const VISIBILITY_KEYS: (keyof VisibilityValue)[] = ['hideOnDesktop', 'hideOnTablet', 'hideOnMobile'];
|
||||
export const VisibilityControl: React.FC<VisibilityControlProps> = ({ value, onChange }) => (
|
||||
<div className="guided-section" data-testid="visibility-control">
|
||||
<SectionLabel>Visibility</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{VISIBILITY_KEYS.map((key) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`visibility-${key}`}
|
||||
checked={!!value[key]}
|
||||
onChange={() => onChange({ ...value, [key]: !value[key] })}
|
||||
/>
|
||||
Hide on {key.replace('hideOn', '')}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user