feat(tabs): detach SQL and data tabs into separate windows

This commit is contained in:
DengQingNian authored and GitHub committed 2026-09-03 01:45:08 +08:00
1 parent 717dcc1294
commit d38be39e03
17 files changed
+957 -21

No files matched your search

+60 -2
View File
@@ -20,8 +20,12 @@ function movePointer(x: number, y = 20, pointerType = "mouse") {
document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: x, clientY: y, pointerType }));
}
function releasePointer() {
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
function releasePointer(x = 0, y = 0) {
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, screenX: x, screenY: y }));
}
function cancelPointer() {
document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true }));
}
function enterDropTarget(drag: ReturnType<typeof useTabDrag>, tabId = "target", x = 10) {
@@ -112,4 +116,58 @@ describe("useTabDrag", () => {
expect(drag.state.suppressClick).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("detaches only when an armed drag is released outside the tab strip", () => {
const onDetach = vi.fn(() => true);
const drag = useTabDrag(
vi.fn(() => true),
onDetach,
);
drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
document.body.appendChild(source);
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
releasePointer(500, 500);
expect(onDetach).toHaveBeenCalledWith("source", { x: 500, y: 500 });
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(true);
drag.setDetachBoundsProvider(null);
});
it("clears an armed drag when the pointer is cancelled", () => {
const { drag, source } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
expect(drag.state.active).toBe(true);
cancelPointer();
expect(drag.state.active).toBe(false);
expect(drag.state.draggedId).toBe(null);
expect(drag.state.suppressClick).toBe(false);
});
it("does not detach when the pointer is released inside the tab strip", () => {
const onDetach = vi.fn(() => true);
const onDrop = vi.fn(() => false);
const drag = useTabDrag(onDrop, onDetach);
drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
document.body.appendChild(source);
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
releasePointer();
expect(onDetach).not.toHaveBeenCalled();
expect(onDrop).not.toHaveBeenCalled();
drag.setDetachBoundsProvider(null);
});
});
+26 -3
View File
@@ -35,6 +35,8 @@ let pending: {
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
let onDetachCallback: ((draggedId: string, position: { x: number; y: number }) => boolean) | null = null;
let detachBoundsProvider: (() => DOMRect | undefined) | null = null;
let dragAxis: () => "horizontal" | "vertical" = () => "horizontal";
let ghostEl: HTMLElement | null = null;
@@ -102,14 +104,27 @@ function onPointerMove(event: PointerEvent) {
}
}
function onMouseUp() {
function onMouseUp(event: PointerEvent) {
state.suppressClick = false;
if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
if (state.active && state.draggedId && onDetachCallback && isOutsideDetachBounds(event.clientX, event.clientY)) {
const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
state.suppressClick = onDetachCallback(state.draggedId, { x: event.screenX * scale, y: event.screenY * scale });
} else if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
state.suppressClick = onDropCallback(state.draggedId, state.targetId, state.dropPosition);
}
reset();
}
function onPointerCancel() {
state.suppressClick = false;
reset();
}
function isOutsideDetachBounds(x: number, y: number): boolean {
const bounds = detachBoundsProvider?.();
return !!bounds && (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom);
}
function reset() {
state.active = false;
state.draggedId = null;
@@ -129,14 +144,21 @@ function ensureListeners() {
if (listenersAttached) return;
document.addEventListener("pointermove", onPointerMove, true);
document.addEventListener("pointerup", onMouseUp, true);
document.addEventListener("pointercancel", onPointerCancel, true);
window.addEventListener("blur", onPointerCancel, true);
listenersAttached = true;
}
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean, axis: () => "horizontal" | "vertical" = () => "horizontal") {
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean, onDetach?: (draggedId: string, position: { x: number; y: number }) => boolean, axis: () => "horizontal" | "vertical" = () => "horizontal") {
ensureListeners();
onDropCallback = onDrop;
onDetachCallback = onDetach ?? null;
dragAxis = axis;
function setDetachBoundsProvider(provider: (() => DOMRect | undefined) | null) {
detachBoundsProvider = provider;
}
function startDrag(event: PointerEvent, tabId: string) {
if (event.button !== 0) return;
// Touch input has no reliable equivalent of mouse jitter: a real finger's
@@ -183,5 +205,6 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio
startDrag,
updateTarget,
clearTarget,
setDetachBoundsProvider,
};
}