)
}
// 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 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 mit
// inline-style + Tags (b/i/u/sup/sub). Newlines werden zu .
function runsToHtml(runs) {
if (!Array.isArray(runs)) return ''
const escape = (s) => s.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(' ')
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 += ''; closes = '' + closes }
if (r.italic) { opens += ''; closes = '' + closes }
if (r.underline) { opens += ''; closes = '' + closes }
if (r.sup) { opens += ''; closes = '' + closes }
else if (r.sub) { opens += ''; closes = '' + closes }
const inner = opens + escape(seg) + closes
if (styles.length) out.push(`${inner}`)
else out.push(inner)
}
}
return out.join('')
}
function SymbolPopover({ open, onClose, onPick }) {
if (!open) return null
return (
<>
{SYMBOL_GROUPS.map((grp, gi) => (
{grp.title}
{grp.symbols.map(s => (
))}
))}
>
)
}
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
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 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 (
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
{
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={[
,
...styles.map(s => (
)),
]} />
{
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
?
: fonts.map(f => )
} />
{
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 => (
))}
/>
Layer
{/* 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. */}
setHorizontalToView(b => !b)}
title="Text steht immer zur Kamera (DrawForward)">
Zur Kamera setScaleWithModel(b => !b)}
title="Text skaliert sich mit dem Massstab (Annotation Scaling). Aus = absolute Modellhöhe.">
Masstäblich