fix(structure): keep horizontal scrollbar visible

This commit is contained in:
zipg authored and GitHub committed 2026-08-16 11:23:42 +08:00
1 parent 175c22d927
commit 1838a4726e
2 files changed
+201 -3

No files matched your search

@@ -497,6 +497,39 @@ describe("TableStructureEditor local column order notice", () => {
});
});
describe("TableStructureEditor horizontal scrolling", () => {
it("shows a fixed scrollbar for overflowing columns 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, {
clientWidth: { configurable: true, value: 400 },
scrollWidth: { configurable: true, value: 1200 },
scrollLeft: { configurable: true, value: 200, writable: true },
});
scroller.dispatchEvent(new Event("scroll"));
await nextTick();
await nextTick();
const track = root.querySelector<HTMLElement>(".structure-horizontal-scrollbar");
const thumb = root.querySelector<HTMLElement>(".structure-horizontal-scrollbar__thumb");
if (!track || !thumb) throw new Error("Missing fixed horizontal scrollbar");
expect(Number.parseFloat(thumb.style.width)).toBeCloseTo(100 / 3);
expect(Number.parseFloat(thumb.style.left)).toBeCloseTo(100 / 6);
track.getBoundingClientRect = () => DOMRect.fromRect({ width: 300, height: 10 });
document.body.style.userSelect = "text";
track.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 60, isPrimary: true }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 160, isPrimary: true }));
expect(scroller.scrollLeft).toBeCloseTo(600);
expect(document.body.style.userSelect).toBe("none");
window.dispatchEvent(new PointerEvent("pointerup", { isPrimary: true }));
expect(document.body.style.userSelect).toBe("text");
});
});
describe("TableStructureEditor metadata loading", () => {
it("opens the initial DDL tab without starting structure metadata loads", async () => {
await mountLoadingEditor("ddl");
@@ -98,6 +98,9 @@ const foreignKeysScrollerRef = ref<StructureScrollerRef>();
const constraintsScrollerRef = ref<StructureScrollerRef>();
const triggersScrollerRef = ref<StructureScrollerRef>();
const ddlScrollerRef = ref<StructureScrollerRef>();
const structureHorizontalScrollbarTrackRef = ref<HTMLDivElement>();
const structureHorizontalScrollbarThumbRef = ref<HTMLDivElement>();
const hasStructureHorizontalOverflow = ref(false);
const dynamicDataTypeOptionsCache = new Map<string, string[]>();
const sqlHighlighter = ref<SqlHighlighter>();
@@ -868,6 +871,17 @@ let syncingDraft = false;
let draftHydrated = false;
let hydratingRestoredDraft = false;
let structureScrollFrame = 0;
let structureHorizontalScrollbarThumbLeftPercent = 0;
let structureHorizontalScrollbarThumbWidthPercent = 100;
let structureHorizontalScrollbarResizeObserver: ResizeObserver | null = null;
let structureHorizontalScrollbarObserverGeneration = 0;
let structureHorizontalScrollbarPreviousUserSelect: string | null = null;
let structureHorizontalScrollbarDragState: {
scroller: HTMLElement;
trackRect: DOMRect;
thumbOffsetPx: number;
maxScrollLeft: number;
} | null = null;
// A context-menu target may arrive before metadata rows render, so search text
// and row scrolling are tracked separately for each request.
let appliedInitialTargetSearchKey = "";
@@ -895,6 +909,103 @@ function structureScrollerForTab(tab: TableInfoTab): HTMLElement | undefined {
return undefined;
}
function activeStructureHorizontalScroller(): HTMLElement | undefined {
if (activeTab.value !== "columns" && activeTab.value !== "indexes") return undefined;
return structureScrollerForTab(activeTab.value);
}
function applyStructureHorizontalScrollbarThumbStyle(): boolean {
const thumb = structureHorizontalScrollbarThumbRef.value;
if (!thumb) return false;
thumb.style.width = `${structureHorizontalScrollbarThumbWidthPercent}%`;
thumb.style.left = `${structureHorizontalScrollbarThumbLeftPercent}%`;
return true;
}
function updateStructureHorizontalScrollbar(scroller = activeStructureHorizontalScroller()) {
if (!scroller) {
hasStructureHorizontalOverflow.value = false;
return;
}
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
const hasOverflow = maxScrollLeft > 1;
hasStructureHorizontalOverflow.value = hasOverflow;
const thumbWidth = scroller.scrollWidth > 0 ? Math.min(100, Math.max(6, (scroller.clientWidth / scroller.scrollWidth) * 100)) : 100;
structureHorizontalScrollbarThumbWidthPercent = thumbWidth;
structureHorizontalScrollbarThumbLeftPercent = maxScrollLeft > 0 ? (scroller.scrollLeft / maxScrollLeft) * Math.max(0, 100 - thumbWidth) : 0;
if (!applyStructureHorizontalScrollbarThumbStyle() && hasOverflow) void nextTick(applyStructureHorizontalScrollbarThumbStyle);
}
function observeStructureHorizontalScroller() {
const generation = ++structureHorizontalScrollbarObserverGeneration;
structureHorizontalScrollbarResizeObserver?.disconnect();
structureHorizontalScrollbarResizeObserver = null;
const tab = activeTab.value;
void nextTick(() => {
if (generation !== structureHorizontalScrollbarObserverGeneration || tab !== activeTab.value) return;
const scroller = activeStructureHorizontalScroller();
updateStructureHorizontalScrollbar(scroller);
if (!scroller || typeof ResizeObserver === "undefined") return;
structureHorizontalScrollbarResizeObserver = new ResizeObserver(() => updateStructureHorizontalScrollbar(scroller));
structureHorizontalScrollbarResizeObserver.observe(scroller);
for (const child of Array.from(scroller.children)) structureHorizontalScrollbarResizeObserver.observe(child);
});
}
function applyStructureHorizontalScrollbarDrag(clientX: number) {
const dragState = structureHorizontalScrollbarDragState;
if (!dragState) return;
const thumbWidthPx = dragState.trackRect.width * (structureHorizontalScrollbarThumbWidthPercent / 100);
const maxThumbLeftPx = Math.max(1, dragState.trackRect.width - thumbWidthPx);
const thumbLeftPx = Math.min(maxThumbLeftPx, Math.max(0, clientX - dragState.trackRect.left - dragState.thumbOffsetPx));
dragState.scroller.scrollLeft = (thumbLeftPx / maxThumbLeftPx) * dragState.maxScrollLeft;
updateStructureHorizontalScrollbar(dragState.scroller);
}
function onStructureHorizontalScrollbarPointerMove(event: PointerEvent) {
if (!structureHorizontalScrollbarDragState) return;
event.preventDefault();
applyStructureHorizontalScrollbarDrag(event.clientX);
}
function stopStructureHorizontalScrollbarDrag() {
if (!structureHorizontalScrollbarDragState) return;
structureHorizontalScrollbarDragState = null;
structureHorizontalScrollbarTrackRef.value?.classList.remove("structure-horizontal-scrollbar--dragging");
window.removeEventListener("pointermove", onStructureHorizontalScrollbarPointerMove, true);
window.removeEventListener("pointerup", stopStructureHorizontalScrollbarDrag, true);
window.removeEventListener("pointercancel", stopStructureHorizontalScrollbarDrag, true);
document.body.style.userSelect = structureHorizontalScrollbarPreviousUserSelect ?? "";
structureHorizontalScrollbarPreviousUserSelect = null;
}
function startStructureHorizontalScrollbarDrag(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return;
const scroller = activeStructureHorizontalScroller();
const track = structureHorizontalScrollbarTrackRef.value;
if (!scroller || !track || !hasStructureHorizontalOverflow.value) return;
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
if (maxScrollLeft <= 1) return;
const trackRect = track.getBoundingClientRect();
const thumbLeftPx = trackRect.width * (structureHorizontalScrollbarThumbLeftPercent / 100);
const thumbWidthPx = trackRect.width * (structureHorizontalScrollbarThumbWidthPercent / 100);
const pointerX = event.clientX - trackRect.left;
structureHorizontalScrollbarDragState = {
scroller,
trackRect,
thumbOffsetPx: pointerX >= thumbLeftPx && pointerX <= thumbLeftPx + thumbWidthPx ? pointerX - thumbLeftPx : thumbWidthPx / 2,
maxScrollLeft,
};
track.classList.add("structure-horizontal-scrollbar--dragging");
structureHorizontalScrollbarPreviousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onStructureHorizontalScrollbarPointerMove, true);
window.addEventListener("pointerup", stopStructureHorizontalScrollbarDrag, true);
window.addEventListener("pointercancel", stopStructureHorizontalScrollbarDrag, true);
event.preventDefault();
applyStructureHorizontalScrollbarDrag(event.clientX);
}
function restoreStructureScrollPosition(tab = activeTab.value) {
const position = structureScrollPositions.value[tab];
if (!position) return;
@@ -903,12 +1014,14 @@ function restoreStructureScrollPosition(tab = activeTab.value) {
if (!scroller) return;
scroller.scrollTop = Math.max(0, position.scrollTop);
scroller.scrollLeft = Math.max(0, position.scrollLeft);
if (tab === "columns" || tab === "indexes") updateStructureHorizontalScrollbar(scroller);
});
}
function onStructureContentScroll(tab: TableInfoTab, event: Event) {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
if (tab === "columns" || tab === "indexes") updateStructureHorizontalScrollbar(target);
const position: TableStructureEditorViewport = {
scrollTop: Math.max(0, Math.round(target.scrollTop)),
scrollLeft: Math.max(0, Math.round(target.scrollLeft)),
@@ -2478,6 +2591,7 @@ onMounted(() => {
applyInitialStructureTarget();
}
structureEditorReady = true;
observeStructureHorizontalScroller();
if (props.draft?.initialized) {
void hydrateRestoredDraftFromDatabase().then(() => {
applyInitialStructureTarget();
@@ -2494,6 +2608,7 @@ onMounted(() => {
onActivated(() => {
registerStructureEditorShortcuts();
observeStructureHorizontalScroller();
void loadDynamicDataTypeOptions();
if (props.draft?.initialized && !draftHydrated) {
restoreDraft(props.draft);
@@ -2505,9 +2620,18 @@ onActivated(() => {
}
restoreStructureScrollPosition();
});
onDeactivated(unregisterStructureEditorShortcuts);
onDeactivated(() => {
unregisterStructureEditorShortcuts();
structureHorizontalScrollbarObserverGeneration += 1;
structureHorizontalScrollbarResizeObserver?.disconnect();
structureHorizontalScrollbarResizeObserver = null;
stopStructureHorizontalScrollbarDrag();
});
onBeforeUnmount(() => {
stopColumnDragTracking();
stopStructureHorizontalScrollbarDrag();
structureHorizontalScrollbarObserverGeneration += 1;
structureHorizontalScrollbarResizeObserver?.disconnect();
unregisterStructureEditorShortcuts();
clearSqlPreviewState();
if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer);
@@ -2600,6 +2724,7 @@ watch(
);
watch(activeTab, () => {
stopStructureHorizontalScrollbarDrag();
selectedColumnId.value = null;
highlightedColumnId.value = null;
highlightedIndexId.value = null;
@@ -2607,6 +2732,8 @@ watch(activeTab, () => {
syncDraftToParent();
});
watch([activeTab, loading, indexesLoading, visibleColWidths, indexColWidths], observeStructureHorizontalScroller, { deep: true, flush: "post", immediate: true });
watch(
columns,
(items) => {
@@ -2811,7 +2938,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</div>
</div>
<TabsContent ref="columnsScrollerRef" v-if="tableMetadataCapabilities.columns" value="columns" class="m-0 min-h-0 flex-1 overflow-auto p-0" @scroll.passive="onStructureContentScroll('columns', $event)">
<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' }">
<thead class="sticky top-0 z-10 bg-background">
<tr>
@@ -3186,7 +3313,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</table>
</TabsContent>
<TabsContent ref="indexesScrollerRef" v-if="tableMetadataCapabilities.indexes" value="indexes" class="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="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") }}
@@ -3290,6 +3417,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</table>
</TabsContent>
<div v-if="hasStructureHorizontalOverflow && (activeTab === 'columns' || activeTab === 'indexes')" ref="structureHorizontalScrollbarTrackRef" class="structure-horizontal-scrollbar" @pointerdown="startStructureHorizontalScrollbarDrag">
<div ref="structureHorizontalScrollbarThumbRef" class="structure-horizontal-scrollbar__thumb" />
</div>
<TabsContent ref="foreignKeysScrollerRef" v-if="tableMetadataCapabilities.foreignKeys" value="foreignKeys" class="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" />
@@ -3497,6 +3628,40 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</template>
<style scoped>
.structure-table-scroller::-webkit-scrollbar {
width: 8px;
height: 0;
}
.structure-horizontal-scrollbar {
position: relative;
height: 10px;
flex-shrink: 0;
cursor: pointer;
touch-action: none;
background: var(--background);
}
.structure-horizontal-scrollbar__thumb {
position: absolute;
top: 3px;
height: 4px;
min-width: 24px;
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 30%, transparent);
transition:
top 120ms ease,
height 120ms ease,
background-color 120ms ease;
}
.structure-horizontal-scrollbar:hover .structure-horizontal-scrollbar__thumb,
.structure-horizontal-scrollbar--dragging .structure-horizontal-scrollbar__thumb {
top: 2px;
height: 6px;
background: color-mix(in oklab, var(--foreground) 48%, transparent);
}
/* Editable values behave like grid cells, not a row of independent pill controls. */
.structure-edit-grid :deep(.structure-grid-control) {
border-color: transparent;