Text-Editor: Rhino RTF-Dialekt + Round-Trip + Oberleiste-Sync

Lange Iteration mit dem Rhino TextEntity-RTF-Parser (siehe MEMORY:
rhino_textentity_rtf_limits.md). Finale Form:

- RTF-Body: per-Segment {\fN\cfN\b\i\ulnone seg}-Groups, \par
  zwischen Groups als Linebreak, { }-Space-Group fuer Leerzeilen
  (Rhino collapsed sonst aufeinanderfolgende \par). \fs (Font-Size)
  ist NICHT unterstuetzt → eine Size pro TextEntity (global).
- htmlToRuns: emittiert \n VOR Block-Elementen wenn schon Content
  davor — fixt nested <div>A<div>B</div></div> die sonst A+B ohne
  Trenner als ein Run liefern.
- Round-Trip-Erhaltung: editor.innerHTML 1:1 als UserString
  "dossier_text_html" persistiert, beim Reopen direkt gesetzt
  (kein runsToHtml-Konvertieren das Zeilen verlieren kann).
- Oberleiste-Editing: in-place modify von obj.Geometry + Commit-
  Changes statt Duplicate+Replace (Mac Rhino gibt False zurueck
  bei RichText-Klonen). Plus _patch_rtf_b_i_ul: regex-flippt
  \b/\b0, \i/\i0, \ul/\ulnone global in der RTF damit Bold/Italic/
  Underline OFF in der Oberleiste auch wirklich auf DOSSIER-Texte
  greift (per-Segment-Codes wuerden te.Font-Aenderung sonst
  uebersteuern).
- Stil-ID am Text persistiert + von read_selection_settings
  zurueckgelesen → Stil-Dropdown spiegelt Selektion.
- Editor neu: V-Align (Top/Middle/Bottom), Mask-Type (None/Viewport/
  Solid) mit Farb-Picker, Case-Transform (upper/lower/capitalize/
  invert), Masstaeblich-Toggle (AnnotationScalingEnabled),
  Symbol-Popover, Frame-Optionen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:56:16 +02:00
