RealBlocks / client /src /components /VisualEditor /CssRulePanel.tsx
SafeSight's picture
File linking 1
3258d18
Raw
History Blame Contribute Delete
11.5 kB
import { useState, useCallback, useMemo } from 'react';
import { useEditorStore } from '../../store/editorStore';
import type { CssRule, CssProperty } from '@shared/types';
import { Plus, Trash2, Palette, FileType, Globe } from 'lucide-react';
import { v4 as uuidv4 } from 'uuid';
function collectSelectorsFromElements(elements: any[]): string[] {
const selectors = new Set<string>();
const walk = (els: any[]) => {
for (const el of els) {
if (el.props?.id) selectors.add(`#${el.props.id}`);
if (el.tagName) selectors.add(el.tagName);
el.classes?.forEach((c: string) => {
if (c) selectors.add(`.${c}`);
});
if (el.children) walk(el.children);
}
};
walk(elements);
return Array.from(selectors).sort();
}
function rulesToCssText(rules: CssRule[]): string {
return rules
.filter((r: CssRule) => r.enabled)
.map((r: CssRule) => {
const props = r.properties
.filter((p: CssProperty) => p.name && p.value)
.map((p: CssProperty) => {
const important = p.important ? ' !important' : '';
return ` ${p.name}: ${p.value}${important};`;
})
.join('\n');
if (!props) return '';
const media = r.mediaQuery ? `\n@media ${r.mediaQuery} {\n` : '';
const closeMedia = r.mediaQuery ? '\n}' : '';
return `${media}${r.selector} {\n${props}\n}${closeMedia}`;
})
.filter(Boolean)
.join('\n\n');
}
export default function CssRulePanel() {
const {
activeFileId, fileTree, elementRegistry,
cssRules, setCssRules, updateFile,
} = useEditorStore();
const activeFile = useMemo(
() => activeFileId ? fileTree.find(f => f.id === activeFileId) : null,
[activeFileId, fileTree]
);
// Get elements from linked HTML files
const linkedElementSelectors = useMemo(() => {
if (!activeFileId) return [];
// Find HTML files that link to this CSS file, or CSS files linked FROM HTML
const linkingHtmlFiles = fileTree.filter(
f => f.type === 'html' && (f.linkedFiles || []).includes(activeFileId)
);
const selectors = new Set<string>();
for (const hf of linkingHtmlFiles) {
const els = elementRegistry[hf.id] || [];
collectSelectorsFromElements(els).forEach(s => selectors.add(s));
}
// Also add selectors from all HTML files as fallback
const allHtmlFiles = fileTree.filter(f => f.type === 'html');
for (const hf of allHtmlFiles) {
const els = elementRegistry[hf.id] || [];
collectSelectorsFromElements(els).forEach(s => selectors.add(s));
}
return Array.from(selectors).sort();
}, [activeFileId, fileTree, elementRegistry]);
const rules = useMemo(() => {
if (!activeFileId) return [];
return cssRules[activeFileId] || [];
}, [activeFileId, cssRules]);
const [expandedRule, setExpandedRule] = useState<string | null>(null);
const addRule = useCallback(() => {
if (!activeFileId) return;
const newRule: CssRule = {
id: uuidv4(),
selector: linkedElementSelectors[0] || 'body',
type: 'class',
properties: [{ id: uuidv4(), name: 'color', value: '#333', important: false, type: 'standard', unit: '' }],
enabled: true,
};
setCssRules(activeFileId, [...rules, newRule]);
setExpandedRule(newRule.id);
}, [activeFileId, linkedElementSelectors, rules, setCssRules]);
const updateRule = useCallback((ruleId: string, updates: Partial<CssRule>) => {
if (!activeFileId) return;
setCssRules(activeFileId, rules.map((r: CssRule) => r.id === ruleId ? { ...r, ...updates } : r));
}, [activeFileId, rules, setCssRules]);
const removeRule = useCallback((ruleId: string) => {
if (!activeFileId) return;
setCssRules(activeFileId, rules.filter((r: CssRule) => r.id !== ruleId));
}, [activeFileId, rules, setCssRules]);
const addProperty = useCallback((ruleId: string) => {
const rule = rules.find((r: CssRule) => r.id === ruleId);
if (!rule) return;
const newProp: CssProperty = { id: uuidv4(), name: '', value: '', important: false, type: 'standard', unit: '' };
updateRule(ruleId, { properties: [...rule.properties, newProp] });
}, [rules, updateRule]);
const updateProperty = useCallback((ruleId: string, propId: string, updates: Partial<CssProperty>) => {
const rule = rules.find((r: CssRule) => r.id === ruleId);
if (!rule) return;
updateRule(ruleId, {
properties: rule.properties.map((p: CssProperty) => p.id === propId ? { ...p, ...updates } : p),
});
}, [rules, updateRule]);
const removeProperty = useCallback((ruleId: string, propId: string) => {
const rule = rules.find((r: CssRule) => r.id === ruleId);
if (!rule) return;
updateRule(ruleId, { properties: rule.properties.filter((p: CssProperty) => p.id !== propId) });
}, [rules, updateRule]);
// Save CSS rules to file content
const applyToFile = useCallback(() => {
if (!activeFileId || !activeFile) return;
const cssText = rulesToCssText(rules);
updateFile(activeFileId, { content: cssText });
}, [activeFileId, activeFile, rules, updateFile]);
if (!activeFile || activeFile.type !== 'css') {
return (
<div className="flex items-center justify-center h-full text-surface-500 text-xs p-4">
Select a CSS file to manage styles
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-surface-700">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-surface-300 uppercase tracking-wider flex items-center gap-1.5">
<FileType className="w-3.5 h-3.5 text-amber-400" />
CSS Rules
</h3>
<div className="flex items-center gap-1">
<button onClick={applyToFile}
className="px-2 py-1 text-[10px] bg-primary-600 text-white rounded hover:bg-primary-500">
Apply to File
</button>
<button onClick={addRule}
className="p-1 text-surface-400 hover:text-primary-400 hover:bg-surface-700 rounded">
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
{linkedElementSelectors.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
<span className="text-[9px] text-surface-500 mr-1 mt-0.5">
<Globe className="w-2.5 h-2.5 inline mr-0.5" />
Elements:
</span>
{linkedElementSelectors.slice(0, 8).map(sel => (
<button key={sel}
onClick={() => setExpandedRule(sel)}
className="text-[9px] px-1.5 py-0.5 bg-surface-800 text-primary-300 rounded hover:bg-primary-500/20"
>
{sel}
</button>
))}
{linkedElementSelectors.length > 8 && (
<span className="text-[9px] text-surface-500">+{linkedElementSelectors.length - 8} more</span>
)}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{rules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-surface-500">
<Palette className="w-8 h-8 mb-2 opacity-30" />
<p className="text-xs">No CSS rules yet</p>
<p className="text-[10px] mt-1">Click + to add a rule</p>
</div>
) : (
rules.map(rule => (
<div key={rule.id} className="border border-surface-700/50 rounded-lg overflow-hidden">
{/* Rule header */}
<div className="flex items-center gap-1 px-2 py-1.5 bg-surface-800/50"
onClick={() => setExpandedRule(expandedRule === rule.id ? null : rule.id)}>
<input type="text" value={rule.selector}
onChange={(e) => updateRule(rule.id, { selector: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-xs font-mono text-primary-300 outline-none"
placeholder=".my-class" />
<span className="text-[9px] text-surface-500">{rule.properties.filter((p: CssProperty) => p.name && p.value).length} props</span>
<button onClick={(e) => { e.stopPropagation(); removeRule(rule.id); }}
className="p-0.5 text-red-400 hover:text-red-300 hover:bg-surface-700 rounded">
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Rule properties */}
{expandedRule === rule.id && (
<div className="px-2 py-1.5 space-y-1">
<div className="flex items-center gap-1 text-[9px] text-surface-500 mb-1">
<label className="w-20">Selector</label>
<div className="flex flex-wrap gap-1">
{linkedElementSelectors.map(sel => (
<button key={sel}
onClick={() => updateRule(rule.id, { selector: sel })}
className={`px-1 py-0.5 rounded text-[9px] transition-colors ${
rule.selector === sel ? 'bg-primary-600 text-white' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'
}`}>
{sel}
</button>
))}
</div>
</div>
{rule.properties.map((prop: CssProperty) => (
<div key={prop.id} className="flex items-center gap-1 group">
<input type="text" value={prop.name}
onChange={(e) => updateProperty(rule.id, prop.id, { name: e.target.value })}
className="w-24 bg-surface-800 text-[10px] text-surface-200 rounded px-1.5 py-0.5 border border-surface-700 outline-none font-mono"
placeholder="property" />
<span className="text-surface-500 text-[9px]">:</span>
<input type="text" value={prop.value}
onChange={(e) => updateProperty(rule.id, prop.id, { value: e.target.value })}
className="flex-1 bg-surface-800 text-[10px] text-surface-200 rounded px-1.5 py-0.5 border border-surface-700 outline-none font-mono"
placeholder="value" />
<button onClick={() => updateProperty(rule.id, prop.id, { important: !prop.important })}
className={`text-[9px] px-1 py-0.5 rounded ${prop.important ? 'text-amber-400 bg-amber-500/10' : 'text-surface-600'}`}>
!important
</button>
<button onClick={() => removeProperty(rule.id, prop.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-red-400 hover:text-red-300">
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
))}
<button onClick={() => addProperty(rule.id)}
className="w-full flex items-center gap-1 px-2 py-1 rounded text-[9px] text-surface-500 hover:text-primary-400 hover:bg-surface-800 transition-colors">
<Plus className="w-2.5 h-2.5" /> Add property
</button>
</div>
)}
</div>
))
)}
</div>
</div>
);
}