Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.
Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
Prioritäts-Verschneidung (section_boolean.rs), einstellbare
Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
@@ -39,6 +39,8 @@ import {
|
||||
OpeningSection,
|
||||
StairSection,
|
||||
RoomSection,
|
||||
ExtrudedSolidSection,
|
||||
ColumnSection,
|
||||
} from "./ObjectInfoPanel";
|
||||
|
||||
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
|
||||
@@ -315,6 +317,8 @@ export function AttributesPanel() {
|
||||
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
|
||||
{sel.stair && <StairSection stair={sel.stair} host={host} />}
|
||||
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
|
||||
{sel.extrudedSolid && <ExtrudedSolidSection solid={sel.extrudedSolid} host={host} />}
|
||||
{sel.column && <ColumnSection column={sel.column} host={host} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
// Inhalts-Panel „Elemente" — BIM-Baum Geschoss → Bauteilklasse → Element
|
||||
// (ROADMAP §11 „Element-Übersicht", Phase 1).
|
||||
//
|
||||
// Datenquelle: `scheduleRows()` (export/exportSchedule.ts) — dieselbe
|
||||
// Auswertung wie die Bauteilliste (CSV-Export), hier aber live zu einem
|
||||
// klick-/zoombaren Baum gruppiert statt exportiert. Legacy-„Tür"-Datensätze
|
||||
// (project.doors) werden ausgeblendet: dafür existiert aktuell KEIN
|
||||
// Auswahl-Kanal (nichts legt sie mehr an, siehe evalDoor-Kommentar) — echte
|
||||
// Türen laufen als Öffnung (`Opening.kind === "door"`) und erscheinen hier
|
||||
// als Bauteilklasse „Öffnung".
|
||||
//
|
||||
// Klick selektiert das Element (leert alle anderen Auswahl-Kanäle, wechselt
|
||||
// bei Bedarf das Geschoss); Shift-Klick oder Doppelklick selektiert UND passt
|
||||
// die Plan-Ansicht ein. Auf-/zuklappbare Geschoss- und Klassen-Knoten, ein
|
||||
// Textfilter oben durchsucht Name/Typ/Klasse.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { ScheduleKind, ScheduleRow } from "./host";
|
||||
import { scheduleRows } from "../export/exportSchedule";
|
||||
import { t } from "../i18n";
|
||||
|
||||
/** Bauteilklassen, die im Baum erscheinen (Reihenfolge = Anzeigereihenfolge). */
|
||||
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
|
||||
"Wand",
|
||||
"Decke",
|
||||
"Fenster",
|
||||
"Öffnung",
|
||||
"Treppe",
|
||||
"Extrusion",
|
||||
];
|
||||
|
||||
/** i18n-Key des Klassen-Labels je Bauteilklasse. */
|
||||
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
|
||||
Wand: "elements.kind.wall",
|
||||
Decke: "elements.kind.ceiling",
|
||||
Fenster: "elements.kind.window",
|
||||
Öffnung: "elements.kind.opening",
|
||||
Treppe: "elements.kind.stair",
|
||||
Extrusion: "elements.kind.extrusion",
|
||||
};
|
||||
|
||||
/** Kurzform einer ID für die Anzeige (voller Wert bleibt im Tooltip). */
|
||||
function shortId(id: string): string {
|
||||
return id.length <= 14 ? id : `${id.slice(0, 4)}…${id.slice(-6)}`;
|
||||
}
|
||||
|
||||
/** Ein Geschoss-Knoten des Baums (auch für Elemente ohne bekanntes Geschoss). */
|
||||
interface FloorGroup {
|
||||
/** Gruppierungsschlüssel: die Geschoss-ID, oder ein Namens-Fallback. */
|
||||
key: string;
|
||||
/** Anzeigename (Geschoss-Name bzw. bereits aufgelöster Fallback aus row.floor). */
|
||||
name: string;
|
||||
rows: ScheduleRow[];
|
||||
}
|
||||
|
||||
/** Gruppiert Zeilen nach Geschoss, in der Reihenfolge der Zeichnungsebenen (oben→unten). */
|
||||
function buildFloorGroups(
|
||||
rows: ScheduleRow[],
|
||||
levels: { id: string; name: string }[],
|
||||
): FloorGroup[] {
|
||||
const byKey = new Map<string, ScheduleRow[]>();
|
||||
for (const r of rows) {
|
||||
const key = r.floorId ?? `name:${r.floor}`;
|
||||
const list = byKey.get(key);
|
||||
if (list) list.push(r);
|
||||
else byKey.set(key, [r]);
|
||||
}
|
||||
// Oberstes Geschoss zuoberst, wie im Zeichnungsebenen-Panel.
|
||||
const levelsTopDown = [...levels].reverse();
|
||||
const groups: FloorGroup[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const lvl of levelsTopDown) {
|
||||
const list = byKey.get(lvl.id);
|
||||
if (list && list.length) {
|
||||
groups.push({ key: lvl.id, name: lvl.name, rows: list });
|
||||
used.add(lvl.id);
|
||||
}
|
||||
}
|
||||
// Rest (Elemente ohne bekannte Zeichnungsebene, z. B. verwaiste Referenz).
|
||||
for (const [key, list] of byKey) {
|
||||
if (used.has(key)) continue;
|
||||
groups.push({ key, name: list[0].floor, rows: list });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Ob eine Zeile zum Suchtext passt (Name/Typ/Klasse, case-insensitive). */
|
||||
function matchesQuery(row: ScheduleRow, classLabel: string, query: string): boolean {
|
||||
const haystack = `${row.typeName} ${classLabel} ${row.id}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
export function ElementTreePanel() {
|
||||
const host = usePanelHost();
|
||||
const [query, setQuery] = useState("");
|
||||
const [collapsedFloors, setCollapsedFloors] = useState<Set<string>>(new Set());
|
||||
const [collapsedClasses, setCollapsedClasses] = useState<Set<string>>(new Set());
|
||||
|
||||
const allRows = useMemo(
|
||||
() => scheduleRows(host.project).filter((r) => r.kind !== "Tür"),
|
||||
[host.project],
|
||||
);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const searching = q.length > 0;
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
if (!searching) return allRows;
|
||||
return allRows.filter((r) =>
|
||||
matchesQuery(r, t(CLASS_LABEL_KEY[r.kind as Exclude<ScheduleKind, "Tür">]), q),
|
||||
);
|
||||
}, [allRows, q, searching]);
|
||||
|
||||
const floorGroups = useMemo(
|
||||
() => buildFloorGroups(visibleRows, host.project.drawingLevels),
|
||||
[visibleRows, host.project.drawingLevels],
|
||||
);
|
||||
|
||||
const toggleFloor = (key: string) =>
|
||||
setCollapsedFloors((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
const toggleClass = (key: string) =>
|
||||
setCollapsedClasses((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const onRowSelect = (row: ScheduleRow, zoom: boolean) => {
|
||||
host.onSelectScheduleRow(row.kind, row.id, row.floorId, { zoom });
|
||||
};
|
||||
|
||||
if (allRows.length === 0) {
|
||||
return (
|
||||
<section className="nav-group">
|
||||
<header className="nav-group-head">
|
||||
<span className="nav-group-title">{t("elements.title")}</span>
|
||||
</header>
|
||||
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("elements.empty")}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="nav-group">
|
||||
<header className="nav-group-head">
|
||||
<span className="nav-group-title">{t("elements.title")}</span>
|
||||
</header>
|
||||
|
||||
<div style={{ padding: "0 6px 6px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={t("elements.search")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
background: "var(--input)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
color: "var(--label)",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 11,
|
||||
padding: "4px 8px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{floorGroups.length === 0 ? (
|
||||
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("elements.noMatch")}
|
||||
</div>
|
||||
) : (
|
||||
floorGroups.map((floor) => {
|
||||
const floorOpen = searching || !collapsedFloors.has(floor.key);
|
||||
return (
|
||||
<div className="level-cat" key={floor.key}>
|
||||
<header
|
||||
className="level-cat-head"
|
||||
onClick={() => toggleFloor(floor.key)}
|
||||
>
|
||||
<span
|
||||
className={`level-cat-chevron${floorOpen ? " open" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
<span className="level-cat-title">{floor.name}</span>
|
||||
<span style={{ fontSize: 10, color: "var(--muted)" }}>
|
||||
{floor.rows.length}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{floorOpen && (
|
||||
<div className="nav-list">
|
||||
{CLASS_ORDER.map((kind) => {
|
||||
const rowsOfKind = floor.rows.filter((r) => r.kind === kind);
|
||||
if (rowsOfKind.length === 0) return null;
|
||||
const classKey = `${floor.key}::${kind}`;
|
||||
const classOpen = searching || !collapsedClasses.has(classKey);
|
||||
return (
|
||||
<div key={classKey}>
|
||||
<header
|
||||
className="level-cat-head"
|
||||
style={{ paddingLeft: 20, minHeight: 20 }}
|
||||
onClick={() => toggleClass(classKey)}
|
||||
>
|
||||
<span
|
||||
className={`level-cat-chevron${classOpen ? " open" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
<span className="level-cat-title" style={{ fontSize: 9 }}>
|
||||
{t(CLASS_LABEL_KEY[kind])}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: "var(--muted)" }}>
|
||||
{rowsOfKind.length}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{classOpen && (
|
||||
<div className="nav-list">
|
||||
{rowsOfKind.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="nav-row"
|
||||
style={{ paddingLeft: 34 }}
|
||||
title={row.id}
|
||||
onClick={(e) => onRowSelect(row, e.shiftKey)}
|
||||
onDoubleClick={() => onRowSelect(row, true)}
|
||||
>
|
||||
<span className="nav-label">{row.typeName}</span>
|
||||
<span className="nav-elev">{shortId(row.id)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab-Symbol „Elemente" — kleiner Baum (Ast + drei Blätter). */
|
||||
export function ElementsTabIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<line x1="3" y1="2.5" x2="3" y2="13.5" />
|
||||
<line x1="3" y1="5" x2="8" y2="5" />
|
||||
<line x1="3" y1="8" x2="8" y2="8" />
|
||||
<line x1="3" y1="11" x2="8" y2="11" />
|
||||
<circle cx="11" cy="5" r="1.6" />
|
||||
<circle cx="11" cy="8" r="1.6" />
|
||||
<circle cx="11" cy="11" r="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
// Inhalts-Panel „Layouts" — Druck-/Plan-Blätter als ORDNER-/BAUM-Struktur
|
||||
// (DOSSIER A3, ROADMAP §11). Ordner enthalten Layouts (Verschachtelung möglich);
|
||||
// Masterlayouts bilden einen eigenen Abschnitt (Vorlagen, kein Ordner-Mitglied).
|
||||
//
|
||||
// Ein einziges „+"-Menü oben legt Ordner/Layout/Masterlayout an; Layout/Master
|
||||
// öffnen zuvor einen Grössen-Dialog. Neu angelegte Elemente landen im aktiven
|
||||
// Ordner (Klick auf eine Ordnerzeile) und werden SOFORT inline umbenennbar
|
||||
// (autofocus, Enter bestätigt — KEIN window.prompt, im Tauri-WKWebView deaktiviert).
|
||||
// Ein Ordner lässt sich als EIN Mehrseiten-PDF exportieren.
|
||||
//
|
||||
// Reine Verdrahtung über den Host-Context (usePanelHost); Mutationslogik in
|
||||
// App.tsx (setProject), pure CRUD/Baum-Aufbau in panels/layoutModel.ts.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePanelHost } from "./host";
|
||||
import type {
|
||||
Layout,
|
||||
LayoutOrientation,
|
||||
LayoutPaperFormat,
|
||||
MasterLayout,
|
||||
} from "../model/types";
|
||||
import {
|
||||
buildLayoutTree,
|
||||
buildMasterTree,
|
||||
PAPER_FORMAT_GROUPS,
|
||||
type FolderNode,
|
||||
type MasterFolderNode,
|
||||
} from "./layoutModel";
|
||||
import {
|
||||
NewLayoutDialog,
|
||||
NewMasterLayoutDialog,
|
||||
type LayoutSizeStart,
|
||||
type NewLayoutStart,
|
||||
} from "../ui/LayoutCreateDialogs";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
|
||||
|
||||
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
|
||||
function TreeIcon({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
|
||||
function FolderIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<TreeIcon>
|
||||
{open ? (
|
||||
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
|
||||
) : (
|
||||
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
|
||||
)}
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Layout-Blatt — Papier-Rechteck mit ein paar Inhaltslinien. */
|
||||
function LayoutSheetIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<rect x="3.5" y="2" width="9" height="12" rx="1" />
|
||||
<line x1="5.5" y1="5" x2="10.5" y2="5" />
|
||||
<line x1="5.5" y1="7.5" x2="10.5" y2="7.5" />
|
||||
<line x1="5.5" y1="10" x2="8.5" y2="10" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Masterlayout — Papier mit Stern-Akzent (Vorlage), hebt sich vom Blatt ab. */
|
||||
function MasterSheetIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<rect x="3" y="2" width="10" height="12" rx="1" />
|
||||
<path d="M8 5l0.9 1.9 2 0.2-1.5 1.4 0.5 2L8 9.6 6.1 10.5l0.5-2L5.1 7.1l2-0.2z" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
|
||||
function FolderPlusIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
|
||||
<path d="M2 4.2v9.3h6.5" />
|
||||
<line x1="11.5" y1="9" x2="11.5" y2="14" />
|
||||
<line x1="9" y1="11.5" x2="14" y2="11.5" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Erstellen" — reines Plus (Layout/Masterlayout). */
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<line x1="8" y1="3" x2="8" y2="13" />
|
||||
<line x1="3" y1="8" x2="13" y2="8" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Welche Zeilenart gerade inline umbenannt wird (Commit-Handler unterscheidet sich). */
|
||||
type RenameKind = "folder" | "layout" | "master";
|
||||
interface Renaming {
|
||||
id: string;
|
||||
kind: RenameKind;
|
||||
}
|
||||
|
||||
/** Ein Inline-Rename-Input (Muster wie ViewSnapshotsPanel: autoFocus/Enter/Escape/Blur). */
|
||||
function RenameInput({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="settings-num-input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type="text"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommit();
|
||||
else if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kompakte Paper/Orientation-Auswahl (Master-Editor). Setzt Custom zurück. */
|
||||
function PaperControls({
|
||||
paper,
|
||||
orientation,
|
||||
onPaper,
|
||||
onOrientation,
|
||||
}: {
|
||||
paper: LayoutPaperFormat;
|
||||
orientation: LayoutOrientation;
|
||||
onPaper: (p: LayoutPaperFormat) => void;
|
||||
onOrientation: (o: LayoutOrientation) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<select
|
||||
className="res-select"
|
||||
value={paper}
|
||||
title={t("layouts.paper")}
|
||||
onChange={(e) => onPaper(e.target.value as LayoutPaperFormat)}
|
||||
>
|
||||
{PAPER_FORMAT_GROUPS.map((g) => (
|
||||
<optgroup
|
||||
key={g.label}
|
||||
label={g.label === "A" ? t("layouts.paperGroup.a") : t("layouts.paperGroup.b")}
|
||||
>
|
||||
{g.formats.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="res-select"
|
||||
value={orientation}
|
||||
title={t("layouts.orientation")}
|
||||
onChange={(e) => onOrientation(e.target.value as LayoutOrientation)}
|
||||
>
|
||||
<option value="portrait">{t("layouts.portrait")}</option>
|
||||
<option value="landscape">{t("layouts.landscape")}</option>
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MasterRow({
|
||||
master,
|
||||
depth,
|
||||
renaming,
|
||||
renameDraft,
|
||||
setRenameDraft,
|
||||
startRename,
|
||||
commitRename,
|
||||
cancelRename,
|
||||
}: {
|
||||
master: MasterLayout;
|
||||
depth: number;
|
||||
renaming: boolean;
|
||||
renameDraft: string;
|
||||
setRenameDraft: (v: string) => void;
|
||||
startRename: (e: React.MouseEvent) => void;
|
||||
commitRename: () => void;
|
||||
cancelRename: () => void;
|
||||
}) {
|
||||
const host = usePanelHost();
|
||||
const [open, setOpen] = useState(false);
|
||||
const tb = master.titleBlock;
|
||||
const onDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
host.onDeleteMasterLayout(master.id);
|
||||
};
|
||||
const field = (key: keyof typeof tb, label: string) => (
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: "0 0 82px" }}>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="res-input"
|
||||
style={{ flex: 1 }}
|
||||
value={tb[key] ?? ""}
|
||||
placeholder={t("layouts.tb.autoPlaceholder")}
|
||||
onChange={(e) =>
|
||||
host.onPatchMasterLayout(master.id, {
|
||||
titleBlock: { ...tb, [key]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="nav-row"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
paddingLeft: 10 + depth * 14,
|
||||
}}
|
||||
>
|
||||
{renaming ? (
|
||||
<RenameInput
|
||||
value={renameDraft}
|
||||
onChange={setRenameDraft}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="nav-label"
|
||||
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
|
||||
>
|
||||
<span style={{ flex: "0 0 auto" }}>{open ? "▾" : "▸"}</span>
|
||||
<MasterSheetIcon />
|
||||
{master.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.rename")}
|
||||
onClick={startRename}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.delete")}
|
||||
onClick={onDelete}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 5, padding: "6px 10px 10px" }}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<PaperControls
|
||||
paper={master.paper}
|
||||
orientation={master.orientation}
|
||||
onPaper={(p) =>
|
||||
host.onPatchMasterLayout(master.id, {
|
||||
paper: p,
|
||||
customWidthMm: undefined,
|
||||
customHeightMm: undefined,
|
||||
})
|
||||
}
|
||||
onOrientation={(o) =>
|
||||
host.onPatchMasterLayout(master.id, {
|
||||
orientation: o,
|
||||
customWidthMm: undefined,
|
||||
customHeightMm: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={master.border ?? true}
|
||||
onChange={(e) =>
|
||||
host.onPatchMasterLayout(master.id, { border: e.target.checked })
|
||||
}
|
||||
/>
|
||||
{t("layouts.border")}
|
||||
</label>
|
||||
</div>
|
||||
{field("projectName", t("layouts.tb.projectName"))}
|
||||
{field("sheetName", t("layouts.tb.sheetName"))}
|
||||
{field("scale", t("layouts.tb.scale"))}
|
||||
{field("date", t("layouts.tb.date"))}
|
||||
{field("author", t("layouts.tb.author"))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutsPanel() {
|
||||
const host = usePanelHost();
|
||||
const layouts = host.project.layouts ?? [];
|
||||
const masters = host.project.masterLayouts ?? [];
|
||||
const folders = host.project.layoutFolders ?? [];
|
||||
// Getrennte Bäume je `kind` — Layout- und Master-Ordner werden NIE vermischt.
|
||||
const layoutFolders = folders.filter((f) => f.kind !== "master");
|
||||
const masterFolders = folders.filter((f) => f.kind === "master");
|
||||
const tree = buildLayoutTree(layoutFolders, layouts);
|
||||
const masterTree = buildMasterTree(masterFolders, masters);
|
||||
|
||||
// Aktiver Ordner = Ziel neuer Elemente (Klick auf eine Ordnerzeile). Null = Wurzel.
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
// Aktiver Master-Ordner (eigener Zustand — Ziel neuer Master/Master-Ordner).
|
||||
const [activeMasterFolderId, setActiveMasterFolderId] = useState<string | null>(null);
|
||||
// Auf-/zugeklappte Ordner.
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
// Inline-Rename (welche Zeile + Entwurf).
|
||||
const [renaming, setRenaming] = useState<Renaming | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
// Offener Erstell-Dialog.
|
||||
const [dialog, setDialog] = useState<null | "layout" | "master">(null);
|
||||
// Drag&Drop: gezogenes Element + gerade überfahrenes Drop-Ziel (Ordner-Id
|
||||
// oder "root" für die Wurzel-Ablagefläche).
|
||||
const [dragItem, setDragItem] = useState<{
|
||||
kind: "layout" | "folder";
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
|
||||
|
||||
const endDrag = () => {
|
||||
setDragItem(null);
|
||||
setDragOverId(null);
|
||||
};
|
||||
const dropOnFolder = (targetFolderId: string) => {
|
||||
if (!dragItem) return;
|
||||
if (dragItem.kind === "layout") {
|
||||
host.onMoveLayoutToFolder(dragItem.id, targetFolderId);
|
||||
} else if (dragItem.id !== targetFolderId) {
|
||||
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
|
||||
host.onMoveFolderToFolder(dragItem.id, targetFolderId);
|
||||
}
|
||||
expandFolder(targetFolderId);
|
||||
endDrag();
|
||||
};
|
||||
const dropOnRoot = () => {
|
||||
if (!dragItem) return;
|
||||
if (dragItem.kind === "layout") host.onMoveLayoutToFolder(dragItem.id, null);
|
||||
else host.onMoveFolderToFolder(dragItem.id, null);
|
||||
endDrag();
|
||||
};
|
||||
|
||||
const isExpanded = (id: string) => !collapsed.has(id);
|
||||
const toggleFolder = (id: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const expandFolder = (id: string) =>
|
||||
setCollapsed((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
|
||||
setRenaming({ id, kind });
|
||||
setRenameDraft(currentName);
|
||||
};
|
||||
const commitRename = () => {
|
||||
if (!renaming) return;
|
||||
const name = renameDraft.trim();
|
||||
if (name) {
|
||||
if (renaming.kind === "folder") host.onRenameLayoutFolder(renaming.id, name);
|
||||
else if (renaming.kind === "layout") host.onRenameLayout(renaming.id, name);
|
||||
else host.onRenameMasterLayout(renaming.id, name);
|
||||
}
|
||||
setRenaming(null);
|
||||
};
|
||||
const cancelRename = () => setRenaming(null);
|
||||
const renamingRow = (id: string, kind: RenameKind) =>
|
||||
renaming?.id === id && renaming.kind === kind;
|
||||
|
||||
// ── Anlegen (zwei getrennte Buttons je Abschnitt) ─────────────────────────
|
||||
const addFolder = () => {
|
||||
const id = host.onAddLayoutFolder(activeFolderId ?? undefined);
|
||||
if (activeFolderId) expandFolder(activeFolderId);
|
||||
beginRename(id, "folder", t("layouts.folder.defaultName"));
|
||||
};
|
||||
const addMasterFolder = () => {
|
||||
const id = host.onAddMasterFolder(activeMasterFolderId ?? undefined);
|
||||
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
|
||||
beginRename(id, "folder", t("layouts.folder.defaultName"));
|
||||
};
|
||||
const createLayoutFromDialog = (start: NewLayoutStart) => {
|
||||
const id = host.onCreateLayout({
|
||||
folderId: activeFolderId ?? undefined,
|
||||
masterId: start.masterId,
|
||||
paper: start.paper,
|
||||
orientation: start.orientation,
|
||||
customWidthMm: start.customWidthMm,
|
||||
customHeightMm: start.customHeightMm,
|
||||
});
|
||||
if (activeFolderId) expandFolder(activeFolderId);
|
||||
setDialog(null);
|
||||
beginRename(id, "layout", t("layouts.defaultName"));
|
||||
};
|
||||
const createMasterFromDialog = (start: LayoutSizeStart) => {
|
||||
const id = host.onCreateMasterLayout({
|
||||
folderId: activeMasterFolderId ?? undefined,
|
||||
paper: start.paper,
|
||||
orientation: start.orientation,
|
||||
customWidthMm: start.customWidthMm,
|
||||
customHeightMm: start.customHeightMm,
|
||||
});
|
||||
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
|
||||
setDialog(null);
|
||||
beginRename(id, "master", t("layouts.defaultMasterName"));
|
||||
};
|
||||
|
||||
// ── Baum-Zeilen ─────────────────────────────────────────────────────────
|
||||
const LayoutRow = (layout: Layout, depth: number) => (
|
||||
<div
|
||||
key={layout.id}
|
||||
className="nav-row"
|
||||
title={t("layouts.openHint", { name: layout.name })}
|
||||
draggable={!renamingRow(layout.id, "layout")}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragItem({ kind: "layout", id: layout.id });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
onClick={() =>
|
||||
renamingRow(layout.id, "layout") ? undefined : host.onOpenLayout(layout.id)
|
||||
}
|
||||
onDoubleClick={() => host.onOpenLayout(layout.id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
|
||||
>
|
||||
{renamingRow(layout.id, "layout") ? (
|
||||
<RenameInput
|
||||
value={renameDraft}
|
||||
onChange={setRenameDraft}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="nav-label"
|
||||
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
|
||||
>
|
||||
<LayoutSheetIcon />
|
||||
{layout.name}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: "var(--muted)" }}>
|
||||
{(layout.customWidthMm && layout.customHeightMm
|
||||
? `${Math.round(layout.customWidthMm)}×${Math.round(layout.customHeightMm)}`
|
||||
: layout.paper.toUpperCase())}{" "}
|
||||
· {layout.viewports.length}
|
||||
</span>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.rename")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(layout.id, "layout", layout.name);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
host.onDeleteLayout(layout.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FolderRowTree = (node: FolderNode, depth: number): React.ReactNode => {
|
||||
const { folder } = node;
|
||||
const expanded = isExpanded(folder.id);
|
||||
const active = activeFolderId === folder.id;
|
||||
const empty = node.layouts.length === 0 && node.subfolders.length === 0;
|
||||
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
|
||||
const dragOver = dragOverId === folder.id && !isDragged;
|
||||
return (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
|
||||
title={folder.name}
|
||||
draggable={!renamingRow(folder.id, "folder")}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragItem({ kind: "folder", id: folder.id });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
onDragOver={(e) => {
|
||||
if (!dragItem) return;
|
||||
e.stopPropagation();
|
||||
if (isDragged) {
|
||||
if (dragOverId !== null) setDragOverId(null);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOverId !== folder.id) setDragOverId(folder.id);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverId === folder.id) setDragOverId(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropOnFolder(folder.id);
|
||||
}}
|
||||
onClick={() => setActiveFolderId(active ? null : folder.id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
|
||||
>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
|
||||
title={expanded ? "▾" : "▸"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(folder.id);
|
||||
}}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
{renamingRow(folder.id, "folder") ? (
|
||||
<RenameInput
|
||||
value={renameDraft}
|
||||
onChange={setRenameDraft}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="nav-label"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<FolderIcon open={expanded} />
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.folder.export")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
host.onExportFolderPdf(folder.id);
|
||||
}}
|
||||
>
|
||||
⭳
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.folder.rename")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(folder.id, "folder", folder.name);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.folder.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeFolderId === folder.id) setActiveFolderId(null);
|
||||
host.onDeleteLayoutFolder(folder.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
|
||||
{node.layouts.map((l) => LayoutRow(l, depth + 1))}
|
||||
{empty && (
|
||||
<div
|
||||
style={{
|
||||
padding: "2px 10px 4px",
|
||||
paddingLeft: 10 + (depth + 1) * 14,
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{t("layouts.folder.empty")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Master-Baum-Zeilen (eigener Baum; kein Drag&Drop, kein PDF-Export) ─────
|
||||
const renderMasterRow = (m: MasterLayout, depth: number) => (
|
||||
<MasterRow
|
||||
key={m.id}
|
||||
master={m}
|
||||
depth={depth}
|
||||
renaming={renamingRow(m.id, "master")}
|
||||
renameDraft={renameDraft}
|
||||
setRenameDraft={setRenameDraft}
|
||||
startRename={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(m.id, "master", m.name);
|
||||
}}
|
||||
commitRename={commitRename}
|
||||
cancelRename={cancelRename}
|
||||
/>
|
||||
);
|
||||
|
||||
const MasterFolderRowTree = (node: MasterFolderNode, depth: number): React.ReactNode => {
|
||||
const { folder } = node;
|
||||
const expanded = isExpanded(folder.id);
|
||||
const active = activeMasterFolderId === folder.id;
|
||||
const empty = node.masters.length === 0 && node.subfolders.length === 0;
|
||||
return (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
className={`nav-row${active ? " active" : ""}`}
|
||||
title={folder.name}
|
||||
onClick={() => setActiveMasterFolderId(active ? null : folder.id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
|
||||
>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
|
||||
title={expanded ? "▾" : "▸"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(folder.id);
|
||||
}}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
{renamingRow(folder.id, "folder") ? (
|
||||
<RenameInput
|
||||
value={renameDraft}
|
||||
onChange={setRenameDraft}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="nav-label"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<FolderIcon open={expanded} />
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.folder.rename")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(folder.id, "folder", folder.name);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("layouts.folder.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeMasterFolderId === folder.id) setActiveMasterFolderId(null);
|
||||
host.onDeleteMasterFolder(folder.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
{node.subfolders.map((sub) => MasterFolderRowTree(sub, depth + 1))}
|
||||
{node.masters.map((m) => renderMasterRow(m, depth + 1))}
|
||||
{empty && (
|
||||
<div
|
||||
style={{
|
||||
padding: "2px 10px 4px",
|
||||
paddingLeft: 10 + (depth + 1) * 14,
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{t("layouts.folder.empty")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const treeEmpty = tree.rootFolders.length === 0 && tree.rootLayouts.length === 0;
|
||||
const masterTreeEmpty =
|
||||
masterTree.rootFolders.length === 0 && masterTree.rootMasters.length === 0;
|
||||
|
||||
return (
|
||||
<section className="nav-group">
|
||||
<header className="nav-group-head">
|
||||
<span className="nav-group-title">{t("layouts.title")}</span>
|
||||
<span className="nav-group-actions">
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ display: "inline-flex", padding: "1px 5px" }}
|
||||
title={t("layouts.add.folder")}
|
||||
onClick={addFolder}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ display: "inline-flex", padding: "1px 5px" }}
|
||||
title={t("layouts.add.layout")}
|
||||
onClick={() => setDialog("layout")}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{treeEmpty ? (
|
||||
<div style={{ padding: "8px 10px", fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("layouts.tree.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
if (!dragItem) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOverId !== "root") setDragOverId("root");
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
if (dragOverId === "root") setDragOverId(null);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dropOnRoot();
|
||||
}}
|
||||
>
|
||||
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
|
||||
{tree.rootLayouts.map((l) => LayoutRow(l, 0))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Master-Sektion — eigener Ordner-Baum (Master-Ordner, kind:"master"). */}
|
||||
<header className="nav-group-head" style={{ marginTop: 6 }}>
|
||||
<span className="nav-group-title">{t("layouts.masters")}</span>
|
||||
<span className="nav-group-actions">
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ display: "inline-flex", padding: "1px 5px" }}
|
||||
title={t("layouts.add.folder")}
|
||||
onClick={addMasterFolder}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ display: "inline-flex", padding: "1px 5px" }}
|
||||
title={t("layouts.add.master")}
|
||||
onClick={() => setDialog("master")}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
{masterTreeEmpty ? (
|
||||
<div style={{ padding: "4px 10px 8px", fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("layouts.noMasters")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="nav-list">
|
||||
{masterTree.rootFolders.map((node) => MasterFolderRowTree(node, 0))}
|
||||
{masterTree.rootMasters.map((m) => renderMasterRow(m, 0))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog === "layout" && (
|
||||
<NewLayoutDialog
|
||||
masters={masters}
|
||||
onCreate={createLayoutFromDialog}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog === "master" && (
|
||||
<NewMasterLayoutDialog
|
||||
onCreate={createMasterFromDialog}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab-Symbol „Layouts" — gestapelte Blätter. */
|
||||
export function LayoutsTabIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<rect x="4.5" y="2.5" width="9" height="11" rx="1" />
|
||||
<path d="M2.5 4.5v9h8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+382
-63
@@ -19,6 +19,7 @@ import { t } from "../i18n";
|
||||
import { formatM } from "../model/types";
|
||||
import type {
|
||||
SiaCategory,
|
||||
SliceTermination,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
WallReferenceLine,
|
||||
@@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown";
|
||||
import { usePanelHost } from "./host";
|
||||
import type {
|
||||
CeilingInfo,
|
||||
ColumnInfo,
|
||||
ExtrudedSolidInfo,
|
||||
OpeningInfo,
|
||||
RoomInfo,
|
||||
StairInfo,
|
||||
@@ -68,11 +71,24 @@ export function ObjectInfoPanel() {
|
||||
const width = bbox.maxX - bbox.minX;
|
||||
const height = bbox.maxY - bbox.minY;
|
||||
|
||||
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
|
||||
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
|
||||
// X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit
|
||||
// verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die
|
||||
// eingegebene Koordinate wandert (Delta = neu − aktuell → generischer Move
|
||||
// über den Host). Bei Elementarten, die der Transform-Kern nicht kennt
|
||||
// (Decke/Öffnung/Treppe/Raum), ist der Host-Aufruf ein No-op — das Feld
|
||||
// zeigt danach wieder den unveränderten Wert (wie bei Breite/Höhe oben).
|
||||
const px = bbox.minX + anchor.fx * width;
|
||||
const py = bbox.minY + anchor.fy * height;
|
||||
|
||||
// Rohe Drawing2D-Geometrie der Selektion (nur bei kind==="drawing2d") — für
|
||||
// die Linien-Länge/Kreis-Radius-Felder unten; die Selection-Sicht trägt
|
||||
// keine Geometrieform, daher direkter (Lese-)Zugriff aufs Projekt.
|
||||
const rawDrawing =
|
||||
sel.kind === "drawing2d"
|
||||
? host.project.drawings2d.find((d) => d.id === sel.id)
|
||||
: undefined;
|
||||
const drawingShape = rawDrawing?.geom.shape;
|
||||
|
||||
// ── Maß-Commit ──────────────────────────────────────────────────────────────
|
||||
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
|
||||
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
|
||||
@@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* Bezugspunkt-Würfel (3×3). */}
|
||||
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
|
||||
rechts daneben (Nutzer-Wunsch). */}
|
||||
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
|
||||
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
|
||||
{FRACTIONS.map((fy) =>
|
||||
FRACTIONS.map((fx) => {
|
||||
const active = anchor.fx === fx && anchor.fy === fy;
|
||||
return (
|
||||
<button
|
||||
key={anchorKey(fx, fy)}
|
||||
type="button"
|
||||
className={"objinfo-anchor" + (active ? " active" : "")}
|
||||
title={t("objinfo.setRefpoint")}
|
||||
aria-pressed={active}
|
||||
onClick={() => setAnchor({ fx, fy })}
|
||||
>
|
||||
<span className="objinfo-dot" />
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<div className="objinfo-refrow">
|
||||
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
|
||||
{FRACTIONS.map((fy) =>
|
||||
FRACTIONS.map((fx) => {
|
||||
const active = anchor.fx === fx && anchor.fy === fy;
|
||||
return (
|
||||
<button
|
||||
key={anchorKey(fx, fy)}
|
||||
type="button"
|
||||
className={"objinfo-anchor" + (active ? " active" : "")}
|
||||
title={t("objinfo.setRefpoint")}
|
||||
aria-pressed={active}
|
||||
onClick={() => setAnchor({ fx, fy })}
|
||||
>
|
||||
<span className="objinfo-dot" />
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* X / Y des gewählten Bezugspunkts (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.x")}</span>
|
||||
<span className="objinfo-fval">{formatM(px)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.y")}</span>
|
||||
<span className="objinfo-fval">{formatM(py)}</span>
|
||||
</div>
|
||||
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
|
||||
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.z")}</span>
|
||||
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
|
||||
—
|
||||
</span>
|
||||
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
|
||||
Dreh-Feld, als Spalte. */}
|
||||
<div className="objinfo-xyzcol">
|
||||
<DimensionField
|
||||
label={t("objinfo.x")}
|
||||
value={px}
|
||||
min={-Infinity}
|
||||
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.y")}
|
||||
value={py}
|
||||
min={-Infinity}
|
||||
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
|
||||
/>
|
||||
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
|
||||
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.z")}</span>
|
||||
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein
|
||||
persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */}
|
||||
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() {
|
||||
value={height}
|
||||
onCommit={(h) => commitSize(width, h)}
|
||||
/>
|
||||
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
|
||||
Linie/ein Kreis ist — das Modell gibt das direkt her, andere Formen
|
||||
bleiben ohne Zusatzfeld). */}
|
||||
{drawingShape === "line" && (
|
||||
<DimensionField
|
||||
label={t("objinfo.drawing.length")}
|
||||
value={Math.hypot(width, height)}
|
||||
onCommit={(v) => host.onSetDrawingLineLength(v)}
|
||||
/>
|
||||
)}
|
||||
{drawingShape === "circle" && (
|
||||
<DimensionField
|
||||
label={t("objinfo.drawing.radius")}
|
||||
value={width / 2}
|
||||
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
|
||||
/>
|
||||
)}
|
||||
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
|
||||
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||
die exportierten *Section-Komponenten). */}
|
||||
@@ -228,6 +274,153 @@ export function RoomSection({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
|
||||
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
|
||||
export function ExtrudedSolidSection({
|
||||
solid,
|
||||
host,
|
||||
}: {
|
||||
solid: ExtrudedSolidInfo;
|
||||
host: ReturnType<typeof usePanelHost>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.extrudedSolid.section")}</div>
|
||||
|
||||
{/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.height")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="objinfo-text"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
value={solid.height}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v);
|
||||
}}
|
||||
title={t("objinfo.extrudedSolid.height")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.taper")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="objinfo-text"
|
||||
step={0.05}
|
||||
min={0}
|
||||
max={1}
|
||||
value={solid.taper}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v);
|
||||
}}
|
||||
title={t("objinfo.extrudedSolid.taper")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grundfläche (read-only, abgeleitet). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.area")}</span>
|
||||
<span className="objinfo-fval">{solid.area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
|
||||
{/* Geschoss (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.floor")}</span>
|
||||
<span className="objinfo-fval">{solid.floorName}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stützen-Attribut-Abschnitt (Tragwerk) ───────────────────────────────────
|
||||
// Profil (Rechteck/Kreis) + Masse, Höhe, Drehung editierbar; Geschoss + UK/OK
|
||||
// read-only.
|
||||
export function ColumnSection({
|
||||
column,
|
||||
host,
|
||||
}: {
|
||||
column: ColumnInfo;
|
||||
host: ReturnType<typeof usePanelHost>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.column.section")}</div>
|
||||
|
||||
{/* Profil-Dropdown (Rechteck/Kreis). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.column.profile")}</span>
|
||||
<Dropdown
|
||||
value={column.profile.kind}
|
||||
onChange={(v) => host.onSetColumnProfileKind(v as "rect" | "round")}
|
||||
options={[
|
||||
{ value: "rect", label: t("objinfo.column.profile.rect") },
|
||||
{ value: "round", label: t("objinfo.column.profile.round") },
|
||||
]}
|
||||
title={t("objinfo.column.profile")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editierbare Masse je Profil. */}
|
||||
{column.profile.kind === "round" ? (
|
||||
<DimensionField
|
||||
label={t("objinfo.column.radius")}
|
||||
value={column.profile.radius}
|
||||
onCommit={(v) => host.onSetColumnRadius(v)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DimensionField
|
||||
label={t("objinfo.column.width")}
|
||||
value={column.profile.kind === "rect" ? column.profile.width : 0}
|
||||
onCommit={(v) => host.onSetColumnWidth(v)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.column.depth")}
|
||||
value={column.profile.kind === "rect" ? column.profile.depth : 0}
|
||||
onCommit={(v) => host.onSetColumnDepth(v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DimensionField
|
||||
label={t("objinfo.column.height")}
|
||||
value={column.height}
|
||||
onCommit={(v) => host.onSetColumnHeight(v)}
|
||||
/>
|
||||
|
||||
{/* Drehung (Grad). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.column.rotation")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="objinfo-text"
|
||||
step={1}
|
||||
value={Number(column.rotationDeg.toFixed(1))}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) host.onSetColumnRotation(v);
|
||||
}}
|
||||
title={t("objinfo.column.rotation")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Geschoss (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.column.floor")}</span>
|
||||
<span className="objinfo-fval">{column.floorName}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
|
||||
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
||||
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
||||
@@ -243,6 +436,24 @@ export function StairSection({
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
|
||||
|
||||
{/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.type")}</span>
|
||||
<Dropdown
|
||||
value={stair.typeId ?? ""}
|
||||
onChange={(v) => host.onSetStairType(v)}
|
||||
options={[
|
||||
{ value: "", label: t("objinfo.type.none") },
|
||||
...(host.project.stairTypes ?? []).map((st) => ({
|
||||
value: st.id,
|
||||
label: st.name,
|
||||
})),
|
||||
]}
|
||||
title={t("objinfo.stair.type")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grundform-Dropdown. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
|
||||
@@ -360,6 +571,24 @@ export function OpeningSection({
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
|
||||
|
||||
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
|
||||
<Dropdown
|
||||
value={opening.typeId ?? ""}
|
||||
onChange={(v) => host.onSetOpeningType(v)}
|
||||
options={[
|
||||
{ value: "", label: t("objinfo.type.none") },
|
||||
...(isDoor
|
||||
? host.project.doorTypes ?? []
|
||||
: host.project.windowTypes ?? []
|
||||
).map((ty) => ({ value: ty.id, label: ty.name })),
|
||||
]}
|
||||
title={t("objinfo.opening.type")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Art-Segment Fenster/Tür. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
|
||||
@@ -596,11 +825,15 @@ export function CeilingSection({
|
||||
<span className="objinfo-fval">{ceiling.floorName}</span>
|
||||
</div>
|
||||
|
||||
{/* Fläche (read-only). */}
|
||||
{/* Fläche + Volumen (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
|
||||
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
|
||||
<span className="objinfo-fval">{ceiling.volume.toFixed(2)} m³</span>
|
||||
</div>
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
@@ -614,11 +847,16 @@ export function CeilingSection({
|
||||
onChange={(a) => host.onSetCeilingTop(a)}
|
||||
/>
|
||||
|
||||
{/* UK = OK − Dicke (abgeleitet, read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span>
|
||||
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span>
|
||||
</div>
|
||||
{/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override
|
||||
bleibt es beim heutigen Verhalten UK = OK − Dicke. */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.ceiling.bottom")}
|
||||
anchor={ceiling.bottom}
|
||||
defaultZ={ceiling.zBottom}
|
||||
floors={ceiling.floors}
|
||||
defaultFloorId={ceiling.floorId}
|
||||
onChange={(a) => host.onSetCeilingBottom?.(a)}
|
||||
/>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
|
||||
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
|
||||
@@ -678,6 +916,23 @@ export function WallSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand
|
||||
an der Decke endet oder oberhalb wieder auftaucht. Default "both". */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.sliceTermination")}</span>
|
||||
<Dropdown
|
||||
value={wall.sliceTermination}
|
||||
onChange={(v) => host.onSetWallSliceTermination(v as SliceTermination)}
|
||||
options={[
|
||||
{ value: "both", label: t("objinfo.wall.sliceTermination.both") },
|
||||
{ value: "below", label: t("objinfo.wall.sliceTermination.below") },
|
||||
{ value: "above", label: t("objinfo.wall.sliceTermination.above") },
|
||||
]}
|
||||
title={t("objinfo.wall.sliceTermination")}
|
||||
width={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Aufbau-Typ-Segment. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
|
||||
@@ -730,6 +985,26 @@ export function WallSection({
|
||||
<span className="objinfo-fval">{wall.floorName}</span>
|
||||
</div>
|
||||
|
||||
{/* Länge + Volumen (read-only). Volumen ist NETTO — Fenster/Türen
|
||||
abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.length")}</span>
|
||||
<span className="objinfo-fval">{formatM(wall.length)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
|
||||
<span
|
||||
className="objinfo-fval"
|
||||
title={
|
||||
wall.openingVolume > 0
|
||||
? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} m³ − ${wall.openingVolume.toFixed(2)} m³`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{wall.netVolume.toFixed(2)} m³
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
|
||||
<label className="objinfo-field objinfo-check-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
|
||||
@@ -755,15 +1030,8 @@ export function WallSection({
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.wall.bottom")}
|
||||
anchor={wall.bottom}
|
||||
defaultZ={wall.zBottom}
|
||||
floors={wall.floors}
|
||||
defaultFloorId={wall.floorId}
|
||||
onChange={(a) => host.onSetWallBottom(a)}
|
||||
/>
|
||||
{/* OK & UK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". OK
|
||||
(oben) steht oben, UK (unten) darunter — räumlich passend (Nutzer). */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.wall.top")}
|
||||
anchor={wall.top}
|
||||
@@ -772,6 +1040,14 @@ export function WallSection({
|
||||
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
|
||||
onChange={(a) => host.onSetWallTop(a)}
|
||||
/>
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.wall.bottom")}
|
||||
anchor={wall.bottom}
|
||||
defaultZ={wall.zBottom}
|
||||
floors={wall.floors}
|
||||
defaultFloorId={wall.floorId}
|
||||
onChange={(a) => host.onSetWallBottom(a)}
|
||||
/>
|
||||
|
||||
{/* Höhe = OK − UK (abgeleitet, read-only). */}
|
||||
<div className="objinfo-field">
|
||||
@@ -836,25 +1112,27 @@ function VerticalAnchorRow({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Unterzeile in normaler Label-/Wert-Struktur: Label links, Control in
|
||||
der Wert-Spalte (Nutzer-Wunsch — vorher hing das Control ohne Label
|
||||
im Einzug der linken Spalte). */}
|
||||
{mode === "floor" ? (
|
||||
<div className="objinfo-field objinfo-subfield">
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.anchor.refFloor")}</span>
|
||||
<Dropdown
|
||||
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
|
||||
onChange={(id) => onChange({ mode: "floor", floorId: id })}
|
||||
options={floors.map((f) => ({ value: f.id, label: f.name }))}
|
||||
title={label}
|
||||
title={t("objinfo.anchor.refFloor")}
|
||||
width={150}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="objinfo-subfield">
|
||||
<DimensionField
|
||||
label={t("objinfo.z")}
|
||||
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
|
||||
min={-Infinity}
|
||||
onCommit={(z) => onChange({ mode: "custom", z })}
|
||||
/>
|
||||
</div>
|
||||
<DimensionField
|
||||
label={t("objinfo.anchor.customHeight")}
|
||||
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
|
||||
min={-Infinity}
|
||||
onCommit={(z) => onChange({ mode: "custom", z })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -915,3 +1193,44 @@ function DimensionField({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dreh-Feld (Delta-Winkel in Grad) ─────────────────────────────────────────
|
||||
// Anders als DimensionField zeigt dieses Feld KEINEN persistenten Host-Wert —
|
||||
// eine „aktuelle Rotation" gibt es für die Selektion generell nicht (Wand ist
|
||||
// nur eine Achse, Drawing2D/Extrusion tragen keinen Rotationswinkel). Das Feld
|
||||
// ist daher ein Delta: Eingabe = Drehung ab jetzt, danach zeigt es wieder „0".
|
||||
function RotateField({ onCommit }: { onCommit: (deg: number) => void }) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const shown = draft ?? "0";
|
||||
|
||||
function commit() {
|
||||
if (draft === null) return;
|
||||
const v = Number(draft);
|
||||
setDraft(null);
|
||||
if (isFinite(v) && v !== 0) onCommit(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.rotate")}</span>
|
||||
<input
|
||||
className="objinfo-input"
|
||||
type="number"
|
||||
step={1}
|
||||
value={shown}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
commit();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === "Escape") {
|
||||
setDraft(null);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="objinfo-unit">°</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,25 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="roomstamp-field">
|
||||
<span className="roomstamp-label">{t("roomStamp.roundingStep")}</span>
|
||||
<select
|
||||
className="roomstamp-input"
|
||||
value={draft.roundingStep ?? ""}
|
||||
disabled={!draft.showFloorArea}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ roundingStep: v === "" ? undefined : Number(v) });
|
||||
}}
|
||||
>
|
||||
<option value="">{t("roomStamp.roundingStepDefault")}</option>
|
||||
<option value="0.01">0.01 m²</option>
|
||||
<option value="0.1">0.1 m²</option>
|
||||
<option value="0.5">0.5 m²</option>
|
||||
<option value="1">1 m²</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="roomstamp-row">
|
||||
<label className="roomstamp-check">
|
||||
<input
|
||||
@@ -200,6 +219,21 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
|
||||
onChange={(usageAlign) => patch({ usageAlign })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="roomstamp-field">
|
||||
<span className="roomstamp-label">{t("roomStamp.occupancy")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="roomstamp-input"
|
||||
value={draft.occupancy ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ occupancy: v === "" ? undefined : Number(v) });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="texteditor-foot">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { usePanelHost } from "./host";
|
||||
import type { ContextObject } from "../model/types";
|
||||
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { useStore } from "../state/appStore";
|
||||
|
||||
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
|
||||
function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
@@ -33,6 +34,7 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
|
||||
export function SitePanel() {
|
||||
const host = usePanelHost();
|
||||
const notify = useStore((s) => s.notify);
|
||||
const objects = host.contextObjects;
|
||||
const contourSets = objects.filter((o) => o.type === "contourSet");
|
||||
|
||||
@@ -50,9 +52,9 @@ export function SitePanel() {
|
||||
if (!effectiveSet) return;
|
||||
const id = host.onGenerateTerrain(effectiveSet);
|
||||
if (id === null) {
|
||||
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox).
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(t("site.terrainFailed"));
|
||||
// Kein sinnvolles TIN — ruhiger Hinweis als Toast (keine harte Fehlerbox;
|
||||
// window.alert wäre im Tauri-WKWebView ohnehin wirkungslos).
|
||||
notify(t("site.terrainFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -124,6 +124,24 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
||||
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
case "text":
|
||||
// Serifen-„A" als Text-Symbol.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M 4 13 L 8 3 L 12 13" fill="none" />
|
||||
<path d="M 5.5 9.5 L 10.5 9.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
case "textbox":
|
||||
// Spalten-Rahmen mit drei Textzeilen (Absatztext).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="3" width="11" height="10" fill="none" />
|
||||
<line x1="4.5" y1="6" x2="11.5" y2="6" />
|
||||
<line x1="4.5" y1="8.5" x2="11.5" y2="8.5" />
|
||||
<line x1="4.5" y1="11" x2="9" y2="11" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -140,7 +158,10 @@ export function ToolsPanel() {
|
||||
{TOOL_ORDER.map((id) => {
|
||||
const tool = getTool(id);
|
||||
const active = host.activeTool === id;
|
||||
const disabled = id !== "select" && !host.toolsEnabled;
|
||||
// Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss
|
||||
// gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf
|
||||
// Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar).
|
||||
const disabled = !!tool.floorOnly && !host.toolsEnabled;
|
||||
const title = disabled
|
||||
? t("tool.floorOnlyDisabled")
|
||||
: t(tool.hintKey(tool.init()));
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
// Inhalts-Panel „Ausschnitte" — benannte, gespeicherte Ansichten (View-Snapshots,
|
||||
// DOSSIER A2, ROADMAP §2c/§11) als ORDNER-/BAUM-Struktur (spiegelbildlich zum
|
||||
// Layouts-Panel).
|
||||
//
|
||||
// Ein Ausschnitt fängt den kompletten Darstellungszustand ein (Ansichtstyp,
|
||||
// Kamera-Preset, Massstab, Detailgrad, Ebenen-/Zeichnungssichtbarkeit, aktive
|
||||
// Overrides, aktives Geschoss) und stellt ihn per Klick wieder her. Das Panel
|
||||
// zeigt Ordner (auf-/zuklappbar) mit Ausschnitten als Blättern; zwei Header-
|
||||
// Buttons legen einen Ordner bzw. einen neuen Ausschnitt im AKTIVEN Ordner an
|
||||
// (Klick auf eine Ordnerzeile = aktiver Ordner; ohne Auswahl = Wurzel). Neue
|
||||
// Ordner werden sofort inline umbenannt (autofocus/Enter — KEIN window.prompt,
|
||||
// im Tauri-WKWebView deaktiviert). Ausschnitte/Ordner lassen sich per HTML5-
|
||||
// Drag&Drop zwischen Ordnern verschieben (Zyklus-Schutz im Host).
|
||||
//
|
||||
// Die Footer-Bar (Massstab/Kombis/Overrides/Umbenennen bei angewähltem
|
||||
// Ausschnitt) und die Anwahl-Persistenz (`selectedViewSnapshotId`) bleiben
|
||||
// unverändert erhalten — sie hängen unter dem Baum.
|
||||
//
|
||||
// Reine Verdrahtung über den Host-Context (usePanelHost); die Erfassungs-/
|
||||
// Anwendungslogik lebt in App.tsx (Setter/rAF) bzw. state/viewSnapshots.ts, die
|
||||
// pure Baum-/Ordner-Logik in state/viewSnapshotFolders.ts.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { ViewSnapshot } from "../model/types";
|
||||
import {
|
||||
buildViewSnapshotTree,
|
||||
type ViewSnapshotFolderNode,
|
||||
} from "../state/viewSnapshotFolders";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
|
||||
|
||||
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
|
||||
function TreeIcon({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
|
||||
function FolderIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<TreeIcon>
|
||||
{open ? (
|
||||
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
|
||||
) : (
|
||||
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
|
||||
)}
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
|
||||
function FolderPlusIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
|
||||
<path d="M2 4.2v9.3h6.5" />
|
||||
<line x1="11.5" y1="9" x2="11.5" y2="14" />
|
||||
<line x1="9" y1="11.5" x2="14" y2="11.5" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Erstellen" — reines Plus (neuer Ausschnitt). */
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<line x1="8" y1="3" x2="8" y2="13" />
|
||||
<line x1="3" y1="8" x2="13" y2="8" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ausschnitt-Blatt — Kamera-Sucherrahmen (passt zum Tab-Symbol). */
|
||||
function SnapshotIcon() {
|
||||
return (
|
||||
<TreeIcon>
|
||||
<rect x="2.5" y="4" width="11" height="8" rx="1" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
<line x1="5" y1="4" x2="5.8" y2="2.8" />
|
||||
</TreeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein Inline-Rename-Input (autoFocus/Enter/Escape/Blur). */
|
||||
function RenameInput({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="settings-num-input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type="text"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommit();
|
||||
else if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Deep-Equal zweier `Record<string, boolean>`-Maps (gleiche Keys UND Werte). */
|
||||
function boolMapEqual(
|
||||
a: Record<string, boolean>,
|
||||
b: Record<string, boolean>,
|
||||
): boolean {
|
||||
const ka = Object.keys(a);
|
||||
if (ka.length !== Object.keys(b).length) return false;
|
||||
for (const k of ka) {
|
||||
if (a[k] !== b[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht unter den gespeicherten Kombinationen (`names` + `load`) diejenige,
|
||||
* deren Map EXAKT `target` entspricht — liefert den Namen oder `null`. Grundlage
|
||||
* der Footer-Anzeige „welche Ebenen-/Zeichnungskombi war aktiv".
|
||||
*/
|
||||
function matchingComboName(
|
||||
names: string[],
|
||||
load: (name: string) => Record<string, boolean> | null,
|
||||
target: Record<string, boolean>,
|
||||
): string | null {
|
||||
for (const name of names) {
|
||||
const map = load(name);
|
||||
if (map && boolMapEqual(map, target)) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Welche Zeilenart gerade inline umbenannt wird (Commit unterscheidet sich). */
|
||||
type RenameKind = "folder" | "snapshot";
|
||||
interface Renaming {
|
||||
id: string;
|
||||
kind: RenameKind;
|
||||
}
|
||||
|
||||
export function ViewSnapshotsPanel() {
|
||||
const host = usePanelHost();
|
||||
const snaps = host.project.viewSnapshots ?? [];
|
||||
const folders = host.project.viewSnapshotFolders ?? [];
|
||||
|
||||
const tree = useMemo(
|
||||
() => buildViewSnapshotTree(folders, snaps),
|
||||
[folders, snaps],
|
||||
);
|
||||
|
||||
// In-App-Eingabe statt window.prompt (im Tauri-WKWebView deaktiviert → gäbe
|
||||
// null zurück, nichts würde gespeichert). `naming` = neuer Ausschnitt.
|
||||
const [naming, setNaming] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
// Inline-Rename einer Baumzeile (Ordner ODER Ausschnitt) + Entwurf.
|
||||
const [renaming, setRenaming] = useState<Renaming | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
// Inline-Umbenennen DIREKT in der Footer-Bar (eigener Draft, damit Liste und
|
||||
// Footer sich nicht ins Gehege kommen). `footerRenaming` = Bearbeitung aktiv.
|
||||
const [footerRenaming, setFooterRenaming] = useState(false);
|
||||
const [footerDraft, setFooterDraft] = useState("");
|
||||
|
||||
// Aktiver Ordner = Ziel neuer Ausschnitte/Ordner (Klick auf Ordnerzeile). Null = Wurzel.
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
// Auf-/zugeklappte Ordner.
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
// Drag&Drop: gezogenes Element + überfahrenes Drop-Ziel (Ordner-Id oder "root").
|
||||
const [dragItem, setDragItem] = useState<{
|
||||
kind: "snapshot" | "folder";
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
|
||||
|
||||
// Der aktuell „angewählte" Ausschnitt (bleibt markiert, bis App bei einer
|
||||
// Abweichung `selectedViewSnapshotId` auf null setzt). Footer-Bar nur sichtbar,
|
||||
// solange dieser existiert.
|
||||
const selectedId = host.selectedViewSnapshotId;
|
||||
const selectedSnap = useMemo(
|
||||
() => (selectedId ? snaps.find((s) => s.id === selectedId) ?? null : null),
|
||||
[selectedId, snaps],
|
||||
);
|
||||
|
||||
// Footer-Auflösung: Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal auf
|
||||
// die gespeicherte Map) und die Namen der aktiven Override-Regeln.
|
||||
const footer = useMemo(() => {
|
||||
if (!selectedSnap) return null;
|
||||
const layerCombo = matchingComboName(
|
||||
host.listLayerCombos(),
|
||||
host.loadLayerCombo,
|
||||
selectedSnap.layerVisibility,
|
||||
);
|
||||
const drawingCombo = matchingComboName(
|
||||
host.listDrawingCombos(),
|
||||
host.loadDrawingCombo,
|
||||
selectedSnap.drawingVisibility,
|
||||
);
|
||||
const ids = new Set(selectedSnap.enabledOverrideRuleIds ?? []);
|
||||
const overrideNames = (host.project.overrideRules ?? [])
|
||||
.filter((r) => ids.has(r.id))
|
||||
.map((r) => r.name);
|
||||
return { layerCombo, drawingCombo, overrideNames };
|
||||
}, [selectedSnap, host]);
|
||||
|
||||
// ── Auf-/Zuklappen ────────────────────────────────────────────────────────
|
||||
const isExpanded = (id: string) => !collapsed.has(id);
|
||||
const toggleFolder = (id: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const expandFolder = (id: string) =>
|
||||
setCollapsed((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// ── Inline-Rename (Baumzeilen) ──────────────────────────────────────────────
|
||||
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
|
||||
setRenaming({ id, kind });
|
||||
setRenameDraft(currentName);
|
||||
};
|
||||
const commitRename = () => {
|
||||
if (!renaming) return;
|
||||
const name = renameDraft.trim();
|
||||
if (name) {
|
||||
if (renaming.kind === "folder")
|
||||
host.onRenameViewSnapshotFolder(renaming.id, name);
|
||||
else host.onRenameViewSnapshot(renaming.id, name);
|
||||
}
|
||||
setRenaming(null);
|
||||
};
|
||||
const cancelRename = () => setRenaming(null);
|
||||
const renamingRow = (id: string, kind: RenameKind) =>
|
||||
renaming?.id === id && renaming.kind === kind;
|
||||
|
||||
// ── Anlegen (zwei Header-Buttons) ──────────────────────────────────────────
|
||||
const addFolder = () => {
|
||||
const id = host.onAddViewSnapshotFolder(activeFolderId ?? undefined);
|
||||
if (activeFolderId) expandFolder(activeFolderId);
|
||||
beginRename(id, "folder", t("viewsnap.folder.defaultName"));
|
||||
};
|
||||
const commitSave = () => {
|
||||
const name = draft.trim();
|
||||
if (name) host.onCaptureViewSnapshot(name, activeFolderId ?? undefined);
|
||||
if (activeFolderId) expandFolder(activeFolderId);
|
||||
setNaming(false);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
// ── Drag&Drop ──────────────────────────────────────────────────────────────
|
||||
const endDrag = () => {
|
||||
setDragItem(null);
|
||||
setDragOverId(null);
|
||||
};
|
||||
const dropOnFolder = (targetFolderId: string) => {
|
||||
if (!dragItem) return;
|
||||
if (dragItem.kind === "snapshot") {
|
||||
host.onMoveViewSnapshotToFolder(dragItem.id, targetFolderId);
|
||||
} else if (dragItem.id !== targetFolderId) {
|
||||
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
|
||||
host.onMoveViewSnapshotFolder(dragItem.id, targetFolderId);
|
||||
}
|
||||
expandFolder(targetFolderId);
|
||||
endDrag();
|
||||
};
|
||||
const dropOnRoot = () => {
|
||||
if (!dragItem) return;
|
||||
if (dragItem.kind === "snapshot")
|
||||
host.onMoveViewSnapshotToFolder(dragItem.id, null);
|
||||
else host.onMoveViewSnapshotFolder(dragItem.id, null);
|
||||
endDrag();
|
||||
};
|
||||
|
||||
// ── Footer-Rename ──────────────────────────────────────────────────────────
|
||||
const startFooterRename = () => {
|
||||
if (!selectedSnap) return;
|
||||
setFooterRenaming(true);
|
||||
setFooterDraft(selectedSnap.name);
|
||||
};
|
||||
const commitFooterRename = () => {
|
||||
if (!selectedSnap) return;
|
||||
const name = footerDraft.trim();
|
||||
if (name && name !== selectedSnap.name)
|
||||
host.onRenameViewSnapshot(selectedSnap.id, name);
|
||||
setFooterRenaming(false);
|
||||
};
|
||||
|
||||
// ── Baum-Zeilen ────────────────────────────────────────────────────────────
|
||||
const SnapshotRow = (snap: ViewSnapshot, depth: number) => (
|
||||
<div
|
||||
key={snap.id}
|
||||
className={"nav-row" + (snap.id === selectedId ? " active" : "")}
|
||||
title={t("viewsnap.applyHint", { name: snap.name })}
|
||||
draggable={!renamingRow(snap.id, "snapshot")}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragItem({ kind: "snapshot", id: snap.id });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
onClick={() =>
|
||||
renamingRow(snap.id, "snapshot")
|
||||
? undefined
|
||||
: host.onApplyViewSnapshot(snap.id)
|
||||
}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
|
||||
>
|
||||
{renamingRow(snap.id, "snapshot") ? (
|
||||
<RenameInput
|
||||
value={renameDraft}
|
||||
onChange={setRenameDraft}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="nav-label"
|
||||
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
|
||||
>
|
||||
<SnapshotIcon />
|
||||
{snap.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("viewsnap.rename")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(snap.id, "snapshot", snap.name);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("viewsnap.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
host.onDeleteViewSnapshot(snap.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FolderRowTree = (
|
||||
node: ViewSnapshotFolderNode,
|
||||
depth: number,
|
||||
): React.ReactNode => {
|
||||
const { folder } = node;
|
||||
const expanded = isExpanded(folder.id);
|
||||
const active = activeFolderId === folder.id;
|
||||
const empty = node.snapshots.length === 0 && node.subfolders.length === 0;
|
||||
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
|
||||
const dragOver = dragOverId === folder.id && !isDragged;
|
||||
return (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
|
||||
title={folder.name}
|
||||
draggable={!renamingRow(folder.id, "folder")}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragItem({ kind: "folder", id: folder.id });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
onDragOver={(e) => {
|
||||
if (!dragItem) return;
|
||||
e.stopPropagation();
|
||||
if (isDragged) {
|
||||
if (dragOverId !== null) setDragOverId(null);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOverId !== folder.id) setDragOverId(folder.id);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverId === folder.id) setDragOverId(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropOnFolder(folder.id);
|
||||
}}
|
||||
onClick={() => setActiveFolderId(active ? null : folder.id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
|
||||
>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
|
||||
title={expanded ? "▾" : "▸"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(folder.id);
|
||||
}}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
{renamingRow(folder.id, "folder") ? (
|
||||
<RenameInput
|
||||
value={renameDraft}
|
||||
onChange={setRenameDraft}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="nav-label"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<FolderIcon open={expanded} />
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("viewsnap.folder.rename")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(folder.id, "folder", folder.name);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("viewsnap.folder.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeFolderId === folder.id) setActiveFolderId(null);
|
||||
host.onDeleteViewSnapshotFolder(folder.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
|
||||
{node.snapshots.map((s) => SnapshotRow(s, depth + 1))}
|
||||
{empty && (
|
||||
<div
|
||||
style={{
|
||||
padding: "2px 10px 4px",
|
||||
paddingLeft: 10 + (depth + 1) * 14,
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{t("viewsnap.folder.empty")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const treeEmpty =
|
||||
tree.rootFolders.length === 0 && tree.rootSnapshots.length === 0;
|
||||
|
||||
return (
|
||||
<section className="nav-group">
|
||||
<header className="nav-group-head">
|
||||
<span className="nav-group-title">{t("viewsnap.title")}</span>
|
||||
<span className="nav-group-actions">
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ display: "inline-flex", padding: "1px 5px" }}
|
||||
title={t("viewsnap.add.folder")}
|
||||
onClick={addFolder}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
</button>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ display: "inline-flex", padding: "1px 5px" }}
|
||||
title={t("viewsnap.add.snapshot")}
|
||||
onClick={() => {
|
||||
setNaming(true);
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{naming && (
|
||||
<div className="nav-row" style={{ display: "flex", gap: 4, padding: "4px 8px" }}>
|
||||
<input
|
||||
className="settings-num-input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type="text"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t("viewsnap.savePrompt")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitSave();
|
||||
else if (e.key === "Escape") {
|
||||
setNaming(false);
|
||||
setDraft("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="nav-add" onClick={commitSave} title={t("viewsnap.add.snapshot")}>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{treeEmpty ? (
|
||||
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("viewsnap.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
if (!dragItem) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOverId !== "root") setDragOverId("root");
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
if (dragOverId === "root") setDragOverId(null);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dropOnRoot();
|
||||
}}
|
||||
>
|
||||
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
|
||||
{tree.rootSnapshots.map((s) => SnapshotRow(s, 0))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer-Bar: sichtbar, sobald ein Ausschnitt angewählt ist. Zeigt den
|
||||
erfassten Massstab, die passende Ebenen-/Zeichnungskombi und die
|
||||
aktiven Overrides und erlaubt das Umbenennen direkt hier. */}
|
||||
{selectedSnap && footer && (
|
||||
<div className="viewsnap-footer">
|
||||
<div className="viewsnap-footer-name">
|
||||
{footerRenaming ? (
|
||||
<input
|
||||
className="settings-num-input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type="text"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
value={footerDraft}
|
||||
onChange={(e) => setFooterDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitFooterRename();
|
||||
else if (e.key === "Escape") setFooterRenaming(false);
|
||||
}}
|
||||
onBlur={commitFooterRename}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="viewsnap-footer-title">{selectedSnap.name}</span>
|
||||
<button
|
||||
className="nav-add"
|
||||
style={{ padding: "1px 5px" }}
|
||||
title={t("viewsnap.rename")}
|
||||
onClick={startFooterRename}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<dl className="viewsnap-footer-grid">
|
||||
<dt>{t("viewsnap.footer.scale")}</dt>
|
||||
<dd>1:{selectedSnap.scaleDenominator}</dd>
|
||||
<dt>{t("viewsnap.footer.layerCombo")}</dt>
|
||||
<dd>{footer.layerCombo ?? t("viewsnap.footer.none")}</dd>
|
||||
<dt>{t("viewsnap.footer.drawingCombo")}</dt>
|
||||
<dd>{footer.drawingCombo ?? t("viewsnap.footer.none")}</dd>
|
||||
<dt>{t("viewsnap.footer.overrides")}</dt>
|
||||
<dd>
|
||||
{footer.overrideNames.length
|
||||
? footer.overrideNames.join(", ")
|
||||
: t("viewsnap.footer.none")}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab-Symbol „Ausschnitte" — Kamera-Blende / Sucherrahmen. */
|
||||
export function ViewSnapshotsTabIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
|
||||
<circle cx="8" cy="8" r="2.4" />
|
||||
<line x1="4.5" y1="3.5" x2="5.5" y2="2" />
|
||||
<line x1="11.5" y1="3.5" x2="10.5" y2="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import { AttributesPanel } from "./AttributesPanel";
|
||||
import { ObjectInfoPanel } from "./ObjectInfoPanel";
|
||||
import { SitePanel } from "./SitePanel";
|
||||
import { RoomBalancePanel } from "./RoomBalancePanel";
|
||||
import { ElementTreePanel, ElementsTabIcon } from "./ElementTreePanel";
|
||||
import { ViewSnapshotsPanel, ViewSnapshotsTabIcon } from "./ViewSnapshotsPanel";
|
||||
import { LayoutsPanel, LayoutsTabIcon } from "./LayoutsPanel";
|
||||
import {
|
||||
ToolsTabIcon,
|
||||
AttributesTabIcon,
|
||||
@@ -41,6 +44,9 @@ export const BUILTIN_PANEL_IDS = {
|
||||
objectInfo: "object-info",
|
||||
site: "site",
|
||||
roomBalance: "room-balance",
|
||||
elements: "elements",
|
||||
viewSnapshots: "view-snapshots",
|
||||
layouts: "layouts",
|
||||
} as const;
|
||||
|
||||
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
|
||||
@@ -105,3 +111,32 @@ registerPanel({
|
||||
hasDisplayMode: false,
|
||||
render: () => <RoomBalancePanel />,
|
||||
});
|
||||
|
||||
// Element-Baum (ROADMAP §11) — Geschoss → Bauteilklasse → Element, Suche + Klick-Selektion.
|
||||
registerPanel({
|
||||
id: BUILTIN_PANEL_IDS.elements,
|
||||
title: "elements.title",
|
||||
icon: <ElementsTabIcon />,
|
||||
hasDisplayMode: false,
|
||||
render: () => <ElementTreePanel />,
|
||||
});
|
||||
|
||||
// Ausschnitte / View-Snapshots (DOSSIER A2, ROADMAP §2c/§11) — benannte,
|
||||
// wiederherstellbare Ansichten (Darstellungszustand als Preset).
|
||||
registerPanel({
|
||||
id: BUILTIN_PANEL_IDS.viewSnapshots,
|
||||
title: "viewsnap.title",
|
||||
icon: <ViewSnapshotsTabIcon />,
|
||||
hasDisplayMode: false,
|
||||
render: () => <ViewSnapshotsPanel />,
|
||||
});
|
||||
|
||||
// Layouts / Masterlayouts (DOSSIER A3, ROADMAP §11) — Druck-/Plan-Blätter mit
|
||||
// platzierten Viewports (an Ausschnitte gebunden), Editor als schwebendes Fenster.
|
||||
registerPanel({
|
||||
id: BUILTIN_PANEL_IDS.layouts,
|
||||
title: "layouts.panelTitle",
|
||||
icon: <LayoutsTabIcon />,
|
||||
hasDisplayMode: false,
|
||||
render: () => <LayoutsPanel />,
|
||||
});
|
||||
|
||||
@@ -22,9 +22,13 @@ import type {
|
||||
DrawingLevel,
|
||||
HatchStyle,
|
||||
LayerCategory,
|
||||
LayoutOrientation,
|
||||
LayoutPaperFormat,
|
||||
LineStyle,
|
||||
MasterLayout,
|
||||
Project,
|
||||
SiaCategory,
|
||||
SliceTermination,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
WallReferenceLine,
|
||||
@@ -32,6 +36,7 @@ import type {
|
||||
} from "../model/types";
|
||||
import type { SnapSettings, ToolId } from "../tools/types";
|
||||
import type { Selection } from "../state/selectionInfo";
|
||||
import type { ScheduleKind } from "../export/exportSchedule";
|
||||
|
||||
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
|
||||
|
||||
@@ -196,10 +201,38 @@ export interface PanelHostValue {
|
||||
h: number,
|
||||
anchor: { fx: number; fy: number },
|
||||
) => void;
|
||||
/**
|
||||
* Verschiebt die Selektion um (dx,dy) Meter — generischer Move über das
|
||||
* Transform-System (analog dem Move-Werkzeug/-Befehl), deckt Wand/Drawing2D/
|
||||
* Extrusionskörper ab. Bei Decke/Öffnung/Treppe/Raum (Transform-Kern kennt
|
||||
* diese Elementarten nicht) ein No-op.
|
||||
*/
|
||||
onMoveSelectionBy: (dx: number, dy: number) => void;
|
||||
/**
|
||||
* Dreht die Selektion um `deg` Grad um den Weltpunkt (cx,cy) — generischer
|
||||
* Rotate über das Transform-System; deckt dieselben Selektionsarten ab wie
|
||||
* {@link onMoveSelectionBy}.
|
||||
*/
|
||||
onRotateSelectionAround: (cx: number, cy: number, deg: number) => void;
|
||||
/**
|
||||
* Setzt die Länge einer Linien-Drawing2D (Endpunkt entlang der Richtung neu
|
||||
* skaliert, Startpunkt bleibt fix). No-op, wenn die Selektion keine Linie ist.
|
||||
*/
|
||||
onSetDrawingLineLength: (length: number) => void;
|
||||
/**
|
||||
* Setzt den Radius einer Kreis-Drawing2D (Mittelpunkt bleibt fix). No-op,
|
||||
* wenn die Selektion kein Kreis ist.
|
||||
*/
|
||||
onSetDrawingCircleRadius: (radius: number) => void;
|
||||
|
||||
// ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─
|
||||
/** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */
|
||||
onSetWallReferenceLine: (ref: WallReferenceLine) => void;
|
||||
/**
|
||||
* Setzt die Terminierungs-Regel am Deckenanschluss (Zuschnitt, NICHT Priorität):
|
||||
* "both" = heutiges Verhalten, "below"/"above" = die Wand endet an der Decke.
|
||||
*/
|
||||
onSetWallSliceTermination: (termination: SliceTermination) => void;
|
||||
/** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */
|
||||
onSetWallType: (wallTypeId: string) => void;
|
||||
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
|
||||
@@ -216,6 +249,12 @@ export interface PanelHostValue {
|
||||
onSetCeilingThickness: (thickness: number) => void;
|
||||
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
|
||||
onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
|
||||
/**
|
||||
* Setzt/entfernt die UK-Bindung der Decke (`null` = OK − Dicke). Optional,
|
||||
* da bestehende Hosts (App.tsx) diesen Setter ggf. noch nicht verdrahtet
|
||||
* haben — additive Erweiterung analog `onSetWallBottom`.
|
||||
*/
|
||||
onSetCeilingBottom?: (anchor: VerticalAnchor | null) => void;
|
||||
|
||||
// ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─
|
||||
/** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */
|
||||
@@ -242,6 +281,8 @@ export interface PanelHostValue {
|
||||
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
|
||||
/** Setzt die Sturzlinien-Darstellung der Tür. */
|
||||
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
|
||||
/** Weist der Öffnung einen Tür-/Fenstertyp (Bibliothek) zu; "" löst die Zuweisung. */
|
||||
onSetOpeningType: (typeId: string) => void;
|
||||
|
||||
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||
@@ -256,6 +297,8 @@ export interface PanelHostValue {
|
||||
onSetStairUp: (up: boolean) => void;
|
||||
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
|
||||
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
||||
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
|
||||
onSetStairType: (typeId: string) => void;
|
||||
|
||||
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
|
||||
/** Setzt den Raum-Namen. */
|
||||
@@ -265,6 +308,26 @@ export interface PanelHostValue {
|
||||
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
|
||||
onEditRoomStamp: (roomId: string) => void;
|
||||
|
||||
// ── Extrusions-Attribute (truck-Integration; nur der selektierte Körper) ──
|
||||
/** Setzt die Extrusionshöhe (Meter, > 0) — löst eine Re-Extrusion aus. */
|
||||
onSetExtrudedSolidHeight: (height: number) => void;
|
||||
/** Setzt die Verjüngung (0..1) — löst eine Re-Extrusion aus. */
|
||||
onSetExtrudedSolidTaper: (taper: number) => void;
|
||||
|
||||
// ── Stützen-Attribute (Tragwerk; nur die selektierte Stütze) ────────────
|
||||
/** Wechselt das Profil (Rechteck/Kreis); leitet ein sinnvolles Mass ab. */
|
||||
onSetColumnProfileKind: (kind: "rect" | "round") => void;
|
||||
/** Setzt die Breite (X) eines Rechteckprofils (Meter). */
|
||||
onSetColumnWidth: (width: number) => void;
|
||||
/** Setzt die Tiefe (Y) eines Rechteckprofils (Meter). */
|
||||
onSetColumnDepth: (depth: number) => void;
|
||||
/** Setzt den Radius eines Kreisprofils (Meter). */
|
||||
onSetColumnRadius: (radius: number) => void;
|
||||
/** Setzt die Höhe der Stütze (Meter, > 0). */
|
||||
onSetColumnHeight: (height: number) => void;
|
||||
/** Setzt die Drehung der Stütze (Grad; intern in Radiant gespeichert). */
|
||||
onSetColumnRotation: (deg: number) => void;
|
||||
|
||||
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
|
||||
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
|
||||
contextObjects: ContextObject[];
|
||||
@@ -279,6 +342,166 @@ export interface PanelHostValue {
|
||||
onGenerateTerrain: (contourSetId: string) => string | null;
|
||||
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
||||
onImportDxf: () => void;
|
||||
|
||||
// ── Element-Baum (Elemente-Panel) ───────────────────────────────────────
|
||||
/**
|
||||
* Selektiert EIN Bauteil-Vorkommen aus der Elementliste (`scheduleRows`) im
|
||||
* Baum: leert alle Auswahl-Kanäle und setzt genau dieses Element im
|
||||
* passenden Kanal (Wand/Decke/Fenster+Öffnung/Treppe/Extrusion). Liegt das
|
||||
* Element auf einem anderen Geschoss, wechselt zuerst die aktive
|
||||
* Zeichnungsebene. Für Bauteilklassen ohne Auswahl-Kanal (aktuell nur die
|
||||
* Legacy-„Tür"-Datensätze aus `project.doors`, die kein Werkzeug mehr
|
||||
* anlegt) ein No-op. `opts.zoom` (Shift-Klick/Doppelklick in der Baumzeile)
|
||||
* passt die Plan-Ansicht zusätzlich ein.
|
||||
*/
|
||||
onSelectScheduleRow: (
|
||||
kind: ScheduleKind,
|
||||
id: string,
|
||||
floorId: string | undefined,
|
||||
opts?: { zoom?: boolean },
|
||||
) => void;
|
||||
|
||||
// ── Ausschnitte / View-Snapshots (Ausschnitte-Panel, DOSSIER A2) ─────────
|
||||
// Die Liste selbst liest das Panel über `project.viewSnapshots`. Die
|
||||
// Erfassungs-/Anwendungslogik (Setter/rAF) lebt in App.tsx; hier nur die
|
||||
// immutablen Handler.
|
||||
/**
|
||||
* Erfasst den aktuellen Darstellungszustand als neuen, benannten Ausschnitt.
|
||||
* `folderId` legt ihn direkt im angegebenen Ordner ab (sonst Wurzelebene) —
|
||||
* additiv, Alt-Aufrufe ohne Ordner-Argument bleiben gültig.
|
||||
*/
|
||||
onCaptureViewSnapshot: (name: string, folderId?: string) => void;
|
||||
/** Stellt alle Felder eines Ausschnitts wieder her (Geschoss/Sichtbarkeit/Overrides/Ansicht). */
|
||||
onApplyViewSnapshot: (id: string) => void;
|
||||
/** Benennt einen Ausschnitt um. */
|
||||
onRenameViewSnapshot: (id: string, name: string) => void;
|
||||
/** Löscht einen Ausschnitt. */
|
||||
onDeleteViewSnapshot: (id: string) => void;
|
||||
|
||||
// ── Ausschnitte-Baum (Ordner, DOSSIER A2) ───────────────────────────────
|
||||
// Die Listen liest das Panel über `project.viewSnapshots`/
|
||||
// `project.viewSnapshotFolders`; pure CRUD in state/viewSnapshotFolders.ts.
|
||||
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
|
||||
onAddViewSnapshotFolder: (parentId?: string) => string;
|
||||
/** Benennt einen Ausschnitte-Ordner um. */
|
||||
onRenameViewSnapshotFolder: (id: string, name: string) => void;
|
||||
/**
|
||||
* Löscht einen Ausschnitte-Ordner (enthaltene Ausschnitte + Unterordner wandern
|
||||
* auf die Elternebene, kein Datenverlust).
|
||||
*/
|
||||
onDeleteViewSnapshotFolder: (id: string) => void;
|
||||
/**
|
||||
* Verschiebt einen Ausschnitt per Drag&Drop in einen Ordner (`folderId`) bzw.
|
||||
* auf die Wurzelebene (`null`).
|
||||
*/
|
||||
onMoveViewSnapshotToFolder: (snapshotId: string, folderId: string | null) => void;
|
||||
/**
|
||||
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
|
||||
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
|
||||
* werden verworfen (No-op).
|
||||
*/
|
||||
onMoveViewSnapshotFolder: (folderId: string, parentId: string | null) => void;
|
||||
|
||||
/**
|
||||
* Id des aktuell „angewählten" Ausschnitts (Footer-Bar sichtbar) oder `null`.
|
||||
* Bleibt gesetzt, solange der Live-Zustand exakt dem Ausschnitt entspricht;
|
||||
* App löscht ihn, sobald der Nutzer eine erfasste Grösse ändert.
|
||||
*/
|
||||
selectedViewSnapshotId: string | null;
|
||||
/** Namen aller gespeicherten Ebenen-Kombinationen (localStorage). */
|
||||
listLayerCombos: () => string[];
|
||||
/** Lädt die `codes`-Map einer Ebenen-Kombination (`null`, wenn unbekannt). */
|
||||
loadLayerCombo: (name: string) => Record<string, boolean> | null;
|
||||
/** Namen aller gespeicherten Zeichnungs-Kombinationen (localStorage). */
|
||||
listDrawingCombos: () => string[];
|
||||
/** Lädt die `ids`-Map einer Zeichnungs-Kombination (`null`, wenn unbekannt). */
|
||||
loadDrawingCombo: (name: string) => Record<string, boolean> | null;
|
||||
|
||||
// ── Layouts / Masterlayouts (Layouts-Panel, DOSSIER A3) ─────────────────
|
||||
// Die Listen liest das Panel über `project.layouts`/`project.masterLayouts`.
|
||||
// Die Mutationslogik lebt in App.tsx (setProject); pure CRUD in
|
||||
// panels/layoutModel.ts.
|
||||
/** Legt ein neues, leeres Layout an und öffnet den Editor. */
|
||||
onAddLayout: (
|
||||
name: string,
|
||||
paper: LayoutPaperFormat,
|
||||
orientation: LayoutOrientation,
|
||||
) => void;
|
||||
/** Benennt ein Layout um. */
|
||||
onRenameLayout: (id: string, name: string) => void;
|
||||
/** Löscht ein Layout (schliesst ggf. den offenen Editor). */
|
||||
onDeleteLayout: (id: string) => void;
|
||||
/** Öffnet ein Layout im schwebenden Layout-Editor. */
|
||||
onOpenLayout: (id: string) => void;
|
||||
/** Legt ein neues Masterlayout an. */
|
||||
onAddMasterLayout: (name: string) => void;
|
||||
/** Benennt ein Masterlayout um. */
|
||||
onRenameMasterLayout: (id: string, name: string) => void;
|
||||
/** Löscht ein Masterlayout (löst Bindungen der Layouts). */
|
||||
onDeleteMasterLayout: (id: string) => void;
|
||||
/** Ändert Felder eines Masterlayouts (Titelblock/Papier/Rahmen). */
|
||||
onPatchMasterLayout: (id: string, patch: Partial<MasterLayout>) => void;
|
||||
|
||||
// ── Layouts-Baum (Ordner) + Erstell-Dialoge (DOSSIER A3) ────────────────
|
||||
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
|
||||
onAddLayoutFolder: (parentId?: string) => string;
|
||||
/**
|
||||
* Legt einen neuen MASTER-Ordner (`kind:"master"`) an (optional in `parentId`);
|
||||
* liefert die neue Id. Analog {@link onAddLayoutFolder}, aber im Master-Baum.
|
||||
*/
|
||||
onAddMasterFolder: (parentId?: string) => string;
|
||||
/** Benennt einen Ordner um (Layout- wie Master-Ordner, per Id). */
|
||||
onRenameLayoutFolder: (id: string, name: string) => void;
|
||||
/** Löscht einen Ordner (Inhalt wandert auf die Elternebene, kein Datenverlust). */
|
||||
onDeleteLayoutFolder: (id: string) => void;
|
||||
/**
|
||||
* Löscht einen Master-Ordner (enthaltene Masterlayouts + Unterordner wandern
|
||||
* auf die Elternebene, kein Datenverlust). Analog {@link onDeleteLayoutFolder}.
|
||||
*/
|
||||
onDeleteMasterFolder: (id: string) => void;
|
||||
/**
|
||||
* Legt ein Layout mit expliziten Startwerten (Master-Vorlage ODER freie
|
||||
* Grösse) an und liefert dessen Id (das Panel versetzt es in Inline-Rename).
|
||||
* Öffnet das Blatt NICHT automatisch (anders als {@link onAddLayout}).
|
||||
*/
|
||||
onCreateLayout: (opts: CreateLayoutOptions) => string;
|
||||
/** Legt ein Masterlayout mit Grösse an und liefert dessen Id. */
|
||||
onCreateMasterLayout: (opts: CreateMasterLayoutOptions) => string;
|
||||
/** Exportiert alle Layouts eines Ordners als EIN Mehrseiten-PDF. */
|
||||
onExportFolderPdf: (folderId: string) => void;
|
||||
/**
|
||||
* Verschiebt ein Layout per Drag&Drop in einen Ordner (`folderId`) bzw. auf
|
||||
* die Wurzelebene (`null`).
|
||||
*/
|
||||
onMoveLayoutToFolder: (layoutId: string, folderId: string | null) => void;
|
||||
/**
|
||||
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
|
||||
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
|
||||
* werden verworfen (No-op).
|
||||
*/
|
||||
onMoveFolderToFolder: (folderId: string, parentId: string | null) => void;
|
||||
}
|
||||
|
||||
/** Startwerte eines neuen Layouts (aus dem „Neues Layout"-Dialog). */
|
||||
export interface CreateLayoutOptions {
|
||||
/** Zielordner; ohne → Wurzelebene. */
|
||||
folderId?: string;
|
||||
/** Gebundenes Masterlayout (erbt dessen Grösse); ohne → freie Grösse. */
|
||||
masterId?: string;
|
||||
paper: LayoutPaperFormat;
|
||||
orientation: LayoutOrientation;
|
||||
customWidthMm?: number;
|
||||
customHeightMm?: number;
|
||||
}
|
||||
|
||||
/** Startwerte eines neuen Masterlayouts (aus dem „Neues Masterlayout"-Dialog). */
|
||||
export interface CreateMasterLayoutOptions {
|
||||
/** Ziel-Master-Ordner; ohne → Wurzelebene des Master-Baums. */
|
||||
folderId?: string;
|
||||
paper: LayoutPaperFormat;
|
||||
orientation: LayoutOrientation;
|
||||
customWidthMm?: number;
|
||||
customHeightMm?: number;
|
||||
}
|
||||
|
||||
// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so
|
||||
@@ -286,6 +509,7 @@ export interface PanelHostValue {
|
||||
export type { DrawingLevel, LayerCategory };
|
||||
export type { SnapSettings, ToolId };
|
||||
export type { Selection } from "../state/selectionInfo";
|
||||
export type { ScheduleKind, ScheduleRow } from "../export/exportSchedule";
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -34,7 +34,13 @@ import type {
|
||||
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
|
||||
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
|
||||
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
|
||||
export const LAYOUT_VERSION = 8;
|
||||
// v9: Element-Baum ("elements", ROADMAP Paragraf 11) als weiterer Tab in der
|
||||
// rechten unteren Gruppe (neben Zeichnungsebenen/Ebenen/Umgebung).
|
||||
// v10: Ausschnitte-Panel ("view-snapshots", DOSSIER A2 / ROADMAP Paragraf 2c)
|
||||
// als weiterer Tab in der rechten unteren Gruppe.
|
||||
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
|
||||
// Tab in der rechten unteren Gruppe.
|
||||
export const LAYOUT_VERSION = 11;
|
||||
|
||||
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
||||
const CURRENT_KEY = "cad.layout";
|
||||
@@ -68,7 +74,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
||||
{
|
||||
tabs: ["drawing-levels", "layers", "site"],
|
||||
tabs: ["drawing-levels", "layers", "site", "elements", "view-snapshots", "layouts"],
|
||||
activeTab: "drawing-levels",
|
||||
weight: 1.5,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
// Tests der reinen Layout-Logik (DOSSIER A3): Blatt-Geometrie, Fabriken,
|
||||
// immutables Viewport-CRUD, Master-Vererbung der Titelblock-Werte,
|
||||
// Viewport→Ausschnitt-Auflösung + Massstab, Sichtbarkeits-Ableitung.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
Layout,
|
||||
LayoutFolder,
|
||||
MasterLayout,
|
||||
Project,
|
||||
ViewSnapshot,
|
||||
} from "../model/types";
|
||||
import {
|
||||
addAnnotation,
|
||||
addViewport,
|
||||
buildLayoutTree,
|
||||
buildMasterTree,
|
||||
collectFolderLayouts,
|
||||
createAnnotation,
|
||||
createLayout,
|
||||
createLayoutFolder,
|
||||
createMasterLayout,
|
||||
createViewport,
|
||||
deleteFolder,
|
||||
deleteMasterFolder,
|
||||
effectiveSheetSizeMm,
|
||||
findMaster,
|
||||
findSnapshot,
|
||||
folderPdfPages,
|
||||
isDescendant,
|
||||
moveFolderToFolder,
|
||||
moveLayoutToFolder,
|
||||
PAPER_FORMATS,
|
||||
patchAnnotation,
|
||||
patchViewport,
|
||||
removeAnnotation,
|
||||
removeViewport,
|
||||
resolveTitleBlock,
|
||||
resolveViewportScale,
|
||||
sheetSizeMm,
|
||||
visibleCodesFromSnapshot,
|
||||
} from "./layoutModel";
|
||||
|
||||
/** Minimaler Ausschnitt (nur die im Layout-System genutzten Felder tragen Werte). */
|
||||
function snap(id: string, patch?: Partial<ViewSnapshot>): ViewSnapshot {
|
||||
return {
|
||||
id,
|
||||
name: `Snap ${id}`,
|
||||
viewType: "grundriss",
|
||||
view3d: "top",
|
||||
scaleDenominator: 100,
|
||||
detail: "mittel",
|
||||
activeLevelId: "floor-0",
|
||||
layerVisibility: { A: true, B: false, C: true },
|
||||
drawingVisibility: {},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimales Projekt-Gerüst mit optionalen Layout-Feldern. */
|
||||
function project(patch?: Partial<Project>): Project {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Villa Test",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [],
|
||||
wallTypes: [],
|
||||
drawingLevels: [],
|
||||
layers: [],
|
||||
walls: [],
|
||||
doors: [],
|
||||
drawings2d: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sheetSizeMm", () => {
|
||||
it("liefert A4/A3 in Hoch- und Querformat", () => {
|
||||
expect(sheetSizeMm("a4", "portrait")).toEqual({ w: 210, h: 297 });
|
||||
expect(sheetSizeMm("a4", "landscape")).toEqual({ w: 297, h: 210 });
|
||||
expect(sheetSizeMm("a3", "portrait")).toEqual({ w: 297, h: 420 });
|
||||
expect(sheetSizeMm("a3", "landscape")).toEqual({ w: 420, h: 297 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fabriken", () => {
|
||||
it("createLayout: leer, ohne Master", () => {
|
||||
const l = createLayout("l1", "Blatt 1", "a3", "landscape");
|
||||
expect(l).toEqual({
|
||||
id: "l1",
|
||||
name: "Blatt 1",
|
||||
paper: "a3",
|
||||
orientation: "landscape",
|
||||
viewports: [],
|
||||
});
|
||||
expect(l.masterId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("createMasterLayout: leerer Titelblock + Rahmen an", () => {
|
||||
const m = createMasterLayout("m1", "Master", "a4", "portrait");
|
||||
expect(m.titleBlock).toEqual({});
|
||||
expect(m.border).toBe(true);
|
||||
});
|
||||
|
||||
it("createViewport: Defaults + optionaler Massstab", () => {
|
||||
const v = createViewport("v1", "s1");
|
||||
expect(v).toMatchObject({ id: "v1", snapshotId: "s1", xMm: 20, yMm: 20, widthMm: 120, heightMm: 90 });
|
||||
expect(v.scaleDenominator).toBeUndefined();
|
||||
const v2 = createViewport("v2", "s1", { xMm: 5, scaleDenominator: 50 });
|
||||
expect(v2.xMm).toBe(5);
|
||||
expect(v2.scaleDenominator).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Viewport-CRUD (immutabel)", () => {
|
||||
const base = addViewport(createLayout("l1", "L", "a4", "portrait"), createViewport("v1", "s1"));
|
||||
|
||||
it("addViewport verändert das Original nicht", () => {
|
||||
const before = createLayout("l1", "L", "a4", "portrait");
|
||||
const after = addViewport(before, createViewport("v1", "s1"));
|
||||
expect(before.viewports).toHaveLength(0);
|
||||
expect(after.viewports).toHaveLength(1);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it("removeViewport entfernt per Id", () => {
|
||||
const two = addViewport(base, createViewport("v2", "s2"));
|
||||
const one = removeViewport(two, "v1");
|
||||
expect(one.viewports.map((v) => v.id)).toEqual(["v2"]);
|
||||
});
|
||||
|
||||
it("patchViewport ändert nur das Ziel, immutabel", () => {
|
||||
const patched = patchViewport(base, "v1", { xMm: 42, scaleDenominator: 50 });
|
||||
expect(patched.viewports[0]).toMatchObject({ xMm: 42, scaleDenominator: 50 });
|
||||
expect(base.viewports[0].xMm).toBe(20);
|
||||
// Unbekannte Id → No-op (gleiche Werte, neue Liste).
|
||||
const noop = patchViewport(base, "nope", { xMm: 1 });
|
||||
expect(noop.viewports[0].xMm).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Annotationen (Linie/Rechteck/Text) ───────────────────────────────────────
|
||||
|
||||
describe("createAnnotation — Defaults je Art", () => {
|
||||
it("line: Default-Geometrie ohne Farbe/Strichstärke", () => {
|
||||
const a = createAnnotation("a1", "line");
|
||||
expect(a).toEqual({ id: "a1", kind: "line", x1Mm: 20, y1Mm: 20, x2Mm: 80, y2Mm: 20 });
|
||||
});
|
||||
it("line: init überschreibt einzelne Felder + optionale Farbe/Strichstärke", () => {
|
||||
const a = createAnnotation("a1", "line", { x1Mm: 5, color: "#f00", weightMm: 0.5 });
|
||||
expect(a).toMatchObject({ x1Mm: 5, y1Mm: 20, color: "#f00", weightMm: 0.5 });
|
||||
});
|
||||
it("rect: Default-Grösse", () => {
|
||||
const a = createAnnotation("a2", "rect");
|
||||
expect(a).toEqual({ id: "a2", kind: "rect", xMm: 20, yMm: 20, widthMm: 40, heightMm: 30 });
|
||||
});
|
||||
it("text: Default-Zeilenhöhe 3.5mm, leerer Text", () => {
|
||||
const a = createAnnotation("a3", "text");
|
||||
expect(a).toEqual({ id: "a3", kind: "text", xMm: 20, yMm: 20, text: "", heightMm: 3.5 });
|
||||
});
|
||||
it("text: init setzt Inhalt + Höhe", () => {
|
||||
const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 });
|
||||
expect(a).toMatchObject({ text: "EG", heightMm: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Annotations-CRUD (immutabel)", () => {
|
||||
const base = createLayout("l1", "L", "a4", "portrait");
|
||||
|
||||
it("addAnnotation verändert das Original nicht", () => {
|
||||
const after = addAnnotation(base, createAnnotation("a1", "line"));
|
||||
expect(base.annotations).toBeUndefined();
|
||||
expect(after.annotations).toHaveLength(1);
|
||||
expect(after).not.toBe(base);
|
||||
});
|
||||
|
||||
it("addAnnotation baut auf einer fehlenden `annotations`-Liste auf (Alt-Layout)", () => {
|
||||
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
|
||||
const after = addAnnotation(legacy, createAnnotation("a1", "rect"));
|
||||
expect(after.annotations).toHaveLength(1);
|
||||
expect(legacy.annotations).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removeAnnotation entfernt per Id", () => {
|
||||
let l = addAnnotation(base, createAnnotation("a1", "line"));
|
||||
l = addAnnotation(l, createAnnotation("a2", "rect"));
|
||||
const removed = removeAnnotation(l, "a1");
|
||||
expect(removed.annotations!.map((a) => a.id)).toEqual(["a2"]);
|
||||
});
|
||||
|
||||
it("removeAnnotation auf Alt-Layout ohne `annotations` bleibt heil (No-op)", () => {
|
||||
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
|
||||
const r = removeAnnotation(legacy, "nope");
|
||||
expect(r.annotations).toEqual([]);
|
||||
});
|
||||
|
||||
it("patchAnnotation ändert nur das Ziel, immutabel", () => {
|
||||
const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" }));
|
||||
const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" });
|
||||
expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" });
|
||||
expect(l.annotations![0].color).toBe("#111");
|
||||
});
|
||||
|
||||
it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => {
|
||||
const l = addAnnotation(base, createAnnotation("a1", "line"));
|
||||
const noop = patchAnnotation(l, "nope", { x1Mm: 1 });
|
||||
expect((noop.annotations![0] as { x1Mm: number }).x1Mm).toBe(20);
|
||||
});
|
||||
|
||||
it("patchAnnotation auf einer Text-Annotation ändert den Inhalt", () => {
|
||||
const l = addAnnotation(base, createAnnotation("t1", "text", { text: "EG" }));
|
||||
const patched = patchAnnotation(l, "t1", { text: "1. OG" });
|
||||
expect(patched.annotations![0]).toMatchObject({ text: "1. OG" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveViewportScale", () => {
|
||||
const s = snap("s1", { scaleDenominator: 200 });
|
||||
it("Viewport-Override gewinnt", () => {
|
||||
expect(resolveViewportScale(createViewport("v", "s1", { scaleDenominator: 50 }), s)).toBe(50);
|
||||
});
|
||||
it("sonst Ausschnitt-Massstab", () => {
|
||||
expect(resolveViewportScale(createViewport("v", "s1"), s)).toBe(200);
|
||||
});
|
||||
it("Fallback ohne beides", () => {
|
||||
expect(resolveViewportScale(createViewport("v", "s1"), undefined, 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSnapshot / findMaster", () => {
|
||||
const p = project({
|
||||
viewSnapshots: [snap("s1"), snap("s2")],
|
||||
masterLayouts: [createMasterLayout("m1", "M1", "a4", "portrait")],
|
||||
});
|
||||
it("findSnapshot", () => {
|
||||
expect(findSnapshot(p, "s2")?.id).toBe("s2");
|
||||
expect(findSnapshot(p, "x")).toBeUndefined();
|
||||
});
|
||||
it("findMaster nur bei gesetzter masterId", () => {
|
||||
const bound: Layout = { ...createLayout("l", "L", "a4", "portrait"), masterId: "m1" };
|
||||
expect(findMaster(p, bound)?.id).toBe("m1");
|
||||
expect(findMaster(p, createLayout("l2", "L2", "a4", "portrait"))).toBeUndefined();
|
||||
// Dangling masterId → undefined.
|
||||
expect(findMaster(p, { ...bound, masterId: "gone" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTitleBlock — Master-Vererbung + Defaults", () => {
|
||||
const p = project({ name: "Villa Test", viewSnapshots: [snap("s1", { scaleDenominator: 50 })] });
|
||||
|
||||
it("ohne Master: Defaults aus Projekt/Layout + Massstab aus erstem Viewport", () => {
|
||||
let l = createLayout("l1", "EG Grundriss", "a4", "portrait");
|
||||
l = addViewport(l, createViewport("v1", "s1"));
|
||||
const tb = resolveTitleBlock(p, l, undefined, "2026-07-07");
|
||||
expect(tb.projectName).toBe("Villa Test");
|
||||
expect(tb.sheetName).toBe("EG Grundriss");
|
||||
expect(tb.scale).toBe("1:50");
|
||||
expect(tb.date).toBe("2026-07-07");
|
||||
expect(tb.author).toBe("");
|
||||
});
|
||||
|
||||
it("Master-Werte übersteuern die Defaults", () => {
|
||||
const master: MasterLayout = {
|
||||
id: "m1",
|
||||
name: "M",
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
titleBlock: { projectName: "Kundenprojekt", author: "GV", scale: "1:100" },
|
||||
};
|
||||
const l = createLayout("l1", "EG", "a4", "portrait");
|
||||
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
|
||||
expect(tb.projectName).toBe("Kundenprojekt");
|
||||
expect(tb.author).toBe("GV");
|
||||
expect(tb.scale).toBe("1:100"); // Master-Wert schlägt den Viewport-Default
|
||||
expect(tb.sheetName).toBe("EG"); // leer im Master → Layout-Name
|
||||
});
|
||||
|
||||
it("leere/whitespace Master-Felder fallen auf Defaults zurück", () => {
|
||||
const master: MasterLayout = {
|
||||
id: "m1",
|
||||
name: "M",
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
titleBlock: { projectName: " ", sheetName: "" },
|
||||
};
|
||||
const l = createLayout("l1", "OG", "a4", "portrait");
|
||||
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
|
||||
expect(tb.projectName).toBe("Villa Test");
|
||||
expect(tb.sheetName).toBe("OG");
|
||||
expect(tb.scale).toBe("—"); // kein Viewport → kein Massstab
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleCodesFromSnapshot", () => {
|
||||
it("nur sichtbare Codes landen im Set", () => {
|
||||
const codes = visibleCodesFromSnapshot(snap("s1"));
|
||||
expect([...codes].sort()).toEqual(["A", "C"]);
|
||||
expect(codes.has("B")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Freie Grösse (custom übersteuert Papier) ──────────────────────────────────
|
||||
|
||||
describe("sheetSizeMm / effectiveSheetSizeMm — freie Grösse", () => {
|
||||
it("custom übersteuert paper/orientation, wenn beide positiv gesetzt", () => {
|
||||
expect(sheetSizeMm("a4", "portrait", 500, 300)).toEqual({ w: 500, h: 300 });
|
||||
});
|
||||
it("fällt auf paper zurück, wenn custom fehlt oder nicht positiv ist", () => {
|
||||
expect(sheetSizeMm("a4", "portrait", undefined, 300)).toEqual({ w: 210, h: 297 });
|
||||
expect(sheetSizeMm("a3", "landscape", 0, 300)).toEqual({ w: 420, h: 297 });
|
||||
});
|
||||
it("effectiveSheetSizeMm liest die Felder eines Layouts/Masters", () => {
|
||||
expect(
|
||||
effectiveSheetSizeMm({ paper: "a4", orientation: "portrait" }),
|
||||
).toEqual({ w: 210, h: 297 });
|
||||
expect(
|
||||
effectiveSheetSizeMm({
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
customWidthMm: 250,
|
||||
customHeightMm: 250,
|
||||
}),
|
||||
).toEqual({ w: 250, h: 250 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMasterLayout — freie Grösse", () => {
|
||||
it("übernimmt custom Breite/Höhe, wenn übergeben", () => {
|
||||
const m = createMasterLayout("m", "M", "a4", "portrait", {
|
||||
widthMm: 400,
|
||||
heightMm: 250,
|
||||
});
|
||||
expect(m.customWidthMm).toBe(400);
|
||||
expect(m.customHeightMm).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ordner-Baum (CRUD + Aufbau) ───────────────────────────────────────────────
|
||||
|
||||
function lay(id: string, folderId?: string): Layout {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
viewports: [],
|
||||
...(folderId ? { folderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createLayoutFolder", () => {
|
||||
it("Wurzelordner ohne parentId", () => {
|
||||
expect(createLayoutFolder("f1", "Ordner")).toEqual({ id: "f1", name: "Ordner" });
|
||||
});
|
||||
it("Unterordner mit parentId", () => {
|
||||
expect(createLayoutFolder("f2", "Sub", "f1")).toEqual({
|
||||
id: "f2",
|
||||
name: "Sub",
|
||||
parentId: "f1",
|
||||
});
|
||||
});
|
||||
it("Master-Ordner trägt kind:\"master\"", () => {
|
||||
expect(createLayoutFolder("m1", "Master-Ordner", undefined, "master")).toEqual({
|
||||
id: "m1",
|
||||
name: "Master-Ordner",
|
||||
kind: "master",
|
||||
});
|
||||
});
|
||||
it("kind \"layout\"/undefined wird NICHT gesetzt (rückwärtskompatibel)", () => {
|
||||
expect(createLayoutFolder("l1", "Layout-Ordner", undefined, "layout")).toEqual({
|
||||
id: "l1",
|
||||
name: "Layout-Ordner",
|
||||
});
|
||||
expect(createLayoutFolder("l2", "Layout-Ordner", "p", undefined)).toEqual({
|
||||
id: "l2",
|
||||
name: "Layout-Ordner",
|
||||
parentId: "p",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLayoutTree", () => {
|
||||
it("gruppiert Layouts in Ordner + Wurzel; Unterordner verschachtelt", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("root1"), lay("inA", "A"), lay("inB", "B")];
|
||||
const tree = buildLayoutTree(folders, layouts);
|
||||
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["root1"]);
|
||||
expect(tree.rootFolders).toHaveLength(1);
|
||||
const a = tree.rootFolders[0];
|
||||
expect(a.folder.id).toBe("A");
|
||||
expect(a.layouts.map((l) => l.id)).toEqual(["inA"]);
|
||||
expect(a.subfolders).toHaveLength(1);
|
||||
expect(a.subfolders[0].folder.id).toBe("B");
|
||||
expect(a.subfolders[0].layouts.map((l) => l.id)).toEqual(["inB"]);
|
||||
});
|
||||
|
||||
it("verwaiste Referenzen landen auf der Wurzel", () => {
|
||||
const tree = buildLayoutTree(
|
||||
[createLayoutFolder("orphan", "O", "missing")],
|
||||
[lay("ghost", "gone")],
|
||||
);
|
||||
// Ordner mit unbekanntem parent → Wurzel; Layout mit unbekanntem folder → Wurzel.
|
||||
expect(tree.rootFolders.map((f) => f.folder.id)).toEqual(["orphan"]);
|
||||
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["ghost"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Master-Baum + Layout/Master-Trennung (kind) ──────────────────────────────
|
||||
|
||||
function mas(id: string, folderId?: string): MasterLayout {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
titleBlock: {},
|
||||
...(folderId ? { folderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMasterTree", () => {
|
||||
it("gruppiert Master in Master-Ordner + Wurzel; Unterordner verschachtelt", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A", undefined, "master"),
|
||||
createLayoutFolder("B", "B", "A", "master"),
|
||||
];
|
||||
const masters = [mas("root1"), mas("inA", "A"), mas("inB", "B")];
|
||||
const tree = buildMasterTree(folders, masters);
|
||||
expect(tree.rootMasters.map((m) => m.id)).toEqual(["root1"]);
|
||||
expect(tree.rootFolders).toHaveLength(1);
|
||||
const a = tree.rootFolders[0];
|
||||
expect(a.folder.id).toBe("A");
|
||||
expect(a.masters.map((m) => m.id)).toEqual(["inA"]);
|
||||
expect(a.subfolders[0].masters.map((m) => m.id)).toEqual(["inB"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout-/Master-Ordner bleiben getrennt (kind-Filter)", () => {
|
||||
it("Master-Ordner erscheint NICHT im Layout-Baum und umgekehrt", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("L", "Layout-Ordner"), // kind fehlt → Layout
|
||||
createLayoutFolder("M", "Master-Ordner", undefined, "master"),
|
||||
];
|
||||
const layouts = [lay("l1", "L"), lay("lm", "M")]; // lm zeigt fälschlich auf Master-Ordner
|
||||
const masters = [mas("m1", "M"), mas("ml", "L")]; // ml zeigt fälschlich auf Layout-Ordner
|
||||
|
||||
// Wie das Panel filtert: Layout-Baum nur kind!=="master", Master-Baum nur kind==="master".
|
||||
const layoutTree = buildLayoutTree(
|
||||
folders.filter((f) => f.kind !== "master"),
|
||||
layouts,
|
||||
);
|
||||
const masterTree = buildMasterTree(
|
||||
folders.filter((f) => f.kind === "master"),
|
||||
masters,
|
||||
);
|
||||
|
||||
// Layout-Baum kennt nur den Layout-Ordner; das auf M zeigende Layout fällt auf die Wurzel.
|
||||
expect(layoutTree.rootFolders.map((f) => f.folder.id)).toEqual(["L"]);
|
||||
expect(layoutTree.rootLayouts.map((l) => l.id)).toEqual(["lm"]);
|
||||
// Master-Baum kennt nur den Master-Ordner; der auf L zeigende Master fällt auf die Wurzel.
|
||||
expect(masterTree.rootFolders.map((f) => f.folder.id)).toEqual(["M"]);
|
||||
expect(masterTree.rootMasters.map((m) => m.id)).toEqual(["ml"]);
|
||||
expect(masterTree.rootFolders[0].masters.map((m) => m.id)).toEqual(["m1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMasterFolder", () => {
|
||||
it("zieht Master + Unterordner auf die Elternebene und erhält kind:\"master\"", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A", undefined, "master"),
|
||||
createLayoutFolder("B", "B", "A", "master"),
|
||||
];
|
||||
const masters = [mas("a1", "A"), mas("b1", "B")];
|
||||
const r = deleteMasterFolder("A", folders, masters);
|
||||
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
|
||||
// B auf Wurzel hochgezogen, aber weiterhin Master-Ordner.
|
||||
expect(r.folders[0].parentId).toBeUndefined();
|
||||
expect(r.folders[0].kind).toBe("master");
|
||||
expect(r.masters.find((m) => m.id === "a1")!.folderId).toBeUndefined();
|
||||
expect(r.masters.find((m) => m.id === "b1")!.folderId).toBe("B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFolderLayouts", () => {
|
||||
it("liefert direkte Blätter zuerst, dann rekursiv Unterordner (PDF-Reihenfolge)", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("root1"), lay("a1", "A"), lay("a2", "A"), lay("b1", "B")];
|
||||
expect(collectFolderLayouts("A", folders, layouts).map((l) => l.id)).toEqual([
|
||||
"a1",
|
||||
"a2",
|
||||
"b1",
|
||||
]);
|
||||
expect(collectFolderLayouts("B", folders, layouts).map((l) => l.id)).toEqual([
|
||||
"b1",
|
||||
]);
|
||||
});
|
||||
it("unbekannter Ordner → leer", () => {
|
||||
expect(collectFolderLayouts("nope", [], [lay("x")])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFolder", () => {
|
||||
it("zieht Layouts + Unterordner auf die Elternebene (kein Datenverlust)", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("a1", "A"), lay("b1", "B")];
|
||||
// A (Wurzel) löschen → a1 auf Wurzel, B verliert parent → Wurzel.
|
||||
const r = deleteFolder("A", folders, layouts);
|
||||
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
|
||||
expect(r.folders[0].parentId).toBeUndefined();
|
||||
expect(r.layouts.find((l) => l.id === "a1")!.folderId).toBeUndefined();
|
||||
expect(r.layouts.find((l) => l.id === "b1")!.folderId).toBe("B");
|
||||
});
|
||||
it("Unterordner löschen zieht Kinder zum Grosselter", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("b1", "B")];
|
||||
const r = deleteFolder("B", folders, layouts);
|
||||
expect(r.folders.map((f) => f.id)).toEqual(["A"]);
|
||||
expect(r.layouts[0].folderId).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderPdfPages", () => {
|
||||
it("plant Seiten in Baum-Reihenfolge mit effektiver Blattgrösse", () => {
|
||||
const master: MasterLayout = createMasterLayout("m", "M", "a3", "landscape");
|
||||
const layouts: Layout[] = [
|
||||
{ ...lay("p1", "F"), masterId: "m" }, // erbt A3 quer vom Master
|
||||
{ ...lay("p2", "F"), customWidthMm: 500, customHeightMm: 200 }, // freie Grösse
|
||||
lay("p3", "F"), // A4 hoch (Default)
|
||||
];
|
||||
const p = project({
|
||||
layoutFolders: [createLayoutFolder("F", "F")],
|
||||
layouts,
|
||||
masterLayouts: [master],
|
||||
});
|
||||
const pages = folderPdfPages(p, "F");
|
||||
expect(pages.map((x) => x.layout.id)).toEqual(["p1", "p2", "p3"]);
|
||||
expect(pages[0]).toMatchObject({ widthMm: 420, heightMm: 297 }); // Master A3 quer
|
||||
expect(pages[1]).toMatchObject({ widthMm: 500, heightMm: 200 }); // custom
|
||||
expect(pages[2]).toMatchObject({ widthMm: 210, heightMm: 297 }); // A4 hoch
|
||||
});
|
||||
it("leerer/unbekannter Ordner → keine Seiten", () => {
|
||||
expect(folderPdfPages(project(), "nope")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PAGE_MM / Standard-Formate A0–A6 + B0–B6", () => {
|
||||
it("PAPER_FORMATS listet 14 Formate (A-Reihe, dann B-Reihe)", () => {
|
||||
expect(PAPER_FORMATS).toEqual([
|
||||
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
|
||||
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
|
||||
]);
|
||||
});
|
||||
it("liefert offizielle ISO-216-Masse (Hochformat, mm)", () => {
|
||||
expect(sheetSizeMm("a0", "portrait")).toEqual({ w: 841, h: 1189 });
|
||||
expect(sheetSizeMm("a1", "portrait")).toEqual({ w: 594, h: 841 });
|
||||
expect(sheetSizeMm("a2", "portrait")).toEqual({ w: 420, h: 594 });
|
||||
expect(sheetSizeMm("a5", "portrait")).toEqual({ w: 148, h: 210 });
|
||||
expect(sheetSizeMm("a6", "portrait")).toEqual({ w: 105, h: 148 });
|
||||
expect(sheetSizeMm("b0", "portrait")).toEqual({ w: 1000, h: 1414 });
|
||||
expect(sheetSizeMm("b3", "portrait")).toEqual({ w: 353, h: 500 });
|
||||
expect(sheetSizeMm("b6", "portrait")).toEqual({ w: 125, h: 176 });
|
||||
});
|
||||
it("Querformat dreht die Basis (Beispiel A0)", () => {
|
||||
expect(sheetSizeMm("a0", "landscape")).toEqual({ w: 1189, h: 841 });
|
||||
});
|
||||
it("jedes Format hat eine definierte Grösse", () => {
|
||||
for (const f of PAPER_FORMATS) {
|
||||
const s = sheetSizeMm(f, "portrait");
|
||||
expect(s.w).toBeGreaterThan(0);
|
||||
expect(s.h).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDescendant — Zyklus-Schutz", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
createLayoutFolder("C", "C", "B"),
|
||||
createLayoutFolder("X", "X"),
|
||||
];
|
||||
it("ein Ordner ist sein eigener Nachfahre (self)", () => {
|
||||
expect(isDescendant(folders, "A", "A")).toBe(true);
|
||||
});
|
||||
it("erkennt direkte + tiefe Nachfahren", () => {
|
||||
expect(isDescendant(folders, "B", "A")).toBe(true);
|
||||
expect(isDescendant(folders, "C", "A")).toBe(true);
|
||||
});
|
||||
it("nicht-verwandte Ordner sind keine Nachfahren", () => {
|
||||
expect(isDescendant(folders, "X", "A")).toBe(false);
|
||||
expect(isDescendant(folders, "A", "B")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveLayoutToFolder", () => {
|
||||
const layouts = [lay("l1", "A"), lay("l2")];
|
||||
it("setzt folderId auf den Zielordner", () => {
|
||||
const r = moveLayoutToFolder(layouts, "l2", "A");
|
||||
expect(r.find((l) => l.id === "l2")!.folderId).toBe("A");
|
||||
});
|
||||
it("null → zurück auf Wurzel (folderId entfernt)", () => {
|
||||
const r = moveLayoutToFolder(layouts, "l1", null);
|
||||
expect(r.find((l) => l.id === "l1")!.folderId).toBeUndefined();
|
||||
expect("folderId" in r.find((l) => l.id === "l1")!).toBe(false);
|
||||
});
|
||||
it("unbekannte Id → unverändert", () => {
|
||||
expect(moveLayoutToFolder(layouts, "nope", "A")).toEqual(layouts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveFolderToFolder", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
createLayoutFolder("X", "X"),
|
||||
];
|
||||
it("setzt parentId auf den Zielordner", () => {
|
||||
const r = moveFolderToFolder(folders, "X", "A");
|
||||
expect(r.find((f) => f.id === "X")!.parentId).toBe("A");
|
||||
});
|
||||
it("null → zurück auf Wurzel (parentId entfernt)", () => {
|
||||
const r = moveFolderToFolder(folders, "B", null);
|
||||
expect(r.find((f) => f.id === "B")!.parentId).toBeUndefined();
|
||||
});
|
||||
it("kind bleibt beim Wurzel-Verschieben erhalten (Master-Ordner)", () => {
|
||||
const withMaster: LayoutFolder[] = [
|
||||
createLayoutFolder("P", "P", undefined, "master"),
|
||||
createLayoutFolder("Q", "Q", "P", "master"),
|
||||
];
|
||||
const r = moveFolderToFolder(withMaster, "Q", null);
|
||||
const q = r.find((f) => f.id === "Q")!;
|
||||
expect(q.parentId).toBeUndefined();
|
||||
expect(q.kind).toBe("master");
|
||||
});
|
||||
it("Zyklus (in eigenen Nachfahren) → No-op, Original zurück", () => {
|
||||
expect(moveFolderToFolder(folders, "A", "B")).toEqual(folders);
|
||||
});
|
||||
it("in sich selbst → No-op", () => {
|
||||
expect(moveFolderToFolder(folders, "A", "A")).toEqual(folders);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,673 @@
|
||||
// Layout-Blätter mit Masterlayout (DOSSIER A3) — reine, testbare Logik.
|
||||
//
|
||||
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf Layouts/Master/
|
||||
// Viewports, die Master-Vererbung der Titelblock-Werte, die Auflösung eines
|
||||
// Viewports auf seinen gebundenen Ausschnitt und die Blatt-Geometrie (Papier-mm).
|
||||
// Das UI-Wiring (App.tsx setProject, LayoutEditor-Rendering) baut hierauf auf.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type {
|
||||
Layout,
|
||||
LayoutAnnotation,
|
||||
LayoutAnnotationPatch,
|
||||
LayoutFolder,
|
||||
LayoutOrientation,
|
||||
LayoutPaperFormat,
|
||||
LayoutTitleBlock,
|
||||
LayoutViewport,
|
||||
MasterLayout,
|
||||
Project,
|
||||
ViewSnapshot,
|
||||
} from "../model/types";
|
||||
|
||||
// ── Blatt-Geometrie ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Blattmasse je Format im Hochformat (mm) — offizielle ISO-216-Masse (auf ganze
|
||||
* mm gerundet). A-Reihe A0–A6 und B-Reihe B0–B6.
|
||||
*/
|
||||
const PAGE_MM: Record<LayoutPaperFormat, { w: number; h: number }> = {
|
||||
a0: { w: 841, h: 1189 },
|
||||
a1: { w: 594, h: 841 },
|
||||
a2: { w: 420, h: 594 },
|
||||
a3: { w: 297, h: 420 },
|
||||
a4: { w: 210, h: 297 },
|
||||
a5: { w: 148, h: 210 },
|
||||
a6: { w: 105, h: 148 },
|
||||
b0: { w: 1000, h: 1414 },
|
||||
b1: { w: 707, h: 1000 },
|
||||
b2: { w: 500, h: 707 },
|
||||
b3: { w: 353, h: 500 },
|
||||
b4: { w: 250, h: 353 },
|
||||
b5: { w: 176, h: 250 },
|
||||
b6: { w: 125, h: 176 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Auswahlreihenfolge der Standard-Formate für Dropdowns (A-Reihe, dann B-Reihe).
|
||||
* Gruppierbar über {@link PAPER_FORMAT_GROUPS} (A/B) für optgroups.
|
||||
*/
|
||||
export const PAPER_FORMATS: LayoutPaperFormat[] = [
|
||||
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
|
||||
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
|
||||
];
|
||||
|
||||
/** Format-Liste je Reihe (A/B) — für gruppierte Dropdown-Anzeige (optgroup). */
|
||||
export const PAPER_FORMAT_GROUPS: { label: string; formats: LayoutPaperFormat[] }[] = [
|
||||
{ label: "A", formats: ["a0", "a1", "a2", "a3", "a4", "a5", "a6"] },
|
||||
{ label: "B", formats: ["b0", "b1", "b2", "b3", "b4", "b5", "b6"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Blattmasse (mm) nach Format + Ausrichtung (Quer = Basis gedreht). Sind
|
||||
* `customWidthMm`/`customHeightMm` (freie Grösse) gesetzt und positiv, gewinnen
|
||||
* sie und `paper`/`orientation` werden ignoriert (additiv — Alt-Aufrufe ohne
|
||||
* Custom-Argumente verhalten sich unverändert).
|
||||
*/
|
||||
export function sheetSizeMm(
|
||||
paper: LayoutPaperFormat,
|
||||
orientation: LayoutOrientation,
|
||||
customWidthMm?: number,
|
||||
customHeightMm?: number,
|
||||
): { w: number; h: number } {
|
||||
if (
|
||||
customWidthMm !== undefined &&
|
||||
customHeightMm !== undefined &&
|
||||
customWidthMm > 0 &&
|
||||
customHeightMm > 0
|
||||
) {
|
||||
return { w: customWidthMm, h: customHeightMm };
|
||||
}
|
||||
const base = PAGE_MM[paper];
|
||||
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effektive Blattgrösse eines Layouts/Masters (mm) — respektiert die freie
|
||||
* Grösse (`customWidthMm`/`customHeightMm`), sonst `paper`+`orientation`. EIN
|
||||
* Ort für die „Custom übersteuert Papier"-Regel (Panel/PDF/Editor teilen ihn).
|
||||
*/
|
||||
export function effectiveSheetSizeMm(item: {
|
||||
paper: LayoutPaperFormat;
|
||||
orientation: LayoutOrientation;
|
||||
customWidthMm?: number;
|
||||
customHeightMm?: number;
|
||||
}): { w: number; h: number } {
|
||||
return sheetSizeMm(
|
||||
item.paper,
|
||||
item.orientation,
|
||||
item.customWidthMm,
|
||||
item.customHeightMm,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fabriken ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Neues, leeres Layout (ohne Viewports, ohne Master). */
|
||||
export function createLayout(
|
||||
id: string,
|
||||
name: string,
|
||||
paper: LayoutPaperFormat,
|
||||
orientation: LayoutOrientation,
|
||||
): Layout {
|
||||
return { id, name, paper, orientation, viewports: [] };
|
||||
}
|
||||
|
||||
/** Neues Masterlayout mit leerem Titelblock + gezeichnetem Rahmen. */
|
||||
export function createMasterLayout(
|
||||
id: string,
|
||||
name: string,
|
||||
paper: LayoutPaperFormat,
|
||||
orientation: LayoutOrientation,
|
||||
custom?: { widthMm: number; heightMm: number },
|
||||
): MasterLayout {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
paper,
|
||||
orientation,
|
||||
titleBlock: {},
|
||||
border: true,
|
||||
...(custom ? { customWidthMm: custom.widthMm, customHeightMm: custom.heightMm } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer, leerer Ordner für den Layouts- ODER Master-Baum. `kind:"master"` markiert
|
||||
* einen Master-Ordner; `"layout"`/`undefined` bleibt ein (rückwärtskompatibler)
|
||||
* Layout-Ordner — das Feld wird dann NICHT gesetzt (Alt-Projekte unverändert).
|
||||
*/
|
||||
export function createLayoutFolder(
|
||||
id: string,
|
||||
name: string,
|
||||
parentId?: string,
|
||||
kind?: "layout" | "master",
|
||||
): LayoutFolder {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(kind === "master" ? { kind } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Layouts-Baum (Ordner + Blätter) ──────────────────────────────────────────
|
||||
|
||||
/** Ein Ordner-Knoten im Baum: der Ordner, seine Unterordner und direkten Blätter. */
|
||||
export interface FolderNode {
|
||||
folder: LayoutFolder;
|
||||
subfolders: FolderNode[];
|
||||
layouts: Layout[];
|
||||
}
|
||||
|
||||
/** Der aufgebaute Baum: Wurzel-Blätter (ohne Ordner) + Wurzel-Ordner. */
|
||||
export interface LayoutTree {
|
||||
/** Layouts ohne (gültigen) `folderId` — auf Wurzelebene. */
|
||||
rootLayouts: Layout[];
|
||||
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
|
||||
rootFolders: FolderNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus der flachen Ordner-/Layout-Liste den verschachtelten Baum. Robust
|
||||
* gegen verwaiste Referenzen: ein Layout mit unbekanntem `folderId` bzw. ein
|
||||
* Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben
|
||||
* Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten.
|
||||
*/
|
||||
export function buildLayoutTree(
|
||||
folders: LayoutFolder[],
|
||||
layouts: Layout[],
|
||||
): LayoutTree {
|
||||
const folderIds = new Set(folders.map((f) => f.id));
|
||||
const nodes = new Map<string, FolderNode>();
|
||||
for (const f of folders) {
|
||||
nodes.set(f.id, { folder: f, subfolders: [], layouts: [] });
|
||||
}
|
||||
|
||||
const rootFolders: FolderNode[] = [];
|
||||
for (const f of folders) {
|
||||
const node = nodes.get(f.id)!;
|
||||
const parent =
|
||||
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
|
||||
if (parent) parent.subfolders.push(node);
|
||||
else rootFolders.push(node);
|
||||
}
|
||||
|
||||
const rootLayouts: Layout[] = [];
|
||||
for (const l of layouts) {
|
||||
const node =
|
||||
l.folderId && folderIds.has(l.folderId) ? nodes.get(l.folderId) : undefined;
|
||||
if (node) node.layouts.push(l);
|
||||
else rootLayouts.push(l);
|
||||
}
|
||||
|
||||
return { rootLayouts, rootFolders };
|
||||
}
|
||||
|
||||
// ── Master-Baum (Ordner + Masterlayouts) ─────────────────────────────────────
|
||||
// Eigener Baum NEBEN dem Layout-Baum: Master-Ordner (`kind:"master"`) tragen
|
||||
// Masterlayouts als Blätter. Bewusst KEINE gemeinsame Struktur mit dem Layout-
|
||||
// Baum, damit Layout- und Master-Ordner nie vermischt werden.
|
||||
|
||||
/** Ein Master-Ordner-Knoten: der Ordner, seine Unterordner und direkten Master. */
|
||||
export interface MasterFolderNode {
|
||||
folder: LayoutFolder;
|
||||
subfolders: MasterFolderNode[];
|
||||
masters: MasterLayout[];
|
||||
}
|
||||
|
||||
/** Der Master-Baum: Wurzel-Master (ohne Ordner) + Wurzel-Ordner. */
|
||||
export interface MasterTree {
|
||||
/** Masterlayouts ohne (gültigen) `folderId` — auf Wurzelebene. */
|
||||
rootMasters: MasterLayout[];
|
||||
/** Master-Ordner ohne (gültigen) `parentId` — je mit Unterbaum. */
|
||||
rootFolders: MasterFolderNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus der flachen Master-Ordner-/Master-Liste den verschachtelten Baum —
|
||||
* analog {@link buildLayoutTree}, aber mit Masterlayouts als Blättern. Der
|
||||
* Aufrufer übergibt bereits die NUR-Master-Ordner (kind:"master"); verwaiste
|
||||
* Referenzen landen robust auf der Wurzelebene.
|
||||
*/
|
||||
export function buildMasterTree(
|
||||
folders: LayoutFolder[],
|
||||
masters: MasterLayout[],
|
||||
): MasterTree {
|
||||
const folderIds = new Set(folders.map((f) => f.id));
|
||||
const nodes = new Map<string, MasterFolderNode>();
|
||||
for (const f of folders) {
|
||||
nodes.set(f.id, { folder: f, subfolders: [], masters: [] });
|
||||
}
|
||||
|
||||
const rootFolders: MasterFolderNode[] = [];
|
||||
for (const f of folders) {
|
||||
const node = nodes.get(f.id)!;
|
||||
const parent =
|
||||
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
|
||||
if (parent) parent.subfolders.push(node);
|
||||
else rootFolders.push(node);
|
||||
}
|
||||
|
||||
const rootMasters: MasterLayout[] = [];
|
||||
for (const m of masters) {
|
||||
const node =
|
||||
m.folderId && folderIds.has(m.folderId) ? nodes.get(m.folderId) : undefined;
|
||||
if (node) node.masters.push(m);
|
||||
else rootMasters.push(m);
|
||||
}
|
||||
|
||||
return { rootMasters, rootFolders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Layouts eines Ordners in Baum-Reihenfolge — direkte Blätter zuerst, dann
|
||||
* (rekursiv) die Blätter der Unterordner. Für den Ordner→Mehrseiten-PDF-Export
|
||||
* (jedes Layout = eine Seite, deterministische Reihenfolge).
|
||||
*/
|
||||
export function collectFolderLayouts(
|
||||
folderId: string,
|
||||
folders: LayoutFolder[],
|
||||
layouts: Layout[],
|
||||
): Layout[] {
|
||||
const tree = buildLayoutTree(folders, layouts);
|
||||
const find = (nodes: FolderNode[]): FolderNode | undefined => {
|
||||
for (const n of nodes) {
|
||||
if (n.folder.id === folderId) return n;
|
||||
const deep = find(n.subfolders);
|
||||
if (deep) return deep;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const node = find(tree.rootFolders);
|
||||
if (!node) return [];
|
||||
const out: Layout[] = [];
|
||||
const walk = (n: FolderNode) => {
|
||||
out.push(...n.layouts);
|
||||
for (const sub of n.subfolders) walk(sub);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Eine geplante Ordner-PDF-Seite: das Layout + seine effektive Blattgrösse (mm). */
|
||||
export interface FolderPdfPage {
|
||||
layout: Layout;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Seiten-Planung des Ordner→Mehrseiten-PDF (ohne DOM/jsPDF): welche
|
||||
* Layouts in welcher Reihenfolge mit welcher Blattgrösse. Reihenfolge =
|
||||
* Baum-Reihenfolge (siehe {@link collectFolderLayouts}). Die effektive Grösse
|
||||
* respektiert die freie Grösse (Custom übersteuert Papier); ein gebundener
|
||||
* Master erzwingt die Blattgrösse (das Blatt erbt Papier/Ausrichtung des Masters).
|
||||
*/
|
||||
export function folderPdfPages(
|
||||
project: Project,
|
||||
folderId: string,
|
||||
): FolderPdfPage[] {
|
||||
const folders = project.layoutFolders ?? [];
|
||||
const layouts = project.layouts ?? [];
|
||||
return collectFolderLayouts(folderId, folders, layouts).map((layout) => {
|
||||
const master = findMaster(project, layout);
|
||||
const { w, h } = effectiveSheetSizeMm(master ?? layout);
|
||||
return { layout, widthMm: w, heightMm: h };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Ordner (immutabel). Unterordner und Layouts des Ordners werden
|
||||
* auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen, statt
|
||||
* mitgelöscht zu werden — es gehen keine Blätter verloren. Liefert die neuen
|
||||
* `folders`/`layouts`-Listen.
|
||||
*/
|
||||
export function deleteFolder(
|
||||
folderId: string,
|
||||
folders: LayoutFolder[],
|
||||
layouts: Layout[],
|
||||
): { folders: LayoutFolder[]; layouts: Layout[] } {
|
||||
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
|
||||
const nextLayouts = layouts.map((l) =>
|
||||
l.folderId === folderId
|
||||
? grandParentId
|
||||
? { ...l, folderId: grandParentId }
|
||||
: { ...l, folderId: undefined }
|
||||
: l,
|
||||
);
|
||||
return { folders: nextFolders, layouts: nextLayouts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Master-Ordner (immutabel) — analog {@link deleteFolder}, aber die
|
||||
* enthaltenen Masterlayouts (und Unterordner) werden auf die Elternebene
|
||||
* hochgezogen statt mitgelöscht. Liefert die neuen `folders`/`masters`-Listen.
|
||||
*/
|
||||
export function deleteMasterFolder(
|
||||
folderId: string,
|
||||
folders: LayoutFolder[],
|
||||
masters: MasterLayout[],
|
||||
): { folders: LayoutFolder[]; masters: MasterLayout[] } {
|
||||
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
|
||||
const nextMasters = masters.map((m) =>
|
||||
m.folderId === folderId
|
||||
? grandParentId
|
||||
? { ...m, folderId: grandParentId }
|
||||
: { ...m, folderId: undefined }
|
||||
: m,
|
||||
);
|
||||
return { folders: nextFolders, masters: nextMasters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Ordner aus der flachen Liste und zieht dessen direkte
|
||||
* Unterordner auf die Elternebene (`grandParentId`). Das `kind`-Feld bleibt dabei
|
||||
* ERHALTEN (Spread statt Neu-Bau), damit ein hochgezogener Master-Ordner nicht
|
||||
* versehentlich zum Layout-Ordner wird. Liefert die neue Liste + `grandParentId`
|
||||
* (den die Blatt-Reparentierung der Aufrufer nutzt).
|
||||
*/
|
||||
function foldersAfterDelete(
|
||||
folderId: string,
|
||||
folders: LayoutFolder[],
|
||||
): { folders: LayoutFolder[]; grandParentId?: string } {
|
||||
const target = folders.find((f) => f.id === folderId);
|
||||
const grandParentId = target?.parentId;
|
||||
const nextFolders = folders
|
||||
.filter((f) => f.id !== folderId)
|
||||
.map((f) =>
|
||||
f.parentId === folderId
|
||||
? grandParentId
|
||||
? { ...f, parentId: grandParentId }
|
||||
: stripParentId(f)
|
||||
: f,
|
||||
);
|
||||
return { folders: nextFolders, grandParentId };
|
||||
}
|
||||
|
||||
/** Ordner auf die Wurzelebene setzen (parentId entfernen, `kind` bleibt erhalten). */
|
||||
function stripParentId(folder: LayoutFolder): LayoutFolder {
|
||||
const next = { ...folder };
|
||||
delete next.parentId;
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Baum-Umsortieren (Drag & Drop) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den
|
||||
* Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich
|
||||
* selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum).
|
||||
* Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab).
|
||||
*/
|
||||
export function isDescendant(
|
||||
folders: LayoutFolder[],
|
||||
candidateId: string,
|
||||
rootId: string,
|
||||
): boolean {
|
||||
if (candidateId === rootId) return true;
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const seen = new Set<string>();
|
||||
let cur = byId.get(candidateId);
|
||||
while (cur && cur.parentId && !seen.has(cur.id)) {
|
||||
if (cur.parentId === rootId) return true;
|
||||
seen.add(cur.id);
|
||||
cur = byId.get(cur.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt ein Layout in einen anderen Ordner (immutabel). `folderId = null`
|
||||
* → zurück auf die Wurzelebene. No-op, wenn die Layout-Id fehlt.
|
||||
*/
|
||||
export function moveLayoutToFolder(
|
||||
layouts: Layout[],
|
||||
layoutId: string,
|
||||
folderId: string | null,
|
||||
): Layout[] {
|
||||
return layouts.map((l) => {
|
||||
if (l.id !== layoutId) return l;
|
||||
if (folderId) return { ...l, folderId };
|
||||
const next = { ...l };
|
||||
delete next.folderId;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt einen Ordner unter einen neuen Elternordner (immutabel).
|
||||
* `parentId = null` → Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in
|
||||
* sich selbst oder einen seiner Nachfahren wandern (dann No-op — Original zurück).
|
||||
*/
|
||||
export function moveFolderToFolder(
|
||||
folders: LayoutFolder[],
|
||||
folderId: string,
|
||||
parentId: string | null,
|
||||
): LayoutFolder[] {
|
||||
if (parentId && isDescendant(folders, parentId, folderId)) return folders;
|
||||
return folders.map((f) => {
|
||||
if (f.id !== folderId) return f;
|
||||
if (parentId) return { ...f, parentId };
|
||||
return stripParentId(f);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer Viewport (Standardgrösse/-position). `snapshotId` bindet ihn an einen
|
||||
* Ausschnitt; Position/Grösse in Papier-mm setzt der Aufrufer per {@link patchViewport}.
|
||||
*/
|
||||
export function createViewport(
|
||||
id: string,
|
||||
snapshotId: string,
|
||||
init?: Partial<Omit<LayoutViewport, "id" | "snapshotId">>,
|
||||
): LayoutViewport {
|
||||
return {
|
||||
id,
|
||||
snapshotId,
|
||||
xMm: init?.xMm ?? 20,
|
||||
yMm: init?.yMm ?? 20,
|
||||
widthMm: init?.widthMm ?? 120,
|
||||
heightMm: init?.heightMm ?? 90,
|
||||
...(init?.scaleDenominator !== undefined
|
||||
? { scaleDenominator: init.scaleDenominator }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Immutable CRUD (Viewports auf einem Layout) ──────────────────────────────
|
||||
|
||||
/** Fügt einen Viewport an (immutabel). */
|
||||
export function addViewport(layout: Layout, viewport: LayoutViewport): Layout {
|
||||
return { ...layout, viewports: [...layout.viewports, viewport] };
|
||||
}
|
||||
|
||||
/** Entfernt einen Viewport per Id (immutabel). */
|
||||
export function removeViewport(layout: Layout, viewportId: string): Layout {
|
||||
return {
|
||||
...layout,
|
||||
viewports: layout.viewports.filter((v) => v.id !== viewportId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ändert Felder eines Viewports (immutabel). No-op, wenn die Id fehlt. */
|
||||
export function patchViewport(
|
||||
layout: Layout,
|
||||
viewportId: string,
|
||||
patch: Partial<Omit<LayoutViewport, "id">>,
|
||||
): Layout {
|
||||
return {
|
||||
...layout,
|
||||
viewports: layout.viewports.map((v) =>
|
||||
v.id === viewportId ? { ...v, ...patch } : v,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fabrik + immutable CRUD (Annotationen auf einem Layout) ─────────────────
|
||||
// Analog zu Viewports: Linie/Rechteck/Text als vom Viewport-Baum unabhängige
|
||||
// Markup-Schicht in Papier-mm. `annotations ?? []` ist überall die Basis, damit
|
||||
// Alt-Layouts (ohne das Feld) unverändert bleiben (additiv).
|
||||
|
||||
/**
|
||||
* Neue Annotation mit Default-Geometrie je Art (Linie/Rechteck: sichtbare
|
||||
* Mindestgrösse; Text: 3.5 mm Zeilenhöhe, wie im Attribute-Editor üblich).
|
||||
* `init` überschreibt einzelne Felder — der Aufrufer (Editor) übergibt i. d. R.
|
||||
* die vollständige, aus dem Zeichnen gewonnene Geometrie.
|
||||
*/
|
||||
export function createAnnotation(
|
||||
id: string,
|
||||
kind: LayoutAnnotation["kind"],
|
||||
init: LayoutAnnotationPatch = {},
|
||||
): LayoutAnnotation {
|
||||
const colorPatch = init.color !== undefined ? { color: init.color } : {};
|
||||
if (kind === "line") {
|
||||
return {
|
||||
id,
|
||||
kind: "line",
|
||||
x1Mm: init.x1Mm ?? 20,
|
||||
y1Mm: init.y1Mm ?? 20,
|
||||
x2Mm: init.x2Mm ?? 80,
|
||||
y2Mm: init.y2Mm ?? 20,
|
||||
...colorPatch,
|
||||
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
|
||||
};
|
||||
}
|
||||
if (kind === "rect") {
|
||||
return {
|
||||
id,
|
||||
kind: "rect",
|
||||
xMm: init.xMm ?? 20,
|
||||
yMm: init.yMm ?? 20,
|
||||
widthMm: init.widthMm ?? 40,
|
||||
heightMm: init.heightMm ?? 30,
|
||||
...colorPatch,
|
||||
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind: "text",
|
||||
xMm: init.xMm ?? 20,
|
||||
yMm: init.yMm ?? 20,
|
||||
text: init.text ?? "",
|
||||
heightMm: init.heightMm ?? 3.5,
|
||||
...colorPatch,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fügt eine Annotation an (immutabel). */
|
||||
export function addAnnotation(layout: Layout, annotation: LayoutAnnotation): Layout {
|
||||
return { ...layout, annotations: [...(layout.annotations ?? []), annotation] };
|
||||
}
|
||||
|
||||
/** Entfernt eine Annotation per Id (immutabel). */
|
||||
export function removeAnnotation(layout: Layout, annotationId: string): Layout {
|
||||
return {
|
||||
...layout,
|
||||
annotations: (layout.annotations ?? []).filter((a) => a.id !== annotationId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert Felder einer Annotation (immutabel). No-op, wenn die Id fehlt. Der
|
||||
* Patch ist bewusst art-unabhängig ({@link LayoutAnnotationPatch}) — es werden
|
||||
* nur die tatsächlich übergebenen Felder gemergt.
|
||||
*/
|
||||
export function patchAnnotation(
|
||||
layout: Layout,
|
||||
annotationId: string,
|
||||
patch: LayoutAnnotationPatch,
|
||||
): Layout {
|
||||
return {
|
||||
...layout,
|
||||
annotations: (layout.annotations ?? []).map((a) =>
|
||||
a.id === annotationId ? ({ ...a, ...patch } as LayoutAnnotation) : a,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Auflösungen ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Findet einen Ausschnitt (View-Snapshot) im Projekt. */
|
||||
export function findSnapshot(
|
||||
project: Project,
|
||||
snapshotId: string,
|
||||
): ViewSnapshot | undefined {
|
||||
return (project.viewSnapshots ?? []).find((s) => s.id === snapshotId);
|
||||
}
|
||||
|
||||
/** Findet das Masterlayout eines Layouts (falls gebunden). */
|
||||
export function findMaster(
|
||||
project: Project,
|
||||
layout: Layout,
|
||||
): MasterLayout | undefined {
|
||||
if (!layout.masterId) return undefined;
|
||||
return (project.masterLayouts ?? []).find((m) => m.id === layout.masterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effektiver Massstab-Nenner eines Viewports: sein `scaleDenominator` übersteuert
|
||||
* den des gebundenen Ausschnitts. Fällt auf `fallback` zurück, wenn weder
|
||||
* Viewport noch Ausschnitt einen Wert tragen (defensiv, sollte nicht auftreten).
|
||||
*/
|
||||
export function resolveViewportScale(
|
||||
viewport: LayoutViewport,
|
||||
snapshot: ViewSnapshot | undefined,
|
||||
fallback = 100,
|
||||
): number {
|
||||
return viewport.scaleDenominator ?? snapshot?.scaleDenominator ?? fallback;
|
||||
}
|
||||
|
||||
/** Aufgelöste Titelblock-Werte (alle Felder gefüllt) für die Darstellung. */
|
||||
export interface ResolvedTitleBlock {
|
||||
projectName: string;
|
||||
sheetName: string;
|
||||
scale: string;
|
||||
date: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master-Vererbung der Titelblock-Werte: der Master liefert die Vorlagen-Texte;
|
||||
* leere Felder werden aus Projekt/Layout mit sinnvollen Defaults gefüllt
|
||||
* (Projektname ← `Project.name`, Blattname ← `Layout.name`, Datum ← `today`).
|
||||
* Ohne Master bleibt der Titelblock trotzdem sinnvoll befüllt (nur Defaults).
|
||||
* Der `scale`-Default ergibt sich aus dem ERSTEN Viewport des Layouts (falls
|
||||
* vorhanden), damit ein Ein-Viewport-Blatt gleich seinen Massstab trägt.
|
||||
*/
|
||||
export function resolveTitleBlock(
|
||||
project: Project,
|
||||
layout: Layout,
|
||||
master: MasterLayout | undefined,
|
||||
today: string = new Date().toISOString().slice(0, 10),
|
||||
): ResolvedTitleBlock {
|
||||
const tb: LayoutTitleBlock = master?.titleBlock ?? {};
|
||||
const firstVp = layout.viewports[0];
|
||||
const firstSnap = firstVp ? findSnapshot(project, firstVp.snapshotId) : undefined;
|
||||
const scaleN = firstVp
|
||||
? resolveViewportScale(firstVp, firstSnap)
|
||||
: undefined;
|
||||
const nonEmpty = (s: string | undefined): string | undefined =>
|
||||
s && s.trim() ? s.trim() : undefined;
|
||||
return {
|
||||
projectName: nonEmpty(tb.projectName) ?? project.name,
|
||||
sheetName: nonEmpty(tb.sheetName) ?? layout.name,
|
||||
scale: nonEmpty(tb.scale) ?? (scaleN ? `1:${scaleN}` : "—"),
|
||||
date: nonEmpty(tb.date) ?? today,
|
||||
author: nonEmpty(tb.author) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sichtbare Kategorie-Codes eines Ausschnitts als Set (für `generatePlan`). Aus
|
||||
* der gespeicherten `layerVisibility`-Map (Code → sichtbar) abgeleitet.
|
||||
*/
|
||||
export function visibleCodesFromSnapshot(snap: ViewSnapshot): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const [code, visible] of Object.entries(snap.layerVisibility)) {
|
||||
if (visible) out.add(code);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user