Files
DOSSIER/src/OberleisteApp.jsx
T
karim 3f5f48cb2c Caret differentiated + Accent-Hover wie in Elemente
User:
1. Wireframe-Caret war fast draussen (zu weit rechts), bei anderen
   minimal mehr rechts erwartet
2. Hover-Effekt mit accent-border wie in Elemente fuer alle Pills

Fix 1: backgroundPosition differenziert:
- Ohne Gear: 'right 10px center' (normaler Pill-Rand-Abstand)
- Mit Gear: 'right 1px center' (klebt am Gear)
So sitzt der Caret optisch konsistent — bei Display innen, bei den
anderen direkt vor dem Gear.

Fix 2: Hover-Akzent analog ElementeApp PillButton:
- BarCombo Pill-Container: bg → bg-item-hover, border → accent
- BarButton (Camera, Settings-Gears in Pills, Print-Toggle): selbe Logik,
  active-State bleibt accent-fill
- View-Toggle-Buttons: bg → bg-item-hover, text → accent-light, active
  bleibt accent-fill
Transitions 0.15s fuer smooth feel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 23:32:24 +02:00

738 lines
29 KiB
React

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,
} 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: 'view_quilt', label: 'Top' },
{ value: 'Front', icon: 'north', label: 'Front' },
{ value: 'Right', icon: 'east', label: 'Right' },
{ value: 'Iso', icon: 'view_in_ar', label: 'Iso' },
{ value: 'Perspective', icon: '3d_rotation', 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
}
// ---------------------------------------------------------------------------
// 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,
}
// BarSelect: Icon roh links + custom-pill <select>. Vectorworks-Stil —
// dunkler Pill-Container, Caret rechts, joinedRight macht die rechte Kante
// flach fuer die Verkettung mit BarButton.
function BarSelect({ icon, value, onChange, title, disabled, width, children, joinedRight }) {
return (
<div title={title} style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
opacity: disabled ? 0.5 : 1, flexShrink: 0,
}}>
{icon && (
<Icon name={icon} size={13}
style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
)}
<select
value={value || ''}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{
height: BAR_H, width,
background: 'var(--bg-item)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderTopLeftRadius: 999, borderBottomLeftRadius: 999,
borderTopRightRadius: joinedRight ? 0 : 999,
borderBottomRightRadius: joinedRight ? 0 : 999,
borderRight: joinedRight ? 'none' : '1px solid var(--border)',
padding: '0 24px 0 12px',
fontSize: 11, fontFamily: 'var(--font)',
appearance: 'none', WebkitAppearance: 'none',
backgroundImage: 'var(--select-arrow)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 9px center',
cursor: disabled ? 'not-allowed' : 'pointer',
flexShrink: 0, outline: 'none',
letterSpacing: 0,
}}
>{children}</select>
</div>
)
}
// 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 */}
{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,
})
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. Orthogonale Standard-Views matchen via viewName.
// 'Iso' und 'Perspective' werden via parallel-Flag unterschieden — die
// Rhino-Viewport-Namen sind beide oft "Perspective".
const matchView = (v) => {
const name = (state.viewName || '').toLowerCase()
const ortho = ['top', 'front', 'right', 'bottom', 'left', 'back']
if (v === 'Iso') {
// Parallel-Projektion + kein orthogonaler Standardname → Iso
return state.parallel === true && !ortho.includes(name)
}
if (v === 'Perspective') {
return state.parallel === false
}
return name === 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: 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 (Top/Front/Right/Iso/Persp + Kamera) ======
Segmented-Pill-Gruppe analog Vectorworks. Active = accent fill. */}
<div style={{ display: 'inline-flex', flexShrink: 0 }}>
{VIEWS.map((v, idx) => {
const isFirst = idx === 0
const isLast = idx === VIEWS.length - 1
const isActive = matchView(v.value)
return (
<button
key={v.value}
onClick={() => setView(v.value)}
title={`Ansicht ${v.label}`}
onMouseEnter={(e) => {
if (isActive) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (isActive) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
height: BAR_H, padding: '0 10px',
background: isActive ? 'var(--accent)' : 'var(--bg-input)',
color: isActive ? 'var(--bg-panel)' : 'var(--text-primary)',
border: '1px solid var(--border)',
borderLeft: isFirst ? '1px solid var(--border)' : 'none',
borderTopLeftRadius: isFirst ? 999 : 0,
borderBottomLeftRadius: isFirst ? 999 : 0,
borderTopRightRadius: isLast ? 999 : 0,
borderBottomRightRadius: isLast ? 999 : 0,
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10, fontWeight: isActive ? 600 : 500,
cursor: 'pointer', flexShrink: 0,
transition: 'background 0.15s, color 0.15s',
}}
>
<Icon name={v.icon} size={13} />
<span>{v.label}</span>
</button>
)
})}
</div>
<BarButton icon="videocam" onClick={() => openKameraPanel()}
title="Kamera-Einstellungen (Position, Target, Linse, Presets)" />
<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]
*/}
{(() => {
const SegBtn = ({ icon, onClick, title, disabled, active, isFirst, isLast }) => (
<button onClick={onClick} disabled={disabled} title={title}
style={{
height: BAR_H, width: 30,
background: active ? 'var(--accent)' : 'var(--bg-input)',
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,
}}>
<Icon name={icon} size={13}
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />
</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 = 80 // Breite der linken Stat-Chips (1:N / %)
const statChipStyle = (accentTint) => ({
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
height: BAR_H, width: STAT_W,
background: accentTint ? 'var(--accent-dim)' : 'var(--bg-input)',
color: accentTint ? 'var(--accent-light)' : 'var(--text-muted)',
border: '1px solid var(--border)',
borderRadius: 999,
fontFamily: 'DM Mono, monospace', fontSize: 11,
fontWeight: accentTint ? 600 : 500,
flexShrink: 0,
})
return (
<div style={{
display: 'grid', gridTemplateColumns: 'auto auto', gap: '4px 6px',
alignItems: 'center', flexShrink: 0,
}}>
{/* Reihe 1, Spalte 1: Aktueller Live-Massstab */}
<div style={statChipStyle(false)}
title={isPerspective ? 'Perspektive — kein Massstab' : 'Aktueller Live-Massstab'}>
{isPerspective ? '—' : fmtScale(scaleVal)}
</div>
{/* Reihe 1, Spalte 2: Gesetzter Massstab Dropdown */}
{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: 158,
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
icon="straighten"
value={dropdownValue}
onChange={(v) => applyDropdown(v)}
disabled={isPerspective}
width={140}
valueAccent={appliedScale != null}
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 1: Zoom-Verhaeltnis zum gesetzten Massstab */}
<div style={statChipStyle(atScale)}
title={ratio != null
? `Aktueller Zoom = ${ratioText} des gesetzten Massstabs`
: (isPerspective ? 'Perspektive' : 'Kein Massstab gesetzt')}>
{ratioText}
</div>
{/* Reihe 2, Spalte 2: Buttons-Pill */}
<div style={{
display: 'inline-flex',
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>
)
})()}
{/* 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>
)
}