fix(editor): constrain table structure hover size

* fix(editor): constrain table structure hover size

* fix(editor): improve DDL hover scrolling

* fix(editor): use CodeMirror tooltip lifecycle for hover layout

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
authored and GitHub committed 2026-08-11 00:53:42 +08:00
1 parent 823c260316
commit 7b3f4875e3
3 files changed
+293 -9

No files matched your search

@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { constrainSqlHoverLayout, sqlHoverScrollbarMetrics } from "@/lib/editor/sqlHoverLayout";
describe("constrainSqlHoverLayout", () => {
it("caps table structure hovers and scrolls oversized DDL inside the content area", () => {
const root = document.createElement("div");
const content = document.createElement("div");
constrainSqlHoverLayout(root, content);
expect(root.dataset.sqlStructureHover).toBe("true");
expect(root.style.width).toBe("80vw");
expect(root.style.maxWidth).toBe("900px");
expect(root.style.maxHeight).toBe("calc(50vh - 12px)");
expect(root.style.overflow).toBe("hidden");
expect(content.dataset.sqlStructureHoverContent).toBe("true");
expect(content.style.flex).toBe("0 1 auto");
expect(content.style.maxHeight).toBe("480px");
expect(content.style.maxWidth).toBe("100%");
expect(content.style.overflowX).toBe("hidden");
expect(content.style.overflowY).toBe("auto");
expect(content.style.overscrollBehavior).toBe("contain");
expect(content.style.scrollbarGutter).toBe("stable");
expect(root.querySelector('[data-sql-structure-hover-scrollbar="true"]')).not.toBeNull();
expect(root.querySelector('[data-sql-structure-hover-scrollbar-thumb="true"]')).not.toBeNull();
});
it("maps content scroll position to a proportional custom scrollbar thumb", () => {
expect(sqlHoverScrollbarMetrics(600, 1200, 300, 600)).toEqual({
maxScroll: 600,
maxThumbLeft: 300,
thumbLeft: 150,
thumbWidth: 300,
});
expect(sqlHoverScrollbarMetrics(600, 6000, 5400, 600)).toEqual({
maxScroll: 5400,
maxThumbLeft: 540,
thumbLeft: 540,
thumbWidth: 60,
});
});
it("keeps horizontal trackpad scrolling while the native scrollbar is hidden", () => {
const root = document.createElement("div");
const content = document.createElement("div");
Object.defineProperty(content, "clientWidth", { configurable: true, value: 600 });
Object.defineProperty(content, "scrollWidth", { configurable: true, value: 1200 });
constrainSqlHoverLayout(root, content);
const wheelEvent = new WheelEvent("wheel", { cancelable: true, deltaX: 120 });
content.dispatchEvent(wheelEvent);
expect(content.scrollLeft).toBe(120);
expect(wheelEvent.defaultPrevented).toBe(true);
});
it("starts the scrollbar on mount and stops responding after destroy", () => {
const root = document.createElement("div");
const content = document.createElement("div");
Object.defineProperty(content, "clientWidth", { configurable: true, value: 600 });
Object.defineProperty(content, "scrollWidth", { configurable: true, value: 2400 });
const controller = constrainSqlHoverLayout(root, content);
const track = root.querySelector<HTMLElement>('[data-sql-structure-hover-scrollbar="true"]')!;
// 挂载前未跑过 update,轨道仍隐藏
expect(track.hidden).toBe(true);
controller.mount();
expect(track.hidden).toBe(false);
const before = content.scrollLeft;
content.dispatchEvent(new WheelEvent("wheel", { cancelable: true, deltaX: 120 }));
expect(content.scrollLeft).toBe(before + 120);
controller.destroy();
const afterDestroy = content.scrollLeft;
content.dispatchEvent(new WheelEvent("wheel", { cancelable: true, deltaX: 120 }));
expect(content.scrollLeft).toBe(afterDestroy);
});
});
@@ -0,0 +1,136 @@
const SQL_HOVER_SCROLLBAR_MIN_THUMB_WIDTH = 36;
export function sqlHoverScrollbarMetrics(clientWidth: number, scrollWidth: number, scrollLeft: number, trackWidth: number) {
const maxScroll = Math.max(0, scrollWidth - clientWidth);
const thumbWidth = maxScroll > 0 ? Math.max(SQL_HOVER_SCROLLBAR_MIN_THUMB_WIDTH, trackWidth * (clientWidth / scrollWidth)) : trackWidth;
const maxThumbLeft = Math.max(0, trackWidth - thumbWidth);
const thumbLeft = maxScroll > 0 ? (Math.min(Math.max(scrollLeft, 0), maxScroll) / maxScroll) * maxThumbLeft : 0;
return { maxScroll, maxThumbLeft, thumbLeft, thumbWidth };
}
export interface SqlHoverLayoutController {
/** Called after the tooltip dom is added to the document. */
mount(): void;
/** Called when the tooltip is removed or the editor is destroyed. */
destroy(): void;
}
function appendSqlHoverHorizontalScrollbar(root: HTMLElement, sqlContent: HTMLElement): SqlHoverLayoutController {
const track = document.createElement("div");
track.dataset.sqlStructureHoverScrollbar = "true";
track.hidden = true;
const thumb = document.createElement("div");
thumb.dataset.sqlStructureHoverScrollbarThumb = "true";
track.appendChild(thumb);
root.appendChild(track);
let dragOffset = 0;
let dragging = false;
let resizeObserver: ResizeObserver | null = null;
const update = () => {
const metrics = sqlHoverScrollbarMetrics(sqlContent.clientWidth, sqlContent.scrollWidth, sqlContent.scrollLeft, track.clientWidth);
track.hidden = metrics.maxScroll <= 1;
thumb.style.width = `${metrics.thumbWidth}px`;
thumb.style.transform = `translateX(${metrics.thumbLeft}px)`;
};
const scrollFromPointer = (clientX: number) => {
const trackRect = track.getBoundingClientRect();
const metrics = sqlHoverScrollbarMetrics(sqlContent.clientWidth, sqlContent.scrollWidth, sqlContent.scrollLeft, trackRect.width);
if (metrics.maxScroll <= 0 || metrics.maxThumbLeft <= 0) return;
const thumbLeft = Math.min(Math.max(clientX - trackRect.left - dragOffset, 0), metrics.maxThumbLeft);
sqlContent.scrollLeft = (thumbLeft / metrics.maxThumbLeft) * metrics.maxScroll;
update();
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const thumbRect = thumb.getBoundingClientRect();
dragOffset = event.target === thumb ? event.clientX - thumbRect.left : thumbRect.width / 2;
dragging = true;
track.setPointerCapture(event.pointerId);
scrollFromPointer(event.clientX);
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragging) return;
event.preventDefault();
event.stopPropagation();
scrollFromPointer(event.clientX);
};
const handlePointerUp = (event: PointerEvent) => {
if (!dragging) return;
event.preventDefault();
event.stopPropagation();
dragging = false;
if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
};
const handleWheel = (event: WheelEvent) => {
const horizontalDelta = event.deltaX || (event.shiftKey ? event.deltaY : 0);
if (!horizontalDelta) return;
const maxScroll = Math.max(0, sqlContent.scrollWidth - sqlContent.clientWidth);
const nextScrollLeft = Math.min(Math.max(sqlContent.scrollLeft + horizontalDelta, 0), maxScroll);
if (nextScrollLeft === sqlContent.scrollLeft) return;
event.preventDefault();
sqlContent.scrollLeft = nextScrollLeft;
update();
};
// 事件监听在构建时绑定即可,元素未挂载也不影响;ResizeObserver 必须等
// dom 进入文档后才能拿到正确尺寸,故放到 mount() 里启动。
sqlContent.addEventListener("scroll", update, { passive: true });
sqlContent.addEventListener("wheel", handleWheel, { passive: false });
track.addEventListener("pointerdown", handlePointerDown);
track.addEventListener("pointermove", handlePointerMove);
track.addEventListener("pointerup", handlePointerUp);
track.addEventListener("pointercancel", handlePointerUp);
return {
mount() {
update();
resizeObserver = new ResizeObserver(update);
resizeObserver.observe(sqlContent);
resizeObserver.observe(track);
},
destroy() {
resizeObserver?.disconnect();
resizeObserver = null;
sqlContent.removeEventListener("scroll", update);
sqlContent.removeEventListener("wheel", handleWheel);
track.removeEventListener("pointerdown", handlePointerDown);
track.removeEventListener("pointermove", handlePointerMove);
track.removeEventListener("pointerup", handlePointerUp);
track.removeEventListener("pointercancel", handlePointerUp);
},
};
}
/** Keep table-DDL hovers readable without allowing one long line to size the tooltip. */
export function constrainSqlHoverLayout(root: HTMLElement, sqlContent: HTMLElement): SqlHoverLayoutController {
root.dataset.sqlStructureHover = "true";
root.style.boxSizing = "border-box";
root.style.display = "flex";
root.style.flexDirection = "column";
root.style.maxHeight = "calc(50vh - 12px)";
root.style.maxWidth = "900px";
root.style.overflow = "hidden";
root.style.width = "80vw";
sqlContent.dataset.sqlStructureHoverContent = "true";
sqlContent.style.flex = "0 1 auto";
sqlContent.style.maxHeight = "480px";
sqlContent.style.maxWidth = "100%";
sqlContent.style.minHeight = "0";
sqlContent.style.overflowX = "hidden";
sqlContent.style.overflowY = "auto";
sqlContent.style.overscrollBehavior = "contain";
sqlContent.style.scrollbarGutter = "stable";
return appendSqlHoverHorizontalScrollbar(root, sqlContent);
}