diff --git a/src/layouts/WorkspaceTabs.css b/src/layouts/WorkspaceTabs.css index 91221e8..3362d54 100644 --- a/src/layouts/WorkspaceTabs.css +++ b/src/layouts/WorkspaceTabs.css @@ -51,7 +51,6 @@ overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; - scroll-behavior: smooth; scrollbar-width: none; } diff --git a/src/layouts/WorkspaceTabs.tsx b/src/layouts/WorkspaceTabs.tsx index fcdd318..3441f1c 100644 --- a/src/layouts/WorkspaceTabs.tsx +++ b/src/layouts/WorkspaceTabs.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { ComponentType } from 'react'; import { Dropdown, Tooltip } from 'antd'; import type { MenuProps } from 'antd'; @@ -28,7 +28,9 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import './WorkspaceTabs.css'; const OPEN_TABS_KEY = 'game-admin-open-tabs'; +const OPEN_TABS_SCROLL_KEY = 'game-admin-open-tabs-scroll-left'; const HOME_PATH = '/'; +let savedTabsScrollLeft = 0; interface WorkspaceTabDefinition { path: string; @@ -65,6 +67,23 @@ function saveOpenedPaths(paths: string[]) { } catch {} } +function saveTabsScrollLeft(scrollLeft: number) { + savedTabsScrollLeft = Math.max(0, scrollLeft); + try { + sessionStorage.setItem(OPEN_TABS_SCROLL_KEY, String(savedTabsScrollLeft)); + } catch {} +} + +function readTabsScrollLeft() { + try { + const storedScrollLeft = Number(sessionStorage.getItem(OPEN_TABS_SCROLL_KEY)); + if (Number.isFinite(storedScrollLeft) && storedScrollLeft >= 0) { + savedTabsScrollLeft = storedScrollLeft; + } + } catch {} + return savedTabsScrollLeft; +} + function readOpenedPaths(activePath: string) { let storedPaths: string[] = []; @@ -94,8 +113,11 @@ export default function WorkspaceTabs() { const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath)); const tabsRailRef = useRef(null); const tabsTrackRef = useRef(null); + const tabScrollBeforePointerRef = useRef(null); + const preserveScrollOnUnmountRef = useRef(false); + const tabViewportReadyRef = useRef(false); const [tabScrollState, setTabScrollState] = useState({ - hasOverflow: false, + hasOverflow: true, canScrollLeft: false, canScrollRight: false, }); @@ -147,35 +169,85 @@ export default function WorkspaceTabs() { }); }, []); + const handleTabScroll = useCallback(() => { + const track = tabsTrackRef.current; + if (!track) return; + if (!tabViewportReadyRef.current) return; + if (tabScrollBeforePointerRef.current !== null || preserveScrollOnUnmountRef.current) return; + + saveTabsScrollLeft(track.scrollLeft); + updateTabScrollState(); + }, [updateTabScrollState]); + + const rememberTabScrollBeforePointer = useCallback(() => { + tabScrollBeforePointerRef.current = tabsTrackRef.current?.scrollLeft ?? savedTabsScrollLeft; + }, []); + + const openWorkspaceTab = useCallback( + (path: string) => { + const track = tabsTrackRef.current; + saveTabsScrollLeft(tabScrollBeforePointerRef.current ?? track?.scrollLeft ?? 0); + tabScrollBeforePointerRef.current = null; + preserveScrollOnUnmountRef.current = path !== activePath; + navigate(path); + }, + [activePath, navigate], + ); + + const syncTabViewport = useCallback(() => { + const track = tabsTrackRef.current; + if (!track || track.clientWidth === 0) return; + + const maxScrollLeft = Math.max(0, track.scrollWidth - track.clientWidth); + const restoredScrollLeft = Math.min(readTabsScrollLeft(), maxScrollLeft); + track.scrollLeft = restoredScrollLeft; + + const activeTabElement = track.querySelector('.workspace-tab.is-active'); + if (activeTabElement) { + const trackRect = track.getBoundingClientRect(); + const activeTabRect = activeTabElement.getBoundingClientRect(); + let nextScrollLeft = track.scrollLeft; + + if (activeTabRect.left < trackRect.left) { + nextScrollLeft += activeTabRect.left - trackRect.left; + } else if (activeTabRect.right > trackRect.right) { + nextScrollLeft += activeTabRect.right - trackRect.right; + } + + track.scrollLeft = Math.max(0, Math.min(nextScrollLeft, maxScrollLeft)); + } + + saveTabsScrollLeft(track.scrollLeft); + tabViewportReadyRef.current = true; + updateTabScrollState(); + }, [updateTabScrollState]); + useEffect(() => { saveOpenedPaths(openedPaths); }, [openedPaths]); + useLayoutEffect(() => { + const animationFrame = window.requestAnimationFrame(syncTabViewport); + return () => window.cancelAnimationFrame(animationFrame); + }, [activePath, openedTabs, syncTabViewport]); + useEffect(() => { const rail = tabsRailRef.current; const track = tabsTrackRef.current; if (!rail || !track) return; - updateTabScrollState(); - const resizeObserver = - typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateTabScrollState); + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncTabViewport); resizeObserver?.observe(rail); resizeObserver?.observe(track); - window.addEventListener('resize', updateTabScrollState); + window.addEventListener('resize', syncTabViewport); return () => { + if (!preserveScrollOnUnmountRef.current) saveTabsScrollLeft(track.scrollLeft); resizeObserver?.disconnect(); - window.removeEventListener('resize', updateTabScrollState); + window.removeEventListener('resize', syncTabViewport); }; - }, [openedTabs, updateTabScrollState]); - - useEffect(() => { - const track = tabsTrackRef.current; - const activeTabElement = track?.querySelector('.workspace-tab.is-active'); - activeTabElement?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); - updateTabScrollState(); - }, [activePath, updateTabScrollState]); + }, [syncTabViewport]); const closeTab = useCallback( (path: string) => { @@ -223,6 +295,7 @@ export default function WorkspaceTabs() { const logout = useCallback(async () => { try { sessionStorage.removeItem(OPEN_TABS_KEY); + sessionStorage.removeItem(OPEN_TABS_SCROLL_KEY); } catch {} await HttpUtil.post('/logout'); window.location.href = window.ADMIN_UI_BASE_PATH || '/'; @@ -335,7 +408,7 @@ export default function WorkspaceTabs() { className="workspace-tabs__track" role="tablist" aria-label="已打开页面" - onScroll={updateTabScrollState} + onScroll={handleTabScroll} > {openedTabs.map((tab) => { const Icon = tab.icon; @@ -349,7 +422,8 @@ export default function WorkspaceTabs() { aria-label={tab.title} aria-selected={active} title={tab.title} - onClick={() => navigate(tab.path)} + onPointerDown={rememberTabScrollBeforePointer} + onClick={() => openWorkspaceTab(tab.path)} > {tab.title} diff --git a/src/test/workspace-tabs.test.tsx b/src/test/workspace-tabs.test.tsx index 0b23a80..a02db74 100644 --- a/src/test/workspace-tabs.test.tsx +++ b/src/test/workspace-tabs.test.tsx @@ -1,10 +1,12 @@ -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; -import { afterEach, expect, test } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import WorkspaceTabs from '@/layouts/WorkspaceTabs'; import { renderWithProviders } from './test-utils'; +const workspaceLayoutSpies: Array<{ mockRestore: () => void }> = []; + function LocationProbe() { const location = useLocation(); return {location.pathname}; @@ -30,6 +32,7 @@ function renderTabs(initialPath: string) { afterEach(() => { sessionStorage.clear(); + workspaceLayoutSpies.splice(0).forEach((spy) => spy.mockRestore()); }); test('opens the current route beside the pinned overview tab', () => { @@ -138,3 +141,81 @@ test('opens and closes the order management tab', () => { expect(screen.getByLabelText('当前路由').textContent).toBe('/'); expect(screen.queryByRole('tab', { name: '订单管理' })).toBeNull(); }); + +test('keeps the horizontal tab position when switching between visible tabs', async () => { + const makeRect = (left: number, right: number) => + ({ + x: left, + y: 0, + top: 0, + bottom: 40, + left, + right, + width: right - left, + height: 40, + toJSON: () => ({}), + }) as DOMRect; + + workspaceLayoutSpies.push( + vi + .spyOn(HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.classList.contains('workspace-tabs__track') || + this.classList.contains('workspace-tabs__rail') + ? 300 + : 0; + }), + vi + .spyOn(HTMLElement.prototype, 'scrollWidth', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.classList.contains('workspace-tabs__track') ? 600 : this.clientWidth; + }), + vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: Element) { + if (this.classList.contains('workspace-tabs__track')) return makeRect(0, 300); + if (this.classList.contains('workspace-tab')) return makeRect(50, 150); + return makeRect(0, 0); + }), + ); + + sessionStorage.setItem('game-admin-open-tabs', '["/","/players","/accounts"]'); + sessionStorage.setItem('game-admin-open-tabs-scroll-left', '200'); + + renderWithProviders( + + + + + + + } + /> + + + + + } + /> + + , + ); + + const playerTrack = document.querySelector('.workspace-tabs__track')!; + await waitFor(() => expect(playerTrack.scrollLeft).toBe(200)); + + const accountTab = screen.getByRole('tab', { name: '账号管理' }); + fireEvent.pointerDown(accountTab); + fireEvent.click(accountTab); + + await waitFor(() => expect(screen.getByLabelText('当前路由').textContent).toBe('/accounts')); + const accountTrack = document.querySelector('.workspace-tabs__track')!; + expect(accountTrack).not.toBe(playerTrack); + await waitFor(() => expect(accountTrack.scrollLeft).toBe(200)); +});