parent ae80185064
commit 26c7d9e67d
3 changed files with 210 additions and 131 deletions
+62 -50
View File
@@ -90,8 +90,10 @@ function Dropdown({ value, onChange, options, width, title }) {
function htmlToRuns(rootEl) {
const runs = []
function flush(text, ctx) {
if (text === '') return
runs.push({ text, ...ctx })
// ZWSP (U+200B) ist unser Style-Marker — nie in die Runs
const cleaned = text.replace(//g, '')
if (cleaned === '') return
runs.push({ text: cleaned, ...ctx })
}
function walk(node, ctx) {
if (node.nodeType === Node.TEXT_NODE) {
@@ -100,6 +102,14 @@ function htmlToRuns(rootEl) {
if (node.nodeType !== Node.ELEMENT_NODE) return
const tag = node.tagName.toLowerCase()
if (tag === 'br') { flush('\n', ctx); return }
// Block-Elemente: \n VOR dem Element wenn schon Content davor ist.
// Fix fuer verschachtelte divs (<div>A<div>B</div></div>) — sonst
// sieht End-of-Outer das \n von Inner und ueberspringt, A und B
// landen ohne Trenner in den Runs.
const isBlock = (tag === 'div' || tag === 'p')
if (isBlock && runs.length > 0 && !runs[runs.length-1].text.endsWith('\n')) {
flush('\n', ctx)
}
const nc = { ...ctx }
if (tag === 'b' || tag === 'strong') nc.bold = true
if (tag === 'i' || tag === 'em') nc.italic = true
@@ -116,14 +126,8 @@ function htmlToRuns(rootEl) {
}
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
}
// Font-Size wird NICHT gelesen — Rhinos TextEntity-RTF unterstuetzt
// \fs nicht. Es gibt nur EINE Size pro TextEntity (te.TextHeight).
// Legacy <font> Element von execCommand
if (tag === 'font') {
const c = node.getAttribute('color'); if (c) nc.color = c
@@ -152,32 +156,35 @@ function htmlToRuns(rootEl) {
function runsToHtml(runs) {
if (!Array.isArray(runs)) return ''
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const out = []
// Erst in Zeilen flatten (gleiche Logik wie das Python-Backend)
const lines = [[]]
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('<br>')
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 += '<b>'; closes = '</b>' + closes }
if (r.italic) { opens += '<i>'; closes = '</i>' + closes }
if (r.underline) { opens += '<u>'; closes = '</u>' + closes }
if (r.sup) { opens += '<sup>'; closes = '</sup>' + closes }
else if (r.sub) { opens += '<sub>'; closes = '</sub>' + closes }
const inner = opens + escape(seg) + closes
if (styles.length) out.push(`<span style="${styles.join('; ')}">${inner}</span>`)
else out.push(inner)
if (i > 0) lines.push([])
if (segs[i]) lines[lines.length-1].push({ ...r, text: segs[i] })
}
}
return out.join('')
// Jede Zeile als <div>; leere Zeilen als <div><br></div> (sonst
// collapsed contentEditable die Leerzeile visuell)
return lines.map(line => {
if (line.length === 0) return '<div><br></div>'
const inner = line.map(seg => {
const styles = []
if (seg.font) styles.push(`font-family: ${seg.font}`)
if (seg.color) styles.push(`color: ${seg.color}`)
let opens = '', closes = ''
if (seg.bold) { opens += '<b>'; closes = '</b>' + closes }
if (seg.italic) { opens += '<i>'; closes = '</i>' + closes }
if (seg.underline) { opens += '<u>'; closes = '</u>' + closes }
if (seg.sup) { opens += '<sup>'; closes = '</sup>' + closes }
else if (seg.sub) { opens += '<sub>'; closes = '</sub>' + closes }
const text = opens + escape(seg.text) + closes
return styles.length ? `<span style="${styles.join('; ')}">${text}</span>` : text
}).join('')
return `<div>${inner}</div>`
}).join('')
}
function SymbolPopover({ open, onClose, onPick }) {
@@ -254,6 +261,7 @@ export default function TextEditorApp() {
const [maskMargin, setMaskMargin] = useState(0)
const [symbolsOpen, setSymbolsOpen] = useState(false)
const [styles, setStyles] = useState([])
const [activeStyleId, setActiveStyleId] = useState('')
const editorRef = useRef(null)
const savedRangeRef = useRef(null)
@@ -294,7 +302,10 @@ export default function TextEditorApp() {
try { sel.addRange(savedRangeRef.current) } catch (e) {}
}
// Wrap aktuelle Selektion in ein <span> mit gegebenem CSS-Property
// Wrap aktuelle Selektion in ein <span> mit gegebenem CSS-Property.
// No-op bei leerer Selektion — das ist Absicht: Auto-Marker-Spans
// wandern zu unvorhergesehenen Positionen wenn der User auf andere
// Stellen klickt, Stile gehen dabei verloren.
const applyInlineStyleToSelection = (styleProp, value) => {
restoreSelection()
const sel = window.getSelection()
@@ -307,12 +318,10 @@ export default function TextEditorApp() {
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) }
}
@@ -332,14 +341,19 @@ export default function TextEditorApp() {
if (s.scaleWithModel != null) setScaleWithModel(!!s.scaleWithModel)
if (s.maskType) setMaskType(s.maskType)
if (Array.isArray(s.maskColor)) setMaskColor(s.maskColor)
if (s.styleId) setActiveStyleId(s.styleId)
// 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
const initialHtml = data.initialHtml
setTimeout(() => {
if (editorRef.current) {
if (initialRuns && initialRuns.length > 0) {
if (initialHtml) {
// Bevorzugt: Editor-Innen-HTML 1:1 wiederherstellen
editorRef.current.innerHTML = initialHtml
} else if (initialRuns && initialRuns.length > 0) {
editorRef.current.innerHTML = runsToHtml(initialRuns)
} else if (initialText) {
editorRef.current.innerText = initialText
@@ -387,27 +401,21 @@ export default function TextEditorApp() {
// kommende Tippen).
const applyStyle = (style) => {
if (!style) return
// Toolbar-State synchronisieren
// Stil setzt globale Toolbar-Defaults (Font + Size gelten fuer
// den GANZEN Text, weil Rhino nur eine Size pro Entity kann).
// Auf die Selektion wirken nur Font/Bold/Italic/Underline.
setActiveStyleId(style.id || '')
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
@@ -461,16 +469,21 @@ export default function TextEditorApp() {
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) }
// Editor-HTML 1:1 mitschicken — beim Reopen wird der genau gleiche
// Editor-Zustand wiederhergestellt (kein runsToHtml mehr, das Lines
// zusammenpurzeln kann)
const html = el.innerHTML
send('COMMIT', {
text,
runs,
html,
settings: {
font, size, bold, italic, underline, align, valign, color,
frame, horizontalToView, rotation, scaleWithModel,
maskType, maskColor, maskMargin,
styleId: activeStyleId || null,
},
})
}
@@ -486,10 +499,11 @@ export default function TextEditorApp() {
}}>
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Dropdown value=""
<Dropdown value={activeStyleId}
onChange={(v) => {
const st = styles.find(s => s.id === v)
if (st) applyStyle(st)
else setActiveStyleId('')
}}
width={150}
title="Text-Stil anwenden (auf Selektion oder als Default fuer kommendes Tippen)"
@@ -516,11 +530,9 @@ export default function TextEditorApp() {
onChange={(v) => {
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"
width={90} title="Texthöhe (m) — gilt für den ganzen Text (Rhino unterstützt keine per-Segment Sizes)"
options={SIZE_PRESETS.map(s => (
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
))}
@@ -697,7 +709,7 @@ export default function TextEditorApp() {
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'Helvetica, sans-serif',
fontSize: 20, lineHeight: 1.5,
fontSize: sizeToPx(size), lineHeight: 1.3,
outline: 'none',
overflowY: 'auto',
textAlign: align,