Files
DOSSIER/src/TextEditorApp.jsx
T
karim ae80185064 Text-Editor: Toggle "Masstäblich" (AnnotationScaling)
Neuer Pill-Button in Row 3 neben "Zur Kamera": setzt
TextEntity.AnnotationScalingEnabled. Property-Name wird in mehreren
Varianten probiert (Rhino 8 API hat das je nach Build leicht anders
benannt). Zustand wird zusaetzlich als UserString
"dossier_text_scaled" persistiert, sodass open_for_edit den Toggle
auch dann korrekt restored wenn die API-Property nicht gelesen
werden kann.

Default = an (entspricht aktuellem Verhalten: Text skaliert mit der
Annotation-Scale des Layouts).

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

727 lines
30 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, send } from './lib/rhinoBridge'
const SYMBOL_GROUPS = [
{ title: 'Mathematik / Architektur',
symbols: ['∅', 'Ø', '⌀', '°', '±', '×', '÷', '²', '³', '½', '¼', '¾', '⅓', '⅔',
'≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ'] },
{ title: 'Pfeile',
symbols: ['←', '→', '↑', '↓', '↔', '↕', '⇐', '⇒', '⇑', '⇓', '⇔'] },
{ title: 'Auszeichnung',
symbols: ['•', '·', '▪', '◆', '◇', '★', '☆', '✓', '✗', '§', '¶', '©', '®', '™'] },
]
const FRAME_OPTIONS = [
{ value: 'none', label: 'Kein Rahmen' },
{ value: 'rect', label: 'Rechteck' },
{ value: 'capsule', label: 'Kapsel' },
]
const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00]
const BAR_H = 22
function Pill({ children, onClick, onMouseDown, active, disabled, title, style }) {
return (
<button
onClick={onClick}
onMouseDown={onMouseDown}
disabled={disabled}
title={title}
onMouseEnter={(e) => {
if (disabled || active) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
if (active) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border)'
}}
style={{
height: BAR_H, minWidth: BAR_H,
padding: '0 10px',
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
gap: 4, fontSize: 11,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, boxSizing: 'border-box',
transition: 'background 0.15s, border-color 0.15s',
...style,
}}
>{children}</button>
)
}
function Dropdown({ value, onChange, options, width, title }) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center',
height: BAR_H + 2, width, boxSizing: 'border-box',
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}} title={title}>
<select value={value || ''}
onChange={(e) => onChange(e.target.value)}
style={{
width: '100%', height: '100%',
background: 'transparent', color: 'var(--text-primary)',
border: 'none', outline: 'none',
padding: '0 18px 0 10px', fontSize: 11,
appearance: 'none', WebkitAppearance: 'none',
backgroundImage: 'var(--select-arrow)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 6px center',
cursor: 'pointer',
}}>
{options}
</select>
</div>
)
}
// HTML im contentEditable → Format-Runs fuer Rhino RTF (Phase 2)
function htmlToRuns(rootEl) {
const runs = []
function flush(text, ctx) {
if (text === '') return
runs.push({ text, ...ctx })
}
function walk(node, ctx) {
if (node.nodeType === Node.TEXT_NODE) {
flush(node.textContent, ctx); return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const tag = node.tagName.toLowerCase()
if (tag === 'br') { flush('\n', ctx); return }
const nc = { ...ctx }
if (tag === 'b' || tag === 'strong') nc.bold = true
if (tag === 'i' || tag === 'em') nc.italic = true
if (tag === 'u') nc.underline = true
if (tag === 'sup') nc.sup = true
if (tag === 'sub') nc.sub = true
// Computed style oder inline style.
const cs = window.getComputedStyle ? window.getComputedStyle(node) : null
if (node.style.fontFamily) nc.font = node.style.fontFamily.replace(/['"]/g, '').split(',')[0].trim()
if (node.style.color) nc.color = node.style.color
if (node.style.fontWeight) {
const fw = node.style.fontWeight
if (fw === 'bold' || parseInt(fw) >= 600) nc.bold = true
}
if (node.style.fontStyle === 'italic') nc.italic = true
if (node.style.textDecoration?.includes('underline')) nc.underline = true
// Font-Size: aus inline-style oder computed style (Pixel)
if (node.style.fontSize) {
const m = node.style.fontSize.match(/(\d+\.?\d*)px/)
if (m) nc.fontSizePx = parseFloat(m[1])
} else if (cs?.fontSize) {
const px = parseFloat(cs.fontSize)
if (px && px !== ctx._basePx) nc.fontSizePx = px
}
// Legacy <font> Element von execCommand
if (tag === 'font') {
const c = node.getAttribute('color'); if (c) nc.color = c
const f = node.getAttribute('face'); if (f) nc.font = f.split(',')[0].trim()
}
for (const child of node.childNodes) walk(child, nc)
// Block-Element bekommt Newline am Ende
if (tag === 'p' || tag === 'div') {
if (!runs.length || !runs[runs.length-1].text.endsWith('\n'))
flush('\n', ctx)
}
}
const basePx = parseFloat(window.getComputedStyle(rootEl).fontSize) || 14
const baseCtx = { font: null, color: null, bold: false, italic: false,
underline: false, sup: false, sub: false,
fontSizePx: null, _basePx: basePx }
for (const child of rootEl.childNodes) walk(child, baseCtx)
// Trailing-Newline weg
if (runs.length && runs[runs.length-1].text === '\n') runs.pop()
return runs
}
// Runs zurueck in HTML konvertieren — fuer das Re-Open eines bestehenden
// DOSSIER-Texts. Spiegelt die persistierten Format-Runs als <span> mit
// inline-style + Tags (b/i/u/sup/sub). Newlines werden zu <br>.
function runsToHtml(runs) {
if (!Array.isArray(runs)) return ''
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const out = []
for (const r of runs) {
const raw = r.text || ''
if (!raw) continue
// Per Newline splitten — jede non-leere Section bekommt eigene span
const segs = raw.split('\n')
for (let i = 0; i < segs.length; i++) {
if (i > 0) out.push('<br>')
const seg = segs[i]
if (!seg) continue
const styles = []
if (r.font) styles.push(`font-family: ${r.font}`)
if (r.fontSizePx) styles.push(`font-size: ${r.fontSizePx}px`)
if (r.color) styles.push(`color: ${r.color}`)
let opens = '', closes = ''
if (r.bold) { opens += '<b>'; closes = '</b>' + closes }
if (r.italic) { opens += '<i>'; closes = '</i>' + closes }
if (r.underline) { opens += '<u>'; closes = '</u>' + closes }
if (r.sup) { opens += '<sup>'; closes = '</sup>' + closes }
else if (r.sub) { opens += '<sub>'; closes = '</sub>' + closes }
const inner = opens + escape(seg) + closes
if (styles.length) out.push(`<span style="${styles.join('; ')}">${inner}</span>`)
else out.push(inner)
}
}
return out.join('')
}
function SymbolPopover({ open, onClose, onPick }) {
if (!open) return null
return (
<>
<div onClick={onClose}
style={{ position: 'fixed', inset: 0, zIndex: 100,
background: 'transparent' }} />
<div style={{
position: 'absolute', zIndex: 101,
marginTop: 4,
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
display: 'flex', flexDirection: 'column', gap: 8,
maxWidth: 360,
}}>
{SYMBOL_GROUPS.map((grp, gi) => (
<div key={gi} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)',
textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{grp.title}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{grp.symbols.map(s => (
<button key={s} onClick={() => { onPick(s); onClose() }}
title={`Einfügen: ${s}`}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border)'
}}
style={{
width: 30, height: 26,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 4,
fontSize: 14, lineHeight: 1, cursor: 'pointer', padding: 0,
transition: 'background 0.12s, border-color 0.12s',
}}>{s}</button>
))}
</div>
</div>
))}
</div>
</>
)
}
export default function TextEditorApp() {
const [fonts, setFonts] = useState([])
const [font, setFont] = useState('Helvetica')
const [size, setSize] = useState(0.20)
const [bold, setBold] = useState(false)
const [italic, setItalic] = useState(false)
const [underline, setUnderline] = useState(false)
const [align, setAlign] = useState('left')
const [color, setColor] = useState(null) // [r,g,b] oder null
// Neue Options
const [frame, setFrame] = useState('none') // none | rect | capsule
const [horizontalToView, setHorizontalToView] = useState(false)
const [rotation, setRotation] = useState(0)
const [valign, setVAlign] = useState('top') // top | middle | bottom
const [scaleWithModel, setScaleWithModel] = useState(true)
const [maskType, setMaskType] = useState('none') // none | viewport | solid
const [maskColor, setMaskColor] = useState([255, 255, 255])
const [maskMargin, setMaskMargin] = useState(0)
const [symbolsOpen, setSymbolsOpen] = useState(false)
const [styles, setStyles] = useState([])
const editorRef = useRef(null)
const savedRangeRef = useRef(null)
// 1m model = 100px im Editor (= "richtungsweisende" Optik). Pro-Run
// Pixel werden ans Backend als fontSizePx geschickt; das mappt sie
// zurueck zu \fs in der RTF mit base = settings.size * 100.
const sizeToPx = (sizeM) => Math.max(8, Math.round(sizeM * 100))
// Aktuelle Selection im Editor merken (selectionchange Event). Wenn
// User dann ausserhalb des Editors klickt (z.B. Toolbar-Dropdown),
// koennen wir die Selection wieder herstellen bevor wir Aenderungen
// applizieren.
useEffect(() => {
const onSelChange = () => {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
const ed = editorRef.current
if (!ed) return
if (ed.contains(sel.anchorNode)) {
try { savedRangeRef.current = sel.getRangeAt(0).cloneRange() }
catch (e) {}
// B/I/U Toolbar-State an die echte Cursor-Position syncen
try { setBold(document.queryCommandState('bold')) } catch (e) {}
try { setItalic(document.queryCommandState('italic')) } catch (e) {}
try { setUnderline(document.queryCommandState('underline')) } catch (e) {}
}
}
document.addEventListener('selectionchange', onSelChange)
// styleWithCSS aktivieren — execCommand inline statt deprecated <font>
try { document.execCommand('styleWithCSS', false, true) } catch (e) {}
return () => document.removeEventListener('selectionchange', onSelChange)
}, [])
const restoreSelection = () => {
if (!savedRangeRef.current) return
const sel = window.getSelection()
sel.removeAllRanges()
try { sel.addRange(savedRangeRef.current) } catch (e) {}
}
// Wrap aktuelle Selektion in ein <span> mit gegebenem CSS-Property
const applyInlineStyleToSelection = (styleProp, value) => {
restoreSelection()
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
const range = sel.getRangeAt(0)
if (range.collapsed) return
const span = document.createElement('span')
span.style.setProperty(styleProp, value)
try {
const contents = range.extractContents()
span.appendChild(contents)
range.insertNode(span)
// Neue Selektion auf das eingefuegte Span legen
const newRange = document.createRange()
newRange.selectNodeContents(span)
sel.removeAllRanges()
sel.addRange(newRange)
// Saved-Range updaten damit Folge-Operationen wirken
savedRangeRef.current = newRange.cloneRange()
} catch (e) { console.error('applyInlineStyle', e) }
}
useEffect(() => {
onMessage('INIT', (data) => {
setFonts(data.fonts || [])
setStyles(data.styles || [])
const s = data.settings || {}
if (s.font) setFont(s.font)
if (s.size != null) setSize(s.size)
if (s.bold != null) setBold(!!s.bold)
if (s.italic != null) setItalic(!!s.italic)
if (s.underline != null) setUnderline(!!s.underline)
if (s.align) setAlign(s.align)
if (s.valign) setVAlign(s.valign)
if (s.scaleWithModel != null) setScaleWithModel(!!s.scaleWithModel)
if (s.maskType) setMaskType(s.maskType)
if (Array.isArray(s.maskColor)) setMaskColor(s.maskColor)
// Bei Edit-Mode: bestehenden Text in den Editor laden. Wenn Runs
// persistiert sind (= reicher Format-Stand vom letzten Save),
// diese als HTML laden — sonst PlainText fallback.
const initialText = data.initialText || ''
const initialRuns = data.initialRuns
setTimeout(() => {
if (editorRef.current) {
if (initialRuns && initialRuns.length > 0) {
editorRef.current.innerHTML = runsToHtml(initialRuns)
} else if (initialText) {
editorRef.current.innerText = initialText
}
editorRef.current.focus()
}
}, 100)
})
notifyReady()
}, [])
const exec = (cmd, value) => {
try {
restoreSelection()
document.execCommand(cmd, false, value)
editorRef.current?.focus()
} catch (e) { console.error('execCommand', cmd, e) }
}
const insertText = (s) => {
try {
restoreSelection()
document.execCommand('insertText', false, s)
editorRef.current?.focus()
} catch (e) { console.error('insertText', e) }
}
// Mouse-Down-Guard fuer Toolbar-Buttons: preventDefault verhindert
// dass der Button-Klick den Focus aus dem Editor stiehlt (sonst
// verschwindet die Selektion bevor execCommand laufen kann).
const onBtnMouseDown = (fn) => (e) => {
e.preventDefault()
fn()
}
// setState NICHT manuell — der selectionchange-Listener syncen das
// an die echte queryCommandState-Antwort, sonst hinkt's hinterher.
const toggleBold = () => exec('bold')
const toggleItalic = () => exec('italic')
const toggleUnderline = () => exec('underline')
const doAlign = (a) => { setAlign(a)
exec(a === 'center' ? 'justifyCenter' : a === 'right' ? 'justifyRight' : 'justifyLeft') }
const doSup = () => exec('superscript')
const doSub = () => exec('subscript')
// Stil anwenden: Toolbar-State setzen + Selection restoren + via
// execCommand applizieren (auf Selektion oder als Default fuer's
// kommende Tippen).
const applyStyle = (style) => {
if (!style) return
// Toolbar-State synchronisieren
if (style.font) setFont(style.font)
if (style.size != null) setSize(style.size)
setBold(!!style.bold)
setItalic(!!style.italic)
setUnderline(!!style.underline)
if (style.align) setAlign(style.align)
// Selection wiederherstellen + Editor-Focus damit execCommand greift.
editorRef.current?.focus()
restoreSelection()
try {
document.execCommand('styleWithCSS', false, true)
if (style.font) document.execCommand('fontName', false, style.font)
// Size auf Selektion via Inline-Span (execCommand fontSize macht
// nur 1-7 Scale, kein px)
const sel = window.getSelection()
const hasSel = sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed
if (style.size != null && hasSel) {
applyInlineStyleToSelection('font-size', sizeToPx(style.size) + 'px')
}
// Bold/Italic/Underline togglen wenn nicht schon im Wunsch-State
const wantBold = !!style.bold
const wantItal = !!style.italic
const wantUnd = !!style.underline
if (document.queryCommandState('bold') !== wantBold)
document.execCommand('bold')
if (document.queryCommandState('italic') !== wantItal)
document.execCommand('italic')
if (document.queryCommandState('underline') !== wantUnd)
document.execCommand('underline')
} catch (e) { console.error('applyStyle', e) }
editorRef.current?.focus()
}
const onColorPick = (hex) => {
const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
if (m) setColor([parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)])
exec('foreColor', hex)
}
const clearColor = () => { setColor(null); exec('foreColor', '#000000') }
const onMaskColorPick = (hex) => {
const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
if (m) setMaskColor([parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)])
if (maskType === 'none') setMaskType('solid')
}
// Case-Transformationen auf die aktuelle Selektion. Wenn nichts
// markiert ist, no-op (kein All-Text-Modus, sonst zu invasiv).
const transformCase = (mode) => {
restoreSelection()
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
const range = sel.getRangeAt(0)
if (range.collapsed) return
const src = range.toString()
let out = src
if (mode === 'upper') out = src.toUpperCase()
else if (mode === 'lower') out = src.toLowerCase()
else if (mode === 'capitalize') out = src.replace(/\w\S*/g, w =>
w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
else if (mode === 'invert') out = src.split('').map(ch =>
ch === ch.toUpperCase() ? ch.toLowerCase() : ch.toUpperCase()).join('')
document.execCommand('insertText', false, out)
editorRef.current?.focus()
}
const rgbToHex = ([r, g, b]) => '#' +
[r, g, b].map(n => Math.max(0, Math.min(255, n|0)).toString(16).padStart(2, '0')).join('')
const onCommit = () => {
const el = editorRef.current
if (!el) return
const text = el.innerText || ''
if (!text.trim()) return
// Phase 2: Format-Runs aus HTML extrahieren fuer Rich-Text-Mapping
let runs = null
try { runs = htmlToRuns(el) } catch (e) { console.error(e) }
send('COMMIT', {
text,
runs,
settings: {
font, size, bold, italic, underline, align, valign, color,
frame, horizontalToView, rotation, scaleWithModel,
maskType, maskColor, maskMargin,
},
})
}
const onCancel = () => send('CANCEL', {})
return (
<div style={{
padding: 10, height: '100vh',
display: 'flex', flexDirection: 'column', gap: 8,
fontFamily: 'var(--font)', color: 'var(--text-primary)',
background: 'var(--bg-panel)',
boxSizing: 'border-box', overflow: 'hidden',
}}>
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Dropdown value=""
onChange={(v) => {
const st = styles.find(s => s.id === v)
if (st) applyStyle(st)
}}
width={150}
title="Text-Stil anwenden (auf Selektion oder als Default fuer kommendes Tippen)"
options={[
<option key="__none__" value=""> Stil wählen </option>,
...styles.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
)),
]} />
<Dropdown value={font}
onChange={(v) => {
setFont(v)
// Auf Selektion applizieren: span-Wrap mit font-family
applyInlineStyleToSelection('font-family', v)
editorRef.current?.focus()
}}
width={150} title="Schrift — wenn nichts ausgewählt: gilt für ganzen Text als Default"
options={
fonts.length === 0
? <option value=""> Fonts laden </option>
: fonts.map(f => <option key={f} value={f}>{f}</option>)
} />
<Dropdown value={String(size)}
onChange={(v) => {
const n = parseFloat(v)
setSize(n)
// Auf Selektion applizieren via span-Wrap
applyInlineStyleToSelection('font-size', sizeToPx(n) + 'px')
editorRef.current?.focus()
}}
width={90} title="Texthöhe (m) — wenn nichts ausgewählt: gilt für ganzen Text"
options={SIZE_PRESETS.map(s => (
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
))}
/>
<label style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: BAR_H + 2, padding: '0 8px',
background: 'var(--bg-input)', border: '1px solid var(--border)',
borderRadius: 999, cursor: 'pointer', flexShrink: 0,
boxSizing: 'border-box',
}} title="Textfarbe">
<input type="color"
style={{ width: 16, height: 16, border: 'none', padding: 0, background: 'transparent' }}
onChange={(e) => onColorPick(e.target.value)} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Farbe</span>
</label>
<Pill onClick={clearColor} title="Layer-Farbe verwenden">Layer</Pill>
</div>
{/* Toolbar Row 2: B I U + Align + Sup/Sub
onMouseDown preventDefault verhindert dass Buttons den Focus
aus dem Editor stehlen → Selection bleibt erhalten → execCommand
wirkt richtig auf den markierten Text. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Pill active={bold} onMouseDown={onBtnMouseDown(toggleBold)} title="Fett">
<Icon name="format_bold" size={13} />
</Pill>
<Pill active={italic} onMouseDown={onBtnMouseDown(toggleItalic)} title="Kursiv">
<Icon name="format_italic" size={13} />
</Pill>
<Pill active={underline} onMouseDown={onBtnMouseDown(toggleUnderline)} title="Unterstrichen">
<Icon name="format_underlined" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill active={align === 'left'} onMouseDown={onBtnMouseDown(() => doAlign('left'))} title="Links">
<Icon name="format_align_left" size={13} />
</Pill>
<Pill active={align === 'center'} onMouseDown={onBtnMouseDown(() => doAlign('center'))} title="Mittig">
<Icon name="format_align_center" size={13} />
</Pill>
<Pill active={align === 'right'} onMouseDown={onBtnMouseDown(() => doAlign('right'))} title="Rechts">
<Icon name="format_align_right" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill active={valign === 'top'} onMouseDown={onBtnMouseDown(() => setVAlign('top'))} title="Oben">
<Icon name="vertical_align_top" size={13} />
</Pill>
<Pill active={valign === 'middle'} onMouseDown={onBtnMouseDown(() => setVAlign('middle'))} title="Mittig">
<Icon name="vertical_align_center" size={13} />
</Pill>
<Pill active={valign === 'bottom'} onMouseDown={onBtnMouseDown(() => setVAlign('bottom'))} title="Unten">
<Icon name="vertical_align_bottom" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill onMouseDown={onBtnMouseDown(doSup)} title="Hochstellen (x²)" style={{ fontFamily: 'serif' }}></Pill>
<Pill onMouseDown={onBtnMouseDown(doSub)} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
<div style={{ width: 6 }} />
<Dropdown value=""
onChange={(v) => { if (v) transformCase(v) }}
width={130}
title="Selektion in Gross-/Kleinschrift wandeln"
options={[
<option key="" value=""> Aa </option>,
<option key="upper" value="upper">GROSSBUCHSTABEN</option>,
<option key="lower" value="lower">kleinbuchstaben</option>,
<option key="capitalize" value="capitalize">Erste Buchstaben</option>,
<option key="invert" value="invert">Umkehren</option>,
]} />
</div>
{/* Toolbar Row 3: Frame / Mask (Type/Color/Margin) / Rotation / Camera / Symbole */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Dropdown value={frame} onChange={setFrame} width={130}
title="Rahmen um den Text"
options={FRAME_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))} />
<Dropdown value={maskType} onChange={setMaskType} width={120}
title="Maske hinter dem Text (verdeckt Hintergrund)"
options={[
<option key="none" value="none">Keine Maske</option>,
<option key="viewport" value="viewport">Viewport-Farbe</option>,
<option key="solid" value="solid">Solide Farbe</option>,
]} />
{maskType === 'solid' && (
<label style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: BAR_H + 2, padding: '0 8px',
background: 'var(--bg-input)', border: '1px solid var(--border)',
borderRadius: 999, cursor: 'pointer', flexShrink: 0,
boxSizing: 'border-box',
}} title="Mask-Farbe">
<input type="color" value={rgbToHex(maskColor)}
style={{ width: 16, height: 16, border: 'none', padding: 0, background: 'transparent' }}
onChange={(e) => onMaskColorPick(e.target.value)} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Farbe</span>
</label>
)}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: BAR_H + 2, padding: '0 10px', boxSizing: 'border-box',
background: 'var(--bg-input)', border: '1px solid var(--border)',
borderRadius: 999, flexShrink: 0,
opacity: maskType === 'none' ? 0.5 : 1,
}} title="Rand um den Text-Inhalt der mit-maskiert wird (m)">
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Rand</span>
<input type="number" step="0.05" min="0"
value={maskMargin}
onChange={(e) => setMaskMargin(parseFloat(e.target.value) || 0)}
style={{
width: 50, 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>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: BAR_H + 2, padding: '0 10px', boxSizing: 'border-box',
background: 'var(--bg-input)', border: '1px solid var(--border)',
borderRadius: 999, flexShrink: 0,
}} title="Rotation in Grad">
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Rot</span>
<input type="number" step="1" value={rotation}
onChange={(e) => setRotation(parseFloat(e.target.value) || 0)}
style={{
width: 50, 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)' }}>°</span>
</div>
<Pill active={horizontalToView}
onClick={() => setHorizontalToView(b => !b)}
title="Text steht immer zur Kamera (DrawForward)">
<Icon name="screen_rotation" size={13} />
<span style={{ fontSize: 10 }}>Zur Kamera</span>
</Pill>
<Pill active={scaleWithModel}
onClick={() => setScaleWithModel(b => !b)}
title="Text skaliert sich mit dem Massstab (Annotation Scaling). Aus = absolute Modellhöhe.">
<Icon name="zoom_out_map" size={13} />
<span style={{ fontSize: 10 }}>Masstäblich</span>
</Pill>
<div style={{ position: 'relative' }}>
<Pill onClick={() => setSymbolsOpen(o => !o)}
active={symbolsOpen}
title="Sonderzeichen einfügen">
<Icon name="emoji_symbols" size={13} />
<span style={{ fontSize: 10 }}>Symbole</span>
</Pill>
<SymbolPopover
open={symbolsOpen}
onClose={() => setSymbolsOpen(false)}
onPick={(s) => insertText(s)} />
</div>
</div>
{/* WYSIWYG Editor — kein state-getriebenes fontFamily/fontSize hier
(sonst ueberschreiben die Container-Styles die per-Span-Styles).
white-space pre-wrap preserves Newlines + Spaces. */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
spellCheck={false}
style={{
flex: 1, minHeight: 100,
padding: 12,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'Helvetica, sans-serif',
fontSize: 20, lineHeight: 1.5,
outline: 'none',
overflowY: 'auto',
textAlign: align,
whiteSpace: 'pre-wrap',
}}
/>
{/* Bottom Buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
<Pill onClick={onCancel}>Abbrechen</Pill>
<button onClick={onCommit}
style={{
height: BAR_H + 2, padding: '0 14px',
background: 'var(--accent)',
color: 'var(--bg-panel)',
border: '1px solid var(--accent)',
borderRadius: 999,
fontSize: 11, fontWeight: 600,
cursor: 'pointer',
appearance: 'none', WebkitAppearance: 'none',
boxSizing: 'border-box',
}}>Einfügen</button>
</div>
</div>
)
}