2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+727
View File
@@ -0,0 +1,727 @@
// 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 {
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 <div>,
* darin <span>-Runs; leere Absätze haben ein <br>.
* 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<HTMLDivElement>(null);
// Letzter in innerHTML geschriebener serialisierter Zustand — verhindert
// Cursor-Sprünge durch unnötige DOM-Updates.
const lastSerializedRef = useRef<string>("");
// 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<TextRange | null>(null);
// Immer aktuelles value/onChange ohne Stale-Closure-Problem (keine useCallback-Deps).
const valueRef = useRef<RichTextDoc>(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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const id = e.target.value;
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);
e.target.value = "";
},
[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 currentSize = commonMarkValue(value, queryRange, "sizePt") ?? 12;
const rawColor = commonMarkValue(value, queryRange, "color") ?? "#ededed";
const currentColor = rawColor.startsWith("#") ? rawColor : "#ededed";
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="rt-editor">
<div className="rt-toolbar">
{/* Fett */}
<button
type="button"
className={`rt-btn${isBold ? " rt-btn--active" : ""}`}
title={t("rt.bold")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("bold");
}}
>
<strong>B</strong>
</button>
{/* Kursiv */}
<button
type="button"
className={`rt-btn${isItalic ? " rt-btn--active" : ""}`}
title={t("rt.italic")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("italic");
}}
>
<em>I</em>
</button>
{/* Unterstrichen */}
<button
type="button"
className={`rt-btn${isUnderline ? " rt-btn--active" : ""}`}
title={t("rt.underline")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("underline");
}}
>
<u>U</u>
</button>
{/* Durchgestrichen */}
<button
type="button"
className={`rt-btn${isStrike ? " rt-btn--active" : ""}`}
title={t("rt.strike")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("strike");
}}
>
<s>S</s>
</button>
<div className="rt-toolbar-sep" />
{/* Schriftgrösse */}
<label className="rt-toolbar-label" title={t("rt.fontSize")}>
<input
type="number"
className="rt-size-input"
min={6}
max={96}
step={1}
value={currentSize}
onChange={handleSizeChange}
title={t("rt.fontSize")}
/>
<span className="rt-unit">pt</span>
</label>
{/* Farbe */}
<input
type="color"
className="rt-color-input"
value={currentColor}
onChange={handleColorChange}
title={t("rt.color")}
/>
{/* Presets */}
{effectivePresets.length > 0 && (
<select
className="rt-preset-select"
defaultValue=""
onChange={handlePreset}
title={t("rt.preset")}
>
<option value="" disabled>
{t("rt.preset")}
</option>
{effectivePresets.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
)}
</div>
{/* Editierbare Fläche */}
<div
ref={editorRef}
className="rt-surface"
contentEditable
suppressContentEditableWarning
spellCheck={false}
onInput={handleInput}
onKeyDown={handleKeyDown}
onSelect={captureSelection}
onKeyUp={captureSelection}
onMouseUp={captureSelection}
/>
</div>
);
}
export default RichTextEditor;
export { emptyDoc };
+212
View File
@@ -0,0 +1,212 @@
// Render-Adapter für das Rich-Text-Modell (richText.ts): einmal nach HTML/CSS
// (für den WYSIWYG-Editor und die Bildschirm-Vorschau auf dem Canvas) und einmal
// nach SVG-<tspan> (für den vektoriellen Grundriss-/Print-Renderer). Beide Wege
// bilden dieselbe Mark→Stil-Zuordnung ab, damit Editor-Vorschau und gezeichneter
// Text identisch aussehen. Reines Modul: keine React-/App-Abhängigkeit.
import type { Marks, Paragraph, RichTextDoc, TextRun } from "./richText";
// ── Text-Escaping ───────────────────────────────────────────────────────────
/** Escapt Text für HTML/XML/SVG-Inhalt (kein Attribut). */
export function escapeText(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** Escapt einen Wert für ein doppelt-gequotetes XML-Attribut. */
function escapeAttr(s: string): string {
return escapeText(s).replace(/"/g, "&quot;");
}
// ── Gemeinsame Mark→Stil-Zuordnung ──────────────────────────────────────────
/** Kombiniert underline + strike zu einem CSS/SVG-text-decoration-Wert. */
function textDecoration(marks: Marks): string | undefined {
const parts: string[] = [];
if (marks.underline) parts.push("underline");
if (marks.strike) parts.push("line-through");
return parts.length ? parts.join(" ") : undefined;
}
/** Faktor, um den super-/sub-Text zu verkleinern. */
const SCRIPT_SCALE = 0.7;
/**
* CSS-Deklarationen (als `{prop: value}`) für einen Run. `basePt` ist die
* Basisschriftgrösse in pt, falls der Run selbst keine `sizePt` trägt.
*/
export function runCssProps(marks: Marks, basePt = 12): Record<string, string> {
const css: Record<string, string> = {};
if (marks.bold) css["font-weight"] = "700";
if (marks.italic) css["font-style"] = "italic";
const deco = textDecoration(marks);
if (deco) css["text-decoration"] = deco;
if (marks.font) css["font-family"] = marks.font;
const sizePt = (marks.sizePt ?? basePt) * (marks.super || marks.sub ? SCRIPT_SCALE : 1);
css["font-size"] = `${round(sizePt)}pt`;
if (marks.color) css["color"] = marks.color;
if (marks.super) css["vertical-align"] = "super";
if (marks.sub) css["vertical-align"] = "sub";
return css;
}
function cssPropsToString(css: Record<string, string>): string {
return Object.entries(css)
.map(([k, v]) => `${k}:${v}`)
.join(";");
}
function round(n: number): number {
return Math.round(n * 1000) / 1000;
}
// ── HTML-Render (Editor / Canvas-Vorschau) ──────────────────────────────────
export interface HtmlOptions {
/** Basisschriftgrösse in pt für Runs ohne eigene Grösse. */
basePt?: number;
/** Standardfarbe (CSS) falls kein Run eine Farbe setzt. */
color?: string;
}
/**
* Rendert das Dokument zu einem sanitisierten HTML-String: ein `<div>` pro
* Absatz (mit `text-align`), darin ein `<span>` pro Run mit Inline-Styles.
* Leere Absätze erhalten ein `<br>`, damit die Zeilenhöhe erhalten bleibt.
*/
export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string {
const basePt = opts.basePt ?? 12;
return doc.paragraphs
.map((p) => paragraphToHtml(p, basePt, opts.color))
.join("");
}
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : "";
if (p.runs.length === 0) return `<div${align}><br></div>`;
const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join("");
return `<div${align}>${inner}</div>`;
}
function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string {
const css = runCssProps(run.marks, basePt);
if (color && !css["color"]) css["color"] = color;
const style = cssPropsToString(css);
const text = escapeText(run.text) || "";
return `<span style="${escapeAttr(style)}">${text}</span>`;
}
// ── SVG-Render (Grundriss / Print) ──────────────────────────────────────────
/** Beschreibung eines einzelnen <tspan> (ein Run innerhalb einer Zeile). */
export interface SvgTspan {
text: string;
/** Schriftgrösse in Benutzereinheiten (bereits umgerechnet). */
fontSize: number;
fontWeight?: "700";
fontStyle?: "italic";
fontFamily?: string;
fill: string;
textDecoration?: string;
/** Vertikaler Versatz für Hoch-/Tiefstellung, in Benutzereinheiten. */
baselineShift?: number;
}
/** Eine gerenderte Zeile (= Absatz) mit ihren Runs. */
export interface SvgLine {
tspans: SvgTspan[];
align: "left" | "center" | "right";
}
export interface SvgTextOptions {
/** Ankerpunkt X in Benutzereinheiten. */
x: number;
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
y: number;
/** Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten. */
lineHeight: number;
/**
* Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten.
* Der Aufrufer bestimmt so, ob Text in px, mm oder Metern gezeichnet wird.
*/
unitPerPt: number;
/** Basis-`sizePt` für Runs ohne eigene Grösse. */
basePt?: number;
/** Standardfarbe "#rrggbb" für Runs ohne Farbe. */
color?: string;
}
/**
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
* der Zeile i = `y + i·lineHeight`).
*/
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
const basePt = opts.basePt ?? 12;
const color = opts.color ?? "#000000";
return doc.paragraphs.map((p) => ({
align: p.align ?? "left",
tspans: p.runs
.filter((r) => r.text.length > 0)
.map((r) => runToTspan(r, basePt, opts.unitPerPt, color)),
}));
}
function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: string): SvgTspan {
const m = run.marks;
const script = m.super || m.sub;
const sizePt = (m.sizePt ?? basePt) * (script ? SCRIPT_SCALE : 1);
const fontSize = sizePt * unitPerPt;
const tspan: SvgTspan = {
text: run.text,
fontSize: round(fontSize),
fill: m.color ?? color,
};
if (m.bold) tspan.fontWeight = "700";
if (m.italic) tspan.fontStyle = "italic";
if (m.font) tspan.fontFamily = m.font;
const deco = textDecoration(m);
if (deco) tspan.textDecoration = deco;
if (m.super) tspan.baselineShift = round(fontSize * 0.35);
if (m.sub) tspan.baselineShift = round(-fontSize * 0.2);
return tspan;
}
/**
* Baut ein komplettes `<text>`-SVG-Element als String. Jede Zeile ist ein
* `<tspan x=… dy=…>`, darin ein `<tspan>` je Run. `text-anchor` folgt der
* Absatz-Ausrichtung (left→start, center→middle, right→end).
*/
export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string {
const lines = docToLines(doc, opts);
const parts: string[] = [];
lines.forEach((line, i) => {
const dy = i === 0 ? 0 : opts.lineHeight;
const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
const content = runSpans || "&#8203;";
parts.push(
`<tspan x="${round(opts.x)}" dy="${round(dy)}" text-anchor="${anchor}">${content}</tspan>`,
);
});
return `<text x="${round(opts.x)}" y="${round(opts.y)}">${parts.join("")}</text>`;
}
function tspanToSvg(t: SvgTspan): string {
const attrs: string[] = [`font-size="${t.fontSize}"`, `fill="${escapeAttr(t.fill)}"`];
if (t.fontWeight) attrs.push(`font-weight="${t.fontWeight}"`);
if (t.fontStyle) attrs.push(`font-style="${t.fontStyle}"`);
if (t.fontFamily) attrs.push(`font-family="${escapeAttr(t.fontFamily)}"`);
if (t.textDecoration) attrs.push(`text-decoration="${t.textDecoration}"`);
if (t.baselineShift) attrs.push(`dy="${-t.baselineShift}"`);
const body = escapeText(t.text);
// Bei baseline-shift den dy nach dem Run zurücksetzen, damit die Grundlinie hält.
if (t.baselineShift) {
return `<tspan ${attrs.join(" ")}>${body}</tspan><tspan dy="${t.baselineShift}">&#8203;</tspan>`;
}
return `<tspan ${attrs.join(" ")}>${body}</tspan>`;
}
+451
View File
@@ -0,0 +1,451 @@
// 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";
/** Ein Absatz: eine Zeile aus Runs plus optionale Ausrichtung. */
export interface Paragraph {
runs: TextRun[];
align?: Align;
}
/** 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;
}