Files
dbx/apps/desktop/src/composables/useTabDrag.ts
T

185 lines
5.1 KiB
TypeScript

import { reactive, readonly } from "vue";
export type TabDropPosition = "before" | "after";
interface TabDragState {
active: boolean;
draggedId: string | null;
targetId: string | null;
dropPosition: TabDropPosition | null;
suppressClick: boolean;
startX: number;
startY: number;
}
// Many touchscreen digitizers report to the OS/webview as a plain HID mouse
// (no distinguishable pointerType), with tap contact-point drift well beyond
// what real mouse/trackpad click jitter produces. 24px absorbs that jitter
// while still requiring a clearly deliberate drag to reorder a tab.
export const TAB_DRAG_HORIZONTAL_THRESHOLD = 24;
const state = reactive<TabDragState>({
active: false,
draggedId: null,
targetId: null,
dropPosition: null,
suppressClick: false,
startX: 0,
startY: 0,
});
let pending: {
id: string;
x: number;
y: number;
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
let ghostEl: HTMLElement | null = null;
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
const ghost = document.createElement("div");
const textNode = sourceEl.querySelector(".truncate");
ghost.textContent = textNode?.textContent || "";
ghost.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.9;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-radius: var(--dbx-radius-fixed-6);
background: var(--background, #fff);
border: 1px solid var(--border, #e5e7eb);
max-width: 200px;
height: 28px;
padding: 0 12px;
font-size: 12px;
line-height: 28px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
left: ${x + 12}px;
top: ${y - 14}px;
`;
document.body.appendChild(ghost);
return ghost;
}
function moveGhost(x: number, y: number) {
if (!ghostEl) return;
ghostEl.style.left = `${x + 8}px`;
ghostEl.style.top = `${y - 14}px`;
}
function removeGhost() {
if (ghostEl) {
ghostEl.remove();
ghostEl = null;
}
}
function onPointerMove(event: PointerEvent) {
if (!pending && !state.active) return;
if (pending && !state.active) {
const dx = event.clientX - pending.x;
if (Math.abs(dx) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
state.active = true;
state.draggedId = pending.id;
state.startX = pending.x;
state.startY = pending.y;
if (pending.sourceEl) {
ghostEl = createGhost(pending.sourceEl, event.clientX, event.clientY);
}
pending = null;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
if (state.active) {
moveGhost(event.clientX, event.clientY);
}
}
function onMouseUp() {
state.suppressClick = false;
if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
state.suppressClick = onDropCallback(state.draggedId, state.targetId, state.dropPosition);
}
reset();
}
function reset() {
state.active = false;
state.draggedId = null;
state.targetId = null;
state.dropPosition = null;
state.startX = 0;
state.startY = 0;
pending = null;
removeGhost();
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
let listenersAttached = false;
function ensureListeners() {
if (listenersAttached) return;
document.addEventListener("pointermove", onPointerMove, true);
document.addEventListener("pointerup", onMouseUp, true);
listenersAttached = true;
}
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean) {
ensureListeners();
onDropCallback = onDrop;
function startDrag(event: PointerEvent, tabId: string) {
if (event.button !== 0) return;
// Touch input has no reliable equivalent of mouse jitter: a real finger's
// contact point commonly drifts well past TAB_DRAG_HORIZONTAL_THRESHOLD
// during an ordinary tap, which would misread the tap as a drag. Skip
// arming the drag for touch entirely and let the tab strip's native
// horizontal scroll handle the gesture instead.
if (event.pointerType === "touch") return;
const target = event.target as HTMLElement;
if (target.closest("button, input, [data-tab-title-input]")) return;
state.suppressClick = false;
const el = (event.currentTarget as HTMLElement) || null;
pending = { id: tabId, x: event.clientX, y: event.clientY, sourceEl: el };
}
function updateTarget(event: MouseEvent, tabId: string) {
if (!state.active || tabId === state.draggedId) {
if (state.targetId === tabId) {
state.targetId = null;
state.dropPosition = null;
}
return;
}
state.targetId = tabId;
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const x = event.clientX - rect.left;
state.dropPosition = x < rect.width / 2 ? "before" : "after";
}
function clearTarget(tabId: string) {
if (state.targetId === tabId) {
state.targetId = null;
state.dropPosition = null;
}
}
return {
state: readonly(state),
startDrag,
updateTarget,
clearTarget,
};
}