Text-Editor: V-Align + Mask-Type/Color + Case-Transform

Erweitert den Editor um die fehlenden Rhino-Text-Optionen:

- Vertical Alignment (Top/Middle/Bottom) als 3 Pill-Buttons in Row 2.
  Backend: text_create._apply_valign mit TextVerticalAlignment-Enum.
- Mask-Type Dropdown (None/Viewport/Solid). Bei Solid erscheint ein
  Mask-Color-Picker. MaskMargin disabled wenn Type=none.
  Backend: schaltet te.MaskEnabled + te.MaskUsesViewportColor +
  te.MaskColor entsprechend.
- Case-Dropdown in Row 2: upper/lower/capitalize/invert. Wirkt nur
  auf die Selektion via execCommand insertText.

open_for_edit liest valign + maskType/Color/Margin aus bestehendem
TextEntity zurueck, damit beim Re-Open der Zustand erhalten bleibt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 13:08:51 +02:00
parent de6f84346c
commit 6b3421e7af
3 changed files with 137 additions and 7 deletions
+84 -5
View File
@@ -247,6 +247,9 @@ export default function TextEditorApp() {
const [frame, setFrame] = useState('none') // none | rect | capsule
const [horizontalToView, setHorizontalToView] = useState(false)
const [rotation, setRotation] = useState(0)
const [valign, setVAlign] = useState('top') // top | middle | bottom
const [maskType, setMaskType] = useState('none') // none | viewport | solid
const [maskColor, setMaskColor] = useState([255, 255, 255])
const [maskMargin, setMaskMargin] = useState(0)
const [symbolsOpen, setSymbolsOpen] = useState(false)
const [styles, setStyles] = useState([])
@@ -324,6 +327,9 @@ export default function TextEditorApp() {
if (s.italic != null) setItalic(!!s.italic)
if (s.underline != null) setUnderline(!!s.underline)
if (s.align) setAlign(s.align)
if (s.valign) setVAlign(s.valign)
if (s.maskType) setMaskType(s.maskType)
if (Array.isArray(s.maskColor)) setMaskColor(s.maskColor)
// Bei Edit-Mode: bestehenden Text in den Editor laden. Wenn Runs
// persistiert sind (= reicher Format-Stand vom letzten Save),
// diese als HTML laden — sonst PlainText fallback.
@@ -420,6 +426,34 @@ export default function TextEditorApp() {
}
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 el = editorRef.current
if (!el) return
@@ -432,8 +466,9 @@ export default function TextEditorApp() {
text,
runs,
settings: {
font, size, bold, italic, underline, align, color,
frame, horizontalToView, rotation, maskMargin,
font, size, bold, italic, underline, align, valign, color,
frame, horizontalToView, rotation,
maskType, maskColor, maskMargin,
},
})
}
@@ -528,24 +563,68 @@ export default function TextEditorApp() {
<Icon name="format_align_right" size={13} />
</Pill>
<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' }}></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>
{/* 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' }}>
<Dropdown value={frame} onChange={setFrame} width={130}
title="Rahmen um den Text"
options={FRAME_OPTIONS.map(o => (
<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={{
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="Mask-Rand (m) — weisser Hintergrund hinter Text">
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>Mask</span>
opacity: maskType === 'none' ? 0.5 : 1,
}} 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"
value={maskMargin}
onChange={(e) => setMaskMargin(parseFloat(e.target.value) || 0)}