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:
+102
-17
@@ -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' }}>x²</Pill>
|
||||
<Pill onClick={doSub} title="Tiefstellen (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>
|
||||
</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',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user