Files
DOSSIER-STANDALONE/src/text/richText.ts
T
karim d0b94a75ec Zeilenhöhe-Regler statt "+ Text"; Linienstil editierbar; DOSSIER-Audit gesichert
- "+ Text"-Knopf entfernt (war ohnehin immer deaktiviert, kein Werkzeug
  dahinter — Text wird ein eigenständiges Zeichenwerkzeug, AUDIT B1).
  An seiner Stelle ein Zeilenhöhe-Regler (Stepper, Vielfaches der
  Schriftgrösse) — neues Paragraph.lineHeight, durchgereicht bis in
  HTML-Vorschau (line-height) und SVG-Render (kumulierte Zeilen-
  Vorschübe statt festem lineGap).
- Linienstil im Attribute-Panel war rein informativ (kein Setter im
  Host-Kontrakt). onSetSelectionLineStyle ergänzt (nur Drawing2D),
  jetzt echtes Dropdown statt "—"-Anzeige.
- docs/design/dossier-feature-audit.md: der ausführliche A1-A6/B1-B4/
  C1-C3/D1-D3/E-Auditbericht aus einer alten Session gesichert (lag
  bisher nur im Transkript, nicht im Repo) — Quelle der HANDOVER.md-
  AUDIT-Kürzel.
2026-07-04 05:36:18 +02:00

457 lines
16 KiB
TypeScript

