Text-Editor: Symbol-Popover, Frame/Mask/Rot/Kamera, Phase 2 RTF-Mapping
User: Sonderzeichen in einen Button packen mit Popover-Box, neue
Optionen (Frame around text, Horizontal to view, Rotation, Mask
margins), und Phase 2 = mixed-fonts via Rich-Text.
Frontend (TextEditorApp.jsx):
- Sonderzeichen-Reihe weg, ersetzt durch [Symbole] Pill-Button mit
Popover-Box. Symbols gruppiert (Mathematik, Pfeile, Auszeichnung)
- Toolbar Reihe 3 neu: [Rahmen ▼] [Mask ____ m] [Rot ____ °]
[Zur Kamera Toggle] [Symbole Popover]
- htmlToRuns(rootEl): walks contentEditable DOM, extrahiert Format-
Runs (text, font, color, bold, italic, underline, sup, sub) basierend
auf Tag-Hierarchie (b/i/u/sup/sub/font/span style)
- onCommit sendet jetzt zusaetzlich runs[] + frame/horizontalToView/
rotation/maskMargin in settings
Backend (text_editor.py):
- _commit: setzt Plane mit Rotation um Z (math.radians + Plane.Rotate)
- MaskFrame: NoFrame/RectFrame/CapsuleFrame ueber TextMaskFrame-Enum
- MaskEnabled+MaskOffset+MaskUsesViewportColor wenn maskMargin>0
- te.DrawForward = horizontalToView (Text steht zur Kamera)
- _runs_to_rtf(runs, default_font): Phase 2 — generiert Rhinos RTF aus
den Runs. Triviale Runs (alle plain, ein Font) → None (PlainText
fallback). Sonst: \rtf1 + fonttbl + colortbl + Format-Codes pro Run
(\f \cf \b \i \ul \super \sub \nosupersub). _rtf_escape handelt \,
{, }, \n und non-ASCII (\u-Notation). te.RichText = rtf, Fallback
auf PlainText wenn das fehlschlaegt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+145
-3
@@ -54,17 +54,40 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
|
|
||||||
def _commit(self, payload):
|
def _commit(self, payload):
|
||||||
import System
|
import System
|
||||||
|
import math
|
||||||
doc = Rhino.RhinoDoc.ActiveDoc
|
doc = Rhino.RhinoDoc.ActiveDoc
|
||||||
if doc is None or self._frame is None: return
|
if doc is None or self._frame is None: return
|
||||||
text = (payload.get("text") or "").strip()
|
text = (payload.get("text") or "").strip()
|
||||||
if not text: return
|
if not text: return
|
||||||
st = payload.get("settings") or {}
|
st = payload.get("settings") or {}
|
||||||
|
runs = payload.get("runs") # Phase 2: rich-text runs (oder None)
|
||||||
origin, width, height, _p1, _p2 = self._frame
|
origin, width, height, _p1, _p2 = self._frame
|
||||||
|
|
||||||
try:
|
try:
|
||||||
te = rg.TextEntity()
|
te = rg.TextEntity()
|
||||||
te.Plane = rg.Plane(origin, rg.Vector3d.ZAxis)
|
# Plane mit optionaler Rotation um Z
|
||||||
te.PlainText = text
|
rot_deg = 0.0
|
||||||
|
try: rot_deg = float(st.get("rotation") or 0)
|
||||||
|
except Exception: pass
|
||||||
|
plane = rg.Plane(origin, rg.Vector3d.ZAxis)
|
||||||
|
if abs(rot_deg) > 1e-6:
|
||||||
|
try:
|
||||||
|
plane.Rotate(math.radians(rot_deg), rg.Vector3d.ZAxis, origin)
|
||||||
|
except Exception: pass
|
||||||
|
te.Plane = plane
|
||||||
|
|
||||||
|
# Rich-Text (Phase 2) wenn vorhanden + nicht-trivial, sonst Plain
|
||||||
|
rtf = _runs_to_rtf(runs, st.get("font") or "Helvetica") if runs else None
|
||||||
|
applied_rtf = False
|
||||||
|
if rtf:
|
||||||
|
try:
|
||||||
|
te.RichText = rtf
|
||||||
|
applied_rtf = True
|
||||||
|
except Exception as ex:
|
||||||
|
print("[TEXT-EDITOR] RichText set fail:", ex)
|
||||||
|
if not applied_rtf:
|
||||||
|
te.PlainText = text
|
||||||
|
|
||||||
try: te.TextHeight = float(st.get("size") or 0.2)
|
try: te.TextHeight = float(st.get("size") or 0.2)
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
text_create._apply_font(
|
text_create._apply_font(
|
||||||
@@ -82,6 +105,31 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
try: te.TextWrap = True
|
try: te.TextWrap = True
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
||||||
|
# Frame um den Text + Mask-Margin
|
||||||
|
frame_kind = (st.get("frame") or "none").lower()
|
||||||
|
try:
|
||||||
|
MF = Rhino.DocObjects.TextMaskFrame
|
||||||
|
te.MaskFrame = (
|
||||||
|
MF.RectFrame if frame_kind == "rect" else
|
||||||
|
MF.CapsuleFrame if frame_kind == "capsule" else
|
||||||
|
MF.NoFrame
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
print("[TEXT-EDITOR] frame:", ex)
|
||||||
|
try:
|
||||||
|
mask_m = float(st.get("maskMargin") or 0)
|
||||||
|
if mask_m > 0:
|
||||||
|
te.MaskEnabled = True
|
||||||
|
te.MaskOffset = mask_m
|
||||||
|
te.MaskUsesViewportColor = True
|
||||||
|
except Exception as ex:
|
||||||
|
print("[TEXT-EDITOR] mask:", ex)
|
||||||
|
|
||||||
|
# Horizontal-to-view (DrawForward = text steht immer zur Kamera)
|
||||||
|
try:
|
||||||
|
te.DrawForward = bool(st.get("horizontalToView"))
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
attrs = Rhino.DocObjects.ObjectAttributes()
|
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:
|
||||||
@@ -95,7 +143,8 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
doc.Objects.AddText(te, attrs)
|
doc.Objects.AddText(te, attrs)
|
||||||
doc.Views.Redraw()
|
doc.Views.Redraw()
|
||||||
|
|
||||||
# Defaults speichern (ohne color)
|
# Defaults speichern (ohne color/rotation/frame — die sind
|
||||||
|
# situativ, nicht zu uebernehmen)
|
||||||
text_create.save_settings(doc, {
|
text_create.save_settings(doc, {
|
||||||
"font": st.get("font"),
|
"font": st.get("font"),
|
||||||
"size": st.get("size"),
|
"size": st.get("size"),
|
||||||
@@ -108,6 +157,99 @@ class TextEditorBridge(panel_base.BaseBridge):
|
|||||||
print("[TEXT-EDITOR] commit:", ex)
|
print("[TEXT-EDITOR] commit:", ex)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2: Rich-Text-Runs (vom Frontend HTML-parsing) → Rhino-RTF.
|
||||||
|
|
||||||
|
def _parse_color_to_rgb(c):
|
||||||
|
"""CSS-Farbe ('rgb(r,g,b)' | '#rrggbb' | '#rgb') → (r,g,b) Ints 0-255."""
|
||||||
|
if not c: return (0, 0, 0)
|
||||||
|
c = c.strip()
|
||||||
|
if c.startswith("rgb"):
|
||||||
|
import re
|
||||||
|
m = re.search(r"(\d+)\D+(\d+)\D+(\d+)", c)
|
||||||
|
if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||||
|
if c.startswith("#"):
|
||||||
|
h = c.lstrip("#")
|
||||||
|
if len(h) == 3: h = "".join(ch * 2 for ch in h)
|
||||||
|
if len(h) == 6:
|
||||||
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||||
|
return (0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _rtf_escape(s):
|
||||||
|
"""RTF-Escape: \\, {, }, sowie non-ASCII via \\u-Notation."""
|
||||||
|
out = []
|
||||||
|
for ch in s:
|
||||||
|
cp = ord(ch)
|
||||||
|
if ch == "\\": out.append("\\\\")
|
||||||
|
elif ch == "{": out.append("\\{")
|
||||||
|
elif ch == "}": out.append("\\}")
|
||||||
|
elif ch == "\n": out.append("\\par\n")
|
||||||
|
elif cp < 128: out.append(ch)
|
||||||
|
else:
|
||||||
|
# Signed 16-bit → Rhinos RTF erwartet das so
|
||||||
|
v = cp if cp < 0x8000 else cp - 0x10000
|
||||||
|
out.append("\\u{}?".format(v))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _runs_to_rtf(runs, default_font):
|
||||||
|
"""Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von
|
||||||
|
dicts mit Keys text/font/bold/italic/underline/sup/sub/color."""
|
||||||
|
if not runs: return None
|
||||||
|
# Triviale Runs (alle plain, ein Font) → kein RTF noetig
|
||||||
|
nontrivial = False
|
||||||
|
for r in runs:
|
||||||
|
if r.get("bold") or r.get("italic") or r.get("underline") \
|
||||||
|
or r.get("sup") or r.get("sub") or r.get("color") \
|
||||||
|
or (r.get("font") and r["font"] != default_font):
|
||||||
|
nontrivial = True; break
|
||||||
|
if not nontrivial: return None
|
||||||
|
|
||||||
|
# Font-Tabelle + Color-Tabelle
|
||||||
|
fonts = [default_font]
|
||||||
|
colors = [] # nur explizit gesetzte (Index 1+)
|
||||||
|
def font_idx(f):
|
||||||
|
if not f: return 0
|
||||||
|
if f not in fonts: fonts.append(f)
|
||||||
|
return fonts.index(f)
|
||||||
|
def color_idx(c):
|
||||||
|
if not c: return 0
|
||||||
|
rgb = _parse_color_to_rgb(c)
|
||||||
|
if rgb in colors: return colors.index(rgb) + 1
|
||||||
|
colors.append(rgb)
|
||||||
|
return len(colors)
|
||||||
|
|
||||||
|
parts = ["{\\rtf1\\ansi\\deff0"]
|
||||||
|
parts.append("{\\fonttbl")
|
||||||
|
for i, f in enumerate(fonts):
|
||||||
|
parts.append("{{\\f{} {};}}".format(i, f))
|
||||||
|
parts.append("}")
|
||||||
|
if colors:
|
||||||
|
parts.append("{\\colortbl;")
|
||||||
|
for r, g, b in colors:
|
||||||
|
parts.append("\\red{}\\green{}\\blue{};".format(r, g, b))
|
||||||
|
parts.append("}")
|
||||||
|
parts.append("\\pard")
|
||||||
|
|
||||||
|
for run in runs:
|
||||||
|
codes = []
|
||||||
|
codes.append("\\f{}".format(font_idx(run.get("font") or default_font)))
|
||||||
|
ci = color_idx(run.get("color")) if run.get("color") else 0
|
||||||
|
if ci > 0: codes.append("\\cf{}".format(ci))
|
||||||
|
codes.append("\\b" if run.get("bold") else "\\b0")
|
||||||
|
codes.append("\\i" if run.get("italic") else "\\i0")
|
||||||
|
codes.append("\\ul" if run.get("underline") else "\\ulnone")
|
||||||
|
if run.get("sup"): codes.append("\\super")
|
||||||
|
elif run.get("sub"): codes.append("\\sub")
|
||||||
|
else: codes.append("\\nosupersub")
|
||||||
|
body = _rtf_escape(run.get("text") or "")
|
||||||
|
parts.append("{} {}".format("".join(codes), body))
|
||||||
|
|
||||||
|
parts.append("}")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def open_with_frame(p1, p2, origin, width, height):
|
def open_with_frame(p1, p2, origin, width, height):
|
||||||
"""Aufgerufen aus text_create.create_text() nach Frame-Pick.
|
"""Aufgerufen aus text_create.create_text() nach Frame-Pick.
|
||||||
Oeffnet das React-WYSIWYG-Editor-Fenster (Topmost) neben dem Frame.
|
Oeffnet das React-WYSIWYG-Editor-Fenster (Topmost) neben dem Frame.
|
||||||
|
|||||||
+196
-37
@@ -2,13 +2,19 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import Icon from './components/Icon'
|
import Icon from './components/Icon'
|
||||||
import { onMessage, notifyReady, send } from './lib/rhinoBridge'
|
import { onMessage, notifyReady, send } from './lib/rhinoBridge'
|
||||||
|
|
||||||
const SYMBOLS = [
|
const SYMBOL_GROUPS = [
|
||||||
'∅', 'Ø', '⌀', '°', '±', '×', '÷',
|
{ title: 'Mathematik / Architektur',
|
||||||
'²', '³', '½', '¼', '¾', '⅓', '⅔',
|
symbols: ['∅', 'Ø', '⌀', '°', '±', '×', '÷', '²', '³', '½', '¼', '¾', '⅓', '⅔',
|
||||||
'≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ',
|
'≤', '≥', '≠', '≈', '∞', '√', '∆', 'π', 'µ'] },
|
||||||
'←', '→', '↑', '↓', '↔', '↕',
|
{ title: 'Pfeile',
|
||||||
'•', '·', '▪', '◆', '★', '☆', '✓', '✗',
|
symbols: ['←', '→', '↑', '↓', '↔', '↕', '⇐', '⇒', '⇑', '⇓', '⇔'] },
|
||||||
'§', '¶', '©', '®', '™',
|
{ title: 'Auszeichnung',
|
||||||
|
symbols: ['•', '·', '▪', '◆', '◇', '★', '☆', '✓', '✗', '§', '¶', '©', '®', '™'] },
|
||||||
|
]
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ value: 'none', label: 'Kein Rahmen' },
|
||||||
|
{ value: 'rect', label: 'Rechteck' },
|
||||||
|
{ value: 'capsule', label: 'Kapsel' },
|
||||||
]
|
]
|
||||||
const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00]
|
const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00]
|
||||||
|
|
||||||
@@ -79,6 +85,113 @@ function Dropdown({ value, onChange, options, width, title }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML im contentEditable → Format-Runs fuer Rhino RTF (Phase 2)
|
||||||
|
function htmlToRuns(rootEl) {
|
||||||
|
const runs = []
|
||||||
|
function flush(text, ctx) {
|
||||||
|
if (text === '') return
|
||||||
|
runs.push({ text, ...ctx })
|
||||||
|
}
|
||||||
|
function walk(node, ctx) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
flush(node.textContent, ctx); return
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||||
|
const tag = node.tagName.toLowerCase()
|
||||||
|
if (tag === 'br') { flush('\n', ctx); return }
|
||||||
|
const nc = { ...ctx }
|
||||||
|
if (tag === 'b' || tag === 'strong') nc.bold = true
|
||||||
|
if (tag === 'i' || tag === 'em') nc.italic = true
|
||||||
|
if (tag === 'u') nc.underline = true
|
||||||
|
if (tag === 'sup') nc.sup = true
|
||||||
|
if (tag === 'sub') nc.sub = true
|
||||||
|
// Computed style oder inline style.
|
||||||
|
const cs = window.getComputedStyle ? window.getComputedStyle(node) : null
|
||||||
|
if (node.style.fontFamily) nc.font = node.style.fontFamily.replace(/['"]/g, '').split(',')[0].trim()
|
||||||
|
else if (cs?.fontFamily && cs.fontFamily !== ctx.font && cs.fontFamily) {
|
||||||
|
// nur uebernehmen wenn explizit anders
|
||||||
|
}
|
||||||
|
if (node.style.color) nc.color = node.style.color
|
||||||
|
if (node.style.fontWeight) {
|
||||||
|
const fw = node.style.fontWeight
|
||||||
|
if (fw === 'bold' || parseInt(fw) >= 600) nc.bold = true
|
||||||
|
}
|
||||||
|
if (node.style.fontStyle === 'italic') nc.italic = true
|
||||||
|
if (node.style.textDecoration?.includes('underline')) nc.underline = true
|
||||||
|
// Legacy <font> Element von execCommand
|
||||||
|
if (tag === 'font') {
|
||||||
|
const c = node.getAttribute('color'); if (c) nc.color = c
|
||||||
|
const f = node.getAttribute('face'); if (f) nc.font = f.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
for (const child of node.childNodes) walk(child, nc)
|
||||||
|
// Block-Element bekommt Newline am Ende
|
||||||
|
if (tag === 'p' || tag === 'div') {
|
||||||
|
if (!runs.length || !runs[runs.length-1].text.endsWith('\n'))
|
||||||
|
flush('\n', ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseCtx = { font: null, color: null, bold: false, italic: false,
|
||||||
|
underline: false, sup: false, sub: false }
|
||||||
|
for (const child of rootEl.childNodes) walk(child, baseCtx)
|
||||||
|
// Trailing-Newline weg
|
||||||
|
if (runs.length && runs[runs.length-1].text === '\n') runs.pop()
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
|
function SymbolPopover({ open, onClose, onPick }) {
|
||||||
|
if (!open) return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div onClick={onClose}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 100,
|
||||||
|
background: 'transparent' }} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', zIndex: 101,
|
||||||
|
marginTop: 4,
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 8,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
maxWidth: 360,
|
||||||
|
}}>
|
||||||
|
{SYMBOL_GROUPS.map((grp, gi) => (
|
||||||
|
<div key={gi} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{grp.title}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||||
|
{grp.symbols.map(s => (
|
||||||
|
<button key={s} onClick={() => { onPick(s); onClose() }}
|
||||||
|
title={`Einfügen: ${s}`}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-input)'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 30, height: 26,
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 14, lineHeight: 1, cursor: 'pointer', padding: 0,
|
||||||
|
transition: 'background 0.12s, border-color 0.12s',
|
||||||
|
}}>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function TextEditorApp() {
|
export default function TextEditorApp() {
|
||||||
const [fonts, setFonts] = useState([])
|
const [fonts, setFonts] = useState([])
|
||||||
const [font, setFont] = useState('Helvetica')
|
const [font, setFont] = useState('Helvetica')
|
||||||
@@ -88,6 +201,12 @@ export default function TextEditorApp() {
|
|||||||
const [underline, setUnderline] = useState(false)
|
const [underline, setUnderline] = useState(false)
|
||||||
const [align, setAlign] = useState('left')
|
const [align, setAlign] = useState('left')
|
||||||
const [color, setColor] = useState(null) // [r,g,b] oder null
|
const [color, setColor] = useState(null) // [r,g,b] oder null
|
||||||
|
// Neue Options
|
||||||
|
const [frame, setFrame] = useState('none') // none | rect | capsule
|
||||||
|
const [horizontalToView, setHorizontalToView] = useState(false)
|
||||||
|
const [rotation, setRotation] = useState(0)
|
||||||
|
const [maskMargin, setMaskMargin] = useState(0)
|
||||||
|
const [symbolsOpen, setSymbolsOpen] = useState(false)
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,11 +253,20 @@ export default function TextEditorApp() {
|
|||||||
const clearColor = () => { setColor(null); exec('foreColor', '#000000') }
|
const clearColor = () => { setColor(null); exec('foreColor', '#000000') }
|
||||||
|
|
||||||
const onCommit = () => {
|
const onCommit = () => {
|
||||||
const text = editorRef.current?.innerText || ''
|
const el = editorRef.current
|
||||||
|
if (!el) return
|
||||||
|
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
|
||||||
|
try { runs = htmlToRuns(el) } catch (e) { console.error(e) }
|
||||||
send('COMMIT', {
|
send('COMMIT', {
|
||||||
text,
|
text,
|
||||||
settings: { font, size, bold, italic, underline, align, color },
|
runs,
|
||||||
|
settings: {
|
||||||
|
font, size, bold, italic, underline, align, color,
|
||||||
|
frame, horizontalToView, rotation, maskMargin,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const onCancel = () => send('CANCEL', {})
|
const onCancel = () => send('CANCEL', {})
|
||||||
@@ -208,34 +336,65 @@ export default function TextEditorApp() {
|
|||||||
<Pill onClick={doSub} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
|
<Pill onClick={doSub} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Symbol-Palette */}
|
{/* Toolbar Row 3: Frame / Rotation / Mask / Horizontal-to-view / Symbole */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)',
|
<Dropdown value={frame} onChange={setFrame} width={130}
|
||||||
textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
title="Rahmen um den Text"
|
||||||
Sonderzeichen
|
options={FRAME_OPTIONS.map(o => (
|
||||||
</span>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
))} />
|
||||||
{SYMBOLS.map(s => (
|
<div style={{
|
||||||
<button key={s} onClick={() => insertText(s)} title={`Einfügen: ${s}`}
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
onMouseEnter={(e) => {
|
height: BAR_H + 2, padding: '0 10px', boxSizing: 'border-box',
|
||||||
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
background: 'var(--bg-input)', border: '1px solid var(--border)',
|
||||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
borderRadius: 999, flexShrink: 0,
|
||||||
}}
|
}} title="Mask-Rand (m) — weisser Hintergrund hinter Text">
|
||||||
onMouseLeave={(e) => {
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Mask</span>
|
||||||
e.currentTarget.style.background = 'var(--bg-input)'
|
<input type="number" step="0.05" min="0"
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
value={maskMargin}
|
||||||
}}
|
onChange={(e) => setMaskMargin(parseFloat(e.target.value) || 0)}
|
||||||
style={{
|
style={{
|
||||||
width: 28, height: 22,
|
width: 50, background: 'transparent', border: 'none', outline: 'none',
|
||||||
background: 'var(--bg-input)',
|
color: 'var(--text-primary)', fontSize: 11,
|
||||||
color: 'var(--text-primary)',
|
fontFamily: 'DM Mono, monospace', padding: 0, textAlign: 'right',
|
||||||
border: '1px solid var(--border)',
|
appearance: 'auto',
|
||||||
borderRadius: 4,
|
}} />
|
||||||
fontSize: 13, lineHeight: 1,
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>m</span>
|
||||||
cursor: 'pointer', padding: 0,
|
</div>
|
||||||
transition: 'background 0.12s, border-color 0.12s',
|
<div style={{
|
||||||
}}>{s}</button>
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
))}
|
height: BAR_H + 2, padding: '0 10px', boxSizing: 'border-box',
|
||||||
|
background: 'var(--bg-input)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 999, flexShrink: 0,
|
||||||
|
}} title="Rotation in Grad">
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Rot</span>
|
||||||
|
<input type="number" step="1" value={rotation}
|
||||||
|
onChange={(e) => setRotation(parseFloat(e.target.value) || 0)}
|
||||||
|
style={{
|
||||||
|
width: 50, background: 'transparent', border: 'none', outline: 'none',
|
||||||
|
color: 'var(--text-primary)', fontSize: 11,
|
||||||
|
fontFamily: 'DM Mono, monospace', padding: 0, textAlign: 'right',
|
||||||
|
appearance: 'auto',
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>°</span>
|
||||||
|
</div>
|
||||||
|
<Pill active={horizontalToView}
|
||||||
|
onClick={() => setHorizontalToView(b => !b)}
|
||||||
|
title="Text steht immer zur Kamera (DrawForward)">
|
||||||
|
<Icon name="screen_rotation" size={13} />
|
||||||
|
<span style={{ fontSize: 10 }}>Zur Kamera</span>
|
||||||
|
</Pill>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Pill onClick={() => setSymbolsOpen(o => !o)}
|
||||||
|
active={symbolsOpen}
|
||||||
|
title="Sonderzeichen einfügen">
|
||||||
|
<Icon name="emoji_symbols" size={13} />
|
||||||
|
<span style={{ fontSize: 10 }}>Symbole</span>
|
||||||
|
</Pill>
|
||||||
|
<SymbolPopover
|
||||||
|
open={symbolsOpen}
|
||||||
|
onClose={() => setSymbolsOpen(false)}
|
||||||
|
onPick={(s) => insertText(s)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user