Text-Editor: Selection-Preservation + per-Span Font/Size

User-Bug: Stile aendern nichts im Editor oder springen alle in eine
Zeile. Mit "Herumfummeln" partiell ge-fixed. Root-Causes:

1. Toolbar-Buttons stehlen Focus aus Editor → Selection futsch →
   execCommand wirkt auf nichts. Fix: onMouseDown + preventDefault
   auf B/I/U/Sup/Sub/Align (Pill akzeptiert jetzt onMouseDown prop).

2. Editor-div hat fontFamily/fontSize aus React-State → ueberschreibt
   per-Span-Styles → alles sieht gleich aus. Fix: editor-div hat
   statische Defaults (Helvetica 20px), per-Selection Styles wirken
   ueber span-Wrapping (applyInlineStyleToSelection).

3. Newlines kollabieren (text springt auf eine Zeile). Fix:
   white-space: pre-wrap auf editor-div.

4. Font/Size dropdowns: alter execCommand fontName war buggy. Neu:
   applyInlineStyleToSelection('font-family', font) bzw. 'font-size'
   wickelt die Selektion in ein <span style="..."> ein, neue Selection
   liegt auf dem Span (Folge-Operationen wirken sauber).

5. Selection-change Event-Listener speichert die letzte Editor-Selection
   in savedRangeRef. restoreSelection() vor jeder Operation stellt sie
   wieder her — robust auch wenn der Focus zwischendurch weg war.

