Files
DOSSIER-STANDALONE/src/panels/panelDrag.tsx
T
karim ca859c4aa4 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.
2026-06-30 20:52:27 +02:00

305 lines
11 KiB
TypeScript

// 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>
);
}