Files
DOSSIER/src/components/EbenenManager.jsx
T
karim d5bcee2157 UI-Konsistenz: shared BarControls + Tabellen-Look fuer Panels
Pill-basierte Toolbar-Primitiven aus OberleisteApp extrahiert nach
src/components/BarControls.jsx — BarCombo (Dropdown), BarButton
(Icon-Button), BarToggle (Label/Icon mit Active-State), BAR_H=22.

OberleisteApp nutzt jetzt die geteilten Komponenten (Verhalten
unveraendert).

EbenenManager + GeschossManager:
- Sichtbarkeits-Toolbar: native <select> + btn-icon-sm → BarCombo
  (mit visibility-Icon links) + BarButton add/settings.
- GeschossManager Stift-Icon (edit) → Settings-Icon.
- Zeilen-Layout: eckig statt Pill (margin 0, borderRadius 0,
  3px Accent-Strip links fuer aktive Zeile), minHeight 24, gap 4,
  kompaktere Padding/Icon-Sizes — Vectorworks-naeher.

DimensionenApp:
- Welt/CPlane: 2x BarToggle statt btn-contained/outlined
- Z-Selektor: 3x BarToggle (icon-only)
- Drehen-Apply + 90°-CCW/CW: BarButton mit rotate_*-Icons (4
  Preset-Buttons -90/-45/45/90 ersetzt durch 2 schnelle 90°-Buttons
  — passt besser in die schmale Sidebar)

README aktualisiert: Runtime jetzt CPython 3.9, TextEntity-RTF-Limit
dokumentiert, BarControls + text_editor/text_create erwaehnt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:56:33 +02:00

