e7a1753519
User-Wunsch: vorgespeicherte Stile (Heading, Paragraph Helvetica/Georgia) direkt im Editor anwendbar. Backend (text_create.py): - _DEFAULT_STYLES: 7 sinnvolle Architektur-Defaults — Titel (0.40m bold), Heading 1 (0.30m bold), Heading 2 (0.20m bold), Paragraph Helvetica (0.15m), Paragraph Georgia (0.15m Georgia), Notiz (0.10m italic), Bildlegende (0.08m italic) - list_styles: seedet die Defaults beim ersten Zugriff falls noch keine Styles im Doc existieren (analog mass_style) - Bestehende save_style/delete_style/apply_style funktionieren weiter Backend (text_editor.py): - INIT-Payload erweitert um styles[] (Liste aller verfuegbaren Stile mit id/name/font/size/bold/italic/underline/align) Frontend (TextEditorApp.jsx): - Neuer Stil-Picker als erstes Dropdown in Toolbar-Row 1 (150px) - Optionen: "— Stil wählen —" + alle verfuegbaren Stile - onChange: applyStyle(style) — setzt Toolbar-State + appliziert via execCommand auf die aktuelle Selektion im WYSIWYG-Editor (oder als Default fuer kommendes Tippen wenn keine Selektion) - queryCommandState-Check fuer Bold/Italic/Underline damit nur toggled wird wenn nicht schon im gewuenschten State Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
279 lines
9.9 KiB
Python
279 lines
9.9 KiB
Python
#! 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
|
|
|
|
|
|
class TextEditorBridge(panel_base.BaseBridge):
|
|
def __init__(self, frame_data, settings, fonts):
|
|
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
|
|
|
|
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,
|
|
})
|
|
|
|
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") 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")
|
|
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):
|
|
"""Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von
|
|
dicts mit Keys text/font/bold/italic/underline/sup/sub/color."""
|
|
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("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")
|
|
|
|
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))
|
|
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):
|
|
"""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.
|
|
"""
|
|
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)
|