From 26c7d9e67d3488e319fb129e16d459df2e5f97c1 Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 21 May 2026 23:56:16 +0200 Subject: [PATCH] Text-Editor: Rhino RTF-Dialekt + Round-Trip + Oberleiste-Sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
A
B
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 --- rhino/text_create.py | 101 ++++++++++++++++++++++++++------- rhino/text_editor.py | 128 ++++++++++++++++++++++-------------------- src/TextEditorApp.jsx | 112 +++++++++++++++++++----------------- 3 files changed, 210 insertions(+), 131 deletions(-) diff --git a/rhino/text_create.py b/rhino/text_create.py index 4603f42..4d67521 100644 --- a/rhino/text_create.py +++ b/rhino/text_create.py @@ -188,6 +188,9 @@ def apply_style(doc, sid): # Defaults aus Style schreiben (ohne id/name) patch = {k: style[k] for k in style if k in _DEFAULTS} save_settings(doc, patch) + # styleId mitschicken damit apply_settings_to_selection ihn als + # UserString an die Texte haengt — fuer "Stil aktiv"-Anzeige + patch["__style_id__"] = sid apply_settings_to_selection(doc, patch) @@ -707,6 +710,30 @@ def _pick_text_frame(): return None +def _patch_rtf_b_i_ul(rt, bold, italic, underline): + """Patcht alle Bold/Italic/Underline-Codes in der RTF auf den + gewuenschten globalen State. Erhaelt aber per-Segment Font/Color/ + Sup/Sub. + + Wird vom Oberleiste-Path benutzt: die te.Font-Aenderung greift bei + DOSSIER-Texten nicht (RTF-per-Segment-Codes ueberschreiben sie). + Indem wir die Codes selber auf den globalen Toggle setzen, wirken + Bold/Italic/Underline OFF auch tatsaechlich auf den ganzen Text.""" + import re + if not rt: return rt + # Bold: \b oder \b0 als komplettes Token (nicht gefolgt von alpha/digit, + # damit z.B. \bullet nicht versehentlich matched) + pat_b = re.compile(r'\\b0?(?![a-zA-Z0-9])') + rt = pat_b.sub(lambda m: '\\b' if bold else '\\b0', rt) + # Italic + pat_i = re.compile(r'\\i0?(?![a-zA-Z0-9])') + rt = pat_i.sub(lambda m: '\\i' if italic else '\\i0', rt) + # Underline: \ul (on) oder \ulnone (off) — nicht gefolgt von alpha + pat_ul = re.compile(r'\\ul(?:none)?(?![a-zA-Z])') + rt = pat_ul.sub(lambda m: '\\ul' if underline else '\\ulnone', rt) + return rt + + def _apply_font(te, face, bold, italic, underline=False): """Setzt Font auf TextEntity. Mehrere Konstruktor-Pfade fuer verschiedene RhinoCommon-Versionen: @@ -826,7 +853,6 @@ def apply_settings_to_selection(doc, patch): for obj in selected: try: old = obj.Geometry - # Aktuelle Werte lesen (vor Modifikation) cur = old.Font try: cur_face = cur.QuartetName if cur else "Helvetica" except Exception: cur_face = "Helvetica" @@ -837,7 +863,6 @@ def apply_settings_to_selection(doc, patch): try: cur_underline = bool(cur.Underlined) if cur else False except Exception: cur_underline = False - # Neue Werte aus Patch + Fallback auf aktuell face = patch.get("font") or cur_face bold = patch["bold"] if "bold" in patch else cur_bold italic = patch["italic"] if "italic" in patch else cur_italic @@ -845,27 +870,57 @@ def apply_settings_to_selection(doc, patch): size = float(patch["size"]) if "size" in patch else float(old.TextHeight) align = patch["align"] if patch.get("align") in _ALIGNS else None - # FRESH TextEntity bauen statt Duplicate-Modify. Bypassed - # Probleme wo te.Font-Setter wegen Rich-Text-Runs oder - # DimensionStyle-Override nicht greift. - te = rg.TextEntity() - te.Plane = old.Plane - try: te.PlainText = old.PlainText + is_dossier = False + try: + is_dossier = obj.Attributes.GetUserString("dossier_text") == "1" except Exception: pass - te.TextHeight = size - # DimensionStyle entkoppeln damit unser Font nicht von Style - # ueberschrieben wird. - try: te.DimensionStyleId = System.Guid.Empty - except Exception: pass - _apply_font(te, face, bool(bold), bool(italic), bool(underline)) - # Alignment: aus Patch oder vom alten Entity uebernehmen + + # IN-PLACE Modifikation der Live-Geometry (kein Duplicate, + # keine fresh-Entity — Mac Rhino hat Probleme mit Replace + # auf RichText-Entities die nicht aus dem Doc kommen). + old.TextHeight = size + _apply_font(old, face, bool(bold), bool(italic), bool(underline)) if align: - _apply_align(te, align) - else: - try: te.TextHorizontalAlignment = old.TextHorizontalAlignment + _apply_align(old, align) + + # DOSSIER-Texte: die RTF hat per-Segment Codes (\b0, \i0, + # \ulnone) die die te.Font-Aenderung uebersteuern. Wir + # patchen die Codes global damit Bold/Italic/Underline OFF + # auch wirklich greifen. + if is_dossier: + try: + rt = old.RichText + if rt: + new_rt = _patch_rtf_b_i_ul( + rt, bool(bold), bool(italic), bool(underline)) + if new_rt != rt: + old.RichText = new_rt + except Exception as ex: + print("[TEXT] RTF patch fail:", ex) + + # Style-ID am Text persistieren wenn ueber apply_style + # appliziert (Oberleiste-Anzeige "Stil aktiv" bei Selektion) + sid = patch.get("__style_id__") + if sid: + try: obj.Attributes.SetUserString("dossier_text_style_id", sid) except Exception: pass - doc.Objects.Replace(obj.Id, te) + # CommitChanges ist die RhinoObject-API um Aenderungen an der + # in-place modifizierten Geometry persistent zu machen. + try: + ok = obj.CommitChanges() + print("[TEXT] CommitChanges: {} (dossier={})".format(ok, is_dossier)) + except Exception as ex: + print("[TEXT] CommitChanges fail:", ex) + ok = False + + # Falls CommitChanges nicht greift → Replace als Fallback + if not ok: + try: + ok2 = doc.Objects.Replace(obj.Id, old) + print("[TEXT] Replace fallback: {}".format(ok2)) + except Exception as ex: + print("[TEXT] Replace fallback fail:", ex) n += 1 except Exception as ex: print("[TEXT] apply selection:", ex) @@ -880,7 +935,8 @@ def read_selection_settings(doc): sel = _selected_text_objects(doc) if not sel: return None try: - te = sel[0].Geometry + obj = sel[0] + te = obj.Geometry font = te.Font face = font.QuartetName if font else "Helvetica" bold = bool(font.Bold) if font else False @@ -893,6 +949,10 @@ def read_selection_settings(doc): if h == Rhino.DocObjects.TextHorizontalAlignment.Center: align = "center" elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: align = "right" except Exception: pass + # Style-ID falls am Text gespeichert (= via apply_style appliziert) + style_id = None + try: style_id = obj.Attributes.GetUserString("dossier_text_style_id") or None + except Exception: pass return { "font": face, "size": float(te.TextHeight), @@ -900,6 +960,7 @@ def read_selection_settings(doc): "italic": italic, "underline": underline, "align": align, + "styleId": style_id, } except Exception as ex: print("[TEXT] read selection:", ex) diff --git a/rhino/text_editor.py b/rhino/text_editor.py index 1f5eb85..344a605 100644 --- a/rhino/text_editor.py +++ b/rhino/text_editor.py @@ -84,7 +84,8 @@ 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="", initial_runs=None): + edit_obj_id=None, initial_text="", initial_runs=None, + initial_html=None): panel_base.BaseBridge.__init__(self, "text_editor") self._frame = frame_data # (origin, width, height, p1, p2) self._initial_settings = settings @@ -93,6 +94,7 @@ class TextEditorBridge(panel_base.BaseBridge): 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 + self._initial_html = initial_html # 1:1 Editor-HTML beim Reopen def set_form(self, form): self._form_ref = form @@ -108,6 +110,7 @@ class TextEditorBridge(panel_base.BaseBridge): "styles": styles, "initialText": self._initial_text, "initialRuns": self._initial_runs, + "initialHtml": self._initial_html, "editMode": bool(self._edit_obj_id), }) @@ -282,6 +285,10 @@ class TextEditorBridge(panel_base.BaseBridge): print("[TEXT-EDITOR] color:", ex) attrs.SetUserString("dossier_text", "1") attrs.SetUserString("dossier_text_scaled", "1" if scale_flag else "0") + sid = st.get("styleId") + if sid: + try: attrs.SetUserString("dossier_text_style_id", sid) + except Exception: pass # 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. @@ -291,6 +298,15 @@ class TextEditorBridge(panel_base.BaseBridge): attrs.SetUserString("dossier_text_runs", json.dumps(runs)) except Exception as ex: print("[TEXT-EDITOR] runs persist:", ex) + # Editor-innerHTML 1:1 persistieren — beim Reopen wird der + # exakte Editor-Zustand wiederhergestellt, kein Round-Trip + # ueber runs (was Zeilen zusammen ziehen kann). + html = payload.get("html") + if html: + try: + attrs.SetUserString("dossier_text_html", html) + except Exception as ex: + print("[TEXT-EDITOR] html persist:", ex) # Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu if self._edit_obj_id is not None: @@ -366,17 +382,13 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20): """Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von dicts mit Keys text/font/bold/italic/underline/sup/sub/color/fontSizePx. base_size_m: TextEntity.TextHeight (in m). Frontend rendert 1m = 100px, - also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF.""" + also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF. + + Wir emittieren IMMER RTF wenn Runs vorliegen — auch wenn die Runs + auf den ersten Blick "trivial" aussehen. So bleibt das Format + stabil ueber Re-Edits hinweg und es gibt keinen impliziten Fallback + auf _apply_font (= alles auf eine Schrift).""" if not runs: return None - # Triviale Runs (alle plain, ein Font) → kein RTF noetig - nontrivial = False - for r in runs: - if r.get("bold") or r.get("italic") or r.get("underline") \ - or r.get("sup") or r.get("sub") or r.get("color") \ - or r.get("fontSizePx") \ - or (r.get("font") and r["font"] != default_font): - nontrivial = True; break - if not nontrivial: return None # ──────────────────────────────────────────────────────────────── # PASS 1: Runs verarbeiten + Fonts/Colors sammeln + RTF-Bodies bauen @@ -410,57 +422,39 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20): out.append("\\u{}?".format(v)) return "".join(out) - # Ansatz: jedes Text-Segment in eigene {}-Group mit lokalen Codes. - # Zwischen Segmenten (und ueber Run-Grenzen hinweg) \\par fuer - # Paragraph-Breaks. So bleibt Per-Run-Formatting (Groups isolieren - # State) UND Mehrzeiligkeit (\\par ist Rhinos echter Linebreak). - body_parts = [] - pending_pars = 0 # Wieviele \\par muessen noch vor naechstem Segment - - def _emit_group(run, seg): - codes = [] - codes.append("\\f{}".format(font_idx(run.get("font") or default_font))) - ci = color_idx(run.get("color")) if run.get("color") else 0 - codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0") - fsp = run.get("fontSizePx") - if fsp and abs(fsp - BASE_PX) > 0.1: - rtf_fs = max(2, int(round(20.0 * fsp / BASE_PX))) - codes.append("\\fs{}".format(rtf_fs)) - else: - codes.append("\\fs20") - codes.append("\\b" if run.get("bold") else "\\b0") - codes.append("\\i" if run.get("italic") else "\\i0") - codes.append("\\ul" if run.get("underline") else "\\ulnone") - if run.get("sup"): codes.append("\\super") - elif run.get("sub"): codes.append("\\sub") - else: codes.append("\\nosupersub") - body_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg))) - - def _flush_pars(n): - # 1 \\par = einfacher Zeilenumbruch. N>1 \\par hintereinander - # wuerde Rhino zu einem einzigen Umbruch collapsen — fuer - # jede zusaetzliche Leerzeile schieben wir einen Space-Group - # dazwischen, damit der leere Paragraph Inhalt hat. - if n <= 0: return - body_parts.append("\\par ") - for _ in range(n - 1): - body_parts.append("{ }\\par ") - - first_emitted = False + # Rhinos TextEntity-RTF: per-Segment in {}-Group, \par zwischen + # Groups als Linebreak. Diese Form hat in commit 3bd949e fuer + # Newlines funktioniert. Fuer Leerzeilen: zusaetzliche {\par}-Group + # zwischen den regulaeren Segmenten — leere Group mit eigenem \par + # umgeht den Multi-\par-Collapse. + lines = [[]] for run in runs: raw = run.get("text") or "" - segments = raw.split("\n") - for i, seg in enumerate(segments): - if i > 0: - pending_pars += 1 - if seg: - if first_emitted: - _flush_pars(pending_pars) - pending_pars = 0 - _emit_group(run, seg) - first_emitted = True - if first_emitted and pending_pars > 0: - _flush_pars(pending_pars) + parts_in_run = raw.split("\n") + for j, part in enumerate(parts_in_run): + if j > 0: + lines.append([]) + if part: + lines[-1].append((run, part)) + + body_parts = [] + for li, line in enumerate(lines): + if li > 0: + body_parts.append("\\par ") + if not line: + # Leere Zeile: Space-Group damit der Paragraph Inhalt hat + # (sonst collapsed Rhino zwei \par auf einen Linebreak). + body_parts.append("{ }") + continue + for (run, seg) in line: + codes = [] + codes.append("\\f{}".format(font_idx(run.get("font") or default_font))) + ci = color_idx(run.get("color")) if run.get("color") else 0 + codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0") + codes.append("\\b" if run.get("bold") else "\\b0") + codes.append("\\i" if run.get("italic") else "\\i0") + codes.append("\\ul" if run.get("underline") else "\\ulnone") + body_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg))) # ──────────────────────────────────────────────────────────────── # PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body @@ -530,6 +524,10 @@ def open_for_edit(obj): settings["align"] = "right" else: settings["align"] = "left" except Exception: pass + try: + sid = obj.Attributes.GetUserString("dossier_text_style_id") + if sid: settings["styleId"] = sid + except Exception: pass try: flag = obj.Attributes.GetUserString("dossier_text_scaled") if flag in ("0", "1"): @@ -573,6 +571,13 @@ def open_for_edit(obj): if rj: initial_runs = json.loads(rj) except Exception as ex: print("[TEXT-EDITOR] read runs:", ex) + # Editor-innerHTML (Round-Trip-Konservierung): wenn vorhanden, + # wird der Editor exakt mit diesem HTML geoeffnet + initial_html = None + try: + h = obj.Attributes.GetUserString("dossier_text_html") + if h: initial_html = h + except Exception: pass # Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung) p1 = te.Plane.Origin @@ -596,7 +601,8 @@ def open_for_edit(obj): settings, fonts, edit_obj_id=obj.Id, initial_text=initial_text, - initial_runs=initial_runs) + initial_runs=initial_runs, + initial_html=initial_html) sc.sticky["text_editor_bridge"] = bridge form = panel_base.open_satellite_window( diff --git a/src/TextEditorApp.jsx b/src/TextEditorApp.jsx index e44fbff..8340a31 100644 --- a/src/TextEditorApp.jsx +++ b/src/TextEditorApp.jsx @@ -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 (
A
B
) — 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 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, '&').replace(//g, '>') - 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('
') - 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) + if (i > 0) lines.push([]) + if (segs[i]) lines[lines.length-1].push({ ...r, text: segs[i] }) } } - return out.join('') + // Jede Zeile als
; leere Zeilen als

(sonst + // collapsed contentEditable die Leerzeile visuell) + return lines.map(line => { + if (line.length === 0) return '

' + 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 += ''; closes = '' + closes } + if (seg.italic) { opens += ''; closes = '' + closes } + if (seg.underline) { opens += ''; closes = '' + closes } + if (seg.sup) { opens += ''; closes = '' + closes } + else if (seg.sub) { opens += ''; closes = '' + closes } + const text = opens + escape(seg.text) + closes + return styles.length ? `${text}` : text + }).join('') + return `
${inner}
` + }).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 mit gegebenem CSS-Property + // Wrap aktuelle Selektion in ein 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 */}
- { 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 => ( ))} @@ -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,