fix(sidebar): improve pinned item drag ordering

This commit is contained in:
zhangsan authored and GitHub committed 2026-07-27 14:41:04 +08:00
1 parent 4c57520a12
commit d5e0cde7e2
7 files changed
+476 -32

No files matched your search

@@ -0,0 +1,186 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { dragSortAutoScrollDelta, useDragSort } from "@/composables/useDragSort";
const scrollerRect = {
left: 100,
right: 300,
top: 100,
bottom: 400,
};
afterEach(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("drag sort edge auto-scroll", () => {
it("calculates proportional scrolling near either vertical edge", () => {
const base = {
pointerX: 150,
rect: scrollerRect,
scrollTop: 200,
clientHeight: 300,
scrollHeight: 1000,
};
expect(dragSortAutoScrollDelta({ ...base, pointerY: 95 })).toBe(-20);
expect(dragSortAutoScrollDelta({ ...base, pointerY: 110 })).toBeLessThan(-4);
expect(dragSortAutoScrollDelta({ ...base, pointerY: 250 })).toBe(0);
expect(dragSortAutoScrollDelta({ ...base, pointerY: 390 })).toBeGreaterThan(4);
expect(dragSortAutoScrollDelta({ ...base, pointerY: 405 })).toBe(20);
});
it("does not scroll at a boundary or when the pointer moves away horizontally", () => {
const base = {
rect: scrollerRect,
clientHeight: 300,
scrollHeight: 1000,
};
expect(dragSortAutoScrollDelta({ ...base, pointerX: 150, pointerY: 95, scrollTop: 0 })).toBe(0);
expect(dragSortAutoScrollDelta({ ...base, pointerX: 150, pointerY: 405, scrollTop: 700 })).toBe(0);
expect(dragSortAutoScrollDelta({ ...base, pointerX: 20, pointerY: 95, scrollTop: 200 })).toBe(0);
});
it("scrolls an opted-in container, refreshes the drop target, and stops on mouseup", () => {
let nextFrameId = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
const id = nextFrameId++;
frames.set(id, callback);
return id;
});
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
frames.delete(id);
});
const container = document.createElement("div");
container.className = "connection-tree-scroller";
container.scrollTop = 200;
Object.defineProperties(container, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 1000 },
});
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ ...scrollerRect, width: 200, height: 300, x: 100, y: 100, toJSON: () => ({}) } as DOMRect);
const source = document.createElement("button");
const target = document.createElement("div");
container.append(source, target);
document.body.append(container);
const onDrop = vi.fn();
const { state, startDrag, updateTarget } = useDragSort(onDrop);
source.addEventListener("mousedown", (event) => startDrag(event, "dragged", "__pinned-tree-node__", { autoScroll: true, scrollContainer: container }));
target.addEventListener("mousemove", (event) => updateTarget(event, "target", "__pinned-tree-node__"));
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ left: 100, right: 300, top: 100, bottom: 128, width: 200, height: 28, x: 100, y: 100, toJSON: () => ({}) } as DOMRect);
vi.spyOn(document, "elementFromPoint").mockReturnValue(target);
source.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 150, clientY: 150 }));
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, buttons: 1, clientX: 150, clientY: 95 }));
expect(state.active).toBe(true);
expect(frames.size).toBe(1);
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, buttons: 1, clientX: 150, clientY: 90 }));
expect(frames.size).toBe(1);
const [frameId, frame] = [...frames.entries()][0];
frames.delete(frameId);
frame(16);
expect(container.scrollTop).toBe(180);
expect((document.body.lastElementChild as HTMLElement).style.top).toBe("78px");
expect(state.targetId).toBe("target");
expect(state.dropPosition).toBe("before");
expect(frames.size).toBe(1);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(onDrop).toHaveBeenCalledWith("dragged", "target", "before");
expect(state.active).toBe(false);
expect(frames.size).toBe(0);
expect(cancelFrame).toHaveBeenCalled();
});
it("drops no stale target after auto-scroll reaches an invalid row", () => {
let nextFrameId = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
const id = nextFrameId++;
frames.set(id, callback);
return id;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
frames.delete(id);
});
const container = document.createElement("div");
container.className = "connection-tree-scroller";
container.scrollTop = 200;
Object.defineProperties(container, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 1000 },
});
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ ...scrollerRect, width: 200, height: 300, x: 100, y: 100, toJSON: () => ({}) } as DOMRect);
const source = document.createElement("button");
const validTarget = document.createElement("div");
const invalidTarget = document.createElement("div");
container.append(source, validTarget, invalidTarget);
document.body.append(container);
const onDrop = vi.fn();
const { state, startDrag, updateTarget } = useDragSort(onDrop);
source.addEventListener("mousedown", (event) => startDrag(event, "dragged", "__pinned-tree-node__", { autoScroll: true, scrollContainer: container }));
validTarget.addEventListener("mousemove", (event) => updateTarget(event, "valid", "__pinned-tree-node__"));
vi.spyOn(validTarget, "getBoundingClientRect").mockReturnValue({ left: 100, right: 300, top: 100, bottom: 128, width: 200, height: 28, x: 100, y: 100, toJSON: () => ({}) } as DOMRect);
vi.spyOn(document, "elementFromPoint").mockReturnValueOnce(validTarget).mockReturnValue(invalidTarget);
source.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 150, clientY: 150 }));
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, buttons: 1, clientX: 150, clientY: 95 }));
const firstFrameEntry = [...frames.entries()][0];
frames.delete(firstFrameEntry[0]);
firstFrameEntry[1](16);
expect(state.targetId).toBe("valid");
const secondFrameEntry = [...frames.entries()][0];
frames.delete(secondFrameEntry[0]);
secondFrameEntry[1](32);
expect(state.targetId).toBeNull();
expect(state.dropPosition).toBeNull();
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(onDrop).not.toHaveBeenCalled();
});
it("leaves ordinary drag sorting unchanged unless auto-scroll is requested", () => {
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
const source = document.createElement("button");
document.body.append(source);
const { state, startDrag } = useDragSort(vi.fn());
source.addEventListener("mousedown", (event) => startDrag(event, "dragged", "connection"));
source.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 150, clientY: 150 }));
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, buttons: 1, clientX: 150, clientY: 95 }));
expect(state.active).toBe(true);
expect(requestFrame).not.toHaveBeenCalled();
});
it("ends a prepared drag even when mouseup happens before activation", () => {
const source = document.createElement("button");
document.body.append(source);
const onEnd = vi.fn();
const { startDrag } = useDragSort(vi.fn());
source.addEventListener("mousedown", (event) => startDrag(event, "dragged", "__pinned-tree-node__", { onEnd }));
source.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 150, clientY: 150 }));
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(onEnd).toHaveBeenCalledOnce();
});
});
+161 -2
View File
@@ -12,7 +12,26 @@ interface DragState {
startY: number;
}
export interface DragSortStartOptions {
autoScroll?: boolean;
scrollContainer?: HTMLElement | null;
onEnd?: () => void;
}
export interface DragSortAutoScrollInput {
pointerX: number;
pointerY: number;
rect: Pick<DOMRect, "left" | "right" | "top" | "bottom">;
scrollTop: number;
clientHeight: number;
scrollHeight: number;
}
const DRAG_THRESHOLD = 5;
export const DRAG_SORT_AUTO_SCROLL_EDGE_SIZE = 40;
const DRAG_SORT_AUTO_SCROLL_HORIZONTAL_TOLERANCE = 32;
const DRAG_SORT_AUTO_SCROLL_MIN_SPEED = 4;
const DRAG_SORT_AUTO_SCROLL_MAX_SPEED = 20;
const state = reactive<DragState>({
active: false,
@@ -30,9 +49,42 @@ let pending: {
x: number;
y: number;
sourceEl: HTMLElement | null;
autoScroll: boolean;
scrollContainer: HTMLElement | null;
onEnd: (() => void) | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: DropPosition) => void) | null = null;
let onDragEndCallback: (() => void) | null = null;
let ghostEl: HTMLElement | null = null;
let autoScrollContainer: HTMLElement | null = null;
let autoScrollFrame = 0;
let pointerX = 0;
let pointerY = 0;
const dropTargetRefreshEvents = new WeakSet<MouseEvent>();
export function dragSortAutoScrollDelta({ pointerX, pointerY, rect, scrollTop, clientHeight, scrollHeight }: DragSortAutoScrollInput): number {
if (pointerX < rect.left - DRAG_SORT_AUTO_SCROLL_HORIZONTAL_TOLERANCE || pointerX > rect.right + DRAG_SORT_AUTO_SCROLL_HORIZONTAL_TOLERANCE) return 0;
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
let direction = 0;
let edgeDistance = 0;
if (pointerY < rect.top + DRAG_SORT_AUTO_SCROLL_EDGE_SIZE) {
direction = -1;
edgeDistance = Math.max(0, pointerY - rect.top);
} else if (pointerY > rect.bottom - DRAG_SORT_AUTO_SCROLL_EDGE_SIZE) {
direction = 1;
edgeDistance = Math.max(0, rect.bottom - pointerY);
} else {
return 0;
}
if ((direction < 0 && scrollTop <= 0) || (direction > 0 && scrollTop >= maxScrollTop)) return 0;
const intensity = Math.min(1, Math.max(0, (DRAG_SORT_AUTO_SCROLL_EDGE_SIZE - edgeDistance) / DRAG_SORT_AUTO_SCROLL_EDGE_SIZE));
const speed = Math.round(DRAG_SORT_AUTO_SCROLL_MIN_SPEED + (DRAG_SORT_AUTO_SCROLL_MAX_SPEED - DRAG_SORT_AUTO_SCROLL_MIN_SPEED) * intensity);
return direction * speed;
}
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
const ghost = document.createElement("div");
@@ -75,9 +127,97 @@ function removeGhost() {
}
}
function cancelAutoScroll() {
if (autoScrollFrame) {
window.cancelAnimationFrame(autoScrollFrame);
autoScrollFrame = 0;
}
}
function refreshDropTargetAtPointer(container: HTMLElement) {
state.targetId = null;
state.dropPosition = null;
if (typeof document.elementFromPoint !== "function") return;
const rect = container.getBoundingClientRect();
const targetX = Math.min(Math.max(pointerX, rect.left + 1), rect.right - 1);
const targetY = Math.min(Math.max(pointerY, rect.top + 1), rect.bottom - 1);
const target = document.elementFromPoint(targetX, targetY);
if (!target) return;
const event = new MouseEvent("mousemove", {
bubbles: true,
clientX: pointerX,
clientY: targetY,
buttons: 1,
});
dropTargetRefreshEvents.add(event);
target.dispatchEvent(event);
}
function autoScrollStep() {
autoScrollFrame = 0;
const container = autoScrollContainer;
if (!state.active || !container) return;
const delta = dragSortAutoScrollDelta({
pointerX,
pointerY,
rect: container.getBoundingClientRect(),
scrollTop: container.scrollTop,
clientHeight: container.clientHeight,
scrollHeight: container.scrollHeight,
});
if (!delta) return;
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
const previousScrollTop = container.scrollTop;
container.scrollTop = Math.min(maxScrollTop, Math.max(0, previousScrollTop + delta));
if (container.scrollTop !== previousScrollTop) refreshDropTargetAtPointer(container);
scheduleAutoScroll();
}
function scheduleAutoScroll() {
if (autoScrollFrame || !autoScrollContainer || !state.active) return;
const delta = dragSortAutoScrollDelta({
pointerX,
pointerY,
rect: autoScrollContainer.getBoundingClientRect(),
scrollTop: autoScrollContainer.scrollTop,
clientHeight: autoScrollContainer.clientHeight,
scrollHeight: autoScrollContainer.scrollHeight,
});
if (!delta) return;
autoScrollFrame = window.requestAnimationFrame(autoScrollStep);
}
function updateAutoScroll() {
const container = autoScrollContainer;
if (!container || !state.active) {
cancelAutoScroll();
return;
}
const delta = dragSortAutoScrollDelta({
pointerX,
pointerY,
rect: container.getBoundingClientRect(),
scrollTop: container.scrollTop,
clientHeight: container.clientHeight,
scrollHeight: container.scrollHeight,
});
if (!delta) {
cancelAutoScroll();
return;
}
scheduleAutoScroll();
}
function onMouseMove(event: MouseEvent) {
if (dropTargetRefreshEvents.has(event)) return;
if (!pending && !state.active) return;
pointerX = event.clientX;
pointerY = event.clientY;
if (pending && !state.active) {
const dx = event.clientX - pending.x;
const dy = event.clientY - pending.y;
@@ -90,6 +230,8 @@ function onMouseMove(event: MouseEvent) {
if (pending.sourceEl) {
ghostEl = createGhost(pending.sourceEl, event.clientX, event.clientY);
}
autoScrollContainer = pending.autoScroll ? (pending.scrollContainer ?? pending.sourceEl?.closest<HTMLElement>(".connection-tree-scroller") ?? null) : null;
onDragEndCallback = pending.onEnd;
pending = null;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
@@ -97,6 +239,7 @@ function onMouseMove(event: MouseEvent) {
if (state.active) {
moveGhost(event.clientX, event.clientY);
updateAutoScroll();
}
}
@@ -108,6 +251,7 @@ function onMouseUp() {
}
function reset() {
const endCallback = onDragEndCallback ?? pending?.onEnd ?? null;
state.active = false;
state.draggedId = null;
state.draggedType = null;
@@ -116,9 +260,15 @@ function reset() {
state.startX = 0;
state.startY = 0;
pending = null;
onDragEndCallback = null;
autoScrollContainer = null;
pointerX = 0;
pointerY = 0;
cancelAutoScroll();
removeGhost();
document.body.style.cursor = "";
document.body.style.userSelect = "";
endCallback?.();
}
let listenersAttached = false;
@@ -134,10 +284,19 @@ export function useDragSort(onDrop: (draggedId: string, targetId: string, positi
ensureListeners();
onDropCallback = onDrop;
function startDrag(event: MouseEvent, nodeId: string, nodeType: string) {
function startDrag(event: MouseEvent, nodeId: string, nodeType: string, options: DragSortStartOptions = {}) {
if (event.button !== 0) return;
const el = (event.currentTarget as HTMLElement) || null;
pending = { id: nodeId, type: nodeType, x: event.clientX, y: event.clientY, sourceEl: el };
pending = {
id: nodeId,
type: nodeType,
x: event.clientX,
y: event.clientY,
sourceEl: el,
autoScroll: options.autoScroll === true,
scrollContainer: options.scrollContainer ?? null,
onEnd: options.onEnd ?? null,
};
}
function updateTarget(event: MouseEvent, nodeId: string, nodeType: string) {