Text-Editor: Symbol-Popover, Frame/Mask/Rot/Kamera, Phase 2 RTF-Mapping

User: Sonderzeichen in einen Button packen mit Popover-Box, neue
Optionen (Frame around text, Horizontal to view, Rotation, Mask
margins), und Phase 2 = mixed-fonts via Rich-Text.

Frontend (TextEditorApp.jsx):
- Sonderzeichen-Reihe weg, ersetzt durch [Symbole] Pill-Button mit
  Popover-Box. Symbols gruppiert (Mathematik, Pfeile, Auszeichnung)
- Toolbar Reihe 3 neu: [Rahmen ▼] [Mask ____ m] [Rot ____ °]
  [Zur Kamera Toggle] [Symbole Popover]
- htmlToRuns(rootEl): walks contentEditable DOM, extrahiert Format-
  Runs (text, font, color, bold, italic, underline, sup, sub) basierend
  auf Tag-Hierarchie (b/i/u/sup/sub/font/span style)
- onCommit sendet jetzt zusaetzlich runs[] + frame/horizontalToView/
  rotation/maskMargin in settings

Backend (text_editor.py):
- _commit: setzt Plane mit Rotation um Z (math.radians + Plane.Rotate)
- MaskFrame: NoFrame/RectFrame/CapsuleFrame ueber TextMaskFrame-Enum
- MaskEnabled+MaskOffset+MaskUsesViewportColor wenn maskMargin>0
- te.DrawForward = horizontalToView (Text steht zur Kamera)
- _runs_to_rtf(runs, default_font): Phase 2 — generiert Rhinos RTF aus
  den Runs. Triviale Runs (alle plain, ein Font) → None (PlainText
  fallback). Sonst: \rtf1 + fonttbl + colortbl + Format-Codes pro Run
  (\f \cf \b \i \ul \super \sub \nosupersub). _rtf_escape handelt \,
  {, }, \n und non-ASCII (\u-Notation). te.RichText = rtf, Fallback
  auf PlainText wenn das fehlschlaegt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 01:35:28 +02:00
parent ab0ecfbf14
commit 54aa1c9e84
2 changed files with 341 additions and 40 deletions
+196 -37
View File
@@ -2,13 +2,19 @@ import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import { onMessage, notifyReady, send } from './lib/rhinoBridge'
const SYMBOLS = [
'∅', 'Ø', '⌀', '°', '±', '×', ',
'²', '³', '½', '¼', '¾', '⅓', '⅔',
'≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ',
'←', '→', '↑', '↓', '↔', '↕',
'', '·', '', '', '', '', '', '',
'§', '¶', '©', '®', '™',
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]
@@ -79,6 +85,113 @@ function Dropdown({ value, onChange, options, width, title }) {
)
}
// 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 <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 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 (
<>
<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')
@@ -88,6 +201,12 @@ export default function TextEditorApp() {
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 editorRef = useRef(null)
useEffect(() => {
@@ -134,11 +253,20 @@ export default function TextEditorApp() {
const clearColor = () => { setColor(null); exec('foreColor', '#000000') }
const onCommit = () => {
const text = editorRef.current?.innerText || ''
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,
settings: { font, size, bold, italic, underline, align, color },
runs,
settings: {
font, size, bold, italic, underline, align, color,
frame, horizontalToView, rotation, maskMargin,
},
})
}
const onCancel = () => send('CANCEL', {})
@@ -208,34 +336,65 @@ export default function TextEditorApp() {
<Pill onClick={doSub} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
</div>
{/* Symbol-Palette */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)',
textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Sonderzeichen
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{SYMBOLS.map(s => (
<button key={s} onClick={() => insertText(s)} 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: 28, height: 22,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 4,
fontSize: 13, lineHeight: 1,
cursor: 'pointer', padding: 0,
transition: 'background 0.12s, border-color 0.12s',
}}>{s}</button>
))}
{/* Toolbar Row 3: Frame / Rotation / Mask / Horizontal-to-view / 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>
))} />
<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="Mask-Rand (m) — weisser Hintergrund hinter Text">
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Mask</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>
<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>