Files
DOSSIER/rhino/text_editor.py
T
karim 51987dcc38 Text-Editor: Selection-Preservation + per-Span Font/Size
User-Bug: Stile aendern nichts im Editor oder springen alle in eine
Zeile. Mit "Herumfummeln" partiell ge-fixed. Root-Causes:

1. Toolbar-Buttons stehlen Focus aus Editor → Selection futsch →
   execCommand wirkt auf nichts. Fix: onMouseDown + preventDefault
   auf B/I/U/Sup/Sub/Align (Pill akzeptiert jetzt onMouseDown prop).

2. Editor-div hat fontFamily/fontSize aus React-State → ueberschreibt
   per-Span-Styles → alles sieht gleich aus. Fix: editor-div hat
   statische Defaults (Helvetica 20px), per-Selection Styles wirken
   ueber span-Wrapping (applyInlineStyleToSelection).

3. Newlines kollabieren (text springt auf eine Zeile). Fix:
   white-space: pre-wrap auf editor-div.

4. Font/Size dropdowns: alter execCommand fontName war buggy. Neu:
   applyInlineStyleToSelection('font-family', font) bzw. 'font-size'
   wickelt die Selektion in ein <span style="..."> ein, neue Selection
   liegt auf dem Span (Folge-Operationen wirken sauber).

5. Selection-change Event-Listener speichert die letzte Editor-Selection
   in savedRangeRef. restoreSelection() vor jeder Operation stellt sie
   wieder her — robust auch wenn der Focus zwischendurch weg war.

Backend (_runs_to_rtf): BASE_PX = base_size_m * 100 statt hardcoded 14.
Frontend rendert 1m = 100px, also entspricht base_size_m*100px dem
\\fs20 in RTF (= 1.0× TextEntity.TextHeight). _commit passes settings.
size mit, damit das Mapping stimmt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:54:25 +02:00

