Spaces:
Running
Running
| import { useState, useCallback } from 'react'; | |
| import { VisualElement } from '../../types/blocks'; | |
| import { useEditorStore } from '../../store/editorStore'; | |
| import { | |
| Plus, Trash2, Palette, Type, Move, Square, | |
| Bold, Italic, AlignLeft, AlignCenter, AlignRight, | |
| MousePointer, EyeOff, Maximize2, Grid3X3, Play, RotateCcw, | |
| Underline, Strikethrough | |
| } from 'lucide-react'; | |
| interface PropertyPanelProps { | |
| element: VisualElement | null; | |
| } | |
| // Spacing quick buttons | |
| const SPACING_PRESETS = ['0', '4px', '8px', '12px', '16px', '24px', '32px', '48px']; | |
| const RADIUS_PRESETS = [ | |
| { label: 'None', value: '0' }, { label: 'Small', value: '4px' }, | |
| { label: 'Medium', value: '8px' }, { label: 'Large', value: '16px' }, | |
| { label: 'Round', value: '50%' }, | |
| ]; | |
| interface CatProp { | |
| key: string; label: string; type: string; | |
| options?: { label: string; value: string }[]; suffix?: string; | |
| } | |
| const CSS_CATEGORIES: { name: string; icon: any; properties: CatProp[] }[] = [ | |
| { | |
| name: 'Colors', icon: Palette, properties: [ | |
| { key: 'backgroundColor', label: 'Background', type: 'color' }, | |
| { key: 'color', label: 'Text Color', type: 'color' }, | |
| { key: 'opacity', label: 'Opacity', type: 'number', suffix: '' }, | |
| ] | |
| }, | |
| { | |
| name: 'Text', icon: Type, properties: [ | |
| { key: 'fontSize', label: 'Size', type: 'px', suffix: 'px' }, | |
| { key: 'fontWeight', label: 'Weight', type: 'select', options: [ | |
| { label: 'Normal', value: 'normal' }, { label: 'Bold', value: 'bold' }, | |
| { label: 'Light', value: '300' }, { label: 'Medium', value: '500' }, | |
| { label: 'Semi Bold', value: '600' }, { label: 'Black', value: '900' }, | |
| ]}, | |
| { key: 'fontFamily', label: 'Font', type: 'select', options: [ | |
| { label: 'Default', value: '' }, { label: 'Arial', value: 'Arial, sans-serif' }, | |
| { label: 'Georgia', value: 'Georgia, serif' }, { label: 'Verdana', value: 'Verdana, sans-serif' }, | |
| { label: 'Monospace', value: "'Courier New', monospace" }, | |
| ]}, | |
| { key: 'textAlign', label: 'Align', type: 'select', options: [ | |
| { label: 'Left', value: 'left' }, { label: 'Center', value: 'center' }, { label: 'Right', value: 'right' }, | |
| ]}, | |
| { key: 'lineHeight', label: 'Line Height', type: 'number', suffix: '' }, | |
| { key: 'letterSpacing', label: 'Letter Space', type: 'px', suffix: 'px' }, | |
| { key: 'textDecoration', label: 'Decoration', type: 'select', options: [ | |
| { label: 'None', value: 'none' }, { label: 'Underline', value: 'underline' }, | |
| { label: 'Line Through', value: 'line-through' }, | |
| ]}, | |
| ] | |
| }, | |
| { | |
| name: 'Spacing & Size', icon: Move, properties: [ | |
| { key: 'padding', label: 'All Padding', type: 'text', suffix: '' }, | |
| { key: 'paddingTop', label: 'Padding Top', type: 'px', suffix: 'px' }, | |
| { key: 'paddingBottom', label: 'Padding Bottom', type: 'px', suffix: 'px' }, | |
| { key: 'paddingLeft', label: 'Padding Left', type: 'px', suffix: 'px' }, | |
| { key: 'paddingRight', label: 'Padding Right', type: 'px', suffix: 'px' }, | |
| { key: 'margin', label: 'All Margin', type: 'text', suffix: '' }, | |
| { key: 'marginTop', label: 'Margin Top', type: 'px', suffix: 'px' }, | |
| { key: 'marginBottom', label: 'Margin Bottom', type: 'px', suffix: 'px' }, | |
| { key: 'marginLeft', label: 'Margin Left', type: 'px', suffix: 'px' }, | |
| { key: 'marginRight', label: 'Margin Right', type: 'px', suffix: 'px' }, | |
| { key: 'width', label: 'Width', type: 'text', suffix: '' }, | |
| { key: 'height', label: 'Height', type: 'text', suffix: '' }, | |
| { key: 'minWidth', label: 'Min Width', type: 'text', suffix: '' }, | |
| { key: 'minHeight', label: 'Min Height', type: 'text', suffix: '' }, | |
| { key: 'maxWidth', label: 'Max Width', type: 'text', suffix: '' }, | |
| { key: 'maxHeight', label: 'Max Height', type: 'text', suffix: '' }, | |
| ] | |
| }, | |
| { | |
| name: 'Layout', icon: Grid3X3, properties: [ | |
| { key: 'display', label: 'Display', type: 'select', options: [ | |
| { label: 'Default', value: '' }, { label: 'Block', value: 'block' }, | |
| { label: 'Inline', value: 'inline' }, { label: 'Inline Block', value: 'inline-block' }, | |
| { label: 'Flex', value: 'flex' }, { label: 'Grid', value: 'grid' }, | |
| { label: 'None', value: 'none' }, | |
| ]}, | |
| { key: 'flexDirection', label: 'Flex Direction', type: 'select', options: [ | |
| { label: 'Row', value: 'row' }, { label: 'Column', value: 'column' }, | |
| ]}, | |
| { key: 'justifyContent', label: 'Justify', type: 'select', options: [ | |
| { label: 'Start', value: 'flex-start' }, { label: 'Center', value: 'center' }, | |
| { label: 'End', value: 'flex-end' }, { label: 'Space Between', value: 'space-between' }, | |
| ]}, | |
| { key: 'alignItems', label: 'Align', type: 'select', options: [ | |
| { label: 'Start', value: 'flex-start' }, { label: 'Center', value: 'center' }, | |
| { label: 'End', value: 'flex-end' }, { label: 'Stretch', value: 'stretch' }, | |
| ]}, | |
| { key: 'gap', label: 'Gap', type: 'px', suffix: 'px' }, | |
| { key: 'position', label: 'Position', type: 'select', options: [ | |
| { label: 'Static', value: 'static' }, { label: 'Relative', value: 'relative' }, | |
| { label: 'Absolute', value: 'absolute' }, { label: 'Fixed', value: 'fixed' }, | |
| { label: 'Sticky', value: 'sticky' }, | |
| ]}, | |
| { key: 'top', label: 'Top', type: 'px', suffix: 'px' }, | |
| { key: 'left', label: 'Left', type: 'px', suffix: 'px' }, | |
| { key: 'right', label: 'Right', type: 'px', suffix: 'px' }, | |
| { key: 'bottom', label: 'Bottom', type: 'px', suffix: 'px' }, | |
| { key: 'overflow', label: 'Overflow', type: 'select', options: [ | |
| { label: 'Visible', value: 'visible' }, { label: 'Hidden', value: 'hidden' }, | |
| { label: 'Auto', value: 'auto' }, { label: 'Scroll', value: 'scroll' }, | |
| ]}, | |
| ] | |
| }, | |
| { | |
| name: 'Border & Rounding', icon: Square, properties: [ | |
| { key: 'borderWidth', label: 'Border Width', type: 'px', suffix: 'px' }, | |
| { key: 'borderStyle', label: 'Border Style', type: 'select', options: [ | |
| { label: 'None', value: 'none' }, { label: 'Solid', value: 'solid' }, | |
| { label: 'Dashed', value: 'dashed' }, { label: 'Dotted', value: 'dotted' }, | |
| ]}, | |
| { key: 'borderColor', label: 'Border Color', type: 'color' }, | |
| { key: 'borderRadius', label: 'Round Corners', type: 'px', suffix: 'px' }, | |
| { key: 'boxShadow', label: 'Shadow', type: 'select', options: [ | |
| { label: 'None', value: 'none' }, { label: 'Small', value: '0 1px 3px rgba(0,0,0,0.12)' }, | |
| { label: 'Medium', value: '0 4px 12px rgba(0,0,0,0.15)' }, | |
| { label: 'Large', value: '0 10px 40px rgba(0,0,0,0.2)' }, | |
| { label: 'Glow', value: '0 0 20px rgba(99,102,241,0.3)' }, | |
| ]}, | |
| ] | |
| }, | |
| { | |
| name: 'Effects', icon: EyeOff, properties: [ | |
| { key: 'transition', label: 'Transition', type: 'select', options: [ | |
| { label: 'None', value: 'none' }, { label: 'Fast', value: 'all 0.15s ease' }, | |
| { label: 'Normal', value: 'all 0.3s ease' }, { label: 'Slow', value: 'all 0.5s ease' }, | |
| ]}, | |
| { key: 'transform', label: 'Transform', type: 'select', options: [ | |
| { label: 'None', value: 'none' }, { label: 'Scale 1.1', value: 'scale(1.1)' }, | |
| { label: 'Rotate 45°', value: 'rotate(45deg)' }, { label: 'Rotate 90°', value: 'rotate(90deg)' }, | |
| { label: 'Flip X', value: 'scaleX(-1)' }, { label: 'Flip Y', value: 'scaleY(-1)' }, | |
| ]}, | |
| { key: 'cursor', label: 'Cursor', type: 'select', options: [ | |
| { label: 'Default', value: 'default' }, { label: 'Pointer', value: 'pointer' }, | |
| { label: 'Grab', value: 'grab' }, { label: 'Move', value: 'move' }, | |
| ]}, | |
| ] | |
| }, | |
| ]; | |
| export default function PropertyPanel({ element }: PropertyPanelProps) { | |
| const { updateVisualElement, removeVisualElement, setSelectedElement } = useEditorStore(); | |
| const [expandedCat, setExpandedCat] = useState<string>('Colors'); | |
| const [addingToCat, setAddingToCat] = useState<string | null>(null); | |
| if (!element) { | |
| return ( | |
| <div className="p-4 text-center text-surface-400 text-sm"> | |
| <MousePointer className="w-8 h-8 mx-auto mb-2 opacity-50" /> | |
| <p>Select an element on the canvas</p> | |
| <p className="text-xs mt-1">or in the element tree</p> | |
| </div> | |
| ); | |
| } | |
| const updateProp = (key: string, value: any) => | |
| updateVisualElement(element.id, { props: { ...element.props, [key]: value } }); | |
| const updateStyle = (key: string, value: any) => | |
| updateVisualElement(element.id, { styles: { ...element.styles, [key]: value } }); | |
| const removeStyle = (key: string) => { | |
| const { [key]: _, ...rest } = element.styles; | |
| updateVisualElement(element.id, { styles: rest }); | |
| }; | |
| const updateAttribute = (key: string, value: any) => | |
| updateVisualElement(element.id, { attributes: { ...element.attributes, [key]: value } }); | |
| const activeStyles = new Set(Object.keys(element.styles || {})); | |
| const addStyle = (key: string) => { | |
| updateStyle(key, ''); | |
| setAddingToCat(null); | |
| }; | |
| const renderStyleEditor = (prop: CatProp) => { | |
| const value = (element.styles || {})[prop.key] ?? ''; | |
| return ( | |
| <div key={prop.key} className="flex items-center gap-1.5 group py-0.5"> | |
| {prop.type === 'color' ? ( | |
| <div className="flex items-center gap-1 flex-1 min-w-0"> | |
| <input type="color" value={value || '#000000'} | |
| onChange={(e) => updateStyle(prop.key, e.target.value)} | |
| className="w-6 h-6 rounded cursor-pointer border border-surface-600 p-0.5 flex-shrink-0" /> | |
| <input type="text" value={value} onChange={(e) => updateStyle(prop.key, e.target.value)} | |
| className="flex-1 bg-transparent text-[11px] text-surface-300 outline-none min-w-0" placeholder={prop.label} /> | |
| </div> | |
| ) : prop.type === 'select' ? ( | |
| <select value={value} onChange={(e) => updateStyle(prop.key, e.target.value)} | |
| className="flex-1 bg-surface-800 text-[11px] text-surface-300 rounded px-1.5 py-0.5 border border-surface-700 outline-none"> | |
| <option value="">--</option> | |
| {(prop.options || []).map(o => ( | |
| <option key={o.value} value={o.value}>{o.label}</option> | |
| ))} | |
| </select> | |
| ) : prop.type === 'px' ? ( | |
| <div className="flex items-center gap-1 flex-1"> | |
| <input type="number" value={parseInt(value) || ''} | |
| onChange={(e) => updateStyle(prop.key, e.target.value ? `${e.target.value}px` : '')} | |
| className="flex-1 bg-transparent text-[11px] text-surface-300 outline-none min-w-0" placeholder={prop.label} | |
| min="0" /> | |
| <span className="text-[10px] text-surface-500 w-4">px</span> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-1 flex-1"> | |
| <input type="text" value={value} onChange={(e) => updateStyle(prop.key, e.target.value)} | |
| className="flex-1 bg-transparent text-[11px] text-surface-300 outline-none min-w-0" placeholder={prop.label} /> | |
| {prop.suffix && <span className="text-[10px] text-surface-500">{prop.suffix}</span>} | |
| </div> | |
| )} | |
| <button onClick={() => removeStyle(prop.key)} | |
| className="opacity-0 group-hover:opacity-100 p-0.5 text-red-400 hover:text-red-300 transition-opacity flex-shrink-0"> | |
| <Trash2 className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="p-3 space-y-3 text-sm overflow-y-auto"> | |
| {/* Element header */} | |
| <div className="flex items-center justify-between border-b border-surface-700 pb-2"> | |
| <div> | |
| <span className="text-xs font-medium text-primary-400 bg-primary-500/10 px-2 py-0.5 rounded"> | |
| <{element.tagName}> | |
| </span> | |
| <span className="text-[10px] text-surface-500 ml-2"> | |
| {element.props?.id ? `#${element.props.id}` : ''} | |
| </span> | |
| </div> | |
| <button onClick={() => { removeVisualElement(element.id); setSelectedElement(null); }} | |
| className="p-1 hover:bg-surface-700 rounded text-red-400"><Trash2 className="w-3.5 h-3.5" /></button> | |
| </div> | |
| {/* Quick element identity */} | |
| <div className="grid grid-cols-2 gap-1.5"> | |
| <div> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">ID</label> | |
| <input type="text" value={element.props?.id || ''} onChange={(e) => updateProp('id', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" placeholder="my-id" /> | |
| </div> | |
| <div> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Classes</label> | |
| <input type="text" value={(element.classes || []).join(' ')} onChange={(e) => | |
| updateVisualElement(element.id, { classes: e.target.value.split(' ').filter(Boolean) })} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" placeholder="class1 class2" /> | |
| </div> | |
| </div> | |
| {/* Text Content */} | |
| {['p','h1','h2','h3','h4','h5','h6','span','a','button','label','li','strong','em','b','i','u','s','mark','figcaption','legend','summary','dt','dd','th','td','caption','option','blockquote','pre','code','abbr','cite','time','address'].includes(element.tagName) && ( | |
| <div className="border-t border-surface-700 pt-2"> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Text Content</label> | |
| <textarea value={element.props?.textContent || ''} onChange={(e) => updateProp('textContent', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-1 text-[11px] text-surface-200 outline-none resize-none h-12" /> | |
| </div> | |
| )} | |
| {/* Image/Link/Input-specific props */} | |
| {element.tagName === 'img' && ( | |
| <div className="border-t border-surface-700 pt-2 space-y-1.5"> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Image URL</label> | |
| <input type="text" value={element.props?.src || ''} onChange={(e) => updateProp('src', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" /> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Alt Text</label> | |
| <input type="text" value={element.props?.alt || ''} onChange={(e) => updateProp('alt', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" /> | |
| </div> | |
| )} | |
| {element.tagName === 'a' && ( | |
| <div className="border-t border-surface-700 pt-2 space-y-1.5"> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Link URL</label> | |
| <input type="text" value={element.props?.href || ''} onChange={(e) => updateProp('href', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" /> | |
| <div className="flex gap-2 items-center"> | |
| <select value={element.props?.target || '_self'} onChange={(e) => updateProp('target', e.target.value)} | |
| className="flex-1 bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none"> | |
| <option value="_self">Same tab</option> | |
| <option value="_blank">New tab</option> | |
| </select> | |
| </div> | |
| </div> | |
| )} | |
| {element.tagName === 'input' && ( | |
| <div className="border-t border-surface-700 pt-2 space-y-1.5"> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Input Type</label> | |
| <select value={element.props?.type || 'text'} onChange={(e) => updateProp('type', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none"> | |
| {['text','number','email','password','date','color','file','checkbox','radio','range','tel','url','search'].map(t => ( | |
| <option key={t} value={t}>{t}</option> | |
| ))} | |
| </select> | |
| {!['checkbox','radio','color','range','file'].includes(element.props?.type || 'text') && ( | |
| <> | |
| <label className="text-[10px] text-surface-400 block mb-0.5">Placeholder</label> | |
| <input type="text" value={element.props?.placeholder || ''} onChange={(e) => updateProp('placeholder', e.target.value)} | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" /> | |
| </> | |
| )} | |
| <div className="flex items-center gap-3"> | |
| <label className="flex items-center gap-1.5"> | |
| <input type="checkbox" checked={element.props?.required || false} onChange={(e) => updateProp('required', e.target.checked)} | |
| className="rounded bg-surface-700 border-surface-600 w-3 h-3" /> | |
| <span className="text-[11px] text-surface-400">Required</span> | |
| </label> | |
| <label className="flex items-center gap-1.5"> | |
| <input type="checkbox" checked={element.props?.disabled || false} onChange={(e) => updateProp('disabled', e.target.checked)} | |
| className="rounded bg-surface-700 border-surface-600 w-3 h-3" /> | |
| <span className="text-[11px] text-surface-400">Disabled</span> | |
| </label> | |
| </div> | |
| </div> | |
| )} | |
| {/* ===== CSS CATEGORIES WITH PLUS BUTTONS ===== */} | |
| <div className="border-t border-surface-700 pt-2 space-y-1"> | |
| <h4 className="text-[10px] font-semibold text-surface-400 uppercase tracking-wider mb-1">CSS Styles</h4> | |
| {CSS_CATEGORIES.map((cat) => { | |
| const isOpen = expandedCat === cat.name; | |
| const CatIcon = cat.icon; | |
| const activeCount = cat.properties.filter(p => activeStyles.has(p.key)).length; | |
| return ( | |
| <div key={cat.name} className="border border-surface-700/50 rounded-md"> | |
| {/* Category header */} | |
| <button onClick={() => setExpandedCat(isOpen ? '' : cat.name)} | |
| className="w-full flex items-center gap-1.5 px-2 py-1.5 bg-surface-800/50 hover:bg-surface-800 text-xs text-surface-300 transition-colors rounded-t-md"> | |
| <CatIcon className="w-3.5 h-3.5 text-surface-400" /> | |
| <span className="flex-1 text-left text-[11px] font-medium">{cat.name}</span> | |
| {activeCount > 0 && <span className="text-[10px] bg-primary-500/20 text-primary-400 px-1.5 rounded-full">{activeCount}</span>} | |
| </button> | |
| {isOpen && ( | |
| <div className="px-2 py-1.5 space-y-1"> | |
| {/* Active properties */} | |
| {cat.properties.filter(p => activeStyles.has(p.key)).map(p => renderStyleEditor(p))} | |
| {/* Quick presets for Spacing */} | |
| {cat.name === 'Spacing & Size' && activeStyles.has('padding') && ( | |
| <div className="flex flex-wrap gap-1 pt-1"> | |
| {SPACING_PRESETS.map(v => ( | |
| <button key={v} onClick={() => updateStyle('padding', v)} | |
| className={`px-1.5 py-0.5 text-[10px] rounded transition-colors ${ | |
| (element.styles?.padding || '') === v ? 'bg-primary-600 text-white' : 'bg-surface-700 text-surface-300 hover:bg-surface-600' | |
| }`}>{v}</button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Quick presets for border radius */} | |
| {cat.name === 'Border & Rounding' && activeStyles.has('borderRadius') && ( | |
| <div className="flex flex-wrap gap-1 pt-1"> | |
| {RADIUS_PRESETS.map(r => ( | |
| <button key={r.value} onClick={() => updateStyle('borderRadius', r.value)} | |
| className={`px-1.5 py-0.5 text-[10px] rounded transition-colors ${ | |
| (element.styles?.borderRadius || '') === r.value ? 'bg-primary-600 text-white' : 'bg-surface-700 text-surface-300 hover:bg-surface-600' | |
| }`}>{r.label}</button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Add property button */} | |
| <div className="relative pt-0.5"> | |
| <button onClick={() => setAddingToCat(addingToCat === cat.name ? null : cat.name)} | |
| className="w-full flex items-center gap-1 px-2 py-1 rounded text-[10px] text-surface-500 hover:text-primary-400 hover:bg-surface-800 transition-colors"> | |
| <Plus className="w-3 h-3" /> Add property | |
| </button> | |
| {addingToCat === cat.name && ( | |
| <div className="mt-1 bg-surface-800 border border-surface-700 rounded-md shadow-xl max-h-36 overflow-y-auto"> | |
| {cat.properties.filter(p => !activeStyles.has(p.key)).map(p => ( | |
| <button key={p.key} onClick={() => addStyle(p.key)} | |
| className="w-full text-left px-2 py-1 text-[11px] text-surface-300 hover:bg-surface-700 transition-colors"> | |
| {p.label} | |
| </button> | |
| ))} | |
| {cat.properties.filter(p => !activeStyles.has(p.key)).length === 0 && ( | |
| <div className="px-2 py-2 text-[10px] text-surface-500 italic text-center">All properties shown</div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Clear all */} | |
| {activeStyles.size > 0 && ( | |
| <div className="flex gap-2 pt-1 border-t border-surface-700"> | |
| <button onClick={() => updateVisualElement(element.id, { styles: {} })} | |
| className="flex-1 px-2 py-1 text-[10px] text-red-400 bg-red-500/10 rounded hover:bg-red-500/20 transition-colors"> | |
| Clear All Styles | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |