fix(structure): 表结构编辑器补充可拖拽的纵向滚动条 (#9585)

编辑表结构的「字段 / 索引」页签之前只有自绘的横向滚动条,纵向只能靠滚轮,
字段较多时既没有“还有更多内容”的视觉提示,也无法拖拽定位。

- 复用 useVerticalOverlayScrollbar 组合式函数,为当前激活的表格页签
  (字段 / 索引)渲染与横向滚动条同款的覆盖式纵向滚动条;
- 页签面板改为三行两列的 grid:工具栏、滚动区、横向条在第一列,
  纵向条独占第二列,仅在有溢出且处于表格页签时出现;
- 隐藏该滚动区残留的原生纵向滚动条,避免与自绘条重叠;
- 补充单测:纵向滚动条在有溢出时出现、拖拽可同步 scrollTop,
  以及非表格页签(DDL)下不渲染。

Fixes #9408

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipg
2026-09-19 15:23:27 +08:00
committed by GitHub
co-authored by zipg
parent 0adef6e16a
commit c71c35624b
4 changed files with 164 additions and 15 deletions
@@ -837,6 +837,55 @@ describe("TableStructureEditor horizontal scrolling", () => {
});
});
describe("TableStructureEditor vertical scrolling", () => {
it("shows a fixed scrollbar for overflowing fields and syncs thumb dragging", async () => {
const root = await mountEditor("postgres");
const scroller = root.querySelector<HTMLElement>(".structure-table-scroller");
if (!scroller) throw new Error("Missing structure table scroller");
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 200 },
scrollHeight: { configurable: true, value: 800 },
scrollTop: { configurable: true, value: 0, writable: true },
});
scroller.dispatchEvent(new Event("scroll"));
await nextTick();
await nextTick();
const track = root.querySelector<HTMLElement>(".structure-vertical-scrollbar");
const thumb = root.querySelector<HTMLElement>(".structure-vertical-scrollbar__thumb");
if (!track || !thumb) throw new Error("Missing fixed vertical scrollbar");
expect(Number.parseFloat(thumb.style.height)).toBeCloseTo(25);
expect(Number.parseFloat(thumb.style.top)).toBeCloseTo(0);
track.getBoundingClientRect = () => DOMRect.fromRect({ width: 10, height: 200 });
document.body.style.userSelect = "text";
track.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientY: 50, isPrimary: true }));
window.dispatchEvent(new PointerEvent("pointermove", { clientY: 125, isPrimary: true }));
expect(scroller.scrollTop).toBeCloseTo(400);
expect(document.body.style.userSelect).toBe("none");
window.dispatchEvent(new PointerEvent("pointerup", { isPrimary: true }));
expect(document.body.style.userSelect).toBe("");
});
it("does not render the vertical scrollbar when the editor opens on the DDL tab", async () => {
const root = await mountLoadingEditor("ddl");
const scroller = root.querySelector<HTMLElement>(".structure-table-scroller");
if (!scroller) throw new Error("Missing structure table scroller");
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 200 },
scrollHeight: { configurable: true, value: 800 },
});
scroller.dispatchEvent(new Event("scroll"));
await nextTick();
await nextTick();
expect(root.querySelector(".structure-vertical-scrollbar")).toBeNull();
});
});
describe("TableStructureEditor metadata loading", () => {
it("opens the initial DDL tab with its always-visible table comment", async () => {
const root = await mountLoadingEditor("ddl", "app_user", "Application users");
@@ -28,6 +28,7 @@ import { useTheme } from "@/composables/useTheme";
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { useToast } from "@/composables/useToast";
import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
import { formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
@@ -128,6 +129,9 @@ const constraintsScrollerRef = ref<StructureScrollerRef>();
const triggersScrollerRef = ref<StructureScrollerRef>();
const ddlScrollerRef = ref<StructureScrollerRef>();
const structureHorizontalScrollbarTrackRef = ref<HTMLDivElement>();
const columnsTableRef = ref<HTMLElement | null>(null);
const indexesTableRef = ref<HTMLElement | null>(null);
const structureVerticalScrollbarTrackRef = ref<HTMLElement | null>(null);
const structureHorizontalScrollbarThumbRef = ref<HTMLDivElement>();
const hasStructureHorizontalOverflow = ref(false);
const dynamicDataTypeOptionsCache = new Map<string, string[]>();
@@ -167,6 +171,21 @@ const emit = defineEmits<{
}>();
const activeTab = ref<TableInfoTab>("columns");
// DBX hides native scrollbars, so the columns/indexes tables only had the custom
// horizontal bar. Render the same overlay affordance vertically for whichever
// table tab is active; the bar occupies the tab panel's second grid column.
const activeStructureTableScrollerRef = computed<HTMLElement | null>(() => structureScrollerElement(activeTab.value === "indexes" ? indexesScrollerRef.value : columnsScrollerRef.value) ?? null);
const activeStructureTableContentRef = computed<HTMLElement | null>(() => (activeTab.value === "indexes" ? indexesTableRef.value : columnsTableRef.value) ?? null);
const {
hasOverflow: hasStructureVerticalOverflow,
isScrolling: isStructureVerticalScrollbarScrolling,
isDragging: isStructureVerticalScrollbarDragging,
thumbStyle: structureVerticalScrollbarThumbStyle,
onScroll: onStructureVerticalScrollerScroll,
onTrackPointerDown: onStructureVerticalScrollbarTrackPointerDown,
onThumbPointerDown: onStructureVerticalScrollbarThumbPointerDown,
} = useVerticalOverlayScrollbar(activeStructureTableScrollerRef, activeStructureTableContentRef, structureVerticalScrollbarTrackRef);
const loading = ref(false);
const saving = ref(false);
const postSaveRefreshing = ref(false);
@@ -1390,7 +1409,10 @@ function restoreStructureScrollPosition(tab = activeTab.value) {
function onStructureContentScroll(tab: TableInfoTab, event: Event) {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
if (tab === "columns" || tab === "indexes") updateStructureHorizontalScrollbar(target);
if (tab === "columns" || tab === "indexes") {
updateStructureHorizontalScrollbar(target);
if (activeTab.value === tab) onStructureVerticalScrollerScroll();
}
const position: TableStructureEditorViewport = {
scrollTop: Math.max(0, Math.round(target.scrollTop)),
scrollLeft: Math.max(0, Math.round(target.scrollLeft)),
@@ -4337,8 +4359,8 @@ watch(
<div v-else class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div class="min-h-0 min-w-0 flex-1 overflow-hidden rounded-md border">
<Tabs v-model="activeTab" class="flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center justify-between gap-2 border-b px-2 py-[var(--structure-header-py)]">
<Tabs v-model="activeTab" class="structure-tabs h-full min-h-0">
<div class="col-start-1 row-start-1 flex shrink-0 items-center justify-between gap-2 border-b px-2 py-[var(--structure-header-py)]">
<TabsList>
<TabsTrigger v-if="tableMetadataCapabilities.ddl && !isCreateMode" value="ddl">
DDL
@@ -4456,8 +4478,8 @@ watch(
</div>
</div>
<TabsContent ref="columnsScrollerRef" v-if="tableMetadataCapabilities.columns" value="columns" class="structure-table-scroller m-0 min-h-0 flex-1 overflow-auto p-0" @scroll.passive="onStructureContentScroll('columns', $event)">
<table class="structure-edit-grid border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: visibleColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<TabsContent ref="columnsScrollerRef" v-if="tableMetadataCapabilities.columns" value="columns" class="col-start-1 row-start-2 structure-table-scroller m-0 min-h-0 flex-1 overflow-auto p-0" @scroll.passive="onStructureContentScroll('columns', $event)">
<table ref="columnsTableRef" class="structure-edit-grid border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: visibleColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<thead class="sticky top-0 z-10 bg-background">
<tr>
<th
@@ -4914,7 +4936,7 @@ watch(
</table>
</TabsContent>
<TabsContent ref="indexesScrollerRef" v-if="tableMetadataCapabilities.indexes" value="indexes" class="structure-table-scroller m-0 min-h-0 flex-1 overflow-auto p-0" @scroll.passive="onStructureContentScroll('indexes', $event)">
<TabsContent ref="indexesScrollerRef" v-if="tableMetadataCapabilities.indexes" value="indexes" class="col-start-1 row-start-2 structure-table-scroller m-0 min-h-0 flex-1 overflow-auto p-0" @scroll.passive="onStructureContentScroll('indexes', $event)">
<div v-if="indexesLoading" class="flex items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -4922,7 +4944,7 @@ watch(
<div v-else-if="secondaryMetadataErrors.indexes" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.indexes }}
</div>
<table v-else class="structure-edit-grid border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: indexColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<table v-else ref="indexesTableRef" class="structure-edit-grid border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: indexColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<thead class="sticky top-0 z-10 bg-background">
<tr>
<th
@@ -5028,11 +5050,27 @@ watch(
</table>
</TabsContent>
<div v-if="hasStructureHorizontalOverflow && (activeTab === 'columns' || activeTab === 'indexes')" ref="structureHorizontalScrollbarTrackRef" class="structure-horizontal-scrollbar" @pointerdown="startStructureHorizontalScrollbarDrag">
<div v-if="hasStructureHorizontalOverflow && (activeTab === 'columns' || activeTab === 'indexes')" ref="structureHorizontalScrollbarTrackRef" class="structure-horizontal-scrollbar col-start-1 row-start-3" @pointerdown="startStructureHorizontalScrollbarDrag">
<div ref="structureHorizontalScrollbarThumbRef" class="structure-horizontal-scrollbar__thumb" />
</div>
<TabsContent ref="foreignKeysScrollerRef" v-if="tableMetadataCapabilities.foreignKeys" value="foreignKeys" class="structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]" @scroll.passive="onStructureContentScroll('foreignKeys', $event)">
<div
v-if="hasStructureVerticalOverflow && (activeTab === 'columns' || activeTab === 'indexes')"
ref="structureVerticalScrollbarTrackRef"
class="structure-vertical-scrollbar col-start-2 row-start-2"
:class="{ 'structure-vertical-scrollbar--scrolling': isStructureVerticalScrollbarScrolling, 'structure-vertical-scrollbar--dragging': isStructureVerticalScrollbarDragging }"
@pointerdown="onStructureVerticalScrollbarTrackPointerDown"
>
<div class="structure-vertical-scrollbar__thumb" :style="structureVerticalScrollbarThumbStyle" @pointerdown.stop="onStructureVerticalScrollbarThumbPointerDown" />
</div>
<TabsContent
ref="foreignKeysScrollerRef"
v-if="tableMetadataCapabilities.foreignKeys"
value="foreignKeys"
class="col-start-1 row-start-2 structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]"
@scroll.passive="onStructureContentScroll('foreignKeys', $event)"
>
<div v-if="foreignKeysLoading" class="flex items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -5085,7 +5123,13 @@ watch(
</div>
</TabsContent>
<TabsContent ref="constraintsScrollerRef" v-if="tableMetadataCapabilities.constraints" value="constraints" class="structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]" @scroll.passive="onStructureContentScroll('constraints', $event)">
<TabsContent
ref="constraintsScrollerRef"
v-if="tableMetadataCapabilities.constraints"
value="constraints"
class="col-start-1 row-start-2 structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]"
@scroll.passive="onStructureContentScroll('constraints', $event)"
>
<div v-if="constraintsLoading" class="flex items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -5111,7 +5155,7 @@ watch(
</div>
</TabsContent>
<TabsContent ref="triggersScrollerRef" v-if="tableMetadataCapabilities.triggers" value="triggers" class="structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]" @scroll.passive="onStructureContentScroll('triggers', $event)">
<TabsContent ref="triggersScrollerRef" v-if="tableMetadataCapabilities.triggers" value="triggers" class="col-start-1 row-start-2 structure-card-scroller m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)]" @scroll.passive="onStructureContentScroll('triggers', $event)">
<div v-if="triggersLoading" class="flex items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -5197,7 +5241,14 @@ watch(
</div>
</TabsContent>
<TabsContent ref="ddlScrollerRef" v-if="tableMetadataCapabilities.ddl" value="ddl" force-mount class="structure-card-scroller relative m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)] data-[state=inactive]:hidden" @scroll.passive="onStructureContentScroll('ddl', $event)">
<TabsContent
ref="ddlScrollerRef"
v-if="tableMetadataCapabilities.ddl"
value="ddl"
force-mount
class="col-start-1 row-start-2 structure-card-scroller relative m-0 min-h-0 flex-1 overflow-auto p-[var(--structure-cell-px)] data-[state=inactive]:hidden"
@scroll.passive="onStructureContentScroll('ddl', $event)"
>
<div v-if="ddlLoading" class="flex items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -5436,8 +5487,25 @@ watch(
background-clip: padding-box;
}
.structure-table-scroller {
scrollbar-width: none;
-ms-overflow-style: none;
}
/* Tab panel layout: toolbar, scrollable content and the custom horizontal bar
stack in the first column, while the custom vertical bar occupies the second
column of the content row. */
.structure-tabs {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr) auto;
}
/* Both native scrollbars are replaced: the horizontal one by
`.structure-horizontal-scrollbar`, the vertical one by
`.structure-vertical-scrollbar`. */
.structure-table-scroller::-webkit-scrollbar {
width: 10px;
width: 0;
height: 0;
}
@@ -5485,6 +5553,35 @@ watch(
background-clip: padding-box;
}
.structure-vertical-scrollbar {
position: relative;
width: 10px;
cursor: default;
touch-action: none;
}
.structure-vertical-scrollbar__thumb {
position: absolute;
left: 3px;
width: 4px;
min-height: 24px;
border-radius: 999px;
background: rgba(82, 82, 82, 0.3);
background: color-mix(in oklab, var(--foreground) 30%, transparent);
transition:
left 120ms ease,
width 120ms ease,
background-color 120ms ease;
}
.structure-vertical-scrollbar:hover .structure-vertical-scrollbar__thumb,
.structure-vertical-scrollbar--dragging .structure-vertical-scrollbar__thumb {
left: 2px;
width: 6px;
background: rgba(82, 82, 82, 0.48);
background: color-mix(in oklab, var(--foreground) 48%, transparent);
}
.structure-horizontal-scrollbar {
position: relative;
height: 10px;
@@ -5595,7 +5692,8 @@ watch(
html.dbx-legacy-webview.dark .structure-ddl-editor .cm-scroller::-webkit-scrollbar-thumb,
html.dbx-legacy-webview.dark .structure-card-scroller::-webkit-scrollbar-thumb,
html.dbx-legacy-webview.dark .structure-table-scroller::-webkit-scrollbar-thumb,
html.dbx-legacy-webview.dark .structure-horizontal-scrollbar__thumb {
html.dbx-legacy-webview.dark .structure-horizontal-scrollbar__thumb,
html.dbx-legacy-webview.dark .structure-vertical-scrollbar__thumb {
background: rgba(212, 212, 216, 0.3);
}
@@ -5603,7 +5701,9 @@ html.dbx-legacy-webview.dark .structure-ddl-editor .cm-scroller::-webkit-scrollb
html.dbx-legacy-webview.dark .structure-card-scroller::-webkit-scrollbar-thumb:hover,
html.dbx-legacy-webview.dark .structure-table-scroller::-webkit-scrollbar-thumb:hover,
html.dbx-legacy-webview.dark .structure-horizontal-scrollbar:hover .structure-horizontal-scrollbar__thumb,
html.dbx-legacy-webview.dark .structure-horizontal-scrollbar--dragging .structure-horizontal-scrollbar__thumb {
html.dbx-legacy-webview.dark .structure-horizontal-scrollbar--dragging .structure-horizontal-scrollbar__thumb,
html.dbx-legacy-webview.dark .structure-vertical-scrollbar:hover .structure-vertical-scrollbar__thumb,
html.dbx-legacy-webview.dark .structure-vertical-scrollbar--dragging .structure-vertical-scrollbar__thumb {
background: rgba(212, 212, 216, 0.48);
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB