// 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="" + data-group-index an der Tab-Leiste einer Gruppe, // data-tab-index="" an jedem Tab-Knopf, // data-group-body + data-dock + data-group-index am Körper einer Gruppe, // data-drop-zone="" 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("[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(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["drag"] }) { if (!drag) return null; const def = getPanel(drag.panelId); const label = def ? t(def.title) : drag.panelId; const hover = drag.hover; return (
{/* Einfüge-Caret in der überfahrenen Tab-Leiste (vertikal). */} {hover.kind === "tabstrip" && (
)} {/* Neue-Gruppe-Caret am oberen/unteren Rand eines Gruppen-Körpers (horizontal). */} {hover.kind === "split" && (
)} {/* Geist-Etikett am Cursor. */}
{label}
); }