fix(sidebar): improve pinned item drag ordering
This commit is contained in:
parent
4c57520a12
commit
d5e0cde7e2
|
|
@ -45,7 +45,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import { canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||||
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||||
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
|
||||
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
|
||||
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
|
|
@ -633,6 +633,11 @@ function formattedObjectStorage(): string {
|
|||
|
||||
const alignedCommentLabelWidth = computed(() => (settingsStore.editorSettings.sidebarObjectInfoMode === "comment-aligned" ? props.commentLabelWidth : undefined));
|
||||
|
||||
function alignedCommentLeadingStyle(): { width: string } | undefined {
|
||||
const width = alignedCommentLeadingWidth(alignedCommentLabelWidth.value, canTreeNodePin(activeNode.value.type));
|
||||
return width === undefined ? undefined : { width: `${width}px` };
|
||||
}
|
||||
|
||||
function hasTrailingMetadata(): boolean {
|
||||
return !!trailingComment.value || !!formattedObjectStorage();
|
||||
}
|
||||
|
|
@ -641,7 +646,7 @@ const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(activeNode.valu
|
|||
|
||||
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
|
||||
|
||||
const labelWidthClass = computed(() => treeLabelWidthClass({ fullWidth: usesFullWidthLabel.value, hasTrailingComment: hasTrailingMetadata() }));
|
||||
const labelWidthClass = computed(() => treeLabelWidthClass({ fullWidth: usesFullWidthLabel.value, hasTrailingComment: hasTrailingMetadata(), hasInlineAction: isPinned.value }));
|
||||
|
||||
watch(() => [isRightAlignedComment(), visibleLabel(activeNode.value), trailingComment.value, trailingCommentLayoutRef.value, trailingCommentLeadingRef.value], refreshTrailingCommentMeasurement, { flush: "post", immediate: true });
|
||||
|
||||
|
|
@ -815,7 +820,7 @@ function isPinnedOrderDrag(): boolean {
|
|||
|
||||
const dragVisual = computed(() => {
|
||||
const targetId = isPinnedOrderDrag() ? pinnedSortKey() : activeNode.value.id;
|
||||
const isDropTarget = isPinnedOrderDrag() ? !!dragState.draggedId && connectionStore.canReorderPinnedTreeNodes(dragState.draggedId, pinnedSortKey()) : activeNode.value.type === "connection" || activeNode.value.type === "connection-group";
|
||||
const isDropTarget = isPinnedOrderDrag() ? connectionStore.isPinnedTreeNodeReorderTarget(pinnedSortKey()) : activeNode.value.type === "connection" || activeNode.value.type === "connection-group";
|
||||
|
||||
return {
|
||||
isDropTarget,
|
||||
|
|
@ -827,8 +832,14 @@ const dragVisual = computed(() => {
|
|||
});
|
||||
|
||||
function startPinnedOrderDrag(event: MouseEvent) {
|
||||
if (!canDragPinnedOrder()) return;
|
||||
startDrag(event, pinnedSortKey(), PINNED_TREE_NODE_DRAG_TYPE);
|
||||
if (event.button !== 0 || !canDragPinnedOrder()) return;
|
||||
const draggedKey = pinnedSortKey();
|
||||
connectionStore.beginPinnedTreeNodeReorder(draggedKey);
|
||||
startDrag(event, draggedKey, PINNED_TREE_NODE_DRAG_TYPE, {
|
||||
autoScroll: true,
|
||||
scrollContainer: rowRef.value?.closest<HTMLElement>(".connection-tree-scroller") ?? null,
|
||||
onEnd: connectionStore.endPinnedTreeNodeReorder,
|
||||
});
|
||||
}
|
||||
|
||||
function updateTreeDragTarget(event: MouseEvent) {
|
||||
|
|
@ -1131,11 +1142,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||
<Loader2 v-else-if="node.type === 'load-more' && node.isLoading" class="w-3.5 h-3.5 shrink-0 animate-spin text-primary" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="databaseOpenVisual.iconClass" />
|
||||
<div ref="trailingCommentLayoutRef" :class="hasTrailingMetadata() ? 'flex flex-1 min-w-0 items-center' : 'contents'">
|
||||
<div
|
||||
ref="trailingCommentLeadingRef"
|
||||
:class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'"
|
||||
:style="alignedCommentLabelWidth ? { width: `${alignedCommentLabelWidth}px` } : undefined"
|
||||
>
|
||||
<div ref="trailingCommentLeadingRef" :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'" :style="alignedCommentLeadingStyle()">
|
||||
<input
|
||||
v-if="isRenamingGroup"
|
||||
ref="renameInputRef"
|
||||
|
|
@ -1147,6 +1154,19 @@ function onKeydown(event: KeyboardEvent) {
|
|||
@click.stop
|
||||
/>
|
||||
<span v-else ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
|
||||
<button
|
||||
v-if="canDragPinnedOrder()"
|
||||
type="button"
|
||||
class="flex h-4 w-4 shrink-0 cursor-grab items-center justify-center rounded-sm text-primary hover:bg-primary/10 active:cursor-grabbing"
|
||||
:aria-label="t('contextMenu.reorderPinned')"
|
||||
:title="t('contextMenu.reorderPinned')"
|
||||
@mousedown.stop="startPinnedOrderDrag"
|
||||
@click.stop.prevent
|
||||
@dblclick.stop.prevent
|
||||
>
|
||||
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
</button>
|
||||
<Pin v-else-if="isPinned" class="h-3 w-3 shrink-0 fill-current text-primary" aria-hidden="true" />
|
||||
<ProductionContextBadge v-if="showProductionBadge" compact />
|
||||
<span
|
||||
v-if="
|
||||
|
|
@ -1174,19 +1194,6 @@ function onKeydown(event: KeyboardEvent) {
|
|||
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
|
||||
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
|
||||
<button
|
||||
v-if="canDragPinnedOrder()"
|
||||
type="button"
|
||||
class="flex h-4 w-4 shrink-0 cursor-grab items-center justify-center rounded-sm text-primary hover:bg-primary/10 active:cursor-grabbing"
|
||||
:aria-label="t('contextMenu.reorderPinned')"
|
||||
:title="t('contextMenu.reorderPinned')"
|
||||
@mousedown.stop="startPinnedOrderDrag"
|
||||
@click.stop.prevent
|
||||
@dblclick.stop.prevent
|
||||
>
|
||||
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
</button>
|
||||
<Pin v-else-if="isPinned" class="w-3 h-3 shrink-0 text-primary fill-current" aria-hidden="true" />
|
||||
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
|
||||
<button
|
||||
v-if="isConnecting"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { alignedSidebarCommentLabelWidths, canTreeNodeShowExpander, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||||
import { alignedCommentLeadingWidth, alignedSidebarCommentLabelWidths, canTreeNodeShowExpander, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||||
|
||||
describe("sidebar tree item layout", () => {
|
||||
it("keeps a table row constrained when it displays a comment", () => {
|
||||
|
|
@ -11,6 +11,13 @@ describe("sidebar tree item layout", () => {
|
|||
expect(treeLabelWidthClass({ fullWidth: false, hasTrailingComment: true })).toBe("min-w-0 flex-1 truncate");
|
||||
});
|
||||
|
||||
it("keeps a pinned action next to the name while preserving the aligned comment column", () => {
|
||||
expect(treeLabelWidthClass({ fullWidth: false, hasTrailingComment: true, hasInlineAction: true })).toBe("min-w-0 shrink truncate");
|
||||
expect(alignedCommentLeadingWidth(100, true)).toBe(124);
|
||||
expect(alignedCommentLeadingWidth(100, false)).toBe(100);
|
||||
expect(alignedCommentLeadingWidth(undefined, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders etcd Keys and Dashboard as aligned leaf actions", () => {
|
||||
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
|
||||
expect(canTreeNodeShowExpander({ type: "etcd-dashboard", childCount: 0 })).toBe(false);
|
||||
|
|
|
|||
|
|
@ -60,11 +60,17 @@ export function treeItemPaddingLeft(depth: number): string {
|
|||
}
|
||||
|
||||
export const trailingCommentGapPx = 8;
|
||||
export const sidebarPinnedActionSlotWidthPx = 24;
|
||||
|
||||
export function trailingCommentAvailableWidth(containerWidth: number, leadingWidth: number): number {
|
||||
return Math.max(0, Math.floor(containerWidth - leadingWidth - trailingCommentGapPx));
|
||||
}
|
||||
|
||||
export function alignedCommentLeadingWidth(labelWidth: number | undefined, reservePinnedAction: boolean): number | undefined {
|
||||
if (labelWidth === undefined) return undefined;
|
||||
return labelWidth + (reservePinnedAction ? sidebarPinnedActionSlotWidthPx : 0);
|
||||
}
|
||||
|
||||
export interface SidebarCommentAlignmentItem {
|
||||
id: string;
|
||||
depth: number;
|
||||
|
|
@ -132,8 +138,9 @@ export function usesFullWidthTreeLabel(type: TreeNodeType, allowHorizontalScroll
|
|||
return allowHorizontalScroll && !hasTrailingComment && fullWidthLabelTypes.has(type);
|
||||
}
|
||||
|
||||
export function treeLabelWidthClass({ fullWidth, hasTrailingComment }: { fullWidth: boolean; hasTrailingComment: boolean }): string {
|
||||
export function treeLabelWidthClass({ fullWidth, hasTrailingComment, hasInlineAction = false }: { fullWidth: boolean; hasTrailingComment: boolean; hasInlineAction?: boolean }): string {
|
||||
if (fullWidth) return "shrink-0 whitespace-nowrap";
|
||||
if (hasTrailingComment && hasInlineAction) return "min-w-0 shrink truncate";
|
||||
return hasTrailingComment ? "min-w-0 flex-1 truncate" : "min-w-0 truncate";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,13 @@ describe("connectionStore pinned tree node removal", () => {
|
|||
|
||||
const usersKey = treeNodePinKey(users);
|
||||
const ordersKey = treeNodePinKey(orders);
|
||||
store.beginPinnedTreeNodeReorder(usersKey);
|
||||
expect(store.reorderPinnedTreeNodes(usersKey, ordersKey, "after")).toBe(true);
|
||||
store.endPinnedTreeNodeReorder();
|
||||
await vi.waitFor(() => expect(savePinnedTreeNodeIds).toHaveBeenCalledTimes(1));
|
||||
store.beginPinnedTreeNodeReorder(ordersKey);
|
||||
expect(store.reorderPinnedTreeNodes(ordersKey, usersKey, "after")).toBe(true);
|
||||
store.endPinnedTreeNodeReorder();
|
||||
|
||||
expect(savePinnedTreeNodeIds).toHaveBeenCalledTimes(1);
|
||||
resolvers[0]!();
|
||||
|
|
@ -97,6 +101,49 @@ describe("connectionStore pinned tree node removal", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("caches active drag targets and invalidates them on tree changes and drag end", async () => {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const users = tableNode("users");
|
||||
const orders = tableNode("orders");
|
||||
const logs = tableNode("logs");
|
||||
store.treeNodes = [{ id: "conn", label: "Connection", type: "connection", connectionId: "conn", children: [users, orders, logs] }];
|
||||
store.toggleTreeNodePin(users);
|
||||
store.toggleTreeNodePin(orders);
|
||||
store.toggleTreeNodePin(logs);
|
||||
|
||||
const usersKey = treeNodePinKey(users);
|
||||
const ordersKey = treeNodePinKey(orders);
|
||||
const logsKey = treeNodePinKey(logs);
|
||||
let schemaReads = 0;
|
||||
Object.defineProperty(orders, "schema", {
|
||||
configurable: true,
|
||||
get() {
|
||||
schemaReads += 1;
|
||||
return "public";
|
||||
},
|
||||
});
|
||||
|
||||
store.beginPinnedTreeNodeReorder(usersKey);
|
||||
expect(store.isPinnedTreeNodeReorderTarget(ordersKey)).toBe(true);
|
||||
const readsAfterFirstLookup = schemaReads;
|
||||
expect(readsAfterFirstLookup).toBeGreaterThan(0);
|
||||
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
expect(store.isPinnedTreeNodeReorderTarget(index % 2 === 0 ? ordersKey : logsKey)).toBe(true);
|
||||
}
|
||||
expect(schemaReads).toBe(readsAfterFirstLookup);
|
||||
|
||||
store.treeNodes[0].children = [users, logs];
|
||||
store.treeNodes.push({ id: "other", label: "Other", type: "connection", connectionId: "other", children: [orders] });
|
||||
expect(store.isPinnedTreeNodeReorderTarget(ordersKey)).toBe(false);
|
||||
|
||||
store.endPinnedTreeNodeReorder();
|
||||
expect(store.isPinnedTreeNodeReorderTarget(logsKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a renamed pinned object to its new identity so recreating the old name is unpinned", async () => {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const sidebarTableStorageInFlight = new Map<string, Promise<ObjectStatistics[]>>();
|
||||
const pinnedTreeNodeOrder = ref<string[]>([]);
|
||||
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
|
||||
const activePinnedTreeNodeReorderKey = ref<string | null>(null);
|
||||
let pinnedTreeNodePersistQueue: Promise<void> = Promise.resolve();
|
||||
const connectedIds = ref<Set<string>>(new Set());
|
||||
const identifierQuotes = ref<Record<string, string>>({});
|
||||
|
|
@ -1922,13 +1923,40 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
function collectPinnedTreeNodeReorderTargets(draggedKey: string): Set<string> {
|
||||
const dragged = findPinnedTreeNodeLocation(treeNodes.value, draggedKey);
|
||||
if (!dragged || !isTreeNodePinned(dragged.node) || isFixedPriorityTreeNode(dragged.node)) return new Set();
|
||||
|
||||
const targets = new Set<string>();
|
||||
for (const sibling of dragged.siblings) {
|
||||
const siblingKey = treeNodePinKey(sibling);
|
||||
if (siblingKey === draggedKey || !isTreeNodePinned(sibling) || isFixedPriorityTreeNode(sibling)) continue;
|
||||
targets.add(siblingKey);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
const activePinnedTreeNodeReorderTargets = computed(() => {
|
||||
const draggedKey = activePinnedTreeNodeReorderKey.value;
|
||||
return draggedKey ? collectPinnedTreeNodeReorderTargets(draggedKey) : new Set<string>();
|
||||
});
|
||||
|
||||
function beginPinnedTreeNodeReorder(draggedKey: string) {
|
||||
activePinnedTreeNodeReorderKey.value = draggedKey || null;
|
||||
}
|
||||
|
||||
function endPinnedTreeNodeReorder() {
|
||||
activePinnedTreeNodeReorderKey.value = null;
|
||||
}
|
||||
|
||||
function isPinnedTreeNodeReorderTarget(targetKey: string): boolean {
|
||||
return !!targetKey && targetKey !== activePinnedTreeNodeReorderKey.value && activePinnedTreeNodeReorderTargets.value.has(targetKey);
|
||||
}
|
||||
|
||||
function canReorderPinnedTreeNodes(draggedKey: string, targetKey: string): boolean {
|
||||
if (!draggedKey || !targetKey || draggedKey === targetKey) return false;
|
||||
const dragged = findPinnedTreeNodeLocation(treeNodes.value, draggedKey);
|
||||
const target = findPinnedTreeNodeLocation(treeNodes.value, targetKey);
|
||||
if (!dragged || !target || dragged.siblings !== target.siblings) return false;
|
||||
if (!isTreeNodePinned(dragged.node) || !isTreeNodePinned(target.node)) return false;
|
||||
return !isFixedPriorityTreeNode(dragged.node) && !isFixedPriorityTreeNode(target.node);
|
||||
if (activePinnedTreeNodeReorderKey.value === draggedKey) return activePinnedTreeNodeReorderTargets.value.has(targetKey);
|
||||
return collectPinnedTreeNodeReorderTargets(draggedKey).has(targetKey);
|
||||
}
|
||||
|
||||
function reorderPinnedTreeNodes(draggedKey: string, targetKey: string, position: DropPosition): boolean {
|
||||
|
|
@ -6089,6 +6117,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
isTreeNodePinned,
|
||||
orderByPinnedTreeNodes,
|
||||
toggleTreeNodePin,
|
||||
beginPinnedTreeNodeReorder,
|
||||
endPinnedTreeNodeReorder,
|
||||
isPinnedTreeNodeReorderTarget,
|
||||
canReorderPinnedTreeNodes,
|
||||
reorderPinnedTreeNodes,
|
||||
addConnection,
|
||||
|
|
|
|||
Loading…
Reference in New Issue