diff --git a/apps/desktop/src/renderer/src/components/tabs/tab-bar-scroll.test.tsx b/apps/desktop/src/renderer/src/components/tabs/tab-bar-scroll.test.tsx new file mode 100644 index 000000000..4f451ab33 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/tabs/tab-bar-scroll.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import { TabBarWithDrag } from './tab-bar-with-drag' + +const mocks = vi.hoisted(() => ({ + tabGroup: null as Record | null +})) + +vi.mock('@memry/i18n/renderer', () => ({ + useT: () => ({ t: (key: string) => key }) +})) + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: ReactNode }) =>
{children}
, + horizontalListSortingStrategy: {} +})) + +vi.mock('@/contexts/day-panel-context', () => ({ + useDayPanel: () => ({ isOpen: false, width: 320, isResizing: false, toggle: vi.fn() }) +})) + +vi.mock('@/contexts/tabs', () => ({ + useTabGroup: () => mocks.tabGroup +})) + +vi.mock('@/components/ui/sidebar', () => ({ + useSidebar: () => ({ state: 'expanded' }) +})) + +vi.mock('./sortable-tab', () => ({ + SortableTab: ({ tab }: { tab: { id: string; title: string } }) => ( +
{tab.title}
+ ) +})) + +vi.mock('./pinned-tab', () => ({ + PinnedTab: ({ tab }: { tab: { title: string } }) =>
{tab.title}
+})) + +vi.mock('./tab-bar-action', () => ({ + TabBarAction: ({ tooltip, onClick }: { tooltip: string; onClick: () => void }) => ( + + ) +})) + +vi.mock('./new-tab-menu', () => ({ + NewTabMenu: () => +})) + +vi.mock('./tab-bar-context-menu', () => ({ + TabBarContextMenu: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('./tab-context-menu', () => ({ + TabContextMenu: ({ children }: { children: ReactNode }) =>
{children}
+})) + +/** Strip geometry jsdom does not compute — drives the chevron gutter state. */ +const strip = { scrollLeft: 0, scrollWidth: 1000, clientWidth: 300 } + +const defineMetric = (name: 'scrollWidth' | 'clientWidth' | 'scrollLeft'): PropertyDescriptor => + ({ + configurable: true, + get: () => strip[name], + set: (value: number) => { + strip[name] = value + } + }) as PropertyDescriptor + +const originalDescriptors = new Map() + +const group = (activeTabId: string): Record => ({ + id: 'group-1', + activeTabId, + tabs: [ + { id: 'tab-1', title: 'First', isPinned: false, type: 'note' }, + { id: 'tab-2', title: 'Second', isPinned: false, type: 'note' }, + { id: 'tab-3', title: 'Third', isPinned: false, type: 'note' } + ] +}) + +describe('TabBarWithDrag active-tab scrolling', () => { + let scrollIntoView: ReturnType + + beforeEach(() => { + strip.scrollLeft = 0 + strip.scrollWidth = 1000 + strip.clientWidth = 300 + + for (const name of ['scrollWidth', 'clientWidth', 'scrollLeft'] as const) { + originalDescriptors.set(name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)) + Object.defineProperty(HTMLElement.prototype, name, defineMetric(name)) + } + + scrollIntoView = vi.fn() + HTMLElement.prototype.scrollIntoView = + scrollIntoView as unknown as HTMLElement['scrollIntoView'] + mocks.tabGroup = group('tab-1') + }) + + afterEach(() => { + for (const [name, descriptor] of originalDescriptors) { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, name, descriptor) + } else { + delete (HTMLElement.prototype as unknown as Record)[name] + } + } + originalDescriptors.clear() + vi.restoreAllMocks() + }) + + it('scrolls the active tab into view once, not again as the chevron gutters resize', () => { + // Mount: the strip overflows, so checkScroll flips canScrollToEnd in the same + // commit as the scroll — the gutter re-render must not re-animate. + render() + expect(scrollIntoView).toHaveBeenCalledTimes(1) + + // Scrolling past the start edge flips canScrollToStart, adding the second + // gutter. The active tab is still on screen, so no second animation. + strip.scrollLeft = 50 + fireEvent.scroll(screen.getByTestId('tab-strip')) + expect( + screen.getByRole('button', { name: 'phaseF.componentsTabsTabBarWithDrag.scrollTabsLeft' }) + ).toBeInTheDocument() + expect(scrollIntoView).toHaveBeenCalledTimes(1) + }) + + it('still scrolls when a different tab becomes active', () => { + const { rerender } = render() + expect(scrollIntoView).toHaveBeenCalledTimes(1) + expect(scrollIntoView.mock.instances[0]).toHaveAttribute('data-tab-id', 'tab-1') + + mocks.tabGroup = group('tab-3') + rerender() + + expect(scrollIntoView).toHaveBeenCalledTimes(2) + expect(scrollIntoView.mock.instances[1]).toHaveAttribute('data-tab-id', 'tab-3') + }) +}) diff --git a/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx b/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx index 39abcd506..d6d022815 100644 --- a/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx @@ -39,6 +39,21 @@ const handleWheel = (e: React.WheelEvent): void => { e.currentTarget.scrollLeft += e.deltaY } +/** + * Is the tab already fully inside the strip's visible area? + * The chevron gutters are inline padding on the strip, so they are subtracted — + * a tab sitting under a chevron does not count as visible. Physical sides are + * used because that is what the rects report in both LTR and RTL. + */ +const isTabFullyVisible = (strip: HTMLElement, tabEl: Element): boolean => { + const stripRect = strip.getBoundingClientRect() + const tabRect = tabEl.getBoundingClientRect() + const style = getComputedStyle(strip) + const visibleLeft = stripRect.left + (parseFloat(style.paddingLeft) || 0) + const visibleRight = stripRect.right - (parseFloat(style.paddingRight) || 0) + return tabRect.left >= visibleLeft - 1 && tabRect.right <= visibleRight + 1 +} + /** * Tab bar with drag-to-reorder support and context menu * DndContext is provided by SplitViewContainer for cross-panel support @@ -107,12 +122,20 @@ export const TabBarWithDrag = ({ // Keep the active tab visible — without this the strip stays pinned at the start // and a newly opened (or newly activated) tab sits past the end edge. // Re-runs on the chevron gutters too: they widen the scroll content one render - // after the scroll fires, which would push the tab back out of view. + // after the scroll fires, which would push the tab back out of view. Those + // follow-up runs used to fire a second and third smooth scrollIntoView for the + // same activation, restarting the animation mid-flight; they now only re-scroll + // when the resized gutters actually pushed the tab out of the strip. + const scrolledTabIdRef = useRef(null) useLayoutEffect(() => { if (!activeTabId || activeDragItem) return - const tabEl = scrollRef.current?.querySelector(`[data-tab-id="${CSS.escape(activeTabId)}"]`) + const strip = scrollRef.current + const tabEl = strip?.querySelector(`[data-tab-id="${CSS.escape(activeTabId)}"]`) + if (!strip || !tabEl) return + if (scrolledTabIdRef.current === activeTabId && isTabFullyVisible(strip, tabEl)) return + scrolledTabIdRef.current = activeTabId // scrollIntoView is not implemented in jsdom - tabEl?.scrollIntoView?.({ inline: 'nearest', block: 'nearest', behavior: 'smooth' }) + tabEl.scrollIntoView?.({ inline: 'nearest', block: 'nearest', behavior: 'smooth' }) }, [activeTabId, regularTabsLength, activeDragItem, canScrollToStart, canScrollToEnd]) // If group doesn't exist, don't render (after all hooks) diff --git a/apps/docs/src/user-guide/tabs-split-view.md b/apps/docs/src/user-guide/tabs-split-view.md index 0a8665782..5bb86d26c 100644 --- a/apps/docs/src/user-guide/tabs-split-view.md +++ b/apps/docs/src/user-guide/tabs-split-view.md @@ -12,7 +12,9 @@ Every note, view, search, project, journal entry, or settings panel opens in a t Across the top of the app. Drag to reorder. Drag onto a pane edge to split. -Tabs share the width of the bar evenly. Widen the window and they grow, up to a comfortable maximum; open more tabs, or narrow the window, and they compress — first the close button tucks away, then the title, leaving just the icon. Once tabs reach that icon-only minimum the bar scrolls sideways instead of shrinking further: scroll over it with a trackpad or mouse wheel, or use the chevrons that appear at either end. The active tab is always scrolled into view, so opening a new tab never leaves it hidden off the end. The **+** button stays pinned at the end of the bar while it scrolls. +Tabs share the width of the bar evenly. Widen the window and they grow, up to a comfortable maximum; open more tabs, or narrow the window, and they compress — first the close button tucks away, then the title, leaving just the icon. Once tabs reach that icon-only minimum the bar scrolls sideways instead of shrinking further: scroll over it with a trackpad or mouse wheel, or use the chevrons that appear at either end. The active tab is always scrolled into view, so opening a new tab never leaves it hidden off the end. That scroll animates once per tab you activate — the chevrons appearing part-way through it no longer restart the animation, and a tab already fully in view is left where it is. The **+** button stays pinned at the end of the bar while it scrolls. + +There is no limit on how many tabs you can have open, and memrynote never closes one for you: use the tab context menu (**Close others**, **Close to the right**) when the bar gets long. ### Tab Context Menu