Files
DOSSIER/src/OberleisteApp.jsx
T
karim c16f5ea740 View-Toggle: Icons 13→11, N/O/S/W Font 11→10, BarSelect Dead-Code raus
User: View-Bars wirken hoeher als andere Bars. Hoehen sind mathematisch
identisch (BAR_H + Border = 24 visual pro Reihe), aber 8 Buttons mit
Border-Trennlinien wirken visuell dichter als ein einzelner Dropdown.

Visual-Fix per User-Vorschlag: Icons + Font in Views minimal verkleinert,
macht die Bars weniger "dominant".

Plus BarSelect (komplett unused Dead-Code seit Migration zu BarCombo)
geloescht — der hatte als einziger noch bg-item statt bg-input und
verwirrte die Background-Konsistenz-Frage.

Hintergrund-Check: Alle Pills nutzen jetzt einheitlich var(--bg-input),
Hover var(--bg-item-hover), Active var(--accent).

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

920 lines
37 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
pickLayerCombination, saveLayerCombination,
deleteLayerCombination, openLayerCombinationsDialog,
openDossierSettings, openKameraPanel,
setMasseActive, openMasseSettings,
openAbout, createText, setTextSettings,
} 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' },
]
// Reihe 1: 3D-Ansichten (Top, Iso, Persp) + Kamera-Button
// Reihe 2: 4 Gebaeudeansichten (Norden/Osten/Sueden/Westen) — Buchstaben
// als Symbol, rotieren mit dossier_north_angle.
const VIEWS_ROW1 = [
{ value: 'Top', icon: 'crop_landscape', kind: 'icon' },
{ value: 'Iso', icon: 'view_in_ar', kind: 'icon' },
{ value: 'Perspective', icon: '3d_rotation', kind: 'icon' },
]
const VIEWS_ROW2 = [
{ value: 'N', label: 'N', kind: 'letter' },
{ value: 'O', label: 'O', kind: 'letter' },
{ value: 'S', label: 'S', kind: 'letter' },
{ value: 'W', label: 'W', kind: 'letter' },
]
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
}
// ---------------------------------------------------------------------------
// Vectorworks-inspirierte Bar-Widgets: Icon links, Label/Select Mitte,
// Caret rechts — alle in einem rechteckigen Container, sichtbare Trennung
// zwischen Icon-Kompartiment und Inhalt.
const PILL_H = 20 // alte Pill-Hoehe (Buttons/Chips die nicht migriert sind)
const BAR_H = 22 // neue Widget-Hoehe (BarSelect, BarButton, BarGroup)
const sep = {
width: 1, height: 20,
background: 'var(--border)', flexShrink: 0,
margin: '0 3px',
}
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,
}
// BarCombo: dunklerer (bg-input) Pill-Container der select + optional gear
// als EINE nahtlose Box rendert. Icon roh links daneben (kein Container).
// iconClickable=true macht das Icon zum Toggle-Button (Overrides etc.).
// valueAccent=true faerbt den Select-Text accent (fuer Massstab "gesetzt").
function BarCombo({
icon, iconActive, iconClickable, onIconClick, iconTitle,
value, onChange, width, title, children, disabled,
onGear, gearTitle, valueAccent,
}) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
opacity: disabled ? 0.5 : 1, flexShrink: 0,
}}>
{/* Icon links — fixe Breite fuer X-Axis-Alignment zwischen Reihen.
Wenn icon=null/undefined wird kein Icon-Slot reserviert. */}
{icon && (iconClickable ? (
<button onClick={onIconClick} title={iconTitle}
style={{
width: 18, height: BAR_H,
background: 'transparent', border: 'none',
cursor: 'pointer', flexShrink: 0, padding: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={icon} size={13}
style={{ color: iconActive ? 'var(--accent)' : 'var(--text-muted)' }} />
</button>
) : (
<span style={{
width: 18, height: BAR_H, flexShrink: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={icon} size={13} style={{ color: 'var(--text-muted)' }} />
</span>
))}
{/* Combined pill: select + optional gear, gemeinsamer bg + border */}
<div title={title}
onMouseEnter={(e) => {
if (disabled) return
e.currentTarget.style.borderColor = 'var(--accent)'
e.currentTarget.style.background = 'var(--bg-item-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.background = 'var(--bg-input)'
}}
style={{
display: 'inline-flex', alignItems: 'stretch',
height: BAR_H, width,
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 999,
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}>
<select
value={value || ''}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{
flex: 1, minWidth: 0,
background: 'transparent',
color: valueAccent ? 'var(--accent-light)' : 'var(--text-primary)',
border: 'none', outline: 'none',
padding: '0 22px 0 12px',
fontSize: 11, fontFamily: 'var(--font)',
fontWeight: valueAccent ? 600 : 500,
appearance: 'none', WebkitAppearance: 'none',
backgroundImage: 'var(--select-arrow)',
backgroundRepeat: 'no-repeat',
// Caret-Position differenziert: ohne Gear normaler Abstand
// (10px vom Pill-Rand), mit Gear minimaler Abstand damit
// er an den Gear ranruckt.
backgroundPosition: onGear ? 'right 1px center' : 'right 10px center',
cursor: disabled ? 'not-allowed' : 'pointer',
letterSpacing: 0,
}}
>{children}</select>
{onGear && (
<button onClick={onGear} title={gearTitle}
style={{
background: 'transparent', border: 'none',
padding: '0 8px', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<Icon name="settings" size={12}
style={{ color: 'var(--text-muted)' }} />
</button>
)}
</div>
</div>
)
}
// BarButton: pill-foermiger Icon-Button im selben Stil wie BarSelect.
// joinedLeft = linke Kante flach (dockt rechts an einen BarSelect-joinedRight).
function BarButton({ icon, onClick, title, disabled, active, joinedLeft }) {
return (
<button onClick={onClick} disabled={disabled} title={title}
onMouseEnter={(e) => {
if (disabled || active) return
e.currentTarget.style.borderColor = 'var(--accent)'
e.currentTarget.style.background = 'var(--bg-item-hover)'
}}
onMouseLeave={(e) => {
if (active) return
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.background = 'var(--bg-input)'
}}
style={{
height: BAR_H, width: BAR_H,
background: active ? 'var(--accent)' : 'var(--bg-input)',
border: '1px solid var(--border)',
borderTopLeftRadius: joinedLeft ? 0 : 999,
borderBottomLeftRadius: joinedLeft ? 0 : 999,
borderTopRightRadius: 999, borderBottomRightRadius: 999,
borderLeft: joinedLeft ? 'none' : '1px solid var(--border)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1, flexShrink: 0,
padding: 0,
transition: 'border-color 0.15s, background 0.15s',
}}>
<Icon name={icon} size={13}
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />
</button>
)
}
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: [],
layerCombinations: [], layerCombinationActive: null,
massePresets: [], masseActiveId: null,
textSettings: { font: 'Helvetica', size: 0.20, bold: false, italic: false },
textFonts: [],
northAngle: 0,
lastSetView: null,
})
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)
}
// Active-Highlight aus lastSetView (vom Backend getrackt — vermeidet
// Race-Conditions zwischen ChangeProjection und Viewport-State-Lesen).
// Fallback wenn noch nie geklickt: Viewport-State raten.
const matchView = (v) => {
if (state.lastSetView) return state.lastSetView === v
const name = (state.viewName || '').toLowerCase()
if (v === 'Top') return name === 'top'
if (v === 'Perspective') return state.parallel === false
if (v === 'Iso') {
const ortho = ['top', 'front', 'right', 'bottom', 'left', 'back']
return state.parallel === true && !ortho.includes(name)
}
return false
}
// (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: 6,
padding: '3px 10px 6px',
overflowX: 'auto', overflowY: 'hidden',
flexShrink: 0,
}}>
{/* Logo: DOSSIER. (Petrol-Punkt) — Klick = About-Fenster */}
<button
onClick={() => openAbout()}
title="Über Dossier"
style={{
display: 'flex', alignItems: 'baseline', gap: 8,
flexShrink: 0, userSelect: 'none',
background: 'transparent', border: 'none', padding: 0,
cursor: 'pointer', color: 'inherit',
}}
>
<span style={{
fontFamily: "Krungthep, 'Archivo Black', sans-serif",
fontSize: 17,
letterSpacing: '-0.02em',
color: 'var(--text-primary)',
lineHeight: 1,
}}>
DOSSIER<span style={{ color: 'var(--accent)' }}>.</span>
</span>
</button>
<button
onClick={() => openDossierSettings()}
title="Dossier-Einstellungen"
style={{
background: 'transparent', border: 'none', padding: '2px 4px',
cursor: 'pointer', color: 'var(--text-muted)',
display: 'flex', alignItems: 'center', flexShrink: 0,
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}
>
<Icon name="settings" size={14} />
</button>
<div style={sep} />
{/* ====== VIEW 2x4 Grid ======
Reihe 1: TOP / ISO / PERSP / 📷 (Kamera-Settings)
Reihe 2: N / O / S / W (rotieren mit dossier_north_angle)
*/}
{(() => {
const VIEW_W = 140 // konsistent mit Massstab-Pills
const CELL_W = Math.floor(VIEW_W / 4)
const cellStyle = (isActive, isFirst) => ({
height: BAR_H, width: CELL_W,
background: isActive ? 'var(--accent)' : 'var(--bg-input)',
color: isActive ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
gap: 4, fontWeight: isActive ? 600 : 500,
cursor: 'pointer', flexShrink: 0, padding: 0,
transition: 'background 0.15s, color 0.15s',
})
const hoverIn = (isActive) => (e) => {
if (isActive) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}
const hoverOut = (isActive) => (e) => {
if (isActive) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}
// Reihe 1: 3 View-Icons + Kamera-Settings
const row1 = [
...VIEWS_ROW1,
{ value: '__camera__', icon: 'videocam', kind: 'icon' },
]
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0,
}}>
{/* Reihe 1 */}
<div style={{
display: 'inline-flex', width: VIEW_W,
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}}>
{row1.map((v, idx) => {
const isActive = v.value !== '__camera__' && matchView(v.value)
const label = v.value === '__camera__' ? 'Kamera-Einstellungen'
: `Ansicht ${v.value}`
return (
<button key={v.value}
onClick={() => v.value === '__camera__'
? openKameraPanel() : setView(v.value)}
title={label}
onMouseEnter={hoverIn(isActive)}
onMouseLeave={hoverOut(isActive)}
style={cellStyle(isActive, idx === 0)}>
<Icon name={v.icon} size={11} />
</button>
)
})}
</div>
{/* Reihe 2: N/O/S/W als Buchstaben */}
<div style={{
display: 'inline-flex', width: VIEW_W,
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}}>
{VIEWS_ROW2.map((v, idx) => {
const isActive = matchView(v.value)
return (
<button key={v.value}
onClick={() => setView(v.value)}
title={`Ansicht aus ${v.value} (Norden = ${(state.northAngle || 0).toFixed(0)}°)`}
onMouseEnter={hoverIn(isActive)}
onMouseLeave={hoverOut(isActive)}
style={cellStyle(isActive, idx === 0)}>
<span style={{
fontFamily: 'DM Mono, monospace', fontSize: 10, fontWeight: 600,
}}>{v.label}</span>
</button>
)
})}
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== 2-Reihen Preset-Block ======
Oben: Display | Kombi
Unten: Overrides | Masse
Gleiche Pill-Breiten, identische X-Positionen (Grid-Layout). */}
{(() => {
const PRESET_W = 150
return (
<div style={{
display: 'grid', gridTemplateColumns: 'auto auto',
gap: '4px 6px', flexShrink: 0,
}}>
{/* Reihe 1, Spalte 1: Display */}
<BarCombo
icon="lightbulb"
value={state.displayMode || ''}
onChange={(v) => setDisplayMode(v)}
title="Display-Mode (Wireframe / Shaded / Rendered / etc.)"
width={PRESET_W}
>
{!state.displayMode && <option value=""></option>}
{(state.displayModes || []).map(dm => (
<option key={dm.id} value={dm.name}>{dm.name}</option>
))}
</BarCombo>
{/* Reihe 1, Spalte 2: Ebenenkombination */}
<BarCombo
icon="layers"
value={state.layerCombinationActive || '__none__'}
onChange={(v) => {
if (v === '__configure__') { openLayerCombinationsDialog(); return }
if (v === '__save__') {
const suggested = state.layerCombinationActive
|| `Kombi ${(state.layerCombinations || []).length + 1}`
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
if (!name) return
if ((state.layerCombinations || []).includes(name) &&
!window.confirm(`"${name}" überschreiben?`)) return
saveLayerCombination(name)
return
}
if (v === '__delete__') {
if (state.layerCombinationActive &&
window.confirm(`Kombination "${state.layerCombinationActive}" löschen?`))
deleteLayerCombination(state.layerCombinationActive)
return
}
pickLayerCombination(v === '__none__' ? null : v)
}}
title={state.layerCombinationActive
? `Aktive Kombi: ${state.layerCombinationActive}`
: 'Keine Kombination — manuelle Sichtbarkeit'}
width={PRESET_W}
onGear={openLayerCombinationsDialog}
gearTitle="Ebenenkombinationen bearbeiten"
>
<option value="__none__"> Eigene </option>
{(state.layerCombinations || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
<option disabled></option>
<option value="__save__">+ Aktuelle speichern</option>
{state.layerCombinationActive && (
<option value="__delete__">🗑 Aktuelle löschen</option>
)}
<option value="__configure__">Bearbeiten</option>
</BarCombo>
{/* Reihe 2, Spalte 1: Overrides (Toggle als Icon links) */}
<BarCombo
icon="auto_fix_high"
iconClickable
iconActive={state.overridesEnabled}
onIconClick={() => toggleOverrides(!state.overridesEnabled)}
iconTitle={state.overridesEnabled
? 'Grafische Overrides aktiv — klick zum Ausschalten'
: 'Grafische Overrides ausgeschaltet'}
value={state.overridesActivePreset || '__none__'}
onChange={(v) => {
if (v === '__configure__') { openOverridesPanel(); return }
setOverridesPreset(v === '__none__' ? null : v)
}}
title={state.overridesActivePreset
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
width={PRESET_W}
onGear={openOverridesPanel}
gearTitle="Overrides-Regel-Editor öffnen"
>
<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>
</BarCombo>
{/* Reihe 2, Spalte 2: Masse */}
<BarCombo
icon="straighten"
value={state.masseActiveId || ''}
onChange={(v) => setMasseActive(v)}
title="Aktives Mass — Raum-Rundung + Mass-Linien-Format"
width={PRESET_W}
onGear={openMasseSettings}
gearTitle="Masse bearbeiten / neues anlegen"
>
{(state.massePresets || []).length === 0 && <option value=""></option>}
{(state.massePresets || []).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</BarCombo>
</div>
)
})()}
<div style={sep} />
{/* ====== MASSSTAB 2x2 ======
Reihe 1: [Aktueller Massstab] [Massstab-Dropdown (gruen wenn gesetzt)]
Reihe 2: [Zoom-Verhaeltnis %] [Buttons]
*/}
{(() => {
// Buttons-Pill: gleiche Logik wie View-Toggle (weiss default,
// grün on hover, accent-fill wenn active)
const PILL_W = 140 // Gleiche Breite fuer Dropdown + Buttons-Pill
const N_BTN = 4
const BTN_W = Math.floor(PILL_W / N_BTN) // jeder Button gleich breit
const SegBtn = ({ icon, onClick, title, disabled, active, isFirst, isLast }) => (
<button onClick={onClick} disabled={disabled} title={title}
onMouseEnter={(e) => {
if (disabled || active) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (active) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
height: BAR_H, width: BTN_W,
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1, flexShrink: 0,
padding: 0,
transition: 'background 0.15s, color 0.15s',
}}>
<Icon name={icon} size={13} />
</button>
)
const ratio = (!isPerspective && appliedScale && scaleVal)
? appliedScale / scaleVal
: null
const ratioText = ratio == null
? '—'
: ratio >= 1
? Math.round(ratio * 100) + '%'
: (ratio * 100).toFixed(ratio < 0.1 ? 1 : 0) + '%'
const atScale = ratio != null && Math.abs(ratio - 1) < 0.005
const STAT_W = 70 // Breite der gemeinsamen Stat-Box
// Gesamte 2-Reihen-Hoehe: 2 × BAR_H + gap (4px) = ~48px
return (
<div style={{
display: 'grid', gridTemplateColumns: 'auto auto', gap: '4px 6px',
alignItems: 'center', flexShrink: 0,
}}>
{/* Spalte 1, beide Reihen: EINE Pill mit Live-Massstab oben und
Zoom-% unten. Text-Hoehen bleiben identisch zu den
urspruenglichen 2 Chips — der Trennstrich sitzt in der Mitte
des 4px-Gaps zwischen ihnen. */}
<div style={{
gridRow: '1 / span 2',
display: 'flex', flexDirection: 'column',
width: STAT_W, height: BAR_H * 2 + 6,
background: atScale ? 'var(--accent-dim)' : 'var(--bg-input)',
color: atScale ? 'var(--accent-light)' : 'var(--text-primary)',
border: '1px solid ' + (atScale ? 'var(--accent)' : 'var(--border)'),
borderRadius: 14,
overflow: 'hidden',
fontFamily: 'DM Mono, monospace', fontSize: 11,
fontWeight: atScale ? 600 : 500,
flexShrink: 0,
transition: 'border-color 0.15s, background 0.15s',
}}>
<div style={{
height: BAR_H,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} title={isPerspective ? 'Perspektive — kein Massstab' : 'Aktueller Live-Massstab'}>
{isPerspective ? '—' : fmtScale(scaleVal)}
</div>
<div style={{ height: 6, position: 'relative' }}>
<div style={{
position: 'absolute', left: 6, right: 6,
top: '50%', height: 1,
background: 'var(--border)',
}} />
</div>
<div style={{
height: BAR_H,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} title={ratio != null
? `Aktueller Zoom = ${ratioText} des gesetzten Massstabs`
: (isPerspective ? 'Perspektive' : 'Kein Massstab gesetzt')}>
{ratioText}
</div>
</div>
{/* Reihe 1, Spalte 2: Gesetzter Massstab Dropdown — KEIN Icon, gleiche
Breite wie Buttons-Pill darunter, exakt uebereinander */}
{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={{
height: BAR_H, width: PILL_W,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 999,
padding: '0 12px', fontSize: 11,
fontFamily: 'DM Mono, monospace',
outline: 'none',
}}
title="Massstab eingeben (Enter = uebernehmen, Esc = abbrechen)"
/>
) : (
<BarCombo
value={dropdownValue}
onChange={(v) => applyDropdown(v)}
disabled={isPerspective}
width={PILL_W}
title="Gesetzter Massstab"
>
<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>
</BarCombo>
)}
{/* Reihe 2, Spalte 2: Buttons-Pill — gleiche Breite wie Dropdown */}
<div style={{
display: 'inline-flex', width: PILL_W,
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0, justifySelf: 'start',
}}>
<SegBtn icon="percent" onClick={apply100} isFirst
disabled={isPerspective || !appliedScale}
title={appliedScale ? `Zoom auf 1:${appliedScale} snappen` : 'Erst einen Massstab wählen'} />
<SegBtn icon="fit_screen" onClick={zoomExtents}
title="Auf gesamten Inhalt zoomen" />
<SegBtn icon="center_focus_strong" onClick={zoomSelection}
title="Auf Selektion zoomen" />
<SegBtn
icon={state.showLineweights ? 'print' : 'edit'}
active={state.showLineweights}
onClick={() => setShowLineweights(!state.showLineweights)}
isLast
title={state.showLineweights
? 'Print-View aktiv — klick zum Ausschalten'
: 'Strichstärken anzeigen (Print-View)'} />
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== TEXT-Block 2-Reihen ======
Reihe 1: Font-Dropdown + Groesse (mm)
Reihe 2: B/I-Toggles + "Text einfuegen"-Button
*/}
{(() => {
const ts = state.textSettings || {}
const fonts = state.textFonts || []
const updateTs = (patch) => setTextSettings({ ...ts, ...patch })
const TEXT_W = 150
return (
<div style={{
display: 'grid', gridTemplateColumns: 'auto auto', gap: '4px 6px',
alignItems: 'center', flexShrink: 0,
}}>
{/* Reihe 1, Spalte 1: Font-Dropdown */}
<BarCombo
icon="text_fields"
value={ts.font || ''}
onChange={(v) => updateTs({ font: v })}
width={TEXT_W}
title="Schriftart"
>
{fonts.length === 0 && <option value=""></option>}
{fonts.map(f => <option key={f} value={f}>{f}</option>)}
</BarCombo>
{/* Reihe 1, Spalte 2: Groesse (mm) */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: BAR_H, padding: '0 10px',
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 999,
flexShrink: 0, width: 90,
}}>
<input
type="number" step="0.01" min="0.01"
value={ts.size != null ? ts.size : 0.2}
onChange={(e) => {
const v = parseFloat(e.target.value)
if (!isNaN(v) && v > 0) updateTs({ size: v })
}}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--text-primary)',
fontSize: 11, fontFamily: 'DM Mono, monospace',
padding: 0, textAlign: 'right',
appearance: 'auto',
}}
title="Texthoehe in Model-Units"
/>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>m</span>
</div>
{/* Reihe 2, Spalte 1: B/I-Toggles */}
<div style={{
display: 'inline-flex',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0, width: TEXT_W,
}}>
<button
onClick={() => updateTs({ bold: !ts.bold })}
onMouseEnter={(e) => {
if (ts.bold) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (ts.bold) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
flex: 1, height: BAR_H,
background: ts.bold ? 'var(--accent)' : 'var(--bg-input)',
color: ts.bold ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none', cursor: 'pointer',
fontWeight: 700, fontSize: 11,
transition: 'background 0.15s, color 0.15s',
}}
title="Fett"
>B</button>
<button
onClick={() => updateTs({ italic: !ts.italic })}
onMouseEnter={(e) => {
if (ts.italic) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (ts.italic) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
flex: 1, height: BAR_H,
background: ts.italic ? 'var(--accent)' : 'var(--bg-input)',
color: ts.italic ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none', borderLeft: '1px solid var(--border)',
cursor: 'pointer',
fontStyle: 'italic', fontSize: 11,
transition: 'background 0.15s, color 0.15s',
}}
title="Kursiv"
>I</button>
</div>
{/* Reihe 2, Spalte 2: "Text einfuegen" Button */}
<button
onClick={() => createText()}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
width: 90, height: BAR_H,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
gap: 4, cursor: 'pointer',
fontSize: 11, fontWeight: 500,
transition: 'background 0.15s, color 0.15s, border-color 0.15s',
}}
title="Position picken → Text tippen → Enter"
>
<Icon name="add" size={12} />
Text
</button>
</div>
)
})()}
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
{/* Spacer am rechten Rand */}
<div style={{ flex: 1 }} />
</div>
</div>
)
}