// WYSIWYG Rich-Text-Editor-Komponente. Kontrolliert: Props `value`/`onChange`
// steuern den Dokumentzustand; keine interne Dokumentkopie. Toolbar mit
// Bold/Italic/Underline/Strikethrough, Schriftgrösse, Farbe und Presets.
//
// Selektions-Mapping: DOM-Textknoten werden in Dokumentreihenfolge durchlaufen;
// zwischen Block-Elementen (div/p) zählt je eine Absatzgrenze als +1. So entsteht
// ein linearer Zeichen-Offset, der mit TextRange kompatibel ist.
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { docToHtml } from "./renderHtml";
import { Dropdown } from "../ui/Dropdown";
import {
applyMark,
applyPreset,
commonMarkValue,
DEFAULT_PRESETS,
docLength,
emptyDoc,
isMarkActive,
normalizeDoc,
serialize,
toggleMark,
} from "./richText";
import type {
BoolMark,
Marks,
RichTextDoc,
TextRange,
TextStylePreset,
} from "./richText";
// ── Props ────────────────────────────────────────────────────────────────────
export interface RichTextEditorProps {
value: RichTextDoc;
onChange: (doc: RichTextDoc) => void;
presets?: TextStylePreset[];
autoFocus?: boolean;
}
// ── DOM ↔ Modell-Offset-Übersetzung ─────────────────────────────────────────
/**
* Wandelt eine DOM-Position (Node + Offset innerhalb des Nodes) in einen
* linearen Modell-Offset um. Die contenteditable enthält pro Absatz ein
,
* darin -Runs; leere Absätze haben ein .
* Linearer Modell-Offset = Summe der Zeichen in vorigen Absätzen
* + Absatz-Index als Trennzeichen-Offset (+1 je Absatzumbruch)
* + Zeichen innerhalb des aktuellen Absatzes bis zur Position.
*/
function domOffsetToModelOffset(
editorEl: HTMLElement,
targetNode: Node,
targetOffset: number,
): number {
const divs = Array.from(editorEl.children) as HTMLElement[];
let modelOffset = 0;
for (let pIdx = 0; pIdx < divs.length; pIdx++) {
const div = divs[pIdx];
if (div === targetNode || div.contains(targetNode)) {
let found = false;
let paraOffset = 0;
const walkPara = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
if (node === targetNode) {
paraOffset += targetOffset;
found = true;
return true;
}
paraOffset += (node as Text).length;
return false;
}
for (const child of Array.from(node.childNodes)) {
if ((child as Element).tagName === "BR") {
if (child === targetNode || node === targetNode) {
found = true;
return true;
}
continue;
}
if (walkPara(child)) return true;
}
return false;
};
if (targetNode === div) {
const children = Array.from(div.childNodes);
const limit = Math.min(targetOffset, children.length);
for (let i = 0; i < limit; i++) {
const child = children[i];
if (child.nodeType === Node.TEXT_NODE) {
paraOffset += (child as Text).length;
} else {
paraOffset += (child as Element).textContent?.length ?? 0;
}
}
found = true;
} else {
walkPara(div);
}
if (!found) {
paraOffset = div.textContent?.replace(/\n/g, "").length ?? 0;
}
return modelOffset + paraOffset;
}
const paraText = div.textContent ?? "";
modelOffset += paraText.length + 1; // +1 für '\n'-Trennzeichen
}
return modelOffset;
}
/**
* Findet für einen linearen Modell-Offset den DOM-Node und Offset darin.
*/
function modelOffsetToDomPos(
editorEl: HTMLElement,
modelOffset: number,
): { node: Node; offset: number } | null {
const divs = Array.from(editorEl.children) as HTMLElement[];
let remaining = modelOffset;
for (let pIdx = 0; pIdx < divs.length; pIdx++) {
const div = divs[pIdx];
const paraLen = div.textContent?.length ?? 0;
if (remaining <= paraLen) {
let found: { node: Node; offset: number } | null = null;
let chars = remaining;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const len = (node as Text).length;
if (chars <= len) {
found = { node, offset: chars };
return true;
}
chars -= len;
return false;
}
if ((node as Element).tagName === "BR") {
if (chars === 0) {
found = { node: div, offset: 0 };
return true;
}
return false;
}
for (const child of Array.from(node.childNodes)) {
if (walk(child)) return true;
}
return false;
};
walk(div);
if (!found) {
found = { node: div, offset: div.childNodes.length };
}
return found;
}
remaining -= paraLen + 1;
}
const lastDiv = divs[divs.length - 1];
if (!lastDiv) return null;
return { node: lastDiv, offset: lastDiv.childNodes.length };
}
// ── Plain-Text aus Editor-DOM ────────────────────────────────────────────────
function editorInnerText(editorEl: HTMLElement): string {
const divs = Array.from(editorEl.children) as HTMLElement[];
if (divs.length === 0) {
return editorEl.innerText.replace(/\n$/, "");
}
return divs
.map((div) => {
if (div.children.length === 1 && (div.children[0] as Element).tagName === "BR") {
return "";
}
return div.textContent ?? "";
})
.join("\n");
}
// ── Dokument aus bearbeitetem Text rekonstruieren ────────────────────────────
function rebuildDocFromText(oldDoc: RichTextDoc, newPlain: string): RichTextDoc {
const newLines = newPlain.split("\n");
const oldParas = oldDoc.paragraphs;
const paragraphs = newLines.map((line, pIdx) => {
const oldPara = oldParas[pIdx];
if (!oldPara) {
return { runs: line.length ? [{ text: line, marks: {} }] : [] };
}
type MarkChar = { ch: string; marks: Marks };
const oldChars: MarkChar[] = [];
for (const run of oldPara.runs) {
for (const ch of run.text) {
oldChars.push({ ch, marks: run.marks });
}
}
const newChars: MarkChar[] = [];
let oldIdx = 0;
for (const ch of line) {
if (oldIdx < oldChars.length && oldChars[oldIdx].ch === ch) {
newChars.push(oldChars[oldIdx]);
oldIdx++;
} else {
const nearMarks =
oldIdx < oldChars.length ? oldChars[oldIdx].marks : (oldChars[oldIdx - 1]?.marks ?? {});
newChars.push({ ch, marks: nearMarks });
}
}
if (newChars.length === 0) return { runs: [] };
const runs: { text: string; marks: Marks }[] = [];
let current = { text: newChars[0].ch, marks: newChars[0].marks };
for (let i = 1; i < newChars.length; i++) {
const { ch, marks } = newChars[i];
if (JSON.stringify(marks) === JSON.stringify(current.marks)) {
current.text += ch;
} else {
runs.push(current);
current = { text: ch, marks };
}
}
runs.push(current);
return { runs };
});
return normalizeDoc({ version: 1, paragraphs });
}
// ── Absatz-Einfügung / Löschung ──────────────────────────────────────────────
function insertParagraphBreak(doc: RichTextDoc, pos: number): RichTextDoc {
const paras = doc.paragraphs;
let remaining = pos;
let pIdx = 0;
let charOffset = 0;
for (let i = 0; i < paras.length; i++) {
const len = paras[i].runs.reduce((n, r) => n + r.text.length, 0);
if (remaining <= len) {
pIdx = i;
charOffset = remaining;
break;
}
remaining -= len + 1;
if (i === paras.length - 1) {
return normalizeDoc({ version: 1, paragraphs: [...paras, { runs: [] }] });
}
}
const para = paras[pIdx];
type MarkChar = { ch: string; marks: Marks };
const chars: MarkChar[] = [];
for (const run of para.runs) {
for (const ch of run.text) {
chars.push({ ch, marks: run.marks });
}
}
const buildPara = (chs: MarkChar[]) => {
if (chs.length === 0) return { runs: [] };
const runs: { text: string; marks: Marks }[] = [];
let cur = { text: chs[0].ch, marks: chs[0].marks };
for (let i = 1; i < chs.length; i++) {
if (JSON.stringify(chs[i].marks) === JSON.stringify(cur.marks)) {
cur.text += chs[i].ch;
} else {
runs.push(cur);
cur = { text: chs[i].ch, marks: chs[i].marks };
}
}
runs.push(cur);
return { runs };
};
const before = buildPara(chars.slice(0, charOffset));
const after = buildPara(chars.slice(charOffset));
const newParas = [
...paras.slice(0, pIdx),
{ ...before, align: para.align },
{ ...after, align: para.align },
...paras.slice(pIdx + 1),
];
return normalizeDoc({ version: 1, paragraphs: newParas });
}
function plainTextFromDoc(doc: RichTextDoc): string {
return doc.paragraphs.map((p) => p.runs.map((r) => r.text).join("")).join("\n");
}
function deleteCharAt(doc: RichTextDoc, pos: number): RichTextDoc {
if (pos < 0) return doc;
const plain = plainTextFromDoc(doc);
if (pos >= plain.length) return doc;
if (plain[pos] === "\n") {
return mergeParaAt(doc, pos);
}
const newPlain = plain.slice(0, pos) + plain.slice(pos + 1);
return rebuildDocFromText(doc, newPlain);
}
function mergeParaAt(doc: RichTextDoc, _pos: number): RichTextDoc {
const paras = doc.paragraphs;
let cursor = 0;
for (let i = 0; i < paras.length - 1; i++) {
const len = paras[i].runs.reduce((n, r) => n + r.text.length, 0);
cursor += len;
if (cursor === _pos) {
const merged = {
runs: [...paras[i].runs, ...paras[i + 1].runs],
align: paras[i].align,
};
const newParas = [...paras.slice(0, i), merged, ...paras.slice(i + 2)];
return normalizeDoc({ version: 1, paragraphs: newParas });
}
cursor += 1;
}
return doc;
}
// ── Selektion lesen / wiederherstellen ──────────────────────────────────────
function readSelection(editorEl: HTMLElement): TextRange | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!editorEl.contains(range.commonAncestorContainer)) return null;
const start = domOffsetToModelOffset(editorEl, range.startContainer, range.startOffset);
const end = domOffsetToModelOffset(editorEl, range.endContainer, range.endOffset);
return { start, end };
}
function restoreSelection(editorEl: HTMLElement, selRange: TextRange | null): void {
if (!selRange) return;
const sel = window.getSelection();
if (!sel) return;
const startPos = modelOffsetToDomPos(editorEl, selRange.start);
const endPos = modelOffsetToDomPos(editorEl, selRange.end);
if (!startPos || !endPos) return;
try {
const range = document.createRange();
range.setStart(startPos.node, startPos.offset);
range.setEnd(endPos.node, endPos.offset);
sel.removeAllRanges();
sel.addRange(range);
} catch {
// Ungültige DOM-Position ignorieren.
}
}
// ── Komponente ───────────────────────────────────────────────────────────────
export function RichTextEditor({
value,
onChange,
presets,
autoFocus,
}: RichTextEditorProps): JSX.Element {
const editorRef = useRef(null);
// Letzter in innerHTML geschriebener serialisierter Zustand — verhindert
// Cursor-Sprünge durch unnötige DOM-Updates.
const lastSerializedRef = useRef("");
// Verhindert das nächste onInput nach programmatischem innerHTML-Set.
const suppressNextInput = useRef(false);
// Ausgewählter Bereich im linearen Modell-Offset-Raum.
const [selRange, setSelRange] = useState(null);
// Immer aktuelles value/onChange ohne Stale-Closure-Problem (keine useCallback-Deps).
const valueRef = useRef(value);
const onChangeRef = useRef<(doc: RichTextDoc) => void>(onChange);
valueRef.current = value;
onChangeRef.current = onChange;
const effectivePresets = presets ?? DEFAULT_PRESETS;
// ── HTML-Renderer ──────────────────────────────────────────────────────────
const renderHtml = useCallback((d: RichTextDoc): string => {
return docToHtml(d, { basePt: 12, color: "var(--ink)" });
}, []);
/**
* Setzt innerHTML direkt. `suppressInput` steuert, ob das daraus folgende
* Browser-onInput-Ereignis unterdrückt wird (true nur bei programmatischen
* Edits aus Keyboard-/Toolbar-Handlern, nicht beim externen Prop-Update).
*/
const setEditorHtml = useCallback(
(d: RichTextDoc, restoreSel: TextRange | null = null, suppressInput = false) => {
const el = editorRef.current;
if (!el) return;
const serial = serialize(d);
if (serial !== lastSerializedRef.current) {
if (suppressInput) suppressNextInput.current = true;
lastSerializedRef.current = serial;
el.innerHTML = renderHtml(d);
}
if (restoreSel) {
restoreSelection(el, restoreSel);
}
},
[renderHtml],
);
// ── Initiale Render + externe prop-Änderungen ──────────────────────────────
useEffect(() => {
const el = editorRef.current;
if (!el) return;
// Programmatisches innerHTML-Set feuert kein Browser-onInput — kein suppress nötig.
const serial = serialize(value);
lastSerializedRef.current = serial;
el.innerHTML = renderHtml(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Nur beim Mount.
useEffect(() => {
// Externe prop-Änderungen übernehmen ohne Cursor zu versetzen.
setEditorHtml(value, null);
}, [value, setEditorHtml]);
useEffect(() => {
if (autoFocus) {
editorRef.current?.focus();
}
}, [autoFocus]);
// ── Selektion verfolgen ────────────────────────────────────────────────────
const captureSelection = useCallback(() => {
const el = editorRef.current;
if (!el) return;
const range = readSelection(el);
setSelRange(range);
}, []);
// ── Eingabe-Handler ────────────────────────────────────────────────────────
const handleInput = useCallback(() => {
if (suppressNextInput.current) {
suppressNextInput.current = false;
return;
}
const el = editorRef.current;
if (!el) return;
const currentSel = readSelection(el);
const newPlain = editorInnerText(el);
const newDoc = rebuildDocFromText(valueRef.current, newPlain);
const serial = serialize(newDoc);
lastSerializedRef.current = serial;
onChangeRef.current(newDoc);
// HTML neu setzen (ohne suppressNextInput — programmatische DOM-Änderungen
// lösen kein natives input-Event aus) und Selektion wiederherstellen.
el.innerHTML = renderHtml(newDoc);
if (currentSel) restoreSelection(el, currentSel);
setSelRange(currentSel);
}, [renderHtml]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const el = editorRef.current;
if (!el) return;
const currentSel = readSelection(el);
if (e.key === "Enter") {
e.preventDefault();
const cur = valueRef.current;
const pos = currentSel?.start ?? plainTextFromDoc(cur).length;
const newDoc = insertParagraphBreak(cur, pos);
onChangeRef.current(newDoc);
const newSel: TextRange = { start: pos + 1, end: pos + 1 };
setEditorHtml(newDoc, newSel, true);
setSelRange(newSel);
return;
}
if (e.key === "Backspace") {
if (currentSel && currentSel.start !== currentSel.end) {
return;
}
const pos = currentSel?.start ?? 0;
if (pos === 0) return;
e.preventDefault();
const cur = valueRef.current;
const newDoc = deleteCharAt(cur, pos - 1);
const newPos = pos - 1;
onChangeRef.current(newDoc);
const newSel: TextRange = { start: newPos, end: newPos };
setEditorHtml(newDoc, newSel, true);
setSelRange(newSel);
return;
}
if (e.key === "Delete") {
if (currentSel && currentSel.start !== currentSel.end) {
return;
}
const pos = currentSel?.start ?? 0;
const cur = valueRef.current;
const plain = plainTextFromDoc(cur);
if (pos >= plain.length) return;
e.preventDefault();
const newDoc = deleteCharAt(cur, pos);
onChangeRef.current(newDoc);
const newSel: TextRange = { start: pos, end: pos };
setEditorHtml(newDoc, newSel, true);
setSelRange(newSel);
}
},
[setEditorHtml],
);
// ── Toolbar-Aktionen ───────────────────────────────────────────────────────
const effectiveRange = useCallback((): TextRange => {
if (selRange && selRange.start !== selRange.end) return selRange;
const len = docLength(valueRef.current);
return { start: 0, end: Math.max(1, len) };
}, [selRange]);
const handleToggleMark = useCallback(
(mark: BoolMark) => {
const range = effectiveRange();
const newDoc = toggleMark(valueRef.current, range, mark);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectiveRange, selRange, setEditorHtml],
);
const handleSizeChange = useCallback(
(e: React.ChangeEvent) => {
const v = parseFloat(e.target.value);
if (isNaN(v) || v <= 0) return;
const range = effectiveRange();
const newDoc = applyMark(valueRef.current, range, "sizePt", v);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectiveRange, selRange, setEditorHtml],
);
const handleColorChange = useCallback(
(e: React.ChangeEvent) => {
const range = effectiveRange();
const newDoc = applyMark(valueRef.current, range, "color", e.target.value);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectiveRange, selRange, setEditorHtml],
);
const handlePreset = useCallback(
(id: string) => {
const preset = effectivePresets.find((p) => p.id === id);
if (!preset) return;
const range = selRange ?? { start: 0, end: 0 };
const newDoc = applyPreset(valueRef.current, range, preset);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectivePresets, selRange, setEditorHtml],
);
// ── Toolbar-Zustand berechnen ──────────────────────────────────────────────
const queryRange: TextRange =
selRange && selRange.start !== selRange.end
? selRange
: { start: 0, end: Math.max(1, docLength(value)) };
const isBold = isMarkActive(value, queryRange, "bold");
const isItalic = isMarkActive(value, queryRange, "italic");
const isUnderline = isMarkActive(value, queryRange, "underline");
const isStrike = isMarkActive(value, queryRange, "strike");
const isSuper = isMarkActive(value, queryRange, "super");
const isSub = isMarkActive(value, queryRange, "sub");
const currentSize = commonMarkValue(value, queryRange, "sizePt") ?? 12;
const rawColor = commonMarkValue(value, queryRange, "color") ?? "#ededed";
const currentColor = rawColor.startsWith("#") ? rawColor : "#ededed";
// ── Render ────────────────────────────────────────────────────────────────
return (