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, 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()
else if (cs?.fontFamily && cs.fontFamily !== ctx.font && cs.fontFamily) {
// nur uebernehmen wenn explizit anders
}
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
// 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 baseCtx = { font: null, color: null, bold: false, italic: false,
underline: false, sup: false, sub: false }
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
}
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 [maskMargin, setMaskMargin] = useState(0)
const [symbolsOpen, setSymbolsOpen] = useState(false)
const [styles, setStyles] = useState([])
const editorRef = useRef(null)
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)
// Editor-Default-Font setzen
setTimeout(() => editorRef.current?.focus(), 100)
})
notifyReady()
}, [])
const exec = (cmd, value) => {
try {
document.execCommand(cmd, false, value)
editorRef.current?.focus()
} catch (e) { console.error('execCommand', cmd, e) }
}
const insertText = (s) => {
try {
document.execCommand('insertText', false, s)
editorRef.current?.focus()
} catch (e) { console.error('insertText', e) }
}
const toggleBold = () => { setBold(b => !b); exec('bold') }
const toggleItalic = () => { setItalic(b => !b); exec('italic') }
const toggleUnderline = () => { setUnderline(b => !b); 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 + (wenn Auswahl im Editor) via
// execCommand auf die Selektion applizieren.
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)
// Auf Selektion applizieren (oder zumindest fuer kommendes Tippen)
try {
const sel = window.getSelection()
const hasSel = sel && sel.rangeCount > 0 && sel.toString().length > 0
document.execCommand('styleWithCSS', false, true)
if (style.font) document.execCommand('fontName', false, style.font)
// Bold/Italic/Underline togglen, falls Selektion da ist oder nicht
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 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, color,
frame, horizontalToView, rotation, maskMargin,
},
})
}
const onCancel = () => send('CANCEL', {})
return (
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
{/* Toolbar Row 2: B I U + Align + Sup/Sub */}
doAlign('left')} title="Links">
doAlign('center')} title="Mittig">
doAlign('right')} title="Rechts">
x²
x₂
{/* Toolbar Row 3: Frame / Rotation / Mask / Horizontal-to-view / Symbole */}
(
))} />
Mask
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
setSymbolsOpen(o => !o)}
active={symbolsOpen}
title="Sonderzeichen einfügen">
Symbole
setSymbolsOpen(false)}
onPick={(s) => insertText(s)} />
{/* WYSIWYG Editor */}
{/* Bottom Buttons */}
)
}