feat(tabs): detach SQL and data tabs into separate windows
This commit is contained in:
1 parent
717dcc1294
commit
d38be39e03
17 files changed
+957
-21
No files matched your search
+60
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user