import { useState, useRef, useMemo, useEffect } from 'react' import Icon from './Icon' import ConfirmDeleteEbene from './ConfirmDeleteEbene' import ContextMenu from './ContextMenu' import { BarCombo, BarButton } from './BarControls' import { setLayerStyle, deleteEbene, moveSelectionToEbene, openEbenenSettings } from '../lib/rhinoBridge' const MODES = [ { value: 'all_force', label: 'Alle anzeigen' }, { value: 'all', label: 'Ausgewählte' }, { value: 'active', label: 'Nur aktive' }, { value: 'grey', label: 'Andere grau' }, { value: 'grey_locked', label: 'Andere grau & gesperrt' }, ] const LW_PRESETS = [0.02, 0.10, 0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00] function ColorPicker({ color, onChange }) { const ref = useRef(null) return ( <>
{ ev.stopPropagation(); ref.current?.click() }} title="Farbe ändern" style={{ width: 12, height: 12, borderRadius: 2, background: color, flexShrink: 0, border: '1px solid var(--border)', cursor: 'pointer', }} /> onChange(ev.target.value)} onClick={(ev) => ev.stopPropagation()} style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }} /> ) } function LwCell({ lw, onChange }) { const [editing, setEditing] = useState(false) const [value, setValue] = useState(lw.toFixed(2)) const commit = () => { const v = parseFloat(value) if (!isNaN(v) && v > 0) onChange(v) else setValue(lw.toFixed(2)) setEditing(false) } const step = (dir) => { let next if (dir > 0) next = LW_PRESETS.find(p => p > lw + 0.001) else next = [...LW_PRESETS].reverse().find(p => p < lw - 0.001) if (next !== undefined) onChange(next) } return (
{editing ? ( setValue(ev.target.value)} onBlur={commit} onKeyDown={(ev) => { if (ev.key === 'Enter') commit(); if (ev.key === 'Escape') { setValue(lw.toFixed(2)); setEditing(false) } }} onClick={(ev) => ev.stopPropagation()} style={{ width: 28, fontSize: 9, padding: '1px 2px', textAlign: 'right' }} /> ) : ( { ev.stopPropagation(); setEditing(true); setValue(lw.toFixed(2)) }} title="Doppelklick zum Bearbeiten" style={{ fontSize: 9, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', cursor: 'text', minWidth: 22, textAlign: 'right' }} >{lw.toFixed(2)} )}
) } function EditableText({ value, onCommit, style, fontWeight, fontSize, autoEditTrigger }) { const [editing, setEditing] = useState(false) const [val, setVal] = useState(value) const lastTrigger = useRef(null) const inputRef = useRef(null) // External trigger -> Edit-Modus aktivieren useEffect(() => { if (autoEditTrigger && autoEditTrigger !== lastTrigger.current) { lastTrigger.current = autoEditTrigger setVal(value) setEditing(true) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoEditTrigger]) // Beim Edit-Start: Inhalt selektieren useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus() try { inputRef.current.select() } catch (e) {} } }, [editing]) const commit = () => { const trimmed = (val ?? '').trim() if (trimmed && trimmed !== value) onCommit(trimmed) else setVal(value) setEditing(false) } if (editing) { return ( setVal(ev.target.value)} onBlur={commit} onKeyDown={(ev) => { if (ev.key === 'Enter') commit() if (ev.key === 'Escape') { setVal(value); setEditing(false) } }} onClick={(ev) => ev.stopPropagation()} style={{ ...style, fontWeight, fontSize, padding: '1px 4px' }} /> ) } return ( { ev.stopPropagation(); setVal(value); setEditing(true) }} title="Doppelklick zum Umbenennen" style={{ ...style, fontWeight, fontSize, cursor: 'text' }} >{value} ) } // --- Tree-Helper ----------------------------------------------------------- // Rekursive Updates: code ist global eindeutig (Children duerfen keinen // bestehenden Top-Level Code haben). Helper finden/aendern den passenden // Eintrag irgendwo im Tree. function _updateInTree(ebenen, code, patch) { return ebenen.map(e => { if (e.code === code) return { ...e, ...patch } if (Array.isArray(e.children) && e.children.length) { return { ...e, children: _updateInTree(e.children, code, patch) } } return e }) } function _removeFromTree(ebenen, code) { const out = [] for (const e of ebenen) { if (e.code === code) continue if (Array.isArray(e.children) && e.children.length) { out.push({ ...e, children: _removeFromTree(e.children, code) }) } else { out.push(e) } } return out } function _addChildInTree(ebenen, parentCode, child) { return ebenen.map(e => { if (e.code === parentCode) { const kids = Array.isArray(e.children) ? e.children : [] return { ...e, children: [...kids, child] } } if (Array.isArray(e.children) && e.children.length) { return { ...e, children: _addChildInTree(e.children, parentCode, child) } } return e }) } function _findInTree(ebenen, code) { for (const e of ebenen) { if (e.code === code) return e if (Array.isArray(e.children) && e.children.length) { const f = _findInTree(e.children, code) if (f) return f } } return null } function _allCodes(ebenen) { const out = [] for (const e of ebenen) { out.push(e.code) if (Array.isArray(e.children) && e.children.length) { out.push(..._allCodes(e.children)) } } return out } function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) { // Auge-Logik analog GeschossManager: immer rendern, nur dimmen wenn Mode // das Eye-Flag ueberschreibt (active / all_force). Klick auf Auge in // diesen Modi wechselt zu "Ausgewählte" damit die Aktion wirkt. let eyeIcon, eyeOn, eyeOpacity, eyeTitle if (active) { eyeIcon = e.visible !== false ? 'visibility' : 'visibility_off' eyeOn = true eyeOpacity = 1 eyeTitle = e.visible !== false ? 'Normalerweise sichtbar (aktive Ebene wird trotzdem gezeigt)' : 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv' } else if (mode === 'all_force') { eyeIcon = 'visibility' eyeOn = true eyeOpacity = 0.35 eyeTitle = 'Im „Alle anzeigen"-Mode immer sichtbar — Klick wechselt in „Ausgewählte"' } else if (mode === 'active') { eyeIcon = e.visible !== false ? 'visibility' : 'visibility_off' eyeOn = false eyeOpacity = 0.35 eyeTitle = 'Im „Nur aktive"-Mode ausgeblendet — Klick wechselt in „Ausgewählte"' } else { eyeIcon = e.visible !== false ? 'visibility' : 'visibility_off' eyeOn = e.visible !== false eyeOpacity = 1 eyeTitle = e.visible !== false ? 'Ausblenden' : 'Einblenden' } return (
{hasChildren ? ( ) : ( )}
) } function SortHeader({ label, sortKey, sortBy, sortDir, onSort, style }) { const active = sortBy === sortKey return ( onSort(sortKey)} style={{ cursor: 'pointer', userSelect: 'none', display: 'inline-flex', alignItems: 'center', gap: 2, color: active ? 'var(--text-secondary)' : undefined, ...style, }} > {label} {active && } ) } export default function EbenenManager({ ebenen, activeCode, onActiveChange, onChange, mode, onModeChange, hatchPatterns, }) { const [sortBy, setSortBy] = useState('code') const [sortDir, setSortDir] = useState('asc') const [deleteTarget, setDeleteTarget] = useState(null) const [ctxMenu, setCtxMenu] = useState(null) // { x, y, code } const [clipboard, setClipboard] = useState(null) // { color, lw } const [autoEdit, setAutoEdit] = useState(null) // { code, field, token } const [expanded, setExpanded] = useState({}) // { code: true } // Settings-Dialog laeuft jetzt in einem echten Rhino-Fenster (Satellite- // Window via Eto.Form + WebView). State hier nicht mehr noetig. // Bei Auto-Edit: in View scrollen useEffect(() => { if (!autoEdit) return const el = document.querySelector(`[data-ebene-code="${autoEdit.code}"]`) if (el && el.scrollIntoView) { el.scrollIntoView({ block: 'center', behavior: 'smooth' }) } }, [autoEdit]) const toggleSort = (key) => { if (sortBy === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc') else { setSortBy(key); setSortDir('asc') } } const sortByCurrent = (arr) => { const sorted = [...arr] sorted.sort((a, b) => { let cmp = 0 if (sortBy === 'code') { const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10) cmp = (!isNaN(ca) && !isNaN(cb)) ? ca - cb : a.code.localeCompare(b.code) } else if (sortBy === 'name') { cmp = a.name.localeCompare(b.name) } else if (sortBy === 'lw') { cmp = a.lw - b.lw } return sortDir === 'desc' ? -cmp : cmp }) return sorted } // Sort wirkt innerhalb jeder Ebene des Baums — Children behalten ihre // Beziehung zum Parent, werden aber unter sich sortiert. const sortedEbenen = useMemo(() => sortByCurrent(ebenen), [ebenen, sortBy, sortDir]) const updateByCode = (code, patch) => { onChange(_updateInTree(ebenen, code, patch)) } const handleToggleVisible = (code) => { const cur = _findInTree(ebenen, code) if (cur) updateByCode(code, { visible: !(cur.visible !== false) }) // In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge // klickt will offensichtlich Sichtbarkeit kontrollieren, also direkt // in den "Ausgewählte"-Mode wechseln damit die Aktion wirkt. if (mode === 'active' || mode === 'all_force') onModeChange('all') } const handleToggleLock = (code) => { const cur = _findInTree(ebenen, code) if (cur) updateByCode(code, { locked: !cur.locked }) } const handleColorChange = (code, color) => { updateByCode(code, { color }) setLayerStyle(code, color, undefined) } const handleLwChange = (code, lw) => { updateByCode(code, { lw }) setLayerStyle(code, undefined, lw) } const handleNameChange = (code, name) => { updateByCode(code, { name }) // Wenn wir gerade neue Ebene anlegen (Phase 'name') — fertig if (autoEdit && autoEdit.code === code && autoEdit.field === 'name') { setAutoEdit(null) } } const handleCodeChange = (oldCode, newCode) => { // Code muss global eindeutig sein (sonst gibt es mehrdeutige Layer-Matches) if (_allCodes(ebenen).some(c => c === newCode && c !== oldCode)) return onChange(_updateInTree(ebenen, oldCode, { code: newCode })) // Phase weiterschalten: Code -> Name if (autoEdit && autoEdit.code === oldCode && autoEdit.field === 'code') { setAutoEdit({ code: newCode, field: 'name', token: Date.now() }) } } const handleDelete = (code) => { if (ebenen.length <= 1) return setDeleteTarget(code) } const confirmDelete = (moveToCode) => { const code = deleteTarget deleteEbene(code, moveToCode) onChange(_removeFromTree(ebenen, code)) if (activeCode === code) { const flat = ebenen.flatMap(e => [e, ...(Array.isArray(e.children) ? e.children : [])]) const next = flat.find(e => e.code !== code) if (next) onActiveChange(next.code) } setDeleteTarget(null) } const nextFreeAfter = (afterCode) => { // Naechste freie Nummer NACH afterCode. Codes sind global eindeutig // (auch ueber Children) — also alle Codes als Konfliktraum. const existing = new Set(_allCodes(ebenen)) let n = parseInt(afterCode, 10) if (isNaN(n)) n = 49 for (let i = 1; i < 1000; i++) { const c = String(n + i).padStart(2, '0') if (!existing.has(c)) return c } const codes = _allCodes(ebenen).map(c => parseInt(c, 10)).filter(x => !isNaN(x)) return String((codes.length ? Math.max(...codes) : 49) + 1).padStart(2, '0') } const addNew = () => { // KEIN window.prompt — macOS WKWebView blockiert wiederholte // JavaScript-Dialoge (erster zeigt, nachfolgende returnen null). // Silent-Append mit autoEdit damit der User direkt den Namen // eintippen kann. const code = nextFreeAfter(activeCode) const newEbene = { code, name: 'NEU', color: '#888888', lw: 0.18, visible: true, locked: false, } console.log('[EBENEN-UI] addNew →', { activeCode, code, ebenenCountBefore: ebenen.length }) onChange([...ebenen, newEbene]) // Code-Feld der neuen Ebene fokussieren — User kann sofort tippen // (Tab springt dann zum Name-Feld). setAutoEdit({ code, field: 'code', token: Date.now() }) } const duplicateEbene = (code) => { const src = _findInTree(ebenen, code) if (!src) return const dupCode = nextFreeAfter(code) const dup = { ...src, code: dupCode, name: src.name + ' KOPIE' } // Top-Level Eintrag — wir haengen Duplikat einfach hinten an onChange([...ebenen, dup]) } const addChild = (parentCode) => { const code = nextFreeAfter(parentCode) const child = { code, name: 'NEU', color: '#888888', lw: 0.18, visible: true, locked: false, } onChange(_addChildInTree(ebenen, parentCode, child)) // Parent expanden damit der neue Eintrag sichtbar ist setExpanded(s => ({ ...s, [parentCode]: true })) setAutoEdit({ code, field: 'code', token: Date.now() }) } const toggleExpand = (code) => { setExpanded(s => ({ ...s, [code]: !s[code] })) } const copyProps = (code) => { const e = ebenen.find(x => x.code === code) if (e) setClipboard({ color: e.color, lw: e.lw }) } const pasteProps = (code) => { if (!clipboard) return onChange(ebenen.map(e => e.code === code ? { ...e, ...clipboard } : e)) setLayerStyle(code, clipboard.color, clipboard.lw) } const openContextMenu = (ev, code) => { ev.preventDefault() ev.stopPropagation() setCtxMenu({ x: ev.clientX, y: ev.clientY, code }) } const ctxItems = (code) => [ { label: 'Ebeneneinstellungen…', icon: 'settings', onClick: () => { const target = _findInTree(ebenen, code) if (target) openEbenenSettings(target, hatchPatterns) } }, { divider: true }, { label: 'Sub-Ebene hinzufügen…', icon: 'add', onClick: () => addChild(code) }, { label: 'Selektion hierher übertragen', icon: 'move_down', onClick: () => moveSelectionToEbene(code) }, { divider: true }, { label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateEbene(code) }, { label: 'Eigenschaften kopieren', icon: 'colorize', onClick: () => copyProps(code) }, { label: 'Eigenschaften einfügen', icon: 'format_paint', onClick: () => pasteProps(code), disabled: !clipboard }, { divider: true }, { label: 'Löschen', icon: 'delete', onClick: () => handleDelete(code), danger: true, disabled: ebenen.length <= 1 }, ] return ( <>
Sichtbarkeit
{MODES.map(m => )}
{/* Master-Eye: alle Ebenen sichtbar/unsichtbar */}
{/* Master-Lock: alle Ebenen sperren/entsperren */}
{(() => { // Rekursives Rendern: jede Ebene + sortierte Children (falls expanded) const renderRow = (e, depth) => { const kids = Array.isArray(e.children) ? e.children : [] const hasChildren = kids.length > 0 const isExpanded = !!expanded[e.code] const rows = [ toggleExpand(e.code)} active={e.code === activeCode} mode={mode} onClick={() => onActiveChange(e.code)} onContextMenu={(ev) => openContextMenu(ev, e.code)} onToggleVisible={() => handleToggleVisible(e.code)} onToggleLock={() => handleToggleLock(e.code)} onColorChange={(c) => handleColorChange(e.code, c)} onLwChange={(lw) => handleLwChange(e.code, lw)} onNameChange={(n) => handleNameChange(e.code, n)} onCodeChange={(c) => handleCodeChange(e.code, c)} onDelete={() => handleDelete(e.code)} autoEditCode={autoEdit && autoEdit.code === e.code && autoEdit.field === 'code' ? autoEdit.token : null} autoEditName={autoEdit && autoEdit.code === e.code && autoEdit.field === 'name' ? autoEdit.token : null} /> ] if (hasChildren && isExpanded) { for (const child of sortByCurrent(kids)) { rows.push(...renderRow(child, depth + 1)) } } return rows } return sortedEbenen.flatMap(e => renderRow(e, 0)) })()} {ctxMenu && ( setCtxMenu(null)} /> )} {deleteTarget && ( e.code === deleteTarget)} otherEbenen={ebenen.filter(e => e.code !== deleteTarget)} onConfirm={confirmDelete} onCancel={() => setDeleteTarget(null)} /> )} ) }