From d0b9d22141400f2cde13813f18346b93a44c392e Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 9 Jul 2026 01:28:37 +0200 Subject: [PATCH] Text-Styling-Leiste wirkt auf selektierten Freitext/Textspalte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Text-Formatier-Gruppe der Oberleiste formatierte bisher nur den Raum-Stempel. Jetzt ist auch ein selektierter Freitext/Textspalten-Element (Drawing2D shape "text") ein Formatier-Ziel: - Neues optionales Feld Drawing2D-Text.marks (Marks) für einheitliche Formatierung (Schriftfamilie, fett/kursiv/unterstrichen, Farbe). - App.textTarget adaptiert den Text über docFromText/plainText an die Rich-Text-Leiste; die GRÖSSE bleibt bewusst über die Modell-Höhe (Meter), nicht über pt (massstabslos wäre falsch) — sizePt wird verworfen. - generatePlan/PlanView rendern die Marks (fontFamily/-weight/-style/ textDecoration); marks.color hat Vorrang vor der Kategorie-Farbe. +3 Tests. tsc + vitest grün. --- src/App.tsx | 43 +++++++++++----- src/model/types.ts | 9 +++- src/plan/PlanView.tsx | 14 ++++- src/plan/generatePlan.text.test.ts | 83 ++++++++++++++++++++++++++++++ src/plan/generatePlan.ts | 8 ++- 5 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 src/plan/generatePlan.text.test.ts diff --git a/src/App.tsx b/src/App.tsx index 660ad18..3f90775 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -189,7 +189,7 @@ import type { import type { PanelHostValue } from "./panels/host"; import { deriveSelection } from "./state/selectionInfo"; import { centroid as roomCentroid } from "./geometry/roomArea"; -import { docFromText } from "./text/richText"; +import { docFromText, plainText } from "./text/richText"; import type { RichTextDoc } from "./text/richText"; import { deleteNamedLayout, @@ -2399,17 +2399,36 @@ export default function App() { // gewählten Raums. Ohne aktive Editor-Auswahl wirkt Formatierung auf das ganze // Doc (range: null). Ist kein Raum gewählt → null (Controls setzen nur // Defaults). Der Setter schreibt live in den Store. - const textTarget = useMemo( - () => - selectedRoom - ? { - doc: selectedRoom.stampDoc ?? docFromText(selectedRoom.name), - range: null, - apply: (doc: RichTextDoc) => setRoomStampDoc(selectedRoom.id, doc), - } - : null, - [selectedRoom, setRoomStampDoc], - ); + const textTarget = useMemo(() => { + if (selectedRoom) { + return { + doc: selectedRoom.stampDoc ?? docFromText(selectedRoom.name), + range: null, + apply: (doc: RichTextDoc) => setRoomStampDoc(selectedRoom.id, doc), + }; + } + // Selektierter freier Text/Textspalte: die Oberleisten-Formatier-Gruppe wirkt + // auf Schriftfamilie/fett/kursiv/Farbe des ganzen Texts. Die GRÖSSE bleibt + // bewusst über die Modell-Höhe (Meter, height) geführt — nicht über sizePt, + // damit der Text im Massstab bleibt (Punkt-Grössen wären massstabslos). + if (selectedDrawingId) { + const d = project.drawings2d.find((x) => x.id === selectedDrawingId); + if (d && d.geom.shape === "text") { + const g = d.geom; + return { + doc: docFromText(g.text, g.marks ?? {}), + range: null, + apply: (doc: RichTextDoc) => { + const runMarks = doc.paragraphs.flatMap((p) => p.runs)[0]?.marks ?? {}; + const marks = { ...runMarks }; + delete marks.sizePt; // Grösse folgt der Modell-Höhe, nicht dem pt-Wert. + updateDrawing2D(d.id, { geom: { ...g, text: plainText(doc), marks } }); + }, + }; + } + } + return null; + }, [selectedRoom, setRoomStampDoc, selectedDrawingId, project.drawings2d, updateDrawing2D]); // ── 3D-Editier-Griffe (direkt im Viewport3D) ────────────────────────────── // Genau EINE gewählte Wand (kein 2D-Element) bzw. genau EIN 2D-Element liefert diff --git a/src/model/types.ts b/src/model/types.ts index 4f269ca..6cd5a45 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -16,7 +16,7 @@ export type Vec2 = { x: number; y: number }; import type { SiaCategory } from "../geometry/roomArea"; export type { SiaCategory } from "../geometry/roomArea"; // Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil). -import type { RichTextDoc, Align } from "../text/richText"; +import type { RichTextDoc, Align, Marks } from "../text/richText"; export type { RichTextDoc } from "../text/richText"; // Ansichts-Enums für Ausschnitte/View-Snapshots (Ansichtstyp, Kamera-Preset, // Detailgrad). Rein type-only importiert — zur Laufzeit erased, kein Zyklus @@ -1279,6 +1279,13 @@ export type Drawing2DGeom = * sie, bleibt es einzeiliger Text (heutiges Verhalten). */ width?: number; + /** + * Optionale einheitliche Formatierung des ganzen Texts (Schriftfamilie, fett/ + * kursiv, Farbe …) — gesetzt über die Text-Formatier-Gruppe der Oberleiste, + * wenn dieser Text selektiert ist. Die Grösse bleibt bewusst über `height` + * (Modell-Meter) geführt, nicht über `marks.sizePt`. + */ + marks?: Marks; }; /** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */ diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 0fb5c54..27ad285 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -3283,12 +3283,24 @@ function renderPrimitive( } else { lines.push(p.text); } + // Einheitliche Formatierung (Text-Werkzeug + Oberleisten-Formatier-Gruppe): + // Schriftfamilie/fett/kursiv/Unterstreichung aus `marks`; Grösse bleibt über + // heightM (Modell-Meter). Farbe steckt bereits in p.color (marks.color-Vorrang + // in generatePlan aufgelöst). + const mk = p.marks; + const deco = + mk && (mk.underline || mk.strike) + ? [mk.underline ? "underline" : "", mk.strike ? "line-through" : ""].join(" ").trim() + : undefined; return ( p.kind === "drawingText"); +} + +describe("Drawing2D-Text: Marks im drawingText-Primitiv", () => { + const base: Drawing2D = { + id: "TX1", + type: "drawing2d", + levelId: "eg", + categoryCode: "70", + geom: { shape: "text", at: { x: 1, y: 1 }, text: "Hallo", height: 0.2, angle: 0 }, + }; + + it("trägt marks (font/bold/italic) ins Primitiv", () => { + const p = textPrim({ + ...base, + geom: { ...base.geom, marks: { font: "Merriweather", bold: true, italic: true } }, + }) as { marks?: { font?: string; bold?: boolean; italic?: boolean } } | undefined; + expect(p?.marks?.font).toBe("Merriweather"); + expect(p?.marks?.bold).toBe(true); + expect(p?.marks?.italic).toBe(true); + }); + + it("marks.color übersteuert die Kategorie-Farbe", () => { + const p = textPrim({ + ...base, + geom: { ...base.geom, marks: { color: "#ff0000" } }, + }) as { color: string } | undefined; + expect(p?.color).toBe("#ff0000"); + }); + + it("ohne marks bleibt das Primitiv unverändert (kein marks-Feld)", () => { + const p = textPrim(base) as { marks?: unknown } | undefined; + expect(p).toBeTruthy(); + expect(p?.marks).toBeUndefined(); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index deb4d23..237613f 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -44,7 +44,7 @@ import { import { columnFootprint } from "../geometry/column"; import { effectiveOverrides, hasOverrides } from "../overrides/engine"; import { evaluateRoom, siaLabel } from "../geometry/roomArea"; -import type { RichTextDoc } from "../text/richText"; +import type { Marks, RichTextDoc } from "../text/richText"; import { docFromText } from "../text/richText"; import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp"; import type { RoomStampLine } from "../model/roomStamp"; @@ -335,6 +335,8 @@ export type Primitive = /** Drehung in RADIANT (CCW im Modell). */ angle: number; color: string; + /** Optionale einheitliche Formatierung (Schriftfamilie/fett/kursiv/…). */ + marks?: Marks; /** * Optionale Spaltenbreite in Modell-Metern (Textspalte): ist sie gesetzt, * bricht der Renderer den Text wortweise auf diese Breite um. Fehlt sie, @@ -1289,8 +1291,10 @@ function addDrawing2D( text: g.text, heightM: g.height, angle: g.angle, - color, + // marks.color (falls gesetzt) übersteuert die Kategorie-/Elementfarbe. + color: g.marks?.color ?? color, ...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}), + ...(g.marks ? { marks: g.marks } : {}), greyed, drawingId: d.id, });