Text-Editor: Stile, Size-Dropdown, U+Align, bessere Icons, Fonts-Fallback

User-Wunsch: Text-Editor war unfertig — keine Fonts sichtbar, Bold liess
sich nicht entfernen, Size soll Dropdown mit Eigene, Text-Stile noetig,
Unterstrichen + Links/Mitte/Rechts fehlten, schoenere Icons.

Backend (text_create.py):
- DEFAULTS erweitert um underline + align (left/center/right)
- _normalize() validiert Settings (align nur left/center/right)
- Text-Style-Preset-System analog mass_style:
  - list_styles / save_style / delete_style / apply_style
  - get_active_style_id / set_active_style_id
  - doc.Strings["dossier_text_styles"] (JSON list mit id/name + settings)
  - doc.Strings["dossier_text_style_active"]
- _apply_align(te, "left"|"center"|"right") setzt TextHorizontalAlignment
- apply_settings_to_selection + create_text rufen _apply_align mit auf
- read_selection_settings liest auch align
- available_fonts mit Fallback-Liste (Helvetica, Arial, Times, etc.) wenn
  Rhino.DocObjects.Font.AvailableFontFaceNames leer ist
- underline: in Settings + Styles persistiert, NOCH NICHT visuell
  appliziert (braucht TextEntity-RichText-API)

Backend (oberleiste.py):
- Neue Handler APPLY_TEXT_STYLE / SAVE_TEXT_STYLE / DELETE_TEXT_STYLE
- State liefert textStyles + textStyleActiveId
- textFonts jetzt bei jedem _send_state mitgeschickt (vorher one-shot mit
  _fonts_sent flag — verlor sich nach Panel-Re-Mount und User sah keine
  Fonts mehr)

Frontend (OberleisteApp):
- Text-Block komplett neu gelayoutet (3 Spalten Grid):
  Reihe 1: [Style ▼] [Font ▼] [Size ▼]
  Reihe 2: [B|I|U] [L|C|R] [+]
- Style-Dropdown mit Optionen "+ Speichern…" und "🗑 Aktiven loeschen"
- Size-Dropdown mit Preset-Werten (0.05/0.10/.../1.00 m) + "Eigene…"
  → toggle zu Custom-Number-Input bei "Eigene"-Auswahl
- B/I/U mit Material-Icons format_bold/italic/underlined statt B/I-Text
- L/C/R Alignment-Buttons mit format_align_left/center/right
- ToggleBtn-Helper-Komponente fuer alle 6 Toggles
- "+" Insert-Button bleibt klein (Icon size 14)
- Accent-Border auf allen Pills wenn Text selektiert (visuelles Feedback
  "Aenderungen wirken auf Selektion")
- Bold/Italic/Underline lassen sich jetzt sauber togglen (waren als
  proper Booleans serialisiert — vorher Bug evtl. durch fehlende Font-
  Liste maskiert)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 00:44:17 +02:00
