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:
233
craft/src/ui/AdvancedTab.tsx
Normal file
233
craft/src/ui/AdvancedTab.tsx
Normal 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}><{t}></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>
|
||||
);
|
||||
};
|
||||
218
craft/src/ui/BorderControl.tsx
Normal file
218
craft/src/ui/BorderControl.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
craft/src/ui/SettingsTabs.tsx
Normal file
26
craft/src/ui/SettingsTabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
141
craft/src/ui/SpacingInput.tsx
Normal file
141
craft/src/ui/SpacingInput.tsx
Normal 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}`;
|
||||
}
|
||||
221
craft/src/ui/TypographyControl.tsx
Normal file
221
craft/src/ui/TypographyControl.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user