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

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit 359ce35737
138 changed files with 31585 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
// Schwebendes Panel — ein frei positioniertes, dunkles Fenster über der Mitte.
//
// Rendert denselben Panel-Inhalt wie ein Dock (über Registry + PanelFrame, inkl.
// Darstellungsmodus-Dropdown für Ebenen-Panels), aber in einem eigenen Fenster
// mit Titelleiste (zum Verschieben), Andock-/Schließen-Knopf und einem
// Greifer unten rechts zum Ändern der Größe. Ein Klick irgendwo im Fenster holt
// es nach vorne (z-Index).
//
// Verschieben/Größe ändern laufen über POINTER-Events mit Pointer-Capture, damit
// die Geste auch außerhalb des Fensters weiterläuft. Während des Ziehens der
// Titelleiste meldet `onTitleDrag` jede Cursorposition an den Host (App), der
// daraus eine mögliche Andock-Geste an einer Rand-Zone ableitet; beim Loslassen
// entscheidet `onTitleDrop`, ob das Fenster schwebend bleibt (verschoben) oder
// in ein Dock wandert.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { getPanel } from "./registry";
import type { DisplayMode, FloatingPanel as FloatingPanelState, PanelContext } from "./types";
import { t } from "../i18n";
/** Mindestgröße eines schwebenden Fensters in CSS-Pixeln. */
const MIN_W = 220;
const MIN_H = 160;
export interface FloatingPanelProps {
/** Zustand (Position/Größe/z) dieses schwebenden Panels. */
panel: FloatingPanelState;
/** Generischer Kontext für den Panel-Inhalt (an def.render). */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus (für Panels mit hasDisplayMode). */
displayMode?: DisplayMode;
/** Setter des Darstellungsmodus (zeigt den Umschalter in der Kopfzeile). */
onDisplayModeChange?: (mode: DisplayMode) => void;
/** Holt das Fenster nach vorne (z-Index) — bei Klick/Geste-Beginn. */
onFocus: (panelId: string) => void;
/** Verschiebt das Fenster (Position-Patch). */
onMove: (panelId: string, x: number, y: number) => void;
/** Ändert die Größe (w/h-Patch). */
onResize: (panelId: string, w: number, h: number) => void;
/** Dockt das Fenster wieder an (Standard-Andockziel des Redock-Knopfs). */
onRedock: (panelId: string) => void;
/** Schließt das Fenster (entfernt es ganz). */
onClose: (panelId: string) => void;
/**
* Während des Titel-Ziehens: meldet die aktuelle Cursorposition (clientX/Y) an
* den Host. Der Host hebt mögliche Rand-Andock-Zonen hervor.
*/
onTitleDrag?: (panelId: string, clientX: number, clientY: number) => void;
/**
* Beim Loslassen der Titelleiste über einer Andock-Zone: meldet die finale
* Cursorposition. Liefert `true`, wenn der Host das Fenster angedockt hat (dann
* unterbleibt das reine Verschieben). Sonst bleibt das Fenster schwebend.
*/
onTitleDrop?: (panelId: string, clientX: number, clientY: number) => boolean;
}
/**
* Ein einzelnes schwebendes Panel-Fenster. Titelleiste = verschieben (+ Andocken
* über Rand-Zonen), Greifer unten rechts = Größe ändern, Andock-/Schließen-Knopf
* in der Titelleiste.
*/
export function FloatingPanel({
panel,
ctx,
displayMode,
onDisplayModeChange,
onFocus,
onMove,
onResize,
onRedock,
onClose,
onTitleDrag,
onTitleDrop,
}: FloatingPanelProps) {
const def = getPanel(panel.panelId);
// Verschiebe-Offset (Cursor − linke/obere Kante) für ein ruckelfreies Greifen.
const dragOffset = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
const onTitlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Knöpfe in der Titelleiste nicht als Verschiebe-Start werten.
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
dragOffset.current = {
dx: e.clientX - panel.x,
dy: e.clientY - panel.y,
};
const move = (ev: PointerEvent) => {
const x = ev.clientX - dragOffset.current.dx;
const y = ev.clientY - dragOffset.current.dy;
onMove(panel.panelId, x, y);
onTitleDrag?.(panel.panelId, ev.clientX, ev.clientY);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
// Über einer Rand-Zone? Dann dockt der Host an; sonst bleibt es schweben.
onTitleDrop?.(panel.panelId, ev.clientX, ev.clientY);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.x, panel.y, onFocus, onMove, onTitleDrag, onTitleDrop],
);
const onResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = panel.w;
const startH = panel.h;
const move = (ev: PointerEvent) => {
const w = Math.max(MIN_W, Math.round(startW + (ev.clientX - startX)));
const h = Math.max(MIN_H, Math.round(startH + (ev.clientY - startY)));
onResize(panel.panelId, w, h);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.w, panel.h, onFocus, onResize],
);
const style: CSSProperties = {
left: `${panel.x}px`,
top: `${panel.y}px`,
width: `${panel.w}px`,
height: `${panel.h}px`,
zIndex: 500 + panel.z,
};
const title = def ? t(def.title) : panel.panelId;
return (
<section
className="floating-panel"
style={style}
onPointerDownCapture={() => onFocus(panel.panelId)}
role="dialog"
aria-label={title}
>
<div className="floating-titlebar" onPointerDown={onTitlePointerDown}>
<span className="floating-title">{title}</span>
<div className="floating-actions">
<button
type="button"
className="floating-btn"
title={t("dock.redock")}
aria-label={t("dock.redock")}
onClick={() => onRedock(panel.panelId)}
>
⤣
</button>
<button
type="button"
className="floating-btn"
title={t("dock.close")}
aria-label={t("dock.close")}
onClick={() => onClose(panel.panelId)}
>
×
</button>
</div>
</div>
{def ? (
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
/>
) : (
<div className="panel-frame panel-frame-empty" />
)}
{/* Greifer unten rechts (Größe ändern). */}
<div
className="floating-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
</section>
);
}