2026-07-12 15:13:35 -07:00
|
|
|
import React, { CSSProperties } from 'react';
|
2026-04-05 18:31:16 -07:00
|
|
|
import {
|
|
|
|
|
TEXT_COLORS,
|
|
|
|
|
FONT_FAMILIES,
|
|
|
|
|
TEXT_SIZES,
|
|
|
|
|
FONT_WEIGHTS,
|
2026-07-14 06:41:53 -07:00
|
|
|
LINE_HEIGHTS,
|
|
|
|
|
LETTER_SPACINGS,
|
|
|
|
|
SHADOW_PRESETS,
|
2026-04-05 18:31:16 -07:00
|
|
|
} from '../../../constants/presets';
|
|
|
|
|
import {
|
|
|
|
|
StylePanelProps,
|
|
|
|
|
SectionLabel,
|
|
|
|
|
ColorSwatchGrid,
|
|
|
|
|
PresetButtonGrid,
|
2026-07-14 06:41:53 -07:00
|
|
|
NumericUnitInput,
|
|
|
|
|
CollapsibleSection,
|
|
|
|
|
SpacingControl,
|
|
|
|
|
SpacingSide,
|
|
|
|
|
BorderControl,
|
|
|
|
|
BorderValue,
|
|
|
|
|
buildBorderShorthand,
|
|
|
|
|
AnimationControl,
|
|
|
|
|
VisibilityControl,
|
|
|
|
|
sectionGap,
|
|
|
|
|
labelStyle,
|
2026-07-12 15:13:35 -07:00
|
|
|
useNodeProp,
|
2026-04-05 18:31:16 -07:00
|
|
|
} from './shared';
|
|
|
|
|
|
2026-07-14 06:41:53 -07:00
|
|
|
/* Text-transform is a small fixed enum with no natural home in the shared
|
|
|
|
|
foundation presets (constants/presets.ts is import-only for this
|
|
|
|
|
package), so it lives here as a package-local preset list. */
|
|
|
|
|
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
|
|
|
|
|
{ label: 'None', value: 'none' },
|
|
|
|
|
{ label: 'UPPER', value: 'uppercase' },
|
|
|
|
|
{ label: 'lower', value: 'lowercase' },
|
|
|
|
|
{ label: 'Capitalize', value: 'capitalize' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function capitalize(s: string): string {
|
|
|
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
|
|
|
|
* the {width,style,color} shape BorderControl edits. Only needs to
|
|
|
|
|
* round-trip values this same panel produced via buildBorderShorthand. */
|
|
|
|
|
function parseBorderShorthand(v: string | undefined): BorderValue {
|
|
|
|
|
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
|
|
|
|
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
|
|
|
|
if (!m) return { width: '', style: 'none', color: '#000000' };
|
|
|
|
|
return { width: m[1], style: m[2], color: m[3] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
|
|
|
|
|
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
|
|
|
|
|
function opacityPercent(v: unknown): number {
|
|
|
|
|
if (v === undefined || v === null || v === '') return 100;
|
|
|
|
|
const n = parseFloat(String(v));
|
|
|
|
|
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
/* ---------- TEXT ---------- */
|
|
|
|
|
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
|
|
|
|
const style: CSSProperties = nodeProps.style || {};
|
|
|
|
|
|
2026-07-14 06:41:53 -07:00
|
|
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
|
|
|
|
|
|
|
|
|
const isItalic = style.fontStyle === 'italic';
|
|
|
|
|
const isUnderline = style.textDecoration === 'underline';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Text Color</SectionLabel>
|
|
|
|
|
<ColorSwatchGrid
|
|
|
|
|
colors={TEXT_COLORS}
|
|
|
|
|
activeValue={style.color as string}
|
|
|
|
|
onSelect={(v) => setPropStyle('color', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Font Family</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={FONT_FAMILIES}
|
|
|
|
|
activeValue={style.fontFamily as string}
|
|
|
|
|
onSelect={(v) => setPropStyle('fontFamily', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Text Size</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={TEXT_SIZES}
|
|
|
|
|
activeValue={style.fontSize as string}
|
|
|
|
|
onSelect={(v) => setPropStyle('fontSize', v)}
|
|
|
|
|
/>
|
2026-07-14 06:41:53 -07:00
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
<NumericUnitInput
|
|
|
|
|
value={TEXT_SIZES.some((p) => p.value === style.fontSize) ? '' : ((style.fontSize as string) || '')}
|
|
|
|
|
onChange={(v) => setPropStyle('fontSize', v)}
|
|
|
|
|
units={['px', 'em', 'rem', '%']}
|
|
|
|
|
placeholder="custom"
|
|
|
|
|
testId="text-fontsize-custom"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Font Weight</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={FONT_WEIGHTS}
|
|
|
|
|
activeValue={String(style.fontWeight || '')}
|
|
|
|
|
onSelect={(v) => setPropStyle('fontWeight', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-07-14 06:41:53 -07:00
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Style</SectionLabel>
|
|
|
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`preset-btn ${isItalic ? 'active' : ''}`}
|
|
|
|
|
style={{ flex: 1, fontStyle: 'italic' }}
|
|
|
|
|
onClick={() => setPropStyle('fontStyle', isItalic ? 'normal' : 'italic')}
|
|
|
|
|
title="Italic"
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-italic" /> Italic
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`preset-btn ${isUnderline ? 'active' : ''}`}
|
|
|
|
|
style={{ flex: 1, textDecoration: 'underline' }}
|
|
|
|
|
onClick={() => setPropStyle('textDecoration', isUnderline ? 'none' : 'underline')}
|
|
|
|
|
title="Underline"
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-underline" /> Underline
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Text Transform</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={TEXT_TRANSFORMS}
|
|
|
|
|
activeValue={(style.textTransform as string) || 'none'}
|
|
|
|
|
onSelect={(v) => setPropStyle('textTransform', v === 'none' ? '' : v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Line Height</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={LINE_HEIGHTS}
|
|
|
|
|
activeValue={String(style.lineHeight || '')}
|
|
|
|
|
onSelect={(v) => setPropStyle('lineHeight', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Letter Spacing</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={LETTER_SPACINGS}
|
|
|
|
|
activeValue={String(style.letterSpacing || '')}
|
|
|
|
|
onSelect={(v) => setPropStyle('letterSpacing', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-04-05 18:31:16 -07:00
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Alignment</SectionLabel>
|
|
|
|
|
<div className="preset-grid align-grid">
|
|
|
|
|
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
|
|
|
|
|
<button
|
|
|
|
|
key={a}
|
|
|
|
|
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setPropStyle('textAlign', a)}
|
|
|
|
|
title={a}
|
|
|
|
|
>
|
|
|
|
|
<i className={`fa fa-align-${a}`} />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-07-14 06:41:53 -07:00
|
|
|
|
|
|
|
|
{/* Box model + border/effects + animation/visibility rollout */}
|
|
|
|
|
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
|
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
|
|
|
|
<BorderControl
|
|
|
|
|
value={parseBorderShorthand(style.border as string)}
|
|
|
|
|
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
|
|
|
|
/>
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Shadow</SectionLabel>
|
|
|
|
|
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={0}
|
|
|
|
|
max={100}
|
|
|
|
|
value={opacityPercent(style.opacity)}
|
|
|
|
|
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
<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); }}
|
|
|
|
|
/>
|
|
|
|
|
<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);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
2026-04-05 18:31:16 -07:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|