Text-Editor: Rhino RTF-Dialekt + Round-Trip + Oberleiste-Sync
Lange Iteration mit dem Rhino TextEntity-RTF-Parser (siehe MEMORY:
rhino_textentity_rtf_limits.md). Finale Form:
- RTF-Body: per-Segment {\fN\cfN\b\i\ulnone seg}-Groups, \par
zwischen Groups als Linebreak, { }-Space-Group fuer Leerzeilen
(Rhino collapsed sonst aufeinanderfolgende \par). \fs (Font-Size)
ist NICHT unterstuetzt → eine Size pro TextEntity (global).
- htmlToRuns: emittiert \n VOR Block-Elementen wenn schon Content
davor — fixt nested <div>A<div>B</div></div> die sonst A+B ohne
Trenner als ein Run liefern.
- Round-Trip-Erhaltung: editor.innerHTML 1:1 als UserString
"dossier_text_html" persistiert, beim Reopen direkt gesetzt
(kein runsToHtml-Konvertieren das Zeilen verlieren kann).
- Oberleiste-Editing: in-place modify von obj.Geometry + Commit-
Changes statt Duplicate+Replace (Mac Rhino gibt False zurueck
bei RichText-Klonen). Plus _patch_rtf_b_i_ul: regex-flippt
\b/\b0, \i/\i0, \ul/\ulnone global in der RTF damit Bold/Italic/
Underline OFF in der Oberleiste auch wirklich auf DOSSIER-Texte
greift (per-Segment-Codes wuerden te.Font-Aenderung sonst
uebersteuern).
- Stil-ID am Text persistiert + von read_selection_settings
zurueckgelesen → Stil-Dropdown spiegelt Selektion.
- Editor neu: V-Align (Top/Middle/Bottom), Mask-Type (None/Viewport/
Solid) mit Farb-Picker, Case-Transform (upper/lower/capitalize/
invert), Masstaeblich-Toggle (AnnotationScalingEnabled),
Symbol-Popover, Frame-Optionen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+83
-22
@@ -188,6 +188,9 @@ def apply_style(doc, sid):
|
||||
# Defaults aus Style schreiben (ohne id/name)
|
||||
patch = {k: style[k] for k in style if k in _DEFAULTS}
|
||||
save_settings(doc, patch)
|
||||
# styleId mitschicken damit apply_settings_to_selection ihn als
|
||||
# UserString an die Texte haengt — fuer "Stil aktiv"-Anzeige
|
||||
patch["__style_id__"] = sid
|
||||
apply_settings_to_selection(doc, patch)
|
||||
|
||||
|
||||
@@ -707,6 +710,30 @@ def _pick_text_frame():
|
||||
return None
|
||||
|
||||
|
||||
def _patch_rtf_b_i_ul(rt, bold, italic, underline):
|
||||
"""Patcht alle Bold/Italic/Underline-Codes in der RTF auf den
|
||||
gewuenschten globalen State. Erhaelt aber per-Segment Font/Color/
|
||||
Sup/Sub.
|
||||
|
||||
Wird vom Oberleiste-Path benutzt: die te.Font-Aenderung greift bei
|
||||
DOSSIER-Texten nicht (RTF-per-Segment-Codes ueberschreiben sie).
|
||||
Indem wir die Codes selber auf den globalen Toggle setzen, wirken
|
||||
Bold/Italic/Underline OFF auch tatsaechlich auf den ganzen Text."""
|
||||
import re
|
||||
if not rt: return rt
|
||||
# Bold: \b oder \b0 als komplettes Token (nicht gefolgt von alpha/digit,
|
||||
# damit z.B. \bullet nicht versehentlich matched)
|
||||
pat_b = re.compile(r'\\b0?(?![a-zA-Z0-9])')
|
||||
rt = pat_b.sub(lambda m: '\\b' if bold else '\\b0', rt)
|
||||
# Italic
|
||||
pat_i = re.compile(r'\\i0?(?![a-zA-Z0-9])')
|
||||
rt = pat_i.sub(lambda m: '\\i' if italic else '\\i0', rt)
|
||||
# Underline: \ul (on) oder \ulnone (off) — nicht gefolgt von alpha
|
||||
pat_ul = re.compile(r'\\ul(?:none)?(?![a-zA-Z])')
|
||||
rt = pat_ul.sub(lambda m: '\\ul' if underline else '\\ulnone', rt)
|
||||
return rt
|
||||
|
||||
|
||||
def _apply_font(te, face, bold, italic, underline=False):
|
||||
"""Setzt Font auf TextEntity. Mehrere Konstruktor-Pfade fuer
|
||||
verschiedene RhinoCommon-Versionen:
|
||||
@@ -826,7 +853,6 @@ def apply_settings_to_selection(doc, patch):
|
||||
for obj in selected:
|
||||
try:
|
||||
old = obj.Geometry
|
||||
# Aktuelle Werte lesen (vor Modifikation)
|
||||
cur = old.Font
|
||||
try: cur_face = cur.QuartetName if cur else "Helvetica"
|
||||
except Exception: cur_face = "Helvetica"
|
||||
@@ -837,7 +863,6 @@ def apply_settings_to_selection(doc, patch):
|
||||
try: cur_underline = bool(cur.Underlined) if cur else False
|
||||
except Exception: cur_underline = False
|
||||
|
||||
# Neue Werte aus Patch + Fallback auf aktuell
|
||||
face = patch.get("font") or cur_face
|
||||
bold = patch["bold"] if "bold" in patch else cur_bold
|
||||
italic = patch["italic"] if "italic" in patch else cur_italic
|
||||
@@ -845,27 +870,57 @@ def apply_settings_to_selection(doc, patch):
|
||||
size = float(patch["size"]) if "size" in patch else float(old.TextHeight)
|
||||
align = patch["align"] if patch.get("align") in _ALIGNS else None
|
||||
|
||||
# FRESH TextEntity bauen statt Duplicate-Modify. Bypassed
|
||||
# Probleme wo te.Font-Setter wegen Rich-Text-Runs oder
|
||||
# DimensionStyle-Override nicht greift.
|
||||
te = rg.TextEntity()
|
||||
te.Plane = old.Plane
|
||||
try: te.PlainText = old.PlainText
|
||||
except Exception: pass
|
||||
te.TextHeight = size
|
||||
# DimensionStyle entkoppeln damit unser Font nicht von Style
|
||||
# ueberschrieben wird.
|
||||
try: te.DimensionStyleId = System.Guid.Empty
|
||||
except Exception: pass
|
||||
_apply_font(te, face, bool(bold), bool(italic), bool(underline))
|
||||
# Alignment: aus Patch oder vom alten Entity uebernehmen
|
||||
if align:
|
||||
_apply_align(te, align)
|
||||
else:
|
||||
try: te.TextHorizontalAlignment = old.TextHorizontalAlignment
|
||||
is_dossier = False
|
||||
try:
|
||||
is_dossier = obj.Attributes.GetUserString("dossier_text") == "1"
|
||||
except Exception: pass
|
||||
|
||||
doc.Objects.Replace(obj.Id, te)
|
||||
# IN-PLACE Modifikation der Live-Geometry (kein Duplicate,
|
||||
# keine fresh-Entity — Mac Rhino hat Probleme mit Replace
|
||||
# auf RichText-Entities die nicht aus dem Doc kommen).
|
||||
old.TextHeight = size
|
||||
_apply_font(old, face, bool(bold), bool(italic), bool(underline))
|
||||
if align:
|
||||
_apply_align(old, align)
|
||||
|
||||
# DOSSIER-Texte: die RTF hat per-Segment Codes (\b0, \i0,
|
||||
# \ulnone) die die te.Font-Aenderung uebersteuern. Wir
|
||||
# patchen die Codes global damit Bold/Italic/Underline OFF
|
||||
# auch wirklich greifen.
|
||||
if is_dossier:
|
||||
try:
|
||||
rt = old.RichText
|
||||
if rt:
|
||||
new_rt = _patch_rtf_b_i_ul(
|
||||
rt, bool(bold), bool(italic), bool(underline))
|
||||
if new_rt != rt:
|
||||
old.RichText = new_rt
|
||||
except Exception as ex:
|
||||
print("[TEXT] RTF patch fail:", ex)
|
||||
|
||||
# Style-ID am Text persistieren wenn ueber apply_style
|
||||
# appliziert (Oberleiste-Anzeige "Stil aktiv" bei Selektion)
|
||||
sid = patch.get("__style_id__")
|
||||
if sid:
|
||||
try: obj.Attributes.SetUserString("dossier_text_style_id", sid)
|
||||
except Exception: pass
|
||||
|
||||
# CommitChanges ist die RhinoObject-API um Aenderungen an der
|
||||
# in-place modifizierten Geometry persistent zu machen.
|
||||
try:
|
||||
ok = obj.CommitChanges()
|
||||
print("[TEXT] CommitChanges: {} (dossier={})".format(ok, is_dossier))
|
||||
except Exception as ex:
|
||||
print("[TEXT] CommitChanges fail:", ex)
|
||||
ok = False
|
||||
|
||||
# Falls CommitChanges nicht greift → Replace als Fallback
|
||||
if not ok:
|
||||
try:
|
||||
ok2 = doc.Objects.Replace(obj.Id, old)
|
||||
print("[TEXT] Replace fallback: {}".format(ok2))
|
||||
except Exception as ex:
|
||||
print("[TEXT] Replace fallback fail:", ex)
|
||||
n += 1
|
||||
except Exception as ex:
|
||||
print("[TEXT] apply selection:", ex)
|
||||
@@ -880,7 +935,8 @@ def read_selection_settings(doc):
|
||||
sel = _selected_text_objects(doc)
|
||||
if not sel: return None
|
||||
try:
|
||||
te = sel[0].Geometry
|
||||
obj = sel[0]
|
||||
te = obj.Geometry
|
||||
font = te.Font
|
||||
face = font.QuartetName if font else "Helvetica"
|
||||
bold = bool(font.Bold) if font else False
|
||||
@@ -893,6 +949,10 @@ def read_selection_settings(doc):
|
||||
if h == Rhino.DocObjects.TextHorizontalAlignment.Center: align = "center"
|
||||
elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: align = "right"
|
||||
except Exception: pass
|
||||
# Style-ID falls am Text gespeichert (= via apply_style appliziert)
|
||||
style_id = None
|
||||
try: style_id = obj.Attributes.GetUserString("dossier_text_style_id") or None
|
||||
except Exception: pass
|
||||
return {
|
||||
"font": face,
|
||||
"size": float(te.TextHeight),
|
||||
@@ -900,6 +960,7 @@ def read_selection_settings(doc):
|
||||
"italic": italic,
|
||||
"underline": underline,
|
||||
"align": align,
|
||||
"styleId": style_id,
|
||||
}
|
||||
except Exception as ex:
|
||||
print("[TEXT] read selection:", ex)
|
||||
|
||||
+60
-54
@@ -84,7 +84,8 @@ def _on_idle_check_pending_edit(sender, e):
|
||||
|
||||
class TextEditorBridge(panel_base.BaseBridge):
|
||||
def __init__(self, frame_data, settings, fonts,
|
||||
edit_obj_id=None, initial_text="", initial_runs=None):
|
||||
edit_obj_id=None, initial_text="", initial_runs=None,
|
||||
initial_html=None):
|
||||
panel_base.BaseBridge.__init__(self, "text_editor")
|
||||
self._frame = frame_data # (origin, width, height, p1, p2)
|
||||
self._initial_settings = settings
|
||||
@@ -93,6 +94,7 @@ class TextEditorBridge(panel_base.BaseBridge):
|
||||
self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt
|
||||
self._initial_text = initial_text
|
||||
self._initial_runs = initial_runs # rich-format-Runs falls vorhanden
|
||||
self._initial_html = initial_html # 1:1 Editor-HTML beim Reopen
|
||||
|
||||
def set_form(self, form):
|
||||
self._form_ref = form
|
||||
@@ -108,6 +110,7 @@ class TextEditorBridge(panel_base.BaseBridge):
|
||||
"styles": styles,
|
||||
"initialText": self._initial_text,
|
||||
"initialRuns": self._initial_runs,
|
||||
"initialHtml": self._initial_html,
|
||||
"editMode": bool(self._edit_obj_id),
|
||||
})
|
||||
|
||||
@@ -282,6 +285,10 @@ class TextEditorBridge(panel_base.BaseBridge):
|
||||
print("[TEXT-EDITOR] color:", ex)
|
||||
attrs.SetUserString("dossier_text", "1")
|
||||
attrs.SetUserString("dossier_text_scaled", "1" if scale_flag else "0")
|
||||
sid = st.get("styleId")
|
||||
if sid:
|
||||
try: attrs.SetUserString("dossier_text_style_id", sid)
|
||||
except Exception: pass
|
||||
# Runs als JSON persistieren — beim Re-Open kann der Editor
|
||||
# die ganze Struktur (Fonts/Sizes/Styles pro Segment) wieder
|
||||
# herstellen statt nur PlainText zu zeigen.
|
||||
@@ -291,6 +298,15 @@ class TextEditorBridge(panel_base.BaseBridge):
|
||||
attrs.SetUserString("dossier_text_runs", json.dumps(runs))
|
||||
except Exception as ex:
|
||||
print("[TEXT-EDITOR] runs persist:", ex)
|
||||
# Editor-innerHTML 1:1 persistieren — beim Reopen wird der
|
||||
# exakte Editor-Zustand wiederhergestellt, kein Round-Trip
|
||||
# ueber runs (was Zeilen zusammen ziehen kann).
|
||||
html = payload.get("html")
|
||||
if html:
|
||||
try:
|
||||
attrs.SetUserString("dossier_text_html", html)
|
||||
except Exception as ex:
|
||||
print("[TEXT-EDITOR] html persist:", ex)
|
||||
|
||||
# Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu
|
||||
if self._edit_obj_id is not None:
|
||||
@@ -366,17 +382,13 @@ 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."""
|
||||
also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF.
|
||||
|
||||
Wir emittieren IMMER RTF wenn Runs vorliegen — auch wenn die Runs
|
||||
auf den ersten Blick "trivial" aussehen. So bleibt das Format
|
||||
stabil ueber Re-Edits hinweg und es gibt keinen impliziten Fallback
|
||||
auf _apply_font (= alles auf eine Schrift)."""
|
||||
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
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# PASS 1: Runs verarbeiten + Fonts/Colors sammeln + RTF-Bodies bauen
|
||||
@@ -410,58 +422,40 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20):
|
||||
out.append("\\u{}?".format(v))
|
||||
return "".join(out)
|
||||
|
||||
# Ansatz: jedes Text-Segment in eigene {}-Group mit lokalen Codes.
|
||||
# Zwischen Segmenten (und ueber Run-Grenzen hinweg) \\par fuer
|
||||
# Paragraph-Breaks. So bleibt Per-Run-Formatting (Groups isolieren
|
||||
# State) UND Mehrzeiligkeit (\\par ist Rhinos echter Linebreak).
|
||||
body_parts = []
|
||||
pending_pars = 0 # Wieviele \\par muessen noch vor naechstem Segment
|
||||
# Rhinos TextEntity-RTF: per-Segment in {}-Group, \par zwischen
|
||||
# Groups als Linebreak. Diese Form hat in commit 3bd949e fuer
|
||||
# Newlines funktioniert. Fuer Leerzeilen: zusaetzliche {\par}-Group
|
||||
# zwischen den regulaeren Segmenten — leere Group mit eigenem \par
|
||||
# umgeht den Multi-\par-Collapse.
|
||||
lines = [[]]
|
||||
for run in runs:
|
||||
raw = run.get("text") or ""
|
||||
parts_in_run = raw.split("\n")
|
||||
for j, part in enumerate(parts_in_run):
|
||||
if j > 0:
|
||||
lines.append([])
|
||||
if part:
|
||||
lines[-1].append((run, part))
|
||||
|
||||
def _emit_group(run, seg):
|
||||
body_parts = []
|
||||
for li, line in enumerate(lines):
|
||||
if li > 0:
|
||||
body_parts.append("\\par ")
|
||||
if not line:
|
||||
# Leere Zeile: Space-Group damit der Paragraph Inhalt hat
|
||||
# (sonst collapsed Rhino zwei \par auf einen Linebreak).
|
||||
body_parts.append("{ }")
|
||||
continue
|
||||
for (run, seg) in line:
|
||||
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
|
||||
codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0")
|
||||
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))
|
||||
else:
|
||||
codes.append("\\fs20")
|
||||
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_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg)))
|
||||
|
||||
def _flush_pars(n):
|
||||
# 1 \\par = einfacher Zeilenumbruch. N>1 \\par hintereinander
|
||||
# wuerde Rhino zu einem einzigen Umbruch collapsen — fuer
|
||||
# jede zusaetzliche Leerzeile schieben wir einen Space-Group
|
||||
# dazwischen, damit der leere Paragraph Inhalt hat.
|
||||
if n <= 0: return
|
||||
body_parts.append("\\par ")
|
||||
for _ in range(n - 1):
|
||||
body_parts.append("{ }\\par ")
|
||||
|
||||
first_emitted = False
|
||||
for run in runs:
|
||||
raw = run.get("text") or ""
|
||||
segments = raw.split("\n")
|
||||
for i, seg in enumerate(segments):
|
||||
if i > 0:
|
||||
pending_pars += 1
|
||||
if seg:
|
||||
if first_emitted:
|
||||
_flush_pars(pending_pars)
|
||||
pending_pars = 0
|
||||
_emit_group(run, seg)
|
||||
first_emitted = True
|
||||
if first_emitted and pending_pars > 0:
|
||||
_flush_pars(pending_pars)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
@@ -530,6 +524,10 @@ def open_for_edit(obj):
|
||||
settings["align"] = "right"
|
||||
else: settings["align"] = "left"
|
||||
except Exception: pass
|
||||
try:
|
||||
sid = obj.Attributes.GetUserString("dossier_text_style_id")
|
||||
if sid: settings["styleId"] = sid
|
||||
except Exception: pass
|
||||
try:
|
||||
flag = obj.Attributes.GetUserString("dossier_text_scaled")
|
||||
if flag in ("0", "1"):
|
||||
@@ -573,6 +571,13 @@ def open_for_edit(obj):
|
||||
if rj: initial_runs = json.loads(rj)
|
||||
except Exception as ex:
|
||||
print("[TEXT-EDITOR] read runs:", ex)
|
||||
# Editor-innerHTML (Round-Trip-Konservierung): wenn vorhanden,
|
||||
# wird der Editor exakt mit diesem HTML geoeffnet
|
||||
initial_html = None
|
||||
try:
|
||||
h = obj.Attributes.GetUserString("dossier_text_html")
|
||||
if h: initial_html = h
|
||||
except Exception: pass
|
||||
|
||||
# Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung)
|
||||
p1 = te.Plane.Origin
|
||||
@@ -596,7 +601,8 @@ def open_for_edit(obj):
|
||||
settings, fonts,
|
||||
edit_obj_id=obj.Id,
|
||||
initial_text=initial_text,
|
||||
initial_runs=initial_runs)
|
||||
initial_runs=initial_runs,
|
||||
initial_html=initial_html)
|
||||
sc.sticky["text_editor_bridge"] = bridge
|
||||
|
||||
form = panel_base.open_satellite_window(
|
||||
|
||||
+62
-50
@@ -90,8 +90,10 @@ function Dropdown({ value, onChange, options, width, title }) {
|
||||
function htmlToRuns(rootEl) {
|
||||
const runs = []
|
||||
function flush(text, ctx) {
|
||||
if (text === '') return
|
||||
runs.push({ text, ...ctx })
|
||||
// ZWSP (U+200B) ist unser Style-Marker — nie in die Runs
|
||||
const cleaned = text.replace(//g, '')
|
||||
if (cleaned === '') return
|
||||
runs.push({ text: cleaned, ...ctx })
|
||||
}
|
||||
function walk(node, ctx) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
@@ -100,6 +102,14 @@ function htmlToRuns(rootEl) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (tag === 'br') { flush('\n', ctx); return }
|
||||
// Block-Elemente: \n VOR dem Element wenn schon Content davor ist.
|
||||
// Fix fuer verschachtelte divs (<div>A<div>B</div></div>) — sonst
|
||||
// sieht End-of-Outer das \n von Inner und ueberspringt, A und B
|
||||
// landen ohne Trenner in den Runs.
|
||||
const isBlock = (tag === 'div' || tag === 'p')
|
||||
if (isBlock && runs.length > 0 && !runs[runs.length-1].text.endsWith('\n')) {
|
||||
flush('\n', ctx)
|
||||
}
|
||||
const nc = { ...ctx }
|
||||
if (tag === 'b' || tag === 'strong') nc.bold = true
|
||||
if (tag === 'i' || tag === 'em') nc.italic = true
|
||||
@@ -116,14 +126,8 @@ 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
|
||||
}
|
||||
// Font-Size wird NICHT gelesen — Rhinos TextEntity-RTF unterstuetzt
|
||||
// \fs nicht. Es gibt nur EINE Size pro TextEntity (te.TextHeight).
|
||||
// Legacy <font> Element von execCommand
|
||||
if (tag === 'font') {
|
||||
const c = node.getAttribute('color'); if (c) nc.color = c
|
||||
@@ -152,32 +156,35 @@ function htmlToRuns(rootEl) {
|
||||
function runsToHtml(runs) {
|
||||
if (!Array.isArray(runs)) return ''
|
||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const out = []
|
||||
// Erst in Zeilen flatten (gleiche Logik wie das Python-Backend)
|
||||
const lines = [[]]
|
||||
for (const r of runs) {
|
||||
const raw = r.text || ''
|
||||
if (!raw) continue
|
||||
// Per Newline splitten — jede non-leere Section bekommt eigene span
|
||||
const segs = raw.split('\n')
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
if (i > 0) out.push('<br>')
|
||||
const seg = segs[i]
|
||||
if (!seg) continue
|
||||
if (i > 0) lines.push([])
|
||||
if (segs[i]) lines[lines.length-1].push({ ...r, text: segs[i] })
|
||||
}
|
||||
}
|
||||
// Jede Zeile als <div>; leere Zeilen als <div><br></div> (sonst
|
||||
// collapsed contentEditable die Leerzeile visuell)
|
||||
return lines.map(line => {
|
||||
if (line.length === 0) return '<div><br></div>'
|
||||
const inner = line.map(seg => {
|
||||
const styles = []
|
||||
if (r.font) styles.push(`font-family: ${r.font}`)
|
||||
if (r.fontSizePx) styles.push(`font-size: ${r.fontSizePx}px`)
|
||||
if (r.color) styles.push(`color: ${r.color}`)
|
||||
if (seg.font) styles.push(`font-family: ${seg.font}`)
|
||||
if (seg.color) styles.push(`color: ${seg.color}`)
|
||||
let opens = '', closes = ''
|
||||
if (r.bold) { opens += '<b>'; closes = '</b>' + closes }
|
||||
if (r.italic) { opens += '<i>'; closes = '</i>' + closes }
|
||||
if (r.underline) { opens += '<u>'; closes = '</u>' + closes }
|
||||
if (r.sup) { opens += '<sup>'; closes = '</sup>' + closes }
|
||||
else if (r.sub) { opens += '<sub>'; closes = '</sub>' + closes }
|
||||
const inner = opens + escape(seg) + closes
|
||||
if (styles.length) out.push(`<span style="${styles.join('; ')}">${inner}</span>`)
|
||||
else out.push(inner)
|
||||
}
|
||||
}
|
||||
return out.join('')
|
||||
if (seg.bold) { opens += '<b>'; closes = '</b>' + closes }
|
||||
if (seg.italic) { opens += '<i>'; closes = '</i>' + closes }
|
||||
if (seg.underline) { opens += '<u>'; closes = '</u>' + closes }
|
||||
if (seg.sup) { opens += '<sup>'; closes = '</sup>' + closes }
|
||||
else if (seg.sub) { opens += '<sub>'; closes = '</sub>' + closes }
|
||||
const text = opens + escape(seg.text) + closes
|
||||
return styles.length ? `<span style="${styles.join('; ')}">${text}</span>` : text
|
||||
}).join('')
|
||||
return `<div>${inner}</div>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function SymbolPopover({ open, onClose, onPick }) {
|
||||
@@ -254,6 +261,7 @@ export default function TextEditorApp() {
|
||||
const [maskMargin, setMaskMargin] = useState(0)
|
||||
const [symbolsOpen, setSymbolsOpen] = useState(false)
|
||||
const [styles, setStyles] = useState([])
|
||||
const [activeStyleId, setActiveStyleId] = useState('')
|
||||
const editorRef = useRef(null)
|
||||
const savedRangeRef = useRef(null)
|
||||
|
||||
@@ -294,7 +302,10 @@ export default function TextEditorApp() {
|
||||
try { sel.addRange(savedRangeRef.current) } catch (e) {}
|
||||
}
|
||||
|
||||
// Wrap aktuelle Selektion in ein <span> mit gegebenem CSS-Property
|
||||
// Wrap aktuelle Selektion in ein <span> mit gegebenem CSS-Property.
|
||||
// No-op bei leerer Selektion — das ist Absicht: Auto-Marker-Spans
|
||||
// wandern zu unvorhergesehenen Positionen wenn der User auf andere
|
||||
// Stellen klickt, Stile gehen dabei verloren.
|
||||
const applyInlineStyleToSelection = (styleProp, value) => {
|
||||
restoreSelection()
|
||||
const sel = window.getSelection()
|
||||
@@ -307,12 +318,10 @@ export default function TextEditorApp() {
|
||||
const contents = range.extractContents()
|
||||
span.appendChild(contents)
|
||||
range.insertNode(span)
|
||||
// Neue Selektion auf das eingefuegte Span legen
|
||||
const newRange = document.createRange()
|
||||
newRange.selectNodeContents(span)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(newRange)
|
||||
// Saved-Range updaten damit Folge-Operationen wirken
|
||||
savedRangeRef.current = newRange.cloneRange()
|
||||
} catch (e) { console.error('applyInlineStyle', e) }
|
||||
}
|
||||
@@ -332,14 +341,19 @@ export default function TextEditorApp() {
|
||||
if (s.scaleWithModel != null) setScaleWithModel(!!s.scaleWithModel)
|
||||
if (s.maskType) setMaskType(s.maskType)
|
||||
if (Array.isArray(s.maskColor)) setMaskColor(s.maskColor)
|
||||
if (s.styleId) setActiveStyleId(s.styleId)
|
||||
// Bei Edit-Mode: bestehenden Text in den Editor laden. Wenn Runs
|
||||
// persistiert sind (= reicher Format-Stand vom letzten Save),
|
||||
// diese als HTML laden — sonst PlainText fallback.
|
||||
const initialText = data.initialText || ''
|
||||
const initialRuns = data.initialRuns
|
||||
const initialHtml = data.initialHtml
|
||||
setTimeout(() => {
|
||||
if (editorRef.current) {
|
||||
if (initialRuns && initialRuns.length > 0) {
|
||||
if (initialHtml) {
|
||||
// Bevorzugt: Editor-Innen-HTML 1:1 wiederherstellen
|
||||
editorRef.current.innerHTML = initialHtml
|
||||
} else if (initialRuns && initialRuns.length > 0) {
|
||||
editorRef.current.innerHTML = runsToHtml(initialRuns)
|
||||
} else if (initialText) {
|
||||
editorRef.current.innerText = initialText
|
||||
@@ -387,27 +401,21 @@ export default function TextEditorApp() {
|
||||
// kommende Tippen).
|
||||
const applyStyle = (style) => {
|
||||
if (!style) return
|
||||
// Toolbar-State synchronisieren
|
||||
// Stil setzt globale Toolbar-Defaults (Font + Size gelten fuer
|
||||
// den GANZEN Text, weil Rhino nur eine Size pro Entity kann).
|
||||
// Auf die Selektion wirken nur Font/Bold/Italic/Underline.
|
||||
setActiveStyleId(style.id || '')
|
||||
if (style.font) setFont(style.font)
|
||||
if (style.size != null) setSize(style.size)
|
||||
setBold(!!style.bold)
|
||||
setItalic(!!style.italic)
|
||||
setUnderline(!!style.underline)
|
||||
if (style.align) setAlign(style.align)
|
||||
// Selection wiederherstellen + Editor-Focus damit execCommand greift.
|
||||
editorRef.current?.focus()
|
||||
restoreSelection()
|
||||
try {
|
||||
document.execCommand('styleWithCSS', false, true)
|
||||
if (style.font) document.execCommand('fontName', false, style.font)
|
||||
// Size auf Selektion via Inline-Span (execCommand fontSize macht
|
||||
// nur 1-7 Scale, kein px)
|
||||
const sel = window.getSelection()
|
||||
const hasSel = sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed
|
||||
if (style.size != null && hasSel) {
|
||||
applyInlineStyleToSelection('font-size', sizeToPx(style.size) + 'px')
|
||||
}
|
||||
// Bold/Italic/Underline togglen wenn nicht schon im Wunsch-State
|
||||
const wantBold = !!style.bold
|
||||
const wantItal = !!style.italic
|
||||
const wantUnd = !!style.underline
|
||||
@@ -461,16 +469,21 @@ export default function TextEditorApp() {
|
||||
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) }
|
||||
// Editor-HTML 1:1 mitschicken — beim Reopen wird der genau gleiche
|
||||
// Editor-Zustand wiederhergestellt (kein runsToHtml mehr, das Lines
|
||||
// zusammenpurzeln kann)
|
||||
const html = el.innerHTML
|
||||
send('COMMIT', {
|
||||
text,
|
||||
runs,
|
||||
html,
|
||||
settings: {
|
||||
font, size, bold, italic, underline, align, valign, color,
|
||||
frame, horizontalToView, rotation, scaleWithModel,
|
||||
maskType, maskColor, maskMargin,
|
||||
styleId: activeStyleId || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -486,10 +499,11 @@ export default function TextEditorApp() {
|
||||
}}>
|
||||
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Dropdown value=""
|
||||
<Dropdown value={activeStyleId}
|
||||
onChange={(v) => {
|
||||
const st = styles.find(s => s.id === v)
|
||||
if (st) applyStyle(st)
|
||||
else setActiveStyleId('')
|
||||
}}
|
||||
width={150}
|
||||
title="Text-Stil anwenden (auf Selektion oder als Default fuer kommendes Tippen)"
|
||||
@@ -516,11 +530,9 @@ export default function TextEditorApp() {
|
||||
onChange={(v) => {
|
||||
const n = parseFloat(v)
|
||||
setSize(n)
|
||||
// Auf Selektion applizieren via span-Wrap
|
||||
applyInlineStyleToSelection('font-size', sizeToPx(n) + 'px')
|
||||
editorRef.current?.focus()
|
||||
}}
|
||||
width={90} title="Texthöhe (m) — wenn nichts ausgewählt: gilt für ganzen Text"
|
||||
width={90} title="Texthöhe (m) — gilt für den ganzen Text (Rhino unterstützt keine per-Segment Sizes)"
|
||||
options={SIZE_PRESETS.map(s => (
|
||||
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
|
||||
))}
|
||||
@@ -697,7 +709,7 @@ export default function TextEditorApp() {
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'Helvetica, sans-serif',
|
||||
fontSize: 20, lineHeight: 1.5,
|
||||
fontSize: sizeToPx(size), lineHeight: 1.3,
|
||||
outline: 'none',
|
||||
overflowY: 'auto',
|
||||
textAlign: align,
|
||||
|
||||
Reference in New Issue
Block a user