diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts index ee20fe756e..9c8d90fdfe 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -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(".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(".structure-horizontal-scrollbar"); + const thumb = root.querySelector(".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"); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 5eb8089099..c70cd8b71f 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -98,6 +98,9 @@ const foreignKeysScrollerRef = ref(); const constraintsScrollerRef = ref(); const triggersScrollerRef = ref(); const ddlScrollerRef = ref(); +const structureHorizontalScrollbarTrackRef = ref(); +const structureHorizontalScrollbarThumbRef = ref(); +const hasStructureHorizontalOverflow = ref(false); const dynamicDataTypeOptionsCache = new Map(); const sqlHighlighter = ref(); @@ -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]) => { - + @@ -3186,7 +3313,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
- +
{{ t("common.loading") }} @@ -3290,6 +3417,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { +
+
+
+
@@ -3497,6 +3628,40 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {