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({
},
]}
/>
-