Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5bcee2157 | |||
| 26c7d9e67d | |||
| ae80185064 | |||
| 6b3421e7af | |||
| de6f84346c | |||
| 29699b5eda | |||
| 5abf1c0137 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Rhino-8 Plugin für architektonisches Entwerfen mit smarten Bauteilen — Geschosse, Wände, Decken, Dächer, Öffnungen (Fenster/Türen), Treppen (gerade · L · Wendel). Teil der **OpenStudio-Suite** (mit Rapport als Schwestertool).
|
Rhino-8 Plugin für architektonisches Entwerfen mit smarten Bauteilen — Geschosse, Wände, Decken, Dächer, Öffnungen (Fenster/Türen), Treppen (gerade · L · Wendel). Teil der **OpenStudio-Suite** (mit Rapport als Schwestertool).
|
||||||
|
|
||||||
Die React-UI wird in Rhinos Eto.Forms-WebView über `LoadHtml` (inline) eingebettet — die Plugin-Logik läuft in IronPython3 in Rhino 8 (Mac).
|
Die React-UI wird in Rhinos Eto.Forms-WebView über `LoadHtml` (inline) eingebettet — die Plugin-Logik läuft in **CPython 3.9** (Rhino 8 Script-Editor-Engine, Mac).
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Die React-UI wird in Rhinos Eto.Forms-WebView über `LoadHtml` (inline) eingebet
|
|||||||
| Rhino | 8 (Mac · Windows untestet) |
|
| Rhino | 8 (Mac · Windows untestet) |
|
||||||
| Node.js | ≥ 20 (für Vite 8) |
|
| Node.js | ≥ 20 (für Vite 8) |
|
||||||
| npm | ≥ 10 |
|
| npm | ≥ 10 |
|
||||||
| Python | IronPython 3 (in Rhino integriert) |
|
| Python | CPython 3.9 (Rhino 8 Script-Editor-Engine) |
|
||||||
|
|
||||||
Optional — für den Standalone-Launcher:
|
Optional — für den Standalone-Launcher:
|
||||||
|
|
||||||
@@ -105,7 +105,8 @@ for m in list(sys.modules):
|
|||||||
│ ├── MassstabApp.jsx Massstab/Display-Modes
|
│ ├── MassstabApp.jsx Massstab/Display-Modes
|
||||||
│ ├── DimensionenApp.jsx Objekt-Info (Position/Abmessungen)
|
│ ├── DimensionenApp.jsx Objekt-Info (Position/Abmessungen)
|
||||||
│ ├── OverridePanel.jsx Override-Regeln + Kombinationen
|
│ ├── OverridePanel.jsx Override-Regeln + Kombinationen
|
||||||
│ ├── components/ EbenenManager, GeschossManager, ...
|
│ ├── TextEditorApp.jsx DOSSIER-Text WYSIWYG-Editor (Rich-Text via RTF)
|
||||||
|
│ ├── components/ EbenenManager, GeschossManager, BarControls (shared Pill-UI), ...
|
||||||
│ └── lib/rhinoBridge.js React↔Python Bridge
|
│ └── lib/rhinoBridge.js React↔Python Bridge
|
||||||
├── rhino/ Backend (IronPython 3)
|
├── rhino/ Backend (IronPython 3)
|
||||||
│ ├── rhinopanel.py Haupt-Entry, Bridge-Pattern
|
│ ├── rhinopanel.py Haupt-Entry, Bridge-Pattern
|
||||||
@@ -118,6 +119,8 @@ for m in list(sys.modules):
|
|||||||
│ ├── dimensionen.py Objekt-Info Panel
|
│ ├── dimensionen.py Objekt-Info Panel
|
||||||
│ ├── gestaltung.py Gestaltung (Override-Editor)
|
│ ├── gestaltung.py Gestaltung (Override-Editor)
|
||||||
│ ├── werkzeuge.py Werkzeug-Sammlung
|
│ ├── werkzeuge.py Werkzeug-Sammlung
|
||||||
|
│ ├── text_editor.py DOSSIER-Text Backend (Frame-Pick + Rich-Text-RTF)
|
||||||
|
│ ├── text_create.py Text-Styles, Font-Apply, Selection-Settings
|
||||||
│ └── oberleiste.py Top-Menue (verbindet alle Panels)
|
│ └── oberleiste.py Top-Menue (verbindet alle Panels)
|
||||||
├── launcher/ Tauri-2 Standalone-Launcher (optional)
|
├── launcher/ Tauri-2 Standalone-Launcher (optional)
|
||||||
├── dist/ Gebaute React-App (npm run build)
|
├── dist/ Gebaute React-App (npm run build)
|
||||||
@@ -128,9 +131,10 @@ for m in list(sys.modules):
|
|||||||
|
|
||||||
## Bekannte Limitierungen
|
## Bekannte Limitierungen
|
||||||
|
|
||||||
- IronPython3-spezifisch: keine Umlaute in Source-Strings (`ue/oe/ae` statt `ü/ö/ä`); UTF-8-Header-Kommentar in allen `.py`-Files.
|
- **Python-Identifier ohne Umlaute** (`ue/oe/ae` statt `ü/ö/ä`) — UI-Strings dürfen Umlaute, Code-Bezeichner / Layer-Codes / UserString-Keys nicht. Konvention seit der Py3-Migration.
|
||||||
- **Kein Docking** der Panels (Rhinos `RegisterPanel` schlägt fehl: `"constructor must accept uint, RhinoDoc or no params"`). Panels laufen daher als schwebende `forms.Form`-Fenster.
|
- **Kein Docking** der Panels (Rhinos `RegisterPanel` schlägt fehl: `"constructor must accept uint, RhinoDoc or no params"`). Panels laufen daher als schwebende `forms.Form`-Fenster.
|
||||||
- **`LoadHtml`-inline** statt `file://`-URL — Rhinos WKWebView blockiert sonst `<script type="module">` durch CORS-Restrictions.
|
- **`LoadHtml`-inline** statt `file://`-URL — Rhinos WKWebView blockiert sonst `<script type="module">` durch CORS-Restrictions.
|
||||||
|
- **TextEntity-RTF**: Rhinos eingebauter Parser unterstützt nur `\b \i \ul \strike \fN \tab {}` plus Newline-via-`\par`-zwischen-Groups. **Kein `\fs`** (= eine TextEntity hat global eine Schriftgröße, keine per-Segment-Sizes). Newlines/Replace-Quirks siehe `_runs_to_rtf` in `rhino/text_editor.py`.
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
+99
-22
@@ -188,6 +188,9 @@ def apply_style(doc, sid):
|
|||||||
# Defaults aus Style schreiben (ohne id/name)
|
# Defaults aus Style schreiben (ohne id/name)
|
||||||
patch = {k: style[k] for k in style if k in _DEFAULTS}
|
patch = {k: style[k] for k in style if k in _DEFAULTS}
|
||||||
save_settings(doc, patch)
|
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)
|
apply_settings_to_selection(doc, patch)
|
||||||
|
|
||||||
|
|
||||||
@@ -707,6 +710,30 @@ def _pick_text_frame():
|
|||||||
return None
|
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):
|
def _apply_font(te, face, bold, italic, underline=False):
|
||||||
"""Setzt Font auf TextEntity. Mehrere Konstruktor-Pfade fuer
|
"""Setzt Font auf TextEntity. Mehrere Konstruktor-Pfade fuer
|
||||||
verschiedene RhinoCommon-Versionen:
|
verschiedene RhinoCommon-Versionen:
|
||||||
@@ -799,6 +826,22 @@ def _apply_align(te, align):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_valign(te, valign):
|
||||||
|
"""Setzt TextVerticalAlignment (Top/Middle/Bottom)."""
|
||||||
|
try:
|
||||||
|
m = {
|
||||||
|
"top": Rhino.DocObjects.TextVerticalAlignment.Top,
|
||||||
|
"middle": Rhino.DocObjects.TextVerticalAlignment.Middle,
|
||||||
|
"bottom": Rhino.DocObjects.TextVerticalAlignment.Bottom,
|
||||||
|
}
|
||||||
|
if valign in m:
|
||||||
|
te.TextVerticalAlignment = m[valign]
|
||||||
|
return True
|
||||||
|
except Exception as ex:
|
||||||
|
print("[TEXT] apply valign:", ex)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def apply_settings_to_selection(doc, patch):
|
def apply_settings_to_selection(doc, patch):
|
||||||
"""Wendet font/size/bold/italic/align auf alle selektierten
|
"""Wendet font/size/bold/italic/align auf alle selektierten
|
||||||
TextEntities an. Returns Anzahl der geaenderten Objekte."""
|
TextEntities an. Returns Anzahl der geaenderten Objekte."""
|
||||||
@@ -810,7 +853,6 @@ def apply_settings_to_selection(doc, patch):
|
|||||||
for obj in selected:
|
for obj in selected:
|
||||||
try:
|
try:
|
||||||
old = obj.Geometry
|
old = obj.Geometry
|
||||||
# Aktuelle Werte lesen (vor Modifikation)
|
|
||||||
cur = old.Font
|
cur = old.Font
|
||||||
try: cur_face = cur.QuartetName if cur else "Helvetica"
|
try: cur_face = cur.QuartetName if cur else "Helvetica"
|
||||||
except Exception: cur_face = "Helvetica"
|
except Exception: cur_face = "Helvetica"
|
||||||
@@ -821,7 +863,6 @@ def apply_settings_to_selection(doc, patch):
|
|||||||
try: cur_underline = bool(cur.Underlined) if cur else False
|
try: cur_underline = bool(cur.Underlined) if cur else False
|
||||||
except Exception: cur_underline = False
|
except Exception: cur_underline = False
|
||||||
|
|
||||||
# Neue Werte aus Patch + Fallback auf aktuell
|
|
||||||
face = patch.get("font") or cur_face
|
face = patch.get("font") or cur_face
|
||||||
bold = patch["bold"] if "bold" in patch else cur_bold
|
bold = patch["bold"] if "bold" in patch else cur_bold
|
||||||
italic = patch["italic"] if "italic" in patch else cur_italic
|
italic = patch["italic"] if "italic" in patch else cur_italic
|
||||||
@@ -829,27 +870,57 @@ def apply_settings_to_selection(doc, patch):
|
|||||||
size = float(patch["size"]) if "size" in patch else float(old.TextHeight)
|
size = float(patch["size"]) if "size" in patch else float(old.TextHeight)
|
||||||
align = patch["align"] if patch.get("align") in _ALIGNS else None
|
align = patch["align"] if patch.get("align") in _ALIGNS else None
|
||||||
|
|
||||||
# FRESH TextEntity bauen statt Duplicate-Modify. Bypassed
|
is_dossier = False
|
||||||
# Probleme wo te.Font-Setter wegen Rich-Text-Runs oder
|
try:
|
||||||
# DimensionStyle-Override nicht greift.
|
is_dossier = obj.Attributes.GetUserString("dossier_text") == "1"
|
||||||
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
|
|
||||||
except Exception: pass
|
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
|
n += 1
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT] apply selection:", ex)
|
print("[TEXT] apply selection:", ex)
|
||||||
@@ -864,7 +935,8 @@ def read_selection_settings(doc):
|
|||||||
sel = _selected_text_objects(doc)
|
sel = _selected_text_objects(doc)
|
||||||
if not sel: return None
|
if not sel: return None
|
||||||
try:
|
try:
|
||||||
te = sel[0].Geometry
|
obj = sel[0]
|
||||||
|
te = obj.Geometry
|
||||||
font = te.Font
|
font = te.Font
|
||||||
face = font.QuartetName if font else "Helvetica"
|
face = font.QuartetName if font else "Helvetica"
|
||||||
bold = bool(font.Bold) if font else False
|
bold = bool(font.Bold) if font else False
|
||||||
@@ -877,6 +949,10 @@ def read_selection_settings(doc):
|
|||||||
if h == Rhino.DocObjects.TextHorizontalAlignment.Center: align = "center"
|
if h == Rhino.DocObjects.TextHorizontalAlignment.Center: align = "center"
|
||||||
elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: align = "right"
|
elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: align = "right"
|
||||||
except Exception: pass
|
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 {
|
return {
|
||||||
"font": face,
|
"font": face,
|
||||||
"size": float(te.TextHeight),
|
"size": float(te.TextHeight),
|
||||||
@@ -884,6 +960,7 @@ def read_selection_settings(doc):
|
|||||||
"italic": italic,
|
"italic": italic,
|
||||||
"underline": underline,
|
"underline": underline,
|
||||||
"align": align,
|
"align": align,
|
||||||
|
"styleId": style_id,
|
||||||
}
|
}
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT] read selection:", ex)
|
print("[TEXT] read selection:", ex)
|
||||||
|
|||||||
+124
-33
@@ -84,7 +84,8 @@ def _on_idle_check_pending_edit(sender, e):
|
|||||||
|
|
||||||
class TextEditorBridge(panel_base.BaseBridge):
|
class TextEditorBridge(panel_base.BaseBridge):
|
||||||
def __init__(self, frame_data, settings, fonts,
|
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")
|
panel_base.BaseBridge.__init__(self, "text_editor")
|
||||||
self._frame = frame_data # (origin, width, height, p1, p2)
|
self._frame = frame_data # (origin, width, height, p1, p2)
|
||||||
self._initial_settings = settings
|
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._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt
|
||||||
self._initial_text = initial_text
|
self._initial_text = initial_text
|
||||||
self._initial_runs = initial_runs # rich-format-Runs falls vorhanden
|
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):
|
def set_form(self, form):
|
||||||
self._form_ref = form
|
self._form_ref = form
|
||||||
@@ -108,6 +110,7 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
"styles": styles,
|
"styles": styles,
|
||||||
"initialText": self._initial_text,
|
"initialText": self._initial_text,
|
||||||
"initialRuns": self._initial_runs,
|
"initialRuns": self._initial_runs,
|
||||||
|
"initialHtml": self._initial_html,
|
||||||
"editMode": bool(self._edit_obj_id),
|
"editMode": bool(self._edit_obj_id),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
try: te.TextHeight = float(st.get("size") or 0.2)
|
try: te.TextHeight = float(st.get("size") or 0.2)
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
text_create._apply_align(te, st.get("align") or "left")
|
text_create._apply_align(te, st.get("align") or "left")
|
||||||
|
text_create._apply_valign(te, st.get("valign") or "top")
|
||||||
|
|
||||||
# Content. Bei RichText KEIN _apply_font — sonst ueberschreibt
|
# Content. Bei RichText KEIN _apply_font — sonst ueberschreibt
|
||||||
# te.Font die per-Run-Fonts aus der RTF. Stattdessen lassen
|
# te.Font die per-Run-Fonts aus der RTF. Stattdessen lassen
|
||||||
@@ -225,11 +229,26 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT-EDITOR] frame:", ex)
|
print("[TEXT-EDITOR] frame:", ex)
|
||||||
|
# Mask: Type entscheidet ob/wie maskiert wird. Margin gilt
|
||||||
|
# nur wenn Maske aktiv. Solid-Color erst dann setzen wenn
|
||||||
|
# Type=solid (sonst dominiert Viewport-Color).
|
||||||
try:
|
try:
|
||||||
|
mask_type = (st.get("maskType") or "none").lower()
|
||||||
mask_m = float(st.get("maskMargin") or 0)
|
mask_m = float(st.get("maskMargin") or 0)
|
||||||
if mask_m > 0:
|
if mask_type == "none":
|
||||||
|
te.MaskEnabled = False
|
||||||
|
else:
|
||||||
te.MaskEnabled = True
|
te.MaskEnabled = True
|
||||||
te.MaskOffset = mask_m
|
te.MaskOffset = mask_m
|
||||||
|
if mask_type == "solid":
|
||||||
|
te.MaskUsesViewportColor = False
|
||||||
|
mc = st.get("maskColor") or [255, 255, 255]
|
||||||
|
try:
|
||||||
|
te.MaskColor = System.Drawing.Color.FromArgb(
|
||||||
|
int(mc[0]), int(mc[1]), int(mc[2]))
|
||||||
|
except Exception as ex:
|
||||||
|
print("[TEXT-EDITOR] mask color:", ex)
|
||||||
|
else:
|
||||||
te.MaskUsesViewportColor = True
|
te.MaskUsesViewportColor = True
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT-EDITOR] mask:", ex)
|
print("[TEXT-EDITOR] mask:", ex)
|
||||||
@@ -239,6 +258,22 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
te.DrawForward = bool(st.get("horizontalToView"))
|
te.DrawForward = bool(st.get("horizontalToView"))
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
||||||
|
# 6. Annotation-Scaling (Masstaeblich) — Rhino 8 hat das pro
|
||||||
|
# Annotation-Objekt. Property-Name variiert je nach Build,
|
||||||
|
# deshalb mehrere Varianten versuchen.
|
||||||
|
scale_flag = bool(st.get("scaleWithModel", True))
|
||||||
|
applied_scale = None
|
||||||
|
for prop in ("AnnotationScalingEnabled",
|
||||||
|
"IsAnnotationScalingEnabled",
|
||||||
|
"ModelSpaceScalingEnabled"):
|
||||||
|
try:
|
||||||
|
setattr(te, prop, scale_flag)
|
||||||
|
applied_scale = prop
|
||||||
|
break
|
||||||
|
except Exception: pass
|
||||||
|
if applied_scale is None:
|
||||||
|
print("[TEXT-EDITOR] AnnotationScaling-Property nicht gefunden")
|
||||||
|
|
||||||
attrs = Rhino.DocObjects.ObjectAttributes()
|
attrs = Rhino.DocObjects.ObjectAttributes()
|
||||||
col = st.get("color") # [r,g,b] oder None
|
col = st.get("color") # [r,g,b] oder None
|
||||||
if col is not None and len(col) >= 3:
|
if col is not None and len(col) >= 3:
|
||||||
@@ -249,6 +284,11 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT-EDITOR] color:", ex)
|
print("[TEXT-EDITOR] color:", ex)
|
||||||
attrs.SetUserString("dossier_text", "1")
|
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
|
# Runs als JSON persistieren — beim Re-Open kann der Editor
|
||||||
# die ganze Struktur (Fonts/Sizes/Styles pro Segment) wieder
|
# die ganze Struktur (Fonts/Sizes/Styles pro Segment) wieder
|
||||||
# herstellen statt nur PlainText zu zeigen.
|
# herstellen statt nur PlainText zu zeigen.
|
||||||
@@ -258,6 +298,15 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
attrs.SetUserString("dossier_text_runs", json.dumps(runs))
|
attrs.SetUserString("dossier_text_runs", json.dumps(runs))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT-EDITOR] runs persist:", 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
|
# Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu
|
||||||
if self._edit_obj_id is not None:
|
if self._edit_obj_id is not None:
|
||||||
@@ -333,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
|
"""Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von
|
||||||
dicts mit Keys text/font/bold/italic/underline/sup/sub/color/fontSizePx.
|
dicts mit Keys text/font/bold/italic/underline/sup/sub/color/fontSizePx.
|
||||||
base_size_m: TextEntity.TextHeight (in m). Frontend rendert 1m = 100px,
|
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
|
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
|
# PASS 1: Runs verarbeiten + Fonts/Colors sammeln + RTF-Bodies bauen
|
||||||
@@ -377,36 +422,39 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20):
|
|||||||
out.append("\\u{}?".format(v))
|
out.append("\\u{}?".format(v))
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
body_parts = []
|
# 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:
|
for run in runs:
|
||||||
raw = run.get("text") or ""
|
raw = run.get("text") or ""
|
||||||
segments = raw.split("\n")
|
parts_in_run = raw.split("\n")
|
||||||
for i, seg in enumerate(segments):
|
for j, part in enumerate(parts_in_run):
|
||||||
if i > 0:
|
if j > 0:
|
||||||
body_parts.append("\\par\n")
|
lines.append([])
|
||||||
if not seg:
|
if part:
|
||||||
# Leere Section (aufeinanderfolgende \\n) → leerer
|
lines[-1].append((run, part))
|
||||||
# Paragraph mit einem Space, damit Rhinos Parser eine
|
|
||||||
# echte Leerzeile rendert
|
body_parts = []
|
||||||
body_parts.append(" ")
|
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
|
continue
|
||||||
|
for (run, seg) in line:
|
||||||
codes = []
|
codes = []
|
||||||
codes.append("\\f{}".format(font_idx(run.get("font") or default_font)))
|
codes.append("\\f{}".format(font_idx(run.get("font") or default_font)))
|
||||||
ci = color_idx(run.get("color")) if run.get("color") else 0
|
ci = color_idx(run.get("color")) if run.get("color") else 0
|
||||||
codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0")
|
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("\\b" if run.get("bold") else "\\b0")
|
||||||
codes.append("\\i" if run.get("italic") else "\\i0")
|
codes.append("\\i" if run.get("italic") else "\\i0")
|
||||||
codes.append("\\ul" if run.get("underline") else "\\ulnone")
|
codes.append("\\ul" if run.get("underline") else "\\ulnone")
|
||||||
if run.get("sup"): codes.append("\\super")
|
body_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg)))
|
||||||
elif run.get("sub"): codes.append("\\sub")
|
|
||||||
else: codes.append("\\nosupersub")
|
|
||||||
body_parts.append("{} {}".format("".join(codes), _escape_no_par(seg)))
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
# PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body
|
# PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body
|
||||||
@@ -476,6 +524,41 @@ def open_for_edit(obj):
|
|||||||
settings["align"] = "right"
|
settings["align"] = "right"
|
||||||
else: settings["align"] = "left"
|
else: settings["align"] = "left"
|
||||||
except Exception: pass
|
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"):
|
||||||
|
settings["scaleWithModel"] = (flag == "1")
|
||||||
|
else:
|
||||||
|
for prop in ("AnnotationScalingEnabled",
|
||||||
|
"IsAnnotationScalingEnabled",
|
||||||
|
"ModelSpaceScalingEnabled"):
|
||||||
|
if hasattr(te, prop):
|
||||||
|
settings["scaleWithModel"] = bool(getattr(te, prop))
|
||||||
|
break
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
v = te.TextVerticalAlignment
|
||||||
|
VA = Rhino.DocObjects.TextVerticalAlignment
|
||||||
|
if v == VA.Middle: settings["valign"] = "middle"
|
||||||
|
elif v == VA.Bottom: settings["valign"] = "bottom"
|
||||||
|
else: settings["valign"] = "top"
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
if te.MaskEnabled:
|
||||||
|
settings["maskType"] = "solid" if not te.MaskUsesViewportColor else "viewport"
|
||||||
|
try: settings["maskMargin"] = float(te.MaskOffset)
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
mc = te.MaskColor
|
||||||
|
settings["maskColor"] = [mc.R, mc.G, mc.B]
|
||||||
|
except Exception: pass
|
||||||
|
else:
|
||||||
|
settings["maskType"] = "none"
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
initial_text = ""
|
initial_text = ""
|
||||||
try: initial_text = te.PlainText or ""
|
try: initial_text = te.PlainText or ""
|
||||||
@@ -488,6 +571,13 @@ def open_for_edit(obj):
|
|||||||
if rj: initial_runs = json.loads(rj)
|
if rj: initial_runs = json.loads(rj)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print("[TEXT-EDITOR] read runs:", 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)
|
# Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung)
|
||||||
p1 = te.Plane.Origin
|
p1 = te.Plane.Origin
|
||||||
@@ -511,7 +601,8 @@ def open_for_edit(obj):
|
|||||||
settings, fonts,
|
settings, fonts,
|
||||||
edit_obj_id=obj.Id,
|
edit_obj_id=obj.Id,
|
||||||
initial_text=initial_text,
|
initial_text=initial_text,
|
||||||
initial_runs=initial_runs)
|
initial_runs=initial_runs,
|
||||||
|
initial_html=initial_html)
|
||||||
sc.sticky["text_editor_bridge"] = bridge
|
sc.sticky["text_editor_bridge"] = bridge
|
||||||
|
|
||||||
form = panel_base.open_satellite_window(
|
form = panel_base.open_satellite_window(
|
||||||
|
|||||||
+28
-36
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import Icon from './components/Icon'
|
import Icon from './components/Icon'
|
||||||
|
import { BarToggle, BarButton } from './components/BarControls'
|
||||||
import {
|
import {
|
||||||
onMessage, notifyReady,
|
onMessage, notifyReady,
|
||||||
setRefPoint, setCoordSystem,
|
setRefPoint, setCoordSystem,
|
||||||
@@ -101,24 +102,19 @@ function RefPointGrid({ ref, onChange }) {
|
|||||||
// Z-Referenz-Selektor (Bottom / Mid / Top) — kompakt, nur Icons.
|
// Z-Referenz-Selektor (Bottom / Mid / Top) — kompakt, nur Icons.
|
||||||
function RefZSelector({ z, onChange }) {
|
function RefZSelector({ z, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 2 }}>
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
{[
|
{[
|
||||||
{ code: 'max', icon: 'vertical_align_top', title: 'Z = Top' },
|
{ code: 'max', icon: 'vertical_align_top', title: 'Z = Top' },
|
||||||
{ code: 'mid', icon: 'vertical_align_center', title: 'Z = Mid' },
|
{ code: 'mid', icon: 'vertical_align_center', title: 'Z = Mid' },
|
||||||
{ code: 'min', icon: 'vertical_align_bottom', title: 'Z = Bottom' },
|
{ code: 'min', icon: 'vertical_align_bottom', title: 'Z = Bottom' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<button
|
<BarToggle
|
||||||
key={opt.code}
|
key={opt.code}
|
||||||
|
icon={opt.icon}
|
||||||
|
active={z === opt.code}
|
||||||
onClick={() => onChange(opt.code)}
|
onClick={() => onChange(opt.code)}
|
||||||
className={z === opt.code ? 'btn-contained' : 'btn-outlined'}
|
|
||||||
style={{
|
|
||||||
padding: '2px 5px', fontSize: 10,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
title={opt.title}
|
title={opt.title}
|
||||||
>
|
/>
|
||||||
<Icon name={opt.icon} size={12} />
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -203,19 +199,19 @@ export default function DimensionenApp() {
|
|||||||
}}>
|
}}>
|
||||||
<Icon name="select_all" size={14} style={{ color: 'var(--text-muted)' }} />
|
<Icon name="select_all" size={14} style={{ color: 'var(--text-muted)' }} />
|
||||||
<span style={{ flex: 1, fontWeight: 500 }}>{selLabel()}</span>
|
<span style={{ flex: 1, fontWeight: 500 }}>{selLabel()}</span>
|
||||||
<div style={{ display: 'flex', gap: 2 }}>
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
<button
|
<BarToggle
|
||||||
|
label="Welt"
|
||||||
|
active={state.coordSystem === 'world'}
|
||||||
onClick={() => onCoordChange('world')}
|
onClick={() => onCoordChange('world')}
|
||||||
className={state.coordSystem === 'world' ? 'btn-contained' : 'btn-outlined'}
|
|
||||||
style={{ fontSize: 10, padding: '3px 8px' }}
|
|
||||||
title="Weltkoordinaten"
|
title="Weltkoordinaten"
|
||||||
>Welt</button>
|
/>
|
||||||
<button
|
<BarToggle
|
||||||
|
label="CPlane"
|
||||||
|
active={state.coordSystem === 'cplane'}
|
||||||
onClick={() => onCoordChange('cplane')}
|
onClick={() => onCoordChange('cplane')}
|
||||||
className={state.coordSystem === 'cplane' ? 'btn-contained' : 'btn-outlined'}
|
|
||||||
style={{ fontSize: 10, padding: '3px 8px' }}
|
|
||||||
title="Aktive Konstruktionsebene"
|
title="Aktive Konstruktionsebene"
|
||||||
>CPlane</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,27 +317,23 @@ export default function DimensionenApp() {
|
|||||||
<div style={{ width: 56 }}>
|
<div style={{ width: 56 }}>
|
||||||
<NumInput value={rotationDelta} onCommit={setRotationDelta} suffix="°" />
|
<NumInput value={rotationDelta} onCommit={setRotationDelta} suffix="°" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<BarButton
|
||||||
className="btn-outlined"
|
icon="rotate_right"
|
||||||
onClick={() => { if (rotationDelta) setDimRotationZ(rotationDelta) }}
|
onClick={() => { if (rotationDelta) setDimRotationZ(rotationDelta) }}
|
||||||
disabled={!rotationDelta}
|
disabled={!rotationDelta}
|
||||||
title="Selektion um Z-Achse der aktiven Plane drehen"
|
title="Selektion um Z-Achse der aktiven Plane drehen"
|
||||||
style={{ padding: '3px 8px', fontSize: 11 }}
|
/>
|
||||||
>
|
|
||||||
<Icon name="rotate_right" size={13} />
|
|
||||||
</button>
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
{[-90, -45, 45, 90].map(a => (
|
<BarButton
|
||||||
<button
|
icon="rotate_90_degrees_ccw"
|
||||||
key={a}
|
onClick={() => setDimRotationZ(-90)}
|
||||||
className="btn-outlined"
|
title="90° gegen den Uhrzeigersinn"
|
||||||
onClick={() => setDimRotationZ(a)}
|
/>
|
||||||
style={{ padding: '3px 6px', fontSize: 9, minWidth: 28 }}
|
<BarButton
|
||||||
title={`${a}°`}
|
icon="rotate_90_degrees_cw"
|
||||||
>
|
onClick={() => setDimRotationZ(90)}
|
||||||
{a > 0 ? '+' : ''}{a}°
|
title="90° im Uhrzeigersinn"
|
||||||
</button>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+6
-130
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Icon from './components/Icon'
|
import Icon from './components/Icon'
|
||||||
|
import { BarCombo, BarButton, BAR_H } from './components/BarControls'
|
||||||
import {
|
import {
|
||||||
onMessage, notifyReady,
|
onMessage, notifyReady,
|
||||||
requestMassstab, setMassstab,
|
requestMassstab, setMassstab,
|
||||||
@@ -73,7 +74,6 @@ function parseScale(input) {
|
|||||||
// zwischen Icon-Kompartiment und Inhalt.
|
// zwischen Icon-Kompartiment und Inhalt.
|
||||||
|
|
||||||
const PILL_H = 20 // alte Pill-Hoehe (Buttons/Chips die nicht migriert sind)
|
const PILL_H = 20 // alte Pill-Hoehe (Buttons/Chips die nicht migriert sind)
|
||||||
const BAR_H = 22 // neue Widget-Hoehe (BarSelect, BarButton, BarGroup)
|
|
||||||
|
|
||||||
const sep = {
|
const sep = {
|
||||||
width: 1, height: 20,
|
width: 1, height: 20,
|
||||||
@@ -92,135 +92,9 @@ const pillSelect = {
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
// BarCombo: dunklerer (bg-input) Pill-Container der select + optional gear
|
// BarCombo + BarButton + BAR_H jetzt zentral in ./components/BarControls.jsx —
|
||||||
// als EINE nahtlose Box rendert. Icon roh links daneben (kein Container).
|
// werden auch in Ebenen/anderen Panels verwendet.
|
||||||
// iconClickable=true macht das Icon zum Toggle-Button (Overrides etc.).
|
|
||||||
// valueAccent=true faerbt den Select-Text accent (fuer Massstab "gesetzt").
|
|
||||||
function BarCombo({
|
|
||||||
icon, iconActive, iconClickable, onIconClick, iconTitle,
|
|
||||||
value, onChange, width, title, children, disabled,
|
|
||||||
onGear, gearTitle, valueAccent,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
||||||
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{/* Icon links — fixe Breite fuer X-Axis-Alignment zwischen Reihen.
|
|
||||||
Wenn icon=null/undefined wird kein Icon-Slot reserviert. */}
|
|
||||||
{icon && (iconClickable ? (
|
|
||||||
<button onClick={onIconClick} title={iconTitle}
|
|
||||||
style={{
|
|
||||||
width: 18, height: BAR_H,
|
|
||||||
background: 'transparent', border: 'none',
|
|
||||||
cursor: 'pointer', flexShrink: 0, padding: 0,
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<Icon name={icon} size={13}
|
|
||||||
style={{ color: iconActive ? 'var(--accent)' : 'var(--text-muted)' }} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span style={{
|
|
||||||
width: 18, height: BAR_H, flexShrink: 0,
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<Icon name={icon} size={13} style={{ color: 'var(--text-muted)' }} />
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{/* Combined pill: select + optional gear, gemeinsamer bg + border */}
|
|
||||||
<div title={title}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (disabled) return
|
|
||||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
|
||||||
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
|
||||||
e.currentTarget.style.background = 'var(--bg-input)'
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'stretch',
|
|
||||||
height: BAR_H + 2, width, boxSizing: 'border-box',
|
|
||||||
background: 'var(--bg-input)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 999,
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'border-color 0.15s, background 0.15s',
|
|
||||||
}}>
|
|
||||||
<select
|
|
||||||
value={value || ''}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
style={{
|
|
||||||
flex: 1, minWidth: 0,
|
|
||||||
background: 'transparent',
|
|
||||||
color: valueAccent ? 'var(--accent-light)' : 'var(--text-primary)',
|
|
||||||
border: 'none', outline: 'none',
|
|
||||||
padding: '0 22px 0 12px',
|
|
||||||
fontSize: 11, fontFamily: 'var(--font)',
|
|
||||||
fontWeight: valueAccent ? 600 : 500,
|
|
||||||
appearance: 'none', WebkitAppearance: 'none',
|
|
||||||
backgroundImage: 'var(--select-arrow)',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
// Caret-Position differenziert: ohne Gear normaler Abstand
|
|
||||||
// (10px vom Pill-Rand), mit Gear minimaler Abstand damit
|
|
||||||
// er an den Gear ranruckt.
|
|
||||||
backgroundPosition: onGear ? 'right 1px center' : 'right 10px center',
|
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
||||||
letterSpacing: 0,
|
|
||||||
}}
|
|
||||||
>{children}</select>
|
|
||||||
{onGear && (
|
|
||||||
<button onClick={onGear} title={gearTitle}
|
|
||||||
style={{
|
|
||||||
background: 'transparent', border: 'none',
|
|
||||||
padding: '0 8px', cursor: 'pointer',
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<Icon name="settings" size={12}
|
|
||||||
style={{ color: 'var(--text-muted)' }} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BarButton: pill-foermiger Icon-Button im selben Stil wie BarSelect.
|
|
||||||
// joinedLeft = linke Kante flach (dockt rechts an einen BarSelect-joinedRight).
|
|
||||||
function BarButton({ icon, onClick, title, disabled, active, joinedLeft }) {
|
|
||||||
return (
|
|
||||||
<button onClick={onClick} disabled={disabled} title={title}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (disabled || active) return
|
|
||||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
|
||||||
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (active) return
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
|
||||||
e.currentTarget.style.background = 'var(--bg-input)'
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: BAR_H, width: BAR_H,
|
|
||||||
background: active ? 'var(--accent)' : 'var(--bg-input)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderTopLeftRadius: joinedLeft ? 0 : 999,
|
|
||||||
borderBottomLeftRadius: joinedLeft ? 0 : 999,
|
|
||||||
borderTopRightRadius: 999, borderBottomRightRadius: 999,
|
|
||||||
borderLeft: joinedLeft ? 'none' : '1px solid var(--border)',
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
|
||||||
padding: 0,
|
|
||||||
transition: 'border-color 0.15s, background 0.15s',
|
|
||||||
}}>
|
|
||||||
<Icon name={icon} size={13}
|
|
||||||
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const pillInput = {
|
const pillInput = {
|
||||||
height: PILL_H, lineHeight: PILL_H + 'px',
|
height: PILL_H, lineHeight: PILL_H + 'px',
|
||||||
padding: '0 8px', boxSizing: 'border-box',
|
padding: '0 8px', boxSizing: 'border-box',
|
||||||
@@ -796,7 +670,9 @@ export default function OberleisteApp() {
|
|||||||
const ts = sel || state.textSettings || {}
|
const ts = sel || state.textSettings || {}
|
||||||
const fonts = state.textFonts || []
|
const fonts = state.textFonts || []
|
||||||
const styles = state.textStyles || []
|
const styles = state.textStyles || []
|
||||||
const activeStyleId = state.textStyleActiveId
|
// Bei Selektion: Style-ID vom Text selber (falls per apply_style gesetzt),
|
||||||
|
// sonst auf globalen Active-Style fallen
|
||||||
|
const activeStyleId = (sel && sel.styleId) || state.textStyleActiveId
|
||||||
const updateTs = (patch) => setTextSettings({ ...ts, ...patch })
|
const updateTs = (patch) => setTextSettings({ ...ts, ...patch })
|
||||||
const STYLE_W = 110
|
const STYLE_W = 110
|
||||||
const FONT_W = 130
|
const FONT_W = 130
|
||||||
|
|||||||
+154
-55
@@ -90,8 +90,10 @@ function Dropdown({ value, onChange, options, width, title }) {
|
|||||||
function htmlToRuns(rootEl) {
|
function htmlToRuns(rootEl) {
|
||||||
const runs = []
|
const runs = []
|
||||||
function flush(text, ctx) {
|
function flush(text, ctx) {
|
||||||
if (text === '') return
|
// ZWSP (U+200B) ist unser Style-Marker — nie in die Runs
|
||||||
runs.push({ text, ...ctx })
|
const cleaned = text.replace(//g, '')
|
||||||
|
if (cleaned === '') return
|
||||||
|
runs.push({ text: cleaned, ...ctx })
|
||||||
}
|
}
|
||||||
function walk(node, ctx) {
|
function walk(node, ctx) {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
@@ -100,6 +102,14 @@ function htmlToRuns(rootEl) {
|
|||||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||||
const tag = node.tagName.toLowerCase()
|
const tag = node.tagName.toLowerCase()
|
||||||
if (tag === 'br') { flush('\n', ctx); return }
|
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 }
|
const nc = { ...ctx }
|
||||||
if (tag === 'b' || tag === 'strong') nc.bold = true
|
if (tag === 'b' || tag === 'strong') nc.bold = true
|
||||||
if (tag === 'i' || tag === 'em') nc.italic = 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.fontStyle === 'italic') nc.italic = true
|
||||||
if (node.style.textDecoration?.includes('underline')) nc.underline = true
|
if (node.style.textDecoration?.includes('underline')) nc.underline = true
|
||||||
// Font-Size: aus inline-style oder computed style (Pixel)
|
// Font-Size wird NICHT gelesen — Rhinos TextEntity-RTF unterstuetzt
|
||||||
if (node.style.fontSize) {
|
// \fs nicht. Es gibt nur EINE Size pro TextEntity (te.TextHeight).
|
||||||
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
|
|
||||||
}
|
|
||||||
// Legacy <font> Element von execCommand
|
// Legacy <font> Element von execCommand
|
||||||
if (tag === 'font') {
|
if (tag === 'font') {
|
||||||
const c = node.getAttribute('color'); if (c) nc.color = c
|
const c = node.getAttribute('color'); if (c) nc.color = c
|
||||||
@@ -152,32 +156,35 @@ function htmlToRuns(rootEl) {
|
|||||||
function runsToHtml(runs) {
|
function runsToHtml(runs) {
|
||||||
if (!Array.isArray(runs)) return ''
|
if (!Array.isArray(runs)) return ''
|
||||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
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) {
|
for (const r of runs) {
|
||||||
const raw = r.text || ''
|
const raw = r.text || ''
|
||||||
if (!raw) continue
|
|
||||||
// Per Newline splitten — jede non-leere Section bekommt eigene span
|
|
||||||
const segs = raw.split('\n')
|
const segs = raw.split('\n')
|
||||||
for (let i = 0; i < segs.length; i++) {
|
for (let i = 0; i < segs.length; i++) {
|
||||||
if (i > 0) out.push('<br>')
|
if (i > 0) lines.push([])
|
||||||
const seg = segs[i]
|
if (segs[i]) lines[lines.length-1].push({ ...r, text: segs[i] })
|
||||||
if (!seg) continue
|
}
|
||||||
|
}
|
||||||
|
// 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 = []
|
const styles = []
|
||||||
if (r.font) styles.push(`font-family: ${r.font}`)
|
if (seg.font) styles.push(`font-family: ${seg.font}`)
|
||||||
if (r.fontSizePx) styles.push(`font-size: ${r.fontSizePx}px`)
|
if (seg.color) styles.push(`color: ${seg.color}`)
|
||||||
if (r.color) styles.push(`color: ${r.color}`)
|
|
||||||
let opens = '', closes = ''
|
let opens = '', closes = ''
|
||||||
if (r.bold) { opens += '<b>'; closes = '</b>' + closes }
|
if (seg.bold) { opens += '<b>'; closes = '</b>' + closes }
|
||||||
if (r.italic) { opens += '<i>'; closes = '</i>' + closes }
|
if (seg.italic) { opens += '<i>'; closes = '</i>' + closes }
|
||||||
if (r.underline) { opens += '<u>'; closes = '</u>' + closes }
|
if (seg.underline) { opens += '<u>'; closes = '</u>' + closes }
|
||||||
if (r.sup) { opens += '<sup>'; closes = '</sup>' + closes }
|
if (seg.sup) { opens += '<sup>'; closes = '</sup>' + closes }
|
||||||
else if (r.sub) { opens += '<sub>'; closes = '</sub>' + closes }
|
else if (seg.sub) { opens += '<sub>'; closes = '</sub>' + closes }
|
||||||
const inner = opens + escape(seg) + closes
|
const text = opens + escape(seg.text) + closes
|
||||||
if (styles.length) out.push(`<span style="${styles.join('; ')}">${inner}</span>`)
|
return styles.length ? `<span style="${styles.join('; ')}">${text}</span>` : text
|
||||||
else out.push(inner)
|
}).join('')
|
||||||
}
|
return `<div>${inner}</div>`
|
||||||
}
|
}).join('')
|
||||||
return out.join('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SymbolPopover({ open, onClose, onPick }) {
|
function SymbolPopover({ open, onClose, onPick }) {
|
||||||
@@ -247,9 +254,14 @@ export default function TextEditorApp() {
|
|||||||
const [frame, setFrame] = useState('none') // none | rect | capsule
|
const [frame, setFrame] = useState('none') // none | rect | capsule
|
||||||
const [horizontalToView, setHorizontalToView] = useState(false)
|
const [horizontalToView, setHorizontalToView] = useState(false)
|
||||||
const [rotation, setRotation] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
|
const [valign, setVAlign] = useState('top') // top | middle | bottom
|
||||||
|
const [scaleWithModel, setScaleWithModel] = useState(true)
|
||||||
|
const [maskType, setMaskType] = useState('none') // none | viewport | solid
|
||||||
|
const [maskColor, setMaskColor] = useState([255, 255, 255])
|
||||||
const [maskMargin, setMaskMargin] = useState(0)
|
const [maskMargin, setMaskMargin] = useState(0)
|
||||||
const [symbolsOpen, setSymbolsOpen] = useState(false)
|
const [symbolsOpen, setSymbolsOpen] = useState(false)
|
||||||
const [styles, setStyles] = useState([])
|
const [styles, setStyles] = useState([])
|
||||||
|
const [activeStyleId, setActiveStyleId] = useState('')
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
const savedRangeRef = useRef(null)
|
const savedRangeRef = useRef(null)
|
||||||
|
|
||||||
@@ -290,7 +302,10 @@ export default function TextEditorApp() {
|
|||||||
try { sel.addRange(savedRangeRef.current) } catch (e) {}
|
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) => {
|
const applyInlineStyleToSelection = (styleProp, value) => {
|
||||||
restoreSelection()
|
restoreSelection()
|
||||||
const sel = window.getSelection()
|
const sel = window.getSelection()
|
||||||
@@ -303,12 +318,10 @@ export default function TextEditorApp() {
|
|||||||
const contents = range.extractContents()
|
const contents = range.extractContents()
|
||||||
span.appendChild(contents)
|
span.appendChild(contents)
|
||||||
range.insertNode(span)
|
range.insertNode(span)
|
||||||
// Neue Selektion auf das eingefuegte Span legen
|
|
||||||
const newRange = document.createRange()
|
const newRange = document.createRange()
|
||||||
newRange.selectNodeContents(span)
|
newRange.selectNodeContents(span)
|
||||||
sel.removeAllRanges()
|
sel.removeAllRanges()
|
||||||
sel.addRange(newRange)
|
sel.addRange(newRange)
|
||||||
// Saved-Range updaten damit Folge-Operationen wirken
|
|
||||||
savedRangeRef.current = newRange.cloneRange()
|
savedRangeRef.current = newRange.cloneRange()
|
||||||
} catch (e) { console.error('applyInlineStyle', e) }
|
} catch (e) { console.error('applyInlineStyle', e) }
|
||||||
}
|
}
|
||||||
@@ -324,14 +337,23 @@ export default function TextEditorApp() {
|
|||||||
if (s.italic != null) setItalic(!!s.italic)
|
if (s.italic != null) setItalic(!!s.italic)
|
||||||
if (s.underline != null) setUnderline(!!s.underline)
|
if (s.underline != null) setUnderline(!!s.underline)
|
||||||
if (s.align) setAlign(s.align)
|
if (s.align) setAlign(s.align)
|
||||||
|
if (s.valign) setVAlign(s.valign)
|
||||||
|
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
|
// Bei Edit-Mode: bestehenden Text in den Editor laden. Wenn Runs
|
||||||
// persistiert sind (= reicher Format-Stand vom letzten Save),
|
// persistiert sind (= reicher Format-Stand vom letzten Save),
|
||||||
// diese als HTML laden — sonst PlainText fallback.
|
// diese als HTML laden — sonst PlainText fallback.
|
||||||
const initialText = data.initialText || ''
|
const initialText = data.initialText || ''
|
||||||
const initialRuns = data.initialRuns
|
const initialRuns = data.initialRuns
|
||||||
|
const initialHtml = data.initialHtml
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (editorRef.current) {
|
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)
|
editorRef.current.innerHTML = runsToHtml(initialRuns)
|
||||||
} else if (initialText) {
|
} else if (initialText) {
|
||||||
editorRef.current.innerText = initialText
|
editorRef.current.innerText = initialText
|
||||||
@@ -379,27 +401,21 @@ export default function TextEditorApp() {
|
|||||||
// kommende Tippen).
|
// kommende Tippen).
|
||||||
const applyStyle = (style) => {
|
const applyStyle = (style) => {
|
||||||
if (!style) return
|
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.font) setFont(style.font)
|
||||||
if (style.size != null) setSize(style.size)
|
if (style.size != null) setSize(style.size)
|
||||||
setBold(!!style.bold)
|
setBold(!!style.bold)
|
||||||
setItalic(!!style.italic)
|
setItalic(!!style.italic)
|
||||||
setUnderline(!!style.underline)
|
setUnderline(!!style.underline)
|
||||||
if (style.align) setAlign(style.align)
|
if (style.align) setAlign(style.align)
|
||||||
// Selection wiederherstellen + Editor-Focus damit execCommand greift.
|
|
||||||
editorRef.current?.focus()
|
editorRef.current?.focus()
|
||||||
restoreSelection()
|
restoreSelection()
|
||||||
try {
|
try {
|
||||||
document.execCommand('styleWithCSS', false, true)
|
document.execCommand('styleWithCSS', false, true)
|
||||||
if (style.font) document.execCommand('fontName', false, style.font)
|
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 wantBold = !!style.bold
|
||||||
const wantItal = !!style.italic
|
const wantItal = !!style.italic
|
||||||
const wantUnd = !!style.underline
|
const wantUnd = !!style.underline
|
||||||
@@ -420,20 +436,54 @@ export default function TextEditorApp() {
|
|||||||
}
|
}
|
||||||
const clearColor = () => { setColor(null); exec('foreColor', '#000000') }
|
const clearColor = () => { setColor(null); exec('foreColor', '#000000') }
|
||||||
|
|
||||||
|
const onMaskColorPick = (hex) => {
|
||||||
|
const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
|
||||||
|
if (m) setMaskColor([parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)])
|
||||||
|
if (maskType === 'none') setMaskType('solid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-Transformationen auf die aktuelle Selektion. Wenn nichts
|
||||||
|
// markiert ist, no-op (kein All-Text-Modus, sonst zu invasiv).
|
||||||
|
const transformCase = (mode) => {
|
||||||
|
restoreSelection()
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (!sel || sel.rangeCount === 0) return
|
||||||
|
const range = sel.getRangeAt(0)
|
||||||
|
if (range.collapsed) return
|
||||||
|
const src = range.toString()
|
||||||
|
let out = src
|
||||||
|
if (mode === 'upper') out = src.toUpperCase()
|
||||||
|
else if (mode === 'lower') out = src.toLowerCase()
|
||||||
|
else if (mode === 'capitalize') out = src.replace(/\w\S*/g, w =>
|
||||||
|
w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
else if (mode === 'invert') out = src.split('').map(ch =>
|
||||||
|
ch === ch.toUpperCase() ? ch.toLowerCase() : ch.toUpperCase()).join('')
|
||||||
|
document.execCommand('insertText', false, out)
|
||||||
|
editorRef.current?.focus()
|
||||||
|
}
|
||||||
|
const rgbToHex = ([r, g, b]) => '#' +
|
||||||
|
[r, g, b].map(n => Math.max(0, Math.min(255, n|0)).toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
const onCommit = () => {
|
const onCommit = () => {
|
||||||
const el = editorRef.current
|
const el = editorRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const text = el.innerText || ''
|
const text = el.innerText || ''
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
// Phase 2: Format-Runs aus HTML extrahieren fuer Rich-Text-Mapping
|
|
||||||
let runs = null
|
let runs = null
|
||||||
try { runs = htmlToRuns(el) } catch (e) { console.error(e) }
|
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', {
|
send('COMMIT', {
|
||||||
text,
|
text,
|
||||||
runs,
|
runs,
|
||||||
|
html,
|
||||||
settings: {
|
settings: {
|
||||||
font, size, bold, italic, underline, align, color,
|
font, size, bold, italic, underline, align, valign, color,
|
||||||
frame, horizontalToView, rotation, maskMargin,
|
frame, horizontalToView, rotation, scaleWithModel,
|
||||||
|
maskType, maskColor, maskMargin,
|
||||||
|
styleId: activeStyleId || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -449,10 +499,11 @@ export default function TextEditorApp() {
|
|||||||
}}>
|
}}>
|
||||||
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
|
{/* Toolbar Row 1: Stil | Font | Size | Color | Layer-Reset */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<Dropdown value=""
|
<Dropdown value={activeStyleId}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const st = styles.find(s => s.id === v)
|
const st = styles.find(s => s.id === v)
|
||||||
if (st) applyStyle(st)
|
if (st) applyStyle(st)
|
||||||
|
else setActiveStyleId('')
|
||||||
}}
|
}}
|
||||||
width={150}
|
width={150}
|
||||||
title="Text-Stil anwenden (auf Selektion oder als Default fuer kommendes Tippen)"
|
title="Text-Stil anwenden (auf Selektion oder als Default fuer kommendes Tippen)"
|
||||||
@@ -479,11 +530,9 @@ export default function TextEditorApp() {
|
|||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const n = parseFloat(v)
|
const n = parseFloat(v)
|
||||||
setSize(n)
|
setSize(n)
|
||||||
// Auf Selektion applizieren via span-Wrap
|
|
||||||
applyInlineStyleToSelection('font-size', sizeToPx(n) + 'px')
|
|
||||||
editorRef.current?.focus()
|
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 => (
|
options={SIZE_PRESETS.map(s => (
|
||||||
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
|
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
|
||||||
))}
|
))}
|
||||||
@@ -528,24 +577,68 @@ export default function TextEditorApp() {
|
|||||||
<Icon name="format_align_right" size={13} />
|
<Icon name="format_align_right" size={13} />
|
||||||
</Pill>
|
</Pill>
|
||||||
<div style={{ width: 6 }} />
|
<div style={{ width: 6 }} />
|
||||||
|
<Pill active={valign === 'top'} onMouseDown={onBtnMouseDown(() => setVAlign('top'))} title="Oben">
|
||||||
|
<Icon name="vertical_align_top" size={13} />
|
||||||
|
</Pill>
|
||||||
|
<Pill active={valign === 'middle'} onMouseDown={onBtnMouseDown(() => setVAlign('middle'))} title="Mittig">
|
||||||
|
<Icon name="vertical_align_center" size={13} />
|
||||||
|
</Pill>
|
||||||
|
<Pill active={valign === 'bottom'} onMouseDown={onBtnMouseDown(() => setVAlign('bottom'))} title="Unten">
|
||||||
|
<Icon name="vertical_align_bottom" size={13} />
|
||||||
|
</Pill>
|
||||||
|
<div style={{ width: 6 }} />
|
||||||
<Pill onMouseDown={onBtnMouseDown(doSup)} title="Hochstellen (x²)" style={{ fontFamily: 'serif' }}>x²</Pill>
|
<Pill onMouseDown={onBtnMouseDown(doSup)} title="Hochstellen (x²)" style={{ fontFamily: 'serif' }}>x²</Pill>
|
||||||
<Pill onMouseDown={onBtnMouseDown(doSub)} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
|
<Pill onMouseDown={onBtnMouseDown(doSub)} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
|
||||||
|
<div style={{ width: 6 }} />
|
||||||
|
<Dropdown value=""
|
||||||
|
onChange={(v) => { if (v) transformCase(v) }}
|
||||||
|
width={130}
|
||||||
|
title="Selektion in Gross-/Kleinschrift wandeln"
|
||||||
|
options={[
|
||||||
|
<option key="" value="">— Aa —</option>,
|
||||||
|
<option key="upper" value="upper">GROSSBUCHSTABEN</option>,
|
||||||
|
<option key="lower" value="lower">kleinbuchstaben</option>,
|
||||||
|
<option key="capitalize" value="capitalize">Erste Buchstaben</option>,
|
||||||
|
<option key="invert" value="invert">Umkehren</option>,
|
||||||
|
]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar Row 3: Frame / Rotation / Mask / Horizontal-to-view / Symbole */}
|
{/* Toolbar Row 3: Frame / Mask (Type/Color/Margin) / Rotation / Camera / Symbole */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<Dropdown value={frame} onChange={setFrame} width={130}
|
<Dropdown value={frame} onChange={setFrame} width={130}
|
||||||
title="Rahmen um den Text"
|
title="Rahmen um den Text"
|
||||||
options={FRAME_OPTIONS.map(o => (
|
options={FRAME_OPTIONS.map(o => (
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
))} />
|
))} />
|
||||||
|
<Dropdown value={maskType} onChange={setMaskType} width={120}
|
||||||
|
title="Maske hinter dem Text (verdeckt Hintergrund)"
|
||||||
|
options={[
|
||||||
|
<option key="none" value="none">Keine Maske</option>,
|
||||||
|
<option key="viewport" value="viewport">Viewport-Farbe</option>,
|
||||||
|
<option key="solid" value="solid">Solide Farbe</option>,
|
||||||
|
]} />
|
||||||
|
{maskType === 'solid' && (
|
||||||
|
<label style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
height: BAR_H + 2, padding: '0 8px',
|
||||||
|
background: 'var(--bg-input)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 999, cursor: 'pointer', flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}} title="Mask-Farbe">
|
||||||
|
<input type="color" value={rgbToHex(maskColor)}
|
||||||
|
style={{ width: 16, height: 16, border: 'none', padding: 0, background: 'transparent' }}
|
||||||
|
onChange={(e) => onMaskColorPick(e.target.value)} />
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Farbe</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
height: BAR_H + 2, padding: '0 10px', boxSizing: 'border-box',
|
height: BAR_H + 2, padding: '0 10px', boxSizing: 'border-box',
|
||||||
background: 'var(--bg-input)', border: '1px solid var(--border)',
|
background: 'var(--bg-input)', border: '1px solid var(--border)',
|
||||||
borderRadius: 999, flexShrink: 0,
|
borderRadius: 999, flexShrink: 0,
|
||||||
}} title="Mask-Rand (m) — weisser Hintergrund hinter Text">
|
opacity: maskType === 'none' ? 0.5 : 1,
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Mask</span>
|
}} title="Rand um den Text-Inhalt der mit-maskiert wird (m)">
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Rand</span>
|
||||||
<input type="number" step="0.05" min="0"
|
<input type="number" step="0.05" min="0"
|
||||||
value={maskMargin}
|
value={maskMargin}
|
||||||
onChange={(e) => setMaskMargin(parseFloat(e.target.value) || 0)}
|
onChange={(e) => setMaskMargin(parseFloat(e.target.value) || 0)}
|
||||||
@@ -580,6 +673,12 @@ export default function TextEditorApp() {
|
|||||||
<Icon name="screen_rotation" size={13} />
|
<Icon name="screen_rotation" size={13} />
|
||||||
<span style={{ fontSize: 10 }}>Zur Kamera</span>
|
<span style={{ fontSize: 10 }}>Zur Kamera</span>
|
||||||
</Pill>
|
</Pill>
|
||||||
|
<Pill active={scaleWithModel}
|
||||||
|
onClick={() => setScaleWithModel(b => !b)}
|
||||||
|
title="Text skaliert sich mit dem Massstab (Annotation Scaling). Aus = absolute Modellhöhe.">
|
||||||
|
<Icon name="zoom_out_map" size={13} />
|
||||||
|
<span style={{ fontSize: 10 }}>Masstäblich</span>
|
||||||
|
</Pill>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<Pill onClick={() => setSymbolsOpen(o => !o)}
|
<Pill onClick={() => setSymbolsOpen(o => !o)}
|
||||||
active={symbolsOpen}
|
active={symbolsOpen}
|
||||||
@@ -610,7 +709,7 @@ export default function TextEditorApp() {
|
|||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontFamily: 'Helvetica, sans-serif',
|
fontFamily: 'Helvetica, sans-serif',
|
||||||
fontSize: 20, lineHeight: 1.5,
|
fontSize: sizeToPx(size), lineHeight: 1.3,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
textAlign: align,
|
textAlign: align,
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Icon from './Icon'
|
||||||
|
|
||||||
|
// Gemeinsame Toolbar-Primitiven für Panels im Oberleiste-Stil:
|
||||||
|
// Pill-Container mit konsistenter Höhe, Accent-Border bei Hover/Active.
|
||||||
|
// Quelle: ursprünglich in OberleisteApp.jsx — zur Wiederverwendung in
|
||||||
|
// weiteren Panels extrahiert.
|
||||||
|
|
||||||
|
export const BAR_H = 22
|
||||||
|
|
||||||
|
// BarCombo: dunklerer (bg-input) Pill-Container der select + optional gear
|
||||||
|
// als EINE nahtlose Box rendert. Icon roh links daneben (kein Container).
|
||||||
|
// iconClickable=true macht das Icon zum Toggle-Button.
|
||||||
|
// valueAccent=true faerbt den Select-Text accent.
|
||||||
|
export function BarCombo({
|
||||||
|
icon, iconActive, iconClickable, onIconClick, iconTitle,
|
||||||
|
value, onChange, width, title, children, disabled,
|
||||||
|
onGear, gearTitle, valueAccent,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{icon && (iconClickable ? (
|
||||||
|
<button onClick={onIconClick} title={iconTitle}
|
||||||
|
style={{
|
||||||
|
width: 18, height: BAR_H,
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
cursor: 'pointer', flexShrink: 0, padding: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Icon name={icon} size={13}
|
||||||
|
style={{ color: iconActive ? 'var(--accent)' : 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{
|
||||||
|
width: 18, height: BAR_H, flexShrink: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Icon name={icon} size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<div title={title}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg-input)'
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'stretch',
|
||||||
|
height: BAR_H + 2, width, boxSizing: 'border-box',
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}>
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, minWidth: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
color: valueAccent ? 'var(--accent-light)' : 'var(--text-primary)',
|
||||||
|
border: 'none', outline: 'none',
|
||||||
|
padding: '0 22px 0 12px',
|
||||||
|
fontSize: 11, fontFamily: 'var(--font)',
|
||||||
|
fontWeight: valueAccent ? 600 : 500,
|
||||||
|
appearance: 'none', WebkitAppearance: 'none',
|
||||||
|
backgroundImage: 'var(--select-arrow)',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: onGear ? 'right 1px center' : 'right 10px center',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
letterSpacing: 0,
|
||||||
|
}}
|
||||||
|
>{children}</select>
|
||||||
|
{onGear && (
|
||||||
|
<button onClick={onGear} title={gearTitle}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
padding: '0 8px', cursor: 'pointer',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Icon name="settings" size={12}
|
||||||
|
style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarToggle: Pill-Button mit Label (+ optionalem Icon) und Active-State.
|
||||||
|
// Eignet sich fuer Toggles wie Welt/CPlane, Z-Selektor, Preset-Buttons.
|
||||||
|
export function BarToggle({ icon, label, active, onClick, title, disabled, minWidth }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled} title={title}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (disabled || active) return
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (active) return
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg-input)'
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: BAR_H, minWidth: minWidth || BAR_H,
|
||||||
|
padding: label ? '0 10px' : 0,
|
||||||
|
background: active ? 'var(--accent)' : 'var(--bg-input)',
|
||||||
|
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
|
||||||
|
border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'),
|
||||||
|
borderRadius: 999,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 4, fontSize: 11,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
appearance: 'none', WebkitAppearance: 'none',
|
||||||
|
lineHeight: 1, boxSizing: 'border-box', flexShrink: 0,
|
||||||
|
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
|
||||||
|
}}>
|
||||||
|
{icon && <Icon name={icon} size={12}
|
||||||
|
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />}
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarButton: pill-foermiger Icon-Button im selben Stil wie BarCombo.
|
||||||
|
// joinedLeft = linke Kante flach (dockt rechts an einen BarCombo).
|
||||||
|
export function BarButton({ icon, onClick, title, disabled, active, joinedLeft }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled} title={title}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (disabled || active) return
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (active) return
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg-input)'
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: BAR_H, width: BAR_H,
|
||||||
|
background: active ? 'var(--accent)' : 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderTopLeftRadius: joinedLeft ? 0 : 999,
|
||||||
|
borderBottomLeftRadius: joinedLeft ? 0 : 999,
|
||||||
|
borderTopRightRadius: 999, borderBottomRightRadius: 999,
|
||||||
|
borderLeft: joinedLeft ? 'none' : '1px solid var(--border)',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.5 : 1, flexShrink: 0,
|
||||||
|
padding: 0,
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}>
|
||||||
|
<Icon name={icon} size={13}
|
||||||
|
style={{ color: active ? 'var(--bg-panel)' : 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useRef, useMemo, useEffect } from 'react'
|
|||||||
import Icon from './Icon'
|
import Icon from './Icon'
|
||||||
import ConfirmDeleteEbene from './ConfirmDeleteEbene'
|
import ConfirmDeleteEbene from './ConfirmDeleteEbene'
|
||||||
import ContextMenu from './ContextMenu'
|
import ContextMenu from './ContextMenu'
|
||||||
|
import { BarCombo, BarButton } from './BarControls'
|
||||||
import { setLayerStyle, deleteEbene, moveSelectionToEbene, openEbenenSettings } from '../lib/rhinoBridge'
|
import { setLayerStyle, deleteEbene, moveSelectionToEbene, openEbenenSettings } from '../lib/rhinoBridge'
|
||||||
|
|
||||||
const MODES = [
|
const MODES = [
|
||||||
@@ -79,11 +80,11 @@ function LwCell({ lw, onChange }) {
|
|||||||
>{lw.toFixed(2)}</span>
|
>{lw.toFixed(2)}</span>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(+1) }} style={{ width: 10, height: 7 }}>
|
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(+1) }} style={{ width: 10, height: 6 }}>
|
||||||
<Icon name="arrow_drop_up" size={10} />
|
<Icon name="arrow_drop_up" size={9} />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(-1) }} style={{ width: 10, height: 7 }}>
|
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(-1) }} style={{ width: 10, height: 6 }}>
|
||||||
<Icon name="arrow_drop_down" size={10} />
|
<Icon name="arrow_drop_down" size={9} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,21 +244,21 @@ function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mod
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
padding: '3px 12px',
|
padding: '1px 12px',
|
||||||
paddingLeft: 12 + (depth || 0) * 14,
|
paddingLeft: 12 + (depth || 0) * 12,
|
||||||
margin: active ? '1px 6px' : '0',
|
margin: 0,
|
||||||
background: active ? 'var(--active-dim)'
|
background: active ? 'var(--active-dim)'
|
||||||
: (e.visible !== false) ? 'var(--bg-item)'
|
: (e.visible !== false) ? 'var(--bg-item)'
|
||||||
: 'var(--bg-panel)',
|
: 'var(--bg-panel)',
|
||||||
// Pill-Form fuer die aktive Ebene, sonst Standard-Zeile mit Bottom-Border
|
// Eckige Tabellen-Zeile mit Accent-Strip links fuer aktive Ebene
|
||||||
borderRadius: active ? 999 : 0,
|
borderRadius: 0,
|
||||||
borderLeft: active ? 'none' : '3px solid transparent',
|
borderLeft: '3px solid ' + (active ? 'var(--accent)' : 'transparent'),
|
||||||
borderBottom: active ? 'none' : '1px solid var(--border-light)',
|
borderBottom: '1px solid var(--border-light)',
|
||||||
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
|
|
||||||
opacity: (!active && e.visible === false && mode !== 'all') ? 0.45 : 1,
|
opacity: (!active && e.visible === false && mode !== 'all') ? 0.45 : 1,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
minHeight: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
@@ -265,24 +266,24 @@ function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mod
|
|||||||
className="btn-icon-xs"
|
className="btn-icon-xs"
|
||||||
onClick={(ev) => { ev.stopPropagation(); onToggleExpand() }}
|
onClick={(ev) => { ev.stopPropagation(); onToggleExpand() }}
|
||||||
title={expanded ? 'Einklappen' : 'Aufklappen'}
|
title={expanded ? 'Einklappen' : 'Aufklappen'}
|
||||||
style={{ width: 14, height: 14 }}
|
style={{ width: 12, height: 12 }}
|
||||||
><Icon name={expanded ? 'expand_more' : 'chevron_right'} size={12} /></button>
|
><Icon name={expanded ? 'expand_more' : 'chevron_right'} size={11} /></button>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ width: 14, flexShrink: 0 }} />
|
<span style={{ width: 12, flexShrink: 0 }} />
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
|
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
|
||||||
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
||||||
title={eyeTitle}
|
title={eyeTitle}
|
||||||
style={{ opacity: eyeOpacity }}
|
style={{ opacity: eyeOpacity, width: 16, height: 16 }}
|
||||||
><Icon name={eyeIcon} size={14} /></button>
|
><Icon name={eyeIcon} size={12} /></button>
|
||||||
|
|
||||||
<EditableText
|
<EditableText
|
||||||
value={e.code}
|
value={e.code}
|
||||||
onCommit={onCodeChange}
|
onCommit={onCodeChange}
|
||||||
autoEditTrigger={autoEditCode}
|
autoEditTrigger={autoEditCode}
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', width: 24, textAlign: 'left', flexShrink: 0 }}
|
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', width: 22, textAlign: 'left', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ColorPicker color={e.color} onChange={onColorChange} />
|
<ColorPicker color={e.color} onChange={onColorChange} />
|
||||||
@@ -300,6 +301,7 @@ function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mod
|
|||||||
: 'var(--text-muted)',
|
: 'var(--text-muted)',
|
||||||
display: 'inline-block', width: '100%',
|
display: 'inline-block', width: '100%',
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,14 +312,15 @@ function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mod
|
|||||||
className="btn-icon-xs"
|
className="btn-icon-xs"
|
||||||
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
|
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
|
||||||
title={e.locked ? 'Entsperren' : 'Sperren'}
|
title={e.locked ? 'Entsperren' : 'Sperren'}
|
||||||
style={{ color: e.locked ? 'var(--warn)' : undefined }}
|
style={{ color: e.locked ? 'var(--warn)' : undefined, width: 14, height: 14 }}
|
||||||
><Icon name={e.locked ? 'lock' : 'lock_open'} size={12} /></button>
|
><Icon name={e.locked ? 'lock' : 'lock_open'} size={11} /></button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-icon-xs"
|
className="btn-icon-xs"
|
||||||
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
|
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
><Icon name="close" size={12} /></button>
|
style={{ width: 14, height: 14 }}
|
||||||
|
><Icon name="close" size={11} /></button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -547,16 +550,17 @@ export default function EbenenManager({
|
|||||||
}}>
|
}}>
|
||||||
<span className="label-xs">Sichtbarkeit</span>
|
<span className="label-xs">Sichtbarkeit</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<select
|
<div style={{ flex: 1, minWidth: 0, display: 'flex' }}>
|
||||||
|
<BarCombo
|
||||||
|
icon="visibility"
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={ev => onModeChange(ev.target.value)}
|
onChange={onModeChange}
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
title="Sichtbarkeits-Modus"
|
||||||
>
|
>
|
||||||
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||||
</select>
|
</BarCombo>
|
||||||
<button className="btn-icon-sm" onClick={addNew} title="Ebene hinzufügen">
|
</div>
|
||||||
<Icon name="add" size={14} />
|
<BarButton icon="add" onClick={addNew} title="Ebene hinzufügen" />
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Icon from './Icon'
|
import Icon from './Icon'
|
||||||
import ContextMenu from './ContextMenu'
|
import ContextMenu from './ContextMenu'
|
||||||
|
import { BarCombo, BarButton } from './BarControls'
|
||||||
import { openGeschossSettings, openGeschossDialog } from '../lib/rhinoBridge'
|
import { openGeschossSettings, openGeschossDialog } from '../lib/rhinoBridge'
|
||||||
|
|
||||||
function GeschossBadge({ name }) {
|
function GeschossBadge({ name }) {
|
||||||
@@ -46,31 +47,32 @@ function ZeichnungsebeneRow({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
padding: '4px 12px',
|
padding: '1px 12px',
|
||||||
margin: active ? '1px 6px' : '0',
|
margin: 0,
|
||||||
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
|
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
|
||||||
borderRadius: active ? 999 : 0,
|
borderRadius: 0,
|
||||||
borderLeft: active ? 'none' : '3px solid transparent',
|
borderLeft: '3px solid ' + (active ? 'var(--accent)' : 'transparent'),
|
||||||
borderBottom: active ? 'none' : '1px solid var(--border-light)',
|
borderBottom: '1px solid var(--border-light)',
|
||||||
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
minHeight: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
|
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
|
||||||
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
||||||
title={eyeTitle}
|
title={eyeTitle}
|
||||||
style={{ opacity: eyeOpacity }}
|
style={{ opacity: eyeOpacity, width: 16, height: 16 }}
|
||||||
><Icon name={eyeIcon} size={14} /></button>
|
><Icon name={eyeIcon} size={12} /></button>
|
||||||
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontWeight: active ? 700 : 500,
|
fontWeight: active ? 700 : 500,
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: active ? 'var(--active-light)' : 'var(--text-label)',
|
color: active ? 'var(--active-light)' : 'var(--text-label)',
|
||||||
flex: 1, minWidth: 0,
|
flex: 1, minWidth: 0,
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}>{z.name}</span>
|
}}>{z.name}</span>
|
||||||
|
|
||||||
{isGeschoss && (
|
{isGeschoss && (
|
||||||
@@ -88,24 +90,25 @@ function ZeichnungsebeneRow({
|
|||||||
title={z.hasClipping
|
title={z.hasClipping
|
||||||
? 'Clipping Plane ausschalten'
|
? 'Clipping Plane ausschalten'
|
||||||
: 'Clipping Plane einschalten (Schnitt auf Schnitthöhe)'}
|
: 'Clipping Plane einschalten (Schnitt auf Schnitthöhe)'}
|
||||||
style={{ color: z.hasClipping ? 'var(--accent)' : undefined }}
|
style={{ color: z.hasClipping ? 'var(--accent)' : undefined, width: 14, height: 14 }}
|
||||||
><Icon name="content_cut" size={12} /></button>
|
><Icon name="content_cut" size={11} /></button>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ width: 18, flexShrink: 0 }} />
|
<span style={{ width: 14, flexShrink: 0 }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-icon-xs"
|
className="btn-icon-xs"
|
||||||
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
|
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
|
||||||
title={z.locked ? 'Entsperren' : 'Sperren'}
|
title={z.locked ? 'Entsperren' : 'Sperren'}
|
||||||
style={{ color: z.locked ? 'var(--warn)' : undefined }}
|
style={{ color: z.locked ? 'var(--warn)' : undefined, width: 14, height: 14 }}
|
||||||
><Icon name={z.locked ? 'lock' : 'lock_open'} size={12} /></button>
|
><Icon name={z.locked ? 'lock' : 'lock_open'} size={11} /></button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-icon-xs"
|
className="btn-icon-xs"
|
||||||
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
|
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
><Icon name="close" size={12} /></button>
|
style={{ width: 14, height: 14 }}
|
||||||
|
><Icon name="close" size={11} /></button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -215,21 +218,20 @@ export default function GeschossManager({
|
|||||||
}}>
|
}}>
|
||||||
<span className="label-xs">Sichtbarkeit</span>
|
<span className="label-xs">Sichtbarkeit</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<select
|
<div style={{ flex: 1, minWidth: 0, display: 'flex' }}>
|
||||||
|
<BarCombo
|
||||||
|
icon="visibility"
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={ev => onModeChange(ev.target.value)}
|
onChange={onModeChange}
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
title="Sichtbarkeits-Modus"
|
||||||
>
|
>
|
||||||
{MODES.map(m => (
|
{MODES.map(m => (
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</BarCombo>
|
||||||
<button className="btn-icon-sm" onClick={addQuick} title="Zeichnungsebene hinzufügen">
|
</div>
|
||||||
<Icon name="add" size={14} />
|
<BarButton icon="add" onClick={addQuick} title="Zeichnungsebene hinzufügen" />
|
||||||
</button>
|
<BarButton icon="settings" onClick={() => openGeschossDialog(zeichnungsebenen)} title="Einstellungen" />
|
||||||
<button className="btn-icon-sm" onClick={() => openGeschossDialog(zeichnungsebenen)} title="Bearbeiten">
|
|
||||||
<Icon name="edit" size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user