a308ba62d2
- Project-Settings-Dialog (Voreinstellungen Geschoss/Schnitt + Material-Editor) ueber Zahnrad-Icon in Oberleiste; Defaults werden in schnitte.pick_schnitt + GeschossManager als Vorgabe genommen, pro-Element-Werte unangetastet - Dossier-Library Phase A (lokal, read-only): rhino/library.py + LibraryBrowser Satellite; Seed-Manifest unter ~/Library/Application Support/Dossier/library/ - Material-Merger: _get_all_materials(doc) merged builtin _MATERIAL_LIBRARY mit Projekt-Settings-Materialien (inkl. Library-Imports); Wand-Erstellung, Sub-Layer-Anlage + Elemente-Material-Dropdown ziehen jetzt aus dem Merge Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
679 lines
27 KiB
React
679 lines
27 KiB
React
import { useState } from 'react'
|
|
import Icon from './Icon'
|
|
import ContextMenu from './ContextMenu'
|
|
import { BarCombo, BarButton } from './BarControls'
|
|
import { openGeschossSettings, openGeschossDialog, createSchnitt } from '../lib/rhinoBridge'
|
|
|
|
function GeschossBadge({ name }) {
|
|
return <span className="chip chip-info">{name}</span>
|
|
}
|
|
|
|
function ZeichnungsebeneRow({
|
|
z, active, mode, onClick, onContextMenu,
|
|
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
|
|
onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd,
|
|
isDragging, dropPos,
|
|
}) {
|
|
const isGeschoss = !!z.isGeschoss
|
|
const isSchnitt = z.type === 'schnitt'
|
|
// Schnitt vs Ansicht: cutAtLine!=false = Schnitt (mit Front-Cut), sonst Ansicht
|
|
const schnittIcon = (z.cutAtLine === false) ? 'visibility' : 'content_cut'
|
|
const schnittLabel = (z.cutAtLine === false) ? 'Ansicht' : 'Schnitt'
|
|
// Eye-Logik: die aktive Z ist IMMER sichtbar (Backend forciert das), also
|
|
// zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag.
|
|
// Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an),
|
|
// in 'active' ueberschrieben (alle aus) — Auge dimmt. Sonst (Ausgewaehlte/
|
|
// grey) reflektiert es das Flag direkt.
|
|
let eyeIcon, eyeOn, eyeOpacity, eyeTitle
|
|
if (active) {
|
|
eyeIcon = 'visibility'
|
|
eyeOn = true
|
|
eyeOpacity = 1
|
|
eyeTitle = z.visible !== false
|
|
? 'Sichtbar (aktive Zeichnungsebene)'
|
|
: 'Normalerweise ausgeblendet — wird gezeigt 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 = z.visible !== false ? 'visibility' : 'visibility_off'
|
|
eyeOn = false
|
|
eyeOpacity = 0.35
|
|
eyeTitle = 'Im „Nur aktive"-Mode ausgeblendet — Klick wechselt in „Ausgewählte"'
|
|
} else {
|
|
eyeIcon = z.visible !== false ? 'visibility' : 'visibility_off'
|
|
eyeOn = z.visible !== false
|
|
eyeOpacity = 1
|
|
eyeTitle = z.visible !== false ? 'Ausblenden' : 'Einblenden'
|
|
}
|
|
return (
|
|
<div
|
|
draggable
|
|
onDragStart={(ev) => onDragStart && onDragStart(ev, z.id)}
|
|
onDragOver={(ev) => onDragOver && onDragOver(ev, z.id)}
|
|
onDragLeave={(ev) => onDragLeave && onDragLeave(ev, z.id)}
|
|
onDrop={(ev) => onDrop && onDrop(ev, z.id)}
|
|
onDragEnd={(ev) => onDragEnd && onDragEnd(ev)}
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
padding: '1px 12px 1px 0',
|
|
margin: 0,
|
|
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
|
|
borderRadius: active ? 999 : 0,
|
|
// Drop-Indikator: 2px accent-Linie an Top oder Bottom — zeigt
|
|
// wo der gedragte Eintrag nach Loslassen landet. Sonst normale
|
|
// unten-Trennlinie. isDragging: leichtes Faden des gehobenen
|
|
// Eintrags fuer visuelles Feedback.
|
|
borderTop: dropPos === 'top'
|
|
? '2px solid var(--accent)' : '0 solid transparent',
|
|
borderBottom: dropPos === 'bottom'
|
|
? '2px solid var(--accent)'
|
|
: (active ? '1px solid transparent' : '1px solid var(--border-light)'),
|
|
opacity: isDragging ? 0.4 : 1,
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
minHeight: 24,
|
|
}}
|
|
>
|
|
{/* Spacer-Slot — spiegelt den Chevron-Slot bei Ebenen-Rows wider
|
|
damit die Eye-Icons beider Panels untereinander stehen. */}
|
|
<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>
|
|
|
|
<span style={{
|
|
fontWeight: 500,
|
|
fontSize: 11,
|
|
color: active ? 'var(--active-light)' : 'var(--text-label)',
|
|
flex: 1, minWidth: 0,
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
lineHeight: 1.2,
|
|
}}>{z.name}</span>
|
|
|
|
{isGeschoss && (
|
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
|
|
+{(z.okff ?? 0).toFixed(2)}
|
|
</span>
|
|
)}
|
|
|
|
{isGeschoss && <GeschossBadge name={z.name} />}
|
|
|
|
{isSchnitt && (
|
|
<span title={schnittLabel}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 3,
|
|
fontSize: 10, color: 'var(--text-muted)',
|
|
fontFamily: 'var(--font-mono)',
|
|
}}>
|
|
<Icon name={schnittIcon} size={11}
|
|
style={{ color: active ? 'var(--accent)' : 'var(--text-muted)' }} />
|
|
</span>
|
|
)}
|
|
|
|
{isGeschoss ? (
|
|
<button
|
|
className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
|
|
onClick={(ev) => { ev.stopPropagation(); onToggleClipping() }}
|
|
title={z.hasClipping
|
|
? 'Clipping Plane ausschalten'
|
|
: 'Clipping Plane einschalten (Schnitt auf Schnitthöhe)'}
|
|
style={{ color: z.hasClipping ? 'var(--accent)' : undefined, width: 14, height: 14 }}
|
|
><Icon name="content_cut" size={11} /></button>
|
|
) : (
|
|
<span style={{ width: 14, flexShrink: 0 }} />
|
|
)}
|
|
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
|
|
title={z.locked ? 'Entsperren' : 'Sperren'}
|
|
style={{ color: z.locked ? 'var(--warn)' : undefined, width: 14, height: 14 }}
|
|
><Icon name={z.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>
|
|
)
|
|
}
|
|
|
|
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' },
|
|
]
|
|
|
|
export default function GeschossManager({
|
|
zeichnungsebenen, activeId, onActiveChange, onChange, recalcOkff,
|
|
mode, onModeChange,
|
|
projectSettings,
|
|
}) {
|
|
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, id }
|
|
const [addMenu, setAddMenu] = useState(null) // { x, y } — Picker beim +
|
|
const [geschossDialog, setGeschossDialog] = useState(null) // { x, y, pos:'above'|'below', name, hoehe, schnitthoehe, anchorId }
|
|
// Drag-State fuer Reorder
|
|
const [dragId, setDragId] = useState(null)
|
|
const [dropOverId, setDropOverId] = useState(null)
|
|
const [dropPos, setDropPos] = useState(null) // 'top' | 'bottom'
|
|
|
|
const handleDragStart = (ev, id) => {
|
|
setDragId(id)
|
|
try {
|
|
ev.dataTransfer.effectAllowed = 'move'
|
|
ev.dataTransfer.setData('text/plain', id)
|
|
} catch (e) {}
|
|
}
|
|
const handleDragOver = (ev, targetId) => {
|
|
if (!dragId || dragId === targetId) return
|
|
ev.preventDefault()
|
|
try { ev.dataTransfer.dropEffect = 'move' } catch (e) {}
|
|
const rect = ev.currentTarget.getBoundingClientRect()
|
|
const pos = (ev.clientY < rect.top + rect.height / 2) ? 'top' : 'bottom'
|
|
if (targetId !== dropOverId) setDropOverId(targetId)
|
|
if (pos !== dropPos) setDropPos(pos)
|
|
}
|
|
const handleDragLeave = (ev, targetId) => {
|
|
// Wenn der Cursor die ganze Row verlaesst (nicht in ein Child), reset.
|
|
// Heuristik: relatedTarget liegt nicht innerhalb der Row.
|
|
if (!ev.currentTarget.contains(ev.relatedTarget)) {
|
|
if (dropOverId === targetId) {
|
|
setDropOverId(null); setDropPos(null)
|
|
}
|
|
}
|
|
}
|
|
const handleDrop = (ev, targetId) => {
|
|
ev.preventDefault()
|
|
const srcId = dragId
|
|
setDragId(null); setDropOverId(null); setDropPos(null)
|
|
if (!srcId || srcId === targetId) return
|
|
// Array-Reihenfolge: bottom-up (recalcOkff stacks von index 0 hoch).
|
|
// Display ist umgekehrt (top = letztes Array-Element). Klick auf
|
|
// 'top' eines Display-Eintrags Z heisst: oberhalb Z, d.h. nach Z
|
|
// im Array (höherer Index). 'bottom' = unterhalb Z in Display =
|
|
// vor Z im Array.
|
|
const arr = [...zeichnungsebenen]
|
|
const srcIdx = arr.findIndex(z => z.id === srcId)
|
|
if (srcIdx < 0) return
|
|
const [moved] = arr.splice(srcIdx, 1)
|
|
const tgtIdx = arr.findIndex(z => z.id === targetId)
|
|
if (tgtIdx < 0) return
|
|
const insertIdx = (dropPos === 'top') ? tgtIdx + 1 : tgtIdx
|
|
arr.splice(insertIdx, 0, moved)
|
|
onChange(arr) // ZeichnungsebenenApp ruft recalcOkff → OKFFs stimmen wieder
|
|
}
|
|
const handleDragEnd = () => {
|
|
setDragId(null); setDropOverId(null); setDropPos(null)
|
|
}
|
|
|
|
const sorted = [...zeichnungsebenen].reverse()
|
|
|
|
const addZeichnung = () => {
|
|
const nonGeschossCount = zeichnungsebenen.filter(
|
|
z => !z.isGeschoss && z.type !== "schnitt").length
|
|
const newZ = {
|
|
id: `z_${Date.now()}`,
|
|
name: `Zeichnung ${nonGeschossCount + 1}`,
|
|
isGeschoss: false,
|
|
visible: true,
|
|
}
|
|
onChange([...zeichnungsebenen, newZ])
|
|
}
|
|
|
|
// Vorgeschlagener Geschossname relativ zum Anker. Logik:
|
|
// EG + above → 1OG, EG + below → UG
|
|
// <N>OG + above → <N+1>OG, <N>OG + below → wenn N=1 dann EG, sonst <N-1>OG
|
|
// <N>UG + above → wenn N=1 dann EG, sonst <N-1>UG; <N>UG + below → <N+1>UG
|
|
// sonst: 'Neu'
|
|
const suggestGeschossName = (anchor, pos) => {
|
|
const nm = ((anchor?.name) || '').trim().toUpperCase()
|
|
const ogMatch = nm.match(/^(\d*)OG$/)
|
|
const ugMatch = nm.match(/^(\d*)UG$/)
|
|
if (nm === 'EG') return pos === 'above' ? '1OG' : 'UG'
|
|
if (ogMatch) {
|
|
const n = parseInt(ogMatch[1] || '1', 10)
|
|
if (pos === 'above') return `${n + 1}OG`
|
|
return n <= 1 ? 'EG' : `${n - 1}OG`
|
|
}
|
|
if (ugMatch) {
|
|
const n = parseInt(ugMatch[1] || '1', 10)
|
|
if (pos === 'below') return `${n + 1}UG`
|
|
return n <= 1 ? 'EG' : `${n - 1}UG`
|
|
}
|
|
return 'Neu'
|
|
}
|
|
|
|
// Default-Hoehe: Hierarchie
|
|
// 1. Project-Settings (zentrale Voreinstellung — explizit gesetzt)
|
|
// 2. Aktives Geschoss (wahrscheinlich gleich vom User gewuenscht)
|
|
// 3. Erstes Geschoss in der Liste
|
|
// 4. Hartcodiert 3.0
|
|
// Project-Settings ueberschreibt alle anderen wenn vorhanden, damit
|
|
// der User eine zentrale Vorgabe haben kann.
|
|
const projDefaults = projectSettings?.defaults || {}
|
|
const defaultGeschossHoehe = () => {
|
|
if (projDefaults.geschossHoehe != null) return projDefaults.geschossHoehe
|
|
const act = zeichnungsebenen.find(z => z.id === activeId)
|
|
if (act?.isGeschoss && act.hoehe != null) return act.hoehe
|
|
const first = zeichnungsebenen.find(z => z.isGeschoss && z.hoehe != null)
|
|
return first?.hoehe ?? 3.0
|
|
}
|
|
const defaultSchnitthoehe = () => {
|
|
if (projDefaults.schnitthoehe != null) return projDefaults.schnitthoehe
|
|
const act = zeichnungsebenen.find(z => z.id === activeId)
|
|
if (act?.isGeschoss && act.schnitthoehe != null) return act.schnitthoehe
|
|
const first = zeichnungsebenen.find(z => z.isGeschoss && z.schnitthoehe != null)
|
|
return first?.schnitthoehe ?? 1.0
|
|
}
|
|
|
|
const openGeschossPrompt = (ev) => {
|
|
// Anker = aktives Geschoss (oder erstes Geschoss in der Liste). Falls
|
|
// gar keins da: einfach default-Werte ohne Anker.
|
|
const anchor = zeichnungsebenen.find(z => z.id === activeId && z.isGeschoss)
|
|
|| zeichnungsebenen.find(z => z.isGeschoss)
|
|
|| null
|
|
const pos = 'above'
|
|
const rect = ev?.currentTarget?.getBoundingClientRect()
|
|
setGeschossDialog({
|
|
x: rect ? rect.right - 240 : 200,
|
|
y: rect ? rect.bottom + 4 : 100,
|
|
pos,
|
|
name: suggestGeschossName(anchor, pos),
|
|
hoehe: defaultGeschossHoehe().toFixed(2),
|
|
schnitthoehe: defaultSchnitthoehe().toFixed(2),
|
|
anchorId: anchor?.id || null,
|
|
})
|
|
}
|
|
|
|
const confirmGeschoss = () => {
|
|
if (!geschossDialog) return
|
|
const { pos, name, hoehe, schnitthoehe, anchorId } = geschossDialog
|
|
const h = parseFloat(String(hoehe).replace(',', '.'))
|
|
const sh = parseFloat(String(schnitthoehe).replace(',', '.'))
|
|
const newZ = {
|
|
id: `z_${Date.now()}`,
|
|
name: (name || 'Neu').trim(),
|
|
isGeschoss: true,
|
|
hoehe: isFinite(h) && h > 0 ? h : 3.0,
|
|
schnitthoehe: isFinite(sh) && sh > 0 ? sh : 1.0,
|
|
visible: true,
|
|
}
|
|
// Insertion-Index: anchor finden, dann +1 (above) oder vorne ein (below).
|
|
// Array-Reihenfolge = bottom-up (recalcOkff stacks von index 0 aufwaerts),
|
|
// also "above" = nach anchor, "below" = vor anchor.
|
|
let nextList
|
|
if (anchorId) {
|
|
const idx = zeichnungsebenen.findIndex(z => z.id === anchorId)
|
|
if (idx >= 0) {
|
|
const insertAt = pos === 'above' ? idx + 1 : idx
|
|
nextList = [
|
|
...zeichnungsebenen.slice(0, insertAt),
|
|
newZ,
|
|
...zeichnungsebenen.slice(insertAt),
|
|
]
|
|
} else {
|
|
nextList = [...zeichnungsebenen, newZ]
|
|
}
|
|
} else {
|
|
nextList = [...zeichnungsebenen, newZ]
|
|
}
|
|
onChange(nextList)
|
|
setGeschossDialog(null)
|
|
}
|
|
|
|
const updateGeschossDialog = (patch) => {
|
|
if (!geschossDialog) return
|
|
const next = { ...geschossDialog, ...patch }
|
|
// Wenn pos ODER anchorId sich aendert: Name + Hoehen neu vorschlagen,
|
|
// aber nur wenn der User die jeweiligen Felder nicht schon manuell
|
|
// ueberschrieben hat (heuristisch: aktueller Wert === alter Vorschlag).
|
|
const posChanged = patch.pos && patch.pos !== geschossDialog.pos
|
|
const anchorChanged = patch.anchorId && patch.anchorId !== geschossDialog.anchorId
|
|
if (posChanged || anchorChanged) {
|
|
const oldAnchor = zeichnungsebenen.find(z => z.id === geschossDialog.anchorId)
|
|
const newAnchor = zeichnungsebenen.find(z => z.id === (patch.anchorId || geschossDialog.anchorId))
|
|
const oldSuggestedName = suggestGeschossName(oldAnchor, geschossDialog.pos)
|
|
if (geschossDialog.name === oldSuggestedName) {
|
|
next.name = suggestGeschossName(newAnchor, next.pos)
|
|
}
|
|
// Bei Anchor-Wechsel: Hoehe/Schnitthoehe auch nachziehen wenn unveraendert
|
|
if (anchorChanged && newAnchor?.isGeschoss) {
|
|
const oldHoeheStr = (oldAnchor?.hoehe ?? 3.0).toFixed(2)
|
|
const oldShStr = (oldAnchor?.schnitthoehe ?? 1.0).toFixed(2)
|
|
if (geschossDialog.hoehe === oldHoeheStr && newAnchor.hoehe != null) {
|
|
next.hoehe = newAnchor.hoehe.toFixed(2)
|
|
}
|
|
if (geschossDialog.schnitthoehe === oldShStr && newAnchor.schnitthoehe != null) {
|
|
next.schnitthoehe = newAnchor.schnitthoehe.toFixed(2)
|
|
}
|
|
}
|
|
}
|
|
setGeschossDialog(next)
|
|
}
|
|
|
|
const addSchnitt = () => createSchnitt() // triggert interaktiven Pick + Auto-Activate
|
|
|
|
const openAddMenu = (ev) => {
|
|
if (!ev) { addZeichnung(); return } // Fallback ohne Event-Position
|
|
const rect = ev.currentTarget.getBoundingClientRect()
|
|
setAddMenu({ x: rect.right - 180, y: rect.bottom + 4 })
|
|
}
|
|
|
|
// ContextMenu Items — onClick kriegt KEIN Event. Daher fuer Geschoss
|
|
// einen kleinen Trick: ContextMenu schliesst, dann oeffnen wir den
|
|
// Dialog mit einer kuenstlichen Position (rechts vom Panel).
|
|
const addMenuItems = [
|
|
{ label: 'Geschoss', icon: 'layers',
|
|
onClick: () => openGeschossPrompt({
|
|
currentTarget: { getBoundingClientRect: () =>
|
|
({ right: addMenu?.x + 180, bottom: addMenu?.y + 20 }) } }) },
|
|
{ label: 'Schnitt / Ansicht', icon: 'content_cut', onClick: addSchnitt },
|
|
{ divider: true },
|
|
{ label: 'Zeichnung', icon: 'edit_note', onClick: addZeichnung },
|
|
]
|
|
|
|
const toggleVisible = (id) => {
|
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
|
|
// 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 toggleLock = (id) => {
|
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, locked: !z.locked } : z))
|
|
}
|
|
|
|
const toggleClipping = (id) => {
|
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, hasClipping: !z.hasClipping } : z))
|
|
// Backend rendert die Clipping-Plane nur fuer das AKTIVE Geschoss.
|
|
// Klick auf cut-Icon einer nicht-aktiven Zeile → mit-aktivieren,
|
|
// sonst kriegt der User keine sichtbare Rueckmeldung.
|
|
if (id !== activeId) onActiveChange(id)
|
|
}
|
|
|
|
const duplicate = (id) => {
|
|
const src = zeichnungsebenen.find(z => z.id === id)
|
|
if (!src) return
|
|
const clone = {
|
|
...src,
|
|
id: `z_${Date.now()}`,
|
|
name: `${src.name} Kopie`,
|
|
}
|
|
// Direkt nach dem Original einfuegen
|
|
const idx = zeichnungsebenen.findIndex(z => z.id === id)
|
|
const next = [...zeichnungsebenen]
|
|
next.splice(idx + 1, 0, clone)
|
|
onChange(next)
|
|
}
|
|
|
|
const remove = (id) => {
|
|
if (zeichnungsebenen.length <= 1) return
|
|
const target = zeichnungsebenen.find(z => z.id === id)
|
|
if (!target) return
|
|
if (!window.confirm(`"${target.name}" wirklich löschen?`)) return
|
|
onChange(zeichnungsebenen.filter(z => z.id !== id))
|
|
if (activeId === id) {
|
|
const next = zeichnungsebenen.find(z => z.id !== id)
|
|
if (next) onActiveChange(next.id)
|
|
}
|
|
}
|
|
|
|
const openContextMenu = (ev, id) => {
|
|
ev.preventDefault(); ev.stopPropagation()
|
|
setCtxMenu({ x: ev.clientX, y: ev.clientY, id })
|
|
}
|
|
|
|
const ctxItems = (id) => {
|
|
const z = zeichnungsebenen.find(x => x.id === id)
|
|
if (!z) return []
|
|
return [
|
|
{ label: 'Einstellungen…', icon: 'settings', onClick: () => openGeschossSettings(z) },
|
|
{ divider: true },
|
|
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicate(id) },
|
|
{ divider: true },
|
|
{ label: 'Löschen', icon: 'delete', danger: true,
|
|
disabled: zeichnungsebenen.length <= 1,
|
|
onClick: () => remove(id) },
|
|
]
|
|
}
|
|
|
|
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', width: '100%' }}>
|
|
<BarCombo
|
|
stretch
|
|
icon="visibility"
|
|
value={mode}
|
|
onChange={onModeChange}
|
|
title="Sichtbarkeits-Modus"
|
|
onGear={() => openGeschossDialog(zeichnungsebenen)}
|
|
gearIcon="settings"
|
|
gearTitle="Einstellungen"
|
|
onSecond={openAddMenu}
|
|
secondIcon="add"
|
|
secondTitle="Hinzufuegen: Geschoss / Schnitt / Zeichnung"
|
|
>
|
|
{MODES.map(m => (
|
|
<option key={m.value} value={m.value}>{m.label}</option>
|
|
))}
|
|
</BarCombo>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Master-Row analog EbenenManager. Padding + Icon-Sizes identisch
|
|
damit beide Panels visuell kohaerent sind. Erste 12px-Spanne
|
|
spiegelt den Chevron-Slot der Ebenen-Daten-Rows wider. */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
padding: '2px 12px 2px 0',
|
|
background: 'var(--bg-section)',
|
|
borderBottom: '1px solid var(--border-light)',
|
|
}}>
|
|
<span style={{ width: 12, flexShrink: 0 }} />
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={() => {
|
|
const anyVisible = zeichnungsebenen.some(z => z.visible !== false)
|
|
onChange(zeichnungsebenen.map(z => ({ ...z, visible: !anyVisible })))
|
|
if (mode === 'active' || mode === 'all_force') onModeChange('all')
|
|
}}
|
|
title={zeichnungsebenen.every(z => z.visible !== false)
|
|
? 'Alle Zeichnungsebenen ausblenden'
|
|
: 'Alle Zeichnungsebenen einblenden'}
|
|
style={{ width: 16, height: 16,
|
|
opacity: (mode === 'active' || mode === 'all_force') ? 0.5 : 1 }}
|
|
>
|
|
<Icon
|
|
name={zeichnungsebenen.every(z => z.visible !== false) ? 'visibility' : 'visibility_off'}
|
|
size={11}
|
|
/>
|
|
</button>
|
|
<span style={{ flex: 1 }} />
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={() => {
|
|
const anyLocked = zeichnungsebenen.some(z => z.locked === true)
|
|
onChange(zeichnungsebenen.map(z => ({ ...z, locked: !anyLocked })))
|
|
}}
|
|
title={zeichnungsebenen.every(z => z.locked === true)
|
|
? 'Alle Zeichnungsebenen entsperren'
|
|
: 'Alle Zeichnungsebenen sperren'}
|
|
style={{ width: 14, height: 14 }}
|
|
>
|
|
<Icon
|
|
name={zeichnungsebenen.every(z => z.locked === true) ? 'lock' : 'lock_open'}
|
|
size={11}
|
|
/>
|
|
</button>
|
|
<div style={{ width: 14 }} />
|
|
</div>
|
|
|
|
<div>
|
|
{sorted.map(z => (
|
|
<ZeichnungsebeneRow
|
|
key={z.id}
|
|
z={z}
|
|
active={z.id === activeId}
|
|
mode={mode}
|
|
onClick={() => onActiveChange(z.id)}
|
|
onContextMenu={(ev) => openContextMenu(ev, z.id)}
|
|
onToggleVisible={() => toggleVisible(z.id)}
|
|
onToggleLock={() => toggleLock(z.id)}
|
|
onToggleClipping={() => toggleClipping(z.id)}
|
|
onDelete={() => remove(z.id)}
|
|
isDragging={dragId === z.id}
|
|
dropPos={dropOverId === z.id ? dropPos : null}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onDragEnd={handleDragEnd}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x} y={ctxMenu.y}
|
|
items={ctxItems(ctxMenu.id)}
|
|
onClose={() => setCtxMenu(null)}
|
|
/>
|
|
)}
|
|
{addMenu && (
|
|
<ContextMenu
|
|
x={addMenu.x} y={addMenu.y}
|
|
items={addMenuItems}
|
|
onClose={() => setAddMenu(null)}
|
|
/>
|
|
)}
|
|
{geschossDialog && (() => {
|
|
// Geschoss-Liste (top-down sortiert wie im Panel) fuer das Dropdown
|
|
const geschossOptions = [...zeichnungsebenen]
|
|
.filter(z => z.isGeschoss)
|
|
.reverse()
|
|
return (
|
|
<>
|
|
{/* Backdrop — Klick schliesst */}
|
|
<div onClick={() => setGeschossDialog(null)}
|
|
style={{ position: 'fixed', inset: 0, zIndex: 999,
|
|
background: 'transparent' }} />
|
|
<div style={{
|
|
position: 'fixed', zIndex: 1000,
|
|
left: Math.max(8, Math.min(geschossDialog.x, window.innerWidth - 260)),
|
|
top: Math.max(8, Math.min(geschossDialog.y, window.innerHeight - 220)),
|
|
width: 240,
|
|
background: 'var(--bg-dialog)', color: 'var(--text-primary)',
|
|
border: '1px solid var(--border)', borderRadius: 6,
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
padding: 10,
|
|
fontSize: 11, fontFamily: 'var(--font)',
|
|
display: 'flex', flexDirection: 'column', gap: 8,
|
|
}}
|
|
onClick={(ev) => ev.stopPropagation()}>
|
|
<div style={{ fontSize: 10, color: 'var(--text-muted)',
|
|
fontWeight: 600, letterSpacing: 0.4,
|
|
textTransform: 'uppercase' }}>
|
|
Neues Geschoss
|
|
</div>
|
|
|
|
{/* Position: Anker-Dropdown + Über/Unter-Toggle */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>
|
|
Position relativ zu
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<select
|
|
value={geschossDialog.anchorId || ''}
|
|
onChange={(ev) => updateGeschossDialog({ anchorId: ev.target.value })}
|
|
disabled={geschossOptions.length === 0}
|
|
style={{ flex: 1, fontSize: 11, minWidth: 0 }}>
|
|
{geschossOptions.length === 0 && <option value="">— keins —</option>}
|
|
{geschossOptions.map(g => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
<BarButton icon="arrow_upward"
|
|
active={geschossDialog.pos === 'above'}
|
|
onClick={() => updateGeschossDialog({ pos: 'above' })}
|
|
title="Über dem Anker einfügen" />
|
|
<BarButton icon="arrow_downward"
|
|
active={geschossDialog.pos === 'below'}
|
|
onClick={() => updateGeschossDialog({ pos: 'below' })}
|
|
title="Unter dem Anker einfügen" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Name</span>
|
|
<input type="text" value={geschossDialog.name}
|
|
onChange={(ev) => updateGeschossDialog({ name: ev.target.value })}
|
|
autoFocus
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') confirmGeschoss()
|
|
else if (ev.key === 'Escape') setGeschossDialog(null)
|
|
}}
|
|
style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
{/* Hoehe + Schnitthoehe */}
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Höhe (m)</span>
|
|
<input type="text" value={geschossDialog.hoehe}
|
|
onChange={(ev) => updateGeschossDialog({ hoehe: ev.target.value })}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') confirmGeschoss()
|
|
else if (ev.key === 'Escape') setGeschossDialog(null)
|
|
}}
|
|
style={{ width: '100%', textAlign: 'right' }} />
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}
|
|
title="Höhe der horizontalen Schnitt-Plane über OKFF">
|
|
Schnitt (m)
|
|
</span>
|
|
<input type="text" value={geschossDialog.schnitthoehe}
|
|
onChange={(ev) => updateGeschossDialog({ schnitthoehe: ev.target.value })}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') confirmGeschoss()
|
|
else if (ev.key === 'Escape') setGeschossDialog(null)
|
|
}}
|
|
style={{ width: '100%', textAlign: 'right' }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 4, marginTop: 4 }}>
|
|
<button className="btn-text"
|
|
onClick={() => setGeschossDialog(null)}>Abbrechen</button>
|
|
<button className="btn-contained"
|
|
onClick={confirmGeschoss}>Hinzufügen</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
})()}
|
|
</>
|
|
)
|
|
}
|