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 ( ) } function Dropdown({ value, onChange, options, width, title }) { return (
) } // 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. */}
doAlign('left'))} title="Links"> doAlign('center'))} title="Mittig"> doAlign('right'))} title="Rechts">
setVAlign('top'))} title="Oben"> setVAlign('middle'))} title="Mittig"> setVAlign('bottom'))} title="Unten">
x₂
{ if (v) transformCase(v) }} width={130} title="Selektion in Gross-/Kleinschrift wandeln" options={[ , , , , , ]} />
{/* Toolbar Row 3: Frame / Mask (Type/Color/Margin) / Rotation / Camera / Symbole */}
( ))} /> Keine Maske, , , ]} /> {maskType === 'solid' && ( )}
Rand 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', }} /> m
Rot 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', }} /> °
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
setSymbolsOpen(o => !o)} active={symbolsOpen} title="Sonderzeichen einfügen"> Symbole setSymbolsOpen(false)} onPick={(s) => insertText(s)} />
{/* WYSIWYG Editor — kein state-getriebenes fontFamily/fontSize hier (sonst ueberschreiben die Container-Styles die per-Span-Styles). white-space pre-wrap preserves Newlines + Spaces. */}
{/* Bottom Buttons */}
Abbrechen
) }