Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
// Inhalts-Panel „Attribute" — die Attribut-Palette (Vectorworks-Stil) für die
// aktuelle Auswahl. Zeigt die EFFEKTIV aufgelösten Stift-/Füll-Eigenschaften des
// ersten selektierten Elements und erlaubt das direkte Bearbeiten (Farbe,
// Strichstärke, Füllschraffur).
//
// Daten/Handler kommen ausschließlich über usePanelHost (kein Prop-Drilling).
// Es werden NUR Eigenschaften angeboten, die der Host-Kontrakt tatsächlich
// setzen kann — keine Stubs/Fake-Setter (Konvention wire-dont-stub). Felder ohne
// echten Setter (Linienstil) werden read-only/informativ gezeigt; Felder, die
// das Modell gar nicht trägt (Deckkraft, Caps, Schlagschatten), werden bewusst
// weggelassen, um die Palette ehrlich und schlank zu halten.
//
// DOSSIER-Tabellen-Look: KEINE wiederholten Feld-Labels pro Zeile — ein kompaktes
// Label→Wert-Grid. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
// Alle sichtbaren Texte über t(...).
import { t } from "../i18n";
import { formatM } from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { usePanelHost } from "./host";
export function AttributesPanel() {
const host = usePanelHost();
const sel = host.selection;
const { project } = host;
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist.
if (sel === null) {
return (
<div className="attr-panel">
<div className="attr-empty">{t("attr.empty")}</div>
</div>
);
}
const isDrawing = sel.kind === "drawing2d";
// Strichstärke wirkt laut Kontrakt nur auf Drawing2D; Wände erben sie aus der
// Ebene und sind daher hier nicht editierbar.
const weightEditable = isDrawing;
// Füllung nur bei geschlossener 2D-Form sinnvoll.
const fillEditable = isDrawing && sel.closed === true;
// Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake).
// Wir leiten ihn aus dem Modell-Element ab, wenn explizit gesetzt; sonst „—".
const drawing = isDrawing
? project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const lineStyle = drawing?.lineStyleId
? project.lineStyles.find((l) => l.id === drawing.lineStyleId)
: undefined;
const bboxW = sel.bbox.maxX - sel.bbox.minX;
const bboxH = sel.bbox.maxY - sel.bbox.minY;
return (
<div className="attr-panel">
<div className="attr-title">{t("attr.title")}</div>
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
<div className="attr-section-label">{t("attr.stroke")}</div>
<div className="attr-grid">
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
<span className="attr-key">{t("attr.color")}</span>
<span className="attr-val">
<label className="attr-color">
<span
className="attr-color-swatch"
style={{ background: sel.color }}
/>
<input
type="color"
value={sel.color}
onChange={(e) => host.onSetSelectionColor(e.target.value)}
/>
<span className="attr-color-hex">{sel.color}</span>
</label>
</span>
{/* Strichstärke (mm) — nur Drawing2D editierbar; Wände erben aus Ebene. */}
<span className="attr-key">{t("attr.weight")}</span>
<span className="attr-val">
<input
className="attr-num"
type="number"
step={0.01}
min={0}
list="attr-pen-weights"
value={sel.weightMm}
disabled={!weightEditable}
title={weightEditable ? undefined : t("attr.inheritedFromLayer")}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
}}
/>
<datalist id="attr-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
</span>
{/* Linienstil — read-only/informativ (kein Setter im Kontrakt). */}
<span className="attr-key">{t("attr.lineStyle")}</span>
<span className="attr-val attr-readonly">
{lineStyle ? lineStyle.name : "—"}
</span>
</div>
{/* ── Füllung (nur geschlossene 2D-Form) ────────────────────────── */}
<div className="attr-section-label">{t("attr.fill")}</div>
<div className="attr-grid">
{/* Vollton-Füllfarbe (getrennt von der Strichfarbe). „keine" = transparent. */}
<span className="attr-key">{t("attr.fillColor")}</span>
<span className="attr-val">
<label className="attr-color">
<span
className="attr-color-swatch"
style={{
background: fillEditable && sel.fillColor ? sel.fillColor : "transparent",
}}
/>
<input
type="color"
value={sel.fillColor ?? "#808080"}
disabled={!fillEditable}
title={fillEditable ? undefined : t("attr.fillNeedsClosed")}
onChange={(e) => host.onSetSelectionFillColor(e.target.value)}
/>
<span className="attr-color-hex">
{fillEditable && sel.fillColor ? sel.fillColor : t("attr.none")}
</span>
{fillEditable && sel.fillColor && (
<button
type="button"
className="attr-color-clear"
title={t("attr.clearFill")}
onClick={() => host.onSetSelectionFillColor(null)}
>
×
</button>
)}
</label>
</span>
<span className="attr-key">{t("attr.hatch")}</span>
<span className="attr-val">
<select
className="attr-select"
value={fillEditable ? sel.fillHatchId ?? "" : ""}
disabled={!fillEditable}
title={fillEditable ? undefined : t("attr.fillNeedsClosed")}
onChange={(e) =>
host.onSetSelectionFill(e.target.value || null)
}
>
<option value="">{t("attr.none")}</option>
{project.hatches.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</span>
</div>
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div>
<div className="attr-grid">
<span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Darstellungsmodus-Umschalter — kleines dunkles Dropdown für die Panel-Kopfzeile.
//
// Reines, kontrolliertes Steuerelement: zeigt die fünf DOSSIER-DisplayMode-
// Optionen (Alle anzeigen / Ausgewählte / Nur aktive / Andere grau / Andere
// grau & gesperrt) und meldet die Wahl per Callback. Es kennt weder Host noch
// Registry — der Rahmen (PanelFrame) reicht `value` und `onChange` durch, die
// App verdrahtet sie mit `host.displayMode`.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { DisplayMode } from "./types";
import { t } from "../i18n";
/**
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die
* fünf DOSSIER-Modi aus docs/design/context-menu.md.
*/
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "all_force", labelKey: "display.all_force" },
{ value: "all", labelKey: "display.all" },
{ value: "active", labelKey: "display.active" },
{ value: "grey", labelKey: "display.grey" },
{ value: "grey_locked", labelKey: "display.grey_locked" },
];
/**
* Kleines Pill-Dropdown für den Darstellungsmodus. Vollständig kontrolliert:
* `value` ist der aktuelle Modus, `onChange` liefert den neuen.
*/
export function DisplayModeSelect({
value,
onChange,
title,
}: {
/** Aktuell gewählter Darstellungsmodus. */
value: DisplayMode;
/** Wird mit dem neuen Modus aufgerufen, wenn der Nutzer wählt. */
onChange: (mode: DisplayMode) => void;
/** Optionaler Tooltip auf dem Dropdown (Default: i18n „Darstellungsmodus"). */
title?: string;
}) {
const label = title ?? t("display.title");
return (
<select
className="display-mode"
value={value}
title={label}
aria-label={label}
onChange={(e) => onChange(e.target.value as DisplayMode)}
>
{MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{t(o.labelKey)}
</option>
))}
</select>
);
}
+265
View File
@@ -0,0 +1,265 @@
// Dock — eine angedockte Spalte (links oder rechts) mit GESTAPELTEN Gruppen.
//
// Ein Dock hält eine Liste vertikal gestapelter Gruppen (DockState.groups). Jede
// Gruppe ist ein eigener Tab-Stapel (TabStrip + aktives Panel im PanelFrame) und
// trägt ein Höhen-Gewicht; zwischen den Gruppen sitzt ein Splitter, der die
// Gewichte der Nachbarn verschiebt. So entsteht das Vectorworks-Bild (z. B.
// links Werkzeuge OBEN, Attribute UNTEN gleichzeitig).
//
// Jede Gruppe rendert ihren PanelFrame in einem EIGENEN Host-Provider, damit der
// Darstellungsmodus-Umschalter dem aktiven Tab DIESER Gruppe folgt. Den Provider
// baut der Host (App) und reicht ihn als `buildHost(activeTab)` herein — das Dock
// bleibt so vom konkreten Host-Modell entkoppelt.
//
// Breite ist per Ziehgriff an der INNEREN Kante veränderbar (rechte Kante beim
// linken Dock, linke Kante beim rechten Dock); `dock.size` ist die Quelle der
// Wahrheit, `onResize(size)` meldet jede Änderung zurück. Die Gruppen-Höhen
// meldet `onResizeGroups(weights)`.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { TabStrip } from "./TabStrip";
import { getPanel } from "./registry";
import { PanelHostContext } from "./types";
import type {
DisplayMode,
DockId,
DockState,
PanelContext,
PanelHost,
} from "./types";
import { t } from "../i18n";
/** Untere/obere Grenze der Dock-Breite in CSS-Pixeln (Voreinstellung). */
const DEFAULT_MIN_WIDTH = 200;
const DEFAULT_MAX_WIDTH = 480;
/** Mindest-Pixelhöhe, die eine Gruppe beim Splitter-Ziehen behält. */
const MIN_GROUP_PX = 70;
export interface DockProps {
/** Welches Dock: bestimmt Rand + Seite des Ziehgriffs. */
side: DockId;
/** Zustand dieses Docks (Gruppen + Breite). */
dock: DockState;
/**
* Beginn einer Tab-Geste (pointerdown auf einem Tab) — an die TabStrip
* weitergereicht. Der Drag-Controller klärt Klick (aktivieren) vs. Ziehen
* (Reorder/Andocken/Lösen/Stapeln).
*/
onStartTabDrag: (panelId: string, e: React.PointerEvent) => void;
/** Breite geändert — liefert die neue (bereits geklemmte) Breite in px. */
onResize: (size: number) => void;
/** Gruppen-Höhen geändert — liefert die neue Gewichts-Liste (so lang wie groups). */
onResizeGroups: (weights: number[]) => void;
/**
* Baut den Host-Context-Wert für eine Gruppe (passend zu ihrem aktiven Tab).
* App reicht hier `hostWithMode(...)` herein; das Dock wickelt jede Gruppe in
* einen eigenen <PanelHostContext.Provider>.
*/
buildHost: (activeTab: string | null) => PanelHost;
/** Generischer Kontext, den der PanelFrame an `def.render` weiterreicht. */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */
displayModeFor: (activeTab: string | null) => DisplayMode;
/** Setter des Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */
onDisplayModeChange: (activeTab: string | null, mode: DisplayMode) => void;
/** Minimale Breite in px (Voreinstellung 200). */
minWidth?: number;
/** Maximale Breite in px (Voreinstellung 480). */
maxWidth?: number;
}
/** Begrenzt eine Breite auf [min, max]. */
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Eine angedockte Spalte mit gestapelten Gruppen. Rendert je Gruppe TabStrip +
* aktives Panel (im eigenen Host-Provider) und erlaubt das Verändern von Breite
* (innere Kante) und Gruppen-Höhen (Splitter zwischen Gruppen).
*/
export function Dock({
side,
dock,
onStartTabDrag,
onResize,
onResizeGroups,
buildHost,
ctx,
displayModeFor,
onDisplayModeChange,
minWidth = DEFAULT_MIN_WIDTH,
maxWidth = DEFAULT_MAX_WIDTH,
}: DockProps) {
// Wurzelelement, um die Breite während des Ziehens aus clientX zu berechnen.
const rootRef = useRef<HTMLDivElement | null>(null);
// Container der Gruppen (für die Gesamthöhe beim Gruppen-Splitter).
const stackRef = useRef<HTMLDivElement | null>(null);
// Breite während des aktiven Ziehens; null = nicht im Ziehen (dock.size gilt).
const [dragWidth, setDragWidth] = useState<number | null>(null);
const onResizeStart = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Nur primäre Taste / Berührung; Griff fängt den Zeiger ein, damit das
// Ziehen auch außerhalb des Elements weiterläuft.
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
// Linkes Dock wächst nach rechts (Zeiger linke Kante);
// rechtes Dock wächst nach links (rechte Kante Zeiger).
const raw =
side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX;
const next = clamp(Math.round(raw), minWidth, maxWidth);
setDragWidth(next);
onResize(next);
};
const up = (ev: PointerEvent) => {
setDragWidth(null);
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[side, minWidth, maxWidth, onResize],
);
// Splitter zwischen Gruppe `index` und `index+1`: verschiebt Gewicht zwischen
// den beiden Nachbarn. Umgerechnet wird der Pixel-Versatz über die Gesamthöhe
// des Stapels in einen Gewichts-Anteil (Summe der Gewichte bleibt erhalten).
const onGroupResizeStart = useCallback(
(index: number, e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const stack = stackRef.current;
const totalPx = stack ? stack.getBoundingClientRect().height : 0;
const weights = dock.groups.map((g) => g.weight);
const sumW = weights.reduce((s, w) => s + w, 0) || 1;
const startY = e.clientY;
const wA = weights[index];
const wB = weights[index + 1];
// Pixel ↔ Gewicht: ein Pixel entspricht sumW/totalPx an Gewicht.
const perPx = totalPx > 0 ? sumW / totalPx : 0;
const minW = MIN_GROUP_PX * perPx;
const move = (ev: PointerEvent) => {
if (perPx === 0) return;
const dW = (ev.clientY - startY) * perPx;
// A wächst nach unten, B schrumpft; beide bleiben über dem Minimum.
let nextA = wA + dW;
let nextB = wB - dW;
const pair = wA + wB;
nextA = clamp(nextA, minW, pair - minW);
nextB = pair - nextA;
const out = [...weights];
out[index] = nextA;
out[index + 1] = nextB;
onResizeGroups(out);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[dock.groups, onResizeGroups],
);
const width = dragWidth ?? dock.size;
// Breite als CSS-Variable; styles.css liest --dock-w für flex-basis/width.
const style = { "--dock-w": `${width}px` } as CSSProperties;
return (
<aside
ref={rootRef}
className={`dock dock-${side}${dragWidth !== null ? " resizing" : ""}`}
style={style}
>
<div className="dock-stack" ref={stackRef}>
{dock.groups.map((group, gi) => {
const def = group.activeTab ? getPanel(group.activeTab) : undefined;
const groupStyle = { flexGrow: group.weight } as CSSProperties;
return [
<section
key={`g${gi}`}
className="dock-group"
style={groupStyle}
// Datenattribute für die Drag-Auflösung: über dem Körper einer
// Gruppe entsteht beim Ablegen eine NEUE Gruppe darüber/darunter.
data-group-body=""
data-dock={side}
data-group-index={gi}
>
<TabStrip
side={side}
groupIndex={gi}
group={group}
onStartDrag={onStartTabDrag}
/>
{def ? (
<PanelHostContext.Provider value={buildHost(group.activeTab)}>
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayModeFor(group.activeTab)}
onDisplayModeChange={(m) =>
onDisplayModeChange(group.activeTab, m)
}
/>
</PanelHostContext.Provider>
) : (
<div className="panel-frame panel-frame-empty" />
)}
</section>,
// Splitter zwischen dieser und der nächsten Gruppe.
gi < dock.groups.length - 1 ? (
<div
key={`s${gi}`}
className="dock-group-resize"
role="separator"
aria-orientation="horizontal"
aria-label={t("dock.resize")}
onPointerDown={(e) => onGroupResizeStart(gi, e)}
/>
) : null,
];
})}
</div>
{/* Ziehgriff an der inneren Kante (rechts bei links, links bei rechts). */}
<div
className="dock-resize"
role="separator"
aria-orientation="vertical"
aria-label={t("dock.resize")}
onPointerDown={onResizeStart}
/>
</aside>
);
}
+157
View File
@@ -0,0 +1,157 @@
// Inhalts-Panel „Zeichnungsebenen" — die obere Navigator-Liste als Panel.
//
// Zeigt die Zeichnungsebenen (Geschosse + Schnitte/Ansichten/Zeichnungen)
// exakt wie der bisherige Navigator: EyeIcon schaltet die Sichtbarkeit, ein
// Kind-Badge zeigt die Art, die aktive Ebene ist hervorgehoben, Höhen-Readout
// (+OKFF) bei Geschossen. Kopfzeile mit + Geschoss / + Zeichnung.
//
// Neu gegenüber dem Inline-Navigator: das Panel deklariert hasDisplayMode und
// liest den gewählten DisplayMode aus dem Host-Context. `itemDisplay`
// entscheidet je Zeile, ob sie gezeichnet und ob sie ausgegraut wird (aktives
// Element = Arbeitsfokus, immer sichtbar). Daten/Handler kommen über
// usePanelHost — kein Prop-Drilling.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { EyeIcon } from "../ui/EyeIcon";
import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { DrawingLevel } from "./host";
import type { DisplayMode } from "./types";
import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n";
/** i18n-Key des Kurzbadges für die Art einer Zeichnungsebene. */
const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
floor: "badge.floor",
section: "badge.section",
elevation: "badge.elevation",
drawing: "badge.drawing",
};
/** Inhalt des Panels „Zeichnungsebenen". */
export function DrawingLevelsPanel() {
const host = usePanelHost();
const { project, activeLevelId, displayMode } = host;
// Oberstes Geschoss zuoberst (wie DOSSIER): Anzeige umkehren.
const levelsTopDown = [...project.drawingLevels].reverse();
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift.
const toggle = (id: string) => {
const m = displayMode.mode;
if (m === "all_force" || m === "active") displayMode.setMode("all");
host.onToggleLevel(id);
};
// Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus).
const onRowContext = (id: string, e: React.MouseEvent) => {
e.preventDefault();
host.onLevelContextMenu(id, e.clientX, e.clientY);
};
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.drawingLevels")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddFloor}>
{t("levels.addFloor")}
</button>
<button className="nav-add" onClick={host.onAddDrawing}>
{t("levels.addDrawing")}
</button>
</span>
</header>
<div className="nav-list">
{levelsTopDown.map((level) => (
<LevelRow
key={level.id}
level={level}
active={level.id === activeLevelId}
mode={displayMode.mode}
onSelect={() => host.onSelectLevel(level.id)}
onToggle={() => toggle(level.id)}
onContext={(e) => onRowContext(level.id, e)}
/>
))}
</div>
</section>
);
}
/** Eine Zeile im Zeichnungsebenen-Bereich. */
function LevelRow({
level,
active,
mode,
onSelect,
onToggle,
onContext,
}: {
level: DrawingLevel;
active: boolean;
mode: DisplayMode;
onSelect: () => void;
onToggle: () => void;
onContext: (e: React.MouseEvent) => void;
}) {
// Darstellungsmodus auf diese Zeile anwenden: greyed steuert das Dimmen,
// render würde im Modus "active" Nicht-Aktive ausblenden. Im Navigator bleibt
// jede Zeile bedienbar (Liste = Steuerung), daher blenden wir nicht ganz aus,
// sondern dimmen nicht gezeichnete Zeilen wie ausgegraute.
const display = itemDisplay(mode, active, level.visible);
const dimmed = display.greyed || !display.render;
// In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen, Flag
// ohne Wirkung). „grey_locked" sperrt nicht-aktive Zeilen.
const eyeDimmed = mode === "all_force";
const cls = [
"nav-row",
active ? "active" : "",
display.locked ? "locked" : "",
// Bisheriges Verhalten: nicht-aktive, unsichtbare Zeilen gedimmt.
!active && !level.visible ? "hidden" : "",
]
.filter(Boolean)
.join(" ");
// Zusätzliches Dimmen aus dem DisplayMode (nicht in styles.css → inline).
// Aktiv bleibt immer voll sichtbar.
const style = !active && dimmed ? { opacity: 0.45 } : undefined;
return (
<div
className={cls}
style={style}
onClick={onSelect}
onContextMenu={onContext}
>
<button
className={`eye${level.visible ? " is-on" : ""}`}
aria-label={t("nav.visibility")}
title={level.visible ? t("nav.hide") : t("nav.show")}
style={eyeDimmed ? { opacity: 0.5 } : undefined}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<EyeIcon open={level.visible} />
</button>
<span className="nav-label">{level.name}</span>
{level.kind === "floor" && level.baseElevation !== undefined && (
<span className="nav-elev">+{level.baseElevation.toFixed(2)}</span>
)}
<span className={`chip badge-${level.kind}`}>
{t(KIND_BADGE_KEY[level.kind])}
</span>
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
// Schwebendes Panel — ein frei positioniertes, dunkles Fenster über der Mitte.
//
// Rendert denselben Panel-Inhalt wie ein Dock (über Registry + PanelFrame, inkl.
// Darstellungsmodus-Dropdown für Ebenen-Panels), aber in einem eigenen Fenster
// mit Titelleiste (zum Verschieben), Andock-/Schließen-Knopf und einem
// Greifer unten rechts zum Ändern der Größe. Ein Klick irgendwo im Fenster holt
// es nach vorne (z-Index).
//
// Verschieben/Größe ändern laufen über POINTER-Events mit Pointer-Capture, damit
// die Geste auch außerhalb des Fensters weiterläuft. Während des Ziehens der
// Titelleiste meldet `onTitleDrag` jede Cursorposition an den Host (App), der
// daraus eine mögliche Andock-Geste an einer Rand-Zone ableitet; beim Loslassen
// entscheidet `onTitleDrop`, ob das Fenster schwebend bleibt (verschoben) oder
// in ein Dock wandert.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { getPanel } from "./registry";
import type { DisplayMode, FloatingPanel as FloatingPanelState, PanelContext } from "./types";
import { t } from "../i18n";
/** Mindestgröße eines schwebenden Fensters in CSS-Pixeln. */
const MIN_W = 220;
const MIN_H = 160;
export interface FloatingPanelProps {
/** Zustand (Position/Größe/z) dieses schwebenden Panels. */
panel: FloatingPanelState;
/** Generischer Kontext für den Panel-Inhalt (an def.render). */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus (für Panels mit hasDisplayMode). */
displayMode?: DisplayMode;
/** Setter des Darstellungsmodus (zeigt den Umschalter in der Kopfzeile). */
onDisplayModeChange?: (mode: DisplayMode) => void;
/** Holt das Fenster nach vorne (z-Index) — bei Klick/Geste-Beginn. */
onFocus: (panelId: string) => void;
/** Verschiebt das Fenster (Position-Patch). */
onMove: (panelId: string, x: number, y: number) => void;
/** Ändert die Größe (w/h-Patch). */
onResize: (panelId: string, w: number, h: number) => void;
/** Dockt das Fenster wieder an (Standard-Andockziel des Redock-Knopfs). */
onRedock: (panelId: string) => void;
/** Schließt das Fenster (entfernt es ganz). */
onClose: (panelId: string) => void;
/**
* Während des Titel-Ziehens: meldet die aktuelle Cursorposition (clientX/Y) an
* den Host. Der Host hebt mögliche Rand-Andock-Zonen hervor.
*/
onTitleDrag?: (panelId: string, clientX: number, clientY: number) => void;
/**
* Beim Loslassen der Titelleiste über einer Andock-Zone: meldet die finale
* Cursorposition. Liefert `true`, wenn der Host das Fenster angedockt hat (dann
* unterbleibt das reine Verschieben). Sonst bleibt das Fenster schwebend.
*/
onTitleDrop?: (panelId: string, clientX: number, clientY: number) => boolean;
}
/**
* Ein einzelnes schwebendes Panel-Fenster. Titelleiste = verschieben (+ Andocken
* über Rand-Zonen), Greifer unten rechts = Größe ändern, Andock-/Schließen-Knopf
* in der Titelleiste.
*/
export function FloatingPanel({
panel,
ctx,
displayMode,
onDisplayModeChange,
onFocus,
onMove,
onResize,
onRedock,
onClose,
onTitleDrag,
onTitleDrop,
}: FloatingPanelProps) {
const def = getPanel(panel.panelId);
// Verschiebe-Offset (Cursor linke/obere Kante) für ein ruckelfreies Greifen.
const dragOffset = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
const onTitlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Knöpfe in der Titelleiste nicht als Verschiebe-Start werten.
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
dragOffset.current = {
dx: e.clientX - panel.x,
dy: e.clientY - panel.y,
};
const move = (ev: PointerEvent) => {
const x = ev.clientX - dragOffset.current.dx;
const y = ev.clientY - dragOffset.current.dy;
onMove(panel.panelId, x, y);
onTitleDrag?.(panel.panelId, ev.clientX, ev.clientY);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
// Über einer Rand-Zone? Dann dockt der Host an; sonst bleibt es schweben.
onTitleDrop?.(panel.panelId, ev.clientX, ev.clientY);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.x, panel.y, onFocus, onMove, onTitleDrag, onTitleDrop],
);
const onResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = panel.w;
const startH = panel.h;
const move = (ev: PointerEvent) => {
const w = Math.max(MIN_W, Math.round(startW + (ev.clientX - startX)));
const h = Math.max(MIN_H, Math.round(startH + (ev.clientY - startY)));
onResize(panel.panelId, w, h);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.w, panel.h, onFocus, onResize],
);
const style: CSSProperties = {
left: `${panel.x}px`,
top: `${panel.y}px`,
width: `${panel.w}px`,
height: `${panel.h}px`,
zIndex: 500 + panel.z,
};
const title = def ? t(def.title) : panel.panelId;
return (
<section
className="floating-panel"
style={style}
onPointerDownCapture={() => onFocus(panel.panelId)}
role="dialog"
aria-label={title}
>
<div className="floating-titlebar" onPointerDown={onTitlePointerDown}>
<span className="floating-title">{title}</span>
<div className="floating-actions">
<button
type="button"
className="floating-btn"
title={t("dock.redock")}
aria-label={t("dock.redock")}
onClick={() => onRedock(panel.panelId)}
>
</button>
<button
type="button"
className="floating-btn"
title={t("dock.close")}
aria-label={t("dock.close")}
onClick={() => onClose(panel.panelId)}
>
×
</button>
</div>
</div>
{def ? (
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
/>
) : (
<div className="panel-frame panel-frame-empty" />
)}
{/* Greifer unten rechts (Größe ändern). */}
<div
className="floating-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
</section>
);
}
+155
View File
@@ -0,0 +1,155 @@
// Inhalts-Panel „Ebenen" — der Kategorie-Baum als Panel.
//
// Zeigt den Grafik-Kategorie-Baum (LayerCategory) exakt wie der bisherige
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
//
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive"
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false;
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi):
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
// • "all" — alle sichtbaren normal (Standard, wie bisher).
// • "active" — keine Kategorie aktiv → alle gedimmt.
// • "grey" — sichtbare Kategorien ausgegraut.
// • "grey_locked" — wie „grey", zusätzlich gesperrt.
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
// (styles.css bleibt unangetastet).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { EyeIcon } from "../ui/EyeIcon";
import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { LayerCategory } from "./host";
import type { DisplayMode } from "./types";
import { t } from "../i18n";
/** Inhalt des Panels „Ebenen". */
export function LayersPanel() {
const host = usePanelHost();
const { project, displayMode } = host;
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift.
const toggle = (code: string) => {
const m = displayMode.mode;
if (m === "all_force" || m === "active") displayMode.setMode("all");
host.onToggleCategory(code);
};
// Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus).
const onRowContext = (code: string, e: React.MouseEvent) => {
e.preventDefault();
host.onLayerContextMenu(code, e.clientX, e.clientY);
};
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.layers")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddCategory}>
{t("layers.addCategory")}
</button>
</span>
</header>
<div className="nav-list">
{project.layers.map((cat) => (
<CategoryRow
key={cat.code}
category={cat}
depth={0}
mode={displayMode.mode}
activeCode={host.activeCategoryCode}
onSelect={host.onSelectCategory}
onToggle={toggle}
onContext={onRowContext}
/>
))}
</div>
</section>
);
}
/** Eine Kategorie-Zeile im Ebenen-Baum (rekursiv für Kinder). */
function CategoryRow({
category,
depth,
mode,
activeCode,
onSelect,
onToggle,
onContext,
}: {
category: LayerCategory;
depth: number;
mode: DisplayMode;
activeCode: string;
onSelect: (code: string) => void;
onToggle: (code: string) => void;
onContext: (code: string, e: React.MouseEvent) => void;
}) {
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
const isActive = category.code === activeCode;
const display = itemDisplay(mode, isActive, category.visible);
const dimmed = display.greyed || !display.render;
// In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen).
const eyeDimmed = mode === "all_force";
const cls = [
"cat-row",
isActive ? "active" : "",
display.locked ? "locked" : "",
!category.visible ? "hidden" : "",
]
.filter(Boolean)
.join(" ");
// Einrückung wie bisher; zusätzliches Dimmen aus dem DisplayMode inline.
// In „Alle anzeigen" erzwungen sichtbar → auch unsichtbare Zeilen normal.
const style: React.CSSProperties = { paddingLeft: 6 + depth * 14 };
if (dimmed) style.opacity = 0.45;
return (
<>
<div
className={cls}
style={style}
onClick={() => onSelect(category.code)}
onContextMenu={(e) => onContext(category.code, e)}
title={t("nav.categorySelect", { name: category.name })}
>
<button
className={`eye${category.visible ? " is-on" : ""}`}
aria-label={t("nav.categoryVisible", { name: category.name })}
title={category.visible ? t("nav.hide") : t("nav.show")}
style={eyeDimmed ? { opacity: 0.5 } : undefined}
onClick={(e) => {
e.stopPropagation();
onToggle(category.code);
}}
>
<EyeIcon open={category.visible} />
</button>
<span className="cat-code">{category.code}</span>
<span className="cat-swatch" style={{ background: category.color }} />
<span className="cat-name">{category.name}</span>
</div>
{category.children?.map((child) => (
<CategoryRow
key={child.code}
category={child}
depth={depth + 1}
mode={mode}
activeCode={activeCode}
onSelect={onSelect}
onToggle={onToggle}
onContext={onContext}
/>
))}
</>
);
}
+440
View File
@@ -0,0 +1,440 @@
// Inhalts-Panel „Objekt-Info" (Vectorworks-Stil) — kompakte Sicht auf die
// aktuelle Auswahl: ein 3×3-Bezugspunkt-„Würfel", die X/Y-Koordinaten des
// gewählten Bezugspunkts (read-only) sowie editierbare Maße Breite×Höhe.
//
// Bei Auswahl GENAU EINER Wand zeigt das Panel zusätzlich einen Wand-Abschnitt
// (Referenzlinie, Aufbau-Typ/Dicke/Preset, Referenzgeschoss + UK/OK) — analog
// Vectorworks. Die Referenzlinie sitzt rechts oben im Kopf.
//
// Alle Daten/Handler kommen über usePanelHost (kein Prop-Drilling, kein
// Modellzugriff). Der gewählte Bezugspunkt (fx,fy ∈ {0,0.5,1}) ist lokaler
// State und wird als Anker an onResizeSelection durchgereicht: Beim Ändern
// der Maße bleibt genau dieser Punkt fix.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import { formatM } from "../model/types";
import type { VerticalAnchor, WallReferenceLine } from "../model/types";
import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type { WallInfo } from "../state/selectionInfo";
// ── Bezugspunkt-Raster ───────────────────────────────────────────────────────
// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0).
// Reihenfolge des Arrays = Lesereihenfolge des Grids (links→rechts, oben→unten).
const FRACTIONS = [0, 0.5, 1] as const;
/** Stabiler Schlüssel je Anker (z. B. "0-0.5"), unabhängig von Float-Anzeige. */
function anchorKey(fx: number, fy: number): string {
return `${fx}-${fy}`;
}
export function ObjectInfoPanel() {
const host = usePanelHost();
const sel = host.selection;
// Gewählter Bezugspunkt — Default Mitte (Vectorworks-Standardanker beim
// Skalieren). Bleibt erhalten, solange das Panel montiert ist.
const [anchor, setAnchor] = useState<{ fx: number; fy: number }>({
fx: 0.5,
fy: 0.5,
});
// ── Leerzustand ───────────────────────────────────────────────────────────
if (sel === null) {
return (
<div className="objinfo-panel">
<div className="objinfo-empty">{t("objinfo.empty")}</div>
</div>
);
}
const { bbox } = sel;
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.
const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height;
// ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
function commitSize(nextWidth: number, nextHeight: number) {
if (!isFinite(nextWidth) || !isFinite(nextHeight)) return;
if (nextWidth < 0 || nextHeight < 0) return;
host.onResizeSelection(nextWidth, nextHeight, anchor);
}
const wall = sel.wall;
return (
<div className="objinfo-panel">
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
<div className="objinfo-head">
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
{wall ? (
<div className="objinfo-head-ref" title={t("objinfo.wall.refLine")}>
<span className="objinfo-head-ref-label">
{t("objinfo.wall.refLine")}
</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) =>
host.onSetWallReferenceLine(v as WallReferenceLine)
}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
) : (
<span className="objinfo-cat">{sel.categoryCode}</span>
)}
</div>
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */}
<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>
{/* 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>
</div>
<div className="objinfo-sep" />
{/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */}
<div className="objinfo-section-label">{t("objinfo.dimensions")}</div>
<DimensionField
label={t("objinfo.width")}
value={width}
onCommit={(w) => commitSize(w, height)}
/>
<DimensionField
label={t("objinfo.height")}
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
{wall && <WallSection wall={wall} host={host} />}
</div>
);
}
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
function WallSection({
wall,
host,
}: {
wall: WallInfo;
host: ReturnType<typeof usePanelHost>;
}) {
// Aufbau-Typ-Segment: einschichtig vs. mehrschichtig. Aus dem Modell
// abgeleitet (singleLayer); der Umschalter auf „mehrschichtig" wählt einen
// mehrschichtigen Wandtyp-Preset (erster mit >1 Schicht), auf „einschichtig"
// setzt er die Dicke des aktuellen Aufbaus als einschichtige Wand.
const isSingle = wall.singleLayer;
const multiPresets = wall.wallTypes.filter((wt) => wt.layerCount > 1);
function chooseSingle() {
if (isSingle) return;
// Auf einschichtig wechseln: aktuelle Gesamtdicke als Einzelschicht setzen.
host.onSetWallThickness(wall.thickness);
}
function chooseMulti() {
if (!isSingle) return;
const first = multiPresets[0];
if (first) host.onSetWallType(first.id);
}
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (isSingle ? " active" : "")}
onClick={chooseSingle}
>
{t("objinfo.wall.single")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (!isSingle ? " active" : "")}
onClick={chooseMulti}
disabled={isSingle && multiPresets.length === 0}
>
{t("objinfo.wall.multi")}
</button>
</div>
</div>
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */}
{isSingle ? (
<DimensionField
label={t("objinfo.wall.thickness")}
value={wall.thickness}
onCommit={(v) => host.onSetWallThickness(v)}
/>
) : (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.preset")}</span>
<Dropdown
value={wall.wallTypeId}
onChange={(id) => host.onSetWallType(id)}
options={wall.wallTypes.map((wt) => ({
value: wt.id,
label: t("objinfo.wall.presetLabel", {
name: wt.name,
thickness: wt.thickness.toFixed(2),
}),
}))}
title={t("objinfo.wall.preset")}
width={150}
/>
</div>
)}
{/* Referenzgeschoss (read-only Anzeige). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.refFloor")}</span>
<span className="objinfo-fval">{wall.floorName}</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>
<input
type="checkbox"
className="objinfo-check"
checked={wall.top?.mode === "floor"}
disabled={!wall.floorAbove}
title={
wall.floorAbove
? wall.floorAbove.name
: t("objinfo.wall.floorAboveNone")
}
onChange={(e) => {
if (e.target.checked && wall.floorAbove) {
host.onSetWallTop({ mode: "floor", floorId: wall.floorAbove.id });
} else {
host.onSetWallTop(null);
}
}}
/>
</label>
<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)}
/>
<VerticalAnchorRow
label={t("objinfo.wall.top")}
anchor={wall.top}
defaultZ={wall.zTop}
floors={wall.floors}
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)}
/>
{/* Höhe = OK UK (abgeleitet, read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.height")}</span>
<span className="objinfo-fval">{formatM(wall.zTop - wall.zBottom)}</span>
</div>
</>
);
}
// ── UK/OK-Zeile: Modus-Umschalter (Geschoss/eigene Höhe) + Feld ──────────────
// „Geschoss gebunden" → Geschoss-Dropdown; „eigene Höhe" → absolutes Z-Feld.
// `null`-Anchor (undefined im Modell) = Geschoss-Default; zeigt „eigene Höhe"
// mit dem aufgelösten Default-Z als Ausgangswert NICHT — Default ist
// „an Geschoss gebunden" (Geschoss-Default-Verhalten).
function VerticalAnchorRow({
label,
anchor,
defaultZ,
floors,
defaultFloorId,
onChange,
}: {
label: string;
anchor?: VerticalAnchor;
defaultZ: number;
floors: { id: string; name: string }[];
defaultFloorId: string;
onChange: (a: VerticalAnchor | null) => void;
}) {
const mode: "floor" | "custom" = anchor?.mode === "custom" ? "custom" : "floor";
function setMode(next: "floor" | "custom") {
if (next === mode) return;
if (next === "custom") {
onChange({ mode: "custom", z: defaultZ });
} else {
// Zurück auf Geschoss-Bindung: bei vorhandenem expliziten Geschoss dieses,
// sonst Default-Geschoss → entspricht dem Geschoss-Default-Verhalten.
onChange({ mode: "floor", floorId: defaultFloorId });
}
}
return (
<div className="objinfo-wall-anchor">
<div className="objinfo-field">
<span className="objinfo-flabel">{label}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (mode === "floor" ? " active" : "")}
onClick={() => setMode("floor")}
>
{t("objinfo.wall.anchorFloor")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (mode === "custom" ? " active" : "")}
onClick={() => setMode("custom")}
>
{t("objinfo.wall.anchorCustom")}
</button>
</div>
</div>
{mode === "floor" ? (
<div className="objinfo-field objinfo-subfield">
<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}
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>
)}
</div>
);
}
// ── Editierbares Maßfeld ─────────────────────────────────────────────────────
// Kontrolliert über den vom Host gelieferten Wert, hält aber während des
// Tippens einen lokalen Entwurf. Commit auf blur/Enter; Escape verwirft.
// Nach erfolgreichem Resize liefert der Host eine neue bbox → neuer `value` →
// das Feld zeigt automatisch die aktualisierte Größe. `min` erlaubt negative
// Werte (z. B. absolute Z-Höhen unter 0).
function DimensionField({
label,
value,
onCommit,
min = 0,
}: {
label: string;
value: number;
onCommit: (v: number) => void;
min?: number;
}) {
// Entwurf als String, damit Zwischenzustände (leer, „1.") tippbar sind.
// `null` = kein aktiver Entwurf → zeige den Host-Wert.
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? value.toFixed(3);
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v >= min) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{label}</span>
<input
className="objinfo-input"
type="number"
step={0.01}
min={isFinite(min) ? min : undefined}
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">m</span>
</label>
);
}
+67
View File
@@ -0,0 +1,67 @@
// Panel-Rahmen — Kopfzeile + scrollbarer Körper um einen Panel-Inhalt.
//
// Der Rahmen ist inhaltsneutral: er bekommt eine PanelDef (für Titel und das
// Flag hasDisplayMode) und den generischen PanelContext, mit dem er
// `def.render(ctx)` aufruft. Den eigentlichen Inhalt liefert also das Panel
// selbst; der Rahmen kümmert sich nur um Titelzeile, optionalen
// Darstellungsmodus-Umschalter und den überlaufenden, scrollbaren Körper.
//
// Der Darstellungsmodus wird von außen durchgereicht (value + onChange), damit
// der Rahmen nichts vom Host-Context wissen muss. Der Umschalter erscheint nur,
// wenn das Panel ihn deklariert (def.hasDisplayMode) UND ein onChange anliegt.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { DisplayModeSelect } from "./DisplayModeSelect";
import type { DisplayMode, PanelContext, PanelDef } from "./types";
import { t } from "../i18n";
export interface PanelFrameProps {
/** Definition des darzustellenden Panels (liefert Titel + hasDisplayMode). */
def: PanelDef;
/** Generischer Kontext, der an `def.render` weitergereicht wird. */
ctx?: PanelContext;
/**
* Aktueller Darstellungsmodus. Nur relevant, wenn das Panel
* `hasDisplayMode` deklariert; der Umschalter ist sonst ausgeblendet.
*/
displayMode?: DisplayMode;
/**
* Setter für den Darstellungsmodus. Liegt er an und deklariert das Panel
* `hasDisplayMode`, zeigt die Kopfzeile das Dropdown.
*/
onDisplayModeChange?: (mode: DisplayMode) => void;
}
/**
* Container eines einzelnen Panels: Kopfzeile (Titel + optionaler
* Darstellungsmodus-Umschalter) über einem scrollbaren Körper, der den
* gerenderten Panel-Inhalt aufnimmt.
*/
export function PanelFrame({
def,
ctx,
displayMode,
onDisplayModeChange,
}: PanelFrameProps) {
// Umschalter nur, wenn das Panel ihn will UND er verdrahtet ist.
const showMode =
Boolean(def.hasDisplayMode) &&
onDisplayModeChange !== undefined &&
displayMode !== undefined;
return (
<section className="panel-frame">
<header className="panel-head">
<span className="panel-title">{t(def.title)}</span>
{showMode && (
<DisplayModeSelect
value={displayMode as DisplayMode}
onChange={onDisplayModeChange as (m: DisplayMode) => void}
/>
)}
</header>
<div className="panel-body">{def.render(ctx ?? {})}</div>
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
// Inhalts-Panel „Ressourcen" — dünner Adapter um den ResourceManager.
//
// Wiederverwendet die bestehende <ResourceManager>-Komponente (Bauteile /
// Schraffuren / Linien) UNVERÄNDERT, rendert sie aber INLINE als Panel-Körper
// statt als rechte Schublade. Projekt + Handler kommen aus dem Host-Context.
//
// ResourceManager ist als Overlay-Schublade gebaut (.res-overlay fixed/inset:0
// → .res-drawer mit fester Breite, Rand und Schatten) und erwartet onClose.
// Da styles.css und ResourceManager selbst nicht angefasst werden dürfen,
// zähmt dieser Adapter die Schubladen-Chrome lokal: ein eingebetteter,
// gescopter <style>-Block (kein Eingriff in styles.css) löst das Overlay aus
// der Fixpositionierung, lässt die „Schublade" die Panel-Fläche füllen und
// blendet den Schließen-Knopf aus (ein Panel schließt man über den Rahmen,
// nicht über die Schublade). onClose ist daher ein No-Op.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { ResourceManager } from "../ui/ResourceManager";
import type { ResourceManagerHandlers } from "../ui/ResourceManager";
import { usePanelHost } from "./host";
/** Inhalt des Panels „Ressourcen". */
export function ResourcesPanel() {
const host = usePanelHost();
// Handler aus dem Host auf die Signatur des ResourceManager bündeln.
const handlers: ResourceManagerHandlers = {
onPatchComponent: host.onPatchComponent,
onAddComponent: host.onAddComponent,
onDeleteComponent: host.onDeleteComponent,
onPatchHatch: host.onPatchHatch,
onAddHatch: host.onAddHatch,
onDeleteHatch: host.onDeleteHatch,
onPatchLineStyle: host.onPatchLineStyle,
onAddLineStyle: host.onAddLineStyle,
onDeleteLineStyle: host.onDeleteLineStyle,
onImportLineStyles: host.onImportLineStyles,
onImportHatches: host.onImportHatches,
};
return (
<div className="panel-resources-host">
{/* Gescopte Override-Regeln: machen aus der Overlay-Schublade einen
flächenfüllenden Inline-Körper. Betrifft nur Nachfahren dieses
Wrappers — styles.css bleibt unberührt. */}
<style>{PANEL_RESOURCES_CSS}</style>
<ResourceManager
project={host.project}
handlers={handlers}
onClose={NO_OP}
/>
</div>
);
}
/** onClose-Platzhalter: als Panel gibt es keinen Schubladen-Schließer. */
const NO_OP = () => {};
/**
* Lokale Override-Regeln, die die fixe Overlay-Schublade in einen normalen,
* im Panel sitzenden Block verwandeln. Klassen-Selektoren sind unter
* `.panel-resources-host` verschachtelt, damit nichts außerhalb des Panels
* betroffen ist.
*/
const PANEL_RESOURCES_CSS = `
.panel-resources-host {
height: 100%;
min-height: 0;
display: flex;
}
.panel-resources-host .res-overlay {
position: static;
inset: auto;
z-index: auto;
background: transparent;
display: flex;
flex: 1;
min-height: 0;
justify-content: stretch;
}
.panel-resources-host .res-drawer {
width: 100%;
height: 100%;
border-left: none;
box-shadow: none;
}
/* Im Panel ohne Schubladen-Schließer. */
.panel-resources-host .res-close {
display: none;
}
`;
+125
View File
@@ -0,0 +1,125 @@
// Inhalts-Panel „Gelände & Kontext" — verwaltet die Kontext-Schicht
// (importierte Meshes, Höhenlinien-Sätze, abgeleitetes Gelände-TIN). Daten/
// Handler kommen über usePanelHost (kein Prop-Drilling).
//
// Aufbau:
// • Aktions-Zeile: „DXF importieren" (öffnet den in App montierten Datei-
// Dialog) + „Gelände erzeugen" (aus dem gewählten Kontur-Satz).
// • Liste der Kontext-Objekte: Name + Typ-Badge + „Entfernen".
// • DWG-Hinweis am Fuß.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
switch (obj.type) {
case "importedMesh":
return "site.type.mesh";
case "contourSet":
return "site.type.contours";
case "terrainMesh":
return "site.type.terrain";
}
}
export function SitePanel() {
const host = usePanelHost();
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
// Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener).
const [selectedSet, setSelectedSet] = useState<string>("");
const effectiveSet =
selectedSet && contourSets.some((c) => c.id === selectedSet)
? selectedSet
: (contourSets[0]?.id ?? "");
const onGenerate = () => {
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"));
}
};
return (
<div className="site-panel">
{/* Aktions-Zeile: Import + Gelände-Erzeugung. */}
<div className="site-actions">
<button
type="button"
className="site-btn"
onClick={() => host.onImportDxf()}
>
{t("site.import")}
</button>
</div>
{contourSets.length > 0 && (
<div className="site-terrain-row">
{contourSets.length > 1 ? (
<select
className="site-select"
value={effectiveSet}
onChange={(e) => setSelectedSet(e.target.value)}
aria-label={t("site.contourSet")}
>
{contourSets.map((cs) => (
<option key={cs.id} value={cs.id}>
{cs.name}
</option>
))}
</select>
) : (
<span className="site-terrain-src" title={t("site.contourSet")}>
{contourSets[0].name}
</span>
)}
<button type="button" className="site-btn" onClick={onGenerate}>
{t("site.generateTerrain")}
</button>
</div>
)}
<div className="site-sep" />
{/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */}
{objects.length === 0 ? (
<div className="site-empty">{t("site.empty")}</div>
) : (
<ul className="site-list">
{objects.map((obj) => (
<li key={obj.id} className="site-row">
<span className="site-name" title={obj.name}>
{obj.name}
</span>
<span className={"site-badge site-badge-" + obj.type}>
{t(typeBadgeKey(obj))}
</span>
<button
type="button"
className="site-remove"
title={t("site.delete")}
aria-label={t("site.delete")}
onClick={() => host.onRemoveContextObject(obj.id)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="site-hint">{t("site.dwgHint")}</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
// Tab-Leiste eines Docks — ein Tab je Panel-ID, Titel aus der Registry.
//
// Rein darstellend: bekommt einen DockState (Reihenfolge der Panel-IDs +
// aktiver Tab) und meldet das Greifen eines Tabs über `onStartDrag`. Der
// Drag-Controller (panelDrag) entscheidet anhand der Bewegung, ob daraus ein
// Klick (Tab aktivieren) oder ein Ziehen (Reorder/Andocken/Lösen) wird — die
// Leiste selbst kennt diese Logik nicht.
//
// Titel werden über die Registry (getPanel) aufgelöst, sodass die Leiste nichts
// über konkrete Panels weiß. IDs ohne registriertes Panel (z. B. ein
// abgeschaltetes Plugin) werden übersprungen — vgl. Hinweis in layout.ts.
//
// Datenattribute für die Drag-Trefferprüfung (panelDrag.resolveHover):
// data-tabstrip="<dock>" an der Leiste,
// data-group-index="<g>" an der Leiste (welche Gruppe im Dock),
// data-tab-index="<i>" an jedem Tab.
//
// Viele Tabs laufen nicht um, sondern werden horizontal scrollbar (die Leiste
// trägt .tabstrip mit overflow-x in styles.css). So bleibt jeder Tab erreichbar,
// ohne ein zusätzliches Überlaufmenü zu brauchen.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { getPanel } from "./registry";
import type { DockGroup, DockId } from "./types";
import { t } from "../i18n";
export interface TabStripProps {
/** Welches Dock diese Leiste darstellt (für die Drag-Auflösung). */
side: DockId;
/** Index der Gruppe im Dock (für die Drag-Auflösung). */
groupIndex: number;
/** Zustand der Gruppe (liefert tabs-Reihenfolge + activeTab). */
group: DockGroup;
/**
* Beginn einer Tab-Geste (pointerdown auf einem Tab). Der Drag-Controller
* entscheidet über die Bewegung, ob Klick oder Ziehen.
*/
onStartDrag: (panelId: string, e: React.PointerEvent) => void;
}
/**
* Reihe greifbarer Tabs für eine Dock-Gruppe. Der aktive Tab ist hervorgehoben;
* das Greifen eines Tabs meldet `onStartDrag` (Klick vs. Ziehen klärt der
* Controller).
*/
export function TabStrip({ side, groupIndex, group, onStartDrag }: TabStripProps) {
return (
<div
className="tabstrip"
role="tablist"
data-tabstrip={side}
data-group-index={groupIndex}
>
{group.tabs.map((id, index) => {
const def = getPanel(id);
// Unbekannte ID (kein registriertes Panel) überspringen.
if (!def) return null;
const active = id === group.activeTab;
const title = t(def.title);
return (
<button
key={id}
type="button"
role="tab"
aria-selected={active}
title={title}
data-tab-index={index}
className={`tab${active ? " active" : ""}`}
onPointerDown={(e) => onStartDrag(id, e)}
>
{title}
</button>
);
})}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/
// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das
// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist —
// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen.
//
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Die Werkzeug-
// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { t } from "../i18n";
import { usePanelHost } from "./host";
import type { ToolId } from "./host";
import { TOOL_ORDER, getTool } from "../tools/tools";
// ── Icons ──────────────────────────────────────────────────────────────────
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
// Zeile übernimmt.
function ToolIcon({ id }: { id: ToolId }) {
const common = {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.4,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (id) {
case "select":
// Pfeil-Cursor.
return (
<svg {...common} fill="currentColor" stroke="none">
<path d="M3 2l9 5-3.6 1.1L10.4 13 8.5 13.7 6.6 9.4 3 11z" />
</svg>
);
case "wall":
// Zwei parallele Linien (Wandband).
return (
<svg {...common}>
<line x1="2" y1="5.5" x2="14" y2="5.5" />
<line x1="2" y1="10.5" x2="14" y2="10.5" />
</svg>
);
case "line":
// Einzelne Diagonale.
return (
<svg {...common}>
<line x1="3" y1="13" x2="13" y2="3" />
</svg>
);
case "polyline":
// Dreisegmentiger Zickzack.
return (
<svg {...common}>
<polyline points="2,12 6,5 10,11 14,4" />
</svg>
);
case "rect":
// Rechteck-Umriss.
return (
<svg {...common}>
<rect x="2.5" y="3.5" width="11" height="9" />
</svg>
);
default:
return null;
}
}
// ── Panel ────────────────────────────────────────────────────────────────────
export function ToolsPanel() {
const host = usePanelHost();
const snap = host.snap;
return (
<div className="tools-panel">
{/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */}
{TOOL_ORDER.map((id) => {
const tool = getTool(id);
const active = host.activeTool === id;
const disabled = id !== "select" && !host.toolsEnabled;
const title = disabled
? t("tool.floorOnlyDisabled")
: t(tool.hintKey(tool.init()));
const cls =
"tool-row" + (active ? " active" : "") + (disabled ? " disabled" : "");
return (
<button
key={id}
type="button"
className={cls}
title={title}
disabled={disabled}
onClick={() => host.onSelectTool(id)}
>
<ToolIcon id={id} />
<span>{t(tool.labelKey)}</span>
</button>
);
})}
<div className="tools-sep" />
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */}
{host.activeTool === "wall" && (
<label className="tools-field">
<span>{t("tool.wallType")}</span>
<select
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(e) => host.onActiveWallTypeId(e.target.value)}
>
{host.project.wallTypes.map((wt) => (
<option key={wt.id} value={wt.id}>
{wt.name}
</option>
))}
</select>
</label>
)}
{host.activeTool === "wall" && <div className="tools-sep" />}
{/* Fang-Abschnitt. Master-Checkbox steuert die übrigen Fang-Optionen. */}
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, enabled: e.target.checked })}
/>
<span>{t("snap.enabled")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.endpoint}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, endpoint: e.target.checked })}
/>
<span>{t("snap.endpoint")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.midpoint}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, midpoint: e.target.checked })}
/>
<span>{t("snap.midpoint")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.intersection}
disabled={!snap.enabled}
onChange={(e) =>
host.onSnapChange({ ...snap, intersection: e.target.checked })
}
/>
<span>{t("snap.intersection")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.onEdge}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, onEdge: e.target.checked })}
/>
<span>{t("snap.onEdge")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.grid}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, grid: e.target.checked })}
/>
<span>{t("snap.grid")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.ortho}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, ortho: e.target.checked })}
/>
<span>{t("snap.ortho")}</span>
</label>
<label className="tools-field">
<span>{t("snap.gridSize")}</span>
<input
type="number"
step={0.05}
min={0.01}
value={snap.gridSize}
onChange={(e) =>
host.onSnapChange({ ...snap, gridSize: Number(e.target.value) })
}
/>
</label>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
// Registrierung der eingebauten Inhalts-Panels.
//
// Importiert man dieses Modul (Seiteneffekt), tragen sich die beiden
// dockbaren Kern-Panels in die Registry ein und tauchen damit in den Docks auf:
// • "drawing-levels" — Zeichnungsebenen (mit Darstellungsmodus).
// • "layers" — Ebenen (Kategorie-Baum, mit Darstellungsmodus).
//
// „Ressourcen" ist KEIN Dock-Panel mehr: der ResourceManager ist ein
// schwebendes Fenster (Overlay über allem), das die Oberleiste öffnet — siehe
// App.tsx. Es wird daher hier nicht (mehr) registriert.
//
// Reine Verdrahtung: jedes Panel liest seinen Zustand selbst über den
// Host-Context (usePanelHost), daher reicht render() ohne ctx-Auswertung.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { registerPanel } from "./registry";
import { DrawingLevelsPanel } from "./DrawingLevelsPanel";
import { LayersPanel } from "./LayersPanel";
import { ToolsPanel } from "./ToolsPanel";
import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel";
/** IDs der eingebauten Dock-Panels (auch für defaultLayout/Filter nutzbar). */
export const BUILTIN_PANEL_IDS = {
tools: "tools",
drawingLevels: "drawing-levels",
layers: "layers",
attributes: "attributes",
objectInfo: "object-info",
site: "site",
} as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
registerPanel({
id: BUILTIN_PANEL_IDS.tools,
title: "nav.tools",
hasDisplayMode: false,
render: () => <ToolsPanel />,
});
// Attribute-Palette (Stift/Füllung/Strichstärke der Auswahl, Vectorworks-Stil).
registerPanel({
id: BUILTIN_PANEL_IDS.attributes,
title: "nav.attributes",
hasDisplayMode: false,
render: () => <AttributesPanel />,
});
// Objekt-Info-Palette (Bezugspunkt-Würfel + X/Y, Maße B×H der Auswahl).
registerPanel({
id: BUILTIN_PANEL_IDS.objectInfo,
title: "nav.objectInfo",
hasDisplayMode: false,
render: () => <ObjectInfoPanel />,
});
registerPanel({
id: BUILTIN_PANEL_IDS.drawingLevels,
// i18n-Key statt fertigem Text: TabStrip/PanelFrame lösen ihn über t() auf,
// damit der Titel der gewählten Sprache folgt.
title: "nav.drawingLevels",
hasDisplayMode: true,
render: () => <DrawingLevelsPanel />,
});
registerPanel({
id: BUILTIN_PANEL_IDS.layers,
title: "nav.layers",
hasDisplayMode: true,
render: () => <LayersPanel />,
});
// Gelände-/Kontext-Palette (DXF-Import, Konturen-Liste, Gelände-Erzeugung).
registerPanel({
id: BUILTIN_PANEL_IDS.site,
title: "nav.site",
hasDisplayMode: false,
render: () => <SitePanel />,
});
+69
View File
@@ -0,0 +1,69 @@
// Darstellungsmodus — reiner Helfer.
//
// Übersetzt einen DisplayMode plus den Status eines Elements (aktiv? sichtbar?)
// in eine Render-Entscheidung: ob das Element gezeichnet wird, ob es ausgegraut
// erscheint und ob es gesperrt ist. Dieselbe Logik nutzen Navigator-Listen und
// die abgeleiteten Ansichten (3D, Grundriss), damit die Auswahl überall
// identisch wirkt. Die fünf Modi entsprechen DOSSIER (docs/design/context-menu.md).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Bewusst seiteneffektfrei.
import type { DisplayMode } from "./types";
/** Ergebnis von `itemDisplay`: zeichnen? ausgegraut? gesperrt? */
export interface ItemDisplay {
/** Element zeichnen. */
render: boolean;
/** Element ausgegraut (gedimmt) darstellen. */
greyed: boolean;
/** Element gesperrt (nicht bearbeitbar) — nur im Modus „grey_locked". */
locked: boolean;
}
/**
* Entscheidet anhand des Modus und des Element-Status, wie ein Element
* dargestellt wird — die fünf DOSSIER-Modi:
*
* • "all_force" — alle erzwungen zeichnen (Sichtbarkeits-Flag ignoriert),
* nie ausgegraut.
* • "all" — nur sichtbare zeichnen, nie ausgegraut.
* • "active" — nur das aktive Element zeichnen, nie ausgegraut.
* • "grey" — aktives normal; übrige nur, wenn sichtbar — dann
* ausgegraut.
* • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich
* gesperrt.
*
* Das aktive Element wird in "active"/"grey"/"grey_locked" stets gezeichnet,
* auch wenn seine Sichtbarkeit abgeschaltet ist — es ist der Arbeitsfokus. Ein
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie
* gezeichnet.
*/
export function itemDisplay(
mode: DisplayMode,
isActive: boolean,
isVisible: boolean,
): ItemDisplay {
switch (mode) {
case "all_force":
// Alles erzwungen sichtbar (Flags ignoriert), normal.
return { render: true, greyed: false, locked: false };
case "all":
return { render: isVisible, greyed: false, locked: false };
case "active":
// Nur das aktive Element; andere werden nicht gezeichnet.
return { render: isActive, greyed: false, locked: false };
case "grey": {
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: other, locked: false };
}
case "grey_locked": {
// Wie „grey", übrige sichtbare zusätzlich gesperrt.
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: other, locked: other };
}
}
}
+197
View File
@@ -0,0 +1,197 @@
// Host-Vertrag für Inhalts-Panels — die konkrete Form des PanelHostContext.
//
// Die Foundation (types.ts) hält den Context bewusst lose (Record<string,
// unknown>), damit der Kern nicht vom Projekt-/Handler-Modell abhängt. Die
// eingebauten Panels brauchen jedoch eine klare, getippte Sicht auf den Host.
// Dieses Modul definiert daher EINEN Vertrag (`PanelHostValue`) und einen Hook
// (`usePanelHost`), der den Context liest, gegen das Fehlen eines Providers
// absichert und das Ergebnis auf diesen Vertrag verengt.
//
// App stellt einen Wert dieser Form über <PanelHostContext.Provider> bereit
// (siehe Report am Ende der Aufgabe). Bezeichner englisch, Kommentare deutsch
// (CONVENTIONS.md).
import { useContext } from "react";
import { PanelHostContext } from "./types";
import type { DisplayMode } from "./types";
import type {
Component,
ContextObject,
DrawingLevel,
HatchStyle,
LayerCategory,
LineStyle,
Project,
VerticalAnchor,
WallReferenceLine,
} from "../model/types";
import type { SnapSettings, ToolId } from "../tools/types";
import type { Selection } from "../state/selectionInfo";
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
/**
* Aktueller Darstellungsmodus plus Setter. Panels, die `hasDisplayMode: true`
* deklarieren, lesen `mode` und gruppieren ihre Zeilen danach (über
* `itemDisplay` aus displayMode.ts). Der Umschalter selbst sitzt in der
* Panel-Kopfzeile des Rahmens und ruft `setMode`.
*/
export interface DisplayModeControl {
mode: DisplayMode;
setMode: (mode: DisplayMode) => void;
}
// ── Vollständiger Host-Vertrag ─────────────────────────────────────────────
/**
* Was die eingebauten Panels vom Host erwarten. App reicht genau dieses Objekt
* über <PanelHostContext.Provider value={…}> hinein; jedes Feld entspricht
* einem Stück Zustand bzw. einem immutablen Handler, der heute in App.tsx als
* Closure über setProject existiert.
*/
export interface PanelHostValue {
// Projektzustand (Single Source of Truth aus App).
project: Project;
// Aktive Auswahl (Zeichnungsebene) — Arbeitsfokus für „active"/„grey".
activeLevelId: string;
// ── Werkzeug-Palette (Tools-Panel) ──────────────────────────────────────
/** Aktives Zeichenwerkzeug. */
activeTool: ToolId;
/** Werkzeug wählen. */
onSelectTool: (id: ToolId) => void;
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
toolsEnabled: boolean;
/** Aktiver Wandtyp (für das Wand-Werkzeug). */
activeWallTypeId: string;
onActiveWallTypeId: (id: string) => void;
/** Snap-Einstellungen + Setter (Fang-Optionen in der Werkzeug-Palette). */
snap: SnapSettings;
onSnapChange: (s: SnapSettings) => void;
// Darstellungsmodus für ortsabhängige Panels (DrawingLevels, Layers).
displayMode: DisplayModeControl;
// Zeichnungsebenen-Handler.
onSelectLevel: (id: string) => void;
onToggleLevel: (id: string) => void;
onAddFloor: () => void;
onAddDrawing: () => void;
/**
* Rechtsklick auf eine Zeichnungsebenen-Zeile: öffnet das Kontextmenü an der
* Cursorposition. App hält das offene Menü und baut die Einträge
* (Einstellungen / Duplizieren / Löschen).
*/
onLevelContextMenu: (id: string, clientX: number, clientY: number) => void;
// Ebenen-(Kategorie-)Handler.
/** Aktive Ebene (Kategorie-Code) — Ziel neuer Zeichnungen; per Klick wählbar. */
activeCategoryCode: string;
/** Eine Ebene zur aktiven machen (Klick auf die Zeile im Ebenen-Panel). */
onSelectCategory: (code: string) => void;
onToggleCategory: (code: string) => void;
onAddCategory: () => void;
/**
* Rechtsklick auf eine Ebenen-(Kategorie-)Zeile: öffnet das Kontextmenü an der
* Cursorposition. App baut die Einträge (Einstellungen / Sub-Ebene /
* Selektion übertragen / Duplizieren / Eigenschaften kopieren+einfügen /
* Löschen) und hält das offene Menü.
*/
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
// der UI-Komponente abhängt.
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Import fertiger (id-loser) Linienstile/Schraffuren (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
// ── Selektions-Attribute (Attributes-/Object-Info-Paletten) ─────────────
/**
* Normalisierte, effektiv aufgelöste Sicht auf das ERSTE selektierte Element
* (oder `null`). Die Paletten lesen daraus Farbe/Strichstärke/Füllung/bbox
* und grauen nicht-anwendbare Felder per `selection.kind` aus.
*/
selection: Selection | null;
/** Setzt die (Strich-)Farbe der aktuellen Selektion (Wand oder Drawing2D). */
onSetSelectionColor: (color: string) => void;
/** Setzt die Strichstärke (mm) — wirkt NUR auf Drawing2D (sonst No-op). */
onSetSelectionWeight: (weightMm: number) => void;
/** Setzt/entfernt die Füllschraffur — wirkt NUR auf Drawing2D (sonst No-op). */
onSetSelectionFill: (hatchId: string | null) => void;
/**
* Setzt/entfernt die Vollton-Füllfarbe — wirkt NUR auf Drawing2D (sonst No-op).
* `null` entfernt die Füllfarbe (Fläche wieder transparent).
*/
onSetSelectionFillColor: (color: string | null) => void;
/**
* Skaliert die Selektion auf Zielbreite×-höhe (Meter) um einen Anker
* (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links … 1,1 = unten-rechts).
*/
onResizeSelection: (
w: number,
h: number,
anchor: { fx: number; fy: 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;
/** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */
onSetWallType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
onSetWallThickness: (thickness: number) => void;
/** Setzt/entfernt die UK-Bindung (`null` = Geschoss-Default). */
onSetWallBottom: (anchor: VerticalAnchor | null) => void;
/** Setzt/entfernt die OK-Bindung (`null` = UK + height). */
onSetWallTop: (anchor: VerticalAnchor | null) => void;
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[];
/** Fügt mehrere Kontext-Objekte hinzu (z. B. aus einem DXF-Import). */
onAddContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID. */
onRemoveContextObject: (id: string) => void;
/**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue
* TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN).
*/
onGenerateTerrain: (contourSetId: string) => string | null;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void;
}
// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so
// importieren Panels nur aus „./host" und bleiben auf einen Pfad fokussiert.
export type { DrawingLevel, LayerCategory };
export type { SnapSettings, ToolId };
export type { Selection } from "../state/selectionInfo";
// ── Hook ───────────────────────────────────────────────────────────────────
/**
* Liest den Host-Context und verengt ihn auf `PanelHostValue`. Wirft, wenn das
* Panel außerhalb eines Providers gerendert wird — das ist immer ein
* Programmierfehler im Rahmen und soll laut scheitern statt still „leer"
* anzuzeigen.
*/
export function usePanelHost(): PanelHostValue {
const host = useContext(PanelHostContext);
if (host === null) {
throw new Error(
"usePanelHost: außerhalb von <PanelHostContext.Provider> verwendet.",
);
}
return host as unknown as PanelHostValue;
}
+551
View File
@@ -0,0 +1,551 @@
// Panel-Layout — Standard + Persistenz (localStorage).
//
// Verwaltet den LayoutState (welche Panels in welchem Dock, aktiver Tab,
// Größen). Drei Ebenen:
// 1. defaultLayout() — der eingebaute Startzustand.
// 2. loadLayout()/saveLayout() — das zuletzt benutzte Layout (1 Slot).
// 3. saveNamedLayout()/… und Co. — benannte Layouts (Arbeitsbereiche).
//
// Sämtlicher localStorage-Zugriff ist in try/catch gekapselt und prüft, ob
// `localStorage` überhaupt existiert (SSR/Tests/Privatmodus) — Lesefehler
// liefern den Standard, Schreibfehler werden still geschluckt.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
DockGroup,
DockId,
DockState,
FloatingPanel,
LayoutState,
} from "./types";
/**
* Aktuelle Layout-Schema-Version (bei Strukturänderungen erhöhen). v2: rechtes
* Dock standardmäßig leer, „Ressourcen" nun schwebendes Fenster statt Dock-
* Panel. v3: schwebende Panels (`floating`). v4: Werkzeug-Palette als Kern-
* Panel. v5: GESTAPELTE GRUPPEN — ein Dock hält eine Liste vertikaler Gruppen
* (je eigener Tab-Stapel + aktiver Tab + Höhen-Gewicht) statt eines einzigen
* Tab-Stapels. v6: Attribute- + Objekt-Info-Palette im Default-Layout
* (Vectorworks-Bild). v7: Gelände-/Kontext-Palette („site") als weiterer Tab in
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
*/
export const LAYOUT_VERSION = 7;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout";
/** Präfix der localStorage-Schlüssel benannter Layouts: `cad.layouts.<name>`. */
const NAMED_PREFIX = "cad.layouts.";
/**
* Konventionelle IDs der Kern-Panels für den Standard (entsprechen den IDs in
* builtinPanels: "drawing-levels", "layers"). Sind diese Panels nicht
* registriert (Plugin aus, Test), sollte der Host die IDs gegen die Registry
* filtern (siehe `hasPanel`); das Layout selbst bleibt registry-unabhängig.
*
* Das rechte Dock ist standardmäßig LEER: „Ressourcen" ist kein Dock-Panel
* mehr, sondern ein schwebendes Fenster (ResourceManager), das über die
* Oberleiste geöffnet wird. Ein leeres Dock wird nicht gerendert, sodass die
* Mitte (Grundriss/Perspektive) die Breite voll nutzt.
*/
/**
* Standard-Gruppen je Dock-Seite (oben → unten) — das Vectorworks-Bild.
* Links: Werkzeug-Palette OBEN, Attribute-Palette UNTEN. Rechts: Objekt-Info
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
*/
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info"], activeTab: "object-info", weight: 1 },
{
tabs: ["drawing-levels", "layers", "site"],
activeTab: "drawing-levels",
weight: 1.5,
},
];
/** Standardbreiten der Docks in CSS-Pixeln. */
const DEFAULT_LEFT_SIZE = 280;
const DEFAULT_RIGHT_SIZE = 340;
/** Höhen-Gewicht einer neu erzeugten Gruppe (Mittel der vorhandenen, sonst 1). */
function newGroupWeight(groups: DockGroup[]): number {
if (groups.length === 0) return 1;
return groups.reduce((s, g) => s + g.weight, 0) / groups.length;
}
/** Tiefe (frische Objekte) Kopie einer Gruppenliste. */
function cloneGroups(groups: DockGroup[]): DockGroup[] {
return groups.map((g) => ({ tabs: [...g.tabs], activeTab: g.activeTab, weight: g.weight }));
}
// ── Standard ────────────────────────────────────────────────────────────────
/**
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben,
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
*/
export function defaultLayout(): LayoutState {
return {
left: { groups: cloneGroups(DEFAULT_LEFT_GROUPS), size: DEFAULT_LEFT_SIZE },
right: { groups: cloneGroups(DEFAULT_RIGHT_GROUPS), size: DEFAULT_RIGHT_SIZE },
floating: [],
version: LAYOUT_VERSION,
};
}
// ── Mutations-Helfer (rein, immutabel) ──────────────────────────────────────
//
// Alle Helfer liefern ein NEUES LayoutState und mutieren die Eingabe nicht.
// Sie wahren die Invariante „jedes Panel an genau einer Stelle": ein Panel wird
// zuerst überall entfernt (beide Docks + floating), dann am Ziel eingefügt. Sie
// kennen die Registry NICHT — Aufrufer reichen gültige Panel-IDs herein.
/** Der gegenüberliegende Dock (für Aufräum-Logik). */
function otherDock(dock: DockId): DockId {
return dock === "left" ? "right" : "left";
}
/**
* Entfernt eine Panel-ID aus einer Gruppe (immutabel) oder liefert die Gruppe
* unverändert zurück, wenn sie das Panel nicht enthält. Wird das Panel der
* aktive Tab, rückt `activeTab` auf den nächstgelegenen Nachbarn (rechts, sonst
* links); ist die Gruppe danach leer, wird `activeTab` `null`.
*/
function removeFromGroup(group: DockGroup, panelId: string): DockGroup {
const i = group.tabs.indexOf(panelId);
if (i < 0) return group;
const tabs = group.tabs.filter((id) => id !== panelId);
let activeTab = group.activeTab;
if (activeTab === panelId) activeTab = tabs[i] ?? tabs[i - 1] ?? null;
return { ...group, tabs, activeTab };
}
/**
* Entfernt eine Panel-ID aus allen Gruppen eines Docks und wirft die dadurch
* leer gewordenen Gruppen weg (immutabel). Dock-Breite bleibt erhalten.
*/
function removeFromDock(dock: DockState, panelId: string): DockState {
const groups = dock.groups
.map((g) => removeFromGroup(g, panelId))
.filter((g) => g.tabs.length > 0);
if (groups.length === dock.groups.length && groups.every((g, i) => g === dock.groups[i])) {
return dock; // nichts geändert
}
return { ...dock, groups };
}
/**
* Entfernt eine Panel-ID überall (beide Docks + floating). Basis für alle
* „verschiebe nach …"-Helfer, damit die Eindeutigkeits-Invariante hält.
*/
function detach(layout: LayoutState, panelId: string): LayoutState {
return {
...layout,
left: removeFromDock(layout.left, panelId),
right: removeFromDock(layout.right, panelId),
floating: layout.floating.filter((f) => f.panelId !== panelId),
};
}
/** Höchster vergebener z-Index der schwebenden Panels (0, wenn keine). */
function maxZ(floating: FloatingPanel[]): number {
return floating.reduce((m, f) => Math.max(m, f.z), 0);
}
/**
* Verschiebt ein Panel in eine BESTEHENDE Gruppe eines Docks und macht es dort
* aktiv (Tab-Stapel zusammenlegen). Es wird zuvor überall entfernt. `tabIndex`
* bestimmt die Einfügeposition in der Gruppe; fehlt er, wird angehängt. Liegt
* `groupIndex` außerhalb (z. B. weil das Detachen eine Gruppe entfernt hat),
* wird auf die letzte Gruppe geklemmt; gibt es danach keine Gruppe, wird eine
* neue angelegt.
*/
export function moveToGroup(
layout: LayoutState,
panelId: string,
dock: DockId,
groupIndex: number,
tabIndex?: number,
): LayoutState {
const detached = detach(layout, panelId);
const target = detached[dock];
if (target.groups.length === 0) {
return moveToNewGroup(detached, panelId, dock, 0);
}
const gi = Math.max(0, Math.min(groupIndex, target.groups.length - 1));
const groups = target.groups.map((g, i) => {
if (i !== gi) return g;
const at =
tabIndex === undefined
? g.tabs.length
: Math.max(0, Math.min(tabIndex, g.tabs.length));
const tabs = [...g.tabs.slice(0, at), panelId, ...g.tabs.slice(at)];
return { ...g, tabs, activeTab: panelId };
});
return { ...detached, [dock]: { ...target, groups } };
}
/**
* Legt ein Panel als NEUE Gruppe (eigener Stapel) an Position `atIndex` im Dock
* an (für gestapelte Paletten). Es wird zuvor überall entfernt. `atIndex` wird
* auf [0, groups.length] geklemmt; das neue Gewicht ist das Mittel der
* vorhandenen Gruppen, damit der Stapel ausgewogen startet.
*/
export function moveToNewGroup(
layout: LayoutState,
panelId: string,
dock: DockId,
atIndex: number,
): LayoutState {
const detached = detach(layout, panelId);
const target = detached[dock];
const at = Math.max(0, Math.min(atIndex, target.groups.length));
const group: DockGroup = {
tabs: [panelId],
activeTab: panelId,
weight: newGroupWeight(target.groups),
};
const groups = [...target.groups.slice(0, at), group, ...target.groups.slice(at)];
return { ...detached, [dock]: { ...target, groups } };
}
/**
* Rückwärtskompatible Hülle: verschiebt ein Panel in ein Dock, indem es als
* neue Gruppe ans Ende gehängt wird (das natürliche Ziel beim Andocken an die
* Rand-Zone oder beim Redock eines schwebenden Fensters).
*/
export function moveToDock(
layout: LayoutState,
panelId: string,
dock: DockId,
): LayoutState {
return moveToNewGroup(layout, panelId, dock, layout[dock].groups.length);
}
/**
* Macht ein Panel schwebend (frei positioniert). Es wird zuvor aus jedem Dock
* entfernt. `rect` setzt Position/Größe; das Panel kommt nach vorne (z über
* allen anderen). War es bereits schwebend, wird sein Eintrag durch den neuen
* (mit `rect` und Front-z) ersetzt.
*/
export function moveToFloat(
layout: LayoutState,
panelId: string,
rect: { x: number; y: number; w: number; h: number },
): LayoutState {
const detached = detach(layout, panelId);
const z = maxZ(detached.floating) + 1;
const panel: FloatingPanel = { panelId, ...rect, z };
return { ...detached, floating: [...detached.floating, panel] };
}
/**
* Ordnet einen Tab innerhalb EINER Gruppe um (immutabel). Bewegt den Tab an
* `fromIndex` vor den Tab, der nach dem Entfernen an `toIndex` steht. Indizes
* außerhalb des Bereichs werden geklemmt; ist nichts zu tun, kommt das Layout
* unverändert zurück. Der aktive Tab bleibt erhalten (folgt seiner ID).
*/
export function reorderInGroup(
layout: LayoutState,
dock: DockId,
groupIndex: number,
fromIndex: number,
toIndex: number,
): LayoutState {
const d = layout[dock];
const g = d.groups[groupIndex];
if (!g) return layout;
const n = g.tabs.length;
if (n === 0) return layout;
const from = Math.max(0, Math.min(fromIndex, n - 1));
const to = Math.max(0, Math.min(toIndex, n - 1));
if (from === to) return layout;
const tabs = [...g.tabs];
const [moved] = tabs.splice(from, 1);
tabs.splice(to, 0, moved);
const groups = d.groups.map((gr, i) => (i === groupIndex ? { ...gr, tabs } : gr));
return { ...layout, [dock]: { ...d, groups } };
}
/**
* Verteilt die Höhen-Gewichte der Gruppen eines Docks neu (immutabel) — vom
* Gruppen-Splitter aufgerufen. `weights` muss so lang sein wie `groups`; zu
* kleine/0-Werte werden auf ein Minimum angehoben, damit keine Gruppe
* verschwindet.
*/
export function setGroupWeights(
layout: LayoutState,
dock: DockId,
weights: number[],
): LayoutState {
const d = layout[dock];
if (weights.length !== d.groups.length) return layout;
const groups = d.groups.map((g, i) => ({ ...g, weight: Math.max(0.05, weights[i]) }));
return { ...layout, [dock]: { ...d, groups } };
}
/**
* Aktualisiert ein schwebendes Panel (Position/Größe/z) per Teil-Patch. Ein
* `z`-Wert im Patch wird übernommen wie gegeben (für gezieltes Setzen); zum
* Nach-vorne-Holen siehe `bringToFront`. Unbekannte ID → Layout unverändert.
*/
export function updateFloat(
layout: LayoutState,
panelId: string,
partial: Partial<Omit<FloatingPanel, "panelId">>,
): LayoutState {
let changed = false;
const floating = layout.floating.map((f) => {
if (f.panelId !== panelId) return f;
changed = true;
return { ...f, ...partial };
});
return changed ? { ...layout, floating } : layout;
}
/**
* Holt ein schwebendes Panel nach vorne (höchster z-Index). Ist es bereits
* vorne (oder unbekannt), kommt das Layout unverändert zurück.
*/
export function bringToFront(layout: LayoutState, panelId: string): LayoutState {
const top = maxZ(layout.floating);
const current = layout.floating.find((f) => f.panelId === panelId);
if (!current || current.z === top) return layout;
return updateFloat(layout, panelId, { z: top + 1 });
}
/**
* Entfernt ein schwebendes Panel aus `floating` (immutabel). Damit ist das
* Panel an KEINER Stelle mehr — Aufrufer, die es nicht schließen, sondern
* andocken wollen, nutzen `moveToDock`.
*/
export function removeFloat(layout: LayoutState, panelId: string): LayoutState {
const floating = layout.floating.filter((f) => f.panelId !== panelId);
if (floating.length === layout.floating.length) return layout;
return { ...layout, floating };
}
/**
* Entfernt ein Panel aus einem Dock (immutabel). Dünne, benannte Hülle um
* `removeFromDock` auf Layout-Ebene; nützlich, um ein Panel ganz zu schließen.
*/
export function removeFromDockLayout(
layout: LayoutState,
dock: DockId,
panelId: string,
): LayoutState {
const next = removeFromDock(layout[dock], panelId);
if (next === layout[dock]) return layout;
return { ...layout, [dock]: next };
}
/**
* Setzt das Panel als aktiven Tab innerhalb seiner Gruppe (immutabel). Sucht
* die Gruppe im Dock, die das Panel enthält, und macht es dort aktiv. Liegt die
* ID in keiner Gruppe des Docks (oder ist sie schon aktiv), kommt das Layout
* unverändert zurück.
*/
export function setActiveTab(
layout: LayoutState,
dock: DockId,
panelId: string,
): LayoutState {
const d = layout[dock];
const gi = d.groups.findIndex((g) => g.tabs.includes(panelId));
if (gi < 0 || d.groups[gi].activeTab === panelId) return layout;
const groups = d.groups.map((g, i) => (i === gi ? { ...g, activeTab: panelId } : g));
return { ...layout, [dock]: { ...d, groups } };
}
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
export { otherDock };
// ── localStorage-Sicherung ──────────────────────────────────────────────────
/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */
function storage(): Storage | null {
try {
if (typeof localStorage === "undefined") return null;
return localStorage;
} catch {
// Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus).
return null;
}
}
/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */
function readJson<T>(key: string): T | null {
const ls = storage();
if (!ls) return null;
try {
const raw = ls.getItem(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */
function writeJson(key: string, value: unknown): boolean {
const ls = storage();
if (!ls) return false;
try {
ls.setItem(key, JSON.stringify(value));
return true;
} catch {
// Kontingent überschritten oder Zugriff verweigert — still ignorieren.
return false;
}
}
// ── Validierung / Migration ─────────────────────────────────────────────────
/** Typwächter für eine DockGroup (toleriert Fremddaten aus localStorage). */
function isDockGroup(v: unknown): v is DockGroup {
if (typeof v !== "object" || v === null) return false;
const g = v as Record<string, unknown>;
return (
Array.isArray(g.tabs) &&
g.tabs.every((t) => typeof t === "string") &&
(g.activeTab === null || typeof g.activeTab === "string") &&
typeof g.weight === "number"
);
}
/** Typwächter für einen DockState (toleriert Fremddaten aus localStorage). */
function isDockState(v: unknown): v is DockState {
if (typeof v !== "object" || v === null) return false;
const d = v as Record<string, unknown>;
return (
Array.isArray(d.groups) &&
d.groups.every(isDockGroup) &&
typeof d.size === "number"
);
}
/** Typwächter für einen FloatingPanel (toleriert Fremddaten aus localStorage). */
function isFloatingPanel(v: unknown): v is FloatingPanel {
if (typeof v !== "object" || v === null) return false;
const f = v as Record<string, unknown>;
return (
typeof f.panelId === "string" &&
typeof f.x === "number" &&
typeof f.y === "number" &&
typeof f.w === "number" &&
typeof f.h === "number" &&
typeof f.z === "number"
);
}
/**
* Prüft eine geladene Struktur und führt sie bei Bedarf auf ein gültiges
* Layout zurück. Beschädigte Daten ergeben den Standard (konservativ statt
* Absturz).
*
* MIGRATION: Ältere Layouts (Version < aktuell) ohne `floating` werden
* angehoben, statt verworfen — die Docks bleiben erhalten, `floating` wird zu
* `[]`. So überlebt das zuletzt benutzte Layout den Umbau auf schwebende
* Panels. Unbekannte/zukünftige (zu hohe) Versionen ergeben den Standard.
*/
function normalizeLayout(v: unknown): LayoutState {
if (typeof v !== "object" || v === null) return defaultLayout();
const o = v as Record<string, unknown>;
// Andere Version (älter ODER neuer) → auf den aktuellen Standard zurückfallen.
// So erscheinen bei Schema-/Default-Änderungen (z. B. neues Kern-Panel
// „tools") die neuen Standard-Tabs, statt dass ein altes Layout sie verdeckt.
if (typeof o.version !== "number" || o.version !== LAYOUT_VERSION) {
return defaultLayout();
}
if (!isDockState(o.left) || !isDockState(o.right)) return defaultLayout();
// floating fehlt in v2 → leeres Array; sonst nur gültige Einträge übernehmen.
const floating = Array.isArray(o.floating)
? o.floating.filter(isFloatingPanel)
: [];
return {
left: o.left,
right: o.right,
floating,
version: LAYOUT_VERSION,
};
}
// ── Aktuelles Layout (1 Slot) ───────────────────────────────────────────────
/**
* Lädt das zuletzt benutzte Layout. Fehlt es, ist es beschädigt oder ist
* localStorage nicht verfügbar, kommt `defaultLayout()` zurück.
*/
export function loadLayout(): LayoutState {
const raw = readJson<unknown>(CURRENT_KEY);
if (raw === null) return defaultLayout();
return normalizeLayout(raw);
}
/** Speichert das aktuelle Layout. Liefert `true` bei Erfolg. */
export function saveLayout(layout: LayoutState): boolean {
return writeJson(CURRENT_KEY, layout);
}
// ── Benannte Layouts (Arbeitsbereiche) ──────────────────────────────────────
/** Baut den localStorage-Schlüssel für ein benanntes Layout. */
function namedKey(name: string): string {
return NAMED_PREFIX + name;
}
/** Speichert ein Layout unter einem Namen. Liefert `true` bei Erfolg. */
export function saveNamedLayout(name: string, layout: LayoutState): boolean {
if (!name) return false;
return writeJson(namedKey(name), layout);
}
/**
* Lädt ein benanntes Layout. Fehlt es oder ist es beschädigt, kommt `null`
* zurück (anders als `loadLayout`, damit Aufrufer „nicht gefunden" erkennen).
*/
export function loadNamedLayout(name: string): LayoutState | null {
const raw = readJson<unknown>(namedKey(name));
if (raw === null) return null;
return normalizeLayout(raw);
}
/**
* Listet die Namen aller gespeicherten benannten Layouts (alphabetisch
* sortiert). Leer, wenn localStorage nicht verfügbar ist.
*/
export function listNamedLayouts(): string[] {
const ls = storage();
if (!ls) return [];
const names: string[] = [];
try {
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key && key.startsWith(NAMED_PREFIX)) {
names.push(key.slice(NAMED_PREFIX.length));
}
}
} catch {
return [];
}
return names.sort();
}
/** Löscht ein benanntes Layout. Liefert `true`, wenn der Zugriff gelang. */
export function deleteNamedLayout(name: string): boolean {
const ls = storage();
if (!ls) return false;
try {
ls.removeItem(namedKey(name));
return true;
} catch {
return false;
}
}
+304
View File
@@ -0,0 +1,304 @@
// Panel-Tab-Drag — die Steuerung für „Tab greifen → Reorder / Andocken / Lösen".
//
// Eine Geste beginnt am TAB selbst (pointerdown). Unter dem Schwellwert (~5 px)
// gilt sie als Klick (Tab aktivieren); darüber wird sie zum Ziehen. Während des
// Ziehens folgt ein kleines Geist-Etikett dem Cursor, und gültige Ablageziele
// werden hervorgehoben:
// • über einer Tab-Leiste (links/rechts) → REORDER bzw. Verschieben an den
// ermittelten Einfügeindex (Caret zwischen den Tabs);
// • über einer Rand-Andock-Zone (links/rechts) → in dieses Dock andocken;
// • sonst über der Arbeitsfläche → an der Cursorposition LÖSEN (schweben).
//
// Die Auflösung läuft rein über DOM-Datenattribute, die TabStrip/Dock setzen:
// data-tabstrip="<dock>" + data-group-index an der Tab-Leiste einer Gruppe,
// data-tab-index="<i>" an jedem Tab-Knopf,
// data-group-body + data-dock + data-group-index am Körper einer Gruppe,
// data-drop-zone="<dock>" an den Rand-Andock-Zonen.
// So braucht der Drag-Controller keine Refs der einzelnen Tabs und bleibt vom
// konkreten Layout entkoppelt.
//
// Ablage-Semantik:
// • über einer Tab-Leiste → in DIESE Gruppe einreihen (Stapel zusammenlegen),
// • über einem Gruppen-Körper (obere/untere Hälfte) → NEUE Gruppe darüber/-unter,
// • über einer Rand-Zone → neue Gruppe ans Ende des Docks,
// • sonst → lösen (schweben).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef, useState } from "react";
import type { DockId } from "./types";
import { getPanel } from "./registry";
import { t } from "../i18n";
/** Pixel-Schwelle, ab der ein pointerdown auf einem Tab zum Ziehen wird. */
const DRAG_THRESHOLD = 5;
/** Standardgröße eines neu gelösten (schwebenden) Panels in CSS-Pixeln. */
export const DEFAULT_FLOAT_W = 300;
export const DEFAULT_FLOAT_H = 360;
/** Ein aufgelöstes Ablageziel beim Loslassen. */
export type DropTarget =
| { kind: "join"; dock: DockId; group: number; index?: number }
| { kind: "newgroup"; dock: DockId; at: number }
| { kind: "float"; x: number; y: number };
/** Laufender Drag-Zustand (für Geist + Hervorhebung). */
interface DragState {
panelId: string;
/** Aktuelle Cursorposition (Geist folgt). */
x: number;
y: number;
/** Aktuell überfahrenes Ziel (für Hervorhebung). */
hover: HoverTarget;
}
/** Was gerade überfahren wird (für Caret/Zonen-Hervorhebung). */
type HoverTarget =
| {
kind: "tabstrip";
dock: DockId;
group: number;
index: number;
caretX: number;
stripTop: number;
stripHeight: number;
}
| {
kind: "split";
dock: DockId;
/** Einfügeposition der neuen Gruppe (0..groups.length). */
at: number;
/** Y-Position der horizontalen Caret-Linie + horizontale Ausdehnung. */
lineY: number;
lineLeft: number;
lineWidth: number;
}
| { kind: "zone"; dock: DockId }
| { kind: "none" };
/** Was App zum Abschluss einer Drag-Geste umsetzt. */
export interface PanelDragCallbacks {
/** Tab unter Schwelle losgelassen → als Klick: Tab aktivieren. */
onActivate: (panelId: string) => void;
/** Über einer Tab-Leiste losgelassen → in diese Gruppe einreihen (am Index). */
onJoinGroup: (panelId: string, dock: DockId, group: number, index?: number) => void;
/** Über einem Gruppen-Körper / einer Rand-Zone → neue Gruppe an `at`. */
onNewGroup: (panelId: string, dock: DockId, at: number) => void;
/** Über der Arbeitsfläche losgelassen → an Position lösen (schweben). */
onDropFloat: (panelId: string, x: number, y: number) => void;
}
/**
* Ermittelt das überfahrene Ziel an (clientX, clientY): zuerst eine Tab-Leiste
* (einreihen), dann ein Gruppen-Körper (neue Gruppe ober-/unterhalb), dann eine
* Rand-Andock-Zone, sonst nichts (= später lösen). Liest die Datenattribute der
* unter dem Cursor liegenden Elemente.
*/
function resolveHover(clientX: number, clientY: number): HoverTarget {
const stack = document.elementsFromPoint(clientX, clientY);
// 1) Tab-Leiste? Einfügeindex aus den Tab-Mittelpunkten bestimmen.
const strip = stack.find((el) =>
(el as HTMLElement).dataset?.tabstrip !== undefined,
) as HTMLElement | undefined;
if (strip) {
const dock = strip.dataset.tabstrip as DockId;
const group = Number(strip.dataset.groupIndex ?? 0);
const tabs = Array.from(
strip.querySelectorAll<HTMLElement>("[data-tab-index]"),
);
const stripRect = strip.getBoundingClientRect();
let index = tabs.length;
let caretX = stripRect.left + 4;
for (let i = 0; i < tabs.length; i++) {
const r = tabs[i].getBoundingClientRect();
const mid = r.left + r.width / 2;
if (clientX < mid) {
index = i;
caretX = r.left - 2;
break;
}
caretX = r.right + 2;
}
return {
kind: "tabstrip",
dock,
group,
index,
caretX,
stripTop: stripRect.top,
stripHeight: stripRect.height,
};
}
// 2) Gruppen-Körper? Obere Hälfte → neue Gruppe DARÜBER (at = gi), untere
// Hälfte → DARUNTER (at = gi + 1). Caret-Linie an der jeweiligen Kante.
const body = stack.find((el) =>
(el as HTMLElement).dataset?.groupBody !== undefined,
) as HTMLElement | undefined;
if (body) {
const dock = body.dataset.dock as DockId;
const gi = Number(body.dataset.groupIndex ?? 0);
const r = body.getBoundingClientRect();
const below = clientY > r.top + r.height / 2;
return {
kind: "split",
dock,
at: below ? gi + 1 : gi,
lineY: below ? r.bottom : r.top,
lineLeft: r.left,
lineWidth: r.width,
};
}
// 3) Rand-Andock-Zone?
const zone = stack.find((el) =>
(el as HTMLElement).dataset?.dropZone !== undefined,
) as HTMLElement | undefined;
if (zone) {
return { kind: "zone", dock: zone.dataset.dropZone as DockId };
}
// 4) Sonst: lösen (schweben).
return { kind: "none" };
}
/**
* Drag-Controller. Liefert:
* • startTabDrag — pointerdown-Handler, den TabStrip an jeden Tab hängt;
* • drag — der laufende Zustand (null = nichts) für das Overlay;
* • dropDockHint — der aktuell hervorzuhebende Dock (für Zonen/Strip), oder null.
*/
export function usePanelDrag(cb: PanelDragCallbacks) {
const [drag, setDrag] = useState<DragState | null>(null);
// Mutable Mitschrift der Geste (Listener lesen daraus, ohne Re-Renders).
const ref = useRef<{
panelId: string;
startX: number;
startY: number;
active: boolean;
hover: HoverTarget;
} | null>(null);
const startTabDrag = useCallback(
(panelId: string, e: React.PointerEvent) => {
// Nur primäre Taste / Touch.
if (e.button !== 0 && e.pointerType === "mouse") return;
const startX = e.clientX;
const startY = e.clientY;
ref.current = { panelId, startX, startY, active: false, hover: { kind: "none" } };
const move = (ev: PointerEvent) => {
const s = ref.current;
if (!s) return;
if (!s.active) {
const moved =
Math.abs(ev.clientX - s.startX) + Math.abs(ev.clientY - s.startY);
if (moved < DRAG_THRESHOLD) return;
s.active = true; // Schwelle überschritten → echtes Ziehen.
}
const hover = resolveHover(ev.clientX, ev.clientY);
s.hover = hover;
setDrag({ panelId: s.panelId, x: ev.clientX, y: ev.clientY, hover });
};
const up = (ev: PointerEvent) => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
const s = ref.current;
ref.current = null;
setDrag(null);
if (!s) return;
if (!s.active) {
// Unter der Schwelle: als Klick werten (Tab aktivieren).
cb.onActivate(s.panelId);
return;
}
const hover = resolveHover(ev.clientX, ev.clientY);
if (hover.kind === "tabstrip") {
cb.onJoinGroup(s.panelId, hover.dock, hover.group, hover.index);
} else if (hover.kind === "split") {
cb.onNewGroup(s.panelId, hover.dock, hover.at);
} else if (hover.kind === "zone") {
// Rand-Zone → neue Gruppe ans Ende (at sehr groß, Helfer klemmt).
cb.onNewGroup(s.panelId, hover.dock, Number.MAX_SAFE_INTEGER);
} else {
// Lösen: Geist mittig über dem Cursor platzieren.
cb.onDropFloat(
s.panelId,
Math.round(ev.clientX - DEFAULT_FLOAT_W / 2),
Math.round(ev.clientY - 12),
);
}
};
const cancel = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
ref.current = null;
setDrag(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancel);
},
[cb],
);
return { drag, startTabDrag };
}
/**
* Drag-Overlay: rendert während eines aktiven Tab-Ziehens den Geist am Cursor
* und das Einfüge-Caret in der überfahrenen Tab-Leiste. Die Rand-Andock-Zonen
* selbst rendert der Host (App), damit sie auch beim Verschieben eines
* schwebenden Fensters dieselben Trefferflächen bilden. Liegt über allem;
* reagiert nicht auf Zeiger (pointer-events: none), damit die Trefferprüfung
* weiter die darunterliegenden Tab-Leisten/Zonen erreicht.
*/
export function DragOverlay({ drag }: { drag: ReturnType<typeof usePanelDrag>["drag"] }) {
if (!drag) return null;
const def = getPanel(drag.panelId);
const label = def ? t(def.title) : drag.panelId;
const hover = drag.hover;
return (
<div className="drag-overlay" aria-hidden>
{/* Einfüge-Caret in der überfahrenen Tab-Leiste (vertikal). */}
{hover.kind === "tabstrip" && (
<div
className="tab-caret"
style={{
left: `${hover.caretX}px`,
top: `${hover.stripTop}px`,
height: `${hover.stripHeight}px`,
}}
/>
)}
{/* Neue-Gruppe-Caret am oberen/unteren Rand eines Gruppen-Körpers
(horizontal). */}
{hover.kind === "split" && (
<div
className="group-caret"
style={{
left: `${hover.lineLeft}px`,
top: `${hover.lineY}px`,
width: `${hover.lineWidth}px`,
}}
/>
)}
{/* Geist-Etikett am Cursor. */}
<div
className="drag-ghost"
style={{ left: `${drag.x + 12}px`, top: `${drag.y + 8}px` }}
>
{label}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
// Panel-Registry — die Plugin-Naht.
//
// Modulweites Verzeichnis aller bekannten Panels. Kern-Panels und Plugins/
// eigene Panels rufen `registerPanel` (typisch beim Import ihres Moduls) auf;
// die Docks fragen anschließend über `getPanel`/`listPanels` ab. So bleibt der
// Rahmen offen für Erweiterungen, ohne ihn zu ändern.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { PanelDef } from "./types";
/** Modulweites Verzeichnis: Panel-ID → Definition (Einfügereihenfolge bewahrt). */
const registry = new Map<string, PanelDef>();
/**
* Registriert ein Panel. Idempotent pro ID: ein erneuter Aufruf mit derselben
* ID ersetzt die vorhandene Definition (praktisch für Hot-Reload und für
* Plugins, die Kern-Panels überschreiben). Liefert die übergebene Definition
* zurück, damit Aufrufe verkettet werden können.
*/
export function registerPanel(def: PanelDef): PanelDef {
registry.set(def.id, def);
return def;
}
/** Liefert die registrierte Panel-Definition zur ID oder `undefined`. */
export function getPanel(id: string): PanelDef | undefined {
return registry.get(id);
}
/** Liefert alle registrierten Panels in Registrierungsreihenfolge. */
export function listPanels(): PanelDef[] {
return [...registry.values()];
}
/**
* Ob zu einer ID ein Panel registriert ist. Hilfreich, um aus einem
* persistierten Layout verschwundene (z. B. von einem deaktivierten Plugin
* stammende) Panel-IDs herauszufiltern.
*/
export function hasPanel(id: string): boolean {
return registry.has(id);
}
/**
* Entfernt ein Panel aus der Registry. Liefert `true`, wenn etwas entfernt
* wurde. Vor allem für Tests und das Entladen von Plugins gedacht.
*/
export function unregisterPanel(id: string): boolean {
return registry.delete(id);
}
+164
View File
@@ -0,0 +1,164 @@
// Panel-System — Kerntypen.
//
// Das Panel-System ist der erweiterbare Andockrahmen der App: links und rechts
// je ein Dock mit Tabs, jeder Tab ist ein registriertes Panel. Plugins/eigene
// Panels registrieren sich über die Registry (registry.ts) und tauchen dann in
// den Docks auf — der Rahmen kennt keine konkreten Panels.
//
// Bezeichner englisch, Kommentare/UI-Text deutsch (CONVENTIONS.md). Dieses Modul ist
// rein typdeklarativ plus der React-Context-Träger; es hat keine Laufzeitlogik
// außer createContext und ist daher SSR-/test-sicher.
import { createContext } from "react";
import type { ReactNode } from "react";
// ── Dock-Identität & Layout ────────────────────────────────────────────────
/** Welches der beiden Docks (links/rechts) gemeint ist. */
export type DockId = "left" | "right";
/**
* Eine vertikal gestapelte Gruppe innerhalb eines Docks: ein eigener
* Tab-Stapel mit eigenem aktivem Tab. Mehrere Gruppen übereinander ergeben das
* Vectorworks-Bild (z. B. links Werkzeuge OBEN, Attribute UNTEN). Jede Gruppe
* trägt ein Höhen-Gewicht (relativer flex-Anteil im Dock); die Splitter
* zwischen den Gruppen verschieben diese Gewichte.
*/
export interface DockGroup {
/** Geordnete Liste der Panel-IDs in dieser Gruppe (Tab-Reihenfolge). */
tabs: string[];
/** Aktiver Tab (Panel-ID) oder `null`, wenn die Gruppe leer ist. */
activeTab: string | null;
/**
* Relatives Höhen-Gewicht der Gruppe im Dock (flex-grow-Anteil). Nur die
* Verhältnisse zählen; ein Splitter-Zug verteilt Gewicht zwischen Nachbarn.
*/
weight: number;
}
/**
* Zustand eines einzelnen Docks: eine geordnete Liste vertikal gestapelter
* Gruppen (oben → unten) plus die Dock-Breite. Ein leeres Dock hat `groups: []`
* und wird nicht gerendert (die Mitte nutzt den Platz).
*/
export interface DockState {
/** Vertikal gestapelte Gruppen (oben → unten). */
groups: DockGroup[];
/**
* Breite des Docks in CSS-Pixeln. Persistiert, damit die Aufteilung über
* Sitzungen erhalten bleibt.
*/
size: number;
}
/**
* Ein schwebendes (frei positioniertes) Panel: weder im linken noch im rechten
* Dock, sondern als eigenes Fenster über der Mitte. Position/Größe sind
* CSS-Pixel relativ zum Anwendungsbereich; `z` ist die Stapelreihenfolge
* (höher = weiter vorne), damit zuletzt fokussierte Fenster oben liegen.
*/
export interface FloatingPanel {
/** Panel-ID (Registry-Schlüssel) — wie in DockState.tabs eindeutig. */
panelId: string;
/** Linke Kante in CSS-Pixeln (relativ zum Anwendungsbereich). */
x: number;
/** Obere Kante in CSS-Pixeln (relativ zum Anwendungsbereich). */
y: number;
/** Breite in CSS-Pixeln. */
w: number;
/** Höhe in CSS-Pixeln. */
h: number;
/** Stapelindex (höher = weiter vorne). */
z: number;
}
/**
* Gesamter Layout-Zustand des Panel-Rahmens: beide Docks, die Liste der
* schwebenden Panels plus eine Schema-Version (für Migrationen beim Laden aus
* localStorage).
*
* INVARIANTE: Jede registrierte Panel-ID erscheint an GENAU EINER Stelle —
* in einer Gruppe des linken Docks, in einer Gruppe des rechten Docks oder in
* `floating` (als `panelId`). Die Helfer in layout.ts wahren diese Invariante
* (sie entfernen ein Panel überall, bevor sie es am Ziel einfügen, und werfen
* leer gewordene Gruppen weg).
*/
export interface LayoutState {
left: DockState;
right: DockState;
/** Frei positionierte Panels (nicht in einem Dock). */
floating: FloatingPanel[];
/** Schema-Version des Layouts (für künftige Migrationen). */
version: number;
}
// ── Darstellungsmodus ──────────────────────────────────────────────────────
/**
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
* Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden — die
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md), für Ebenen UND
* Zeichnungsebenen identisch:
* • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
* wechselt zu „Ausgewählte".
* • "all" — sichtbar nach per-Zeile-Flag (Standard).
* • "active" — nur das aktive Element; andere stark gedimmt.
* • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
* • "grey_locked" — wie „grey", andere zusätzlich gesperrt.
*/
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked";
// ── Panel-Definition & Kontext ─────────────────────────────────────────────
/**
* Was ein Panel beim Rendern erhält. Bewusst generisch gehalten: konkrete
* App-Daten (Projekt, Handler, aktive Auswahl) reicht der Host stattdessen über
* den React-Context `PanelHostContext` durch — so bleiben Panels von der
* Props-Signatur des Hosts entkoppelt und Plugins müssen diesen Typ nicht
* kennen. Die Index-Signatur erlaubt es, bei Bedarf trotzdem ad-hoc-Werte
* mitzugeben, ohne den Typ zu brechen.
*/
export interface PanelContext {
[key: string]: unknown;
}
/**
* Definition eines Panels — die Einheit, die in der Registry registriert und in
* einem Dock als Tab dargestellt wird.
*/
export interface PanelDef {
/** Stabile, eindeutige ID (Registry-Schlüssel, in Layouts persistiert). */
id: string;
/**
* Im Tab angezeigter Titel — als i18n-Key (z. B. „nav.layers"). Der Rahmen
* (TabStrip/PanelFrame) löst ihn über `t()` auf, sodass der Titel der
* gewählten Sprache folgt. Ist der String kein bekannter Key, gibt `t()` ihn
* unverändert zurück (Plugins können also auch einen fertigen Text setzen).
*/
title: string;
/** Rendert den Panel-Inhalt. Erhält den (generischen) PanelContext. */
render: (ctx: PanelContext) => ReactNode;
/**
* Ob das Panel den Darstellungsmodus-Umschalter (DisplayMode) in seiner
* Kopfzeile anbieten soll. Default: kein Umschalter.
*/
hasDisplayMode?: boolean;
}
// ── Host-Context (App-Zustand + Handler für Panels) ────────────────────────
/**
* Träger des App-Zustands und der Mutations-Handler, den die App über einen
* Provider bereitstellt und den Panels per `useContext(PanelHostContext)`
* abgreifen. Bewusst lose typisiert (Record), damit dieses Kernmodul nicht vom
* konkreten Projekt-/Handler-Modell abhängt und Plugins frei darauf zugreifen
* können. Die App liefert hier u. a. `project`, die aktive Auswahl, die
* Ressourcen-Handler und die Darstellungsmodi hinein.
*
* `null` bedeutet „außerhalb eines Providers gerendert" — Consumer sollten das
* abfangen (siehe usePanelHost in einem späteren Schritt).
*/
export type PanelHost = Record<string, unknown>;
/** React-Context, über den der Host seinen Zustand an Panels durchreicht. */
export const PanelHostContext = createContext<PanelHost | null>(null);