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