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.
This commit is contained in:
2026-07-04 05:36:18 +02:00
parent 8b6b9291a1
commit d0b94a75ec
11 changed files with 307 additions and 65 deletions
+112
View File
@@ -0,0 +1,112 @@
# DOSSIER-Feature-Audit (A1A6, B1B4, C1C3, D1D3, E)
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
Einzeilern verweist. Ursprünglich von einem Opus-Subagent erarbeitet, der das
DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen den aktuellen Browser-Port
abgeglichen hat — das Ergebnis lag bisher nur in einem Session-Transkript, nicht
im Repo. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
## A — Grösserer Funktionsumfang
**A1 · ❌ Grafische Overrides — Regel-Engine** (`overrides.py:7-40`)
ArchiCAD/Vectorworks-Stil: Regeln der Form `condition{type: layer_name|user_string|
object_name, operator: equals|contains|starts_with|not_equals, value, key} →
actions{color, lineweight, linetype}`, additiv angewendet, oberste Regel gewinnt,
reversibel. Cross-Doc-Presets + Rule-Templates (`list_presets:147`,
`list_rule_templates:218`). Der Port hat nur manuelle Per-Instanz-Farbe (By-Layer/
By-Object/eigener Wert, kein Regelwerk). Wert: enorm für Architektur-Pläne
(Bestand grau, Brandabschnitte, Bauphasen farblich markieren). Aufwand: M-L.
**A2 · ❌ Ausschnitte / View-Snapshots** (`ausschnitte.py:466-531`)
Benannte, gespeicherte Ansichten, die Kamera-Zustand + Layer-Sichtbarkeit +
Massstab + Detailgrad einfangen und wiederherstellen; organisiert in Ordnern +
Presets (`_capture_camera:78`, `_capture_layers:157`, `_capture:466`). Wert:
zentral für Wiederverwendbarkeit und Voraussetzung für A3. Aufwand: L.
**A3 · ❌ Print-Layout-Blätter** (`layouts.py:7-8`)
Layout-Seiten mit mehreren Details, jedes Detail an einen Ausschnitt-Snapshot
gebunden (`_BIND_KEY:28`), Papierformate A4/A3, Ordnerstruktur, PDF-Export pro
Layout-Blatt. Der Port exportiert aktuell nur eine einzelne Zeichnung nach PDF.
Aufwand: L, hängt an A2.
**A4 · ❌ Tragwerk-Elemente** (`elemente.py`)
Stütze, Träger, Unterzug, I-Profil als eigene Elementtypen. Der Port kennt nur
Wand/Decke/Öffnung/Treppe/Raum. Wert: vervollständigt den BIM-Elementsatz.
Aufwand: M je Typ.
**A5 · 🟡 Öffnungen viel reicher** (`elemente.py:59-68`)
Mehrere Flügel (`OEFF_FLUEGEL`), Sims innen+aussen mit eigenen Stilen
(`SIMS_AUS`/`SIMS_IN`), Glas-Toggle (`OEFF_GLAS`), Rahmen-Lage aussen/mittig/innen
(`RAHMEN_POS`), Rahmen-Profilbreite/-tiefe, Detailgrad einfach/standard/detail =
SIA-400-Darstellung (`OEFF_DARSTELLUNG:68`). Der Port hat nur swing/hinge/
frameDepth/frameThickness. Aufwand: M.
**A6 · 🟡 Element-Schedule / Bauteilliste** (`elemente_uebersicht.py:45,214`)
Voller Element-Überblick + SIA-Flächenbilanz + CSV-Export
(`_export_bilanz:214`). Der Port hat nur einen Raum-CSV-Export. Aufwand: M.
## B — Werkzeuge & Objekt-Info
**B1 · ❌ Text-Platzierungs-Werkzeug** (`text_create.py:972,847,105`)
On-Canvas-Text-Objekte als eigenständiges Zeichenwerkzeug (nicht nur
Raumstempel): Textstile, Fonts, Rich-Text fett/kursiv/unterstrichen,
Ausrichtung, Symbol-Einfügung, „auf Selektion anwenden". Der Port hat den
Rich-Text-Editor (`src/text/RichTextEditor.tsx`) bereits, aber kein
Platzierungs-Werkzeug, um damit ein freistehendes Textobjekt zu zeichnen
(Shortcut „1" ist bewusst noch unbelegt). Aufwand: M.
**B2 · 🟡 Object-Info numerisch erweitern** (`dimensions.py:342-350,240,267`)
Position, Rotation um Z-Achse (`_rotate_around_axis:240`), Kreis-Radius,
Linien-Länge (`_set_line_length:267`), Rechteck Breite/Höhe,
Koordinatensystem World/CPlane, 9-Punkt-Referenz. Der Port kann nur per
Anker-Griff skalieren. Aufwand: S-M.
**B3 · ❌ Kamera-Presets + Nordwinkel** (`kamera.py:28,36-45,87`)
Nicht in HANDOVER.md gelistet, aber im Audit gefunden: Kardinal-Presets
(N/O/S/W), Iso-Oktanten, Rotation um einen einstellbaren Nordwinkel
(georeferenziert, relevant für Swisstopo-Kontext).
**B4 · ❌ Massstab-Toolbar-Funktionen** (nicht separat referenziert, Teil der
Toolbar-Logik) — Zoom 1:1/auf Selektion, Linienstärken-Set, Grid/Ortho/
Referenzlinien-Toggles direkt aus der Symbolleiste.
## C — Zusätzlich gefunden, nicht in HANDOVER.md
**C1 · LoD pro Ansicht** — Darstellung einfach/standard/detail je Ansicht
umschaltbar (SIA-400-Detailgrad-Konvention), nicht nur global.
**C2 · Override-Presets + Rule-Templates als Bibliothek** — vertieft A1: die
Regeln selbst sind wiederverwendbare, benannte Presets/Templates, nicht nur
Ad-hoc-Zustand pro Dokument.
**C3 · Mass-Style-Presets** — Dezimalstellen/Rundung als benannte,
wiederverwendbare Bemassungs-Stile.
## D — Weitere Backlog-Ergänzungen aus dem Audit
**D1** Per-Layout-PDF-Export (vertieft A3).
**D2** Bauteil-CSV mit vollem Element-Set (vertieft A6).
**D3** Komponenten-Thumbnails (visuelle Vorschau im Component-Manager).
## E — Bewusst NICHT zu portieren
Rhino-/Windows-gebundene Implementierungsdetails ohne Browser-Äquivalent:
Window-Layout-XML-Persistierung, Auto-DPI via CoreGraphics, Custom-Grips-Code
(Rhino-SDK-spezifisch), nativer `.3dm`-Geometrie-Import.
## Priorisierung (aus dem Original-Audit)
1. A1 (Override-Regel-Engine)
2. A2 (View-Snapshots)
3. A3 (Print-Layout-Blätter)
4. A5 (reichere Öffnungen)
5. B1 (Text-Platzierungs-Werkzeug)
6. A4 (Tragwerk)
7. B2 (Object-Info numerisch)
8. A6 (Bauteil-Schedule)
Siehe auch `ROADMAP.md` §11 für eine noch breitere, unabhängig entstandene
Backlog-Liste (4-Wege-Survey über Bauteile/Darstellung/Pläne/Kontext) mit
teilweiser Überschneidung.
+5 -1
View File
@@ -2156,6 +2156,11 @@ export default function App() {
else if (selection?.kind === "drawing2d") else if (selection?.kind === "drawing2d")
setElementFill(selection.id, hatchId); setElementFill(selection.id, hatchId);
}, },
onSetSelectionLineStyle: (lineStyleId) => {
// Nur Drawing2D trägt einen Linienstil (Wand/Decke/etc. nicht).
if (selection?.kind === "drawing2d")
updateDrawing2D(selection.id, { lineStyleId });
},
onSetSelectionFillColor: (color) => { onSetSelectionFillColor: (color) => {
// Vollton-Füllfarbe nur bei Drawing2D (geschlossene Formen). // Vollton-Füllfarbe nur bei Drawing2D (geschlossene Formen).
if (selection?.kind === "drawing2d") if (selection?.kind === "drawing2d")
@@ -3061,7 +3066,6 @@ export default function App() {
/> />
} }
textTarget={textTarget} textTarget={textTarget}
onAddText={null}
/> />
<div className="body"> <div className="body">
+5 -2
View File
@@ -212,6 +212,7 @@ export const de = {
"attr.color": "Farbe", "attr.color": "Farbe",
"attr.weight": "Strichstärke (mm)", "attr.weight": "Strichstärke (mm)",
"attr.lineStyle": "Linienstil", "attr.lineStyle": "Linienstil",
"attr.lineStyle.none": "Standard",
"attr.fill": "Füllung", "attr.fill": "Füllung",
"attr.fillColor": "Füllfarbe", "attr.fillColor": "Füllfarbe",
"attr.clearFill": "Füllfarbe entfernen", "attr.clearFill": "Füllfarbe entfernen",
@@ -882,8 +883,10 @@ export const de = {
"text.align.left": "Linksbündig", "text.align.left": "Linksbündig",
"text.align.center": "Zentriert", "text.align.center": "Zentriert",
"text.align.right": "Rechtsbündig", "text.align.right": "Rechtsbündig",
"text.add": "Text", "text.lineHeight": "Zeilenhöhe",
"text.add.hint": "Text-Werkzeug — neuen Text platzieren (bald verfügbar)", "text.lineHeight.hint": "Zeilenhöhe (Vielfaches der Schriftgrösse)",
"text.lineHeight.increase": "Zeilenhöhe erhöhen",
"text.lineHeight.decrease": "Zeilenhöhe verringern",
"text.editTitle": "Text bearbeiten", "text.editTitle": "Text bearbeiten",
"text.apply": "Übernehmen", "text.apply": "Übernehmen",
"text.cancel": "Abbrechen", "text.cancel": "Abbrechen",
+5 -2
View File
@@ -211,6 +211,7 @@ export const en: Record<TranslationKey, string> = {
"attr.color": "Color", "attr.color": "Color",
"attr.weight": "Line weight (mm)", "attr.weight": "Line weight (mm)",
"attr.lineStyle": "Line style", "attr.lineStyle": "Line style",
"attr.lineStyle.none": "Default",
"attr.fill": "Fill", "attr.fill": "Fill",
"attr.fillColor": "Fill color", "attr.fillColor": "Fill color",
"attr.clearFill": "Remove fill color", "attr.clearFill": "Remove fill color",
@@ -874,8 +875,10 @@ export const en: Record<TranslationKey, string> = {
"text.align.left": "Align left", "text.align.left": "Align left",
"text.align.center": "Center", "text.align.center": "Center",
"text.align.right": "Align right", "text.align.right": "Align right",
"text.add": "Text", "text.lineHeight": "Line height",
"text.add.hint": "Text tool — place new text (coming soon)", "text.lineHeight.hint": "Line height (multiple of font size)",
"text.lineHeight.increase": "Increase line height",
"text.lineHeight.decrease": "Decrease line height",
"text.editTitle": "Edit text", "text.editTitle": "Edit text",
"text.apply": "Apply", "text.apply": "Apply",
"text.cancel": "Cancel", "text.cancel": "Cancel",
+17 -3
View File
@@ -199,10 +199,24 @@ export function AttributesPanel() {
</>, </>,
)} )}
{/* Linienstil — read-only/informativ (kein Setter im Kontrakt). */} {/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/etc. read-only). */}
<span className="attr-key">{t("attr.lineStyle")}</span> <span className="attr-key">{t("attr.lineStyle")}</span>
<span className="attr-val attr-readonly"> <span className="attr-val">
{lineStyle ? lineStyle.name : "—"} {isDrawing ? (
<Dropdown
value={drawing?.lineStyleId ?? "__none__"}
onChange={(v) =>
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
}
width={120}
options={[
{ value: "__none__", label: t("attr.lineStyle.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
]}
/>
) : (
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
)}
</span> </span>
</div> </div>
+5
View File
@@ -152,6 +152,11 @@ export interface PanelHostValue {
* die Quelle, siehe {@link onSetSelectionHatchSource}). * die Quelle, siehe {@link onSetSelectionHatchSource}).
*/ */
onSetSelectionFill: (hatchId: string | null) => void; onSetSelectionFill: (hatchId: string | null) => void;
/**
* Setzt/entfernt den Linienstil-Override — wirkt NUR auf Drawing2D (sonst
* No-op). `undefined` löscht den Wert (fällt zurück auf den Default-Stil).
*/
onSetSelectionLineStyle: (lineStyleId: string | undefined) => void;
/** /**
* Setzt/entfernt die Vollton-Füllfarbe — wirkt NUR auf Drawing2D (sonst No-op). * Setzt/entfernt die Vollton-Füllfarbe — wirkt NUR auf Drawing2D (sonst No-op).
* `null` entfernt die Füllfarbe (Fläche wieder transparent). * `null` entfernt die Füllfarbe (Fläche wieder transparent).
+14 -3
View File
@@ -3012,13 +3012,24 @@ function renderPrimitive(
}); });
const extra: SvgLine[] = p.extraLines.map((line) => ({ const extra: SvgLine[] = p.extraLines.map((line) => ({
align: line.align, align: line.align,
lineHeight: lineGap,
tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }], tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }],
})); }));
const lines = [...docLines, ...extra]; const lines = [...docLines, ...extra];
if (lines.length === 0) return null; if (lines.length === 0) return null;
// Block vertikal um den Anker zentrieren. // Block vertikal um den Anker zentrieren. Absätze können eine eigene
const totalH = baseFs + (lines.length - 1) * lineGap; // Zeilenhöhe tragen (Text-Gruppe, Zeilenhöhe-Regler) — darum kumulierte
// Vorschübe statt eines einzigen festen lineGap.
const totalH = baseFs + lines.slice(1).reduce((sum, l) => sum + l.lineHeight, 0);
const startY = c.y - totalH / 2 + baseFs * 0.8; const startY = c.y - totalH / 2 + baseFs * 0.8;
const lineYs: number[] = [];
{
let y = startY;
lines.forEach((line, k) => {
if (k > 0) y += line.lineHeight;
lineYs.push(y);
});
}
return ( return (
<text style={{ pointerEvents: "none", userSelect: "none" }}> <text style={{ pointerEvents: "none", userSelect: "none" }}>
{lines.map((line, k) => { {lines.map((line, k) => {
@@ -3032,7 +3043,7 @@ function renderPrimitive(
<tspan <tspan
key={k} key={k}
x={c.x} x={c.x}
y={startY + k * lineGap} y={lineYs[k]}
textAnchor={anchor} textAnchor={anchor}
> >
{line.tspans.map((ts, j) => ( {line.tspans.map((ts, j) => (
+35 -24
View File
@@ -486,36 +486,47 @@ body {
gap: 6px; gap: 6px;
} }
/* „+ Text"-Knopf: Pille mit Icon + Label. */ /* „+ Text"-Knopf: Pille mit Icon + Label. */
.tb-addtext { /* Zeilenhöhe-Regler (ersetzt den früheren „+ Text"-Knopf — Text wird ein
eigenständiges Zeichenwerkzeug, AUDIT B1). Gleiche dunkle Pillen-Optik wie
die übrigen Oberleisten-Controls (.tb-dd-trigger/.tb-seg). */
.tb-lineheight {
display: inline-flex;
align-items: stretch;
height: 24px;
width: 80px;
box-sizing: border-box;
border: 1px solid #4a4a4a;
border-radius: 8px;
overflow: hidden;
background: #2c2c2c;
flex: 0 0 auto;
}
.tb-lineheight-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
width: 80px; flex: 0 0 auto;
height: 24px; width: 22px;
box-sizing: border-box; border: none;
padding: 0 8px; background: transparent;
border: 1px solid var(--border); color: #b0b0b0;
border-radius: 999px;
background: var(--input);
color: var(--ink-2);
font-family: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s; transition: background 0.14s, color 0.14s;
} }
.tb-addtext:hover:not(:disabled) { .tb-lineheight-btn:hover {
background: var(--accent-dim); background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-border); color: #f0f0f0;
color: var(--accent);
} }
.tb-addtext:disabled { .tb-lineheight-value {
opacity: 0.5;
cursor: default;
}
.tb-addtext-label {
flex: 1 1 auto; flex: 1 1 auto;
text-align: left; display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
color: #e8e8e8;
border-left: 1px solid #4a4a4a;
border-right: 1px solid #4a4a4a;
} }
/* Marken-Gruppe: Wortmarke + kleiner Icon-Stapel daneben (Settings/Ressourcen), /* Marken-Gruppe: Wortmarke + kleiner Icon-Stapel daneben (Settings/Ressourcen),
+18 -6
View File
@@ -85,10 +85,13 @@ export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string {
} }
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string { function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : ""; const styles: string[] = [];
if (p.runs.length === 0) return `<div${align}><br></div>`; if (p.align && p.align !== "left") styles.push(`text-align:${p.align}`);
if (p.lineHeight != null) styles.push(`line-height:${p.lineHeight}`);
const style = styles.length ? ` style="${styles.join(";")}"` : "";
if (p.runs.length === 0) return `<div${style}><br></div>`;
const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join(""); const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join("");
return `<div${align}>${inner}</div>`; return `<div${style}>${inner}</div>`;
} }
function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string { function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string {
@@ -119,6 +122,8 @@ export interface SvgTspan {
export interface SvgLine { export interface SvgLine {
tspans: SvgTspan[]; tspans: SvgTspan[];
align: "left" | "center" | "right"; align: "left" | "center" | "right";
/** Zeilenhöhe dieses Absatzes (Vielfaches der Basisgrösse), s. `Paragraph.lineHeight`. */
lineHeight: number;
} }
export interface SvgTextOptions { export interface SvgTextOptions {
@@ -126,7 +131,12 @@ export interface SvgTextOptions {
x: number; x: number;
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */ /** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
y: number; y: number;
/** Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten. */ /**
* Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten für Zeilen OHNE
* eigene `lineHeight` (Rückwärts-Kompatibilität/Fallback). Absätze mit
* gesetzter `Paragraph.lineHeight` berechnen ihren Vorschub stattdessen
* selbst aus `basePt · unitPerPt · lineHeight`.
*/
lineHeight: number; lineHeight: number;
/** /**
* Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten. * Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten.
@@ -142,13 +152,15 @@ export interface SvgTextOptions {
/** /**
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten * Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline * umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
* der Zeile i = `y + lineHeight`). * der Zeile i = `y + Σ lineHeight[0..i-1]`, s. `docToSvgText`).
*/ */
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] { export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
const basePt = opts.basePt ?? 12; const basePt = opts.basePt ?? 12;
const color = opts.color ?? "#000000"; const color = opts.color ?? "#000000";
const baseUnit = basePt * opts.unitPerPt;
return doc.paragraphs.map((p) => ({ return doc.paragraphs.map((p) => ({
align: p.align ?? "left", align: p.align ?? "left",
lineHeight: p.lineHeight != null ? baseUnit * p.lineHeight : opts.lineHeight,
tspans: p.runs tspans: p.runs
.filter((r) => r.text.length > 0) .filter((r) => r.text.length > 0)
.map((r) => runToTspan(r, basePt, opts.unitPerPt, color)), .map((r) => runToTspan(r, basePt, opts.unitPerPt, color)),
@@ -184,7 +196,7 @@ export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string {
const lines = docToLines(doc, opts); const lines = docToLines(doc, opts);
const parts: string[] = []; const parts: string[] = [];
lines.forEach((line, i) => { lines.forEach((line, i) => {
const dy = i === 0 ? 0 : opts.lineHeight; const dy = i === 0 ? 0 : line.lineHeight;
const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join(""); const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt. // Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
+6 -1
View File
@@ -44,10 +44,15 @@ export interface TextRun {
/** Absatz-Ausrichtung. */ /** Absatz-Ausrichtung. */
export type Align = "left" | "center" | "right"; export type Align = "left" | "center" | "right";
/** Ein Absatz: eine Zeile aus Runs plus optionale Ausrichtung. */ /** 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 { export interface Paragraph {
runs: TextRun[]; runs: TextRun[];
align?: Align; align?: Align;
/** Zeilenhöhe als Vielfaches der Basisgrösse (z. B. 1.3 = 130 %). */
lineHeight?: number;
} }
/** Das gesamte Rich-Text-Dokument. */ /** Das gesamte Rich-Text-Dokument. */
+85 -23
View File
@@ -19,6 +19,7 @@ import {
applyMark, applyMark,
applyPreset, applyPreset,
commonMarkValue, commonMarkValue,
DEFAULT_LINE_HEIGHT,
DEFAULT_PRESETS, DEFAULT_PRESETS,
docLength, docLength,
isMarkActive, isMarkActive,
@@ -172,11 +173,6 @@ export interface TopBarProps {
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc. * Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
*/ */
textTarget: TextTarget | null; textTarget: TextTarget | null;
/**
* Startet das Text-Werkzeug (neues Textobjekt platzieren). `null`, solange das
* Werkzeug noch nicht existiert der + Text"-Knopf ist dann deaktiviert.
*/
onAddText: (() => void) | null;
} }
/** /**
@@ -556,16 +552,15 @@ const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
*/ */
function TextGroup({ function TextGroup({
textTarget, textTarget,
onAddText,
}: { }: {
textTarget: TextTarget | null; textTarget: TextTarget | null;
onAddText: (() => void) | null;
}) { }) {
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei // Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider. // aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
const [defFont, setDefFont] = useState<string>("Inter"); const [defFont, setDefFont] = useState<string>("Inter");
const [defSize, setDefSize] = useState<number>(12); const [defSize, setDefSize] = useState<number>(12);
const [defAlign, setDefAlign] = useState<Align>("left"); const [defAlign, setDefAlign] = useState<Align>("left");
const [defLineHeight, setDefLineHeight] = useState<number>(DEFAULT_LINE_HEIGHT);
const [defBold, setDefBold] = useState(false); const [defBold, setDefBold] = useState(false);
const [defItalic, setDefItalic] = useState(false); const [defItalic, setDefItalic] = useState(false);
const [defUnderline, setDefUnderline] = useState(false); const [defUnderline, setDefUnderline] = useState(false);
@@ -593,6 +588,10 @@ function TextGroup({
const curAlign: Align = textTarget const curAlign: Align = textTarget
? commonAlign(textTarget.doc, rng) ?? "left" ? commonAlign(textTarget.doc, rng) ?? "left"
: defAlign; : defAlign;
// Zeilenhöhe: gemeinsamer Wert der berührten Absätze, sonst Default.
const curLineHeight: number = textTarget
? commonLineHeight(textTarget.doc, rng) ?? DEFAULT_LINE_HEIGHT
: defLineHeight;
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ────────────── // ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => { const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
@@ -631,6 +630,15 @@ function TextGroup({
} }
}; };
const setLineHeightValue = (lh: number) => {
const clamped = Math.round(Math.min(3, Math.max(0.8, lh)) * 10) / 10;
if (textTarget) {
textTarget.apply(applyLineHeight(textTarget.doc, textTarget.range, clamped));
} else {
setDefLineHeight(clamped);
}
};
const applyPresetValue = (id: string) => { const applyPresetValue = (id: string) => {
const preset = DEFAULT_PRESETS.find((p) => p.id === id); const preset = DEFAULT_PRESETS.find((p) => p.id === id);
if (!preset) return; if (!preset) return;
@@ -773,19 +781,36 @@ function TextGroup({
}, },
]} ]}
/> />
<button <div
type="button" className="tb-lineheight"
className="tb-addtext" role="group"
disabled={onAddText == null} aria-label={t("text.lineHeight")}
onClick={() => onAddText?.()} title={t("text.lineHeight.hint")}
title={onAddText ? t("text.add") : t("text.add.hint")}
aria-label={t("text.add")}
> >
<span className="material-symbols-outlined tb-ico" aria-hidden="true"> <button
add type="button"
</span> className="tb-lineheight-btn"
<span className="tb-addtext-label">{t("text.add")}</span> onClick={() => setLineHeightValue(curLineHeight - 0.1)}
</button> title={t("text.lineHeight.decrease")}
aria-label={t("text.lineHeight.decrease")}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
remove
</span>
</button>
<span className="tb-lineheight-value">{curLineHeight.toFixed(1)}×</span>
<button
type="button"
className="tb-lineheight-btn"
onClick={() => setLineHeightValue(curLineHeight + 0.1)}
title={t("text.lineHeight.increase")}
aria-label={t("text.lineHeight.increase")}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
add
</span>
</button>
</div>
</div> </div>
</div> </div>
); );
@@ -828,6 +853,42 @@ function applyAlign(doc: RichTextDoc, range: TextRange | null, align: Align): Ri
}; };
} }
/**
* Gemeinsame Zeilenhöhe der vom Bereich berührten Absätze (Vielfaches der
* Basisgrösse); `undefined` bei uneinheitlicher Auswahl.
*/
function commonLineHeight(doc: RichTextDoc, range: TextRange | null): number | undefined {
const paras = touchedParagraphs(doc, range);
if (paras.length === 0) return doc.paragraphs[0]?.lineHeight ?? DEFAULT_LINE_HEIGHT;
let value: number | undefined;
let first = true;
for (const idx of paras) {
const lh = doc.paragraphs[idx]?.lineHeight ?? DEFAULT_LINE_HEIGHT;
if (first) {
value = lh;
first = false;
} else if (lh !== value) {
return undefined;
}
}
return value;
}
/**
* Setzt die Zeilenhöhe auf alle vom Bereich berührten Absätze. Ohne Bereich
* (null) auf ALLE Absätze des Dokuments.
*/
function applyLineHeight(doc: RichTextDoc, range: TextRange | null, lineHeight: number): RichTextDoc {
const touched = new Set(touchedParagraphs(doc, range));
const all = range == null;
return {
version: 1,
paragraphs: doc.paragraphs.map((p, i) =>
all || touched.has(i) ? { ...p, lineHeight } : p,
),
};
}
/** Indizes der Absätze, die der Bereich [start,end) berührt. */ /** Indizes der Absätze, die der Bereich [start,end) berührt. */
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] { function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
if (range == null || range.start === range.end) { if (range == null || range.start === range.end) {
@@ -928,7 +989,6 @@ export function TopBar({
onSaveProject, onSaveProject,
onOpenProject, onOpenProject,
textTarget, textTarget,
onAddText,
}: TopBarProps) { }: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt // Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste // der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
@@ -1105,9 +1165,11 @@ export function TopBar({
<Sep /> <Sep />
{/* Text-Gruppe (immer sichtbar) Stil/Schrift/Grösse + B/I/U · L/C/R + {/* Text-Gruppe (immer sichtbar) Stil/Schrift/Grösse + B/I/U · L/C/R +
+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. Sitzt Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab. */} Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
<TextGroup textTarget={textTarget} onAddText={onAddText} /> + Text" ist raus Text wird ein eigenständiges Zeichenwerkzeug
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
<TextGroup textTarget={textTarget} />
<Sep /> <Sep />