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
+5 -1
View File
@@ -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}
/>
<div className="body">
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -211,6 +211,7 @@ export const en: Record<TranslationKey, string> = {
"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<TranslationKey, string> = {
"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",
+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-val attr-readonly">
{lineStyle ? lineStyle.name : "—"}
<span className="attr-val">
{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>
</div>
+5
View File
@@ -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).
+14 -3
View File
@@ -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 (
<text style={{ pointerEvents: "none", userSelect: "none" }}>
{lines.map((line, k) => {
@@ -3032,7 +3043,7 @@ function renderPrimitive(
<tspan
key={k}
x={c.x}
y={startY + k * lineGap}
y={lineYs[k]}
textAnchor={anchor}
>
{line.tspans.map((ts, j) => (
+35 -24
View File
@@ -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),
+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 {
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : "";
if (p.runs.length === 0) return `<div${align}><br></div>`;
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 `<div${style}><br></div>`;
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 {
@@ -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 + 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.
+6 -1
View File
@@ -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. */
+85 -23
View File
@@ -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<string>("Inter");
const [defSize, setDefSize] = useState<number>(12);
const [defAlign, setDefAlign] = useState<Align>("left");
const [defLineHeight, setDefLineHeight] = useState<number>(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({
},
]}
/>
<button
type="button"
className="tb-addtext"
disabled={onAddText == null}
onClick={() => onAddText?.()}
title={onAddText ? t("text.add") : t("text.add.hint")}
aria-label={t("text.add")}
<div
className="tb-lineheight"
role="group"
aria-label={t("text.lineHeight")}
title={t("text.lineHeight.hint")}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
add
</span>
<span className="tb-addtext-label">{t("text.add")}</span>
</button>
<button
type="button"
className="tb-lineheight-btn"
onClick={() => setLineHeightValue(curLineHeight - 0.1)}
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>
);
@@ -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({
<Sep />
{/* 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. */}
<TextGroup textTarget={textTarget} onAddText={onAddText} />
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. */}
<TextGroup textTarget={textTarget} />
<Sep />