mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
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:
@@ -80,6 +80,7 @@ import {
|
||||
type SqlObjectNavigationTarget,
|
||||
} from "@/lib/sql/sqlNavigation";
|
||||
import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
|
||||
import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout";
|
||||
import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics";
|
||||
import {
|
||||
DBX_TABLE_REFERENCE_MIME,
|
||||
@@ -2314,7 +2315,7 @@ async function ensureForeignKeysForTables(tables: Array<{ name: string; database
|
||||
}
|
||||
}
|
||||
|
||||
function createHoverDom(title: string, detail: string, sqlContent?: string, rows: string[] = []) {
|
||||
function createHoverDom(title: string, detail: string, sqlContent?: string, rows: string[] = []): { dom: HTMLElement; mount?: () => void; destroy?: () => void } {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
|
||||
|
||||
@@ -2328,13 +2329,15 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
|
||||
detailNode.textContent = detail;
|
||||
dom.appendChild(detailNode);
|
||||
|
||||
let layoutController: ReturnType<typeof constrainSqlHoverLayout> | null = null;
|
||||
|
||||
if (sqlContent) {
|
||||
const separator = document.createElement("div");
|
||||
separator.className = "mt-2 border-t border-border/60";
|
||||
dom.appendChild(separator);
|
||||
|
||||
const sqlContainer = document.createElement("div");
|
||||
sqlContainer.className = "mt-1.5 max-h-64 overflow-y-auto text-[11px] leading-5 whitespace-pre font-mono";
|
||||
sqlContainer.className = "mt-1.5 text-[11px] leading-5 whitespace-pre font-mono";
|
||||
|
||||
if (hoverSqlHighlighter) {
|
||||
sqlContainer.innerHTML = hoverSqlHighlighter(sqlContent, isDark.value ? "dark" : "light");
|
||||
@@ -2344,6 +2347,9 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
|
||||
}
|
||||
|
||||
dom.appendChild(sqlContainer);
|
||||
// 返回 mount/destroy 给 CodeMirror TooltipView 生命周期钩子,
|
||||
// 避免 MutationObserver 监听 body 全子树来兜底清理。
|
||||
layoutController = constrainSqlHoverLayout(dom, sqlContainer);
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -2353,7 +2359,11 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
|
||||
dom.appendChild(rowNode);
|
||||
}
|
||||
|
||||
return dom;
|
||||
return {
|
||||
dom,
|
||||
mount: layoutController ? () => layoutController?.mount() : undefined,
|
||||
destroy: layoutController ? () => layoutController?.destroy() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
|
||||
@@ -2476,9 +2486,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
return {
|
||||
pos: range.from,
|
||||
end: range.to,
|
||||
create: () => ({
|
||||
dom: createHoverDom(table.name, sqlObjectHoverDetail(table), sqlContent, metadataLoadFailed ? ["[DBX] Failed to load table structure — check connection"] : undefined),
|
||||
}),
|
||||
create: () => createHoverDom(table.name, sqlObjectHoverDetail(table), sqlContent, metadataLoadFailed ? ["[DBX] Failed to load table structure — check connection"] : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2502,9 +2510,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
return {
|
||||
pos: range.from,
|
||||
end: range.to,
|
||||
create: () => ({
|
||||
dom: createHoverDom(column.name, column.dataType || "column", undefined, [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
|
||||
}),
|
||||
create: () => createHoverDom(column.name, column.dataType || "column", undefined, [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -5802,6 +5808,66 @@ defineExpose({
|
||||
</style>
|
||||
|
||||
<style>
|
||||
[data-sql-structure-hover-content="true"] {
|
||||
scrollbar-color: color-mix(in oklab, var(--foreground) 42%, transparent) color-mix(in oklab, var(--muted) 65%, transparent);
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-content="true"]::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-content="true"]::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-content="true"]::-webkit-scrollbar-track {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--muted) 65%, transparent);
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-content="true"]::-webkit-scrollbar-thumb {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--foreground) 42%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-content="true"]::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-scrollbar="true"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
margin-top: 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--muted) 72%, var(--border));
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
flex: 0 0 10px;
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-scrollbar-thumb="true"] {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
height: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--foreground) 52%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
[data-sql-structure-hover-scrollbar="true"]:hover [data-sql-structure-hover-scrollbar-thumb="true"] {
|
||||
background: color-mix(in oklab, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
.intention-popup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user