fix(structure): keep horizontal scrollbar visible
This commit is contained in:
2 files changed
+201
-3
No files matched your search
+33
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user