442 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! python 3
# -*- coding: utf-8 -*-
"""
text_editor.py
React-WYSIWYG-Editor in Satellite-WebView (Topmost). User picked Frame
in create_text(), dann oeffnet sich dieser Editor neben dem Frame.
TextEditorBridge haelt Frame-Daten + Settings, auf COMMIT erstellt es
die TextEntity und schliesst das Fenster.
"""
import os
import sys
import Rhino
import Rhino.Geometry as rg
import scriptcontext as sc
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
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,
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
def _on_ready(self):
doc = Rhino.RhinoDoc.ActiveDoc
styles = []
try: styles = text_create.list_styles(doc)
except Exception: pass
self.send("INIT", {
"settings": self._initial_settings,
"fonts": self._fonts,
"styles": styles,
"initialText": self._initial_text,
"editMode": bool(self._edit_obj_id),
})
def handle(self, data):
if not isinstance(data, dict): return
t = data.get("type", "")
p = data.get("payload") or {}
if t == "READY":
self._on_ready()
elif t == "COMMIT":
self._commit(p)
try: self._form_ref.Close()
except Exception: pass
elif t == "CANCEL":
try: self._form_ref.Close()
except Exception: pass
def _commit(self, payload):
import System
import math
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None or self._frame is None: return
text = (payload.get("text") or "").strip()
if not text: return
st = payload.get("settings") or {}
runs = payload.get("runs") # Phase 2: rich-text runs (oder None)
origin, width, height, _p1, _p2 = self._frame
try:
te = rg.TextEntity()
# Plane mit optionaler Rotation um Z
rot_deg = 0.0
try: rot_deg = float(st.get("rotation") or 0)
except Exception: pass
plane = rg.Plane(origin, rg.Vector3d.ZAxis)
if abs(rot_deg) > 1e-6:
try:
plane.Rotate(math.radians(rot_deg), rg.Vector3d.ZAxis, origin)
except Exception: pass
te.Plane = plane
# Rich-Text (Phase 2) wenn vorhanden + nicht-trivial, sonst Plain
rtf = _runs_to_rtf(
runs,
st.get("font") or "Helvetica",
base_size_m=float(st.get("size") or 0.2)) if runs else None
applied_rtf = False
if rtf:
try:
te.RichText = rtf
applied_rtf = True
except Exception as ex:
print("[TEXT-EDITOR] RichText set fail:", ex)
if not applied_rtf:
te.PlainText = text
try: te.TextHeight = float(st.get("size") or 0.2)
except Exception: pass
text_create._apply_font(
te,
st.get("font") or "Helvetica",
st.get("bold"), st.get("italic"),
st.get("underline"))
text_create._apply_align(te, st.get("align") or "left")
for attr in ("FormatWidth", "TextWidth", "MaskWidth"):
try:
setattr(te, attr, width); break
except Exception: pass
try: te.TextIsWrapped = True
except Exception:
try: te.TextWrap = True
except Exception: pass
# Frame um den Text + Mask-Margin
frame_kind = (st.get("frame") or "none").lower()
try:
MF = Rhino.DocObjects.TextMaskFrame
te.MaskFrame = (
MF.RectFrame if frame_kind == "rect" else
MF.CapsuleFrame if frame_kind == "capsule" else
MF.NoFrame
)
except Exception as ex:
print("[TEXT-EDITOR] frame:", ex)
try:
mask_m = float(st.get("maskMargin") or 0)
if mask_m > 0:
te.MaskEnabled = True
te.MaskOffset = mask_m
te.MaskUsesViewportColor = True
except Exception as ex:
print("[TEXT-EDITOR] mask:", ex)
# Horizontal-to-view (DrawForward = text steht immer zur Kamera)
try:
te.DrawForward = bool(st.get("horizontalToView"))
except Exception: pass
attrs = Rhino.DocObjects.ObjectAttributes()
col = st.get("color") # [r,g,b] oder None
if col is not None and len(col) >= 3:
try:
attrs.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject
attrs.ObjectColor = System.Drawing.Color.FromArgb(
int(col[0]), int(col[1]), int(col[2]))
except Exception as ex:
print("[TEXT-EDITOR] color:", ex)
attrs.SetUserString("dossier_text", "1")
# 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
# situativ, nicht zu uebernehmen)
text_create.save_settings(doc, {
"font": st.get("font"),
"size": st.get("size"),
"bold": st.get("bold"),
"italic": st.get("italic"),
"underline": st.get("underline"),
"align": st.get("align"),
})
except Exception as ex:
print("[TEXT-EDITOR] commit:", ex)
# ---------------------------------------------------------------------------
# Phase 2: Rich-Text-Runs (vom Frontend HTML-parsing) → Rhino-RTF.
def _parse_color_to_rgb(c):
"""CSS-Farbe ('rgb(r,g,b)' | '#rrggbb' | '#rgb') → (r,g,b) Ints 0-255."""
if not c: return (0, 0, 0)
c = c.strip()
if c.startswith("rgb"):
import re
m = re.search(r"(\d+)\D+(\d+)\D+(\d+)", c)
if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
if c.startswith("#"):
h = c.lstrip("#")
if len(h) == 3: h = "".join(ch * 2 for ch in h)
if len(h) == 6:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
return (0, 0, 0)
def _rtf_escape(s):
"""RTF-Escape: \\, {, }, sowie non-ASCII via \\u-Notation."""
out = []
for ch in s:
cp = ord(ch)
if ch == "\\": out.append("\\\\")
elif ch == "{": out.append("\\{")
elif ch == "}": out.append("\\}")
elif ch == "\n": out.append("\\par\n")
elif cp < 128: out.append(ch)
else:
# Signed 16-bit → Rhinos RTF erwartet das so
v = cp if cp < 0x8000 else cp - 0x10000
out.append("\\u{}?".format(v))
return "".join(out)
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."""
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
# Font-Tabelle + Color-Tabelle
fonts = [default_font]
colors = [] # nur explizit gesetzte (Index 1+)
def font_idx(f):
if not f: return 0
if f not in fonts: fonts.append(f)
return fonts.index(f)
def color_idx(c):
if not c: return 0
rgb = _parse_color_to_rgb(c)
if rgb in colors: return colors.index(rgb) + 1
colors.append(rgb)
return len(colors)
parts = ["{\\rtf1\\ansi\\deff0"]
parts.append("{\\fonttbl")
for i, f in enumerate(fonts):
parts.append("{{\\f{} {};}}".format(i, f))
parts.append("}")
if colors:
parts.append("{\\colortbl;")
for r, g, b in colors:
parts.append("\\red{}\\green{}\\blue{};".format(r, g, b))
parts.append("}")
parts.append("\\pard")
# Frontend rendert 1m = 100px; Standard-Run-Size ist base_size_m * 100
BASE_PX = max(1.0, base_size_m * 100.0)
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")
if run.get("sup"): codes.append("\\super")
elif run.get("sub"): codes.append("\\sub")
else: codes.append("\\nosupersub")
body = _rtf_escape(run.get("text") or "")
parts.append("{} {}".format("".join(codes), body))
parts.append("}")
return "".join(parts)
def open_with_frame(p1, p2, origin, width, height):
"""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()
bridge = TextEditorBridge((origin, width, height, p1, p2),
settings, fonts)
sc.sticky["text_editor_bridge"] = bridge
form = panel_base.open_satellite_window(
"text_editor",
title="Dossier Text",
size=(640, 480),
bridge=bridge,
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)