Text-Editor: Style-Picker focus-restore + Runs-Round-Trip beim Reopen

User-Bugs:
1. Stil-Picker setzt Toolbar-State aber Editor zeigt alle Texte gleich
2. Beim Reopen (Doppelklick auf DOSSIER-Text) ist Editor leer/unformatiert

Fix 1 — applyStyle focus-restore:
Style-Picker stiehlt Focus → savedRange ist da, aber execCommand
ohne Focus auf editable schreibt nichts. Fix: editorRef.focus() +
restoreSelection() VOR execCommand. Plus Size jetzt via inline-span
(execCommand fontSize geht nur 1-7 Scale, kein Pixel).

Fix 2 — Runs persistieren als UserString fuer Round-Trip:
- _commit: runs als JSON in attrs.SetUserString("dossier_text_runs")
  abgelegt zusammen mit dem dossier_text-Tag
- open_for_edit: liest UserString, parsed JSON → initial_runs
- Bridge INIT: sendet initialRuns mit
- Frontend onMessage INIT: wenn initialRuns vorhanden → runsToHtml()
  baut HTML mit spans (font-family, font-size, color) + Tags (b/i/u/
  sup/sub) — Editor zeigt jetzt beim Reopen das tatsaechliche reiche
  Format statt PlainText im Default-Helvetica

runsToHtml: pro run text/style-Splittung per \n → <br>. Escaping fuer
&<> damit kein XSS / Parse-Fehler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 02:13:42 +02:00
parent 3bd949e590
commit 211078d229
2 changed files with 77 additions and 10 deletions
+55 -8
View File
@@ -146,6 +146,40 @@ function htmlToRuns(rootEl) {
return runs
}
// Runs zurueck in HTML konvertieren — fuer das Re-Open eines bestehenden
// DOSSIER-Texts. Spiegelt die persistierten Format-Runs als <span> mit
// inline-style + Tags (b/i/u/sup/sub). Newlines werden zu <br>.
function runsToHtml(runs) {
if (!Array.isArray(runs)) return ''
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const out = []
for (const r of runs) {
const raw = r.text || ''
if (!raw) continue
// Per Newline splitten — jede non-leere Section bekommt eigene span
const segs = raw.split('\n')
for (let i = 0; i < segs.length; i++) {
if (i > 0) out.push('<br>')
const seg = segs[i]
if (!seg) continue
const styles = []
if (r.font) styles.push(`font-family: ${r.font}`)
if (r.fontSizePx) styles.push(`font-size: ${r.fontSizePx}px`)
if (r.color) styles.push(`color: ${r.color}`)
let opens = '', closes = ''
if (r.bold) { opens += '<b>'; closes = '</b>' + closes }
if (r.italic) { opens += '<i>'; closes = '</i>' + closes }
if (r.underline) { opens += '<u>'; closes = '</u>' + closes }
if (r.sup) { opens += '<sup>'; closes = '</sup>' + closes }
else if (r.sub) { opens += '<sub>'; closes = '</sub>' + closes }
const inner = opens + escape(seg) + closes
if (styles.length) out.push(`<span style="${styles.join('; ')}">${inner}</span>`)
else out.push(inner)
}
}
return out.join('')
}
function SymbolPopover({ open, onClose, onPick }) {
if (!open) return null
return (
@@ -290,11 +324,16 @@ export default function TextEditorApp() {
if (s.italic != null) setItalic(!!s.italic)
if (s.underline != null) setUnderline(!!s.underline)
if (s.align) setAlign(s.align)
// Bei Edit-Mode: bestehenden Text in den Editor laden
// 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.
const initialText = data.initialText || ''
const initialRuns = data.initialRuns
setTimeout(() => {
if (editorRef.current) {
if (initialText) {
if (initialRuns && initialRuns.length > 0) {
editorRef.current.innerHTML = runsToHtml(initialRuns)
} else if (initialText) {
editorRef.current.innerText = initialText
}
editorRef.current.focus()
@@ -335,8 +374,9 @@ export default function TextEditorApp() {
exec(a === 'center' ? 'justifyCenter' : a === 'right' ? 'justifyRight' : 'justifyLeft') }
const doSup = () => exec('superscript')
const doSub = () => exec('subscript')
// Stil anwenden: Toolbar-State setzen + (wenn Auswahl im Editor) via
// execCommand auf die Selektion applizieren.
// Stil anwenden: Toolbar-State setzen + Selection restoren + via
// execCommand applizieren (auf Selektion oder als Default fuer's
// kommende Tippen).
const applyStyle = (style) => {
if (!style) return
// Toolbar-State synchronisieren
@@ -346,13 +386,20 @@ export default function TextEditorApp() {
setItalic(!!style.italic)
setUnderline(!!style.underline)
if (style.align) setAlign(style.align)
// Auf Selektion applizieren (oder zumindest fuer kommendes Tippen)
// Selection wiederherstellen + Editor-Focus damit execCommand greift.
editorRef.current?.focus()
restoreSelection()
try {
const sel = window.getSelection()
const hasSel = sel && sel.rangeCount > 0 && sel.toString().length > 0
document.execCommand('styleWithCSS', false, true)
if (style.font) document.execCommand('fontName', false, style.font)
// Bold/Italic/Underline togglen, falls Selektion da ist oder nicht
// 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 wantItal = !!style.italic
const wantUnd = !!style.underline