Add Craft.js site builder (v2) - complete rebuild from GrapesJS

Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
import React, { CSSProperties } from 'react';
import { SpacingInput, SpacingValue, parseSpacingShorthand, spacingToShorthand } from './SpacingInput';
interface AdvancedTabProps {
style: CSSProperties;
onStyleChange: (updates: CSSProperties) => void;
/** Optional: current CSS ID value */
cssId?: string;
onCssIdChange?: (id: string) => void;
/** Optional: current CSS class value */
cssClass?: string;
onCssClassChange?: (cls: string) => void;
/** Optional: show HTML tag selector (for containers) */
showTagSelector?: boolean;
tag?: string;
onTagChange?: (tag: string) => void;
/** Responsive visibility */
hideOnDesktop?: boolean;
onHideOnDesktopChange?: (hide: boolean) => void;
hideOnTablet?: boolean;
onHideOnTabletChange?: (hide: boolean) => void;
hideOnMobile?: boolean;
onHideOnMobileChange?: (hide: boolean) => void;
/** Entrance animation */
animation?: string;
onAnimationChange?: (anim: string) => void;
animationDelay?: string;
onAnimationDelayChange?: (delay: string) => void;
}
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const selectStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const TAG_OPTIONS = ['div', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav'];
export const AdvancedTab: React.FC<AdvancedTabProps> = ({
style,
onStyleChange,
cssId = '',
onCssIdChange,
cssClass = '',
onCssClassChange,
showTagSelector = false,
tag,
onTagChange,
hideOnDesktop = false,
onHideOnDesktopChange,
hideOnTablet = false,
onHideOnTabletChange,
hideOnMobile = false,
onHideOnMobileChange,
animation = 'none',
onAnimationChange,
animationDelay = '0',
onAnimationDelayChange,
}) => {
// Parse margin and padding from style
const margin: SpacingValue = {
top: (style.marginTop as string) || '0',
right: (style.marginRight as string) || '0',
bottom: (style.marginBottom as string) || '0',
left: (style.marginLeft as string) || '0',
};
// If there's a shorthand margin, parse it
const marginShorthand = style.margin as string | undefined;
const resolvedMargin = marginShorthand ? parseSpacingShorthand(marginShorthand) : margin;
const padding: SpacingValue = {
top: (style.paddingTop as string) || '0',
right: (style.paddingRight as string) || '0',
bottom: (style.paddingBottom as string) || '0',
left: (style.paddingLeft as string) || '0',
};
const paddingShorthand = style.padding as string | undefined;
const resolvedPadding = paddingShorthand ? parseSpacingShorthand(paddingShorthand) : padding;
const handleMarginChange = (val: SpacingValue) => {
onStyleChange({
margin: spacingToShorthand(val),
marginTop: undefined,
marginRight: undefined,
marginBottom: undefined,
marginLeft: undefined,
} as CSSProperties);
};
const handlePaddingChange = (val: SpacingValue) => {
onStyleChange({
padding: spacingToShorthand(val),
paddingTop: undefined,
paddingRight: undefined,
paddingBottom: undefined,
paddingLeft: undefined,
} as CSSProperties);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Margin */}
<SpacingInput
label="Margin"
value={resolvedMargin}
onChange={handleMarginChange}
/>
{/* Padding */}
<SpacingInput
label="Padding"
value={resolvedPadding}
onChange={handlePaddingChange}
/>
{/* HTML Tag (for containers) */}
{showTagSelector && onTagChange && (
<div>
<label style={labelStyle}>HTML Tag</label>
<select
value={tag || 'div'}
onChange={(e) => onTagChange(e.target.value)}
style={selectStyle}
>
{TAG_OPTIONS.map((t) => (
<option key={t} value={t}>&lt;{t}&gt;</option>
))}
</select>
</div>
)}
{/* CSS ID */}
{onCssIdChange && (
<div>
<label style={labelStyle}>CSS ID</label>
<input
type="text"
value={cssId}
onChange={(e) => onCssIdChange(e.target.value.replace(/\s/g, '-'))}
placeholder="my-element"
style={inputStyle}
/>
</div>
)}
{/* CSS Class */}
{onCssClassChange && (
<div>
<label style={labelStyle}>CSS Class</label>
<input
type="text"
value={cssClass}
onChange={(e) => onCssClassChange(e.target.value)}
placeholder="class-one class-two"
style={inputStyle}
/>
</div>
)}
{/* Responsive Visibility */}
{(onHideOnDesktopChange || onHideOnTabletChange || onHideOnMobileChange) && (
<div>
<label style={labelStyle}>Visibility</label>
<div style={{ display: 'flex', gap: 8 }}>
{([
{ key: 'hideOnDesktop' as const, label: 'Desktop', icon: 'fa-desktop', value: hideOnDesktop, onChange: onHideOnDesktopChange },
{ key: 'hideOnTablet' as const, label: 'Tablet', icon: 'fa-tablet', value: hideOnTablet, onChange: onHideOnTabletChange },
{ key: 'hideOnMobile' as const, label: 'Mobile', icon: 'fa-mobile', value: hideOnMobile, onChange: onHideOnMobileChange },
] as const).map(({ key, label, icon, value, onChange }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!value}
onChange={(e) => onChange && onChange(!e.target.checked)}
/>
<i className={`fa ${icon}`} />
<span>{label}</span>
</label>
))}
</div>
</div>
)}
{/* Entrance Animation */}
{onAnimationChange && (
<div>
<label style={labelStyle}>Entrance Animation</label>
<select
value={animation || 'none'}
onChange={(e) => onAnimationChange(e.target.value)}
style={selectStyle}
>
<option value="none">None</option>
<option value="fade-in">Fade In</option>
<option value="slide-up">Slide Up</option>
<option value="slide-left">Slide from Left</option>
<option value="slide-right">Slide from Right</option>
<option value="zoom-in">Zoom In</option>
<option value="bounce">Bounce</option>
</select>
{animation && animation !== 'none' && onAnimationDelayChange && (
<div style={{ marginTop: 6 }}>
<label style={labelStyle}>Delay</label>
<select
value={animationDelay || '0'}
onChange={(e) => onAnimationDelayChange(e.target.value)}
style={selectStyle}
>
<option value="0">None</option>
<option value="0.2s">0.2s</option>
<option value="0.4s">0.4s</option>
<option value="0.6s">0.6s</option>
<option value="0.8s">0.8s</option>
<option value="1s">1s</option>
</select>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { CSSProperties } from 'react';
interface BorderControlProps {
style: CSSProperties;
onChange: (updates: CSSProperties) => void;
}
const BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted'] as const;
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
};
const btnStyle = (active: boolean): React.CSSProperties => ({
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
flex: 1, textTransform: 'capitalize',
});
const sideLabel: React.CSSProperties = {
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
letterSpacing: '0.5px',
};
interface FourSidedValue {
top: string;
right: string;
bottom: string;
left: string;
}
function parseFourSided(val: string | undefined): FourSidedValue {
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
const parts = val.trim().split(/\s+/);
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
}
function fourSidedToString(val: FourSidedValue): string {
const { top, right, bottom, left } = val;
if (top === right && right === bottom && bottom === left) return top || '0';
if (top === bottom && right === left) return `${top} ${right}`;
if (right === left) return `${top} ${right} ${bottom}`;
return `${top} ${right} ${bottom} ${left}`;
}
function getNumeric(val: string): string {
return val.replace(/[^0-9.]/g, '') || '0';
}
export const BorderControl: React.FC<BorderControlProps> = ({ style, onChange }) => {
const [widthLinked, setWidthLinked] = useState(true);
const [radiusLinked, setRadiusLinked] = useState(true);
const currentBorderStyle = (style.borderStyle as string) || 'none';
const currentBorderColor = (style.borderColor as string) || '#3f3f46';
// Border width as 4-sided
const borderWidth = parseFourSided(style.borderWidth as string);
// Border radius as 4 corners (TL, TR, BR, BL)
const borderRadius = parseFourSided(style.borderRadius as string);
const handleWidthChange = (side: keyof FourSidedValue, raw: string) => {
const num = raw.replace(/[^0-9.]/g, '');
const newVal = num ? `${num}px` : '0';
let updated: FourSidedValue;
if (widthLinked) {
updated = { top: newVal, right: newVal, bottom: newVal, left: newVal };
} else {
updated = { ...borderWidth, [side]: newVal };
}
onChange({ borderWidth: fourSidedToString(updated) });
};
const handleRadiusChange = (corner: keyof FourSidedValue, raw: string) => {
const num = raw.replace(/[^0-9.]/g, '');
const newVal = num ? `${num}px` : '0';
let updated: FourSidedValue;
if (radiusLinked) {
updated = { top: newVal, right: newVal, bottom: newVal, left: newVal };
} else {
updated = { ...borderRadius, [corner]: newVal };
}
onChange({ borderRadius: fourSidedToString(updated) });
};
const widthSides: { key: keyof FourSidedValue; label: string }[] = [
{ key: 'top', label: 'T' },
{ key: 'right', label: 'R' },
{ key: 'bottom', label: 'B' },
{ key: 'left', label: 'L' },
];
const radiusCorners: { key: keyof FourSidedValue; label: string }[] = [
{ key: 'top', label: 'TL' },
{ key: 'right', label: 'TR' },
{ key: 'bottom', label: 'BR' },
{ key: 'left', label: 'BL' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Border Style */}
<div>
<label style={labelStyle}>Border Style</label>
<div style={{ display: 'flex', gap: 4 }}>
{BORDER_STYLES.map((bs) => (
<button
key={bs}
onClick={() => onChange({ borderStyle: bs })}
style={btnStyle(currentBorderStyle === bs)}
>{bs}</button>
))}
</div>
</div>
{/* Border Width (4-sided) */}
{currentBorderStyle !== 'none' && (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>Border Width</label>
<button
onClick={() => setWidthLinked(!widthLinked)}
title={widthLinked ? 'Unlink sides' : 'Link all sides'}
style={{
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: widthLinked ? '#3b82f6' : '#27272a',
color: widthLinked ? '#fff' : '#71717a',
}}
>
<i className={`fa ${widthLinked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{widthSides.map((s) => (
<div key={s.key}>
<input
type="text"
value={getNumeric(borderWidth[s.key])}
onChange={(e) => handleWidthChange(s.key, e.target.value)}
style={inputStyle}
/>
<div style={sideLabel}>{s.label}</div>
</div>
))}
</div>
</div>
)}
{/* Border Color */}
{currentBorderStyle !== 'none' && (
<div>
<label style={labelStyle}>Border Color</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={currentBorderColor}
onChange={(e) => onChange({ borderColor: e.target.value })}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={currentBorderColor}
onChange={(e) => onChange({ borderColor: e.target.value })}
style={{ ...inputStyle, flex: 1, textAlign: 'left' }}
placeholder="#000000"
/>
</div>
</div>
)}
{/* Border Radius (4 corners) */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>Border Radius</label>
<button
onClick={() => setRadiusLinked(!radiusLinked)}
title={radiusLinked ? 'Unlink corners' : 'Link all corners'}
style={{
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: radiusLinked ? '#3b82f6' : '#27272a',
color: radiusLinked ? '#fff' : '#71717a',
}}
>
<i className={`fa ${radiusLinked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{radiusCorners.map((c) => (
<div key={c.key}>
<input
type="text"
value={getNumeric(borderRadius[c.key])}
onChange={(e) => handleRadiusChange(c.key, e.target.value)}
style={inputStyle}
/>
<div style={sideLabel}>{c.label}</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import React, { useState } from 'react';
interface SettingsTabsProps {
general: React.ReactNode;
style: React.ReactNode;
advanced: React.ReactNode;
}
export const SettingsTabs: React.FC<SettingsTabsProps> = ({ general, style, advanced }) => {
const [tab, setTab] = useState<'general' | 'style' | 'advanced'>('general');
return (
<div>
<div className="settings-tabs">
<button className={tab === 'general' ? 'active' : ''} onClick={() => setTab('general')}>General</button>
<button className={tab === 'style' ? 'active' : ''} onClick={() => setTab('style')}>Style</button>
<button className={tab === 'advanced' ? 'active' : ''} onClick={() => setTab('advanced')}>Advanced</button>
</div>
<div className="settings-content">
{tab === 'general' && general}
{tab === 'style' && style}
{tab === 'advanced' && advanced}
</div>
</div>
);
};

View File

@@ -0,0 +1,141 @@
import React, { useState } from 'react';
export interface SpacingValue {
top: string;
right: string;
bottom: string;
left: string;
}
interface SpacingInputProps {
label: string;
value: SpacingValue;
onChange: (value: SpacingValue) => void;
}
const UNITS = ['px', 'em', '%'] as const;
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
};
const sideLabel: React.CSSProperties = {
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
letterSpacing: '0.5px',
};
function parseValue(val: string): { num: string; unit: string } {
const match = val.match(/^(-?\d*\.?\d+)\s*(px|em|%|rem)?$/);
if (match) return { num: match[1], unit: match[2] || 'px' };
return { num: val.replace(/[^0-9.-]/g, '') || '0', unit: 'px' };
}
export const SpacingInput: React.FC<SpacingInputProps> = ({ label, value, onChange }) => {
const [linked, setLinked] = useState(false);
const [unit, setUnit] = useState<string>(() => parseValue(value.top).unit || 'px');
const handleSideChange = (side: keyof SpacingValue, raw: string) => {
const numericPart = raw.replace(/[^0-9.-]/g, '');
const newVal = numericPart ? `${numericPart}${unit}` : '0';
if (linked) {
onChange({ top: newVal, right: newVal, bottom: newVal, left: newVal });
} else {
onChange({ ...value, [side]: newVal });
}
};
const handleUnitChange = (newUnit: string) => {
setUnit(newUnit);
// Re-apply current numeric values with new unit
const updated: SpacingValue = { top: '', right: '', bottom: '', left: '' };
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
const { num } = parseValue(value[side]);
updated[side] = num && num !== '0' ? `${num}${newUnit}` : '0';
}
onChange(updated);
};
const sides: { key: keyof SpacingValue; label: string }[] = [
{ key: 'top', label: 'T' },
{ key: 'right', label: 'R' },
{ key: 'bottom', label: 'B' },
{ key: 'left', label: 'L' },
];
return (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Unit selector */}
<div style={{ display: 'flex', gap: 2 }}>
{UNITS.map((u) => (
<button
key={u}
onClick={() => handleUnitChange(u)}
style={{
padding: '1px 5px', fontSize: 9, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: unit === u ? '#3b82f6' : '#27272a',
color: unit === u ? '#fff' : '#71717a',
}}
>{u}</button>
))}
</div>
{/* Link toggle */}
<button
onClick={() => setLinked(!linked)}
title={linked ? 'Unlink sides' : 'Link all sides'}
style={{
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: linked ? '#3b82f6' : '#27272a',
color: linked ? '#fff' : '#71717a',
}}
>
<i className={`fa ${linked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{sides.map((s) => (
<div key={s.key}>
<input
type="text"
value={parseValue(value[s.key]).num}
onChange={(e) => handleSideChange(s.key, e.target.value)}
style={inputStyle}
/>
<div style={sideLabel}>{s.label}</div>
</div>
))}
</div>
</div>
);
};
/** Parse a CSS shorthand like "10px 20px 10px 20px" or "10px" into SpacingValue */
export function parseSpacingShorthand(val: string | undefined): SpacingValue {
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
const parts = val.trim().split(/\s+/);
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
}
/** Convert SpacingValue back to CSS shorthand */
export function spacingToShorthand(val: SpacingValue): string {
const { top, right, bottom, left } = val;
if (top === right && right === bottom && bottom === left) return top || '0';
if (top === bottom && right === left) return `${top} ${right}`;
if (right === left) return `${top} ${right} ${bottom}`;
return `${top} ${right} ${bottom} ${left}`;
}

View File

@@ -0,0 +1,221 @@
import React from 'react';
import { CSSProperties } from 'react';
interface TypographyControlProps {
style: CSSProperties;
onChange: (updates: CSSProperties) => void;
}
const FONT_FAMILIES = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
];
const FONT_WEIGHTS = [
{ label: 'Light', value: '300' },
{ label: 'Normal', value: '400' },
{ label: 'Medium', value: '500' },
{ label: 'Semi', value: '600' },
{ label: 'Bold', value: '700' },
];
const SIZE_UNITS = ['px', 'em', 'rem'] as const;
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
{ label: 'Aa', value: 'none' },
{ label: 'AA', value: 'uppercase' },
{ label: 'aa', value: 'lowercase' },
{ label: 'Aa', value: 'capitalize' },
];
const TEXT_ALIGNS = ['left', 'center', 'right', 'justify'] as const;
const ALIGN_ICONS: Record<string, string> = {
left: 'fa-align-left',
center: 'fa-align-center',
right: 'fa-align-right',
justify: 'fa-align-justify',
};
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const selectStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const btnStyle = (active: boolean): React.CSSProperties => ({
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
});
function parseSizeValue(val: string | number | undefined): { num: string; unit: string } {
if (val === undefined || val === '') return { num: '', unit: 'px' };
const s = String(val);
const match = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem|%)?$/);
if (match) return { num: match[1], unit: match[2] || 'px' };
return { num: s.replace(/[^0-9.-]/g, ''), unit: 'px' };
}
export const TypographyControl: React.FC<TypographyControlProps> = ({ style, onChange }) => {
const currentFamily = (style.fontFamily as string) || '';
const currentWeight = String(style.fontWeight || '');
const currentAlign = (style.textAlign as string) || '';
const currentTransform = (style.textTransform as string) || 'none';
const currentColor = (style.color as string) || '#1f2937';
const fontSize = parseSizeValue(style.fontSize);
const lineHeight = String(style.lineHeight || '');
const letterSpacing = String(style.letterSpacing || '');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Font Family */}
<div>
<label style={labelStyle}>Font Family</label>
<select
value={currentFamily}
onChange={(e) => onChange({ fontFamily: e.target.value })}
style={selectStyle}
>
<option value="">Default</option>
{FONT_FAMILIES.map((f) => (
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>
))}
</select>
</div>
{/* Font Weight */}
<div>
<label style={labelStyle}>Font Weight</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{FONT_WEIGHTS.map((w) => (
<button
key={w.value}
onClick={() => onChange({ fontWeight: w.value })}
style={{ ...btnStyle(currentWeight === w.value), flex: 1 }}
>{w.label}</button>
))}
</div>
</div>
{/* Font Size + Line Height row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={labelStyle}>Font Size</label>
<div style={{ display: 'flex', gap: 2 }}>
<input
type="text"
value={fontSize.num}
onChange={(e) => {
const num = e.target.value.replace(/[^0-9.]/g, '');
onChange({ fontSize: num ? `${num}${fontSize.unit}` : '' });
}}
placeholder="16"
style={{ ...inputStyle, flex: 1 }}
/>
<select
value={fontSize.unit}
onChange={(e) => {
const newUnit = e.target.value;
onChange({ fontSize: fontSize.num ? `${fontSize.num}${newUnit}` : '' });
}}
style={{ ...selectStyle, width: 52, padding: '4px 2px', fontSize: 10 }}
>
{SIZE_UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</div>
<div>
<label style={labelStyle}>Line Height</label>
<input
type="text"
value={lineHeight}
onChange={(e) => onChange({ lineHeight: e.target.value })}
placeholder="1.6"
style={inputStyle}
/>
</div>
</div>
{/* Letter Spacing */}
<div>
<label style={labelStyle}>Letter Spacing</label>
<input
type="text"
value={letterSpacing}
onChange={(e) => onChange({ letterSpacing: e.target.value })}
placeholder="0px"
style={inputStyle}
/>
</div>
{/* Text Transform */}
<div>
<label style={labelStyle}>Text Transform</label>
<div style={{ display: 'flex', gap: 4 }}>
{TEXT_TRANSFORMS.map((t, i) => (
<button
key={`${t.value}-${i}`}
onClick={() => onChange({ textTransform: t.value as CSSProperties['textTransform'] })}
style={{ ...btnStyle(currentTransform === t.value), flex: 1, fontStyle: t.value === 'none' ? 'italic' : undefined }}
title={t.value}
>{t.label}</button>
))}
</div>
</div>
{/* Text Align */}
<div>
<label style={labelStyle}>Text Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{TEXT_ALIGNS.map((a) => (
<button
key={a}
onClick={() => onChange({ textAlign: a as CSSProperties['textAlign'] })}
style={{ ...btnStyle(currentAlign === a), flex: 1 }}
title={a}
>
<i className={`fa ${ALIGN_ICONS[a]}`} />
</button>
))}
</div>
</div>
{/* Color */}
<div>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={currentColor}
onChange={(e) => onChange({ color: e.target.value })}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={currentColor}
onChange={(e) => onChange({ color: e.target.value })}
style={{ ...inputStyle, flex: 1 }}
placeholder="#000000"
/>
</div>
</div>
</div>
);
};