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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user