// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Karim Gabriele Varano import { useState, useEffect } from 'react' import Icon from './components/Icon' import ContextMenu from './components/ContextMenu' import { BarToggle, BarButton, BarCombo, BAR_H } from './components/BarControls' import { onMessage, notifyReady, setOverridesEnabled, addRule, updateRule, deleteRule, reorderRules, duplicateRule, reapplyOverrides, clearOverrideRules, savePreset, loadPreset, deletePreset, saveRuleTemplate, addFromTemplate, deleteRuleTemplate, } from './lib/rhinoBridge' const COND_TYPES = [ { value: 'layer_name', label: 'Layer-Name' }, { value: 'user_string', label: 'UserString' }, { value: 'object_name', label: 'Objekt-Name' }, ] const OPS = [ { value: 'equals', label: '=' }, { value: 'not_equals', label: '≠' }, { value: 'contains', label: 'enthält' }, { value: 'starts_with', label: 'beginnt mit' }, { value: 'ends_with', label: 'endet mit' }, ] const labelXs = { fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, } const pillInput = { height: BAR_H, background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: 999, color: 'var(--text-primary)', fontSize: 11, fontFamily: 'var(--font)', padding: '0 10px', outline: 'none', boxSizing: 'border-box', } // --------------------------------------------------------------------------- function ConditionLeaf({ cond, layers, onChange, onRemove, canRemove }) { const t = cond?.type || 'layer_name' const op = cond?.operator || 'equals' return (
onChange({ ...cond, type: v })}> {COND_TYPES.map(c => )} {canRemove && ( )}
{t === 'user_string' && ( onChange({ ...cond, key: e.target.value })} style={{ ...pillInput, width: '100%' }} /> )}
onChange({ ...cond, operator: v })} width={100}> {OPS.map(o => )} {t === 'layer_name' ? ( onChange({ ...cond, value: v })}> {(layers || []).map(l => ( ))} ) : ( onChange({ ...cond, value: e.target.value })} style={{ ...pillInput, flex: 1, minWidth: 0 }} /> )}
) } function ConditionsEditor({ rule, layers, onChange }) { const conds = rule.conditions && rule.conditions.length > 0 ? rule.conditions : (rule.condition ? [rule.condition] : [{ type: 'layer_name', operator: 'equals', value: '' }]) const logic = (rule.conditionsLogic || 'and').toLowerCase() const update = (i, newCond) => { const next = conds.slice() next[i] = newCond onChange({ ...rule, conditions: next, condition: undefined }) } const remove = (i) => { const next = conds.slice() next.splice(i, 1) onChange({ ...rule, conditions: next.length ? next : [{ type: 'layer_name', operator: 'equals', value: '' }], condition: undefined }) } const add = () => { const next = conds.slice() next.push({ type: 'layer_name', operator: 'equals', value: '' }) onChange({ ...rule, conditions: next, condition: undefined }) } const setLogic = (l) => onChange({ ...rule, conditionsLogic: l }) return (
{conds.length > 1 && (
Logik: setLogic('and')} title="Alle Bedingungen müssen zutreffen" /> setLogic('or')} title="Mindestens eine Bedingung muss zutreffen" />
)} {conds.map((c, i) => ( update(i, nc)} onRemove={() => remove(i)} canRemove={conds.length > 1} /> ))}
) } function ActionRow({ label, icon, active, onToggle, children }) { return (
{active && (
{children}
)}
) } function ActionsEditor({ actions, linetypes, hatchPatterns, onChange }) { const a = actions || {} const setProp = (key, val) => { const next = { ...a } if (val === '' || val === null || val === undefined) { delete next[key] } else { next[key] = val } onChange(next) } return (
setProp('color', e.target.checked ? (a.color || '#888888') : '')} > setProp('color', e.target.value)} style={{ width: 32, height: BAR_H, padding: 0, flexShrink: 0, border: '1px solid var(--border)', borderRadius: 999, background: 'var(--bg-input)' }} /> setProp('color', e.target.value)} style={{ ...pillInput, flex: 1, minWidth: 0, fontFamily: 'DM Mono, monospace' }} /> setProp('lineweight', e.target.checked ? (a.lineweight ?? 0.25) : '')} > setProp('lineweight', parseFloat(e.target.value) || 0)} style={{ ...pillInput, width: 80, fontFamily: 'DM Mono, monospace' }} /> mm setProp('linetype', e.target.checked ? (a.linetype || 'Continuous') : '')} > setProp('linetype', v)}> {(linetypes || []).map(lt => )} setProp('hatchPattern', e.target.checked ? (a.hatchPattern || 'Solid') : '')} > setProp('hatchPattern', v)}> {(hatchPatterns || []).map(hp => )} setProp('hatchScale', e.target.checked ? (a.hatchScale ?? 1.0) : '')} > setProp('hatchScale', parseFloat(e.target.value) || 1.0)} style={{ ...pillInput, width: 80, fontFamily: 'DM Mono, monospace' }} />
Hatch-Override modifiziert nur existierende Schraffuren. Curves ohne Hatch bleiben unverändert.
) } function RuleCard({ rule, index, total, layers, linetypes, hatchPatterns, onPatch, onDelete, onDuplicate, onMoveUp, onMoveDown, onContextMenu }) { const [open, setOpen] = useState(false) const summarize = () => { const conds = (rule.conditions && rule.conditions.length > 0) ? rule.conditions : (rule.condition ? [rule.condition] : []) const logic = (rule.conditionsLogic || 'and').toUpperCase() const a = rule.actions || {} const parts = [] const condTexts = conds.map(c => { if (c.type === 'layer_name') return `Layer ${c.operator} "${c.value || '?'}"` if (c.type === 'user_string') return `${c.key || '?'} ${c.operator} "${c.value || '?'}"` if (c.type === 'object_name') return `Name ${c.operator} "${c.value || '?'}"` return '' }).filter(Boolean) if (condTexts.length === 1) parts.push(condTexts[0]) else if (condTexts.length > 1) parts.push(condTexts.join(` ${logic} `)) const acts = Object.keys(a) if (acts.length) parts.push('→ ' + acts.join(', ')) return parts.join(' ') } return (
{ if (onContextMenu) { ev.preventDefault(); onContextMenu(ev) } }} style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', background: 'var(--bg-section)', padding: 8, marginBottom: 8, opacity: rule.enabled === false ? 0.5 : 1, }}> {/* Row 1: index + checkbox + name + edit-toggle */}
#{index + 1} onPatch({ ...rule, enabled: e.target.checked })} title="Regel aktiv" style={{ flexShrink: 0, width: 'auto', height: 'auto', padding: 0 }} /> onPatch({ ...rule, name: e.target.value })} style={{ ...pillInput, flex: 1, minWidth: 0 }} /> setOpen(!open)} title={open ? 'Einklappen' : 'Bearbeiten'} />
{!open && (
{summarize() || '(leer)'}
)} {open && ( <>
onMoveUp()} disabled={index === 0} title="Prio höher (nach oben)" /> onMoveDown()} disabled={index === total - 1} title="Prio tiefer (nach unten)" /> onDuplicate()} title="Duplizieren" />
{ if (confirm(`Regel "${rule.name}" löschen?`)) onDelete() }} title="Löschen" />
Bedingungen
Überschreibungen
onPatch({ ...rule, actions: a })} />
)}
) } // --------------------------------------------------------------------------- export default function OverridesApp() { const [state, setState] = useState({ enabled: false, rules: [], layers: [], linetypes: [], hatchPatterns: [], presets: [], activePreset: null, ruleTemplates: [], }) const [selectedPreset, setSelectedPreset] = useState('') const [selectedTemplate, setSelectedTemplate] = useState('') const [ctxMenu, setCtxMenu] = useState(null) // {x, y, ruleId} useEffect(() => { onMessage('STATE', (s) => { setState((prev) => ({ ...prev, ...s })) // Dropdown synct sich auf das Backend-activePreset nur wenn der wert // gesetzt ist (z.B. nachdem Topbar eine Kombination geladen hat). // Wenn activePreset null wird (Rules wurden gerade editiert -> variant C), // BEHALTEN wir die lokale Auswahl — sonst weiss der Save-Button nicht // mehr in welche Kombination der User gerade editiert. if (s && s.activePreset) { setSelectedPreset(s.activePreset) } }) notifyReady() }, []) const onPatch = (rule) => updateRule(rule.id, rule) const onMove = (id, delta) => { const ids = state.rules.map(r => r.id) const i = ids.indexOf(id) const j = i + delta if (i < 0 || j < 0 || j >= ids.length) return const next = ids.slice() next.splice(j, 0, next.splice(i, 1)[0]) reorderRules(next) } // Kontextmenue fuer eine Regel — analog Ausschnitte/Ebenen. const ruleCtxItems = (ruleId) => { const rule = (state.rules || []).find(r => r.id === ruleId) if (!rule) return [] const i = state.rules.findIndex(r => r.id === ruleId) const enabled = rule.enabled !== false return [ { label: enabled ? 'Deaktivieren' : 'Aktivieren', icon: enabled ? 'visibility_off' : 'visibility', onClick: () => updateRule(ruleId, { ...rule, enabled: !enabled }) }, { divider: true }, { label: 'Prio hoeher (nach oben)', icon: 'arrow_upward', disabled: i <= 0, onClick: () => onMove(ruleId, -1) }, { label: 'Prio tiefer (nach unten)', icon: 'arrow_downward', disabled: i >= state.rules.length - 1, onClick: () => onMove(ruleId, +1) }, { divider: true }, { label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateRule(ruleId) }, { label: 'Als Vorlage speichern…', icon: 'bookmark_add', onClick: () => { const def = rule.name || 'Vorlage' const name = window.prompt('Name für Vorlage:', def) if (!name || !name.trim()) return saveRuleTemplate(name.trim(), rule) } }, { divider: true }, { label: 'Löschen', icon: 'delete', danger: true, onClick: () => { if (window.confirm(`Regel "${rule.name || '(ohne Name)'}" löschen?`)) deleteRule(ruleId) } }, ] } return (
{/* Override-Kombinationen — Dropdown plus kontextabhaengiger Save. Globaler AN/AUS-Toggle ist jetzt in der Oberleiste, hier ueber- fluessig. Reapply-Button raus: Backend re-applied automatisch bei jeder Aenderung. */}
Override-Kombinationen { setSelectedPreset(v) if (v) { loadPreset(v, 'replace') } else { clearOverrideRules() } }} title="Kombination zum Bearbeiten oeffnen"> {(state.presets || []).map(p => ( ))}
0} disabled={state.rules.length === 0} onClick={() => { if (selectedPreset) { savePreset(selectedPreset); return } const existing = (state.presets || []).map(p => p.name) const def = `Kombination ${existing.length + 1}` const name = window.prompt('Name für neue Kombination:', def) if (!name || !name.trim()) return const t = name.trim() if (existing.includes(t) && !window.confirm(`Kombination "${t}" überschreiben?`)) return savePreset(t) setSelectedPreset(t) }} title={selectedPreset ? `Änderungen in "${selectedPreset}" speichern` : 'Aktuelle Regeln als neue Kombination speichern'} />
{ if (!selectedPreset) return if (!window.confirm(`Kombination "${selectedPreset}" dauerhaft loeschen?`)) return deletePreset(selectedPreset) setSelectedPreset('') }} disabled={!selectedPreset} title="Gewaehlte Kombination dauerhaft loeschen" />
{/* Neue Regel: leere Regel ODER aus Vorlage. */}
{ if (!v) { setSelectedTemplate(''); return } if (v === '__delete__') { if (!selectedTemplate) return if (!window.confirm(`Vorlage "${selectedTemplate}" dauerhaft loeschen?`)) return deleteRuleTemplate(selectedTemplate) setSelectedTemplate('') return } addFromTemplate(v) setSelectedTemplate(v) }} title="Regel aus Vorlage einfuegen"> {(state.ruleTemplates || []).map(t => ( ))} {selectedTemplate && (state.ruleTemplates || []).some(t => t.name === selectedTemplate) && ( <> )}
Regeln sind additiv. Bei Konflikt gewinnt die oberste.
{/* Rule list */}
{(state.rules || []).length === 0 && (
Noch keine Regeln.
Oben klicken um eine neue Regel zu erstellen.
)} {(state.rules || []).map((r, i) => ( deleteRule(r.id)} onDuplicate={() => duplicateRule(r.id)} onMoveUp={() => onMove(r.id, -1)} onMoveDown={() => onMove(r.id, +1)} onContextMenu={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, ruleId: r.id })} /> ))}
{ctxMenu && ( setCtxMenu(null)} /> )}
) }