RealBlocks / client /src /components /VisualEditor /PropertyPanel.tsx
incognitolm
Roll Back
2595e43
Raw
History Blame Contribute Delete
22.6 kB
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">
&lt;{element.tagName}&gt;
</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>
);
}