Initial commit — Dossier Rhino 8 Plugin
OpenStudio-Suite Architektur-Plugin fuer Rhino 8 (Mac): - Smart-Elemente: Wand, Decke, Dach (Pult/Sattel/Walm/Mansarde), Oeffnungen (Fenster/Tueren mit Rahmen + Sims + Glas + Fluegel), Treppen (gerade · L · Wendel mit Schrittmass-Validierung) - Live-Previews mit Step-Lines + Soll-Range-Clamping - Bidirektionale Selection-Sync zwischen Source-Linie und Volume - Geschoss-/Ebenen-Verwaltung mit OKFF-Persistenz - Layouts mit PDF-Export - Ausschnitte / Massstab / Override-Regeln - Petrol-Gruen Theme (Rapport-konform) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import GeschossManager from './components/GeschossManager'
|
||||
import EbenenManager from './components/EbenenManager'
|
||||
import AusschnittLayerDialog from './components/AusschnittLayerDialog'
|
||||
import {
|
||||
applyAll, setActiveZeichnungsebene, setActiveEbene,
|
||||
onMessage, notifyReady, applyVisibility,
|
||||
getCombination, applyCombination,
|
||||
saveCurrentAsCombination, deleteCombinationPreset,
|
||||
saveCombinationPreset,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
export function recalcOkff(list) {
|
||||
let acc = 0
|
||||
return list.map(z => {
|
||||
if (z.isGeschoss) {
|
||||
const next = { ...z, okff: parseFloat(acc.toFixed(3)) }
|
||||
acc += (z.hoehe ?? 3.0)
|
||||
return next
|
||||
}
|
||||
return { ...z, okff: undefined }
|
||||
})
|
||||
}
|
||||
|
||||
const INITIAL_ZEICHNUNGSEBENEN = recalcOkff([
|
||||
{ id: 'eg', name: 'EG', isGeschoss: true, hoehe: 3.50, schnitthoehe: 1.00, visible: true },
|
||||
{ id: '1og', name: '1OG', isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true },
|
||||
{ id: '2og', name: '2OG', isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true },
|
||||
])
|
||||
|
||||
const INITIAL_EBENEN = [
|
||||
{ code: '00', name: 'RASTER', color: '#484850', lw: 0.13, visible: true, locked: false },
|
||||
{ code: '01', name: 'VERMESSUNG', color: '#707078', lw: 0.18, visible: true, locked: false },
|
||||
{ code: '10', name: 'SITUATION', color: '#909090', lw: 0.18, visible: true, locked: false },
|
||||
{ code: '11', name: 'STRASSE', color: '#a89070', lw: 0.18, visible: true, locked: false },
|
||||
{ code: '12', name: 'GEBÄUDE', color: '#888888', lw: 0.25, visible: true, locked: false },
|
||||
{ code: '13', name: 'BÄUME', color: '#50a050', lw: 0.13, visible: true, locked: false },
|
||||
{ code: '14', name: 'HÖHENLINIEN', color: '#909050', lw: 0.18, visible: true, locked: false },
|
||||
{ code: '20', name: 'WÄNDE', color: '#0a0a0a', lw: 0.50, visible: true, locked: false },
|
||||
{ code: '21', name: 'TÜREN_FENSTER', color: '#5080c8', lw: 0.25, visible: true, locked: false },
|
||||
{ code: '22', name: 'MÖBEL', color: '#909090', lw: 0.13, visible: true, locked: false },
|
||||
{ code: '25', name: 'STÜTZEN', color: '#c87050', lw: 0.50, visible: true, locked: false },
|
||||
{ code: '30', name: 'DECKEN', color: '#605850', lw: 0.35, visible: true, locked: false },
|
||||
{ code: '31', name: 'DÄCHER', color: '#7a4a3a', lw: 0.35, visible: true, locked: false },
|
||||
{ code: '35', name: 'TRÄGER', color: '#a87858', lw: 0.50, visible: true, locked: false },
|
||||
{ code: '50', name: 'TEXT', color: '#d0d0d0', lw: 0.13, visible: true, locked: false },
|
||||
{ code: '60', name: 'PLANGRAFIK', color: '#c0a040', lw: 0.13, visible: true, locked: false },
|
||||
{ code: '90', name: 'REFERENZEN', color: '#585860', lw: 0.13, visible: true, locked: false },
|
||||
{ code: '99', name: 'KONSTRUKTION', color: '#404048', lw: 0.13, visible: true, locked: false },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [zeichnungsebenen, setZeichnungsebenen] = useState(INITIAL_ZEICHNUNGSEBENEN)
|
||||
const [ebenen, setEbenen] = useState(INITIAL_EBENEN)
|
||||
const [activeId, setActiveId] = useState('eg')
|
||||
const [activeCode, setActiveCode] = useState('20')
|
||||
const [appliedZ, setAppliedZ] = useState(INITIAL_ZEICHNUNGSEBENEN)
|
||||
const [appliedE, setAppliedE] = useState(INITIAL_EBENEN)
|
||||
const [zMode, setZMode] = useState('active')
|
||||
const [eMode, setEMode] = useState('all')
|
||||
const [hatchPatterns, setHatchPatterns] = useState(['Solid', 'Hatch1', 'Hatch2', 'Hatch3', 'Plus', 'Squares', 'Grid', 'Grid60'])
|
||||
// Ebenenkombinationen (geteilter Store mit Ausschnitten)
|
||||
const [combinations, setCombinations] = useState([]) // Liste { name, layers }
|
||||
const [activeCombName, setActiveCombName] = useState(null) // null = "Eigene"
|
||||
// Dialog fuer "alle bearbeiten" (Pencil-Button)
|
||||
const [combDialog, setCombDialog] = useState(null) // { layers, presets } oder null
|
||||
const wantCombDialogRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE_SYNC', ({ zeichnungsebenen: z, ebenen: e, hatchPatterns: hp }) => {
|
||||
if (z) {
|
||||
const r = recalcOkff(z); setZeichnungsebenen(r); setAppliedZ(r)
|
||||
const active = r.find(zz => zz.id === activeId) || r[0]
|
||||
if (active) {
|
||||
setActiveZeichnungsebene(active)
|
||||
// Auch den Sublayer-Code aktiv setzen, damit Rhino's Current-Layer
|
||||
// beim Panel-Start sofort auf der Wahl im Panel landet (sonst bleibt
|
||||
// "Default" und neue Objekte landen dort).
|
||||
if (activeCode) setActiveEbene(activeCode)
|
||||
}
|
||||
}
|
||||
if (e) { setEbenen(e); setAppliedE(e) }
|
||||
if (Array.isArray(hp) && hp.length > 0) setHatchPatterns(hp)
|
||||
})
|
||||
onMessage('COMBINATION_DATA', ({ layers, presets }) => {
|
||||
setCombinations(presets || [])
|
||||
if (wantCombDialogRef.current) {
|
||||
wantCombDialogRef.current = false
|
||||
setCombDialog({ layers: layers || [], presets: presets || [] })
|
||||
} else if (combDialog) {
|
||||
// Dialog ist offen — Layer-Liste live aktualisieren (z.B. nach Preset-Save)
|
||||
setCombDialog(d => d ? { ...d, layers: layers || d.layers, presets: presets || [] } : d)
|
||||
}
|
||||
})
|
||||
onMessage('FIRST_RUN', () => {
|
||||
applyAll(INITIAL_ZEICHNUNGSEBENEN, INITIAL_EBENEN)
|
||||
setAppliedZ(INITIAL_ZEICHNUNGSEBENEN)
|
||||
setAppliedE(INITIAL_EBENEN)
|
||||
const active = INITIAL_ZEICHNUNGSEBENEN.find(zz => zz.id === activeId) || INITIAL_ZEICHNUNGSEBENEN[0]
|
||||
if (active) {
|
||||
setActiveZeichnungsebene(active)
|
||||
if (activeCode) setActiveEbene(activeCode)
|
||||
}
|
||||
})
|
||||
notifyReady()
|
||||
// Initial Liste der Kombinationen holen
|
||||
setTimeout(() => getCombination(), 200)
|
||||
|
||||
// Native Browser-Context-Menu global unterdruecken
|
||||
const blockContext = (ev) => ev.preventDefault()
|
||||
document.addEventListener('contextmenu', blockContext)
|
||||
return () => document.removeEventListener('contextmenu', blockContext)
|
||||
}, [])
|
||||
|
||||
// Sichtbarkeit live anwenden — bei relevanten Aenderungen
|
||||
const visibilityKey = useMemo(() => (
|
||||
activeId + '|' + activeCode + '|' + zMode + '|' + eMode + '|' +
|
||||
zeichnungsebenen.map(z => `${z.id}:${z.visible !== false ? 1 : 0}`).join(',') + '|' +
|
||||
ebenen.map(e => `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`).join(',')
|
||||
), [activeId, activeCode, zMode, eMode, zeichnungsebenen, ebenen])
|
||||
|
||||
useEffect(() => {
|
||||
const activeZ = zeichnungsebenen.find(z => z.id === activeId)
|
||||
if (activeZ) applyVisibility(activeZ, zeichnungsebenen, activeCode, ebenen, zMode, eMode)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visibilityKey])
|
||||
|
||||
// Auto-Apply bei strukturellen Aenderungen (add/remove/rename) UND
|
||||
// wenn die Ebenen-Settings (z.B. fill) sich aendern — Python braucht den
|
||||
// neuen Stand in doc.Strings damit Auto-Fill und 'Nach Ebene' korrekt lesen.
|
||||
// Kanonische Signatur: leere/None-Fills sind alle aequivalent — sonst loest
|
||||
// schon das blosse Oeffnen+Schliessen des Settings-Dialogs ein applyAll aus
|
||||
// (Dialog initialisiert fill mit Default-Werten).
|
||||
const fillSig = (e) => {
|
||||
const f = e.fill
|
||||
if (!f || !f.pattern || f.pattern === 'None') return ''
|
||||
return [f.pattern, f.source || 'layer', f.color || '', f.scale ?? 1, f.rotation ?? 0].join('|')
|
||||
}
|
||||
const structureKey = useMemo(() => (
|
||||
zeichnungsebenen.map(z => `${z.id}:${z.name}:${z.isGeschoss ? 1 : 0}`).join(',') + '|' +
|
||||
ebenen.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
|
||||
), [zeichnungsebenen, ebenen])
|
||||
|
||||
const appliedStructureKey = useMemo(() => (
|
||||
appliedZ.map(z => `${z.id}:${z.name}:${z.isGeschoss ? 1 : 0}`).join(',') + '|' +
|
||||
appliedE.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
|
||||
), [appliedZ, appliedE])
|
||||
|
||||
useEffect(() => {
|
||||
if (structureKey === appliedStructureKey) return
|
||||
const t = setTimeout(() => {
|
||||
applyAll(zeichnungsebenen, ebenen)
|
||||
setAppliedZ(zeichnungsebenen)
|
||||
setAppliedE(ebenen)
|
||||
}, 200)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [structureKey, appliedStructureKey])
|
||||
|
||||
// --- Ebenen-Kombinationen ----------------------------------------------
|
||||
const handlePickCombination = (name) => {
|
||||
if (!name) { setActiveCombName(null); return }
|
||||
const preset = combinations.find(p => p.name === name)
|
||||
if (!preset) return
|
||||
// Eye-State bevorzugen wenn im Preset vorhanden (= verlustfreie Wiederherstellung,
|
||||
// beruecksichtigt z_mode/e_mode); fallback auf doc.Layer-Liste fuer alte Presets.
|
||||
applyCombination({
|
||||
layers: preset.layers || [],
|
||||
dossierEbenen: preset.dossierEbenen,
|
||||
dossierZeichnungsebenen: preset.dossierZeichnungsebenen,
|
||||
})
|
||||
setActiveCombName(name)
|
||||
}
|
||||
const handleSaveCurrentCombination = () => {
|
||||
const suggested = activeCombName || `Kombi ${combinations.length + 1}`
|
||||
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
|
||||
if (!name) return
|
||||
if (combinations.some(p => p.name === name) &&
|
||||
!window.confirm(`"${name}" ueberschreiben?`)) return
|
||||
saveCurrentAsCombination(name)
|
||||
setActiveCombName(name)
|
||||
}
|
||||
const handleDeleteCombination = (name) => {
|
||||
if (!name) return
|
||||
if (!window.confirm(`Ebenenkombination "${name}" löschen?`)) return
|
||||
deleteCombinationPreset(name)
|
||||
if (activeCombName === name) setActiveCombName(null)
|
||||
}
|
||||
const handleOpenCombDialog = () => {
|
||||
wantCombDialogRef.current = true
|
||||
getCombination()
|
||||
}
|
||||
// Wenn der User Sichtbarkeit/Lock manuell aendert -> "Eigene".
|
||||
// Wird direkt von EbenenManager aufgerufen, kein Effect-Race.
|
||||
const handleUserVisibilityChange = () => {
|
||||
if (activeCombName !== null) setActiveCombName(null)
|
||||
}
|
||||
|
||||
const handleActiveChange = (id) => {
|
||||
setActiveId(id)
|
||||
const z = zeichnungsebenen.find(x => x.id === id)
|
||||
if (z) {
|
||||
setActiveZeichnungsebene(z)
|
||||
if (activeCode) setActiveEbene(activeCode)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100vh', overflow: 'hidden',
|
||||
background: 'var(--bg-base)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<GeschossManager
|
||||
zeichnungsebenen={zeichnungsebenen}
|
||||
activeId={activeId}
|
||||
onActiveChange={handleActiveChange}
|
||||
onChange={(updated) => setZeichnungsebenen(recalcOkff(updated))}
|
||||
recalcOkff={recalcOkff}
|
||||
mode={zMode}
|
||||
onModeChange={setZMode}
|
||||
/>
|
||||
<EbenenManager
|
||||
ebenen={ebenen}
|
||||
activeCode={activeCode}
|
||||
onActiveChange={(code) => { setActiveCode(code); setActiveEbene(code) }}
|
||||
onChange={setEbenen}
|
||||
mode={eMode}
|
||||
onModeChange={setEMode}
|
||||
hatchPatterns={hatchPatterns}
|
||||
combinations={combinations}
|
||||
activeCombName={activeCombName}
|
||||
onPickCombination={handlePickCombination}
|
||||
onSaveCurrentCombination={handleSaveCurrentCombination}
|
||||
onDeleteCombination={handleDeleteCombination}
|
||||
onEditCombinations={handleOpenCombDialog}
|
||||
onUserVisibilityChange={handleUserVisibilityChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{combDialog && (
|
||||
<AusschnittLayerDialog
|
||||
snapName="Ebenenkombinationen bearbeiten"
|
||||
layers={combDialog.layers}
|
||||
presets={combDialog.presets}
|
||||
onClose={() => setCombDialog(null)}
|
||||
onSave={(layers) => {
|
||||
applyCombination(layers)
|
||||
setActiveCombName(null)
|
||||
setCombDialog(null)
|
||||
}}
|
||||
onSavePreset={(name, layers) => {
|
||||
saveCombinationPreset(name, layers)
|
||||
setCombDialog(d => d ? {
|
||||
...d,
|
||||
presets: [...d.presets.filter(p => p.name !== name), { name, layers }],
|
||||
} : d)
|
||||
}}
|
||||
onDeletePreset={(name) => {
|
||||
deleteCombinationPreset(name)
|
||||
setCombDialog(d => d ? {
|
||||
...d,
|
||||
presets: d.presets.filter(p => p.name !== name),
|
||||
} : d)
|
||||
if (activeCombName === name) setActiveCombName(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import ContextMenu from './components/ContextMenu'
|
||||
import AusschnittLayerDialog from './components/AusschnittLayerDialog'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
listAusschnitte, saveAusschnitt, updateAusschnitt,
|
||||
restoreAusschnitt, applyAusschnittToDetail,
|
||||
renameAusschnitt, deleteAusschnitt,
|
||||
setAusschnittFolder, setAusschnittScale,
|
||||
duplicateAusschnitt, addAusschnittFolder, removeAusschnittFolder,
|
||||
getAusschnittLayers, updateAusschnittLayers,
|
||||
saveLayerPreset, deleteLayerPreset,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
function EditableInline({ value, onCommit, autoEdit, style, fontSize }) {
|
||||
const [editing, setEditing] = useState(autoEdit || false)
|
||||
const [val, setVal] = useState(value)
|
||||
useEffect(() => { setVal(value) }, [value])
|
||||
useEffect(() => { if (autoEdit) setEditing(true) }, [autoEdit])
|
||||
|
||||
const commit = () => {
|
||||
const trimmed = (val ?? '').trim()
|
||||
if (trimmed && trimmed !== value) onCommit(trimmed)
|
||||
else setVal(value)
|
||||
setEditing(false)
|
||||
}
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
value={val}
|
||||
autoFocus
|
||||
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, fontSize, padding: '2px 6px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
onDoubleClick={(ev) => { ev.stopPropagation(); setVal(value); setEditing(true) }}
|
||||
style={{ ...style, fontSize, cursor: 'text' }}
|
||||
>{value}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ScaleCell({ snap, onChange }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [val, setVal] = useState(snap.scale || '')
|
||||
useEffect(() => { setVal(snap.scale || '') }, [snap.scale])
|
||||
|
||||
const commit = () => {
|
||||
onChange(snap.id, val.trim())
|
||||
setEditing(false)
|
||||
}
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
value={val}
|
||||
autoFocus
|
||||
onChange={(ev) => setVal(ev.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') commit()
|
||||
if (ev.key === 'Escape') { setVal(snap.scale || ''); setEditing(false) }
|
||||
}}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
placeholder="1:50"
|
||||
style={{ width: 64, fontSize: 10, padding: '2px 6px', textAlign: 'right' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
onDoubleClick={(ev) => { ev.stopPropagation(); setEditing(true) }}
|
||||
className={snap.scale ? 'chip chip-accent' : 'chip'}
|
||||
style={{
|
||||
fontSize: 9, cursor: 'text',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={snap.scale ? `Maßstab ${snap.scale} — wird auf Detail angewendet` : 'Doppelklick um Maßstab einzutragen (z.B. 1:50)'}
|
||||
>{snap.scale || '—:—'}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function OrientationBadge({ orientation }) {
|
||||
const variant = orientation === 'perspective' ? {
|
||||
icon: 'view_in_ar', color: 'var(--accent)',
|
||||
title: 'Perspektive',
|
||||
} : orientation === 'horizontal' ? {
|
||||
icon: 'align_horizontal_center', color: 'var(--active)',
|
||||
title: 'Horizontaler Schnitt (Grundriss)',
|
||||
} : {
|
||||
icon: 'align_vertical_center', color: 'var(--warn)',
|
||||
title: 'Vertikaler Schnitt (Schnitt / Ansicht)',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title={variant.title}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, flexShrink: 0,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-input)',
|
||||
color: variant.color,
|
||||
}}
|
||||
>
|
||||
<Icon name={variant.icon} size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AusschnittCard({ snap, onClick, onContextMenu, onMenuClick, onRename, onScaleChange, onDragStart, onDragEnd, dragging }) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--r)',
|
||||
background: 'var(--bg-input)',
|
||||
cursor: 'grab', userSelect: 'none',
|
||||
marginBottom: 4,
|
||||
opacity: dragging ? 0.4 : 1,
|
||||
transition: 'background 0.14s, border-color 0.14s, opacity 0.14s',
|
||||
}}
|
||||
onMouseEnter={(ev) => { ev.currentTarget.style.background = 'var(--bg-item-hover)' }}
|
||||
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'var(--bg-input)' }}
|
||||
>
|
||||
<OrientationBadge orientation={snap.orientation} />
|
||||
<EditableInline
|
||||
value={snap.name}
|
||||
onCommit={(n) => onRename(snap.id, n)}
|
||||
fontSize={11}
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
color: 'var(--text-primary)', fontWeight: 500,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
<ScaleCell snap={snap} onChange={onScaleChange} />
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
|
||||
title="Aktionen"
|
||||
>
|
||||
<Icon name="more_vert" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderCard({
|
||||
name, count, collapsed, onToggle, onContextMenu, onMenuClick,
|
||||
onDragOver, onDragLeave, onDrop, dragOver, children,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: dragOver ? 'var(--accent-dim)' : 'var(--bg-section)',
|
||||
marginBottom: 8,
|
||||
padding: 8,
|
||||
transition: 'background 0.14s, border-color 0.14s',
|
||||
borderColor: dragOver ? 'var(--accent-border)' : 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: 'var(--text-muted)', display: 'inline-flex',
|
||||
}}>
|
||||
<Icon name="arrow_drop_down" size={16} />
|
||||
</span>
|
||||
<Icon name="folder" size={14} style={{ color: 'var(--warn)' }} />
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 500, color: 'var(--text-primary)' }}>{name}</span>
|
||||
<span className="chip" style={{ fontSize: 8 }}>{count}</span>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
|
||||
title="Ordner-Aktionen"
|
||||
>
|
||||
<Icon name="more_vert" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column' }}>
|
||||
{children || (
|
||||
<div style={{
|
||||
padding: '10px 6px', fontSize: 10, color: 'var(--text-muted)',
|
||||
textAlign: 'center', fontStyle: 'italic',
|
||||
}}>
|
||||
Leer — Ausschnitte hier ablegen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RootDropZone({ children, onDragOver, onDragLeave, onDrop, dragOver, empty }) {
|
||||
return (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
background: dragOver ? 'var(--accent-dim)' : 'transparent',
|
||||
border: '1px dashed transparent',
|
||||
borderColor: dragOver ? 'var(--accent-border)' : 'transparent',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: dragOver || empty ? 6 : 0,
|
||||
marginBottom: empty ? 0 : 8,
|
||||
transition: 'background 0.14s, border-color 0.14s, padding 0.14s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AusschnitteApp() {
|
||||
const [snaps, setSnaps] = useState([])
|
||||
const [extraFolders, setExtraFolders] = useState([])
|
||||
const [presets, setPresets] = useState([])
|
||||
const [newName, setNewName] = useState('')
|
||||
const [ctxMenu, setCtxMenu] = useState(null)
|
||||
const [collapsed, setCollapsed] = useState({})
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [dragTarget, setDragTarget] = useState(null)
|
||||
const [layerDialog, setLayerDialog] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('LIST', ({ snapshots, folders, presets }) => {
|
||||
setSnaps(snapshots || [])
|
||||
setExtraFolders(folders || [])
|
||||
setPresets(presets || [])
|
||||
})
|
||||
onMessage('LAYERS_DATA', ({ id, name, layers, presets }) => {
|
||||
setLayerDialog({ id, name, layers: layers || [], presets: presets || [] })
|
||||
})
|
||||
notifyReady()
|
||||
const blockContext = (ev) => ev.preventDefault()
|
||||
document.addEventListener('contextmenu', blockContext)
|
||||
return () => document.removeEventListener('contextmenu', blockContext)
|
||||
}, [])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = {}
|
||||
snaps.forEach(s => {
|
||||
const f = s.folder || ''
|
||||
if (!map[f]) map[f] = []
|
||||
map[f].push(s)
|
||||
})
|
||||
return map
|
||||
}, [snaps])
|
||||
|
||||
const allFolders = useMemo(() => {
|
||||
const set = new Set(extraFolders)
|
||||
snaps.forEach(s => { if (s.folder) set.add(s.folder) })
|
||||
return [...set].sort((a, b) => a.localeCompare(b))
|
||||
}, [snaps, extraFolders])
|
||||
|
||||
const handleSave = () => {
|
||||
const name = newName.trim() || `Ausschnitt ${snaps.length + 1}`
|
||||
saveAusschnitt(name)
|
||||
setNewName('')
|
||||
}
|
||||
|
||||
const handleAddFolder = () => {
|
||||
const name = window.prompt('Name für neuen Ordner:')
|
||||
if (name && name.trim()) addAusschnittFolder(name.trim())
|
||||
}
|
||||
|
||||
const ctxItems = (id) => [
|
||||
{ label: 'Wiederherstellen', icon: 'restore', onClick: () => restoreAusschnitt(id) },
|
||||
{ label: 'Auf Detail anwenden', icon: 'crop_landscape', onClick: () => applyAusschnittToDetail(id) },
|
||||
{ divider: true },
|
||||
{ label: 'Sichtbarkeit bearbeiten…', icon: 'layers', onClick: () => getAusschnittLayers(id) },
|
||||
{ divider: true },
|
||||
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateAusschnitt(id) },
|
||||
{ label: 'Aktualisieren', icon: 'sync', onClick: () => updateAusschnitt(id) },
|
||||
{ divider: true },
|
||||
{ label: 'Löschen', icon: 'delete', danger: true, onClick: () => deleteAusschnitt(id) },
|
||||
]
|
||||
|
||||
const folderCtxItems = (folderName) => [
|
||||
{ label: 'Ordner umbenennen', icon: 'edit', onClick: () => {
|
||||
const newName = window.prompt('Neuer Ordnername:', folderName)
|
||||
if (newName && newName.trim() && newName !== folderName) {
|
||||
snaps.filter(s => s.folder === folderName).forEach(s => setAusschnittFolder(s.id, newName.trim()))
|
||||
addAusschnittFolder(newName.trim())
|
||||
removeAusschnittFolder(folderName)
|
||||
}
|
||||
}},
|
||||
{ divider: true },
|
||||
{ label: 'Ordner löschen', icon: 'folder_off', danger: true, onClick: () => {
|
||||
if (window.confirm(`Ordner "${folderName}" löschen? Ausschnitte werden zur Wurzel verschoben.`)) {
|
||||
removeAusschnittFolder(folderName)
|
||||
}
|
||||
}},
|
||||
]
|
||||
|
||||
const handleDrop = (folderName) => (ev) => {
|
||||
ev.preventDefault()
|
||||
setDragTarget(null)
|
||||
const id = ev.dataTransfer.getData('text/plain') || draggingId
|
||||
if (id) setAusschnittFolder(id, folderName || '')
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (folderName) => (ev) => {
|
||||
ev.preventDefault()
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
setDragTarget(folderName || 'root')
|
||||
}
|
||||
|
||||
const handleDragLeave = () => () => setDragTarget(null)
|
||||
|
||||
const renderSnapshot = (s) => (
|
||||
<AusschnittCard
|
||||
key={s.id}
|
||||
snap={s}
|
||||
dragging={draggingId === s.id}
|
||||
onClick={() => restoreAusschnitt(s.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, id: s.id, kind: 'snap' }) }}
|
||||
onMenuClick={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, id: s.id, kind: 'snap' })}
|
||||
onRename={(id, name) => renameAusschnitt(id, name)}
|
||||
onScaleChange={(id, scale) => setAusschnittScale(id, scale)}
|
||||
onDragStart={(ev) => {
|
||||
ev.dataTransfer.setData('text/plain', s.id)
|
||||
ev.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingId(s.id)
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
|
||||
/>
|
||||
)
|
||||
|
||||
const actions = (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="btn-icon-tonal" onClick={handleAddFolder} title="Neuer Ordner">
|
||||
<Icon name="create_new_folder" size={14} />
|
||||
</button>
|
||||
<button className="btn-icon-tonal" onClick={() => listAusschnitte()} title="Aktualisieren">
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const rootItems = groups[''] || []
|
||||
const isEmpty = snaps.length === 0 && allFolders.length === 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100vh', overflow: 'hidden',
|
||||
background: 'var(--bg-base)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Fixed Header — wie Layouts/Overrides Pattern */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em',
|
||||
color: 'var(--text-primary)' }}>
|
||||
AUSSCHNITTE
|
||||
</span>
|
||||
<span className="chip" style={{ fontSize: 8 }}>{snaps.length}</span>
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
|
||||
{/* Save-Bar als Card */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: 8,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
marginBottom: 8,
|
||||
marginTop: 6,
|
||||
}}>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(ev) => setNewName(ev.target.value)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter') handleSave() }}
|
||||
placeholder="Name für neuen Ausschnitt…"
|
||||
style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font)', minWidth: 0 }}
|
||||
/>
|
||||
<button className="btn-add" onClick={handleSave} title="Ausschnitt speichern">
|
||||
<Icon name="add" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div style={{
|
||||
padding: '32px 16px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<Icon name="photo_library" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div style={{ marginTop: 8 }}>Noch keine Ausschnitte.</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10 }}>Oben einen Namen eingeben und <Icon name="add" size={11} /> klicken.</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Root-Snapshots */}
|
||||
<RootDropZone
|
||||
dragOver={dragTarget === 'root'}
|
||||
empty={rootItems.length === 0}
|
||||
onDragOver={handleDragOver('')}
|
||||
onDragLeave={handleDragLeave()}
|
||||
onDrop={handleDrop('')}
|
||||
>
|
||||
{rootItems.map(s => renderSnapshot(s))}
|
||||
{rootItems.length === 0 && draggingId && (
|
||||
<div style={{
|
||||
padding: '8px 14px', fontSize: 10, color: 'var(--text-muted)',
|
||||
textAlign: 'center', fontStyle: 'italic',
|
||||
}}>Hier ablegen für Wurzel</div>
|
||||
)}
|
||||
</RootDropZone>
|
||||
|
||||
{/* Ordner-Cards */}
|
||||
{allFolders.map(folder => {
|
||||
const isCollapsed = !!collapsed[folder]
|
||||
const items = groups[folder] || []
|
||||
return (
|
||||
<FolderCard
|
||||
key={folder}
|
||||
name={folder}
|
||||
count={items.length}
|
||||
collapsed={isCollapsed}
|
||||
dragOver={dragTarget === folder}
|
||||
onToggle={() => setCollapsed(c => ({ ...c, [folder]: !c[folder] }))}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, name: folder, kind: 'folder' }) }}
|
||||
onMenuClick={(ev) => setCtxMenu({ x: ev.clientX, y: ev.clientY, name: folder, kind: 'folder' })}
|
||||
onDragOver={handleDragOver(folder)}
|
||||
onDragLeave={handleDragLeave()}
|
||||
onDrop={handleDrop(folder)}
|
||||
>
|
||||
{items.length > 0 ? items.map(s => renderSnapshot(s)) : null}
|
||||
</FolderCard>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div style={{ padding: '12px 4px 0', fontSize: 9, color: 'var(--text-muted)',
|
||||
lineHeight: 1.6, fontStyle: 'italic' }}>
|
||||
Drag & Drop auf Ordner-Card zum Verschieben · Doppelklick auf Name/Maßstab = bearbeiten · ⋮ für Aktionen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x} y={ctxMenu.y}
|
||||
items={ctxMenu.kind === 'folder' ? folderCtxItems(ctxMenu.name) : ctxItems(ctxMenu.id)}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{layerDialog && (
|
||||
<AusschnittLayerDialog
|
||||
snapName={layerDialog.name}
|
||||
layers={layerDialog.layers}
|
||||
presets={layerDialog.presets}
|
||||
onSave={(layers) => {
|
||||
updateAusschnittLayers(layerDialog.id,
|
||||
layers.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
|
||||
setLayerDialog(null)
|
||||
}}
|
||||
onClose={() => setLayerDialog(null)}
|
||||
onSavePreset={(name, layers) => {
|
||||
saveLayerPreset(name, layers)
|
||||
setLayerDialog(d => d ? { ...d, presets: [...d.presets.filter(p => p.name !== name), { name, layers }] } : d)
|
||||
}}
|
||||
onDeletePreset={(name) => {
|
||||
deleteLayerPreset(name)
|
||||
setLayerDialog(d => d ? { ...d, presets: d.presets.filter(p => p.name !== name) } : d)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
setRefPoint, setCoordSystem,
|
||||
setDimPosition, setDimDimension, setDimRotationZ,
|
||||
setCircleRadius, setLineLength, setRectangleDims,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
const REF_CODES = ['min', 'mid', 'max'] // row/col mapping
|
||||
const REF_LABELS = { min: 'min', mid: 'mid', max: 'max' }
|
||||
|
||||
function fmtNum(v) {
|
||||
if (v == null) return ''
|
||||
if (typeof v !== 'number') return String(v)
|
||||
// 4 Nachkommastellen, aber unnoetige Nullen weg
|
||||
return Number(v.toFixed(4)).toString()
|
||||
}
|
||||
|
||||
// Input-Komponente: zeigt formatierten Wert, sendet onCommit bei Enter/Blur.
|
||||
// Verhindert Update waehrend des Tippens, damit der Cursor nicht springt.
|
||||
function NumInput({ value, onCommit, disabled, suffix, width }) {
|
||||
const [text, setText] = useState(fmtNum(value))
|
||||
const [focused, setFocused] = useState(false)
|
||||
useEffect(() => { if (!focused) setText(fmtNum(value)) }, [value, focused])
|
||||
const commit = () => {
|
||||
const v = parseFloat(text.replace(',', '.'))
|
||||
if (!isNaN(v) && v !== value) onCommit(v)
|
||||
else setText(fmtNum(value)) // ungueltig → zurueck auf alten Wert
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flex: width ? 0 : 1, width }}>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onFocus={(e) => { setFocused(true); e.target.select() }}
|
||||
onBlur={() => { setFocused(false); commit() }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.target.blur() }
|
||||
else if (e.key === 'Escape') { setText(fmtNum(value)); e.target.blur() }
|
||||
}}
|
||||
style={{ flex: 1, width: '100%', fontFamily: 'DM Mono, monospace', fontSize: 11, textAlign: 'right' }}
|
||||
/>
|
||||
{suffix && <span style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0 }}>{suffix}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 9-Punkt-Referenzpunkt-Selektor im Illustrator-Stil: sichtbarer BBox-Rahmen,
|
||||
// die Punkte sitzen AUF Ecken / Kantenmitten / Zentrum.
|
||||
function RefPointGrid({ ref, onChange }) {
|
||||
const SIZE = 26 // Aussenkanten-Quadrat (px)
|
||||
const DOT = 5 // Punkt-Durchmesser (px)
|
||||
// Position pro Code: 0% (min), 50% (mid), 100% (max)
|
||||
const pct = (c) => c === 'min' ? '0%' : c === 'max' ? '100%' : '50%'
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: SIZE, height: SIZE,
|
||||
border: '1px solid var(--text-muted)',
|
||||
background: 'transparent',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{REF_CODES.map(yc => REF_CODES.map(xc => {
|
||||
const active = ref.x === xc && ref.y === yc
|
||||
// yc 'max' = top in user mental model (Vectorworks/Illustrator)
|
||||
const topPct = yc === 'max' ? '0%' : yc === 'min' ? '100%' : '50%'
|
||||
return (
|
||||
<button
|
||||
key={`${xc}-${yc}`}
|
||||
onClick={() => onChange({ ...ref, x: xc, y: yc })}
|
||||
title={`X=${xc}, Y=${yc}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: pct(xc), top: topPct,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: DOT, height: DOT, padding: 0,
|
||||
borderRadius: 0, // eckig wie Illustrator
|
||||
background: active ? 'var(--accent)' : 'var(--text-muted)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--text-primary)' }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'var(--text-muted)' }}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Z-Referenz-Selektor (Bottom / Mid / Top) — kompakt, nur Icons.
|
||||
function RefZSelector({ z, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{[
|
||||
{ code: 'max', icon: 'vertical_align_top', title: 'Z = Top' },
|
||||
{ code: 'mid', icon: 'vertical_align_center', title: 'Z = Mid' },
|
||||
{ code: 'min', icon: 'vertical_align_bottom', title: 'Z = Bottom' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.code}
|
||||
onClick={() => onChange(opt.code)}
|
||||
className={z === opt.code ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{
|
||||
padding: '2px 5px', fontSize: 10,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
title={opt.title}
|
||||
>
|
||||
<Icon name={opt.icon} size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline-Label vor einem Input — minimal, knackig
|
||||
function Field({ label, children, style }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, ...style }}>
|
||||
<span style={{
|
||||
width: 12, fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--text-muted)', textAlign: 'center',
|
||||
fontFamily: 'DM Mono, monospace', flexShrink: 0,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Hauptkomponente ------------------------------------------------------
|
||||
|
||||
export default function DimensionenApp() {
|
||||
const [state, setState] = useState({
|
||||
selection: { count: 0, type: 'none' },
|
||||
refPoint: { x: 'min', y: 'min', z: 'mid' },
|
||||
coordSystem: 'world',
|
||||
position: null,
|
||||
dimensions: null,
|
||||
shape: null,
|
||||
planeName: 'Welt',
|
||||
})
|
||||
const [rotationDelta, setRotationDelta] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE', (s) => setState((prev) => ({ ...prev, ...s })))
|
||||
notifyReady()
|
||||
}, [])
|
||||
|
||||
const sel = state.selection || { count: 0, type: 'none' }
|
||||
const ref = state.refPoint || { x: 'min', y: 'min', z: 'mid' }
|
||||
const pos = state.position
|
||||
const dims = state.dimensions
|
||||
const shape = state.shape
|
||||
const hasSelection = sel.count > 0 && pos != null
|
||||
|
||||
const onRefChange = (next) => setRefPoint(next.x, next.y, next.z)
|
||||
const onCoordChange = (mode) => setCoordSystem(mode)
|
||||
|
||||
// Selektions-Beschriftung
|
||||
const selLabel = () => {
|
||||
if (sel.count === 0) return 'Keine Selektion'
|
||||
if (sel.count === 1) {
|
||||
const map = {
|
||||
curve: 'Kurve', brep: 'Brep', mesh: 'Mesh', extrusion: 'Extrusion',
|
||||
block: 'Block', point: 'Punkt', text: 'Text', other: 'Objekt',
|
||||
}
|
||||
let base = map[sel.type] || '1 Objekt'
|
||||
if (shape?.type === 'circle') base = 'Kreis'
|
||||
if (shape?.type === 'rectangle') base = 'Rechteck'
|
||||
if (shape?.type === 'line') base = 'Linie'
|
||||
return '1 ' + base
|
||||
}
|
||||
return `${sel.count} Objekte`
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100vh', overflow: 'hidden',
|
||||
background: 'var(--bg-base)', color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font)', fontSize: 11,
|
||||
}}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 6 }}>
|
||||
|
||||
{/* Header: Selektions-Info + World/CPlane */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6,
|
||||
padding: '5px 8px',
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<Icon name="select_all" size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{selLabel()}</span>
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
<button
|
||||
onClick={() => onCoordChange('world')}
|
||||
className={state.coordSystem === 'world' ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ fontSize: 10, padding: '3px 8px' }}
|
||||
title="Weltkoordinaten"
|
||||
>Welt</button>
|
||||
<button
|
||||
onClick={() => onCoordChange('cplane')}
|
||||
className={state.coordSystem === 'cplane' ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ fontSize: 10, padding: '3px 8px' }}
|
||||
title="Aktive Konstruktionsebene"
|
||||
>CPlane</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasSelection ? (
|
||||
<div style={{
|
||||
padding: '32px 16px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<Icon name="aspect_ratio" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div style={{ marginTop: 8 }}>Keine Selektion.</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||||
In Rhino ein oder mehrere Objekte auswaehlen.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Referenzpunkt — kompakte einzeilige Card */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 8px', marginBottom: 6,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
Ref
|
||||
</span>
|
||||
<RefPointGrid ref={ref} onChange={onRefChange} />
|
||||
<RefZSelector z={ref.z} onChange={(z) => onRefChange({ ...ref, z })} />
|
||||
</div>
|
||||
|
||||
{/* Position + Abmessungen nebeneinander */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Position</span>
|
||||
<span style={{ fontWeight: 400, textTransform: 'none',
|
||||
fontFamily: 'DM Mono, monospace', fontSize: 9 }}>
|
||||
{state.planeName}
|
||||
</span>
|
||||
</div>
|
||||
<Field label="X"><NumInput value={pos.x} onCommit={(v) => setDimPosition('x', v)} /></Field>
|
||||
<Field label="Y"><NumInput value={pos.y} onCommit={(v) => setDimPosition('y', v)} /></Field>
|
||||
<Field label="Z"><NumInput value={pos.z} onCommit={(v) => setDimPosition('z', v)} /></Field>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
BBox
|
||||
</div>
|
||||
<Field label="B"><NumInput value={dims?.width} onCommit={(v) => setDimDimension('width', v)} /></Field>
|
||||
<Field label="T"><NumInput value={dims?.depth} onCommit={(v) => setDimDimension('depth', v)} /></Field>
|
||||
<Field label="H"><NumInput value={dims?.height} onCommit={(v) => setDimDimension('height', v)} /></Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shape-spezifisch */}
|
||||
{shape && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '6px 8px', marginBottom: 6,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--accent)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
{shape.type === 'circle' && 'Kreis'}
|
||||
{shape.type === 'rectangle' && 'Rechteck'}
|
||||
{shape.type === 'line' && 'Linie'}
|
||||
</div>
|
||||
{shape.type === 'circle' && (
|
||||
<Field label="R"><NumInput value={shape.radius} onCommit={(v) => setCircleRadius(v)} /></Field>
|
||||
)}
|
||||
{shape.type === 'rectangle' && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Field label="W" style={{ flex: 1 }}>
|
||||
<NumInput value={shape.width}
|
||||
onCommit={(v) => setRectangleDims(v, shape.height)} />
|
||||
</Field>
|
||||
<Field label="H" style={{ flex: 1 }}>
|
||||
<NumInput value={shape.height}
|
||||
onCommit={(v) => setRectangleDims(shape.width, v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{shape.type === 'line' && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Field label="L" style={{ flex: 1 }}>
|
||||
<NumInput value={shape.length} onCommit={(v) => setLineLength(v)} />
|
||||
</Field>
|
||||
<Field label="α" style={{ flex: 1 }}>
|
||||
<NumInput value={shape.angle} onCommit={() => {}} disabled suffix="°" />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotation — kompakt einzeilig */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 8px', marginBottom: 6,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
Drehen
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumInput value={rotationDelta} onCommit={setRotationDelta} suffix="°" />
|
||||
</div>
|
||||
<button
|
||||
className="btn-outlined"
|
||||
onClick={() => { if (rotationDelta) setDimRotationZ(rotationDelta) }}
|
||||
disabled={!rotationDelta}
|
||||
title="Selektion um Z-Achse der aktiven Plane drehen"
|
||||
style={{ padding: '3px 8px', fontSize: 11 }}
|
||||
>
|
||||
<Icon name="rotate_right" size={13} />
|
||||
</button>
|
||||
{[-90, -45, 45, 90].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
className="btn-outlined"
|
||||
onClick={() => setDimRotationZ(a)}
|
||||
style={{ padding: '3px 5px', fontSize: 9, minWidth: 28 }}
|
||||
title={`${a}°`}
|
||||
>
|
||||
{a > 0 ? '+' : ''}{a}°
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1391
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
requestSelection, setColorSource, setLwSource, setLinetypeSource, setLinetypeScale, setFill,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
const LW_PRESETS = [0.02, 0.10, 0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHead({ title }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px 6px' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
{title}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Source-Dropdown im Vectorworks-Stil: Link-Icon + Label + Caret */
|
||||
function SourceSelect({ source, onChange, overrideLabel = 'Eigene' }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'flex' }}>
|
||||
<Icon
|
||||
name={source === 'layer' ? 'link' : 'link_off'}
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
|
||||
color: source === 'layer' ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
style={{
|
||||
paddingLeft: 30, flex: 1,
|
||||
fontFamily: 'var(--font)',
|
||||
fontWeight: 500,
|
||||
fontSize: 11,
|
||||
textTransform: 'none',
|
||||
/* borderRadius/background uebernehmen wir von der globalen <select>-
|
||||
Pille (index.css) damit's optisch konsistent mit dem Fill-Dropdown
|
||||
wirkt. Nur paddingLeft ueberschreiben wegen Link-Icon. */
|
||||
}}
|
||||
>
|
||||
<option value="layer">Nach Ebene</option>
|
||||
<option value="object">{overrideLabel}</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorBar({ color, onChange, height = 22 }) {
|
||||
// Readonly-Variante als reines Anzeige-Rechteck
|
||||
if (!onChange) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%', height,
|
||||
background: color,
|
||||
borderRadius: 'var(--r)',
|
||||
border: '1px solid var(--border)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Editierbar: <label> umschliesst einen sichtbaren Swatch-Div + einen
|
||||
// unsichtbaren color-input. Click aufs Label routet automatisch zum Input
|
||||
// und oeffnet den nativen Picker. Die Anzeige (div background) ist voll
|
||||
// unter unserer Kontrolle und wird durch local-State live aktualisiert.
|
||||
// Debounce 150ms verhindert Python-Spam waehrend Picker-Drag.
|
||||
const [local, setLocal] = useState(color || '#888888')
|
||||
const timer = useRef(null)
|
||||
useEffect(() => {
|
||||
if (color && color !== local) {
|
||||
// Selektion wechselt -> evtl. noch laufenden Debounce abbrechen,
|
||||
// sonst feuert die alte Pick-Farbe auf das neue Objekt.
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||
setLocal(color)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [color])
|
||||
|
||||
const handle = (ev) => {
|
||||
const v = ev.target.value
|
||||
if (!v || !/^#[0-9a-fA-F]{6}$/.test(v)) return
|
||||
setLocal(v)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => onChange(v), 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
position: 'relative', display: 'block',
|
||||
width: '100%', height,
|
||||
borderRadius: 'var(--r)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: local,
|
||||
pointerEvents: 'none',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={local}
|
||||
onInput={handle}
|
||||
onChange={handle}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
width: '100%', height: '100%',
|
||||
opacity: 0,
|
||||
border: 'none', padding: 0, margin: 0,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/** Number-Input mit Commit-on-Blur/Enter und externer Sync. */
|
||||
function NumInput({ value, onCommit, step = 0.01, min, max, width = 56, mono = true }) {
|
||||
const [val, setVal] = useState(String(value))
|
||||
useEffect(() => { setVal(typeof value === 'number' ? value.toFixed(2) : String(value)) }, [value])
|
||||
const commit = () => {
|
||||
const v = parseFloat(val)
|
||||
if (!isNaN(v) && (min == null || v >= min) && (max == null || v <= max)) onCommit(v)
|
||||
else setVal(typeof value === 'number' ? value.toFixed(2) : String(value))
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="number" step={step} min={min} max={max}
|
||||
value={val}
|
||||
onChange={(ev) => setVal(ev.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') ev.target.blur()
|
||||
if (ev.key === 'Escape') { setVal(typeof value === 'number' ? value.toFixed(2) : String(value)); ev.target.blur() }
|
||||
}}
|
||||
style={{ width, textAlign: 'right', fontSize: 11, fontFamily: mono ? 'var(--font-mono)' : 'var(--font)' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LwPreview({ lw }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, height: 22,
|
||||
background: 'var(--bg-item)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '0 8px', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: Math.max(1, Math.min(8, lw * 6)),
|
||||
background: 'var(--text-primary)',
|
||||
borderRadius: 1,
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pen color
|
||||
|
||||
function PenColor({ sel }) {
|
||||
const source = sel.colorSource === 'mixed' ? 'object' : (sel.colorSource || 'layer')
|
||||
const effective = source === 'object'
|
||||
? (sel.color || '#888888')
|
||||
: (sel.layerColor || '#888888')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<SourceSelect
|
||||
source={source}
|
||||
onChange={(v) => setColorSource(v, v === 'object' ? effective : null)}
|
||||
/>
|
||||
<ColorBar
|
||||
color={effective}
|
||||
onChange={(c) => setColorSource('object', c)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// LW row inline
|
||||
function PenLw({ sel }) {
|
||||
const source = sel.lwSource === 'mixed' ? 'object' : (sel.lwSource || 'layer')
|
||||
const effective = source === 'object'
|
||||
? (sel.lw ?? 0.18)
|
||||
: (sel.layerLw ?? 0.18)
|
||||
|
||||
const step = (dir) => {
|
||||
let next
|
||||
if (dir > 0) next = LW_PRESETS.find(p => p > effective + 0.001)
|
||||
else next = [...LW_PRESETS].reverse().find(p => p < effective - 0.001)
|
||||
if (next !== undefined) setLwSource('object', next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={() => setLwSource(source === 'object' ? 'layer' : 'object', source === 'object' ? null : effective)}
|
||||
title={source === 'object' ? 'Nach Ebene' : 'Uebersteuern'}
|
||||
style={{ color: source === 'layer' ? 'var(--text-primary)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Icon name={source === 'layer' ? 'link' : 'link_off'} size={14} />
|
||||
</button>
|
||||
<LwPreview lw={effective} />
|
||||
<NumInput
|
||||
value={effective}
|
||||
onCommit={(v) => setLwSource('object', v)}
|
||||
step={0.01} min={0.01} max={2.0}
|
||||
width={52}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<button className="btn-step" onClick={() => step(+1)}><Icon name="arrow_drop_up" size={12}/></button>
|
||||
<button className="btn-step" onClick={() => step(-1)}><Icon name="arrow_drop_down" size={12}/></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PenLinetype({ sel }) {
|
||||
const source = sel.linetypeSource === 'mixed' ? 'object' : (sel.linetypeSource || 'layer')
|
||||
const effective = source === 'object'
|
||||
? (sel.linetype || sel.layerLinetype || (sel.linetypes?.[0] || ''))
|
||||
: (sel.layerLinetype || (sel.linetypes?.[0] || ''))
|
||||
const options = sel.linetypes || []
|
||||
const hasOption = options.includes(effective)
|
||||
const ltScale = sel.linetypeScale ?? 1.0
|
||||
const isSolid = (effective || '').toLowerCase() === 'continuous'
|
||||
|
||||
// Spezialwert "__layer__" damit dieselbe Pill wie bei Color/Fill — Klick
|
||||
// auf den Wert "Nach Ebene" wechselt zurueck auf source=layer.
|
||||
const ltCurrent = source === 'layer' ? '__layer__' : effective
|
||||
const ltChange = (v) => {
|
||||
if (v === '__layer__') {
|
||||
setLinetypeSource('layer', null)
|
||||
} else {
|
||||
setLinetypeSource('object', v)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'relative', display: 'flex' }}>
|
||||
<Icon
|
||||
name={source === 'layer' ? 'link' : 'link_off'}
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
|
||||
color: source === 'layer' ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={ltCurrent}
|
||||
onChange={(ev) => ltChange(ev.target.value)}
|
||||
style={{
|
||||
paddingLeft: 30, flex: 1,
|
||||
fontFamily: 'var(--font)',
|
||||
fontWeight: 500,
|
||||
fontSize: 11,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="__layer__">Nach Ebene</option>
|
||||
{!hasOption && effective && <option value={effective}>{effective}</option>}
|
||||
{options.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!isSolid && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Icon name="open_in_full" size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} title="Linientyp-Skalierung" />
|
||||
<NumInput
|
||||
value={ltScale} step={0.1} min={0.001} max={1000}
|
||||
onCommit={(v) => setLinetypeScale(v)}
|
||||
width={70}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>× Pattern</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PenBlock({ sel }) {
|
||||
return (
|
||||
<div style={{ padding: '0 14px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<PenColor sel={sel} />
|
||||
<PenLw sel={sel} />
|
||||
<PenLinetype sel={sel} />
|
||||
{sel.layerName && (
|
||||
<div style={{ fontSize: 9, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
Ebene: {sel.layerName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FillBlock({ sel }) {
|
||||
if (sel.canFill !== true) {
|
||||
return (
|
||||
<div style={{ padding: '0 14px 12px', fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Keine geschlossenen 2D-Kurven in der Auswahl.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const enabled = sel.fillEnabled === true
|
||||
const source = sel.fillSource || 'layer'
|
||||
const color = sel.fillColor || sel.layerColor || '#cccccc'
|
||||
const objectPat = enabled ? (sel.fillPattern || 'Solid') : 'None'
|
||||
const scale = sel.fillScale ?? 1.0
|
||||
const rotation = sel.fillRotation ?? 0.0
|
||||
const patternList = sel.hatchPatterns || ['Solid']
|
||||
|
||||
// Special-Value im kombinierten Dropdown:
|
||||
// "__layer__" = Nach Ebene -> Pattern/Scale/Rot/Color aus Ebenen-Einstellungen
|
||||
// "None" = keine Fuellung
|
||||
// "Solid" = volle Flaeche (Eigene Quelle)
|
||||
// sonst = Hatch-Pattern (Eigene Quelle)
|
||||
// source=='layer' -> immer "Nach Ebene" zeigen, auch wenn (noch) keine Hatch
|
||||
// existiert (Ebene hat halt aktuell kein Pattern -> wird gefuellt, sobald
|
||||
// eines definiert wird).
|
||||
const currentValue = source === 'layer'
|
||||
? '__layer__'
|
||||
: (!enabled ? 'None' : objectPat)
|
||||
|
||||
const dropdownOptions = [
|
||||
{ value: '__layer__', label: 'Nach Ebene' },
|
||||
{ value: 'None', label: 'None' },
|
||||
{ value: 'Solid', label: 'Solid' },
|
||||
...patternList
|
||||
.filter(p => p !== 'Solid' && p !== 'None')
|
||||
.map(p => ({ value: p, label: p })),
|
||||
]
|
||||
// Falls aktueller Pattern-Name nicht in der Liste, anhaengen damit's nicht verloren geht
|
||||
if (currentValue !== '__layer__' && currentValue !== 'None'
|
||||
&& !dropdownOptions.some(o => o.value === currentValue)) {
|
||||
dropdownOptions.push({ value: currentValue, label: currentValue })
|
||||
}
|
||||
|
||||
const applyPattern = (newValue) => {
|
||||
if (newValue === '__layer__') {
|
||||
// Source -> layer, Python liest Pattern/Scale/Rot/Color aus Layer.SectionStyle
|
||||
setFill(true, 'layer', null, null, null, null)
|
||||
} else if (newValue === 'None') {
|
||||
setFill(false, source, null, null, scale, rotation)
|
||||
} else {
|
||||
// Eigene Quelle mit gewaehltem Pattern
|
||||
setFill(true, 'object', color, newValue, scale, rotation)
|
||||
}
|
||||
}
|
||||
// Anpassungen wenn schon im "Eigene"-Modus (Scale/Rotation/Color/Source-Toggle)
|
||||
const apply = (over) => setFill(
|
||||
true,
|
||||
over.source ?? (source === 'layer' ? 'object' : source),
|
||||
(over.source ?? source) === 'layer' ? null : (over.color ?? color),
|
||||
over.pattern ?? (objectPat === 'None' ? 'Solid' : objectPat),
|
||||
over.scale ?? scale,
|
||||
over.rotation ?? rotation,
|
||||
)
|
||||
const isLayerSource = source === 'layer'
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 14px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* Pill mit Link-Icon im Dropdown (analog zur Pen-Source-Pill).
|
||||
Link-Icon wenn "Nach Ebene", sonst link_off. */}
|
||||
<div style={{ position: 'relative', display: 'flex' }}>
|
||||
<Icon
|
||||
name={isLayerSource ? 'link' : 'link_off'}
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
|
||||
color: isLayerSource ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={currentValue}
|
||||
onChange={(ev) => applyPattern(ev.target.value)}
|
||||
style={{
|
||||
paddingLeft: 30, flex: 1,
|
||||
fontFamily: 'var(--font)',
|
||||
fontWeight: 500,
|
||||
fontSize: 11,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
{dropdownOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{currentValue !== 'None' && (
|
||||
<>
|
||||
<ColorBar
|
||||
color={color}
|
||||
onChange={isLayerSource ? undefined : ((c) => apply({ source: 'object', color: c }))}
|
||||
/>
|
||||
{!isLayerSource && objectPat !== 'Solid' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Icon name="aspect_ratio" size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} title="Skalierung" />
|
||||
<NumInput
|
||||
value={scale} step={0.05} min={0.001}
|
||||
onCommit={(v) => apply({ scale: v })}
|
||||
width={70}
|
||||
/>
|
||||
<Icon name="rotate_right" size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} title="Winkel (Grad)" />
|
||||
<NumInput
|
||||
value={rotation} step={5}
|
||||
onCommit={(v) => apply({ rotation: v })}
|
||||
width={56}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
|
||||
</div>
|
||||
)}
|
||||
{isLayerSource && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Pattern, Skalierung & Farbe folgen Layer-Section-Style
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '60px 24px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11,
|
||||
display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center',
|
||||
}}>
|
||||
<Icon name="touch_app" size={36} style={{ color: 'var(--text-muted)' }} />
|
||||
<div>Waehle ein oder mehrere Objekte in Rhino aus.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function GestaltungApp() {
|
||||
const [sel, setSel] = useState({ count: 0, linetypes: [] })
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('SELECTION', (data) => setSel(data || { count: 0, linetypes: [] }))
|
||||
notifyReady()
|
||||
const blockContext = (ev) => ev.preventDefault()
|
||||
document.addEventListener('contextmenu', blockContext)
|
||||
return () => document.removeEventListener('contextmenu', blockContext)
|
||||
}, [])
|
||||
|
||||
const empty = sel.count === 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100vh', overflow: 'hidden',
|
||||
background: 'var(--bg-base)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}}>
|
||||
<Icon name="tune" size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ flex: 1, fontWeight: 500, fontSize: 12, color: 'var(--text-primary)' }}>
|
||||
Attribute
|
||||
</span>
|
||||
{sel.count > 0 && <span className="chip">{sel.count}</span>}
|
||||
<button className="btn-icon-sm" onClick={() => requestSelection()} title="Aktualisieren">
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{empty ? <EmptyState /> : (
|
||||
<>
|
||||
<SectionHead title="Fill" />
|
||||
<FillBlock sel={sel} />
|
||||
|
||||
<SectionHead title="Pen" />
|
||||
<PenBlock sel={sel} />
|
||||
|
||||
<SectionHead title="Effects" />
|
||||
<div style={{ padding: '0 14px 14px', fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Schatten / Transparenz folgen spaeter.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import ContextMenu from './components/ContextMenu'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
listLayouts, newLayout, deleteLayout, renameLayout, activateLayout,
|
||||
addDetail, deleteDetail, bindAusschnitt, syncDetail, syncLayout,
|
||||
setPageSize, exportPdf, exportPdfAll, exportPdfMany,
|
||||
addLayoutFolder, removeLayoutFolder, setLayoutFolder,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
const PAPER_SIZES = ['A4', 'A3', 'A2', 'A1', 'A0', 'Letter']
|
||||
|
||||
const PAPER_FORMATS_MM = {
|
||||
A0: [841, 1189], A1: [594, 841], A2: [420, 594], A3: [297, 420],
|
||||
A4: [210, 297], Letter: [216, 279],
|
||||
}
|
||||
|
||||
function detectFormat(wMm, hMm, tol = 2) {
|
||||
if (!(wMm > 0) || !(hMm > 0)) return null
|
||||
const a = Math.min(wMm, hMm), b = Math.max(wMm, hMm)
|
||||
for (const [name, [sw, sh]] of Object.entries(PAPER_FORMATS_MM)) {
|
||||
if (Math.abs(a - sw) <= tol && Math.abs(b - sh) <= tol) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatLabel(wMm, hMm) {
|
||||
if (!(wMm > 0) || !(hMm > 0)) return '—'
|
||||
const f = detectFormat(wMm, hMm)
|
||||
if (!f) return `${Math.round(wMm)}×${Math.round(hMm)}mm`
|
||||
const landscape = wMm > hMm
|
||||
return landscape ? `${f} ↔` : `${f} ↕`
|
||||
}
|
||||
|
||||
function OrientationBadge({ landscape }) {
|
||||
return (
|
||||
<span title={landscape ? 'Querformat' : 'Hochformat'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, flexShrink: 0,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--accent)',
|
||||
}}>
|
||||
<Icon name={landscape ? 'crop_landscape' : 'crop_portrait'} size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: 8,
|
||||
marginBottom: 6,
|
||||
}
|
||||
|
||||
const labelXs = {
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
}
|
||||
|
||||
// Inline-editierbarer Name — Doppelklick aktiviert, Enter/Blur committet.
|
||||
// forceEdit triggert Edit-Mode von aussen (z.B. Kontextmenue „Umbenennen").
|
||||
function EditableName({ value, onCommit, style, title, forceEdit, onEditDone }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [text, setText] = useState(value || '')
|
||||
const inputRef = useRef(null)
|
||||
useEffect(() => { if (!editing) setText(value || '') }, [value, editing])
|
||||
useEffect(() => { if (editing) { inputRef.current?.focus(); inputRef.current?.select() } }, [editing])
|
||||
useEffect(() => { if (forceEdit) setEditing(true) }, [forceEdit])
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditing(false)
|
||||
const t = text.trim()
|
||||
if (t && t !== value) onCommit(t)
|
||||
else setText(value || '')
|
||||
onEditDone && onEditDone()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur()
|
||||
else if (e.key === 'Escape') {
|
||||
setText(value || ''); setEditing(false); onEditDone && onEditDone()
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ flex: 1, fontSize: 'inherit', fontFamily: 'inherit', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true) }}
|
||||
title={title || 'Doppelklick zum Umbenennen'}
|
||||
style={{
|
||||
flex: 1, cursor: 'text',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{value || '(unbenannt)'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LayoutsApp() {
|
||||
const [state, setState] = useState({ layouts: [], snapshots: [], details: {}, folders: [] })
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [dialog, setDialog] = useState(null) // { mode: 'new' | 'edit', layout? }
|
||||
const [checked, setChecked] = useState(new Set()) // Multi-Select IDs
|
||||
const [collapsedFolders, setCollapsedFolders] = useState(new Set())
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [dragTarget, setDragTarget] = useState(null) // '__root' | folderName
|
||||
const [ctxMenu, setCtxMenu] = useState(null) // {x,y,kind,id}
|
||||
const [renamingId, setRenamingId] = useState(null) // signal to inline-edit
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE', (s) => setState((prev) => ({ ...prev, ...s })))
|
||||
notifyReady()
|
||||
}, [])
|
||||
|
||||
const layouts = state.layouts || []
|
||||
const snaps = state.snapshots || []
|
||||
const folders = state.folders || []
|
||||
const details = (selectedId && state.details && state.details[selectedId]) || []
|
||||
const selected = layouts.find(l => l.id === selectedId)
|
||||
|
||||
// Layouts gruppieren: Root + per Folder
|
||||
const grouped = { __root: [] }
|
||||
for (const f of folders) grouped[f] = []
|
||||
for (const l of layouts) {
|
||||
const f = l.folder || '__root'
|
||||
if (!grouped[f]) grouped[f] = []
|
||||
grouped[f].push(l)
|
||||
}
|
||||
|
||||
const toggleCheck = (id) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleFolderCollapse = (name) => {
|
||||
setCollapsedFolders(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name); else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const checkAllInFolder = (folderLayouts) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev)
|
||||
const allIn = folderLayouts.every(l => next.has(l.id))
|
||||
if (allIn) folderLayouts.forEach(l => next.delete(l.id))
|
||||
else folderLayouts.forEach(l => next.add(l.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const handleExportSelection = () => {
|
||||
const ids = [...checked].filter(id => layouts.find(l => l.id === id))
|
||||
if (ids.length === 0) return
|
||||
exportPdfMany(ids, 300)
|
||||
}
|
||||
const handleExportFolder = (folderName) => {
|
||||
const ids = (grouped[folderName] || []).map(l => l.id)
|
||||
if (ids.length === 0) return
|
||||
exportPdfMany(ids, 300)
|
||||
}
|
||||
const handleNewFolder = () => {
|
||||
const name = window.prompt('Name fuer neuen Ordner:')
|
||||
if (name && name.trim()) addLayoutFolder(name.trim())
|
||||
}
|
||||
|
||||
// Kontextmenue-Items pro Layout
|
||||
const layoutCtxItems = (l) => [
|
||||
{ label: 'In Rhino oeffnen', icon: 'open_in_new',
|
||||
onClick: () => activateLayout(l.id) },
|
||||
{ label: 'Umbenennen', icon: 'edit',
|
||||
onClick: () => setRenamingId(l.id) },
|
||||
{ divider: true },
|
||||
{ label: 'Als PDF exportieren', icon: 'picture_as_pdf',
|
||||
onClick: () => exportPdf(l.id, 300) },
|
||||
{ label: 'Papierformat aendern', icon: 'aspect_ratio',
|
||||
onClick: () => setDialog({ mode: 'edit', layout: l }) },
|
||||
{ divider: true },
|
||||
...(folders.length > 0 ? [
|
||||
...folders.map(f => ({
|
||||
label: `Verschieben → ${f}`,
|
||||
icon: 'folder',
|
||||
disabled: l.folder === f,
|
||||
onClick: () => setLayoutFolder(l.id, f),
|
||||
})),
|
||||
{ label: 'Aus Ordner entfernen',
|
||||
icon: 'folder_off',
|
||||
disabled: !l.folder,
|
||||
onClick: () => setLayoutFolder(l.id, '') },
|
||||
{ divider: true },
|
||||
] : []),
|
||||
{ label: 'Loeschen', icon: 'delete', danger: true,
|
||||
onClick: () => {
|
||||
if (window.confirm(`Layout "${l.name}" loeschen?`)) deleteLayout(l.id)
|
||||
} },
|
||||
]
|
||||
|
||||
// Kontextmenue-Items pro Ordner
|
||||
const folderCtxItems = (folderName) => {
|
||||
const items = grouped[folderName] || []
|
||||
return [
|
||||
{ label: collapsedFolders.has(folderName) ? 'Aufklappen' : 'Einklappen',
|
||||
icon: collapsedFolders.has(folderName) ? 'expand_more' : 'expand_less',
|
||||
onClick: () => toggleFolderCollapse(folderName) },
|
||||
{ divider: true },
|
||||
{ label: 'Alle ankreuzen / abwaehlen',
|
||||
icon: 'check_box',
|
||||
onClick: () => checkAllInFolder(items) },
|
||||
{ label: `Ordner als PDF (${items.length})`,
|
||||
icon: 'picture_as_pdf',
|
||||
disabled: items.length === 0,
|
||||
onClick: () => handleExportFolder(folderName) },
|
||||
{ divider: true },
|
||||
{ label: 'Ordner umbenennen',
|
||||
icon: 'edit',
|
||||
onClick: () => {
|
||||
const next = window.prompt('Neuer Ordner-Name:', folderName)
|
||||
if (next && next.trim() && next !== folderName) {
|
||||
// Atomar via Server-Side waere besser; simpler: alle Layouts
|
||||
// umhaengen + alten loeschen + neuen anlegen.
|
||||
addLayoutFolder(next.trim())
|
||||
items.forEach(l => setLayoutFolder(l.id, next.trim()))
|
||||
removeLayoutFolder(folderName)
|
||||
}
|
||||
} },
|
||||
{ label: 'Ordner loeschen',
|
||||
icon: 'folder_off', danger: true,
|
||||
onClick: () => {
|
||||
if (window.confirm(`Ordner "${folderName}" loeschen? Layouts werden zur Wurzel verschoben.`))
|
||||
removeLayoutFolder(folderName)
|
||||
} },
|
||||
]
|
||||
}
|
||||
|
||||
// Drag&Drop-Handler — analog Ausschnitte-Pattern
|
||||
const handleDragOver = (folderName) => (ev) => {
|
||||
ev.preventDefault()
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
setDragTarget(folderName || '__root')
|
||||
}
|
||||
const handleDragLeave = () => () => setDragTarget(null)
|
||||
const handleDrop = (folderName) => (ev) => {
|
||||
ev.preventDefault()
|
||||
setDragTarget(null)
|
||||
const id = ev.dataTransfer.getData('text/plain') || draggingId
|
||||
if (id) setLayoutFolder(id, folderName || '')
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
// Auto-Select erstes Layout wenn nichts gewaehlt
|
||||
useEffect(() => {
|
||||
if (selectedId == null && layouts.length > 0) setSelectedId(layouts[0].id)
|
||||
if (selectedId != null && !layouts.find(l => l.id === selectedId)) {
|
||||
setSelectedId(layouts[0]?.id || null)
|
||||
}
|
||||
}, [layouts, selectedId])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100vh', overflow: 'hidden',
|
||||
background: 'var(--bg-base)', color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font)', fontSize: 11,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em' }}>LAYOUTS</span>
|
||||
<button
|
||||
onClick={handleExportSelection}
|
||||
className="btn-icon-tonal"
|
||||
disabled={checked.size === 0}
|
||||
title={checked.size > 0
|
||||
? `Auswahl (${checked.size}) als ein PDF exportieren`
|
||||
: 'Erst Layouts ankreuzen'}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<Icon name="picture_as_pdf" size={14} />
|
||||
{checked.size > 0 && <span style={{ fontSize: 10 }}>({checked.size})</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportPdfAll(300)}
|
||||
className="btn-icon-tonal"
|
||||
disabled={layouts.length === 0}
|
||||
title="Alle Layouts als ein PDF exportieren"
|
||||
>
|
||||
<Icon name="picture_as_pdf" size={14} />
|
||||
<span style={{ fontSize: 9, marginLeft: 2 }}>·∗</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNewFolder}
|
||||
className="btn-icon-tonal"
|
||||
title="Neuer Ordner"
|
||||
>
|
||||
<Icon name="create_new_folder" size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDialog({ mode: 'new' })}
|
||||
className="btn-add"
|
||||
title="Neues Layout erstellen"
|
||||
>
|
||||
<Icon name="add" size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => listLayouts()}
|
||||
className="btn-icon-tonal"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 8 }}>
|
||||
{/* Layout-Liste */}
|
||||
{layouts.length === 0 && folders.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '32px 16px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<Icon name="dashboard" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div style={{ marginTop: 8 }}>Noch keine Layouts.</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||||
Oben <Icon name="add" size={11} /> klicken um ein neues Layout anzulegen.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
||||
{/* Ordner-Gruppen */}
|
||||
{folders.map(folderName => {
|
||||
const items = grouped[folderName] || []
|
||||
const isCollapsed = collapsedFolders.has(folderName)
|
||||
const allChecked = items.length > 0 && items.every(l => checked.has(l.id))
|
||||
const isDropTarget = dragTarget === folderName
|
||||
return (
|
||||
<div key={folderName}
|
||||
onContextMenu={(ev) => {
|
||||
ev.preventDefault(); ev.stopPropagation()
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName })
|
||||
}}
|
||||
onDragOver={handleDragOver(folderName)}
|
||||
onDragLeave={handleDragLeave()}
|
||||
onDrop={handleDrop(folderName)}
|
||||
style={{
|
||||
border: `1px solid ${isDropTarget ? 'var(--accent-border)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: isDropTarget ? 'var(--accent-dim)' : 'var(--bg-section)',
|
||||
marginBottom: 8,
|
||||
padding: 8,
|
||||
transition: 'background 0.14s, border-color 0.14s',
|
||||
}}>
|
||||
<div
|
||||
onClick={() => toggleFolderCollapse(folderName)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => checkAllInFolder(items)}
|
||||
title="Alle in diesem Ordner ankreuzen"
|
||||
style={{ margin: 0, flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{
|
||||
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: 'var(--text-muted)', display: 'inline-flex',
|
||||
}}>
|
||||
<Icon name="arrow_drop_down" size={16} />
|
||||
</span>
|
||||
<Icon name="folder" size={14} style={{ color: 'var(--warn)' }} />
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 500,
|
||||
color: 'var(--text-primary)' }}>
|
||||
{folderName}
|
||||
</span>
|
||||
<span className="chip" style={{ fontSize: 8 }}>{items.length}</span>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'folder', id: folderName })
|
||||
}}
|
||||
title="Ordner-Aktionen"
|
||||
>
|
||||
<Icon name="more_vert" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column' }}>
|
||||
{items.map(l => (
|
||||
<LayoutRow key={l.id} l={l}
|
||||
active={l.id === selectedId}
|
||||
checked={checked.has(l.id)}
|
||||
dragging={draggingId === l.id}
|
||||
forceEditName={renamingId === l.id}
|
||||
onEditDone={() => setRenamingId(null)}
|
||||
onCheck={() => toggleCheck(l.id)}
|
||||
onSelect={() => setSelectedId(l.id)}
|
||||
onActivate={() => activateLayout(l.id)}
|
||||
onRename={(n) => renameLayout(l.id, n)}
|
||||
onContextMenu={(ev) => {
|
||||
ev.preventDefault(); ev.stopPropagation()
|
||||
setSelectedId(l.id)
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
|
||||
}}
|
||||
onMenuClick={(ev) => {
|
||||
setSelectedId(l.id)
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
|
||||
}}
|
||||
onDragStart={(ev) => {
|
||||
ev.dataTransfer.setData('text/plain', l.id)
|
||||
ev.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingId(l.id)
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<div style={{
|
||||
padding: '10px 6px', fontSize: 10, color: 'var(--text-muted)',
|
||||
textAlign: 'center', fontStyle: 'italic',
|
||||
}}>
|
||||
Leer — Layouts hier ablegen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Root-Drop-Zone — auch wenn keine Root-Layouts existieren, damit
|
||||
man ein Layout aus einem Ordner zurueck auf "kein Ordner"
|
||||
ziehen kann. */}
|
||||
<div
|
||||
onDragOver={handleDragOver('')}
|
||||
onDragLeave={handleDragLeave()}
|
||||
onDrop={handleDrop('')}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 3,
|
||||
padding: grouped.__root.length === 0 ? 8 : 0,
|
||||
border: `1px ${dragTarget === '__root' ? 'solid var(--accent)' : 'dashed transparent'}`,
|
||||
borderRadius: 'var(--r)',
|
||||
background: dragTarget === '__root' ? 'var(--bg-item-active)' : 'transparent',
|
||||
transition: 'all 0.1s',
|
||||
minHeight: grouped.__root.length === 0 ? 32 : 0,
|
||||
}}
|
||||
>
|
||||
{grouped.__root.map(l => (
|
||||
<LayoutRow key={l.id} l={l}
|
||||
active={l.id === selectedId}
|
||||
checked={checked.has(l.id)}
|
||||
dragging={draggingId === l.id}
|
||||
forceEditName={renamingId === l.id}
|
||||
onEditDone={() => setRenamingId(null)}
|
||||
onCheck={() => toggleCheck(l.id)}
|
||||
onSelect={() => setSelectedId(l.id)}
|
||||
onActivate={() => activateLayout(l.id)}
|
||||
onRename={(n) => renameLayout(l.id, n)}
|
||||
onContextMenu={(ev) => {
|
||||
ev.preventDefault(); ev.stopPropagation()
|
||||
setSelectedId(l.id)
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
|
||||
}}
|
||||
onMenuClick={(ev) => {
|
||||
setSelectedId(l.id)
|
||||
setCtxMenu({ x: ev.clientX, y: ev.clientY, kind: 'layout', id: l.id })
|
||||
}}
|
||||
onDragStart={(ev) => {
|
||||
ev.dataTransfer.setData('text/plain', l.id)
|
||||
ev.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingId(l.id)
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragTarget(null) }}
|
||||
/>
|
||||
))}
|
||||
{grouped.__root.length === 0 && draggingId && (
|
||||
<div style={{
|
||||
textAlign: 'center', color: 'var(--text-muted)',
|
||||
fontSize: 10, fontStyle: 'italic',
|
||||
}}>
|
||||
Hier ablegen um aus Ordner zu entfernen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details des aktuell gewaehlten Layouts */}
|
||||
{selected && (
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ ...labelXs, flex: 1 }}>
|
||||
Details · {selected.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => addDetail(selected.id, null)}
|
||||
className="btn-icon-tonal"
|
||||
title="Neues Detail (zentriert auf Seite)"
|
||||
style={{ marginRight: 4 }}
|
||||
>
|
||||
<Icon name="add" size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => syncLayout(selected.id)}
|
||||
className="btn-icon-tonal"
|
||||
disabled={details.length === 0}
|
||||
title="Alle Details mit ihren Ausschnitten neu synchronisieren"
|
||||
>
|
||||
<Icon name="sync" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{details.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px 12px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r)',
|
||||
background: 'var(--bg-input)',
|
||||
}}>
|
||||
<Icon name="crop_landscape" size={24} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div style={{ marginTop: 6 }}>Keine Details auf diesem Layout.</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||||
Oben <Icon name="add" size={11} /> klicken um eines hinzuzufuegen.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{details.map((d, i) => (
|
||||
<div key={d.id} style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--r)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="chip" style={{ flexShrink: 0 }}>#{i + 1}</span>
|
||||
<span style={{
|
||||
flex: 1, fontWeight: 500,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{d.name || '(Detail)'}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', fontFamily: 'DM Mono, monospace' }}>
|
||||
{Math.round(d.width)}×{Math.round(d.height)}
|
||||
</span>
|
||||
<button
|
||||
className="btn-icon-sm btn-icon-danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('Detail loeschen?')) deleteDetail(selected.id, d.id)
|
||||
}}
|
||||
title="Detail loeschen"
|
||||
>
|
||||
<Icon name="delete" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<select
|
||||
value={d.boundAusschnitt || ''}
|
||||
onChange={(e) => bindAusschnitt(selected.id, d.id, e.target.value || null)}
|
||||
style={{ flex: 1, fontSize: 11 }}
|
||||
title="Welcher Ausschnitt auf diesem Detail liegt"
|
||||
>
|
||||
<option value="">— kein Ausschnitt —</option>
|
||||
{snaps.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}{s.folder ? ` · ${s.folder}` : ''}{s.scale ? ` · ${s.scale}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => syncDetail(selected.id, d.id)}
|
||||
disabled={!d.boundAusschnitt}
|
||||
className="btn-icon-sm"
|
||||
title="Gebundenen Ausschnitt neu anwenden"
|
||||
>
|
||||
<Icon name="sync" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kontextmenue */}
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
items={
|
||||
ctxMenu.kind === 'folder'
|
||||
? folderCtxItems(ctxMenu.id)
|
||||
: (() => {
|
||||
const l = layouts.find(x => x.id === ctxMenu.id)
|
||||
return l ? layoutCtxItems(l) : []
|
||||
})()
|
||||
}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layout-Dialog: New oder Edit (Papierformat aendern) */}
|
||||
{dialog && (
|
||||
<LayoutDialog
|
||||
mode={dialog.mode}
|
||||
layout={dialog.layout}
|
||||
onCancel={() => setDialog(null)}
|
||||
onSubmit={(p) => {
|
||||
if (dialog.mode === 'new') {
|
||||
newLayout(p.name, p.format, p.landscape, p.customWidth, p.customHeight)
|
||||
} else {
|
||||
setPageSize(dialog.layout.id, p.format, p.landscape, p.customWidth, p.customHeight)
|
||||
}
|
||||
setDialog(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutDialog({ mode, layout, onCancel, onSubmit }) {
|
||||
const editing = mode === 'edit'
|
||||
const [name, setName] = useState('')
|
||||
const [format, setFormat] = useState('A3')
|
||||
const [landscape, setLandscape] = useState(true)
|
||||
const [cw, setCw] = useState('420') // mm
|
||||
const [ch, setCh] = useState('297') // mm
|
||||
|
||||
// Wenn editieren: aktuelle Layout-Groesse pre-fillen (custom-Mode default)
|
||||
useEffect(() => {
|
||||
if (editing && layout) {
|
||||
// BBox in Doc-Einheiten — wir kennen die Einheit nicht direkt im
|
||||
// Frontend. Fuer den Edit-Modus zeigen wir die Groesse als Zahlen an
|
||||
// und schicken sie als "custom" mit der mm-Annahme. Wenn das Doc nicht
|
||||
// auf mm steht, ergibt sich eine kleine Konvertier-Unschaerfe — das
|
||||
// Backend rechnet mm-Werte konsistent in Doc-Units um.
|
||||
setFormat('custom')
|
||||
setCw(String(Math.round(layout.width)))
|
||||
setCh(String(Math.round(layout.height)))
|
||||
}
|
||||
}, [editing, layout])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 100,
|
||||
}} onClick={(e) => { if (e.target === e.currentTarget) onCancel() }}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-base)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r-lg)',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)',
|
||||
fontWeight: 600 }}>
|
||||
{editing ? `Papierformat: ${layout?.name}` : 'Neues Layout'}
|
||||
</div>
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{!editing && (
|
||||
<div>
|
||||
<div style={labelXs}>Name</div>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Grundriss EG"
|
||||
autoFocus
|
||||
style={{ width: '100%', marginTop: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={labelXs}>Papierformat</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{PAPER_SIZES.map(f => (
|
||||
<button key={f}
|
||||
onClick={() => setFormat(f)}
|
||||
className={format === f ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ padding: '4px 10px', fontSize: 11 }}>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setFormat('custom')}
|
||||
className={format === 'custom' ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ padding: '4px 10px', fontSize: 11 }}>
|
||||
Eigene
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{format === 'custom' ? (
|
||||
<div>
|
||||
<div style={labelXs}>Eigene Groesse (mm)</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4, alignItems: 'center' }}>
|
||||
<input type="text" value={cw} onChange={(e) => setCw(e.target.value)}
|
||||
placeholder="Breite"
|
||||
style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} />
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>×</span>
|
||||
<input type="text" value={ch} onChange={(e) => setCh(e.target.value)}
|
||||
placeholder="Hoehe"
|
||||
style={{ flex: 1, fontFamily: 'DM Mono, monospace', fontSize: 11 }} />
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>mm</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={labelXs}>Ausrichtung</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => setLandscape(true)}
|
||||
className={landscape ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<Icon name="crop_landscape" size={12} /> Quer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLandscape(false)}
|
||||
className={!landscape ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ padding: '4px 10px', fontSize: 11, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<Icon name="crop_portrait" size={12} /> Hoch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 10, borderTop: '1px solid var(--border)',
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
|
||||
<button onClick={onCancel}>Abbrechen</button>
|
||||
<button className="btn-contained"
|
||||
onClick={() => {
|
||||
const payload = { name: name.trim(), format, landscape }
|
||||
if (format === 'custom') {
|
||||
const w = parseFloat(cw), h = parseFloat(ch)
|
||||
if (!(w > 0) || !(h > 0)) { alert('Bitte gueltige Groesse eingeben.'); return }
|
||||
payload.customWidth = w
|
||||
payload.customHeight = h
|
||||
}
|
||||
onSubmit(payload)
|
||||
}}>
|
||||
{editing ? 'Anwenden' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutRow({ l, active, checked, dragging, forceEditName,
|
||||
onCheck, onSelect, onActivate, onRename,
|
||||
onContextMenu, onMenuClick,
|
||||
onDragStart, onDragEnd, onEditDone }) {
|
||||
const landscape = (l.widthMm || l.width) >= (l.heightMm || l.height)
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onActivate}
|
||||
onContextMenu={onContextMenu}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 8px',
|
||||
border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border-light)'),
|
||||
borderRadius: 'var(--r)',
|
||||
background: active ? 'var(--bg-item-active)' : 'var(--bg-input)',
|
||||
cursor: 'grab',
|
||||
userSelect: 'none',
|
||||
marginBottom: 4,
|
||||
opacity: dragging ? 0.4 : 1,
|
||||
transition: 'background 0.14s, border-color 0.14s, opacity 0.14s',
|
||||
}}
|
||||
onMouseEnter={(ev) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-item-hover)' }}
|
||||
onMouseLeave={(ev) => { if (!active && !dragging) ev.currentTarget.style.background = 'var(--bg-input)' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={onCheck}
|
||||
style={{ margin: 0, flexShrink: 0 }}
|
||||
title="Fuer PDF-Export ankreuzen"
|
||||
/>
|
||||
<OrientationBadge landscape={landscape} />
|
||||
<EditableName
|
||||
value={l.name}
|
||||
onCommit={onRename}
|
||||
forceEdit={forceEditName}
|
||||
onEditDone={onEditDone}
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
color: 'var(--text-primary)', fontWeight: 500,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
<span className="chip chip-accent" style={{
|
||||
fontSize: 9, fontFamily: 'DM Mono, monospace', fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}} title={`${Math.round(l.widthMm || 0)}×${Math.round(l.heightMm || 0)} mm`}>
|
||||
{formatLabel(l.widthMm, l.heightMm)}
|
||||
</span>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={(ev) => { ev.stopPropagation(); onMenuClick(ev) }}
|
||||
title="Aktionen"
|
||||
>
|
||||
<Icon name="more_vert" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
requestMassstab, setMassstab,
|
||||
zoomOneToOne, zoomExtents, zoomSelection,
|
||||
setMassstabDpi, detectMassstabDpi, setShowLineweights,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
const PRESETS = [
|
||||
{ value: 1, label: '1:1' },
|
||||
{ value: 5, label: '1:5' },
|
||||
{ value: 10, label: '1:10' },
|
||||
{ value: 20, label: '1:20' },
|
||||
{ value: 25, label: '1:25' },
|
||||
{ value: 50, label: '1:50' },
|
||||
{ value: 100, label: '1:100' },
|
||||
{ value: 200, label: '1:200' },
|
||||
{ value: 500, label: '1:500' },
|
||||
{ value: 1000, label: '1:1000'},
|
||||
]
|
||||
|
||||
function fmtScale(s) {
|
||||
if (s == null) return '—'
|
||||
if (s >= 1) return '1:' + (s >= 10 ? s.toFixed(0) : s.toFixed(1))
|
||||
return (1 / s).toFixed(2) + ':1'
|
||||
}
|
||||
|
||||
function snapToPreset(s, tol = 0.03) {
|
||||
if (s == null) return null
|
||||
for (const p of PRESETS) {
|
||||
if (Math.abs(s - p.value) / p.value <= tol) return p.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseScale(input) {
|
||||
if (!input) return null
|
||||
const s = String(input).trim()
|
||||
for (const sep of [':', '=', '/']) {
|
||||
if (s.includes(sep)) {
|
||||
const [a, b] = s.split(sep, 2)
|
||||
const pa = parseFloat(a)
|
||||
const pb = parseFloat(b)
|
||||
if (pa > 0 && pb > 0) return pb / pa
|
||||
return null
|
||||
}
|
||||
}
|
||||
const n = parseFloat(s)
|
||||
return n > 0 ? n : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function MassstabApp() {
|
||||
const [state, setState] = useState({
|
||||
viewName: null, parallel: false, scale: null,
|
||||
pixelWidth: null, pixelHeight: null, unitSystem: '?',
|
||||
dpi: 96, dpiSource: 'default',
|
||||
showLineweights: false,
|
||||
})
|
||||
const [appliedScale, setAppliedScale] = useState(null)
|
||||
const appliedScaleRef = useRef(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [dpiOpen, setDpiOpen] = useState(false)
|
||||
const [dpiDraft, setDpiDraft] = useState('96')
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE', (s) => {
|
||||
setState((prev) => ({ ...prev, ...s }))
|
||||
// Backend appliedScale (gilt nur fuer aktuellen Viewport) > Live-Snap > roher Live-Wert
|
||||
let next = null
|
||||
if (typeof s?.appliedScale === 'number' && s.appliedScale > 0) {
|
||||
next = s.appliedScale
|
||||
} else if (s?.parallel && typeof s?.scale === 'number' && s.scale > 0) {
|
||||
const snap = snapToPreset(s.scale)
|
||||
next = snap != null ? snap : Math.round(s.scale * 10) / 10
|
||||
}
|
||||
if (next != null && next > 0 && next !== appliedScaleRef.current) {
|
||||
setAppliedScale(next)
|
||||
appliedScaleRef.current = next
|
||||
}
|
||||
})
|
||||
notifyReady()
|
||||
setTimeout(() => requestMassstab(), 50)
|
||||
}, [])
|
||||
|
||||
const isPerspective = state.parallel === false
|
||||
const scaleVal = state.scale
|
||||
const dropdownValue = appliedScale != null ? String(appliedScale) : '__none__'
|
||||
|
||||
const applyDropdown = (val) => {
|
||||
if (val === '__none__') return
|
||||
const r = parseFloat(val)
|
||||
if (r > 0) {
|
||||
setAppliedScale(r)
|
||||
appliedScaleRef.current = r
|
||||
setMassstab(r)
|
||||
}
|
||||
}
|
||||
|
||||
const applyDraft = () => {
|
||||
const r = parseScale(draft)
|
||||
if (r != null) {
|
||||
setAppliedScale(r)
|
||||
appliedScaleRef.current = r
|
||||
setMassstab(r)
|
||||
setDraft('')
|
||||
}
|
||||
}
|
||||
|
||||
const commitDpi = () => {
|
||||
const v = parseFloat(dpiDraft)
|
||||
if (v >= 30 && v <= 600) setMassstabDpi(v)
|
||||
setDpiOpen(false)
|
||||
}
|
||||
|
||||
// "100%" = Viewport-Zoom auf den aktuell eingestellten Massstab snappen
|
||||
// (nicht: Massstab auf 1:1 setzen). Praktisch nach Pan/Zoom, um wieder
|
||||
// zur Soll-Skala zu kommen.
|
||||
const apply100 = () => {
|
||||
if (appliedScale && appliedScale > 0) {
|
||||
setMassstab(appliedScale)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Style-Bausteine ------------------------------------------------------
|
||||
const cellBtn = {
|
||||
fontSize: 11, padding: '0 8px', height: 24,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
background: 'var(--bg-item)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', color: 'var(--text-primary)', cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const cellInput = {
|
||||
fontSize: 11, padding: '0 6px', height: 24, minWidth: 0,
|
||||
background: 'var(--bg-input)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', color: 'var(--text-primary)',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 8px',
|
||||
fontFamily: 'var(--font)', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-base)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Live-Zoom Anzeige */}
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600,
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
minWidth: 56, textAlign: 'right',
|
||||
color: isPerspective ? 'var(--text-muted)' : 'var(--text-primary)',
|
||||
}} title={isPerspective ? 'Perspective — kein Massstab'
|
||||
: `Aktueller Zoom (live)${appliedScale!=null ? ` · gesetzt 1:${appliedScale}` : ''}`}>
|
||||
{isPerspective ? '—' : fmtScale(scaleVal)}
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
|
||||
|
||||
{/* Skala-Dropdown */}
|
||||
<select
|
||||
disabled={isPerspective}
|
||||
value={dropdownValue}
|
||||
onChange={(e) => applyDropdown(e.target.value)}
|
||||
style={{ ...cellInput, width: 80 }}
|
||||
title="Massstab waehlen"
|
||||
>
|
||||
<option value="__none__">1:?</option>
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.value} value={String(p.value)}>{p.label}</option>
|
||||
))}
|
||||
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
|
||||
<option value={String(appliedScale)}>1:{appliedScale}</option>
|
||||
)}
|
||||
</select>
|
||||
|
||||
{/* Freitext */}
|
||||
<input
|
||||
disabled={isPerspective}
|
||||
type="text"
|
||||
placeholder="1:N"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') applyDraft() }}
|
||||
onBlur={() => { if (draft) applyDraft() }}
|
||||
style={{ ...cellInput, width: 64 }}
|
||||
title="Eigenen Massstab eingeben (Enter)"
|
||||
/>
|
||||
|
||||
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
|
||||
|
||||
{/* Aktions-Buttons */}
|
||||
<button
|
||||
disabled={isPerspective || !appliedScale}
|
||||
onClick={apply100}
|
||||
style={cellBtn}
|
||||
title={appliedScale
|
||||
? `Zoom auf eingestellten Massstab snappen (1:${appliedScale >= 10 ? Math.round(appliedScale) : appliedScale})`
|
||||
: 'Erst einen Massstab waehlen'}
|
||||
>100%</button>
|
||||
<button onClick={zoomExtents} style={cellBtn} title="Auf gesamten Inhalt zoomen">
|
||||
<Icon name="fit_screen" size={14} />
|
||||
</button>
|
||||
<button onClick={zoomSelection} style={cellBtn} title="Auf Selektion zoomen">
|
||||
<Icon name="center_focus_strong" size={14} />
|
||||
</button>
|
||||
|
||||
<div style={{ width: 1, height: 18, background: 'var(--border)' }} />
|
||||
|
||||
{/* Print-View / Strichstaerken-Toggle
|
||||
Beide Icons werden permanent gerendert; nur display: none togglet,
|
||||
damit die Font-Ligatur nicht neu aufgeloest wird (sonst Flackern). */}
|
||||
<button
|
||||
onClick={() => setShowLineweights(!state.showLineweights)}
|
||||
style={{
|
||||
...cellBtn,
|
||||
background: state.showLineweights ? 'var(--accent)' : 'var(--bg-item)',
|
||||
color: state.showLineweights ? '#fff' : 'var(--text-primary)',
|
||||
borderColor: state.showLineweights ? 'var(--accent)' : 'var(--border)',
|
||||
}}
|
||||
title={state.showLineweights
|
||||
? 'Strichstaerken werden angezeigt (Print-View) — klicken zum Ausschalten'
|
||||
: 'Strichstaerken als Hairlines (Edit-View) — klicken um Print-View zu zeigen'}
|
||||
>
|
||||
<Icon name="edit" size={14} style={{ display: state.showLineweights ? 'none' : 'inline-block' }} />
|
||||
<Icon name="print" size={14} style={{ display: state.showLineweights ? 'inline-block' : 'none' }} />
|
||||
<span style={{ fontSize: 10 }}>{state.showLineweights ? 'Print' : 'Edit'}</span>
|
||||
</button>
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* View-Name */}
|
||||
<div style={{
|
||||
fontSize: 10, color: 'var(--text-muted)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
maxWidth: 140,
|
||||
}} title={state.viewName || ''}>
|
||||
{state.viewName || '?'}{isPerspective ? ' · Persp.' : ''}
|
||||
</div>
|
||||
|
||||
{/* DPI-Popover */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => { setDpiDraft(String(state.dpi || 96)); setDpiOpen(o => !o) }}
|
||||
style={{ ...cellBtn, fontSize: 10, padding: '0 6px', height: 22,
|
||||
color: state.dpiSource === 'auto' ? 'var(--accent)'
|
||||
: state.dpiSource === 'manual' ? 'var(--text-primary)'
|
||||
: 'var(--text-muted)' }}
|
||||
title={`DPI Kalibrierung — aktuell ${Math.round(state.dpi || 96)} dpi (${state.dpiSource || 'default'})`}
|
||||
>
|
||||
{Math.round(state.dpi || 96)}dpi
|
||||
{state.dpiSource === 'auto' && (
|
||||
<span style={{ marginLeft: 3, fontSize: 8, opacity: 0.8 }}>auto</span>
|
||||
)}
|
||||
</button>
|
||||
{dpiOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, bottom: '100%', marginBottom: 4,
|
||||
background: 'var(--bg-panel)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', padding: 8, display: 'flex',
|
||||
flexDirection: 'column', gap: 6,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', zIndex: 10, minWidth: 220,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
Bildschirm-Aufloesung fuer Massstab-Berechnung
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
type="number" min={30} max={600}
|
||||
value={dpiDraft}
|
||||
onChange={(e) => setDpiDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitDpi() }}
|
||||
autoFocus
|
||||
style={{ ...cellInput, flex: 1 }}
|
||||
/>
|
||||
<button onClick={commitDpi} style={{ ...cellBtn, fontSize: 10 }}>OK</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { detectMassstabDpi(); setDpiOpen(false) }}
|
||||
style={{ ...cellBtn, fontSize: 10, justifyContent: 'flex-start' }}
|
||||
title="DPI automatisch ueber EDID des Bildschirms ermitteln"
|
||||
>
|
||||
<Icon name="auto_fix_high" size={12} /> Auto-Detect (EDID)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
requestMassstab, setMassstab,
|
||||
zoomExtents, zoomSelection, setShowLineweights,
|
||||
setMassstabDpi, detectMassstabDpi,
|
||||
setView, setDisplayMode,
|
||||
toggleOverrides, setOverridesPreset, openOverridesPanel,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
const PRESETS = [
|
||||
{ value: 1, label: '1:1' }, { value: 5, label: '1:5' },
|
||||
{ value: 10, label: '1:10' }, { value: 20, label: '1:20' },
|
||||
{ value: 25, label: '1:25' }, { value: 50, label: '1:50' },
|
||||
{ value: 100, label: '1:100' }, { value: 200, label: '1:200' },
|
||||
{ value: 500, label: '1:500' }, { value: 1000, label: '1:1000' },
|
||||
]
|
||||
|
||||
const VIEWS = [
|
||||
{ value: 'Top', icon: 'north', label: 'Top' },
|
||||
{ value: 'Front', icon: 'view_in_ar', label: 'Front' },
|
||||
{ value: 'Right', icon: 'east', label: 'Right' },
|
||||
{ value: 'Perspective', icon: 'view_quilt', label: 'Persp' },
|
||||
]
|
||||
|
||||
function fmtScale(s) {
|
||||
if (s == null) return '—'
|
||||
if (s >= 1) return '1:' + (s >= 10 ? s.toFixed(0) : s.toFixed(1))
|
||||
return (1 / s).toFixed(2) + ':1'
|
||||
}
|
||||
|
||||
function snapToPreset(s, tol = 0.03) {
|
||||
if (s == null) return null
|
||||
for (const p of PRESETS) {
|
||||
if (Math.abs(s - p.value) / p.value <= tol) return p.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseScale(input) {
|
||||
if (!input) return null
|
||||
const s = String(input).trim()
|
||||
for (const sep of [':', '=', '/']) {
|
||||
if (s.includes(sep)) {
|
||||
const [a, b] = s.split(sep, 2)
|
||||
const pa = parseFloat(a), pb = parseFloat(b)
|
||||
if (pa > 0 && pb > 0) return pb / pa
|
||||
return null
|
||||
}
|
||||
}
|
||||
const n = parseFloat(s)
|
||||
return n > 0 ? n : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Material-UI-Style: alle Pills auf einheitliche Hoehe (PILL_H)
|
||||
// nutzt globale Klassen aus index.css fuer Look (border-radius, colors)
|
||||
|
||||
const PILL_H = 20 // Einheits-Hoehe fuer alle Bar-Elemente
|
||||
|
||||
const sep = {
|
||||
width: 1, height: 18,
|
||||
background: 'var(--border)', flexShrink: 0,
|
||||
margin: '0 2px',
|
||||
}
|
||||
const groupLabel = {
|
||||
fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', fontWeight: 600,
|
||||
alignSelf: 'center', whiteSpace: 'nowrap',
|
||||
padding: '0 4px',
|
||||
}
|
||||
const pillSelect = {
|
||||
height: PILL_H, lineHeight: PILL_H + 'px',
|
||||
padding: '0 20px 0 10px', boxSizing: 'border-box',
|
||||
fontSize: 10,
|
||||
}
|
||||
const pillInput = {
|
||||
height: PILL_H, lineHeight: PILL_H + 'px',
|
||||
padding: '0 8px', boxSizing: 'border-box',
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
}
|
||||
const pillChip = {
|
||||
height: PILL_H, lineHeight: PILL_H + 'px',
|
||||
padding: '0 8px', boxSizing: 'border-box',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
fontSize: 9,
|
||||
}
|
||||
const pillIconBtn = {
|
||||
width: PILL_H, height: PILL_H,
|
||||
borderRadius: '50%', boxSizing: 'border-box',
|
||||
}
|
||||
|
||||
// Tonal button helper (filled when active, outlined when not)
|
||||
function ToolButton({ active, onClick, icon, label, title, disabled }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={active ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ height: PILL_H, padding: '0 8px', boxSizing: 'border-box',
|
||||
fontSize: 9,
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer' }}
|
||||
title={title}
|
||||
>
|
||||
{icon && <Icon name={icon} size={12} />}
|
||||
{label && <span style={{ fontSize: 9 }}>{label}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function OberleisteApp() {
|
||||
const [state, setState] = useState({
|
||||
viewName: null, parallel: false, scale: null,
|
||||
pixelWidth: null, pixelHeight: null, unitSystem: '?',
|
||||
dpi: 96, dpiSource: 'default',
|
||||
showLineweights: false,
|
||||
viewMode: null, displayMode: null, displayModes: [],
|
||||
ortho: false, gridSnap: false, osnap: false,
|
||||
overridesEnabled: false, overridesCount: 0,
|
||||
cmdPrompt: '', cmdOptions: [],
|
||||
overridesActivePreset: null, overridesPresets: [],
|
||||
})
|
||||
const [appliedScale, setAppliedScale] = useState(null)
|
||||
const appliedScaleRef = useRef(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [customMode, setCustomMode] = useState(false) // Dropdown -> Custom-Input switch
|
||||
const customInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE', (s) => {
|
||||
setState((prev) => ({ ...prev, ...s }))
|
||||
// Dropdown spiegelt EXAKT den Backend-appliedScale fuer den aktuellen
|
||||
// Viewport. Kein Live-Skala-Fallback — das Dropdown ist statisch und
|
||||
// pro Viewport gebunden. Backend gibt null zurueck wenn der aktive
|
||||
// Viewport noch keinen gesetzten Massstab hat → Dropdown zeigt "1:?".
|
||||
const next = (typeof s?.appliedScale === 'number' && s.appliedScale > 0)
|
||||
? s.appliedScale
|
||||
: null
|
||||
if (next !== appliedScaleRef.current) {
|
||||
setAppliedScale(next)
|
||||
appliedScaleRef.current = next
|
||||
}
|
||||
})
|
||||
notifyReady()
|
||||
setTimeout(() => requestMassstab(), 50)
|
||||
}, [])
|
||||
|
||||
const isPerspective = state.parallel === false
|
||||
const scaleVal = state.scale
|
||||
const dropdownValue = appliedScale != null ? String(appliedScale) : '__none__'
|
||||
|
||||
const applyDropdown = (val) => {
|
||||
if (val === '__none__') return
|
||||
if (val === '__custom__') {
|
||||
setDraft(appliedScale ? `1:${appliedScale}` : '')
|
||||
setCustomMode(true)
|
||||
// Nach dem Render: Focus + Selektion fuer schnelles Eintippen.
|
||||
setTimeout(() => {
|
||||
customInputRef.current?.focus()
|
||||
customInputRef.current?.select()
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
const r = parseFloat(val)
|
||||
if (r > 0) { setAppliedScale(r); appliedScaleRef.current = r; setMassstab(r) }
|
||||
}
|
||||
const applyDraft = () => {
|
||||
const r = parseScale(draft)
|
||||
if (r != null) {
|
||||
setAppliedScale(r); appliedScaleRef.current = r; setMassstab(r)
|
||||
}
|
||||
setDraft('')
|
||||
setCustomMode(false)
|
||||
}
|
||||
const cancelDraft = () => { setDraft(''); setCustomMode(false) }
|
||||
const apply100 = () => {
|
||||
if (appliedScale && appliedScale > 0) setMassstab(appliedScale)
|
||||
}
|
||||
|
||||
// Aktuelles View-Match (manche User haben "Top" / "Right" als view name)
|
||||
const matchView = (v) => {
|
||||
if (!state.viewName) return false
|
||||
return state.viewName === v || state.viewName.toLowerCase() === v.toLowerCase()
|
||||
}
|
||||
|
||||
// (Command-Bar wurde entfernt — Rhinos eigene Command-Line wird benutzt.)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
fontFamily: 'var(--font)', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-panel)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 12px',
|
||||
overflowX: 'auto', overflowY: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.08em',
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
flexShrink: 0, userSelect: 'none',
|
||||
}}
|
||||
title={`Dossier ${__APP_VERSION__} — Teil von OpenStudio`}
|
||||
>
|
||||
DOSSIER <span style={{ opacity: 0.55 }}>{__APP_VERSION__}</span>
|
||||
</span>
|
||||
<div style={sep} />
|
||||
{/* ====== GRUPPE: VIEW ====== */}
|
||||
<span style={groupLabel}>View</span>
|
||||
{VIEWS.map(v => (
|
||||
<ToolButton
|
||||
key={v.value}
|
||||
onClick={() => setView(v.value)}
|
||||
active={matchView(v.value)}
|
||||
icon={v.icon}
|
||||
label={v.label}
|
||||
title={`Ansicht ${v.label}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* ====== GRUPPE: DISPLAY-MODE ====== */}
|
||||
<span style={groupLabel}>Display</span>
|
||||
<select
|
||||
value={state.displayMode || ''}
|
||||
onChange={(e) => setDisplayMode(e.target.value)}
|
||||
style={pillSelect}
|
||||
title="Display-Mode (Wireframe / Shaded / Rendered / etc.)"
|
||||
>
|
||||
{!state.displayMode && <option value="">—</option>}
|
||||
{(state.displayModes || []).map(dm => (
|
||||
<option key={dm.id} value={dm.name}>{dm.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* ====== GRUPPE: MASSSTAB ====== */}
|
||||
<span style={groupLabel}>Massstab</span>
|
||||
<span
|
||||
className={isPerspective ? 'chip' : 'chip chip-accent'}
|
||||
style={{
|
||||
...pillChip,
|
||||
minWidth: 56, justifyContent: 'center',
|
||||
fontFamily: 'DM Mono, monospace', fontSize: 11, fontWeight: 600,
|
||||
}}
|
||||
title="Live-Zoom"
|
||||
>
|
||||
{/* Live-Zoom des Viewports — immer sichtbar bei Parallelprojektion,
|
||||
unabhaengig davon ob ein Massstab im Dropdown gepinnt ist oder
|
||||
nicht. Nur in Perspective ist die Anzeige nicht sinnvoll. */}
|
||||
{isPerspective ? '—' : fmtScale(scaleVal)}
|
||||
</span>
|
||||
{customMode ? (
|
||||
<input
|
||||
ref={customInputRef}
|
||||
disabled={isPerspective}
|
||||
type="text" placeholder="1:N"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyDraft()
|
||||
else if (e.key === 'Escape') cancelDraft()
|
||||
}}
|
||||
onBlur={applyDraft}
|
||||
style={{ ...pillInput, width: 92 }}
|
||||
title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
disabled={isPerspective}
|
||||
value={dropdownValue}
|
||||
onChange={(e) => applyDropdown(e.target.value)}
|
||||
style={{ ...pillSelect, width: 92 }}
|
||||
>
|
||||
<option value="__none__">—</option>
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.value} value={String(p.value)}>{p.label}</option>
|
||||
))}
|
||||
{appliedScale != null && !PRESETS.some(p => p.value === appliedScale) && (
|
||||
<option value={String(appliedScale)}>1:{appliedScale}</option>
|
||||
)}
|
||||
<option value="__custom__">Eigener…</option>
|
||||
</select>
|
||||
)}
|
||||
<ToolButton
|
||||
onClick={apply100}
|
||||
disabled={isPerspective || !appliedScale}
|
||||
label="100%"
|
||||
title={appliedScale ? `Zoom auf eingestellten Massstab snappen (1:${appliedScale >= 10 ? Math.round(appliedScale) : appliedScale})` : 'Erst einen Massstab waehlen'}
|
||||
/>
|
||||
<button className="btn-icon" onClick={zoomExtents}
|
||||
style={pillIconBtn}
|
||||
title="Auf gesamten Inhalt zoomen">
|
||||
<Icon name="fit_screen" size={14} />
|
||||
</button>
|
||||
<button className="btn-icon" onClick={zoomSelection}
|
||||
style={pillIconBtn}
|
||||
title="Auf Selektion zoomen">
|
||||
<Icon name="center_focus_strong" size={14} />
|
||||
</button>
|
||||
<ToolButton
|
||||
onClick={() => setShowLineweights(!state.showLineweights)}
|
||||
active={state.showLineweights}
|
||||
label={state.showLineweights ? 'Print' : 'Edit'}
|
||||
title={state.showLineweights ? 'Print-View aktiv — klick zum Ausschalten' : 'Strichstaerken anzeigen (Print-View)'}
|
||||
icon={state.showLineweights ? 'print' : 'edit'}
|
||||
/>
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
|
||||
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
|
||||
|
||||
{/* ====== GRUPPE: OVERRIDES ====== */}
|
||||
<span style={groupLabel}>Overrides</span>
|
||||
<ToolButton
|
||||
onClick={() => toggleOverrides(!state.overridesEnabled)}
|
||||
active={state.overridesEnabled}
|
||||
icon="auto_fix_high"
|
||||
label={state.overridesEnabled ? 'AN' : 'AUS'}
|
||||
title={state.overridesEnabled
|
||||
? `Grafische Overrides aktiv — klick zum Ausschalten`
|
||||
: `Grafische Overrides ausgeschaltet`}
|
||||
/>
|
||||
{/* Preset-Dropdown: aktive Kombination waehlen. "—" = keine Kombination
|
||||
(Doc-Rules sind frei editiert oder leer). "Konfigurieren…" oeffnet
|
||||
den grossen Regel-Editor (OVERRIDES-Panel). */}
|
||||
<select
|
||||
value={state.overridesActivePreset || '__none__'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v === '__configure__') { openOverridesPanel(); return }
|
||||
setOverridesPreset(v === '__none__' ? null : v)
|
||||
}}
|
||||
style={{ ...pillSelect, width: 140 }}
|
||||
title={state.overridesActivePreset
|
||||
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
|
||||
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
|
||||
>
|
||||
<option value="__none__">{state.overridesCount > 0 ? `— (${state.overridesCount} Regeln)` : '—'}</option>
|
||||
{(state.overridesPresets || []).map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
<option disabled>──────────</option>
|
||||
<option value="__configure__">Konfigurieren…</option>
|
||||
</select>
|
||||
|
||||
{/* Spacer am rechten Rand */}
|
||||
<div style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import ContextMenu from './components/ContextMenu'
|
||||
import {
|
||||
onMessage, notifyReady,
|
||||
setOverridesEnabled, addRule, updateRule, deleteRule,
|
||||
reorderRules, duplicateRule, reapplyOverrides, clearOverrideRules,
|
||||
savePreset, loadPreset, deletePreset,
|
||||
} 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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConditionLeaf({ cond, layers, onChange, onRemove, canRemove }) {
|
||||
const t = cond?.type || 'layer_name'
|
||||
const op = cond?.operator || 'equals'
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
padding: 8,
|
||||
border: '1px solid var(--border-light)', borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<select
|
||||
value={t}
|
||||
onChange={(e) => onChange({ ...cond, type: e.target.value })}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{COND_TYPES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
{canRemove && (
|
||||
<button onClick={onRemove} className="btn-icon-danger"
|
||||
title="Diese Bedingung entfernen">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{t === 'user_string' && (
|
||||
<input
|
||||
type="text" placeholder="Key"
|
||||
value={cond?.key || ''}
|
||||
onChange={(e) => onChange({ ...cond, key: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<select
|
||||
value={op}
|
||||
onChange={(e) => onChange({ ...cond, operator: e.target.value })}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{OPS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
{t === 'layer_name' ? (
|
||||
<select
|
||||
value={cond?.value || ''}
|
||||
onChange={(e) => onChange({ ...cond, value: e.target.value })}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{(layers || []).map(l => (
|
||||
<option key={l.fullPath} value={l.fullPath}>{l.fullPath}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text" placeholder="Wert"
|
||||
value={cond?.value || ''}
|
||||
onChange={(e) => onChange({ ...cond, value: e.target.value })}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{conds.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Logik:</span>
|
||||
<button
|
||||
onClick={() => setLogic('and')}
|
||||
className={logic === 'and' ? 'btn-contained' : 'btn-outlined'}
|
||||
title="Alle Bedingungen müssen zutreffen"
|
||||
>AND</button>
|
||||
<button
|
||||
onClick={() => setLogic('or')}
|
||||
className={logic === 'or' ? 'btn-contained' : 'btn-outlined'}
|
||||
title="Mindestens eine Bedingung muss zutreffen"
|
||||
>OR</button>
|
||||
</div>
|
||||
)}
|
||||
{conds.map((c, i) => (
|
||||
<ConditionLeaf
|
||||
key={i}
|
||||
cond={c}
|
||||
layers={layers}
|
||||
onChange={(nc) => update(i, nc)}
|
||||
onRemove={() => remove(i)}
|
||||
canRemove={conds.length > 1}
|
||||
/>
|
||||
))}
|
||||
<button onClick={add} className="btn-outlined" style={{ alignSelf: 'flex-start' }}
|
||||
title="Weitere Bedingung hinzufügen">
|
||||
<Icon name="add" size={14} />
|
||||
<span>Bedingung</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionRow({ label, icon, active, onToggle, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
}}>
|
||||
<input type="checkbox" checked={active} onChange={onToggle}
|
||||
style={{ width: 'auto', height: 'auto', padding: 0 }} />
|
||||
<Icon name={icon} size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 11, color: active ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
{active && (
|
||||
<div style={{ marginLeft: 26, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<ActionRow
|
||||
label="Farbe" icon="palette"
|
||||
active={'color' in a}
|
||||
onToggle={(e) => setProp('color', e.target.checked ? (a.color || '#888888') : '')}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={a.color || '#888888'}
|
||||
onChange={(e) => setProp('color', e.target.value)}
|
||||
style={{ width: 36, height: 26, padding: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={a.color || ''}
|
||||
placeholder="#rrggbb"
|
||||
onChange={(e) => setProp('color', e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
</ActionRow>
|
||||
|
||||
<ActionRow
|
||||
label="Strichstärke" icon="line_weight"
|
||||
active={'lineweight' in a}
|
||||
onToggle={(e) => setProp('lineweight', e.target.checked ? (a.lineweight ?? 0.25) : '')}
|
||||
>
|
||||
<input
|
||||
type="number" step={0.05} min={0}
|
||||
value={a.lineweight ?? ''}
|
||||
onChange={(e) => setProp('lineweight', parseFloat(e.target.value) || 0)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>mm</span>
|
||||
</ActionRow>
|
||||
|
||||
<ActionRow
|
||||
label="Linientyp" icon="more_horiz"
|
||||
active={'linetype' in a}
|
||||
onToggle={(e) => setProp('linetype', e.target.checked ? (a.linetype || 'Continuous') : '')}
|
||||
>
|
||||
<select
|
||||
value={a.linetype || ''}
|
||||
onChange={(e) => setProp('linetype', e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{(linetypes || []).map(lt => <option key={lt} value={lt}>{lt}</option>)}
|
||||
</select>
|
||||
</ActionRow>
|
||||
|
||||
<ActionRow
|
||||
label="Schraffur" icon="grid_view"
|
||||
active={'hatchPattern' in a}
|
||||
onToggle={(e) => setProp('hatchPattern', e.target.checked ? (a.hatchPattern || 'Solid') : '')}
|
||||
>
|
||||
<select
|
||||
value={a.hatchPattern || ''}
|
||||
onChange={(e) => setProp('hatchPattern', e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{(hatchPatterns || []).map(hp => <option key={hp} value={hp}>{hp}</option>)}
|
||||
</select>
|
||||
</ActionRow>
|
||||
|
||||
<ActionRow
|
||||
label="Schraffur-Skala" icon="aspect_ratio"
|
||||
active={'hatchScale' in a}
|
||||
onToggle={(e) => setProp('hatchScale', e.target.checked ? (a.hatchScale ?? 1.0) : '')}
|
||||
>
|
||||
<input
|
||||
type="number" step={0.1} min={0.001}
|
||||
value={a.hatchScale ?? ''}
|
||||
onChange={(e) => setProp('hatchScale', parseFloat(e.target.value) || 1.0)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</ActionRow>
|
||||
|
||||
<div style={{ fontSize: 9, color: 'var(--text-muted)', fontStyle: 'italic', lineHeight: 1.4 }}>
|
||||
Hatch-Override modifiziert nur existierende Schraffuren. Curves ohne Hatch bleiben unverändert.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
onContextMenu={(ev) => { 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 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="chip" style={{ flexShrink: 0 }}>#{index + 1}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(e) => onPatch({ ...rule, enabled: e.target.checked })}
|
||||
title="Regel aktiv"
|
||||
style={{ flexShrink: 0, width: 'auto', height: 'auto', padding: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.name || ''}
|
||||
placeholder="Regel-Name"
|
||||
onChange={(e) => onPatch({ ...rule, name: e.target.value })}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button onClick={() => setOpen(!open)} className="btn-icon"
|
||||
title={open ? 'Einklappen' : 'Bearbeiten'}>
|
||||
<Icon name={open ? 'expand_less' : 'edit'} size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!open && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, marginLeft: 4,
|
||||
overflowWrap: 'break-word', wordBreak: 'break-word' }}>
|
||||
{summarize() || '(leer)'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 8 }}>
|
||||
<button onClick={() => onMoveUp()} disabled={index === 0}
|
||||
className="btn-icon-sm" title="Prio höher (nach oben)">
|
||||
<Icon name="arrow_upward" size={14} />
|
||||
</button>
|
||||
<button onClick={() => onMoveDown()} disabled={index === total - 1}
|
||||
className="btn-icon-sm" title="Prio tiefer (nach unten)">
|
||||
<Icon name="arrow_downward" size={14} />
|
||||
</button>
|
||||
<button onClick={() => onDuplicate()} className="btn-icon-sm" title="Duplizieren">
|
||||
<Icon name="content_copy" size={14} />
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => { if (confirm(`Regel "${rule.name}" löschen?`)) onDelete() }}
|
||||
className="btn-icon-danger" title="Löschen">
|
||||
<Icon name="delete" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div>
|
||||
<div style={{ ...labelXs, marginBottom: 6 }}>Bedingungen</div>
|
||||
<ConditionsEditor rule={rule} layers={layers} onChange={onPatch} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ ...labelXs, marginBottom: 6 }}>Überschreibungen</div>
|
||||
<ActionsEditor
|
||||
actions={rule.actions}
|
||||
linetypes={linetypes}
|
||||
hatchPatterns={hatchPatterns}
|
||||
onChange={(a) => onPatch({ ...rule, actions: a })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function OverridesApp() {
|
||||
const [state, setState] = useState({
|
||||
enabled: false, rules: [], layers: [], linetypes: [], hatchPatterns: [], presets: [],
|
||||
activePreset: null,
|
||||
})
|
||||
const [selectedPreset, setSelectedPreset] = 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) },
|
||||
{ divider: true },
|
||||
{ label: 'Loeschen',
|
||||
icon: 'delete', danger: true,
|
||||
onClick: () => {
|
||||
if (window.confirm(`Regel "${rule.name || '(ohne Name)'}" loeschen?`)) deleteRule(ruleId)
|
||||
} },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
padding: 10, gap: 10,
|
||||
fontFamily: 'var(--font)', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-base)',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{/* Header — globaler Toggle + Refresh + FAB neue Regel */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => setOverridesEnabled(!state.enabled)}
|
||||
className={state.enabled ? 'btn-contained' : 'btn-outlined'}
|
||||
style={{ flex: 1 }}
|
||||
title="Overrides global an/aus"
|
||||
>
|
||||
<Icon name={state.enabled ? 'visibility' : 'visibility_off'} size={14} />
|
||||
<span>{state.enabled ? 'Overrides AN' : 'Overrides AUS'}</span>
|
||||
</button>
|
||||
<button onClick={reapplyOverrides} className="btn-icon-tonal" title="Regeln neu anwenden">
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addRule({})}
|
||||
className="btn-add"
|
||||
title="Neue Regel oben einfügen (höchste Priorität)"
|
||||
>
|
||||
<Icon name="add" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Override-Kombinationen — Dropdown plus kontextabhaengiger Save. */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
padding: 10,
|
||||
background: 'var(--bg-section)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}>
|
||||
<span style={labelXs}>Override-Kombinationen</span>
|
||||
<select
|
||||
value={selectedPreset}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSelectedPreset(v)
|
||||
if (v) {
|
||||
loadPreset(v, 'replace')
|
||||
} else {
|
||||
// "— neu / keine —" → Editor wirklich leeren, sonst bleiben
|
||||
// die Regeln der vorigen Kombination stehen und der User
|
||||
// baut versehentlich auf altem Stand weiter.
|
||||
clearOverrideRules()
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
title="Kombination zum Bearbeiten oeffnen"
|
||||
>
|
||||
<option value="">— neu / keine —</option>
|
||||
{(state.presets || []).map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name} ({p.ruleCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
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 fuer neue Kombination:', def)
|
||||
if (!name || !name.trim()) return
|
||||
const t = name.trim()
|
||||
if (existing.includes(t) && !window.confirm(`Kombination "${t}" ueberschreiben?`)) return
|
||||
savePreset(t)
|
||||
setSelectedPreset(t)
|
||||
}}
|
||||
disabled={state.rules.length === 0}
|
||||
className="btn-outlined"
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
||||
title={selectedPreset
|
||||
? `Aenderungen in "${selectedPreset}" speichern`
|
||||
: 'Aktuelle Regeln als neue Kombination speichern'}
|
||||
>
|
||||
<Icon name="save" size={14} />
|
||||
<span>{selectedPreset ? 'Speichern' : 'Als Kombination speichern…'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedPreset) return
|
||||
if (!window.confirm(`Kombination "${selectedPreset}" dauerhaft loeschen?`)) return
|
||||
deletePreset(selectedPreset)
|
||||
setSelectedPreset('')
|
||||
}}
|
||||
disabled={!selectedPreset}
|
||||
className="btn-icon-danger"
|
||||
title="Gewaehlte Kombination dauerhaft loeschen"
|
||||
>
|
||||
<Icon name="delete" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', fontStyle: 'italic', lineHeight: 1.4 }}>
|
||||
Regeln sind additiv. Bei Konflikt gewinnt die <b>oberste</b>.
|
||||
</div>
|
||||
|
||||
{/* Rule list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', minHeight: 0 }}>
|
||||
{(state.rules || []).length === 0 && (
|
||||
<div style={{
|
||||
padding: '32px 16px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<Icon name="auto_fix_high" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div style={{ marginTop: 8 }}>Noch keine Regeln.</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||||
Oben <Icon name="add" size={11} /> klicken um eine neue Regel zu erstellen.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(state.rules || []).map((r, i) => (
|
||||
<RuleCard
|
||||
key={r.id}
|
||||
rule={r}
|
||||
index={i}
|
||||
total={state.rules.length}
|
||||
layers={state.layers}
|
||||
linetypes={state.linetypes}
|
||||
hatchPatterns={state.hatchPatterns}
|
||||
onPatch={onPatch}
|
||||
onDelete={() => 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 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
items={ruleCtxItems(ctxMenu.ruleId)}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import { notifyReady, runRhinoCommand } from './lib/rhinoBridge'
|
||||
|
||||
// Tool-Definitionen: [icon, label, rhino-command, tooltip]
|
||||
// Material-Symbol-Namen siehe https://fonts.google.com/icons
|
||||
const TOOLS = {
|
||||
'2D Zeichnen': [
|
||||
['horizontal_rule', 'Line', '_Line', 'Linie zwischen zwei Punkten'],
|
||||
['polyline', 'Polyline', '_Polyline', 'Polylinie'],
|
||||
['rectangle', 'Rect', '_Rectangle', 'Rechteck'],
|
||||
['radio_button_unchecked', 'Circle', '_Circle', 'Kreis'],
|
||||
['network_intelligence', 'Arc', '_Arc', 'Bogen'],
|
||||
['gesture', 'Curve', '_Curve', 'Freie Kurve (Spline)'],
|
||||
['text_fields', 'Text', '_Text', 'Text'],
|
||||
['grid_view', 'Hatch', '_Hatch', 'Schraffur'],
|
||||
['straighten', 'Dim', '_Dim', 'Linearbemassung'],
|
||||
],
|
||||
'2D Editieren': [
|
||||
['open_with', 'Move', '_Move', 'Verschieben'],
|
||||
['content_copy', 'Copy', '_Copy', 'Kopieren'],
|
||||
['rotate_right', 'Rotate', '_Rotate', 'Drehen'],
|
||||
['aspect_ratio', 'Scale', '_Scale', 'Skalieren'],
|
||||
['flip', 'Mirror', '_Mirror', 'Spiegeln'],
|
||||
['padding', 'Offset', '_Offset', 'Parallelversatz'],
|
||||
['content_cut', 'Trim', '_Trim', 'Stutzen'],
|
||||
['swipe_right_alt', 'Extend', '_Extend', 'Verlaengern'],
|
||||
['link', 'Join', '_Join', 'Verbinden'],
|
||||
['scatter_plot', 'Explode', '_Explode', 'Aufloesen'],
|
||||
['rounded_corner', 'Fillet', '_Fillet', 'Verrunden (Ecke abrunden)'],
|
||||
['apps', 'Array', '_ArrayPolar','Polar-Array'],
|
||||
],
|
||||
'3D Modellieren': [
|
||||
['vertical_align_top','Extrude', '_ExtrudeCrv', 'Kurve zu 3D extrudieren'],
|
||||
['square', 'Box', '_Box', 'Quader'],
|
||||
['join_inner', 'Union', '_BooleanUnion', 'Boolean-Vereinigung'],
|
||||
['remove', 'Diff', '_BooleanDifference', 'Boolean-Differenz'],
|
||||
['gradient', 'Intersect','_BooleanIntersection','Boolean-Schnittmenge'],
|
||||
['roofing', 'Cap', '_Cap', 'Planare Loecher schliessen'],
|
||||
['cut', 'Section', '_Section', 'Schnittlinien erzeugen'],
|
||||
['unfold_more', 'Loft', '_Loft', 'Loft (Kurven verbinden)'],
|
||||
],
|
||||
'Auswahl': [
|
||||
['add_link', 'Chain', '_SelChain', 'Tangentiale Kurvenkette waehlen'],
|
||||
['filter_alt', 'Dup', '_SelDup', 'Doppelte Objekte waehlen'],
|
||||
['loop', 'Closed', '_SelClosedCrv', 'Geschlossene Kurven waehlen'],
|
||||
['compare_arrows', 'Invert', '_Invert', 'Auswahl invertieren'],
|
||||
['select_all', 'All', '_SelAll', 'Alle auswaehlen'],
|
||||
['deselect', 'None', '_SelNone', 'Auswahl aufheben'],
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolButton({ icon, label, cmd, tip }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => runRhinoCommand(cmd)}
|
||||
title={`${tip} (${cmd})`}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'var(--bg-item)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', color: 'var(--text-primary)',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={16} style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 500 }}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupLabel({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
padding: '8px 4px 4px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function WerkzeugeApp() {
|
||||
useEffect(() => { notifyReady() }, [])
|
||||
|
||||
const groups = Object.entries(TOOLS)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', flexDirection: 'column', gap: 0,
|
||||
padding: 6,
|
||||
fontFamily: 'var(--font)', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-base)',
|
||||
boxSizing: 'border-box',
|
||||
overflowY: 'auto', overflowX: 'hidden',
|
||||
}}>
|
||||
{groups.map(([title, items], gi) => (
|
||||
<div key={title} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<GroupLabel>{title}</GroupLabel>
|
||||
{items.map(([icon, label, cmd, tip]) => (
|
||||
<ToolButton key={cmd} icon={icon} label={label} cmd={cmd} tip={tip} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,302 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
// Erzeugt ein vollstaendiges Draft-Array fuer einen Preset.
|
||||
// Layer die im Preset nicht enthalten sind werden auf Default (visible=true,
|
||||
// locked=false) gesetzt — sonst gibt es Schmutz wenn man zwischen Presets
|
||||
// hin- und herwechselt.
|
||||
function draftFromPreset(allLayers, preset) {
|
||||
if (!preset || !preset.layers) {
|
||||
return allLayers.map(l => ({ ...l }))
|
||||
}
|
||||
const map = {}
|
||||
;(preset.layers || []).forEach(l => { map[l.id] = l })
|
||||
return allLayers.map(l => {
|
||||
const ps = map[l.id]
|
||||
if (ps) return { ...l, visible: !!ps.visible, locked: !!ps.locked }
|
||||
return { ...l, visible: true, locked: false }
|
||||
})
|
||||
}
|
||||
|
||||
export default function AusschnittLayerDialog({
|
||||
snapName, layers, presets,
|
||||
onSave, onClose,
|
||||
onSavePreset, onDeletePreset,
|
||||
}) {
|
||||
// Welche Kombination wird gerade angezeigt? null = aktueller Doc-State
|
||||
const [selectedPreset, setSelectedPreset] = useState(null)
|
||||
const [draft, setDraft] = useState(() => layers.map(l => ({ ...l })))
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
// Wenn die Layer-Liste (von Backend) sich aendert wegen Doc-Update,
|
||||
// resetten wir den Draft — aber nur wenn nicht dirty.
|
||||
useEffect(() => {
|
||||
if (dirty) return
|
||||
if (selectedPreset === null) {
|
||||
setDraft(layers.map(l => ({ ...l })))
|
||||
} else {
|
||||
const p = presets.find(p => p.name === selectedPreset)
|
||||
setDraft(draftFromPreset(layers, p))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase()
|
||||
if (!f) return draft
|
||||
return draft.filter(l => (l.fullPath || l.name || '').toLowerCase().includes(f))
|
||||
}, [draft, filter])
|
||||
|
||||
const pickPreset = (name) => {
|
||||
if (dirty && !window.confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||
setSelectedPreset(name || null)
|
||||
setDirty(false)
|
||||
if (!name) {
|
||||
setDraft(layers.map(l => ({ ...l })))
|
||||
} else {
|
||||
const p = presets.find(p => p.name === name)
|
||||
setDraft(draftFromPreset(layers, p))
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (id, field) => {
|
||||
setDraft(d => d.map(l => l.id === id ? { ...l, [field]: !l[field] } : l))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const setAll = (field, value) => {
|
||||
setDraft(d => d.map(l => filtered.find(f => f.id === l.id) ? { ...l, [field]: value } : l))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const savePresetChanges = () => {
|
||||
if (!selectedPreset) return
|
||||
if (!window.confirm(`Änderungen an Kombination "${selectedPreset}" speichern?`)) return
|
||||
onSavePreset(selectedPreset, draft.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
|
||||
setDirty(false)
|
||||
}
|
||||
|
||||
const saveAsNew = () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
if (presets.some(p => p.name === name) &&
|
||||
!window.confirm(`Kombination "${name}" überschreiben?`)) return
|
||||
onSavePreset(name, draft.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
|
||||
setSelectedPreset(name)
|
||||
setDirty(false)
|
||||
setNewName('')
|
||||
}
|
||||
|
||||
const deleteSelected = () => {
|
||||
if (!selectedPreset) return
|
||||
if (!window.confirm(`Kombination "${selectedPreset}" löschen?`)) return
|
||||
onDeletePreset(selectedPreset)
|
||||
setSelectedPreset(null)
|
||||
setDirty(false)
|
||||
setDraft(layers.map(l => ({ ...l })))
|
||||
}
|
||||
|
||||
const applyToDoc = () => {
|
||||
if (dirty && selectedPreset &&
|
||||
!window.confirm('Änderungen an dieser Kombination sind nicht gespeichert. Trotzdem auf das Dokument anwenden?')) return
|
||||
onSave(draft)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 150,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: 30,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
width: 'calc(100% - 24px)', maxWidth: 480,
|
||||
maxHeight: 'calc(100vh - 60px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Icon name="layers" size={16} style={{ color: 'var(--text-secondary)' }} />
|
||||
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
|
||||
{snapName}
|
||||
</span>
|
||||
{dirty && (
|
||||
<span style={{ fontSize: 10, color: 'var(--warn)',
|
||||
padding: '2px 6px', borderRadius: 'var(--r)',
|
||||
background: 'var(--warn-dim)' }}
|
||||
title="Ungespeicherte Änderungen">●</span>
|
||||
)}
|
||||
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Preset-Auswahl */}
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'var(--bg-section)',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="label-xs" style={{ flexShrink: 0 }}>Kombination</span>
|
||||
<select
|
||||
value={selectedPreset || ''}
|
||||
onChange={(ev) => pickPreset(ev.target.value || null)}
|
||||
style={{ flex: 1, fontSize: 11 }}
|
||||
>
|
||||
<option value="">— Aktueller Zustand —</option>
|
||||
{presets.length > 0 && <option disabled>──────────</option>}
|
||||
{presets.map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name} ({p.layers?.length || 0} Ebenen)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPreset && (
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={deleteSelected}
|
||||
title="Diese Kombination löschen"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<Icon name="delete" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{selectedPreset && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
className="btn-contained"
|
||||
onClick={savePresetChanges}
|
||||
disabled={!dirty}
|
||||
style={{ fontSize: 10, padding: '3px 10px',
|
||||
opacity: dirty ? 1 : 0.5 }}
|
||||
title={dirty ? `Änderungen in "${selectedPreset}" speichern` : 'Keine Änderungen'}
|
||||
>
|
||||
<Icon name="save" size={12} />
|
||||
<span>Speichern</span>
|
||||
</button>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
Änderungen werden NICHT automatisch gespeichert.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(ev) => setNewName(ev.target.value)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter') saveAsNew() }}
|
||||
placeholder="Aktuelle Auswahl als neue Kombination speichern…"
|
||||
style={{ flex: 1, fontSize: 10 }}
|
||||
/>
|
||||
<button
|
||||
className="btn-outlined"
|
||||
onClick={saveAsNew}
|
||||
disabled={!newName.trim()}
|
||||
title="Aktuelle Auswahl unter diesem Namen speichern"
|
||||
style={{ fontSize: 10, padding: '3px 8px' }}
|
||||
>
|
||||
<Icon name="add" size={12} /> Neu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Such- und Bulk-Zeile */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}}>
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(ev) => setFilter(ev.target.value)}
|
||||
placeholder="Filter..."
|
||||
style={{ flex: 1, fontSize: 10, padding: '3px 6px' }}
|
||||
/>
|
||||
<button className="btn-icon-xs" onClick={() => setAll('visible', true)} title="Alle (gefiltert) sichtbar">
|
||||
<Icon name="visibility" size={12} />
|
||||
</button>
|
||||
<button className="btn-icon-xs" onClick={() => setAll('visible', false)} title="Alle (gefiltert) ausblenden">
|
||||
<Icon name="visibility_off" size={12} />
|
||||
</button>
|
||||
<button className="btn-icon-xs" onClick={() => setAll('locked', false)} title="Alle (gefiltert) entsperren">
|
||||
<Icon name="lock_open" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Layer-Liste */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 200, maxHeight: '50vh' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '30px 14px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>
|
||||
Keine Ebenen gefunden.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '4px 14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
background: 'var(--bg-item)',
|
||||
opacity: l.visible ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: 2,
|
||||
background: l.color || '#888',
|
||||
flexShrink: 0,
|
||||
border: '1px solid var(--border-light)',
|
||||
}} />
|
||||
<span style={{
|
||||
flex: 1, fontSize: 11,
|
||||
color: 'var(--text-label)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}} title={l.fullPath}>{l.fullPath || l.name}</span>
|
||||
<button
|
||||
className={`btn-icon-xs ${l.visible ? 'is-on' : ''}`}
|
||||
onClick={() => toggle(l.id, 'visible')}
|
||||
title={l.visible ? 'Ausblenden' : 'Einblenden'}
|
||||
><Icon name={l.visible ? 'visibility' : 'visibility_off'} size={12} /></button>
|
||||
<button
|
||||
className={`btn-icon-xs ${l.locked ? 'is-on' : ''}`}
|
||||
onClick={() => toggle(l.id, 'locked')}
|
||||
title={l.locked ? 'Entsperren' : 'Sperren'}
|
||||
><Icon name={l.locked ? 'lock' : 'lock_open'} size={12} /></button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<div style={{ flex: 1, fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
{draft.filter(l => l.visible).length} / {draft.length} sichtbar
|
||||
</div>
|
||||
<button className="btn-text" onClick={onClose}>Schliessen</button>
|
||||
<button className="btn-contained" onClick={applyToDoc}
|
||||
title="Aktuelle Auswahl auf das Dokument anwenden">
|
||||
<Icon name="check" size={12} />
|
||||
<span>Auf Doc anwenden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export default function BottomBar({ onApply, dirty }) {
|
||||
return (
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
padding: '10px 14px',
|
||||
}}>
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={!dirty}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 0',
|
||||
borderRadius: 'var(--r)',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
background: dirty ? 'var(--accent)' : 'var(--bg-item)',
|
||||
color: dirty ? '#fff' : 'var(--text-muted)',
|
||||
border: dirty ? 'none' : '1px solid var(--border)',
|
||||
cursor: dirty ? 'pointer' : 'default',
|
||||
transition: 'background 0.18s, color 0.18s',
|
||||
}}
|
||||
onMouseEnter={e => { if (dirty) e.currentTarget.style.background = 'var(--accent-light)' }}
|
||||
onMouseLeave={e => { if (dirty) e.currentTarget.style.background = dirty ? 'var(--accent)' : 'var(--bg-item)' }}
|
||||
>
|
||||
{dirty ? 'Auf Rhino anwenden' : 'Keine Änderungen'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
export default function ConfirmDeleteEbene({ ebene, otherEbenen, onConfirm, onCancel }) {
|
||||
const [target, setTarget] = useState(otherEbenen[0]?.code ?? '_delete')
|
||||
const isDelete = target === '_delete'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 200,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
width: 320, maxWidth: '100%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ padding: '16px 18px 6px', display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'var(--warn-dim)', color: 'var(--warn)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Icon name="warning" size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
|
||||
Ebene löschen
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
<b>{ebene.code}_{ebene.name}</b> wird in allen Zeichnungsebenen entfernt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px 18px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span className="label-xs">Inhalte auf der Ebene</span>
|
||||
<select value={target} onChange={ev => setTarget(ev.target.value)}>
|
||||
{otherEbenen.map(e => (
|
||||
<option key={e.code} value={e.code}>→ Verschieben nach {e.code}_{e.name}</option>
|
||||
))}
|
||||
<option value="_delete">⚠ Inhalte ebenfalls löschen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 4, padding: '10px 14px',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<button className="btn-text" onClick={onCancel}>Abbrechen</button>
|
||||
<button
|
||||
className="btn-contained"
|
||||
style={isDelete ? { background: 'var(--danger)' } : undefined}
|
||||
onClick={() => onConfirm(isDelete ? null : target)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }) {
|
||||
const ref = useRef(null)
|
||||
const [pos, setPos] = useState({ left: x, top: y })
|
||||
|
||||
// Falls Menue rechts/unten ueberlaufen wuerde, links/oben verschieben
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
let left = x, top = y
|
||||
if (left + rect.width > vw - 4) left = vw - rect.width - 4
|
||||
if (top + rect.height > vh - 4) top = vh - rect.height - 4
|
||||
if (left < 4) left = 4
|
||||
if (top < 4) top = 4
|
||||
setPos({ left, top })
|
||||
}, [x, y])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (ev) => {
|
||||
if (ref.current && !ref.current.contains(ev.target)) onClose()
|
||||
}
|
||||
const handleKey = (ev) => { if (ev.key === 'Escape') onClose() }
|
||||
const handleContext = (ev) => { ev.preventDefault(); onClose() }
|
||||
const t = setTimeout(() => {
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
document.addEventListener('contextmenu', handleContext)
|
||||
}, 0)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.removeEventListener('contextmenu', handleContext)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: pos.top, left: pos.left,
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
padding: '4px 0',
|
||||
minWidth: 180,
|
||||
zIndex: 300,
|
||||
}}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
it.divider ? (
|
||||
<div key={i} style={{ height: 1, background: 'var(--border-light)', margin: '4px 0' }} />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
disabled={it.disabled}
|
||||
onClick={() => { if (!it.disabled) { it.onClick(); onClose() } }}
|
||||
onMouseEnter={(ev) => { if (!it.disabled) ev.currentTarget.style.background = 'var(--overlay-hover)' }}
|
||||
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'transparent' }}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left',
|
||||
padding: '6px 14px', fontSize: 11,
|
||||
color: it.disabled ? 'var(--text-muted)'
|
||||
: it.danger ? 'var(--danger)'
|
||||
: 'var(--text-primary)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
borderRadius: 0,
|
||||
cursor: it.disabled ? 'default' : 'pointer',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
{it.icon
|
||||
? <Icon name={it.icon} size={14} style={{ color: it.disabled ? 'var(--text-muted)' : it.danger ? 'var(--danger)' : 'var(--text-secondary)' }} />
|
||||
: <span style={{ width: 14 }} />}
|
||||
<span style={{ flex: 1 }}>{it.label}</span>
|
||||
{it.shortcut && <span style={{ fontSize: 9, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{it.shortcut}</span>}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import Section from './Section'
|
||||
import Icon from './Icon'
|
||||
import ConfirmDeleteEbene from './ConfirmDeleteEbene'
|
||||
import ContextMenu from './ContextMenu'
|
||||
import EbenenSettingsDialog from './EbenenSettingsDialog'
|
||||
import { setLayerStyle, deleteEbene, moveSelectionToEbene } from '../lib/rhinoBridge'
|
||||
|
||||
const MODES = [
|
||||
{ value: 'all', label: 'Alle anzeigen' },
|
||||
{ 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: 7 }}>
|
||||
<Icon name="arrow_drop_up" size={10} />
|
||||
</button>
|
||||
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(-1) }} style={{ width: 10, height: 7 }}>
|
||||
<Icon name="arrow_drop_down" size={10} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) {
|
||||
// Auge zeigt den Eye-State (User-Intention) — auch fuer die aktive Ebene.
|
||||
// So sieht man auf einen Blick ob sie "normalerweise" sichtbar waere.
|
||||
// Aktive Ebene rendert Rhino zwar immer sichtbar, das visible-Flag bleibt
|
||||
// aber die gespeicherte Intention bis sie wieder de-aktiviert wird.
|
||||
const eyeShown = mode !== 'active'
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
data-ebene-code={e.code}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 12px',
|
||||
margin: active ? '1px 6px' : '0',
|
||||
background: active ? 'var(--active-dim)'
|
||||
: (e.visible !== false) ? 'var(--bg-item)'
|
||||
: 'var(--bg-panel)',
|
||||
// Pill-Form fuer die aktive Ebene, sonst Standard-Zeile mit Bottom-Border
|
||||
borderRadius: active ? 999 : 0,
|
||||
borderLeft: active ? 'none' : '3px solid transparent',
|
||||
borderBottom: active ? 'none' : '1px solid var(--border-light)',
|
||||
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
|
||||
opacity: (!active && e.visible === false && mode !== 'all') ? 0.45 : 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{eyeShown ? (
|
||||
<button
|
||||
className={`btn-icon-sm ${e.visible !== false ? 'is-on' : ''}`}
|
||||
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
||||
title={
|
||||
active
|
||||
? (e.visible !== false
|
||||
? 'Normalerweise sichtbar (aktive Ebene wird trotzdem gezeigt)'
|
||||
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv')
|
||||
: (e.visible !== false ? 'Ausblenden' : 'Einblenden')
|
||||
}
|
||||
><Icon name={e.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
|
||||
) : (
|
||||
<span style={{ width: 18, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<EditableText
|
||||
value={e.code}
|
||||
onCommit={onCodeChange}
|
||||
autoEditTrigger={autoEditCode}
|
||||
fontSize={9}
|
||||
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', width: 24, 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',
|
||||
}}
|
||||
/>
|
||||
</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 }}
|
||||
><Icon name={e.locked ? 'lock' : 'lock_open'} size={12} /></button>
|
||||
|
||||
<button
|
||||
className="btn-icon-xs"
|
||||
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
|
||||
title="Löschen"
|
||||
><Icon name="close" size={12} /></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,
|
||||
combinations = [], activeCombName = null,
|
||||
onPickCombination, onSaveCurrentCombination, onDeleteCombination,
|
||||
onEditCombinations, onUserVisibilityChange,
|
||||
}) {
|
||||
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 [settingsCode, setSettingsCode] = useState(null) // code der Ebene deren Einstellungen offen sind
|
||||
|
||||
// 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 sortedEbenen = useMemo(() => {
|
||||
const arr = [...ebenen]
|
||||
arr.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 arr
|
||||
}, [ebenen, sortBy, sortDir])
|
||||
|
||||
const updateByCode = (code, patch) => {
|
||||
onChange(ebenen.map(e => e.code === code ? { ...e, ...patch } : e))
|
||||
}
|
||||
|
||||
const handleToggleVisible = (code) => {
|
||||
const cur = ebenen.find(e => e.code === code)
|
||||
if (cur) updateByCode(code, { visible: !(cur.visible !== false) })
|
||||
if (onUserVisibilityChange) onUserVisibilityChange()
|
||||
}
|
||||
const handleToggleLock = (code) => {
|
||||
const cur = ebenen.find(e => e.code === code)
|
||||
if (cur) updateByCode(code, { locked: !cur.locked })
|
||||
if (onUserVisibilityChange) onUserVisibilityChange()
|
||||
}
|
||||
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) => {
|
||||
if (ebenen.some(e => e.code === newCode && e.code !== oldCode)) return
|
||||
onChange(ebenen.map(e => e.code === oldCode ? { ...e, code: newCode } : e))
|
||||
// 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(ebenen.filter(e => e.code !== code))
|
||||
if (activeCode === code) {
|
||||
const next = ebenen.find(e => e.code !== code)
|
||||
if (next) onActiveChange(next.code)
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
const nextFreeCode = () => {
|
||||
const codes = ebenen.map(e => parseInt(e.code, 10)).filter(n => !isNaN(n))
|
||||
const next = codes.length ? Math.max(...codes) + 1 : 50
|
||||
return String(next).padStart(2, '0')
|
||||
}
|
||||
|
||||
const addNew = () => {
|
||||
const code = nextFreeCode()
|
||||
onChange([...ebenen, {
|
||||
code, name: 'NEU',
|
||||
color: '#888888', lw: 0.18, visible: true, locked: false,
|
||||
}])
|
||||
// Code-Feld der neuen Ebene fokussieren
|
||||
setAutoEdit({ code, field: 'code', token: Date.now() })
|
||||
}
|
||||
|
||||
const duplicateEbene = (code) => {
|
||||
const src = ebenen.find(e => e.code === code)
|
||||
if (!src) return
|
||||
onChange([...ebenen, {
|
||||
...src, code: nextFreeCode(), name: src.name + ' KOPIE',
|
||||
}])
|
||||
}
|
||||
|
||||
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: () => setSettingsCode(code) },
|
||||
{ divider: true },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
const actionBtns = (
|
||||
<button className="btn-add" onClick={addNew} title="Ebene hinzufügen">
|
||||
<Icon name="add" size={16} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Section title="Ebenen" badge={ebenen.length} action={actionBtns}>
|
||||
{/* Ebenenkombinationen — Label + Dropdown + Save-As-Plus */}
|
||||
<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">Ebenenkombination</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<select
|
||||
value={activeCombName || '__custom__'}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value
|
||||
if (v === '__custom__') return
|
||||
if (v === '__delete__') {
|
||||
if (activeCombName && onDeleteCombination) onDeleteCombination(activeCombName)
|
||||
return
|
||||
}
|
||||
if (onPickCombination) onPickCombination(v)
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
title={activeCombName ? `Aktiv: ${activeCombName}` : 'Eigene Sichtbarkeit (keine Kombination)'}
|
||||
>
|
||||
<option value="__custom__">{activeCombName ? activeCombName : 'Eigene'}</option>
|
||||
{combinations.length > 0 && <option disabled>──────────</option>}
|
||||
{combinations.map(p => (
|
||||
<option key={p.name} value={p.name}>{p.name}</option>
|
||||
))}
|
||||
{activeCombName && combinations.some(p => p.name === activeCombName) && (
|
||||
<>
|
||||
<option disabled>──────────</option>
|
||||
<option value="__delete__">🗑 Aktuelle löschen</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={() => onSaveCurrentCombination && onSaveCurrentCombination()}
|
||||
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
|
||||
>
|
||||
<Icon name="add" size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={() => onEditCombinations && onEditCombinations()}
|
||||
title="Alle Kombinationen bearbeiten (Dialog)"
|
||||
>
|
||||
<Icon name="edit" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select value={mode} onChange={ev => onModeChange(ev.target.value)} style={{ width: '100%' }}>
|
||||
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</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 (onUserVisibilityChange) onUserVisibilityChange()
|
||||
}}
|
||||
title={ebenen.every(e => e.visible !== false)
|
||||
? 'Alle Ebenen ausblenden'
|
||||
: 'Alle Ebenen einblenden'}
|
||||
style={{ width: 18, height: 18 }}
|
||||
>
|
||||
<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 })))
|
||||
if (onUserVisibilityChange) onUserVisibilityChange()
|
||||
}}
|
||||
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>
|
||||
|
||||
{sortedEbenen.map(e => (
|
||||
<EbeneRow
|
||||
key={e.code}
|
||||
e={e}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsCode && (() => {
|
||||
const target = ebenen.find(e => e.code === settingsCode)
|
||||
if (!target) { setSettingsCode(null); return null }
|
||||
return (
|
||||
<EbenenSettingsDialog
|
||||
ebene={target}
|
||||
hatchPatterns={hatchPatterns}
|
||||
onSave={(updated) => {
|
||||
// Code-Wechsel handhaben (eindeutiger key)
|
||||
onChange(ebenen.map(e => e.code === settingsCode ? updated : e))
|
||||
setSettingsCode(null)
|
||||
}}
|
||||
onClose={() => setSettingsCode(null)}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '5px 0' }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-secondary)', fontWeight: 500, letterSpacing: 0.2 }}>
|
||||
{label}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
|
||||
{hint && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: 9, color: 'var(--text-muted)', fontWeight: 600,
|
||||
letterSpacing: 0.5, textTransform: 'uppercase',
|
||||
padding: '8px 0 2px',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
marginTop: 6,
|
||||
}}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EbenenSettingsDialog({ ebene, hatchPatterns = ['Solid'], onSave, onClose }) {
|
||||
const [draft, setDraft] = useState({
|
||||
...ebene,
|
||||
fill: {
|
||||
pattern: 'None',
|
||||
source: 'layer',
|
||||
color: null,
|
||||
scale: 1.0,
|
||||
rotation: 0,
|
||||
lw: null, // Stiftstaerke der Hatch in mm; null = wie Stift der Ebene
|
||||
...(ebene.fill || {}),
|
||||
},
|
||||
})
|
||||
|
||||
const set = (patch) => setDraft({ ...draft, ...patch })
|
||||
const setFill = (patch) => setDraft({ ...draft, fill: { ...draft.fill, ...patch } })
|
||||
|
||||
const fill = draft.fill
|
||||
const isFilled = fill.pattern !== 'None'
|
||||
const isPattern = isFilled && fill.pattern !== 'Solid'
|
||||
const fillFromLayer = fill.source === 'layer'
|
||||
const previewColor = (fillFromLayer || !fill.color) ? draft.color : fill.color
|
||||
|
||||
// Pattern-Optionen: None + Solid + Patterns
|
||||
const patternOptions = [
|
||||
'None', 'Solid',
|
||||
...hatchPatterns.filter(p => p !== 'Solid' && p !== 'None'),
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 150,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: 30,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
width: 'calc(100% - 16px)', maxWidth: 300,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 'calc(100vh - 60px)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Icon name="settings" size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flex: 1, fontWeight: 600, fontSize: 11,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{ebene.code} — {ebene.name}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '6px 12px 4px', overflowY: 'auto' }}>
|
||||
<Field label="CODE">
|
||||
<input
|
||||
value={draft.code}
|
||||
onChange={(ev) => set({ code: ev.target.value })}
|
||||
maxLength={4}
|
||||
style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font-mono)', fontWeight: 600, minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="NAME">
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(ev) => set({ name: ev.target.value })}
|
||||
style={{ flex: 1, fontSize: 11, fontWeight: 600, minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="FARBE (Stift)">
|
||||
<input
|
||||
type="color"
|
||||
value={draft.color}
|
||||
onChange={(ev) => set({ color: ev.target.value })}
|
||||
style={{
|
||||
width: 32, height: 22, padding: 0,
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={draft.color}
|
||||
onChange={(ev) => set({ color: ev.target.value })}
|
||||
style={{ flex: 1, fontSize: 10, fontFamily: 'var(--font-mono)', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="STRICHSTÄRKE (mm)">
|
||||
<input
|
||||
type="number" step="0.01" min="0.01" max="2.0"
|
||||
value={draft.lw}
|
||||
onChange={(ev) => set({ lw: parseFloat(ev.target.value) || draft.lw })}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SectionLabel>Schraffur „Nach Ebene"</SectionLabel>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4, display: 'block', marginBottom: 4 }}>
|
||||
Wird auf neue geschlossene Kurven angewandt die auf dieser Ebene gezeichnet werden.
|
||||
</span>
|
||||
|
||||
<Field label="PATTERN">
|
||||
<select
|
||||
value={fill.pattern}
|
||||
onChange={(ev) => setFill({ pattern: ev.target.value })}
|
||||
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
|
||||
>
|
||||
{patternOptions.map(p => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{isFilled && (
|
||||
<>
|
||||
<Field label="FARBE-QUELLE">
|
||||
<select
|
||||
value={fill.source}
|
||||
onChange={(ev) => setFill({ source: ev.target.value })}
|
||||
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
|
||||
>
|
||||
<option value="layer">Nach Stift (Layerfarbe)</option>
|
||||
<option value="object">Eigene Farbe</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="VORSCHAU / FARBE">
|
||||
<input
|
||||
type="color"
|
||||
value={previewColor}
|
||||
disabled={fillFromLayer}
|
||||
onChange={(ev) => setFill({ color: ev.target.value, source: 'object' })}
|
||||
style={{
|
||||
width: 32, height: 22, padding: 0,
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
||||
cursor: fillFromLayer ? 'default' : 'pointer',
|
||||
background: 'transparent',
|
||||
opacity: fillFromLayer ? 0.6 : 1,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>
|
||||
{previewColor}
|
||||
</span>
|
||||
</Field>
|
||||
|
||||
{isPattern && (
|
||||
<>
|
||||
<Field label="SKALIERUNG">
|
||||
<input
|
||||
type="number" step="0.05" min="0.001"
|
||||
value={fill.scale}
|
||||
onChange={(ev) => setFill({ scale: parseFloat(ev.target.value) || 1.0 })}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="DREHUNG (°)">
|
||||
<input
|
||||
type="number" step="5"
|
||||
value={fill.rotation}
|
||||
onChange={(ev) => setFill({ rotation: parseFloat(ev.target.value) || 0 })}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="STIFTSTÄRKE (mm)"
|
||||
hint="Leer = wie Stift der Ebene. Eigener Wert ueberschreibt die Strichstaerke der Hatch-Linien."
|
||||
>
|
||||
<input
|
||||
type="number" step="0.01" min="0"
|
||||
value={fill.lw == null ? '' : fill.lw}
|
||||
placeholder="—"
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value
|
||||
if (v === '' || v === null) setFill({ lw: null })
|
||||
else {
|
||||
const f = parseFloat(v)
|
||||
if (!isNaN(f) && f >= 0) setFill({ lw: f })
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
className="btn-text"
|
||||
style={{ fontSize: 10, padding: '2px 6px' }}
|
||||
onClick={() => setFill({ lw: null })}
|
||||
title="Auf 'wie Stift der Ebene' zuruecksetzen"
|
||||
>Default</button>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn-text" onClick={onClose}>Abbrechen</button>
|
||||
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
export default function GeschossDialog({ zeichnungsebenen, recalcOkff, onSave, onClose }) {
|
||||
const [draft, setDraft] = useState(zeichnungsebenen.map(z => ({ ...z })))
|
||||
|
||||
const update = (i, field, val) => {
|
||||
const next = draft.map((z, idx) => idx === i ? { ...z, [field]: val } : z)
|
||||
setDraft(recalcOkff(next))
|
||||
}
|
||||
|
||||
const toggleGeschoss = (i) => {
|
||||
const next = draft.map((z, idx) => {
|
||||
if (idx !== i) return z
|
||||
if (z.isGeschoss) {
|
||||
return { ...z, isGeschoss: false, hoehe: undefined, schnitthoehe: undefined, okff: undefined }
|
||||
} else {
|
||||
return { ...z, isGeschoss: true, hoehe: z.hoehe ?? 3.00, schnitthoehe: z.schnitthoehe ?? 1.00 }
|
||||
}
|
||||
})
|
||||
setDraft(recalcOkff(next))
|
||||
}
|
||||
|
||||
const add = (isGeschoss) => {
|
||||
const n = draft.length
|
||||
const newZ = isGeschoss
|
||||
? { id: `z_${Date.now()}`, name: `${n}OG`, isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true }
|
||||
: { id: `z_${Date.now()}`, name: `Neu ${n + 1}`, isGeschoss: false, visible: true }
|
||||
setDraft(recalcOkff([...draft, newZ]))
|
||||
}
|
||||
|
||||
const remove = (i) => {
|
||||
if (draft.length <= 1) return
|
||||
setDraft(recalcOkff(draft.filter((_, idx) => idx !== i)))
|
||||
}
|
||||
|
||||
const move = (i, dir) => {
|
||||
const next = [...draft]
|
||||
const t = i + dir
|
||||
if (t < 0 || t >= next.length) return
|
||||
;[next[i], next[t]] = [next[t], next[i]]
|
||||
setDraft(recalcOkff(next))
|
||||
}
|
||||
|
||||
const gesamthoehe = draft
|
||||
.filter(z => z.isGeschoss)
|
||||
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
|
||||
|
||||
const col = {
|
||||
move: { width: 28, flexShrink: 0 },
|
||||
geschoss:{ width: 24, flexShrink: 0 },
|
||||
name: { flex: 1, minWidth: 60 },
|
||||
okff: { width: 50, flexShrink: 0 },
|
||||
hoehe: { width: 64, flexShrink: 0 },
|
||||
schnitt: { width: 64, flexShrink: 0 },
|
||||
del: { width: 22, flexShrink: 0 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: 40,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
width: 'calc(100% - 24px)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
maxHeight: 'calc(100vh - 80px)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ flex: 1, fontWeight: 600, fontSize: 12, color: 'var(--text-primary)' }}>
|
||||
Zeichnungsebenen
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
Gebäude {gesamthoehe.toFixed(2)} m
|
||||
</span>
|
||||
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px' }}>×</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 14px', background: 'var(--bg-section)',
|
||||
borderBottom: '1px solid var(--border)', flexShrink: 0,
|
||||
}}>
|
||||
<div style={col.move} />
|
||||
<span className="label-xs" style={col.geschoss}>G</span>
|
||||
<span className="label-xs" style={col.name}>Name</span>
|
||||
<span className="label-xs" style={col.okff}>OKFF</span>
|
||||
<span className="label-xs" style={col.hoehe}>Höhe</span>
|
||||
<span className="label-xs" style={col.schnitt}>Schnitt h</span>
|
||||
<div style={col.del} />
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{draft.map((z, i) => (
|
||||
<div key={z.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
background: i % 2 === 0 ? 'var(--bg-item)' : 'var(--bg-dialog)',
|
||||
}}>
|
||||
<div style={{ ...col.move, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<button className="btn-step" onClick={() => move(i, -1)} disabled={i === 0}>
|
||||
<Icon name="arrow_drop_up" size={14} />
|
||||
</button>
|
||||
<button className="btn-step" onClick={() => move(i, 1)} disabled={i === draft.length - 1}>
|
||||
<Icon name="arrow_drop_down" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={col.geschoss}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!z.isGeschoss}
|
||||
onChange={() => toggleGeschoss(i)}
|
||||
title="Ist ein Geschoss"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={z.name}
|
||||
onChange={ev => update(i, 'name', ev.target.value)}
|
||||
style={{ ...col.name, fontWeight: 600, fontSize: 11 }}
|
||||
/>
|
||||
|
||||
<div style={{ ...col.okff, color: z.isGeschoss ? 'var(--text-muted)' : 'transparent', fontSize: 11, fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
|
||||
{z.isGeschoss ? `+${(z.okff ?? 0).toFixed(2)}` : '—'}
|
||||
</div>
|
||||
|
||||
<div style={{ ...col.hoehe, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{z.isGeschoss ? (
|
||||
<>
|
||||
<input type="number" step="0.05" min="0.5" max="20"
|
||||
value={z.hoehe ?? 3.0}
|
||||
onChange={ev => update(i, 'hoehe', parseFloat(ev.target.value) || z.hoehe || 3.0)}
|
||||
style={{ width: 44, textAlign: 'right' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>m</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ ...col.schnitt, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{z.isGeschoss ? (
|
||||
<>
|
||||
<input type="number" step="0.05" min="0.1"
|
||||
value={z.schnitthoehe ?? 1.0}
|
||||
onChange={ev => update(i, 'schnitthoehe', parseFloat(ev.target.value) || 1.0)}
|
||||
style={{ width: 44, textAlign: 'right' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>m</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={col.del}>
|
||||
<button className="btn-icon-sm" onClick={() => remove(i)} title="Löschen">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px', borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)', flexShrink: 0,
|
||||
}}>
|
||||
<button className="btn-outlined" onClick={() => add(true)} style={{
|
||||
color: 'var(--accent-light)', borderColor: 'var(--accent-border)',
|
||||
}}>+ Geschoss</button>
|
||||
<button className="btn-outlined" onClick={() => add(false)}>+ Zeichnung</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn-text" onClick={onClose}>Abbrechen</button>
|
||||
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react'
|
||||
import Section from './Section'
|
||||
import Icon from './Icon'
|
||||
import GeschossDialog from './GeschossDialog'
|
||||
import GeschossSettingsDialog from './GeschossSettingsDialog'
|
||||
|
||||
function GeschossBadge({ name }) {
|
||||
return <span className="chip chip-info">{name}</span>
|
||||
}
|
||||
|
||||
function ZeichnungsebeneRow({ z, active, mode, onClick, onToggleVisible, onSettings }) {
|
||||
// Eye-State auch fuer die aktive Zeichnungsebene anzeigen (User-Intention)
|
||||
const eyeShown = mode !== 'active'
|
||||
const isGeschoss = !!z.isGeschoss
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '4px 12px',
|
||||
margin: active ? '1px 6px' : '0',
|
||||
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
|
||||
// Pill-Form fuer die aktive Zeichnungsebene
|
||||
borderRadius: active ? 999 : 0,
|
||||
borderLeft: active ? 'none' : '3px solid transparent',
|
||||
borderBottom: active ? 'none' : '1px solid var(--border-light)',
|
||||
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
opacity: (!active && z.visible === false && mode !== 'all') ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontWeight: active ? 700 : 500,
|
||||
fontSize: 12,
|
||||
color: active ? 'var(--active-light)' : 'var(--text-label)',
|
||||
flex: 1,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{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} />}
|
||||
|
||||
{isGeschoss && z.hasClipping && (
|
||||
<Icon name="content_cut" size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} title="Clipping Plane aktiv" />
|
||||
)}
|
||||
|
||||
{eyeShown ? (
|
||||
<button
|
||||
className={`btn-icon-sm ${z.visible !== false ? 'is-on' : ''}`}
|
||||
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
||||
title={
|
||||
active
|
||||
? (z.visible !== false
|
||||
? 'Normalerweise sichtbar (aktive Zeichnungsebene wird trotzdem gezeigt)'
|
||||
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv')
|
||||
: (z.visible !== false ? 'Ausblenden' : 'Einblenden')
|
||||
}
|
||||
><Icon name={z.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
|
||||
) : (
|
||||
<span style={{ width: 18, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn-icon-xs"
|
||||
onClick={(ev) => { ev.stopPropagation(); onSettings() }}
|
||||
title="Einstellungen"
|
||||
><Icon name="settings" size={12} /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{ value: 'all', label: 'Alle anzeigen' },
|
||||
{ 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,
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [settingsFor, setSettingsFor] = useState(null) // Geschoss-Objekt oder null
|
||||
|
||||
const sorted = [...zeichnungsebenen].reverse()
|
||||
const gesamthoehe = zeichnungsebenen
|
||||
.filter(z => z.isGeschoss)
|
||||
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
|
||||
|
||||
const addQuick = () => {
|
||||
const newZ = {
|
||||
id: `z_${Date.now()}`,
|
||||
name: `Neu ${zeichnungsebenen.length + 1}`,
|
||||
isGeschoss: false,
|
||||
visible: true,
|
||||
}
|
||||
onChange([...zeichnungsebenen, newZ])
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<button className="btn-add" onClick={addQuick} title="Zeichnungsebene hinzufügen">
|
||||
<Icon name="add" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon-tonal" onClick={() => setDialogOpen(true)} title="Bearbeiten">
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const toggleVisible = (id) => {
|
||||
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title="Zeichnungsebenen" badge={zeichnungsebenen.length} action={actions}>
|
||||
<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>
|
||||
<select value={mode} onChange={ev => onModeChange(ev.target.value)} style={{ width: '100%' }}>
|
||||
{MODES.map(m => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '3px 14px',
|
||||
background: 'var(--bg-section)',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}}>
|
||||
<span className="label-xs">Gebäudehöhe</span>
|
||||
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
|
||||
{gesamthoehe.toFixed(2)} m
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{sorted.map(z => (
|
||||
<ZeichnungsebeneRow
|
||||
key={z.id}
|
||||
z={z}
|
||||
active={z.id === activeId}
|
||||
mode={mode}
|
||||
onClick={() => onActiveChange(z.id)}
|
||||
onToggleVisible={() => toggleVisible(z.id)}
|
||||
onSettings={() => setSettingsFor(z)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{dialogOpen && (
|
||||
<GeschossDialog
|
||||
zeichnungsebenen={zeichnungsebenen}
|
||||
recalcOkff={recalcOkff}
|
||||
onSave={(updated) => { onChange(updated); setDialogOpen(false) }}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsFor && (
|
||||
<GeschossSettingsDialog
|
||||
geschoss={settingsFor}
|
||||
onSave={(updated) => {
|
||||
onChange(zeichnungsebenen.map(z => z.id === updated.id ? updated : z))
|
||||
setSettingsFor(null)
|
||||
}}
|
||||
onClose={() => setSettingsFor(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
/** Vertikales Feld-Layout: Label oben, Input darunter — passt in schmale Panels. */
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '5px 0' }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-secondary)', fontWeight: 500, letterSpacing: 0.2 }}>
|
||||
{label}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
|
||||
{hint && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle-Reihe: Checkbox + Label inline, Hint darunter wenn vorhanden. */
|
||||
function Toggle({ label, checked, onChange, hint }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '5px 0' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} onChange={(ev) => onChange(ev.target.checked)} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-primary)' }}>{label}</span>
|
||||
</label>
|
||||
{hint && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4, marginLeft: 22 }}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GeschossSettingsDialog({ geschoss, onSave, onClose }) {
|
||||
const [draft, setDraft] = useState({ ...geschoss })
|
||||
const set = (patch) => setDraft({ ...draft, ...patch })
|
||||
|
||||
const isG = !!draft.isGeschoss
|
||||
const hoehe = draft.hoehe ?? 3.0
|
||||
const schnitt = draft.schnitthoehe ?? 1.0
|
||||
const hasClip = !!draft.hasClipping
|
||||
const okff = draft.okff ?? 0
|
||||
const clipZ = (okff + schnitt).toFixed(2)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 150,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: 30,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-dialog)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
width: 'calc(100% - 16px)', maxWidth: 280,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Icon name="settings" size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flex: 1, fontWeight: 600, fontSize: 11,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{geschoss.name}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '6px 12px 4px' }}>
|
||||
<Field label="NAME">
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(ev) => set({ name: ev.target.value })}
|
||||
style={{ flex: 1, fontSize: 11, fontWeight: 600, minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="Ist Geschoss"
|
||||
checked={isG}
|
||||
onChange={(v) => set({ isGeschoss: v })}
|
||||
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
|
||||
/>
|
||||
|
||||
{isG && (
|
||||
<>
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
|
||||
|
||||
<Field label="HÖHE (m)">
|
||||
<input
|
||||
type="number" step="0.05" min="0.5" max="30"
|
||||
value={hoehe}
|
||||
onChange={(ev) => set({ hoehe: parseFloat(ev.target.value) || hoehe })}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="SCHNITTHÖHE (m)" hint="über Geschossboden">
|
||||
<input
|
||||
type="number" step="0.05" min="0.1"
|
||||
value={schnitt}
|
||||
onChange={(ev) => set({ schnitthoehe: parseFloat(ev.target.value) || 1.0 })}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
|
||||
|
||||
<Toggle
|
||||
label="Clipping Plane"
|
||||
checked={hasClip}
|
||||
onChange={(v) => set({ hasClipping: v })}
|
||||
hint={
|
||||
hasClip
|
||||
? `Horizontaler Schnitt bei +${clipZ}m (OKFF + Schnitthöhe). Sichtbar in der Top-Ansicht wenn dieses Geschoss aktiv ist.`
|
||||
: 'aus'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn-text" onClick={onClose}>Abbrechen</button>
|
||||
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function Icon({ name, size = 18, fill = 0, weight = 400, style }) {
|
||||
return (
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{
|
||||
fontSize: size,
|
||||
lineHeight: 1,
|
||||
fontVariationSettings: `'FILL' ${fill}, 'wght' ${weight}, 'opsz' 20`,
|
||||
...style,
|
||||
}}
|
||||
>{name}</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
export default function Section({ title, badge, action, defaultOpen = true, children }) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px 4px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: 'var(--text-muted)',
|
||||
display: 'inline-flex',
|
||||
marginLeft: -6,
|
||||
}}>
|
||||
<Icon name="arrow_drop_down" size={18} />
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 500,
|
||||
color: 'var(--text-primary)',
|
||||
letterSpacing: '0.02em',
|
||||
}}>{title}</span>
|
||||
{badge != null && <span className="chip" style={{ fontSize: 8 }}>{badge}</span>}
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginLeft: 4 }} />
|
||||
{action && <div onClick={e => e.stopPropagation()}>{action}</div>}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'opsz' 20;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* === LIGHT MODE (RAPPORT cream + olive + deep green) === */
|
||||
--bg-base: #e0dbd4;
|
||||
--bg-panel: #e0dbd4;
|
||||
--bg-section: #e0dbd4;
|
||||
--bg-item: #e0dbd4;
|
||||
--bg-item-hover: #d4cfc8;
|
||||
--bg-input: #ece8e2;
|
||||
--bg-dialog: #e4dfd7;
|
||||
--bg-overlay: rgba(26, 26, 24, 0.36);
|
||||
|
||||
--border: #c8c2ba;
|
||||
--border-light: #d4cfc8;
|
||||
--border-focus: #2f5d54;
|
||||
|
||||
--text-primary: #1a1a18;
|
||||
--text-secondary:#555550;
|
||||
--text-muted: #8a8580;
|
||||
--text-label: #1a1a18;
|
||||
|
||||
/* Petrol-Gruen (von Rapport-HTML uebernommen) */
|
||||
--accent: #2f5d54;
|
||||
--accent-light: #4a8a7c;
|
||||
--accent-dim: #e6efed;
|
||||
--accent-border: rgba(47, 93, 84, 0.35);
|
||||
|
||||
/* Active-Marker (aktives Geschoss / aktive Ebene) — saturiertes Petrol */
|
||||
--active: #1a655a;
|
||||
--active-light: #2f8275;
|
||||
--active-dim: rgba(26, 101, 90, 0.10);
|
||||
|
||||
--warn: #b5621e;
|
||||
--warn-light: #d47a30;
|
||||
--warn-dim: #fdf0e8;
|
||||
|
||||
--danger: #8a1a1a;
|
||||
--danger-light: #b03030;
|
||||
|
||||
--overlay-hover: rgba(26, 26, 24, 0.05);
|
||||
--overlay-active:rgba(26, 26, 24, 0.10);
|
||||
|
||||
--r: 4px;
|
||||
--r-lg: 8px;
|
||||
--shadow-1: 0 1px 2px rgba(26,26,24,0.08);
|
||||
--shadow-2: 0 2px 8px rgba(26,26,24,0.12);
|
||||
--shadow-3: 0 6px 24px rgba(26,26,24,0.18);
|
||||
|
||||
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M0 0l5 6 5-6z' fill='%23555550'/></svg>");
|
||||
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'DM Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* === DARK MODE (Rhino-tonig) === */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-base: #2a2a2a;
|
||||
--bg-panel: #2a2a2a;
|
||||
--bg-section: #2a2a2a;
|
||||
--bg-item: #2a2a2a;
|
||||
--bg-item-hover: #353535;
|
||||
--bg-input: #1f1f1f;
|
||||
--bg-dialog: #2c2c2c;
|
||||
--bg-overlay: rgba(0,0,0,0.55);
|
||||
|
||||
--border: #4a4a4a;
|
||||
--border-light: #3d3d3d;
|
||||
--border-focus: #5fa896;
|
||||
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary:#a8a8a8;
|
||||
--text-muted: #707070;
|
||||
--text-label: #c8c8c8;
|
||||
|
||||
/* Petrol-Gruen (von Rapport-HTML uebernommen) */
|
||||
--accent: #5fa896;
|
||||
--accent-light: #6db5a4;
|
||||
--accent-dim: rgba(95, 168, 150, 0.15);
|
||||
--accent-border: rgba(95, 168, 150, 0.4);
|
||||
|
||||
/* Active-Marker — saturiertes Petrol, distinct vom accent */
|
||||
--active: #7dc8b8;
|
||||
--active-light: #8ad1bf;
|
||||
--active-dim: rgba(125, 200, 184, 0.16);
|
||||
|
||||
--warn: #d48030;
|
||||
--warn-light: #e09040;
|
||||
--warn-dim: rgba(212, 128, 48, 0.18);
|
||||
|
||||
--danger: #c85050;
|
||||
--danger-light: #d86060;
|
||||
|
||||
--overlay-hover: rgba(255,255,255,0.06);
|
||||
--overlay-active:rgba(255,255,255,0.10);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0,0,0,0.30);
|
||||
--shadow-2: 0 2px 8px rgba(0,0,0,0.40);
|
||||
--shadow-3: 0 6px 24px rgba(0,0,0,0.55);
|
||||
|
||||
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M0 0l5 6 5-6z' fill='%23a8a8a8'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#root { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||||
|
||||
button {
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
position: relative;
|
||||
transition:
|
||||
background 0.18s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.14s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
input, select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 5px 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.16s, box-shadow 0.16s;
|
||||
}
|
||||
input:hover { border-color: var(--text-muted); }
|
||||
input:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||
}
|
||||
input[type="number"]::-webkit-inner-spin-button { opacity: 0.3; }
|
||||
|
||||
/* Pill-shaped select */
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--bg-item);
|
||||
background-image: var(--select-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding: 5px 26px 5px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font);
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
select:hover {
|
||||
background-color: var(--bg-item-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.label-xs {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
|
||||
.btn-outlined {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-item);
|
||||
color: var(--text-secondary);
|
||||
height: 26px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-outlined:hover {
|
||||
background: var(--bg-item-hover);
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-outlined:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-contained {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 16px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-1);
|
||||
height: 28px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-contained:hover {
|
||||
background: var(--accent-light);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
height: 28px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.btn-text:hover {
|
||||
background: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Icon button (circular hover) */
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-icon:active {
|
||||
background: var(--overlay-active);
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-sm:hover {
|
||||
background: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-icon-sm.is-on {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon-xs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-xs:hover {
|
||||
background: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Icon button — danger variant */
|
||||
.btn-icon-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-danger:hover {
|
||||
background: var(--danger-light);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-icon-danger:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Tonal icon button (subtle filled) */
|
||||
.btn-icon-tonal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-item);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-tonal:hover {
|
||||
background: var(--bg-item-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* FAB-style add button */
|
||||
.btn-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
.btn-add:hover {
|
||||
background: var(--accent-light);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.btn-add:active {
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
/* Tiny stepper for lw etc. */
|
||||
.btn-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-step:hover {
|
||||
background: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Chip-style badge */
|
||||
.chip {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-item);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
.chip-accent {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.chip-info {
|
||||
background: var(--active-dim);
|
||||
border-color: rgba(26, 78, 138, 0.3);
|
||||
color: var(--active);
|
||||
}
|
||||
.chip-warn {
|
||||
background: var(--warn-dim);
|
||||
border-color: rgba(181, 98, 30, 0.3);
|
||||
color: var(--warn);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* rhinoBridge.js — Kommunikation React ↔ Rhino/Python
|
||||
* Lange Payloads werden in Chunks <700 Zeichen aufgeteilt (document.title-Limit).
|
||||
*/
|
||||
const CHUNK_SIZE = 700
|
||||
|
||||
let _queue = []
|
||||
let _busy = false
|
||||
|
||||
function _flush() {
|
||||
if (_busy || _queue.length === 0) return
|
||||
_busy = true
|
||||
document.title = 'RHINOMSG::' + _queue.shift()
|
||||
setTimeout(() => { _busy = false; _flush() }, 80)
|
||||
}
|
||||
|
||||
// Mac WKWebView -> .NET String-Bruecke kann mit non-ASCII-Bytes haengen
|
||||
// (Umlaute wie A-Umlaut/O-Umlaut/U-Umlaut -> "Unable to translate bytes from specified code page").
|
||||
// Vor Transport ASCII-eskapen, Python's json.loads dekodiert die \uXXXX wieder.
|
||||
const _NON_ASCII = /[^\x00-\x7f]/g
|
||||
function _asciiEscape(s) {
|
||||
return s.replace(_NON_ASCII, (c) =>
|
||||
'\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4))
|
||||
}
|
||||
|
||||
function send(type, payload = {}) {
|
||||
if (!window.RHINO_MODE) { console.log('[Bridge] →', type, payload); return }
|
||||
const json = _asciiEscape(JSON.stringify({ type, payload }))
|
||||
if (json.length <= CHUNK_SIZE) {
|
||||
_queue.push(json)
|
||||
} else {
|
||||
const total = Math.ceil(json.length / CHUNK_SIZE)
|
||||
for (let i = 0; i < total; i++) {
|
||||
_queue.push(_asciiEscape(JSON.stringify({
|
||||
_chunk: { i, n: total },
|
||||
d: json.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
|
||||
})))
|
||||
}
|
||||
}
|
||||
_flush()
|
||||
}
|
||||
|
||||
const _handlers = {}
|
||||
export function onMessage(type, handler) { _handlers[type] = handler }
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.onRhinoMessage = (data) => {
|
||||
const h = _handlers[data.type]
|
||||
if (h) h(data.payload)
|
||||
else console.log('[Bridge] ←', data.type, data.payload)
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyReady() {
|
||||
const tryReady = () => {
|
||||
if (window.RHINO_MODE) send('READY', {})
|
||||
else setTimeout(tryReady, 50)
|
||||
}
|
||||
tryReady()
|
||||
}
|
||||
|
||||
export function applyAll(zeichnungsebenen, ebenen) {
|
||||
send('APPLY', { zeichnungsebenen, ebenen })
|
||||
}
|
||||
|
||||
export function setLayerVisibility(code, visible) {
|
||||
send('LAYER_VISIBILITY', { code, visible })
|
||||
}
|
||||
|
||||
export function setLayerLock(code, locked) {
|
||||
send('LAYER_LOCK', { code, locked })
|
||||
}
|
||||
|
||||
export function setLayerStyle(code, color, lw) {
|
||||
send('LAYER_STYLE', { code, color, lw })
|
||||
}
|
||||
|
||||
export function setActiveZeichnungsebene(z) {
|
||||
send('SET_ACTIVE', { id: z.id, name: z.name, isGeschoss: !!z.isGeschoss, okff: z.okff ?? null })
|
||||
}
|
||||
|
||||
export function setActiveEbene(code) {
|
||||
send('SET_ACTIVE_LAYER', { code })
|
||||
}
|
||||
|
||||
export function deleteEbene(code, moveTo) {
|
||||
send('DELETE_EBENE', { code, moveTo: moveTo || null })
|
||||
}
|
||||
|
||||
export function moveSelectionToEbene(code) {
|
||||
send('MOVE_SELECTION_TO_LAYER', { code })
|
||||
}
|
||||
|
||||
export function setClippingEnabled(enabled) {
|
||||
send('SET_CLIPPING', { enabled: !!enabled })
|
||||
}
|
||||
|
||||
// --- Ausschnitte ---
|
||||
export function listAusschnitte() { send('LIST', {}) }
|
||||
export function saveAusschnitt(name) { send('SAVE', { name }) }
|
||||
export function updateAusschnitt(id) { send('UPDATE', { id }) }
|
||||
export function restoreAusschnitt(id) { send('RESTORE', { id }) }
|
||||
export function applyAusschnittToDetail(id) { send('APPLY_TO_DETAIL', { id }) }
|
||||
export function renameAusschnitt(id, name) { send('RENAME', { id, name }) }
|
||||
export function deleteAusschnitt(id) { send('DELETE', { id }) }
|
||||
export function setAusschnittFolder(id, folder) { send('SET_FOLDER', { id, folder }) }
|
||||
export function setAusschnittScale(id, scale) { send('SET_SCALE', { id, scale }) }
|
||||
export function duplicateAusschnitt(id) { send('DUPLICATE', { id }) }
|
||||
export function addAusschnittFolder(name) { send('ADD_FOLDER', { name }) }
|
||||
export function removeAusschnittFolder(name) { send('REMOVE_FOLDER', { name }) }
|
||||
export function getAusschnittLayers(id) { send('GET_LAYERS', { id }) }
|
||||
export function updateAusschnittLayers(id, layers) { send('UPDATE_LAYERS', { id, layers }) }
|
||||
export function saveLayerPreset(name, layers) { send('SAVE_PRESET', { name, layers }) }
|
||||
export function deleteLayerPreset(name) { send('DELETE_PRESET', { name }) }
|
||||
|
||||
// --- Gestaltung-Panel ---
|
||||
export function requestSelection() {
|
||||
send('GET_SELECTION', {})
|
||||
}
|
||||
|
||||
export function setColorSource(source, color) {
|
||||
send('SET_COLOR_SOURCE', { source, color: color || null })
|
||||
}
|
||||
|
||||
export function setLwSource(source, lw) {
|
||||
send('SET_LW_SOURCE', { source, lw: lw == null ? null : Number(lw) })
|
||||
}
|
||||
|
||||
export function setLinetypeSource(source, name) {
|
||||
send('SET_LINETYPE_SOURCE', { source, name: name || null })
|
||||
}
|
||||
|
||||
export function setLinetypeScale(scale) {
|
||||
send('SET_LINETYPE_SCALE', { scale: scale == null ? null : Number(scale) })
|
||||
}
|
||||
|
||||
export function setFill(enabled, source, color, pattern, scale, rotation) {
|
||||
send('SET_FILL', {
|
||||
enabled: !!enabled,
|
||||
source: source || 'object',
|
||||
color: color || null,
|
||||
pattern: pattern || null,
|
||||
scale: scale == null ? null : Number(scale),
|
||||
rotation: rotation == null ? null : Number(rotation),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Massstab-Panel ---
|
||||
export function requestMassstab() { send('REQUEST_STATE', {}) }
|
||||
export function setMassstab(ratio) { send('SET_SCALE', { ratio: Number(ratio) }) }
|
||||
export function zoomOneToOne() { send('ZOOM_ONE_TO_ONE', {}) }
|
||||
export function zoomExtents() { send('ZOOM_EXTENTS', {}) }
|
||||
export function zoomSelection() { send('ZOOM_SELECTION', {}) }
|
||||
export function setMassstabDpi(dpi) { send('SET_DPI', { dpi: Number(dpi) }) }
|
||||
export function detectMassstabDpi() { send('DETECT_DPI', {}) }
|
||||
export function setShowLineweights(on) { send('SET_LINEWEIGHTS',{ enabled: !!on }) }
|
||||
|
||||
// --- Werkzeuge-Panel ---
|
||||
export function runRhinoCommand(cmd) { send('RUN', { cmd }) }
|
||||
|
||||
// --- Oberleiste-Panel ---
|
||||
export function setView(view) { send('SET_VIEW', { view }) }
|
||||
export function setDisplayMode(name) { send('SET_DISPLAY_MODE', { name }) }
|
||||
export function toggleOrtho(on) { send('TOGGLE_ORTHO', { enabled: !!on }) }
|
||||
export function toggleGridSnap(on) { send('TOGGLE_GRID_SNAP', { enabled: !!on }) }
|
||||
export function toggleOsnap(on) { send('TOGGLE_OSNAP', { enabled: !!on }) }
|
||||
export function toggleOverrides(on) { send('TOGGLE_OVERRIDES', { enabled: !!on }) }
|
||||
export function setOverridesPreset(name) { send('SET_OVERRIDES_PRESET', { name: name || null }) }
|
||||
export function saveOverridesPreset(name) { send('SAVE_OVERRIDES_PRESET', { name }) }
|
||||
export function openOverridesPanel() { send('OPEN_OVERRIDES_PANEL', {}) }
|
||||
export function runCommand(cmd) { send('RUN_COMMAND', { cmd }) }
|
||||
export function sendKeys(text, enter) { send('SEND_KEYS', { text, enter: enter !== false }) }
|
||||
export function cancelCommand() { send('CANCEL_COMMAND', {}) }
|
||||
export function toggleRhinoCmdLine() { send('TOGGLE_RHINO_CMD_LINE', {}) }
|
||||
|
||||
// --- Overrides-Panel ---
|
||||
export function setOverridesEnabled(on) { send('SET_ENABLED', { enabled: !!on }) }
|
||||
export function addRule(rule) { send('ADD_RULE', { rule }) }
|
||||
export function updateRule(id, rule) { send('UPDATE_RULE', { id, rule }) }
|
||||
export function deleteRule(id) { send('DELETE_RULE', { id }) }
|
||||
export function reorderRules(order) { send('REORDER_RULES', { order }) }
|
||||
export function duplicateRule(id) { send('DUPLICATE_RULE', { id }) }
|
||||
export function reapplyOverrides() { send('REAPPLY', {}) }
|
||||
export function clearOverrideRules() { send('CLEAR_RULES', {}) }
|
||||
export function savePreset(name) { send('SAVE_PRESET', { name }) }
|
||||
export function loadPreset(name, mode) { send('LOAD_PRESET', { name, mode: mode || 'replace' }) }
|
||||
export function deletePreset(name) { send('DELETE_PRESET', { name }) }
|
||||
|
||||
// --- Dimensionen-Panel ---
|
||||
export function setRefPoint(x, y, z) { send('SET_REF_POINT', { x, y, z }) }
|
||||
export function setCoordSystem(mode) { send('SET_COORD_SYSTEM', { mode }) }
|
||||
export function setDimPosition(axis, value) { send('SET_POSITION', { axis, value }) }
|
||||
export function setDimDimension(axis, value) { send('SET_DIMENSION', { axis, value }) }
|
||||
export function setDimRotationZ(angle) { send('SET_ROTATION_Z', { angle }) }
|
||||
export function setCircleRadius(value) { send('SET_CIRCLE_RADIUS', { value }) }
|
||||
export function setLineLength(value) { send('SET_LINE_LENGTH', { value }) }
|
||||
export function setRectangleDims(width, height) { send('SET_RECTANGLE', { width, height }) }
|
||||
|
||||
// --- Layouts-Panel ---
|
||||
export function listLayouts() { send('LIST', {}) }
|
||||
export function newLayout(name, format, landscape, customWidth, customHeight) {
|
||||
send('NEW_LAYOUT', { name, format, landscape: !!landscape,
|
||||
customWidth, customHeight })
|
||||
}
|
||||
export function deleteLayout(id) { send('DELETE_LAYOUT', { id }) }
|
||||
export function renameLayout(id, name) { send('RENAME_LAYOUT', { id, name }) }
|
||||
export function activateLayout(id) { send('ACTIVATE_LAYOUT', { id }) }
|
||||
export function addDetail(pageId, ausschnittId) {
|
||||
send('ADD_DETAIL', { pageId, ausschnittId: ausschnittId || null })
|
||||
}
|
||||
export function deleteDetail(pageId, detailId) {
|
||||
send('DELETE_DETAIL', { pageId, detailId })
|
||||
}
|
||||
export function bindAusschnitt(pageId, detailId, ausschnittId) {
|
||||
send('BIND_AUSSCHNITT', { pageId, detailId, ausschnittId: ausschnittId || null })
|
||||
}
|
||||
export function syncDetail(pageId, detailId) { send('SYNC_DETAIL', { pageId, detailId }) }
|
||||
export function syncLayout(id) { send('SYNC_LAYOUT', { id }) }
|
||||
export function setPageSize(id, format, landscape, customWidth, customHeight) {
|
||||
send('SET_PAGE_SIZE', { id, format, landscape: !!landscape,
|
||||
customWidth, customHeight })
|
||||
}
|
||||
export function exportPdf(id, dpi) { send('EXPORT_PDF', { id, dpi: dpi || 300 }) }
|
||||
export function exportPdfAll(dpi) { send('EXPORT_PDF', { dpi: dpi || 300 }) }
|
||||
export function exportPdfMany(ids, dpi) { send('EXPORT_PDF', { ids, dpi: dpi || 300 }) }
|
||||
export function addLayoutFolder(name) { send('ADD_FOLDER', { name }) }
|
||||
export function removeLayoutFolder(name) { send('REMOVE_FOLDER', { name }) }
|
||||
export function setLayoutFolder(id, folder) { send('SET_FOLDER', { id, folder }) }
|
||||
|
||||
// --- Elemente-Panel (Smart Architektur-Elemente) ---
|
||||
export function listElemente() { send('LIST', {}) }
|
||||
export function createWall(p) { send('CREATE_WALL', p || {}) }
|
||||
export function createDecke(p) { send('CREATE_DECKE', p || {}) }
|
||||
export function createDach(p) { send('CREATE_DACH', p || {}) }
|
||||
export function createFenster(p) { send('CREATE_FENSTER', p || {}) }
|
||||
export function createTuer(p) { send('CREATE_TUER', p || {}) }
|
||||
export function createTreppe(p) { send('CREATE_TREPPE', p || {}) }
|
||||
export function updateElement(id, patch) { send('UPDATE_ELEMENT', { id, ...(patch || {}) }) }
|
||||
export function deleteElement(id) { send('DELETE_ELEMENT', { id }) }
|
||||
// Backwards-Compat-Aliases
|
||||
export function updateWall(id, patch) { send('UPDATE_WALL', { id, ...(patch || {}) }) }
|
||||
export function deleteWall(id) { send('DELETE_WALL', { id }) }
|
||||
export function regenerateAllWalls() { send('REGENERATE_ALL', {}) }
|
||||
export function regenerateAllElements() { send('REGENERATE_ALL', {}) }
|
||||
|
||||
|
||||
let _visTimer = null
|
||||
let _visArgs = null
|
||||
|
||||
// --- EBENEN: Ebenenkombinationen (geteilter Store mit AUSSCHNITTE) ---
|
||||
export function getCombination() { send('GET_COMBINATION', {}) }
|
||||
export function applyCombination(layersOrPreset) {
|
||||
// Akzeptiert entweder ein Array von Layer-States (alt) oder ein Preset-
|
||||
// Objekt {layers, dossierEbenen, dossierZeichnungsebenen} (neu). Backend kommt
|
||||
// mit beiden Formaten klar.
|
||||
if (Array.isArray(layersOrPreset)) {
|
||||
send('APPLY_COMBINATION', { layers: layersOrPreset })
|
||||
} else if (layersOrPreset && typeof layersOrPreset === 'object') {
|
||||
send('APPLY_COMBINATION', layersOrPreset)
|
||||
} else {
|
||||
send('APPLY_COMBINATION', { layers: [] })
|
||||
}
|
||||
}
|
||||
export function saveCombinationPreset(name, layers) { send('SAVE_PRESET', { name, layers }) }
|
||||
export function saveCurrentAsCombination(name) { send('SAVE_CURRENT_AS_PRESET', { name }) }
|
||||
export function deleteCombinationPreset(name) { send('DELETE_PRESET', { name }) }
|
||||
|
||||
export function applyVisibility(activeZ, zeichnungsebenen, activeCode, ebenen, zMode, eMode) {
|
||||
_visArgs = { activeZ, zeichnungsebenen, activeCode, ebenen, zMode, eMode }
|
||||
if (_visTimer) clearTimeout(_visTimer)
|
||||
_visTimer = setTimeout(() => {
|
||||
_visTimer = null
|
||||
const a = _visArgs
|
||||
const slimZ = a.zeichnungsebenen.map(z => ({
|
||||
id: z.id, name: z.name, visible: z.visible !== false,
|
||||
}))
|
||||
const slimE = a.ebenen.map(e => ({
|
||||
code: e.code, visible: e.visible !== false, locked: e.locked === true,
|
||||
}))
|
||||
send('SET_VISIBILITY', {
|
||||
activeZ: { id: a.activeZ.id },
|
||||
activeCode: a.activeCode,
|
||||
zeichnungsebenen: slimZ,
|
||||
ebenen: slimE,
|
||||
zMode: a.zMode,
|
||||
eMode: a.eMode,
|
||||
})
|
||||
}, 30)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import GestaltungApp from './GestaltungApp.jsx'
|
||||
import AusschnitteApp from './AusschnitteApp.jsx'
|
||||
import MassstabApp from './MassstabApp.jsx'
|
||||
import WerkzeugeApp from './WerkzeugeApp.jsx'
|
||||
import OberleisteApp from './OberleisteApp.jsx'
|
||||
import OverridesApp from './OverridesApp.jsx'
|
||||
import DimensionenApp from './DimensionenApp.jsx'
|
||||
import LayoutsApp from './LayoutsApp.jsx'
|
||||
import ElementeApp from './ElementeApp.jsx'
|
||||
|
||||
const mode = (typeof window !== 'undefined' && window.PANEL_MODE) || 'ebenen'
|
||||
const RootApp = mode === 'gestaltung' ? GestaltungApp
|
||||
: mode === 'ausschnitte' ? AusschnitteApp
|
||||
: mode === 'massstab' ? MassstabApp
|
||||
: mode === 'werkzeuge' ? WerkzeugeApp
|
||||
: mode === 'oberleiste' ? OberleisteApp
|
||||
: mode === 'overrides' ? OverridesApp
|
||||
: mode === 'dimensionen' ? DimensionenApp
|
||||
: mode === 'layouts' ? LayoutsApp
|
||||
: mode === 'elemente' ? ElementeApp
|
||||
: App
|
||||
|
||||
window.onerror = function (msg, src, line, col, err) {
|
||||
document.body.style.cssText = 'background:#1c1c1e;margin:0;padding:12px'
|
||||
document.body.innerHTML =
|
||||
'<pre style="color:#ff6b6b;font-size:10px;font-family:monospace;white-space:pre-wrap">' +
|
||||
'JS ERROR:\n' + msg + '\n' + src + ':' + line + '\n' +
|
||||
(err ? err.stack : '') + '</pre>'
|
||||
return false
|
||||
}
|
||||
|
||||
window.onunhandledrejection = function (e) {
|
||||
document.body.style.cssText = 'background:#1c1c1e;margin:0;padding:12px'
|
||||
document.body.innerHTML =
|
||||
'<pre style="color:#ff6b6b;font-size:10px;font-family:monospace;white-space:pre-wrap">' +
|
||||
'PROMISE ERROR:\n' + (e.reason ? e.reason.stack || e.reason : e) + '</pre>'
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<RootApp />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user