ca859c4aa4
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.
305 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|