React WYSIWYG Text-Editor (Topmost Satellite-WebView) — Phase 1

User-Wunsch: eigener WYSIWYG-Editor im React/Topbar-GUI-Stil. Topmost.
Verschiedene Schriftarten/-Dicken sichtbar im Editor selbst.

Neues Backend (rhino/text_editor.py):
- TextEditorBridge mit Frame-Daten im Konstruktor, INIT-Push mit
  Settings + Font-Liste, COMMIT erstellt TextEntity, CANCEL schliesst
- open_with_frame(p1, p2, origin, width, height): oeffnet Satellite-
  Window mit mode='text_editor' + topmost=True
- panel_base.open_satellite_window: neuer Parameter topmost (default
  False) der form.Topmost setzt

text_create.create_text: ruft jetzt text_editor.open_with_frame nach
dem Frame-Pick. Eto-basierter _dossier_text_editor bleibt im Modul als
Fallback aber wird nicht mehr verwendet.

Neues Frontend (src/TextEditorApp.jsx, mode='text_editor'):
- Layout im DOSSIER-Topbar-Stil (dunkle Pills, accent on hover)
- Pill-Helper-Komponente fuer alle Toggle/Action-Buttons
- Dropdown-Helper fuer Font + Size
- Toolbar Row 1: Font-Dropdown | Size-Dropdown | Color-Picker | Layer-Reset
- Toolbar Row 2: B/I/U mit Material-Icons | L/C/R Align | x²/x₂ Sup/Sub
- Sonderzeichen-Palette: 41 Unicode-Symbole (Architektur/Math/Pfeile/
  Auszeichnungen), Klick inserted am Cursor
- WYSIWYG-Editor: contentEditable div mit fontFamily=ausgewaehlt,
  textAlign=ausgewaehlt — Format-Toolbar wirkt via document.execCommand
  (bold/italic/underline/justifyLeft/Center/Right/superscript/subscript/
  fontName/foreColor)
- "Einfuegen" sendet COMMIT mit text (innerText) + settings
- "Abbrechen" CANCEL → Bridge schliesst Form

Phase 1 Limitation: rendert in Rhino als PlainText mit den globalen
Settings (font/size/bold/italic/align/color) — verschiedene Schriftarten
INNERHALB des Texts sind im Editor sichtbar aber nicht im finalen
Rhino-TextEntity. Phase 2: HTML → Rhino RichText/RTF Mapping.

rhinoBridge.js: send() jetzt exportiert (war intern) damit
TextEditorApp generisch COMMIT/CANCEL senden kann.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 01:28:26 +02:00
parent b047d0aa4b
commit ab0ecfbf14
6 changed files with 425 additions and 58 deletions
+281
View File
@@ -0,0 +1,281 @@
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import { onMessage, notifyReady, send } from './lib/rhinoBridge'
const SYMBOLS = [
'∅', 'Ø', '⌀', '°', '±', '×', '÷',
'²', '³', '½', '¼', '¾', '⅓', '⅔',
'≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ',
'←', '→', '↑', '↓', '↔', '↕',
'•', '·', '▪', '◆', '★', '☆', '✓', '✗',
'§', '¶', '©', '®', '™',
]
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>
)
}
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
const editorRef = useRef(null)
useEffect(() => {
onMessage('INIT', (data) => {
setFonts(data.fonts || [])
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')
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 text = editorRef.current?.innerText || ''
if (!text.trim()) return
send('COMMIT', {
text,
settings: { font, size, bold, italic, underline, align, color },
})
}
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: Font | Size | Color | Layer-Reset */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<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>
{/* 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>
))}
</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>
)
}