d0b94a75ec
- "+ 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.
457 lines
16 KiB
TypeScript
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;
|
|
}
|