From d69d6f5f24dd5ce04e845e3ce30fd74a1c6f290b 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. --- 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 ++++++++++++++++++++++++++------- 10 files changed, 195 insertions(+), 65 deletions(-) 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. */} +