diff --git a/rhino/text_editor.py b/rhino/text_editor.py index 167e1da..245cd6c 100644 --- a/rhino/text_editor.py +++ b/rhino/text_editor.py @@ -54,17 +54,40 @@ class TextEditorBridge(panel_base.BaseBridge): 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() - te.Plane = rg.Plane(origin, rg.Vector3d.ZAxis) - te.PlainText = text + # 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( @@ -82,6 +105,31 @@ class TextEditorBridge(panel_base.BaseBridge): 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: @@ -95,7 +143,8 @@ class TextEditorBridge(panel_base.BaseBridge): doc.Objects.AddText(te, attrs) doc.Views.Redraw() - # Defaults speichern (ohne color) + # 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"), @@ -108,6 +157,99 @@ class TextEditorBridge(panel_base.BaseBridge): 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. diff --git a/src/TextEditorApp.jsx b/src/TextEditorApp.jsx index e86b285..50767d3 100644 --- a/src/TextEditorApp.jsx +++ b/src/TextEditorApp.jsx @@ -2,13 +2,19 @@ import { useState, useEffect, useRef } from 'react' import Icon from './components/Icon' import { onMessage, notifyReady, send } from './lib/rhinoBridge' -const SYMBOLS = [ - '∅', 'Ø', '⌀', '°', '±', '×', '÷', - '²', '³', '½', '¼', '¾', '⅓', '⅔', - '≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ', - '←', '→', '↑', '↓', '↔', '↕', - '•', '·', '▪', '◆', '★', '☆', '✓', '✗', - '§', '¶', '©', '®', '™', +const SYMBOL_GROUPS = [ + { title: 'Mathematik / Architektur', + symbols: ['∅', 'Ø', '⌀', '°', '±', '×', '÷', '²', '³', '½', '¼', '¾', '⅓', '⅔', + '≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ'] }, + { title: 'Pfeile', + symbols: ['←', '→', '↑', '↓', '↔', '↕', '⇐', '⇒', '⇑', '⇓', '⇔'] }, + { title: 'Auszeichnung', + symbols: ['•', '·', '▪', '◆', '◇', '★', '☆', '✓', '✗', '§', '¶', '©', '®', '™'] }, +] +const FRAME_OPTIONS = [ + { value: 'none', label: 'Kein Rahmen' }, + { value: 'rect', label: 'Rechteck' }, + { value: 'capsule', label: 'Kapsel' }, ] const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00] @@ -79,6 +85,113 @@ function Dropdown({ value, onChange, options, width, title }) { ) } +// HTML im contentEditable → Format-Runs fuer Rhino RTF (Phase 2) +function htmlToRuns(rootEl) { + const runs = [] + function flush(text, ctx) { + if (text === '') return + runs.push({ text, ...ctx }) + } + function walk(node, ctx) { + if (node.nodeType === Node.TEXT_NODE) { + flush(node.textContent, ctx); return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + const tag = node.tagName.toLowerCase() + if (tag === 'br') { flush('\n', ctx); return } + const nc = { ...ctx } + if (tag === 'b' || tag === 'strong') nc.bold = true + if (tag === 'i' || tag === 'em') nc.italic = true + if (tag === 'u') nc.underline = true + if (tag === 'sup') nc.sup = true + if (tag === 'sub') nc.sub = true + // 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 + if (fw === 'bold' || parseInt(fw) >= 600) nc.bold = true + } + if (node.style.fontStyle === 'italic') nc.italic = true + if (node.style.textDecoration?.includes('underline')) nc.underline = true + // Legacy Element von execCommand + if (tag === 'font') { + const c = node.getAttribute('color'); if (c) nc.color = c + const f = node.getAttribute('face'); if (f) nc.font = f.split(',')[0].trim() + } + for (const child of node.childNodes) walk(child, nc) + // Block-Element bekommt Newline am Ende + if (tag === 'p' || tag === 'div') { + if (!runs.length || !runs[runs.length-1].text.endsWith('\n')) + flush('\n', ctx) + } + } + const baseCtx = { font: null, color: null, bold: false, italic: false, + underline: false, sup: false, sub: false } + for (const child of rootEl.childNodes) walk(child, baseCtx) + // Trailing-Newline weg + if (runs.length && runs[runs.length-1].text === '\n') runs.pop() + return runs +} + +function SymbolPopover({ open, onClose, onPick }) { + if (!open) return null + return ( + <> +
+
+ {SYMBOL_GROUPS.map((grp, gi) => ( +
+ + {grp.title} + +
+ {grp.symbols.map(s => ( + + ))} +
+
+ ))} +
+ + ) +} + export default function TextEditorApp() { const [fonts, setFonts] = useState([]) const [font, setFont] = useState('Helvetica') @@ -88,6 +201,12 @@ export default function TextEditorApp() { const [underline, setUnderline] = useState(false) const [align, setAlign] = useState('left') const [color, setColor] = useState(null) // [r,g,b] oder null + // Neue Options + const [frame, setFrame] = useState('none') // none | rect | capsule + const [horizontalToView, setHorizontalToView] = useState(false) + const [rotation, setRotation] = useState(0) + const [maskMargin, setMaskMargin] = useState(0) + const [symbolsOpen, setSymbolsOpen] = useState(false) const editorRef = useRef(null) useEffect(() => { @@ -134,11 +253,20 @@ export default function TextEditorApp() { const clearColor = () => { setColor(null); exec('foreColor', '#000000') } const onCommit = () => { - const text = editorRef.current?.innerText || '' + const el = editorRef.current + 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) } send('COMMIT', { text, - settings: { font, size, bold, italic, underline, align, color }, + runs, + settings: { + font, size, bold, italic, underline, align, color, + frame, horizontalToView, rotation, maskMargin, + }, }) } const onCancel = () => send('CANCEL', {}) @@ -208,34 +336,65 @@ export default function TextEditorApp() { x₂
- {/* Symbol-Palette */} -
- - Sonderzeichen - -
- {SYMBOLS.map(s => ( - - ))} + {/* Toolbar Row 3: Frame / Rotation / Mask / Horizontal-to-view / Symbole */} +
+ ( + + ))} /> +
+ Mask + setMaskMargin(parseFloat(e.target.value) || 0)} + style={{ + width: 50, background: 'transparent', border: 'none', outline: 'none', + color: 'var(--text-primary)', fontSize: 11, + fontFamily: 'DM Mono, monospace', padding: 0, textAlign: 'right', + appearance: 'auto', + }} /> + m +
+
+ Rot + setRotation(parseFloat(e.target.value) || 0)} + style={{ + width: 50, background: 'transparent', border: 'none', outline: 'none', + color: 'var(--text-primary)', fontSize: 11, + fontFamily: 'DM Mono, monospace', padding: 0, textAlign: 'right', + appearance: 'auto', + }} /> + ° +
+ setHorizontalToView(b => !b)} + title="Text steht immer zur Kamera (DrawForward)"> + + Zur Kamera + +
+ setSymbolsOpen(o => !o)} + active={symbolsOpen} + title="Sonderzeichen einfügen"> + + Symbole + + setSymbolsOpen(false)} + onPick={(s) => insertText(s)} />