From 211078d229b1da29feaf42b918c0412630e048da Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 21 May 2026 02:13:42 +0200 Subject: [PATCH] Text-Editor: Style-Picker focus-restore + Runs-Round-Trip beim Reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Bugs: 1. Stil-Picker setzt Toolbar-State aber Editor zeigt alle Texte gleich 2. Beim Reopen (Doppelklick auf DOSSIER-Text) ist Editor leer/unformatiert Fix 1 — applyStyle focus-restore: Style-Picker stiehlt Focus → savedRange ist da, aber execCommand ohne Focus auf editable schreibt nichts. Fix: editorRef.focus() + restoreSelection() VOR execCommand. Plus Size jetzt via inline-span (execCommand fontSize geht nur 1-7 Scale, kein Pixel). Fix 2 — Runs persistieren als UserString fuer Round-Trip: - _commit: runs als JSON in attrs.SetUserString("dossier_text_runs") abgelegt zusammen mit dem dossier_text-Tag - open_for_edit: liest UserString, parsed JSON → initial_runs - Bridge INIT: sendet initialRuns mit - Frontend onMessage INIT: wenn initialRuns vorhanden → runsToHtml() baut HTML mit spans (font-family, font-size, color) + Tags (b/i/u/ sup/sub) — Editor zeigt jetzt beim Reopen das tatsaechliche reiche Format statt PlainText im Default-Helvetica runsToHtml: pro run text/style-Splittung per \n →
. Escaping fuer &<> damit kein XSS / Parse-Fehler. Co-Authored-By: Claude Opus 4.7 --- rhino/text_editor.py | 24 +++++++++++++++-- src/TextEditorApp.jsx | 63 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/rhino/text_editor.py b/rhino/text_editor.py index fe15332..81f23ce 100644 --- a/rhino/text_editor.py +++ b/rhino/text_editor.py @@ -84,7 +84,7 @@ def _on_idle_check_pending_edit(sender, e): class TextEditorBridge(panel_base.BaseBridge): def __init__(self, frame_data, settings, fonts, - edit_obj_id=None, initial_text=""): + edit_obj_id=None, initial_text="", initial_runs=None): panel_base.BaseBridge.__init__(self, "text_editor") self._frame = frame_data # (origin, width, height, p1, p2) self._initial_settings = settings @@ -92,6 +92,7 @@ class TextEditorBridge(panel_base.BaseBridge): self._form_ref = None self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt self._initial_text = initial_text + self._initial_runs = initial_runs # rich-format-Runs falls vorhanden def set_form(self, form): self._form_ref = form @@ -106,6 +107,7 @@ class TextEditorBridge(panel_base.BaseBridge): "fonts": self._fonts, "styles": styles, "initialText": self._initial_text, + "initialRuns": self._initial_runs, "editMode": bool(self._edit_obj_id), }) @@ -219,6 +221,15 @@ class TextEditorBridge(panel_base.BaseBridge): except Exception as ex: print("[TEXT-EDITOR] color:", ex) attrs.SetUserString("dossier_text", "1") + # Runs als JSON persistieren — beim Re-Open kann der Editor + # die ganze Struktur (Fonts/Sizes/Styles pro Segment) wieder + # herstellen statt nur PlainText zu zeigen. + if runs: + try: + import json + attrs.SetUserString("dossier_text_runs", json.dumps(runs)) + except Exception as ex: + print("[TEXT-EDITOR] runs persist:", ex) # Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu if self._edit_obj_id is not None: @@ -439,6 +450,14 @@ def open_for_edit(obj): initial_text = "" try: initial_text = te.PlainText or "" except Exception: pass + # Runs aus UserString lesen (gespeichert beim letzten Save) + initial_runs = None + try: + import json + rj = obj.Attributes.GetUserString("dossier_text_runs") + if rj: initial_runs = json.loads(rj) + except Exception as ex: + print("[TEXT-EDITOR] read runs:", ex) # Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung) p1 = te.Plane.Origin @@ -461,7 +480,8 @@ def open_for_edit(obj): (origin, width, height, p1, p2), settings, fonts, edit_obj_id=obj.Id, - initial_text=initial_text) + initial_text=initial_text, + initial_runs=initial_runs) sc.sticky["text_editor_bridge"] = bridge form = panel_base.open_satellite_window( diff --git a/src/TextEditorApp.jsx b/src/TextEditorApp.jsx index cf94e53..9da0ccb 100644 --- a/src/TextEditorApp.jsx +++ b/src/TextEditorApp.jsx @@ -146,6 +146,40 @@ function htmlToRuns(rootEl) { return runs } +// Runs zurueck in HTML konvertieren — fuer das Re-Open eines bestehenden +// DOSSIER-Texts. Spiegelt die persistierten Format-Runs als mit +// inline-style + Tags (b/i/u/sup/sub). Newlines werden zu
. +function runsToHtml(runs) { + if (!Array.isArray(runs)) return '' + const escape = (s) => s.replace(/&/g, '&').replace(//g, '>') + const out = [] + 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('
') + 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 += ''; closes = '' + closes } + if (r.italic) { opens += ''; closes = '' + closes } + if (r.underline) { opens += ''; closes = '' + closes } + if (r.sup) { opens += ''; closes = '' + closes } + else if (r.sub) { opens += ''; closes = '' + closes } + const inner = opens + escape(seg) + closes + if (styles.length) out.push(`${inner}`) + else out.push(inner) + } + } + return out.join('') +} + function SymbolPopover({ open, onClose, onPick }) { if (!open) return null return ( @@ -290,11 +324,16 @@ export default function TextEditorApp() { if (s.italic != null) setItalic(!!s.italic) if (s.underline != null) setUnderline(!!s.underline) if (s.align) setAlign(s.align) - // Bei Edit-Mode: bestehenden Text in den Editor laden + // 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 setTimeout(() => { if (editorRef.current) { - if (initialText) { + if (initialRuns && initialRuns.length > 0) { + editorRef.current.innerHTML = runsToHtml(initialRuns) + } else if (initialText) { editorRef.current.innerText = initialText } editorRef.current.focus() @@ -335,8 +374,9 @@ export default function TextEditorApp() { 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. + // Stil anwenden: Toolbar-State setzen + Selection restoren + via + // execCommand applizieren (auf Selektion oder als Default fuer's + // kommende Tippen). const applyStyle = (style) => { if (!style) return // Toolbar-State synchronisieren @@ -346,13 +386,20 @@ export default function TextEditorApp() { setItalic(!!style.italic) setUnderline(!!style.underline) if (style.align) setAlign(style.align) - // Auf Selektion applizieren (oder zumindest fuer kommendes Tippen) + // Selection wiederherstellen + Editor-Focus damit execCommand greift. + editorRef.current?.focus() + restoreSelection() 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 + // 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