676 lines
24 KiB
React

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 (
<>
<div
onClick={(ev) => { 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',
}}
/>
<input
ref={ref}
type="color"
value={color}
onChange={(ev) => 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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, width: 42, justifyContent: 'flex-end', flexShrink: 0 }}>
{editing ? (
<input
type="number" step="0.01" min="0.01" max="2.0"
value={value}
autoFocus
onChange={(ev) => 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' }}
/>
) : (
<span
onDoubleClick={(ev) => { 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)}</span>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(+1) }} style={{ width: 10, height: 6 }}>
<Icon name="arrow_drop_up" size={9} />
</button>
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(-1) }} style={{ width: 10, height: 6 }}>
<Icon name="arrow_drop_down" size={9} />
</button>
</div>
</div>
)
}
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 (
<input
ref={inputRef}
value={val}
onChange={(ev) => 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 (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setVal(value); setEditing(true) }}
title="Doppelklick zum Umbenennen"
style={{ ...style, fontWeight, fontSize, cursor: 'text' }}
>{value}</span>
)
}
// --- 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 (
<div
ref={rowRef}
data-ebene-code={e.code}
onClick={onClick}
onContextMenu={onContextMenu}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '1px 12px',
paddingLeft: 12 + (depth || 0) * 12,
margin: 0,
background: active ? 'var(--active-dim)'
: (e.visible !== false) ? 'var(--bg-item)'
: 'var(--bg-panel)',
// Eckige Tabellen-Zeile mit Accent-Strip links fuer aktive Ebene
borderRadius: 0,
borderLeft: '3px solid ' + (active ? 'var(--accent)' : 'transparent'),
borderBottom: '1px solid var(--border-light)',
opacity: (!active && e.visible === false && mode !== 'all') ? 0.45 : 1,
cursor: 'pointer',
userSelect: 'none',
minHeight: 24,
}}
>
{hasChildren ? (
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onToggleExpand() }}
title={expanded ? 'Einklappen' : 'Aufklappen'}
style={{ width: 12, height: 12 }}
><Icon name={expanded ? 'expand_more' : 'chevron_right'} size={11} /></button>
) : (
<span style={{ width: 12, flexShrink: 0 }} />
)}
<button
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
title={eyeTitle}
style={{ opacity: eyeOpacity, width: 16, height: 16 }}
><Icon name={eyeIcon} size={12} /></button>
<EditableText
value={e.code}
onCommit={onCodeChange}
autoEditTrigger={autoEditCode}
fontSize={9}
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', width: 22, textAlign: 'left', flexShrink: 0 }}
/>
<ColorPicker color={e.color} onChange={onColorChange} />
<div style={{ flex: 1, minWidth: 0 }}>
<EditableText
value={e.name}
onCommit={onNameChange}
autoEditTrigger={autoEditName}
fontWeight={active ? 700 : 400}
fontSize={11}
style={{
color: active ? 'var(--active-light)'
: (e.visible !== false) ? 'var(--text-label)'
: 'var(--text-muted)',
display: 'inline-block', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
lineHeight: 1.2,
}}
/>
</div>
<LwCell lw={e.lw} onChange={onLwChange} />
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
title={e.locked ? 'Entsperren' : 'Sperren'}
style={{ color: e.locked ? 'var(--warn)' : undefined, width: 14, height: 14 }}
><Icon name={e.locked ? 'lock' : 'lock_open'} size={11} /></button>
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
title="Löschen"
style={{ width: 14, height: 14 }}
><Icon name="close" size={11} /></button>
</div>
)
}
function SortHeader({ label, sortKey, sortBy, sortDir, onSort, style }) {
const active = sortBy === sortKey
return (
<span
className="label-xs"
onClick={() => onSort(sortKey)}
style={{
cursor: 'pointer', userSelect: 'none',
display: 'inline-flex', alignItems: 'center', gap: 2,
color: active ? 'var(--text-secondary)' : undefined, ...style,
}}
>
{label}
{active && <Icon name={sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'} size={11} />}
</span>
)
}
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 (
<>
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
}}>
<span className="label-xs">Sichtbarkeit</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex' }}>
<BarCombo
icon="visibility"
value={mode}
onChange={onModeChange}
title="Sichtbarkeits-Modus"
>
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</BarCombo>
</div>
<BarButton icon="add" onClick={addNew} title="Ebene hinzufügen" />
</div>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '2px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border)',
}}>
{/* Master-Eye: alle Ebenen sichtbar/unsichtbar */}
<button
className="btn-icon-xs"
onClick={() => {
const anyVisible = ebenen.some(e => e.visible !== false)
// Wenn irgendeine sichtbar -> alle aus. Wenn keine sichtbar -> alle an.
onChange(ebenen.map(e => ({ ...e, visible: !anyVisible })))
if (mode === 'active' || mode === 'all_force') onModeChange('all')
}}
title={ebenen.every(e => e.visible !== false)
? 'Alle Ebenen ausblenden'
: 'Alle Ebenen einblenden'}
style={{ width: 18, height: 18,
opacity: (mode === 'active' || mode === 'all_force') ? 0.5 : 1 }}
>
<Icon
name={ebenen.every(e => e.visible !== false) ? 'visibility' : 'visibility_off'}
size={12}
/>
</button>
<SortHeader label="Cd" sortKey="code" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ width: 24 }} />
<div style={{ width: 12 }} />
<SortHeader label="Name" sortKey="name" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ flex: 1 }} />
<SortHeader label="Lw" sortKey="lw" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ width: 42, textAlign: 'right', display: 'block' }} />
{/* Master-Lock: alle Ebenen sperren/entsperren */}
<button
className="btn-icon-xs"
onClick={() => {
const anyLocked = ebenen.some(e => e.locked === true)
onChange(ebenen.map(e => ({ ...e, locked: !anyLocked })))
}}
title={ebenen.every(e => e.locked === true)
? 'Alle Ebenen entsperren'
: 'Alle Ebenen sperren'}
style={{ width: 18, height: 18 }}
>
<Icon
name={ebenen.every(e => e.locked === true) ? 'lock' : 'lock_open'}
size={11}
/>
</button>
<div style={{ width: 18 }} />
</div>
{(() => {
// 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 = [
<EbeneRow
key={e.code}
e={e}
depth={depth}
hasChildren={hasChildren}
expanded={isExpanded}
onToggleExpand={() => 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 && (
<ContextMenu
x={ctxMenu.x} y={ctxMenu.y}
items={ctxItems(ctxMenu.code)}
onClose={() => setCtxMenu(null)}
/>
)}
{deleteTarget && (
<ConfirmDeleteEbene
ebene={ebenen.find(e => e.code === deleteTarget)}
otherEbenen={ebenen.filter(e => e.code !== deleteTarget)}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
</>
)
}