From f4404db64a135362e70020e4dfdd764b4bfb4014 Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 21 May 2026 01:44:45 +0200 Subject: [PATCH] Doppelklick-Hook auf DOSSIER-Texte + Size-Mapping in RTF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User: Doppelklick auf DOSSIER-Text oeffnet weiterhin Rhinos Editor. Verschiedene Groessen im Editor erscheinen nicht in Rhino. Doppelklick-Hook (rhino/text_editor.py): - _DossierTextDoubleClickHook subklassiert Rhino.UI.MouseCallback. OnMouseDoubleClick prueft selektierte TextEntities auf UserString "dossier_text"="1" und cancelled das Event (= blockt Rhinos Standard-TextEdit-Dialog), setzt sticky["dossier_pending_text_edit"] mit der Obj-ID - _on_idle_check_pending_edit (RhinoApp.Idle event): nimmt sticky-ID auf naechstem Idle-Tick und ruft open_for_edit(obj) — defer noetig weil Eto-Form aus MouseCallback heraus oeffnen Re-Entrancy macht - _ensure_double_click_hook() installiert Hook + Idle-Handler einmalig pro Rhino-Session (idempotent) - startup.py ruft das jetzt direkt nach Modul-Load auf Edit-Mode (open_for_edit): - Liest aus bestehendem TextEntity die Settings (Font, Size, Bold, Italic, Underline, Align) + PlainText - Frame fuer Dialog-Positionierung aus BBox abgeleitet - TextEditorBridge mit edit_obj_id + initial_text gestartet - INIT-Payload um initialText + editMode erweitert - COMMIT: bei edit_obj_id gesetzt → doc.Objects.Replace statt AddText. Plane wird vom Original uebernommen wenn keine explizite Rotation, damit der Text an seinem Platz bleibt Frontend (TextEditorApp.jsx): - Bei INIT mit initialText: editor.innerText wird damit befuellt - htmlToRuns extrahiert font-size in Pixel pro Run (inline style oder computed style != base) - baseCtx _basePx aus computed style des Editor-divs Size-Mapping (rhino/text_editor.py _runs_to_rtf): - fontSizePx in Runs triggert non-trivial (RTF wird generiert) - Pro Run: \fs in Halb-Punkten = 20 * (run_px / 14_base_px) round - 14px = \fs20 (1.0× TextEntity.TextHeight) - 21px = \fs30 (1.5×) - 28px = \fs40 (2.0×) Co-Authored-By: Claude Opus 4.7 --- rhino/startup.py | 6 ++ rhino/text_create.py | 1 + rhino/text_editor.py | 178 +++++++++++++++++++++++++++++++++++++++--- src/TextEditorApp.jsx | 27 +++++-- 4 files changed, 196 insertions(+), 16 deletions(-) diff --git a/rhino/startup.py b/rhino/startup.py index 5736673..5ae84ac 100644 --- a/rhino/startup.py +++ b/rhino/startup.py @@ -133,6 +133,12 @@ def _load_all(sender, e): print("[STARTUP] {} ({}) OK".format(mod_id, py_name)) except Exception as ex: print("[STARTUP] {} ({}) FEHLER: {}".format(mod_id, py_name, ex)) + # Text-Editor Doppelklick-Hook fuer DOSSIER-Texte + try: + import text_editor + text_editor._ensure_double_click_hook() + except Exception as ex: + print("[STARTUP] text_editor hook:", ex) # DOSSIERUI Window-Layout — Hinweis fuer manuelles Laden _hint_dossier_ui() # Startup-Timing-Summary 3 Sekunden spaeter (nachdem alle async Idle- diff --git a/rhino/text_create.py b/rhino/text_create.py index 053a6c2..7376f3c 100644 --- a/rhino/text_create.py +++ b/rhino/text_create.py @@ -906,6 +906,7 @@ def create_text(): try: import text_editor + text_editor._ensure_double_click_hook() text_editor.open_with_frame(p1, p2, origin, width, height) except Exception as ex: print("[TEXT] open editor:", ex) diff --git a/rhino/text_editor.py b/rhino/text_editor.py index c03af8f..9a9bb17 100644 --- a/rhino/text_editor.py +++ b/rhino/text_editor.py @@ -21,13 +21,77 @@ import panel_base import text_create +# --------------------------------------------------------------------------- +# Doppelklick-Hook: wenn User auf einen DOSSIER-Text doppelklickt, oeffnet +# sich UNSER Editor statt Rhinos Standard-TextEdit. + +class _DossierTextDoubleClickHook(Rhino.UI.MouseCallback): + def OnMouseDoubleClick(self, e): + try: + doc = Rhino.RhinoDoc.ActiveDoc + if doc is None: return + # Welches Objekt ist gerade selektiert? (Rhino's einfache + # Single-Click hat es ausgewaehlt bevor wir hier sind.) + target = None + for obj in doc.Objects.GetSelectedObjects(False, False): + try: + if isinstance(obj.Geometry, rg.TextEntity): + if obj.Attributes.GetUserString("dossier_text") == "1": + target = obj; break + except Exception: pass + if target is not None: + e.Cancel = True # Rhinos eigener TextEdit-Dialog wird geblockt + # Defer auf naechsten Idle-Tick — Eto-Form aus MouseCallback + # heraus zu oeffnen kann zu Re-Entrancy-Problemen fuehren + sc.sticky["dossier_pending_text_edit"] = str(target.Id) + except Exception as ex: + print("[TEXT-HOOK] OnDoubleClick:", ex) + + +_hook_instance = None + + +def _ensure_double_click_hook(): + """Installiert MouseCallback einmalig pro Rhino-Session.""" + global _hook_instance + if _hook_instance is not None: return + try: + _hook_instance = _DossierTextDoubleClickHook() + _hook_instance.Enabled = True + # Idle-Handler einmalig registrieren fuer das deferred Opening + if not sc.sticky.get("dossier_text_idle_registered"): + Rhino.RhinoApp.Idle += _on_idle_check_pending_edit + sc.sticky["dossier_text_idle_registered"] = True + print("[TEXT-HOOK] Doppelklick-Hook installiert") + except Exception as ex: + print("[TEXT-HOOK] install:", ex) + + +def _on_idle_check_pending_edit(sender, e): + pid = sc.sticky.get("dossier_pending_text_edit") + if not pid: return + sc.sticky["dossier_pending_text_edit"] = None + doc = Rhino.RhinoDoc.ActiveDoc + if doc is None: return + try: + import System + obj = doc.Objects.FindId(System.Guid(pid)) + if obj is None: return + open_for_edit(obj) + except Exception as ex: + print("[TEXT-HOOK] open for edit:", ex) + + class TextEditorBridge(panel_base.BaseBridge): - def __init__(self, frame_data, settings, fonts): + def __init__(self, frame_data, settings, fonts, + edit_obj_id=None, initial_text=""): panel_base.BaseBridge.__init__(self, "text_editor") self._frame = frame_data # (origin, width, height, p1, p2) self._initial_settings = settings self._fonts = fonts self._form_ref = None + self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt + self._initial_text = initial_text def set_form(self, form): self._form_ref = form @@ -38,9 +102,11 @@ class TextEditorBridge(panel_base.BaseBridge): try: styles = text_create.list_styles(doc) except Exception: pass self.send("INIT", { - "settings": self._initial_settings, - "fonts": self._fonts, - "styles": styles, + "settings": self._initial_settings, + "fonts": self._fonts, + "styles": styles, + "initialText": self._initial_text, + "editMode": bool(self._edit_obj_id), }) def handle(self, data): @@ -145,7 +211,25 @@ class TextEditorBridge(panel_base.BaseBridge): except Exception as ex: print("[TEXT-EDITOR] color:", ex) attrs.SetUserString("dossier_text", "1") - doc.Objects.AddText(te, attrs) + + # Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu + if self._edit_obj_id is not None: + try: + old = doc.Objects.FindId(self._edit_obj_id) + if old is not None: + # Plane vom Original uebernehmen wenn nicht rotiert + # explizit gewuenscht (sonst wandert der Text) + if abs(rot_deg) < 1e-6: + te.Plane = old.Geometry.Plane + doc.Objects.Replace(self._edit_obj_id, te) + doc.Objects.ModifyAttributes(self._edit_obj_id, attrs, True) + else: + doc.Objects.AddText(te, attrs) + except Exception as ex: + print("[TEXT-EDITOR] replace:", ex) + doc.Objects.AddText(te, attrs) + else: + doc.Objects.AddText(te, attrs) doc.Views.Redraw() # Defaults speichern (ohne color/rotation/frame — die sind @@ -207,6 +291,7 @@ def _runs_to_rtf(runs, default_font): 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 @@ -237,11 +322,19 @@ def _runs_to_rtf(runs, default_font): parts.append("}") parts.append("\\pard") + # Editor Default-Font-Size in px (siehe TextEditorApp Editor-div: 14px) + BASE_PX = 14 for run in runs: 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 if ci > 0: codes.append("\\cf{}".format(ci)) + # Font-Size: \fs in Halb-Punkten relativ zur TextEntity.TextHeight. + # Annahme: \fs20 = 1.0× base. Scaling: \fs = 20 * (run_px / base_px). + 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)) 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") @@ -256,11 +349,8 @@ def _runs_to_rtf(runs, default_font): def open_with_frame(p1, p2, origin, width, height): - """Aufgerufen aus text_create.create_text() nach Frame-Pick. - Oeffnet das React-WYSIWYG-Editor-Fenster (Topmost) neben dem Frame. - Non-blocking — Bridge handlet die Eingabe + erstellt TextEntity bei - COMMIT. - """ + """Frame-Pick-Workflow → Editor oeffnen, neuer Text erstellt.""" + _ensure_double_click_hook() doc = Rhino.RhinoDoc.ActiveDoc settings = text_create.load_settings(doc) fonts = text_create.available_fonts() @@ -276,3 +366,71 @@ def open_with_frame(p1, p2, origin, width, height): topmost=True) if form is not None: bridge.set_form(form) + + +def open_for_edit(obj): + """Bei Doppelklick auf einen DOSSIER-Text: Editor mit den + bestehenden Settings + Text-Inhalt oeffnen. COMMIT macht REPLACE.""" + _ensure_double_click_hook() + doc = Rhino.RhinoDoc.ActiveDoc + if doc is None or obj is None: return + te = obj.Geometry + if not isinstance(te, rg.TextEntity): return + + # Settings aus dem bestehenden Text lesen + settings = text_create.load_settings(doc) + try: settings["size"] = float(te.TextHeight) + except Exception: pass + try: + if te.Font: + settings["font"] = te.Font.QuartetName + settings["bold"] = bool(te.Font.Bold) + settings["italic"] = bool(te.Font.Italic) + try: settings["underline"] = bool(te.Font.Underlined) + except Exception: pass + except Exception: pass + try: + h = te.TextHorizontalAlignment + if h == Rhino.DocObjects.TextHorizontalAlignment.Center: + settings["align"] = "center" + elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: + settings["align"] = "right" + else: settings["align"] = "left" + except Exception: pass + + initial_text = "" + try: initial_text = te.PlainText or "" + except Exception: pass + + # Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung) + p1 = te.Plane.Origin + try: + bb = te.GetBoundingBox(False) + if bb.IsValid: + p2 = rg.Point3d(bb.Max.X, bb.Min.Y, p1.Z) + origin = rg.Point3d(bb.Min.X, bb.Max.Y, p1.Z) + width = bb.Max.X - bb.Min.X + height = bb.Max.Y - bb.Min.Y + else: + p2 = p1 + origin = p1 + width = 1.0; height = 0.5 + except Exception: + p2 = p1; origin = p1; width = 1.0; height = 0.5 + + fonts = text_create.available_fonts() + bridge = TextEditorBridge( + (origin, width, height, p1, p2), + settings, fonts, + edit_obj_id=obj.Id, + initial_text=initial_text) + sc.sticky["text_editor_bridge"] = bridge + + form = panel_base.open_satellite_window( + "text_editor", + title="Dossier Text (Bearbeiten)", + size=(640, 480), + bridge=bridge, + topmost=True) + if form is not None: + bridge.set_form(form) diff --git a/src/TextEditorApp.jsx b/src/TextEditorApp.jsx index f7b066f..e0836b3 100644 --- a/src/TextEditorApp.jsx +++ b/src/TextEditorApp.jsx @@ -108,9 +108,6 @@ function htmlToRuns(rootEl) { // 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 @@ -118,6 +115,14 @@ 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 + } // Legacy Element von execCommand if (tag === 'font') { const c = node.getAttribute('color'); if (c) nc.color = c @@ -130,8 +135,10 @@ function htmlToRuns(rootEl) { flush('\n', ctx) } } + const basePx = parseFloat(window.getComputedStyle(rootEl).fontSize) || 14 const baseCtx = { font: null, color: null, bold: false, italic: false, - underline: false, sup: false, sub: false } + underline: false, sup: false, sub: false, + fontSizePx: null, _basePx: basePx } for (const child of rootEl.childNodes) walk(child, baseCtx) // Trailing-Newline weg if (runs.length && runs[runs.length-1].text === '\n') runs.pop() @@ -221,8 +228,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) - // Editor-Default-Font setzen - setTimeout(() => editorRef.current?.focus(), 100) + // Bei Edit-Mode: bestehenden Text in den Editor laden + const initialText = data.initialText || '' + setTimeout(() => { + if (editorRef.current) { + if (initialText) { + editorRef.current.innerText = initialText + } + editorRef.current.focus() + } + }, 100) }) notifyReady() }, [])