From ab0ecfbf14a9b3af709d132abd300822db556375 Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 21 May 2026 01:28:26 +0200 Subject: [PATCH] =?UTF-8?q?React=20WYSIWYG=20Text-Editor=20(Topmost=20Sate?= =?UTF-8?q?llite-WebView)=20=E2=80=94=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Wunsch: eigener WYSIWYG-Editor im React/Topbar-GUI-Stil. Topmost. Verschiedene Schriftarten/-Dicken sichtbar im Editor selbst. Neues Backend (rhino/text_editor.py): - TextEditorBridge mit Frame-Daten im Konstruktor, INIT-Push mit Settings + Font-Liste, COMMIT erstellt TextEntity, CANCEL schliesst - open_with_frame(p1, p2, origin, width, height): oeffnet Satellite- Window mit mode='text_editor' + topmost=True - panel_base.open_satellite_window: neuer Parameter topmost (default False) der form.Topmost setzt text_create.create_text: ruft jetzt text_editor.open_with_frame nach dem Frame-Pick. Eto-basierter _dossier_text_editor bleibt im Modul als Fallback aber wird nicht mehr verwendet. Neues Frontend (src/TextEditorApp.jsx, mode='text_editor'): - Layout im DOSSIER-Topbar-Stil (dunkle Pills, accent on hover) - Pill-Helper-Komponente fuer alle Toggle/Action-Buttons - Dropdown-Helper fuer Font + Size - Toolbar Row 1: Font-Dropdown | Size-Dropdown | Color-Picker | Layer-Reset - Toolbar Row 2: B/I/U mit Material-Icons | L/C/R Align | x²/x₂ Sup/Sub - Sonderzeichen-Palette: 41 Unicode-Symbole (Architektur/Math/Pfeile/ Auszeichnungen), Klick inserted am Cursor - WYSIWYG-Editor: contentEditable div mit fontFamily=ausgewaehlt, textAlign=ausgewaehlt — Format-Toolbar wirkt via document.execCommand (bold/italic/underline/justifyLeft/Center/Right/superscript/subscript/ fontName/foreColor) - "Einfuegen" sendet COMMIT mit text (innerText) + settings - "Abbrechen" CANCEL → Bridge schliesst Form Phase 1 Limitation: rendert in Rhino als PlainText mit den globalen Settings (font/size/bold/italic/align/color) — verschiedene Schriftarten INNERHALB des Texts sind im Editor sichtbar aber nicht im finalen Rhino-TextEntity. Phase 2: HTML → Rhino RichText/RTF Mapping. rhinoBridge.js: send() jetzt exportiert (war intern) damit TextEditorApp generisch COMMIT/CANCEL senden kann. Co-Authored-By: Claude Opus 4.7 --- rhino/panel_base.py | 5 +- rhino/text_create.py | 62 +-------- rhino/text_editor.py | 131 +++++++++++++++++++ src/TextEditorApp.jsx | 281 +++++++++++++++++++++++++++++++++++++++++ src/lib/rhinoBridge.js | 2 +- src/main.jsx | 2 + 6 files changed, 425 insertions(+), 58 deletions(-) create mode 100644 rhino/text_editor.py create mode 100644 src/TextEditorApp.jsx diff --git a/rhino/panel_base.py b/rhino/panel_base.py index c839bf0..f7f990f 100644 --- a/rhino/panel_base.py +++ b/rhino/panel_base.py @@ -367,7 +367,8 @@ def attach_webview(panel, bridge, mode): # --- Satelliten-Fenster (echtes Rhino-Fenster mit eingebetteter WebView) ---- def open_satellite_window(mode, params=None, title=None, size=(420, 560), - on_save=None, on_cancel=None, bridge=None): + on_save=None, on_cancel=None, bridge=None, + topmost=False): """Oeffnet ein echtes Rhino-Fenster (Eto.Form) mit eingebetteter WebView. Die WebView laedt die React-App mit dem gegebenen `mode` und `params`. @@ -389,7 +390,7 @@ def open_satellite_window(mode, params=None, title=None, size=(420, 560), form.ClientSize = drawing.Size(int(size[0]), int(size[1])) except Exception: pass form.Resizable = True - form.Topmost = False + form.Topmost = bool(topmost) wv = forms.WebView() diff --git a/rhino/text_create.py b/rhino/text_create.py index 0a22040..4a461c4 100644 --- a/rhino/text_create.py +++ b/rhino/text_create.py @@ -862,69 +862,21 @@ def read_selection_settings(doc): def create_text(): - """DOSSIER Custom Text-Workflow: + """DOSSIER Custom Text-Workflow (React WYSIWYG-Editor): 1. Frame ziehen (Live-Rechteck-Vorschau) - 2. _dossier_text_editor (eigener Editor mit Toolbar, Sonderzeichen, - Farbe, Sub/Superscript) oeffnet sich neben dem Frame - 3. TextEntity wird im Frame mit allen gewaehlten Settings erstellt - und mit UserString "dossier_text=1" getagged (fuer evtl. spaeteren - Double-Click-Hook auf unseren Editor) + 2. text_editor.open_with_frame oeffnet das React-WYSIWYG-Fenster + (Topmost, neben dem Frame). Editor handlet die Eingabe + erstellt + die TextEntity bei COMMIT. """ - import System doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return - settings = load_settings(doc) - fonts = available_fonts() frame = _pick_text_frame() if frame is None: return p1, p2, origin, width, height = frame - new_state = _dossier_text_editor(p1, p2, settings, fonts) - if not new_state: return - text = (new_state.get("text") or "").strip() - if not text: return - - # Defaults aus Editor uebernehmen (ohne color) - save_settings(doc, { - "font": new_state.get("font"), - "size": new_state.get("size"), - "bold": new_state.get("bold"), - "italic": new_state.get("italic"), - "underline": new_state.get("underline"), - "align": new_state.get("align"), - }) - try: - te = rg.TextEntity() - te.Plane = rg.Plane(origin, rg.Vector3d.ZAxis) - te.PlainText = text - try: te.TextHeight = float(new_state.get("size") or 0.2) - except Exception: pass - _apply_font(te, new_state.get("font") or "Helvetica", - new_state.get("bold"), new_state.get("italic"), - new_state.get("underline")) - _apply_align(te, new_state.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 - - # Object-Attribute: Farbe wenn explizit gesetzt + DOSSIER-Tag - attrs = Rhino.DocObjects.ObjectAttributes() - col = new_state.get("color") - if col is not None: - 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] color attr:", ex) - attrs.SetUserString("dossier_text", "1") - doc.Objects.AddText(te, attrs) - doc.Views.Redraw() + import text_editor + text_editor.open_with_frame(p1, p2, origin, width, height) except Exception as ex: - print("[TEXT] create:", ex) + print("[TEXT] open editor:", ex) diff --git a/rhino/text_editor.py b/rhino/text_editor.py new file mode 100644 index 0000000..167e1da --- /dev/null +++ b/rhino/text_editor.py @@ -0,0 +1,131 @@ +#! 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): + self.send("INIT", { + "settings": self._initial_settings, + "fonts": self._fonts, + }) + + 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 + 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 {} + origin, width, height, _p1, _p2 = self._frame + + try: + te = rg.TextEntity() + te.Plane = rg.Plane(origin, rg.Vector3d.ZAxis) + 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 + + 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) + 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) + + +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) diff --git a/src/TextEditorApp.jsx b/src/TextEditorApp.jsx new file mode 100644 index 0000000..e86b285 --- /dev/null +++ b/src/TextEditorApp.jsx @@ -0,0 +1,281 @@ +import { useState, useEffect, useRef } from 'react' +import Icon from './components/Icon' +import { onMessage, notifyReady, send } from './lib/rhinoBridge' + +const SYMBOLS = [ + '∅', 'Ø', '⌀', '°', '±', '×', '÷', + '²', '³', '½', '¼', '¾', '⅓', '⅔', + '≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ', + '←', '→', '↑', '↓', '↔', '↕', + '•', '·', '▪', '◆', '★', '☆', '✓', '✗', + '§', '¶', '©', '®', '™', +] +const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00] + +const BAR_H = 22 + +function Pill({ children, onClick, active, disabled, title, style }) { + return ( + + ) +} + +function Dropdown({ value, onChange, options, width, title }) { + return ( +
+ +
+ ) +} + +export default function TextEditorApp() { + const [fonts, setFonts] = useState([]) + const [font, setFont] = useState('Helvetica') + const [size, setSize] = useState(0.20) + const [bold, setBold] = useState(false) + const [italic, setItalic] = useState(false) + const [underline, setUnderline] = useState(false) + const [align, setAlign] = useState('left') + const [color, setColor] = useState(null) // [r,g,b] oder null + const editorRef = useRef(null) + + useEffect(() => { + onMessage('INIT', (data) => { + setFonts(data.fonts || []) + const s = data.settings || {} + if (s.font) setFont(s.font) + if (s.size != null) setSize(s.size) + if (s.bold != null) setBold(!!s.bold) + 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) + }) + notifyReady() + }, []) + + const exec = (cmd, value) => { + try { + document.execCommand(cmd, false, value) + editorRef.current?.focus() + } catch (e) { console.error('execCommand', cmd, e) } + } + const insertText = (s) => { + try { + document.execCommand('insertText', false, s) + editorRef.current?.focus() + } catch (e) { console.error('insertText', e) } + } + + const toggleBold = () => { setBold(b => !b); exec('bold') } + const toggleItalic = () => { setItalic(b => !b); exec('italic') } + const toggleUnderline = () => { setUnderline(b => !b); exec('underline') } + const doAlign = (a) => { setAlign(a) + exec(a === 'center' ? 'justifyCenter' : a === 'right' ? 'justifyRight' : 'justifyLeft') } + const doSup = () => exec('superscript') + const doSub = () => exec('subscript') + const onColorPick = (hex) => { + const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) + if (m) setColor([parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)]) + exec('foreColor', hex) + } + const clearColor = () => { setColor(null); exec('foreColor', '#000000') } + + const onCommit = () => { + const text = editorRef.current?.innerText || '' + if (!text.trim()) return + send('COMMIT', { + text, + settings: { font, size, bold, italic, underline, align, color }, + }) + } + const onCancel = () => send('CANCEL', {}) + + return ( +
+ {/* Toolbar Row 1: Font | Size | Color | Layer-Reset */} +
+ { setFont(v); exec('fontName', v) }} + width={150} title="Schrift" + options={ + fonts.length === 0 + ? + : fonts.map(f => ) + } /> + setSize(parseFloat(v))} + width={90} title="Texthöhe (m)" + options={SIZE_PRESETS.map(s => ( + + ))} + /> + + Layer +
+ + {/* Toolbar Row 2: B I U + Align + Sup/Sub */} +
+ + + + + + + + + +
+ doAlign('left')} title="Links"> + + + doAlign('center')} title="Mittig"> + + + doAlign('right')} title="Rechts"> + + +
+ + x₂ +
+ + {/* Symbol-Palette */} +
+ + Sonderzeichen + +
+ {SYMBOLS.map(s => ( + + ))} +
+
+ + {/* WYSIWYG Editor */} +
+ + {/* Bottom Buttons */} +
+ Abbrechen + +
+
+ ) +} diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index ea52bfa..a49e50b 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -23,7 +23,7 @@ function _asciiEscape(s) { '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4)) } -function send(type, payload = {}) { +export function send(type, payload = {}) { if (!window.RHINO_MODE) { console.log('[Bridge] →', type, payload); return } const json = _asciiEscape(JSON.stringify({ type, payload })) if (json.length <= CHUNK_SIZE) { diff --git a/src/main.jsx b/src/main.jsx index 2919b0b..c2af898 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -14,6 +14,7 @@ import OsmApp from './OsmApp.jsx' import KameraApp from './KameraApp.jsx' import MasseSettingsApp from './MasseSettingsApp.jsx' import AboutApp from './AboutApp.jsx' +import TextEditorApp from './TextEditorApp.jsx' import GestaltungApp from './GestaltungApp.jsx' import AusschnitteApp from './AusschnitteApp.jsx' import MassstabApp from './MassstabApp.jsx' @@ -46,6 +47,7 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp : mode === 'kamera' ? KameraApp : mode === 'masse_settings' ? MasseSettingsApp : mode === 'about' ? AboutApp + : mode === 'text_editor' ? TextEditorApp : App window.onerror = function (msg, src, line, col, err) {