Backend (_runs_to_rtf): BASE_PX = base_size_m * 100 statt hardcoded 14.
Frontend rendert 1m = 100px, also entspricht base_size_m*100px dem
\\fs20 in RTF (= 1.0× TextEntity.TextHeight). _commit passes settings.
size mit, damit das Mapping stimmt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 01:54:25 +02:00
parent f4404db64a
commit 51987dcc38
2 changed files with 112 additions and 22 deletions
+102 -17
View File
@@ -20,10 +20,11 @@ const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00]
const BAR_H = 22
function Pill({ children, onClick, active, disabled, title, style }) {
function Pill({ children, onClick, onMouseDown, active, disabled, title, style }) {
return (
<button
onClick={onClick}
onMouseDown={onMouseDown}
disabled={disabled}
title={title}
onMouseEnter={(e) => {
@@ -216,6 +217,63 @@ export default function TextEditorApp() {
const [symbolsOpen, setSymbolsOpen] = useState(false)
const [styles, setStyles] = useState([])
const editorRef = useRef(null)
const savedRangeRef = useRef(null)
// 1m model = 100px im Editor (= "richtungsweisende" Optik). Pro-Run
// Pixel werden ans Backend als fontSizePx geschickt; das mappt sie
// zurueck zu \fs in der RTF mit base = settings.size * 100.
const sizeToPx = (sizeM) => Math.max(8, Math.round(sizeM * 100))
// Aktuelle Selection im Editor merken (selectionchange Event). Wenn
// User dann ausserhalb des Editors klickt (z.B. Toolbar-Dropdown),
// koennen wir die Selection wieder herstellen bevor wir Aenderungen
// applizieren.
useEffect(() => {
const onSelChange = () => {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
const ed = editorRef.current
if (!ed) return
if (ed.contains(sel.anchorNode)) {
try { savedRangeRef.current = sel.getRangeAt(0).cloneRange() }
catch (e) {}
}
}
document.addEventListener('selectionchange', onSelChange)
// styleWithCSS aktivieren — execCommand inline statt deprecated <font>
try { document.execCommand('styleWithCSS', false, true) } catch (e) {}
return () => document.removeEventListener('selectionchange', onSelChange)
}, [])
const restoreSelection = () => {
if (!savedRangeRef.current) return
const sel = window.getSelection()
sel.removeAllRanges()
try { sel.addRange(savedRangeRef.current) } catch (e) {}
}
// Wrap aktuelle Selektion in ein <span> mit gegebenem CSS-Property
const applyInlineStyleToSelection = (styleProp, value) => {
restoreSelection()
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
const range = sel.getRangeAt(0)
if (range.collapsed) return
const span = document.createElement('span')
span.style.setProperty(styleProp, value)
try {
const contents = range.extractContents()
span.appendChild(contents)
range.insertNode(span)
// Neue Selektion auf das eingefuegte Span legen
const newRange = document.createRange()
newRange.selectNodeContents(span)
sel.removeAllRanges()
sel.addRange(newRange)
// Saved-Range updaten damit Folge-Operationen wirken
savedRangeRef.current = newRange.cloneRange()
} catch (e) { console.error('applyInlineStyle', e) }
}
useEffect(() => {
onMessage('INIT', (data) => {
@@ -244,16 +302,25 @@ export default function TextEditorApp() {
const exec = (cmd, value) => {
try {
restoreSelection()
document.execCommand(cmd, false, value)
editorRef.current?.focus()
} catch (e) { console.error('execCommand', cmd, e) }
}
const insertText = (s) => {
try {
restoreSelection()
document.execCommand('insertText', false, s)
editorRef.current?.focus()
} catch (e) { console.error('insertText', e) }
}
// Mouse-Down-Guard fuer Toolbar-Buttons: preventDefault verhindert
// dass der Button-Klick den Focus aus dem Editor stiehlt (sonst
// verschwindet die Selektion bevor execCommand laufen kann).
const onBtnMouseDown = (fn) => (e) => {
e.preventDefault()
fn()
}
const toggleBold = () => { setBold(b => !b); exec('bold') }
const toggleItalic = () => { setItalic(b => !b); exec('italic') }
@@ -342,16 +409,28 @@ export default function TextEditorApp() {
<option key={s.id} value={s.id}>{s.name}</option>
)),
]} />
<Dropdown value={font} onChange={(v) => { setFont(v); exec('fontName', v) }}
width={150} title="Schrift"
<Dropdown value={font}
onChange={(v) => {
setFont(v)
// Auf Selektion applizieren: span-Wrap mit font-family
applyInlineStyleToSelection('font-family', v)
editorRef.current?.focus()
}}
width={150} title="Schrift — wenn nichts ausgewählt: gilt für ganzen Text als Default"
options={
fonts.length === 0
? <option value=""> Fonts laden </option>
: fonts.map(f => <option key={f} value={f}>{f}</option>)
} />
<Dropdown value={String(size)}
onChange={(v) => setSize(parseFloat(v))}
width={90} title="Texthöhe (m)"
onChange={(v) => {
const n = parseFloat(v)
setSize(n)
// Auf Selektion applizieren via span-Wrap
applyInlineStyleToSelection('font-size', sizeToPx(n) + 'px')
editorRef.current?.focus()
}}
width={90} title="Texthöhe (m) — wenn nichts ausgewählt: gilt für ganzen Text"
options={SIZE_PRESETS.map(s => (
<option key={s} value={String(s)}>{s.toFixed(2)} m</option>
))}
@@ -371,30 +450,33 @@ export default function TextEditorApp() {
<Pill onClick={clearColor} title="Layer-Farbe verwenden">Layer</Pill>
</div>
{/* Toolbar Row 2: B I U + Align + Sup/Sub */}
{/* Toolbar Row 2: B I U + Align + Sup/Sub
onMouseDown preventDefault verhindert dass Buttons den Focus
aus dem Editor stehlen → Selection bleibt erhalten → execCommand
wirkt richtig auf den markierten Text. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Pill active={bold} onClick={toggleBold} title="Fett">
<Pill active={bold} onMouseDown={onBtnMouseDown(toggleBold)} title="Fett">
<Icon name="format_bold" size={13} />
</Pill>
<Pill active={italic} onClick={toggleItalic} title="Kursiv">
<Pill active={italic} onMouseDown={onBtnMouseDown(toggleItalic)} title="Kursiv">
<Icon name="format_italic" size={13} />
</Pill>
<Pill active={underline} onClick={toggleUnderline} title="Unterstrichen">
<Pill active={underline} onMouseDown={onBtnMouseDown(toggleUnderline)} title="Unterstrichen">
<Icon name="format_underlined" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill active={align === 'left'} onClick={() => doAlign('left')} title="Links">
<Pill active={align === 'left'} onMouseDown={onBtnMouseDown(() => doAlign('left'))} title="Links">
<Icon name="format_align_left" size={13} />
</Pill>
<Pill active={align === 'center'} onClick={() => doAlign('center')} title="Mittig">
<Pill active={align === 'center'} onMouseDown={onBtnMouseDown(() => doAlign('center'))} title="Mittig">
<Icon name="format_align_center" size={13} />
</Pill>
<Pill active={align === 'right'} onClick={() => doAlign('right')} title="Rechts">
<Pill active={align === 'right'} onMouseDown={onBtnMouseDown(() => doAlign('right'))} title="Rechts">
<Icon name="format_align_right" size={13} />
</Pill>
<div style={{ width: 6 }} />
<Pill onClick={doSup} title="Hochstellen (x²)" style={{ fontFamily: 'serif' }}></Pill>
<Pill onClick={doSub} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
<Pill onMouseDown={onBtnMouseDown(doSup)} title="Hochstellen (x²)" style={{ fontFamily: 'serif' }}></Pill>
<Pill onMouseDown={onBtnMouseDown(doSub)} title="Tiefstellen (x₂)" style={{ fontFamily: 'serif' }}>x₂</Pill>
</div>
{/* Toolbar Row 3: Frame / Rotation / Mask / Horizontal-to-view / Symbole */}
@@ -459,7 +541,9 @@ export default function TextEditorApp() {
</div>
</div>
{/* WYSIWYG Editor */}
{/* WYSIWYG Editor — kein state-getriebenes fontFamily/fontSize hier
(sonst ueberschreiben die Container-Styles die per-Span-Styles).
white-space pre-wrap preserves Newlines + Spaces. */}
<div
ref={editorRef}
contentEditable
@@ -472,11 +556,12 @@ export default function TextEditorApp() {
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: font,
fontSize: 14, lineHeight: 1.5,
fontFamily: 'Helvetica, sans-serif',
fontSize: 20, lineHeight: 1.5,
outline: 'none',
overflowY: 'auto',
textAlign: align,
whiteSpace: 'pre-wrap',
}}
/>