parent 6fce00343c
commit 38041ab6a0
4 changed files with 398 additions and 148 deletions
+202 -119
View File
@@ -12,6 +12,7 @@ import {
openDossierSettings, openKameraPanel,
setMasseActive, openMasseSettings,
openAbout, createText, setTextSettings,
applyTextStyle, saveTextStyle, deleteTextStyle,
} from './lib/rhinoBridge'
const PRESETS = [
@@ -271,9 +272,12 @@ export default function OberleisteApp() {
overridesActivePreset: null, overridesPresets: [],
layerCombinations: [], layerCombinationActive: null,
massePresets: [], masseActiveId: null,
textSettings: { font: 'Helvetica', size: 0.20, bold: false, italic: false },
textSettings: { font: 'Helvetica', size: 0.20, bold: false, italic: false,
underline: false, align: 'left' },
textSelectionSettings: null,
textFonts: [],
textStyles: [],
textStyleActiveId: null,
northAngle: 0,
lastSetView: null,
})
@@ -282,6 +286,7 @@ export default function OberleisteApp() {
const [draft, setDraft] = useState('')
const [customMode, setCustomMode] = useState(false) // Dropdown -> Custom-Input switch
const customInputRef = useRef(null)
const [textSizeCustom, setTextSizeCustom] = useState(false)
useEffect(() => {
onMessage('STATE', (s) => {
@@ -781,151 +786,229 @@ export default function OberleisteApp() {
<div style={sep} />
{/* ====== TEXT-Block (Vectorworks-Stil) ======
Reihe 1: Font-Dropdown | Size + "m"
Reihe 2: B/I/+ kompakte Segmented-Pill
Reihe 1: Style ▼ | Font ▼ | Size
Reihe 2: [B][I][U] | [L][C][R] | [+]
Wenn TextEntity selektiert: Werte spiegeln Selektion, Aenderungen
applizieren AUF die Selektion + speichern als Default.
gehen direkt rauf und werden zusaetzlich als Default gespeichert.
*/}
{(() => {
const sel = state.textSelectionSettings // null wenn nichts selektiert
const sel = state.textSelectionSettings
const ts = sel || state.textSettings || {}
const fonts = state.textFonts || []
const styles = state.textStyles || []
const activeStyleId = state.textStyleActiveId
const updateTs = (patch) => setTextSettings({ ...ts, ...patch })
const FONT_W = 130
const SIZE_W = 60
const SEG_W = FONT_W + 6 + SIZE_W // B/I/+ Pill spannt ueber beide Spalten in Row 2
const STYLE_W = 110
const FONT_W = 130
const SIZE_W = 80
const ACTIVE_BORDER = sel ? 'var(--accent)' : 'var(--border)'
const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00]
// Toggle-Button-Helper fuer B/I/U/Align
const ToggleBtn = ({ active, onClick, title, children, borderLeft, lastInRow }) => (
<button onClick={onClick} title={title}
onMouseEnter={(e) => {
if (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={{
flex: 1, height: '100%',
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: borderLeft ? '1px solid var(--border)' : 'none',
cursor: 'pointer',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s, color 0.15s',
}}>
{children}
</button>
)
return (
<div style={{
display: 'grid', gridTemplateColumns: `${FONT_W}px ${SIZE_W}px`,
display: 'grid',
gridTemplateColumns: `${STYLE_W}px ${FONT_W}px ${SIZE_W}px`,
gap: '4px 6px', alignItems: 'center', flexShrink: 0,
}}>
{/* Reihe 1, Spalte 1: Font-Dropdown */}
{/* === Reihe 1 === */}
{/* Style-Dropdown */}
<BarCombo
value={activeStyleId || ''}
onChange={(v) => {
if (v === '__save__') {
const n = (window.prompt('Name für neuen Text-Stil:', 'Stil') || '').trim()
if (n) saveTextStyle(n)
return
}
if (v === '__delete__') {
if (activeStyleId &&
window.confirm(`Stil "${styles.find(s => s.id === activeStyleId)?.name}" löschen?`))
deleteTextStyle(activeStyleId)
return
}
if (v) applyTextStyle(v)
}}
width={STYLE_W}
title="Text-Stil — gespeicherte Settings-Presets"
>
<option value=""> Stil </option>
{styles.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
<option disabled></option>
<option value="__save__">+ Speichern</option>
{activeStyleId && <option value="__delete__">🗑 Aktiven löschen</option>}
</BarCombo>
{/* Font-Dropdown */}
<BarCombo
icon="text_fields"
value={ts.font || ''}
onChange={(v) => updateTs({ font: v })}
width={FONT_W}
title={sel ? 'Schriftart (auf Selektion appliziert)' : 'Schriftart'}
>
{fonts.length === 0 && <option value=""></option>}
{fonts.length === 0 && <option value=""> Fonts laden </option>}
{fonts.map(f => <option key={f} value={f}>{f}</option>)}
</BarCombo>
{/* Reihe 1, Spalte 2: Size */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
height: BAR_H + 2, padding: '0 8px', boxSizing: 'border-box',
background: 'var(--bg-input)',
border: '1px solid ' + ACTIVE_BORDER,
borderRadius: 999,
flexShrink: 0, width: SIZE_W,
transition: 'border-color 0.15s',
}}>
<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',
{/* Size-Dropdown mit "Eigene" / Custom-Input */}
{textSizeCustom ? (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
height: BAR_H + 2, padding: '0 8px', boxSizing: 'border-box',
background: 'var(--bg-input)',
border: '1px solid ' + ACTIVE_BORDER,
borderRadius: 999,
flexShrink: 0, width: SIZE_W,
}}>
<input
type="number" step="0.01" min="0.01" autoFocus
value={ts.size != null ? ts.size : 0.2}
onChange={(e) => {
const v = parseFloat(e.target.value)
if (!isNaN(v) && v > 0) updateTs({ size: v })
}}
onBlur={() => setTextSizeCustom(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Escape') {
e.target.blur()
}
}}
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',
}}
/>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>m</span>
</div>
) : (
<BarCombo
value={String(ts.size != null ? ts.size : 0.2)}
onChange={(v) => {
if (v === '__custom__') { setTextSizeCustom(true); return }
const n = parseFloat(v)
if (!isNaN(n) && n > 0) updateTs({ size: n })
}}
width={SIZE_W}
title={sel ? 'Texthoehe (auf Selektion appliziert)' : 'Texthoehe (m)'}
/>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>m</span>
</div>
{/* Reihe 2: B / I / + in einem Segmented-Pill */}
>
{SIZE_PRESETS.map(s => (
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
))}
{ts.size != null && !SIZE_PRESETS.some(s => Math.abs(s - ts.size) < 0.001) && (
<option value={String(ts.size)}>{ts.size.toFixed(2)} m</option>
)}
<option disabled></option>
<option value="__custom__">Eigene</option>
</BarCombo>
)}
{/* === Reihe 2 === */}
{/* B/I/U Segmented */}
<div style={{
gridColumn: '1 / span 2',
display: 'inline-flex', justifySelf: 'start',
border: '1px solid ' + ACTIVE_BORDER,
borderRadius: 999, overflow: 'hidden', flexShrink: 0,
width: SEG_W, height: BAR_H + 2, boxSizing: 'border-box',
display: 'inline-flex',
border: '1px solid ' + ACTIVE_BORDER, borderRadius: 999,
overflow: 'hidden', flexShrink: 0, width: STYLE_W,
height: BAR_H + 2, boxSizing: 'border-box',
transition: 'border-color 0.15s',
}}>
{/* B */}
<button
<ToggleBtn active={!!ts.bold}
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: '100%',
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,
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0,
transition: 'background 0.15s, color 0.15s',
}}
title={(sel ? 'Fett auf Selektion' : 'Fett') + ' (Default)'}
>B</button>
{/* I */}
<button
title={(sel ? 'Fett auf Selektion' : 'Fett') + ' (Default)'}>
<Icon name="format_bold" size={13} />
</ToggleBtn>
<ToggleBtn active={!!ts.italic} borderLeft
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: '100%',
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,
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0,
transition: 'background 0.15s, color 0.15s',
}}
title={(sel ? 'Kursiv auf Selektion' : 'Kursiv') + ' (Default)'}
>I</button>
{/* + Neuen Text einfuegen */}
<button
onClick={() => createText()}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
flex: 1, height: '100%',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: 'none', borderLeft: '1px solid var(--border)',
cursor: 'pointer',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s, color 0.15s',
}}
title="Neuen Text einfuegen — Position picken, Text eingeben"
>
<Icon name="add" size={13} />
</button>
title={(sel ? 'Kursiv auf Selektion' : 'Kursiv') + ' (Default)'}>
<Icon name="format_italic" size={13} />
</ToggleBtn>
<ToggleBtn active={!!ts.underline} borderLeft
onClick={() => updateTs({ underline: !ts.underline })}
title="Unterstrichen (Settings — Rendering kommt mit RichText-Support)">
<Icon name="format_underlined" size={13} />
</ToggleBtn>
</div>
{/* L/C/R Align Segmented */}
<div style={{
display: 'inline-flex',
border: '1px solid ' + ACTIVE_BORDER, borderRadius: 999,
overflow: 'hidden', flexShrink: 0, width: FONT_W,
height: BAR_H + 2, boxSizing: 'border-box',
transition: 'border-color 0.15s',
}}>
<ToggleBtn active={(ts.align || 'left') === 'left'}
onClick={() => updateTs({ align: 'left' })}
title={(sel ? 'Linksbuendig auf Selektion' : 'Linksbuendig')}>
<Icon name="format_align_left" size={13} />
</ToggleBtn>
<ToggleBtn active={ts.align === 'center'} borderLeft
onClick={() => updateTs({ align: 'center' })}
title={(sel ? 'Zentriert auf Selektion' : 'Zentriert')}>
<Icon name="format_align_center" size={13} />
</ToggleBtn>
<ToggleBtn active={ts.align === 'right'} borderLeft
onClick={() => updateTs({ align: 'right' })}
title={(sel ? 'Rechtsbuendig auf Selektion' : 'Rechtsbuendig')}>
<Icon name="format_align_right" size={13} />
</ToggleBtn>
</div>
{/* + Neuen Text einfuegen */}
<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: SIZE_W, height: BAR_H + 2, boxSizing: 'border-box',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, padding: 0, flexShrink: 0,
transition: 'background 0.15s, color 0.15s, border-color 0.15s',
}}
title="Neuen Text einfuegen — Position picken, Text eingeben"
>
<Icon name="add" size={14} />
</button>
</div>
)
})()}
+3
View File
@@ -173,6 +173,9 @@ export function openKameraPanel() { send('OPEN_KAMERA_PANEL', {}) }
export function openAbout() { send('OPEN_ABOUT', {}) }
export function createText() { send('CREATE_TEXT', {}) }
export function setTextSettings(settings) { send('SET_TEXT_SETTINGS', { settings }) }
export function applyTextStyle(id) { send('APPLY_TEXT_STYLE', { id }) }
export function saveTextStyle(name) { send('SAVE_TEXT_STYLE', { name }) }
export function deleteTextStyle(id) { send('DELETE_TEXT_STYLE', { id }) }
// --- Masse (in Oberleiste + Satellite-Fenster MasseSettings) ---
// Topbar: aktives Mass setzen + Settings-Fenster oeffnen