Files
DOSSIER/src/TextEditorApp.jsx
T
karim e7a1753519 Text-Editor: Default-Stile + Stil-Picker im Dialog
User-Wunsch: vorgespeicherte Stile (Heading, Paragraph Helvetica/Georgia)
direkt im Editor anwendbar.

Backend (text_create.py):
- _DEFAULT_STYLES: 7 sinnvolle Architektur-Defaults — Titel (0.40m bold),
  Heading 1 (0.30m bold), Heading 2 (0.20m bold), Paragraph Helvetica
  (0.15m), Paragraph Georgia (0.15m Georgia), Notiz (0.10m italic),
  Bildlegende (0.08m italic)
- list_styles: seedet die Defaults beim ersten Zugriff falls noch keine
  Styles im Doc existieren (analog mass_style)
- Bestehende save_style/delete_style/apply_style funktionieren weiter

Backend (text_editor.py):
- INIT-Payload erweitert um styles[] (Liste aller verfuegbaren Stile
  mit id/name/font/size/bold/italic/underline/align)

Frontend (TextEditorApp.jsx):
- Neuer Stil-Picker als erstes Dropdown in Toolbar-Row 1 (150px)
- Optionen: "— Stil wählen —" + alle verfuegbaren Stile
- onChange: applyStyle(style) — setzt Toolbar-State + appliziert via
  execCommand auf die aktuelle Selektion im WYSIWYG-Editor (oder als
  Default fuer kommendes Tippen wenn keine Selektion)
- queryCommandState-Check fuer Bold/Italic/Underline damit nur toggled
  wird wenn nicht schon im gewuenschten State

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:38:23 +02:00

487 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<button
onClick={onClick}
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()
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')
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 (
<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); exec('fontName', v) }}
width={150} title="Schrift"
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) => setSize(parseFloat(v))}
width={90} title="Texthöhe (m)"
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 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Pill active={bold} onClick={toggleBold} title="Fett">
<Icon name="format_bold" size={13} />
</Pill>
<Pill active={italic} onClick={toggleItalic} title="Kursiv">
<Icon name="format_italic" size={13} />
</Pill>
<Pill active={underline} onClick={toggleUnderline} title="Unterstrichen">
<Icon name="format_underlined" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill active={align === 'left'} onClick={() => doAlign('left')} title="Links">
<Icon name="format_align_left" size={13} />
</Pill>
<Pill active={align === 'center'} onClick={() => doAlign('center')} title="Mittig">
<Icon name="format_align_center" size={13} />
</Pill>
<Pill active={align === 'right'} onClick={() => doAlign('right')} title="Rechts">
<Icon name="format_align_right" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill onClick={doSup} title="Hochstellen (x²)" style={{ fontFamily: 'serif' }}></Pill>
<Pill onClick={doSub} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
</div>
{/* 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>
{/* WYSIWYG Editor */}
<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: font,
fontSize: 14, lineHeight: 1.5,
outline: 'none',
overflowY: 'auto',
textAlign: align,
}}
/>
{/* 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>
)
}