ae80185064
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>
727 lines
30 KiB
React
727 lines
30 KiB
React
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
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' }}>x²</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>
|
||
)
|
||
}
|