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:
@@ -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.
|
||||||
+5
-1
@@ -2156,6 +2156,11 @@ export default function App() {
|
|||||||
else if (selection?.kind === "drawing2d")
|
else if (selection?.kind === "drawing2d")
|
||||||
setElementFill(selection.id, hatchId);
|
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) => {
|
onSetSelectionFillColor: (color) => {
|
||||||
// Vollton-Füllfarbe nur bei Drawing2D (geschlossene Formen).
|
// Vollton-Füllfarbe nur bei Drawing2D (geschlossene Formen).
|
||||||
if (selection?.kind === "drawing2d")
|
if (selection?.kind === "drawing2d")
|
||||||
@@ -3061,7 +3066,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
textTarget={textTarget}
|
textTarget={textTarget}
|
||||||
onAddText={null}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
|
|||||||
+5
-2
@@ -212,6 +212,7 @@ export const de = {
|
|||||||
"attr.color": "Farbe",
|
"attr.color": "Farbe",
|
||||||
"attr.weight": "Strichstärke (mm)",
|
"attr.weight": "Strichstärke (mm)",
|
||||||
"attr.lineStyle": "Linienstil",
|
"attr.lineStyle": "Linienstil",
|
||||||
|
"attr.lineStyle.none": "Standard",
|
||||||
"attr.fill": "Füllung",
|
"attr.fill": "Füllung",
|
||||||
"attr.fillColor": "Füllfarbe",
|
"attr.fillColor": "Füllfarbe",
|
||||||
"attr.clearFill": "Füllfarbe entfernen",
|
"attr.clearFill": "Füllfarbe entfernen",
|
||||||
@@ -882,8 +883,10 @@ export const de = {
|
|||||||
"text.align.left": "Linksbündig",
|
"text.align.left": "Linksbündig",
|
||||||
"text.align.center": "Zentriert",
|
"text.align.center": "Zentriert",
|
||||||
"text.align.right": "Rechtsbündig",
|
"text.align.right": "Rechtsbündig",
|
||||||
"text.add": "Text",
|
"text.lineHeight": "Zeilenhöhe",
|
||||||
"text.add.hint": "Text-Werkzeug — neuen Text platzieren (bald verfügbar)",
|
"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.editTitle": "Text bearbeiten",
|
||||||
"text.apply": "Übernehmen",
|
"text.apply": "Übernehmen",
|
||||||
"text.cancel": "Abbrechen",
|
"text.cancel": "Abbrechen",
|
||||||
|
|||||||
+5
-2
@@ -211,6 +211,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"attr.color": "Color",
|
"attr.color": "Color",
|
||||||
"attr.weight": "Line weight (mm)",
|
"attr.weight": "Line weight (mm)",
|
||||||
"attr.lineStyle": "Line style",
|
"attr.lineStyle": "Line style",
|
||||||
|
"attr.lineStyle.none": "Default",
|
||||||
"attr.fill": "Fill",
|
"attr.fill": "Fill",
|
||||||
"attr.fillColor": "Fill color",
|
"attr.fillColor": "Fill color",
|
||||||
"attr.clearFill": "Remove fill color",
|
"attr.clearFill": "Remove fill color",
|
||||||
@@ -874,8 +875,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"text.align.left": "Align left",
|
"text.align.left": "Align left",
|
||||||
"text.align.center": "Center",
|
"text.align.center": "Center",
|
||||||
"text.align.right": "Align right",
|
"text.align.right": "Align right",
|
||||||
"text.add": "Text",
|
"text.lineHeight": "Line height",
|
||||||
"text.add.hint": "Text tool — place new text (coming soon)",
|
"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.editTitle": "Edit text",
|
||||||
"text.apply": "Apply",
|
"text.apply": "Apply",
|
||||||
"text.cancel": "Cancel",
|
"text.cancel": "Cancel",
|
||||||
|
|||||||
@@ -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-key">{t("attr.lineStyle")}</span>
|
||||||
<span className="attr-val attr-readonly">
|
<span className="attr-val">
|
||||||
{lineStyle ? lineStyle.name : "—"}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ export interface PanelHostValue {
|
|||||||
* die Quelle, siehe {@link onSetSelectionHatchSource}).
|
* die Quelle, siehe {@link onSetSelectionHatchSource}).
|
||||||
*/
|
*/
|
||||||
onSetSelectionFill: (hatchId: string | null) => void;
|
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).
|
* Setzt/entfernt die Vollton-Füllfarbe — wirkt NUR auf Drawing2D (sonst No-op).
|
||||||
* `null` entfernt die Füllfarbe (Fläche wieder transparent).
|
* `null` entfernt die Füllfarbe (Fläche wieder transparent).
|
||||||
|
|||||||
+14
-3
@@ -3012,13 +3012,24 @@ function renderPrimitive(
|
|||||||
});
|
});
|
||||||
const extra: SvgLine[] = p.extraLines.map((line) => ({
|
const extra: SvgLine[] = p.extraLines.map((line) => ({
|
||||||
align: line.align,
|
align: line.align,
|
||||||
|
lineHeight: lineGap,
|
||||||
tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }],
|
tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }],
|
||||||
}));
|
}));
|
||||||
const lines = [...docLines, ...extra];
|
const lines = [...docLines, ...extra];
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
// Block vertikal um den Anker zentrieren.
|
// Block vertikal um den Anker zentrieren. Absätze können eine eigene
|
||||||
const totalH = baseFs + (lines.length - 1) * lineGap;
|
// 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 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 (
|
return (
|
||||||
<text style={{ pointerEvents: "none", userSelect: "none" }}>
|
<text style={{ pointerEvents: "none", userSelect: "none" }}>
|
||||||
{lines.map((line, k) => {
|
{lines.map((line, k) => {
|
||||||
@@ -3032,7 +3043,7 @@ function renderPrimitive(
|
|||||||
<tspan
|
<tspan
|
||||||
key={k}
|
key={k}
|
||||||
x={c.x}
|
x={c.x}
|
||||||
y={startY + k * lineGap}
|
y={lineYs[k]}
|
||||||
textAnchor={anchor}
|
textAnchor={anchor}
|
||||||
>
|
>
|
||||||
{line.tspans.map((ts, j) => (
|
{line.tspans.map((ts, j) => (
|
||||||
|
|||||||
+35
-24
@@ -486,36 +486,47 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
/* „+ Text"-Knopf: Pille mit Icon + Label. */
|
/* „+ 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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
width: 80px;
|
flex: 0 0 auto;
|
||||||
height: 24px;
|
width: 22px;
|
||||||
box-sizing: border-box;
|
border: none;
|
||||||
padding: 0 8px;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
color: #b0b0b0;
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--input);
|
|
||||||
color: var(--ink-2);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
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) {
|
.tb-lineheight-btn:hover {
|
||||||
background: var(--accent-dim);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-color: var(--accent-border);
|
color: #f0f0f0;
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
.tb-addtext:disabled {
|
.tb-lineheight-value {
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.tb-addtext-label {
|
|
||||||
flex: 1 1 auto;
|
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),
|
/* Marken-Gruppe: Wortmarke + kleiner Icon-Stapel daneben (Settings/Ressourcen),
|
||||||
|
|||||||
+18
-6
@@ -85,10 +85,13 @@ export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
|
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
|
||||||
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : "";
|
const styles: string[] = [];
|
||||||
if (p.runs.length === 0) return `<div${align}><br></div>`;
|
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("");
|
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 {
|
function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string {
|
||||||
@@ -119,6 +122,8 @@ export interface SvgTspan {
|
|||||||
export interface SvgLine {
|
export interface SvgLine {
|
||||||
tspans: SvgTspan[];
|
tspans: SvgTspan[];
|
||||||
align: "left" | "center" | "right";
|
align: "left" | "center" | "right";
|
||||||
|
/** Zeilenhöhe dieses Absatzes (Vielfaches der Basisgrösse), s. `Paragraph.lineHeight`. */
|
||||||
|
lineHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SvgTextOptions {
|
export interface SvgTextOptions {
|
||||||
@@ -126,7 +131,12 @@ export interface SvgTextOptions {
|
|||||||
x: number;
|
x: number;
|
||||||
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
|
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
|
||||||
y: number;
|
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;
|
lineHeight: number;
|
||||||
/**
|
/**
|
||||||
* Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten.
|
* 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
|
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
|
||||||
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
|
* 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[] {
|
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
|
||||||
const basePt = opts.basePt ?? 12;
|
const basePt = opts.basePt ?? 12;
|
||||||
const color = opts.color ?? "#000000";
|
const color = opts.color ?? "#000000";
|
||||||
|
const baseUnit = basePt * opts.unitPerPt;
|
||||||
return doc.paragraphs.map((p) => ({
|
return doc.paragraphs.map((p) => ({
|
||||||
align: p.align ?? "left",
|
align: p.align ?? "left",
|
||||||
|
lineHeight: p.lineHeight != null ? baseUnit * p.lineHeight : opts.lineHeight,
|
||||||
tspans: p.runs
|
tspans: p.runs
|
||||||
.filter((r) => r.text.length > 0)
|
.filter((r) => r.text.length > 0)
|
||||||
.map((r) => runToTspan(r, basePt, opts.unitPerPt, color)),
|
.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 lines = docToLines(doc, opts);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
lines.forEach((line, i) => {
|
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 anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
|
||||||
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
|
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
|
||||||
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
|
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
|
||||||
|
|||||||
@@ -44,10 +44,15 @@ export interface TextRun {
|
|||||||
/** Absatz-Ausrichtung. */
|
/** Absatz-Ausrichtung. */
|
||||||
export type Align = "left" | "center" | "right";
|
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 {
|
export interface Paragraph {
|
||||||
runs: TextRun[];
|
runs: TextRun[];
|
||||||
align?: Align;
|
align?: Align;
|
||||||
|
/** Zeilenhöhe als Vielfaches der Basisgrösse (z. B. 1.3 = 130 %). */
|
||||||
|
lineHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Das gesamte Rich-Text-Dokument. */
|
/** Das gesamte Rich-Text-Dokument. */
|
||||||
|
|||||||
+85
-23
@@ -19,6 +19,7 @@ import {
|
|||||||
applyMark,
|
applyMark,
|
||||||
applyPreset,
|
applyPreset,
|
||||||
commonMarkValue,
|
commonMarkValue,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
DEFAULT_PRESETS,
|
DEFAULT_PRESETS,
|
||||||
docLength,
|
docLength,
|
||||||
isMarkActive,
|
isMarkActive,
|
||||||
@@ -172,11 +173,6 @@ export interface TopBarProps {
|
|||||||
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
|
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
|
||||||
*/
|
*/
|
||||||
textTarget: TextTarget | null;
|
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({
|
function TextGroup({
|
||||||
textTarget,
|
textTarget,
|
||||||
onAddText,
|
|
||||||
}: {
|
}: {
|
||||||
textTarget: TextTarget | null;
|
textTarget: TextTarget | null;
|
||||||
onAddText: (() => void) | null;
|
|
||||||
}) {
|
}) {
|
||||||
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
|
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
|
||||||
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
|
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
|
||||||
const [defFont, setDefFont] = useState<string>("Inter");
|
const [defFont, setDefFont] = useState<string>("Inter");
|
||||||
const [defSize, setDefSize] = useState<number>(12);
|
const [defSize, setDefSize] = useState<number>(12);
|
||||||
const [defAlign, setDefAlign] = useState<Align>("left");
|
const [defAlign, setDefAlign] = useState<Align>("left");
|
||||||
|
const [defLineHeight, setDefLineHeight] = useState<number>(DEFAULT_LINE_HEIGHT);
|
||||||
const [defBold, setDefBold] = useState(false);
|
const [defBold, setDefBold] = useState(false);
|
||||||
const [defItalic, setDefItalic] = useState(false);
|
const [defItalic, setDefItalic] = useState(false);
|
||||||
const [defUnderline, setDefUnderline] = useState(false);
|
const [defUnderline, setDefUnderline] = useState(false);
|
||||||
@@ -593,6 +588,10 @@ function TextGroup({
|
|||||||
const curAlign: Align = textTarget
|
const curAlign: Align = textTarget
|
||||||
? commonAlign(textTarget.doc, rng) ?? "left"
|
? commonAlign(textTarget.doc, rng) ?? "left"
|
||||||
: defAlign;
|
: 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) ──────────────
|
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
|
||||||
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
|
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 applyPresetValue = (id: string) => {
|
||||||
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
|
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
|
||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
@@ -773,19 +781,36 @@ function TextGroup({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<div
|
||||||
type="button"
|
className="tb-lineheight"
|
||||||
className="tb-addtext"
|
role="group"
|
||||||
disabled={onAddText == null}
|
aria-label={t("text.lineHeight")}
|
||||||
onClick={() => onAddText?.()}
|
title={t("text.lineHeight.hint")}
|
||||||
title={onAddText ? t("text.add") : t("text.add.hint")}
|
|
||||||
aria-label={t("text.add")}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
<button
|
||||||
add
|
type="button"
|
||||||
</span>
|
className="tb-lineheight-btn"
|
||||||
<span className="tb-addtext-label">{t("text.add")}</span>
|
onClick={() => setLineHeightValue(curLineHeight - 0.1)}
|
||||||
</button>
|
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>
|
||||||
</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. */
|
/** Indizes der Absätze, die der Bereich [start,end) berührt. */
|
||||||
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
|
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
|
||||||
if (range == null || range.start === range.end) {
|
if (range == null || range.start === range.end) {
|
||||||
@@ -928,7 +989,6 @@ export function TopBar({
|
|||||||
onSaveProject,
|
onSaveProject,
|
||||||
onOpenProject,
|
onOpenProject,
|
||||||
textTarget,
|
textTarget,
|
||||||
onAddText,
|
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||||
@@ -1105,9 +1165,11 @@ export function TopBar({
|
|||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
||||||
„+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. Sitzt
|
Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
|
||||||
bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab. */}
|
Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
|
||||||
<TextGroup textTarget={textTarget} onAddText={onAddText} />
|
„+ Text" ist raus — Text wird ein eigenständiges Zeichenwerkzeug
|
||||||
|
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
|
||||||
|
<TextGroup textTarget={textTarget} />
|
||||||
|
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user