From 7c02ea0498b93980523826450eef2bd86b8df693 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 05:36:18 +0200 Subject: [PATCH] =?UTF-8?q?Zeilenh=C3=B6he-Regler=20statt=20"+=20Text";=20?= =?UTF-8?q?Linienstil=20editierbar;=20DOSSIER-Audit=20gesichert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "+ 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. --- docs/design/dossier-feature-audit.md | 112 +++++++++++++++++++++++++++ src/App.tsx | 6 +- src/i18n/de.ts | 7 +- src/i18n/en.ts | 7 +- src/panels/AttributesPanel.tsx | 20 ++++- src/panels/host.ts | 5 ++ src/plan/PlanView.tsx | 17 +++- src/styles.css | 59 ++++++++------ src/text/renderHtml.ts | 24 ++++-- src/text/richText.ts | 7 +- src/ui/TopBar.tsx | 108 ++++++++++++++++++++------ 11 files changed, 307 insertions(+), 65 deletions(-) create mode 100644 docs/design/dossier-feature-audit.md diff --git a/docs/design/dossier-feature-audit.md b/docs/design/dossier-feature-audit.md new file mode 100644 index 0000000..be07308 --- /dev/null +++ b/docs/design/dossier-feature-audit.md @@ -0,0 +1,112 @@ +# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, 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. diff --git a/src/App.tsx b/src/App.tsx index 461b082..f9343fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2156,6 +2156,11 @@ export default function App() { else if (selection?.kind === "drawing2d") 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) => { // Vollton-Füllfarbe nur bei Drawing2D (geschlossene Formen). if (selection?.kind === "drawing2d") @@ -3061,7 +3066,6 @@ export default function App() { /> } textTarget={textTarget} - onAddText={null} />
diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 4bdfc18..7b5e002 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -212,6 +212,7 @@ export const de = { "attr.color": "Farbe", "attr.weight": "Strichstärke (mm)", "attr.lineStyle": "Linienstil", + "attr.lineStyle.none": "Standard", "attr.fill": "Füllung", "attr.fillColor": "Füllfarbe", "attr.clearFill": "Füllfarbe entfernen", @@ -882,8 +883,10 @@ export const de = { "text.align.left": "Linksbündig", "text.align.center": "Zentriert", "text.align.right": "Rechtsbündig", - "text.add": "Text", - "text.add.hint": "Text-Werkzeug — neuen Text platzieren (bald verfügbar)", + "text.lineHeight": "Zeilenhöhe", + "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.apply": "Übernehmen", "text.cancel": "Abbrechen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9e7cb44..62df35d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -211,6 +211,7 @@ export const en: Record = { "attr.color": "Color", "attr.weight": "Line weight (mm)", "attr.lineStyle": "Line style", + "attr.lineStyle.none": "Default", "attr.fill": "Fill", "attr.fillColor": "Fill color", "attr.clearFill": "Remove fill color", @@ -874,8 +875,10 @@ export const en: Record = { "text.align.left": "Align left", "text.align.center": "Center", "text.align.right": "Align right", - "text.add": "Text", - "text.add.hint": "Text tool — place new text (coming soon)", + "text.lineHeight": "Line height", + "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.apply": "Apply", "text.cancel": "Cancel", diff --git a/src/panels/AttributesPanel.tsx b/src/panels/AttributesPanel.tsx index 97d9867..b824c4b 100644 --- a/src/panels/AttributesPanel.tsx +++ b/src/panels/AttributesPanel.tsx @@ -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). */} {t("attr.lineStyle")} - - {lineStyle ? lineStyle.name : "—"} + + {isDrawing ? ( + + 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 })), + ]} + /> + ) : ( + {lineStyle ? lineStyle.name : "—"} + )}
diff --git a/src/panels/host.ts b/src/panels/host.ts index e8b565d..49d9727 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -152,6 +152,11 @@ export interface PanelHostValue { * die Quelle, siehe {@link onSetSelectionHatchSource}). */ 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). * `null` entfernt die Füllfarbe (Fläche wieder transparent). diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 9c42832..d5d709d 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -3012,13 +3012,24 @@ function renderPrimitive( }); const extra: SvgLine[] = p.extraLines.map((line) => ({ align: line.align, + lineHeight: lineGap, tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }], })); const lines = [...docLines, ...extra]; if (lines.length === 0) return null; - // Block vertikal um den Anker zentrieren. - const totalH = baseFs + (lines.length - 1) * lineGap; + // Block vertikal um den Anker zentrieren. Absätze können eine eigene + // 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 lineYs: number[] = []; + { + let y = startY; + lines.forEach((line, k) => { + if (k > 0) y += line.lineHeight; + lineYs.push(y); + }); + } return ( {lines.map((line, k) => { @@ -3032,7 +3043,7 @@ function renderPrimitive( {line.tspans.map((ts, j) => ( diff --git a/src/styles.css b/src/styles.css index c14c243..ccc3dcb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -486,36 +486,47 @@ body { gap: 6px; } /* „+ 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; align-items: center; - gap: 4px; - width: 80px; - height: 24px; - box-sizing: border-box; - padding: 0 8px; - border: 1px solid var(--border); - border-radius: 999px; - background: var(--input); - color: var(--ink-2); - font-family: inherit; - font-size: 11px; - font-weight: 600; + justify-content: center; + flex: 0 0 auto; + width: 22px; + border: none; + background: transparent; + color: #b0b0b0; 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) { - background: var(--accent-dim); - border-color: var(--accent-border); - color: var(--accent); +.tb-lineheight-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #f0f0f0; } -.tb-addtext:disabled { - opacity: 0.5; - cursor: default; -} -.tb-addtext-label { +.tb-lineheight-value { 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), diff --git a/src/text/renderHtml.ts b/src/text/renderHtml.ts index 2d99832..2e6aa7a 100644 --- a/src/text/renderHtml.ts +++ b/src/text/renderHtml.ts @@ -85,10 +85,13 @@ export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string { } 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 `
`; + const styles: string[] = []; + 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 `
`; const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join(""); - return `${inner}`; + return `${inner}`; } function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string { @@ -119,6 +122,8 @@ export interface SvgTspan { export interface SvgLine { tspans: SvgTspan[]; align: "left" | "center" | "right"; + /** Zeilenhöhe dieses Absatzes (Vielfaches der Basisgrösse), s. `Paragraph.lineHeight`. */ + lineHeight: number; } export interface SvgTextOptions { @@ -126,7 +131,12 @@ export interface SvgTextOptions { x: number; /** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */ 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; /** * 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 * umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline - * der Zeile i = `y + i·lineHeight`). + * der Zeile i = `y + Σ lineHeight[0..i-1]`, s. `docToSvgText`). */ export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] { const basePt = opts.basePt ?? 12; const color = opts.color ?? "#000000"; + const baseUnit = basePt * opts.unitPerPt; return doc.paragraphs.map((p) => ({ align: p.align ?? "left", + lineHeight: p.lineHeight != null ? baseUnit * p.lineHeight : opts.lineHeight, tspans: p.runs .filter((r) => r.text.length > 0) .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 parts: string[] = []; 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 runSpans = line.tspans.map((t) => tspanToSvg(t)).join(""); // Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt. diff --git a/src/text/richText.ts b/src/text/richText.ts index 078562e..a707e0f 100644 --- a/src/text/richText.ts +++ b/src/text/richText.ts @@ -44,10 +44,15 @@ export interface TextRun { /** Absatz-Ausrichtung. */ 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 { runs: TextRun[]; align?: Align; + /** Zeilenhöhe als Vielfaches der Basisgrösse (z. B. 1.3 = 130 %). */ + lineHeight?: number; } /** Das gesamte Rich-Text-Dokument. */ diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index e7bc656..9ad7622 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -19,6 +19,7 @@ import { applyMark, applyPreset, commonMarkValue, + DEFAULT_LINE_HEIGHT, DEFAULT_PRESETS, docLength, isMarkActive, @@ -172,11 +173,6 @@ export interface TopBarProps { * Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc. */ 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({ textTarget, - onAddText, }: { textTarget: TextTarget | null; - onAddText: (() => void) | null; }) { // Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei // aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider. const [defFont, setDefFont] = useState("Inter"); const [defSize, setDefSize] = useState(12); const [defAlign, setDefAlign] = useState("left"); + const [defLineHeight, setDefLineHeight] = useState(DEFAULT_LINE_HEIGHT); const [defBold, setDefBold] = useState(false); const [defItalic, setDefItalic] = useState(false); const [defUnderline, setDefUnderline] = useState(false); @@ -593,6 +588,10 @@ function TextGroup({ const curAlign: Align = textTarget ? commonAlign(textTarget.doc, rng) ?? "left" : 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) ────────────── 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 preset = DEFAULT_PRESETS.find((p) => p.id === id); if (!preset) return; @@ -773,19 +781,36 @@ function TextGroup({ }, ]} /> - + + {curLineHeight.toFixed(1)}× + + ); @@ -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. */ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] { if (range == null || range.start === range.end) { @@ -928,7 +989,6 @@ export function TopBar({ onSaveProject, onOpenProject, textTarget, - onAddText, }: TopBarProps) { // Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt // der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste @@ -1105,9 +1165,11 @@ export function TopBar({ {/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R + - „+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. Sitzt - bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab. */} - + Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live. + Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab. + „+ Text" ist raus — Text wird ein eigenständiges Zeichenwerkzeug + (HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */} +