// Rich-Text-Dokumentmodell — die wiederverwendbare Grundlage für TextEntity-
// Annotationen (Text-/Stempel-Bauteil). Bewusst frei von App-Store und React:
// ein reines Datenmodell + Operationen, unit-getestet.
//
// Aufbau (an DOSSIERs text_editor.py angelehnt: Runs mit text/font/bold/italic/
// underline/strike/sup/sub/color, Grössen in Punkt):
//
// RichTextDoc = { version, paragraphs: Paragraph[] }
// Paragraph = { runs: TextRun[], align? }
// TextRun = { text: string, marks: Marks }
// Marks = { bold?, italic?, underline?, strike?, super?, sub?,
// font?, sizePt?, color? }
//
// Absätze trennen Zeilen (harte Umbrüche); innerhalb eines Absatzes tragen die
// Runs die Zeichenformatierung. Ein „Zeichen-Offset" adressiert das Dokument
// linear über alle Absätze hinweg, wobei jeder Absatzumbruch als EIN Zeichen
// zählt (wie „\n"). Das erlaubt Bereichs-Operationen (applyMark/toggleMark) über
// Absatzgrenzen hinweg.
/** Zeichen-Marks eines Runs. Alle optional; fehlend = „nicht gesetzt". */
export interface Marks {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
/** Hochgestellt. super und sub schliessen sich gegenseitig aus. */
super?: boolean;
/** Tiefgestellt. */
sub?: boolean;
/** Schriftfamilie (CSS font-family-Wert), z. B. "Helvetica". */
font?: string;
/** Schriftgrösse in typografischen Punkt (pt). */
sizePt?: number;
/** Farbe als "#rrggbb". */
color?: string;
}
/** Ein zusammenhängender Textabschnitt mit einheitlicher Formatierung. */
export interface TextRun {
text: string;
marks: Marks;
}
/** Absatz-Ausrichtung. */
export type Align = "left" | "center" | "right";
/** Zeilenhöhe als Vielfaches der Basisgrösse, wenn nicht am Absatz gesetzt. */
export const DEFAULT_LINE_HEIGHT = 1.3;
/** Ein Absatz: eine Zeile aus Runs plus optionale Ausrichtung/Zeilenhöhe. */
export interface Paragraph {
runs: TextRun[];
align?: Align;
/** Zeilenhöhe als Vielfaches der Basisgrösse (z. B. 1.3 = 130 %). */
lineHeight?: number;
}
/** Das gesamte Rich-Text-Dokument. */
export interface RichTextDoc {
version: 1;
paragraphs: Paragraph[];
}
/** Namen der booleschen Marks (togglebar). */
export type BoolMark = "bold" | "italic" | "underline" | "strike" | "super" | "sub";
/** Ein Zeichen-Bereich [start, end) im linearen Dokument-Offset. */
export interface TextRange {
start: number;
end: number;
}
// ── Konstruktoren ──────────────────────────────────────────────────────────
/** Leeres Dokument (ein leerer Absatz). */
export function emptyDoc(): RichTextDoc {
return { version: 1, paragraphs: [{ runs: [] }] };
}
/**
* Dokument aus reinem Text. Zeilenumbrüche ("\n") werden zu Absätzen; die
* übergebenen Marks gelten für den ganzen Text.
*/
export function docFromText(text: string, marks: Marks = {}): RichTextDoc {
const lines = text.split("\n");
const paragraphs: Paragraph[] = lines.map((line) => ({
runs: line.length ? [{ text: line, marks: { ...marks } }] : [],
}));
return { version: 1, paragraphs };
}
/** Erzeugt einen Run (defensive Kopie der Marks). */
export function makeRun(text: string, marks: Marks = {}): TextRun {
return { text, marks: { ...marks } };
}
// ── Extraktion ─────────────────────────────────────────────────────────────
/** Reintext des Dokuments; Absätze werden mit "\n" verbunden. */
export function plainText(doc: RichTextDoc): string {
return doc.paragraphs
.map((p) => p.runs.map((r) => r.text).join(""))
.join("\n");
}
/** Ist das Dokument (nach Reintext) leer? */
export function isEmpty(doc: RichTextDoc): boolean {
return plainText(doc).length === 0;
}
/** Gesamtlänge in Zeichen (inkl. Absatzumbrüche als je 1 Zeichen). */
export function docLength(doc: RichTextDoc): number {
return plainText(doc).length;
}
// ── Marks-Vergleich / Normalisierung ───────────────────────────────────────
const MARK_KEYS: (keyof Marks)[] = [
"bold",
"italic",
"underline",
"strike",
"super",
"sub",
"font",
"sizePt",
"color",
];
/** Normalisiert Marks: entfernt „falsy"/leere Einträge, damit Vergleiche stabil sind. */
export function normalizeMarks(marks: Marks): Marks {
const out: Marks = {};
for (const key of MARK_KEYS) {
const value = marks[key];
if (value === undefined || value === false || value === null) continue;
if ((key === "font" || key === "color") && value === "") continue;
// super und sub sind exklusiv — sub gewinnt nicht über super, wir behalten beide
// Werte nur wenn true; Auflösung geschieht bei toggleMark.
(out as Record<string, unknown>)[key] = value;
}
return out;
}
/** Strukturvergleich zweier Marks-Objekte (nach Normalisierung). */
export function marksEqual(a: Marks, b: Marks): boolean {
const na = normalizeMarks(a);
const nb = normalizeMarks(b);
for (const key of MARK_KEYS) {
if ((na as Record<string, unknown>)[key] !== (nb as Record<string, unknown>)[key]) {
return false;
}
}
return true;
}
/**
* Führt benachbarte Runs mit gleichen Marks zusammen und entfernt leere Runs.
* Idempotent; hält das Dokument nach Editieroperationen kompakt.
*/
export function normalizeDoc(doc: RichTextDoc): RichTextDoc {
const paragraphs: Paragraph[] = doc.paragraphs.map((p) => {
const merged: TextRun[] = [];
for (const run of p.runs) {
if (run.text.length === 0) continue;
const norm = { text: run.text, marks: normalizeMarks(run.marks) };
const last = merged[merged.length - 1];
if (last && marksEqual(last.marks, norm.marks)) {
last.text += norm.text;
} else {
merged.push(norm);
}
}
const out: Paragraph = { runs: merged };
if (p.align && p.align !== "left") out.align = p.align;
return out;
});
if (paragraphs.length === 0) paragraphs.push({ runs: [] });
return { version: 1, paragraphs };
}
// ── Bereichs-Adressierung (linearer Offset ↔ Absatz/Run) ────────────────────
interface ParaSpan {
index: number;
/** Start-Offset dieses Absatzes im linearen Dokument. */
start: number;
/** End-Offset (exklusive Umbruch) = start + Reintextlänge des Absatzes. */
end: number;
}
/** Berechnet Start/End-Offsets je Absatz. */
function paragraphSpans(doc: RichTextDoc): ParaSpan[] {
const spans: ParaSpan[] = [];
let cursor = 0;
doc.paragraphs.forEach((p, index) => {
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
spans.push({ index, start: cursor, end: cursor + len });
cursor += len + 1; // +1 für den Absatzumbruch
});
return spans;
}
/**
* Wendet `fn` auf jeden Run an, der (ganz oder teilweise) im Bereich [start,end)
* liegt; teilt Runs an den Bereichsgrenzen auf. Gibt ein neues, normalisiertes
* Dokument zurück. Die Callback bekommt die aktuellen Marks des Teil-Runs und
* liefert die neuen Marks.
*/
export function mapRunsInRange(
doc: RichTextDoc,
range: TextRange,
fn: (marks: Marks) => Marks,
): RichTextDoc {
const start = Math.max(0, Math.min(range.start, range.end));
const end = Math.max(range.start, range.end);
if (start === end) return normalizeDoc(doc);
const spans = paragraphSpans(doc);
const paragraphs: Paragraph[] = doc.paragraphs.map((p, pIdx) => {
const span = spans[pIdx];
// Absatz komplett ausserhalb des Bereichs?
if (span.end <= start || span.start >= end) return p;
const newRuns: TextRun[] = [];
let runStart = span.start;
for (const run of p.runs) {
const runEnd = runStart + run.text.length;
const selStart = Math.max(start, runStart);
const selEnd = Math.min(end, runEnd);
if (selEnd <= selStart) {
// Run ausserhalb — unverändert übernehmen.
newRuns.push(run);
} else {
const relStart = selStart - runStart;
const relEnd = selEnd - runStart;
if (relStart > 0) {
newRuns.push(makeRun(run.text.slice(0, relStart), run.marks));
}
newRuns.push(makeRun(run.text.slice(relStart, relEnd), fn(run.marks)));
if (relEnd < run.text.length) {
newRuns.push(makeRun(run.text.slice(relEnd), run.marks));
}
}
runStart = runEnd;
}
return { ...p, runs: newRuns };
});
return normalizeDoc({ version: 1, paragraphs });
}
// ── Mark-Operationen ───────────────────────────────────────────────────────
/**
* Setzt ein Mark auf einen Bereich. `value` bei boolschen Marks true/false;
* bei font/sizePt/color der jeweilige Wert (oder undefined zum Entfernen).
* super/sub werden exklusiv gehalten.
*/
export function applyMark<K extends keyof Marks>(
doc: RichTextDoc,
range: TextRange,
mark: K,
value: Marks[K] | undefined,
): RichTextDoc {
return mapRunsInRange(doc, range, (marks) => {
const next: Marks = { ...marks };
if (value === undefined || value === false) {
delete next[mark];
} else {
(next as Record<string, unknown>)[mark] = value;
if (mark === "super" && value) delete next.sub;
if (mark === "sub" && value) delete next.super;
}
return next;
});
}
/**
* Ist ein boolsches Mark über den GESAMTEN Bereich aktiv? (Für den
* Toolbar-Zustand: „Fett"-Knopf gedrückt, wenn die ganze Auswahl fett ist.)
*/
export function isMarkActive(doc: RichTextDoc, range: TextRange, mark: BoolMark): boolean {
const start = Math.max(0, Math.min(range.start, range.end));
const end = Math.max(range.start, range.end);
if (start === end) return false;
const spans = paragraphSpans(doc);
let sawAny = false;
for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) {
const span = spans[pIdx];
if (span.end <= start || span.start >= end) continue;
let runStart = span.start;
for (const run of doc.paragraphs[pIdx].runs) {
const runEnd = runStart + run.text.length;
const selStart = Math.max(start, runStart);
const selEnd = Math.min(end, runEnd);
if (selEnd > selStart) {
sawAny = true;
if (!run.marks[mark]) return false;
}
runStart = runEnd;
}
}
return sawAny;
}
/**
* Schaltet ein boolsches Mark über den Bereich um: ist es überall aktiv →
* entfernen, sonst überall setzen.
*/
export function toggleMark(doc: RichTextDoc, range: TextRange, mark: BoolMark): RichTextDoc {
const active = isMarkActive(doc, range, mark);
return applyMark(doc, range, mark, active ? undefined : true);
}
/**
* Liefert den gemeinsamen Wert eines Nicht-Bool-Marks über den Bereich, oder
* undefined wenn uneinheitlich/leer. (Für Toolbar-Anzeige von Grösse/Farbe/Font.)
*/
export function commonMarkValue<K extends "font" | "sizePt" | "color">(
doc: RichTextDoc,
range: TextRange,
mark: K,
): Marks[K] | undefined {
const start = Math.max(0, Math.min(range.start, range.end));
const end = Math.max(range.start, range.end);
if (start === end) return undefined;
const spans = paragraphSpans(doc);
let value: Marks[K] | undefined;
let first = true;
for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) {
const span = spans[pIdx];
if (span.end <= start || span.start >= end) continue;
let runStart = span.start;
for (const run of doc.paragraphs[pIdx].runs) {
const runEnd = runStart + run.text.length;
const selStart = Math.max(start, runStart);
const selEnd = Math.min(end, runEnd);
if (selEnd > selStart) {
const v = run.marks[mark];
if (first) {
value = v;
first = false;
} else if (v !== value) {
return undefined;
}
}
runStart = runEnd;
}
}
return value;
}
// ── Serialisierung (stabiles JSON) ─────────────────────────────────────────
/**
* Stabile JSON-Serialisierung: Schlüssel in fester Reihenfolge, damit gleiche
* Dokumente denselben String ergeben (Diff-/Test-freundlich).
*/
export function serialize(doc: RichTextDoc): string {
const norm = normalizeDoc(doc);
const paragraphs = norm.paragraphs.map((p) => {
const runs = p.runs.map((r) => {
const marks: Record<string, unknown> = {};
for (const key of MARK_KEYS) {
const v = (r.marks as Record<string, unknown>)[key];
if (v !== undefined) marks[key] = v;
}
return { text: r.text, marks };
});
const obj: Record<string, unknown> = { runs };
if (p.align && p.align !== "left") obj.align = p.align;
return obj;
});
return JSON.stringify({ version: 1, paragraphs });
}
/**
* Liest ein Dokument aus JSON. Robust gegenüber fehlenden Feldern; unbekannter
* Input ergibt ein leeres Dokument statt eines Fehlers.
*/
export function deserialize(json: string): RichTextDoc {
try {
const raw = JSON.parse(json) as unknown;
return fromJson(raw);
} catch {
return emptyDoc();
}
}
/** Wandelt ein bereits geparstes Objekt in ein valides Dokument. */
export function fromJson(raw: unknown): RichTextDoc {
if (!raw || typeof raw !== "object") return emptyDoc();
const obj = raw as { paragraphs?: unknown };
if (!Array.isArray(obj.paragraphs)) return emptyDoc();
const paragraphs: Paragraph[] = obj.paragraphs.map((p) => {
const pp = (p ?? {}) as { runs?: unknown; align?: unknown };
const runs: TextRun[] = Array.isArray(pp.runs)
? pp.runs.map((r) => {
const rr = (r ?? {}) as { text?: unknown; marks?: unknown };
const text = typeof rr.text === "string" ? rr.text : "";
const marks = normalizeMarks((rr.marks ?? {}) as Marks);
return { text, marks };
})
: [];
const para: Paragraph = { runs };
if (pp.align === "center" || pp.align === "right") para.align = pp.align;
return para;
});
return normalizeDoc({ version: 1, paragraphs });
}
// ── Style-Presets ──────────────────────────────────────────────────────────
/** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */
export interface TextStylePreset {
/** Stabiler Schlüssel (englisch), z. B. "title". */
id: string;
/** Anzeigename (bereits übersetzt oder Schlüssel für t()). */
label: string;
/** Marks, die der Stil auf die Auswahl (oder das ganze Dokument) legt. */
marks: Marks;
}
/**
* Standard-Presets, an DOSSIERs Textstile angelehnt. Grössen in pt; die
* SVG-/Canvas-Renderer rechnen pt → Meter/px selbst um.
*/
export const DEFAULT_PRESETS: TextStylePreset[] = [
{ id: "title", label: "Titel", marks: { bold: true, sizePt: 18 } },
{ id: "subtitle", label: "Untertitel", marks: { italic: true, sizePt: 13 } },
{ id: "label", label: "Beschriftung", marks: { sizePt: 10 } },
{ id: "note", label: "Notiz", marks: { italic: true, sizePt: 8, color: "#8a8580" } },
];
/**
* Wendet ein Preset auf einen Bereich an (setzt jede Mark des Presets). Ein
* leerer Bereich wird auf das ganze Dokument angewendet.
*/
export function applyPreset(
doc: RichTextDoc,
range: TextRange,
preset: TextStylePreset,
): RichTextDoc {
const full: TextRange =
range.start === range.end ? { start: 0, end: Math.max(1, docLength(doc)) } : range;
let out = doc;
for (const key of MARK_KEYS) {
const v = (preset.marks as Record<string, unknown>)[key];
if (v !== undefined) {
out = applyMark(out, full, key as keyof Marks, v as Marks[keyof Marks]);
}
}
return out;
}