From 5016be38a76773241e9230566847f2c55911f987 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:58:14 +0100 Subject: [PATCH 01/11] feat(core): overflow option + tab-list seam for multi-row tabs (PR-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-core groundwork for the MultiRowTabsModule (the wrap mode itself ships in dockview-modules). No behaviour change — all additions are inert until a module toggles them. - `overflow` option (`DockviewOverflowOptions`): `mode: 'dropdown' | 'wrap'` (default dropdown), `maxRows`, plus `search`/`mru`/`thumbnails` reserved for the AdvancedOverflowModule. Registered in PROPERTY_KEYS_DOCKVIEW. - Tab-list element seam: `Tabs.tabsListElement` → `ITabsContainer` → `DockviewGroupPanelModel.tabsListElement` → `IMultiRowTabsHost.getTabsListElement`, so the wrap controller can measure rows + toggle the wrap class. Returns undefined for a hidden header. - `IMultiRowTabsHost` / `IMultiRowTabsService` contracts + `multiRowTabsService` ServiceCollection slot; `relayoutGroup` host hook wraps the free `DockviewGroupPanel.relayout()` (#1384). - Inert wrap CSS: `.dv-tabs-container--wrap` (flex-wrap, horizontal headers only) + header `:has()` growth rule. Applied only by the module. Exports regenerated (+DockviewOverflowOptions, OverflowThumbnailRenderer, IMultiRowTabsHost, IMultiRowTabsService). Seam unit test added. Co-Authored-By: Claude Opus 4.8 (1M context) --- __generated__/dockview-core-exports.txt | 4 + .../dockview/multiRowTabsSeam.spec.ts | 101 ++++++++++++++++++ .../dockview/components/titlebar/tabs.scss | 14 +++ .../src/dockview/components/titlebar/tabs.ts | 9 ++ .../components/titlebar/tabsContainer.scss | 11 ++ .../components/titlebar/tabsContainer.ts | 6 ++ .../src/dockview/dockviewComponent.ts | 18 +++- .../src/dockview/dockviewGroupPanelModel.ts | 7 ++ .../src/dockview/moduleContracts.ts | 34 ++++++ .../dockview-core/src/dockview/modules.ts | 2 + .../dockview-core/src/dockview/options.ts | 63 +++++++++++ packages/dockview-core/src/index.ts | 2 + 12 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 packages/dockview-core/src/__tests__/dockview/multiRowTabsSeam.spec.ts diff --git a/__generated__/dockview-core-exports.txt b/__generated__/dockview-core-exports.txt index 22995b408..f13515e91 100644 --- a/__generated__/dockview-core-exports.txt +++ b/__generated__/dockview-core-exports.txt @@ -67,6 +67,7 @@ DockviewModule DockviewMutableDisposable DockviewOptions DockviewOrigin +DockviewOverflowOptions DockviewPanel DockviewPanelApi DockviewPanelMoveParams @@ -153,6 +154,8 @@ IHeaderActionsRenderer IKeyboardDockingService ILayoutHistoryHost ILayoutHistoryService +IMultiRowTabsHost +IMultiRowTabsService INodeDescriptor IPanePart IPanel @@ -194,6 +197,7 @@ MovePanelEvent MovementOptions MovementOptions2 Orientation +OverflowThumbnailRenderer PROPERTY_KEYS_DOCKVIEW PROPERTY_KEYS_GRIDVIEW PROPERTY_KEYS_PANEVIEW diff --git a/packages/dockview-core/src/__tests__/dockview/multiRowTabsSeam.spec.ts b/packages/dockview-core/src/__tests__/dockview/multiRowTabsSeam.spec.ts new file mode 100644 index 000000000..76c436ba2 --- /dev/null +++ b/packages/dockview-core/src/__tests__/dockview/multiRowTabsSeam.spec.ts @@ -0,0 +1,101 @@ +import { DockviewComponent } from '../../dockview/dockviewComponent'; +import { IContentRenderer } from '../../dockview/types'; +import { GroupPanelPartInitParameters } from '../../dockview/types'; +import { PanelUpdateEvent } from '../../panel/types'; + +class TestContentPart implements IContentRenderer { + public element = document.createElement('div'); + + constructor(public readonly id: string) { + this.element.className = `content-part-${id}`; + } + + init(_params: GroupPanelPartInitParameters): void { + // + } + + layout(_width: number, _height: number): void { + // + } + + update(_event: PanelUpdateEvent): void { + // + } + + dispose(): void { + // + } +} + +/** + * The free seam the `MultiRowTabsModule` consumes (PR-A): the tab-list element + * exposed up through the group model + host, and the `relayoutGroup` host hook. + * The module itself (wrap layout) is tested in `dockview-modules`. + */ +describe('multi-row tabs seam', () => { + function createComponent() { + return new DockviewComponent(document.createElement('div'), { + createComponent(options) { + switch (options.name) { + case 'component': + return new TestContentPart(options.id); + default: + throw new Error(`unsupported ${options.name}`); + } + }, + }); + } + + test('group model exposes the tab-list element (.dv-tabs-container)', () => { + const cut = createComponent(); + const panel = cut.addPanel({ id: 'panel1', component: 'component' }); + + const tabsList = panel.group.model.tabsListElement; + expect(tabsList).toBeInstanceOf(HTMLElement); + expect(tabsList.classList.contains('dv-tabs-container')).toBe(true); + expect(panel.group.element.contains(tabsList)).toBe(true); + + cut.dispose(); + }); + + test('host getTabsListElement returns the list, undefined when the header is hidden', () => { + const cut = createComponent(); + + const withHeader = cut.addPanel({ id: 'a', component: 'component' }); + expect(cut.getTabsListElement(withHeader.group)).toBe( + withHeader.group.model.tabsListElement + ); + + const hidden = cut.addPanel({ + id: 'b', + component: 'component', + position: { direction: 'right' }, + }); + hidden.group.model.header.hidden = true; + expect(cut.getTabsListElement(hidden.group)).toBeUndefined(); + + cut.dispose(); + }); + + test('host relayoutGroup re-runs the group layout', () => { + const cut = createComponent(); + const panel = cut.addPanel({ id: 'panel1', component: 'component' }); + + const spy = jest.spyOn(panel.group, 'relayout'); + cut.relayoutGroup(panel.group); + expect(spy).toHaveBeenCalledTimes(1); + + cut.dispose(); + }); + + test('overflow option is accepted and exposed on options', () => { + const cut = new DockviewComponent(document.createElement('div'), { + createComponent: (options) => new TestContentPart(options.id), + overflow: { mode: 'wrap', maxRows: 2 }, + }); + + expect(cut.options.overflow).toEqual({ mode: 'wrap', maxRows: 2 }); + + cut.dispose(); + }); +}); diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss index 6c1756afb..f7b30367c 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss @@ -5,6 +5,20 @@ overflow: auto; scrollbar-width: thin; // firefox + /** + * Multi-row (wrapping) tabs. Inert until the `MultiRowTabsModule` toggles + * `.dv-tabs-container--wrap` on this element (`overflow.mode: 'wrap'`): + * tabs wrap onto multiple rows instead of clipping into the dropdown, and + * the strip grows to fit (the header grows via the `:has()` rule in + * tabsContainer.scss; the free header-aware content-sizing seam then shrinks + * the content area to match). Restricted to horizontal headers in v1. + */ + &.dv-tabs-container--wrap:not(.dv-tabs-container-vertical) { + flex-wrap: wrap; + height: auto; + overflow: visible; + } + /* GPU optimizations for smooth scrolling */ will-change: scroll-position; transform: translate3d(0, 0, 0); diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index aa9823ddc..b6c511392 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -136,6 +136,15 @@ export class Tabs extends CompositeDisposable { return this._element; } + /** + * The scrollable tab list (`.dv-tabs-container`) holding the tab elements — + * exposed (read-only) so the multi-row wrap controller can measure the + * wrapped row count and toggle the wrap class. Not the outer header element. + */ + get tabsListElement(): HTMLElement { + return this._tabsList; + } + set voidContainer(el: HTMLElement | null) { this._voidContainerListeners?.dispose(); this._voidContainerListeners = null; diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss index a49d8a748..30a29f61f 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss @@ -6,6 +6,17 @@ height: var(--dv-tabs-and-actions-container-height); font-size: var(--dv-tabs-and-actions-container-font-size); + /** + * Multi-row (wrapping) tabs: when the tab list wraps onto multiple rows + * (`.dv-tabs-container--wrap`, toggled by the `MultiRowTabsModule`), the + * header grows to fit instead of clipping. Inert otherwise — the single-row + * fixed height above is unchanged. + */ + &:has(.dv-tabs-container--wrap) { + height: auto; + min-height: var(--dv-tabs-and-actions-container-height); + } + &.dv-single-tab.dv-full-width-single-tab { .dv-scrollable { flex-grow: 1; diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index ad845888f..d522e0222 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -46,6 +46,8 @@ export interface GroupDragEvent { export interface ITabsContainer extends IDisposable { readonly element: HTMLElement; + /** The scrollable tab list element (`.dv-tabs-container`); see `Tabs.tabsListElement`. */ + readonly tabsListElement: HTMLElement; readonly panels: string[]; readonly size: number; readonly onDrop: Event; @@ -157,6 +159,10 @@ export class TabsContainer return this._element; } + get tabsListElement(): HTMLElement { + return this.tabs.tabsListElement; + } + constructor( private readonly accessor: DockviewComponent, private readonly group: DockviewGroupPanel diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 24a2178e8..411ead50d 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -96,6 +96,7 @@ import { IDropGuideHost, ILayoutHistoryHost, LayoutHistoryChangeEvent, + IMultiRowTabsHost, ISmartGuidesHost, SmartGuidesSnapEvent, SmartGuidesSnapTogetherEvent, @@ -529,7 +530,8 @@ export class DockviewComponent ILayoutHistoryHost, IDropGuideHost, ISmartGuidesHost, - IAutoHideEdgeGroupHost + IAutoHideEdgeGroupHost, + IMultiRowTabsHost { private readonly nextGroupId = sequentialNumberGenerator(); private readonly _deserializer = new DefaultDockviewDeserialzier(this); @@ -1006,6 +1008,20 @@ export class DockviewComponent : content; } + /** IMultiRowTabsHost — the group's scrollable tab list (`.dv-tabs-container`), + * the element the wrap controller toggles + measures. */ + getTabsListElement(group: DockviewGroupPanel): HTMLElement | undefined { + return group.model.header.hidden + ? undefined + : group.model.tabsListElement; + } + + /** IMultiRowTabsHost — re-run a group's layout so a wrapped-header height + * change propagates to the content + active panel. */ + relayoutGroup(group: DockviewGroupPanel): void { + group.relayout(); + } + /** IDropGuideHost — the layout root (`.dv-dockview`, a positioned element), * the surface the outer-cell landing preview is drawn over. */ getLayoutElement(): HTMLElement { diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 86f825148..d1a4fa82a 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -193,6 +193,7 @@ export interface IDockviewGroupPanelModel extends IPanel { setActive(isActive: boolean): void; initialize(): void; relayout(): void; + readonly tabsListElement: HTMLElement; // state isPanelActive: (panel: IDockviewPanel) => boolean; indexOf(panel: IDockviewPanel): number; @@ -418,6 +419,12 @@ export class DockviewGroupPanelModel return this.tabsContainer; } + /** The scrollable tab list element (`.dv-tabs-container`) — exposed for the + * multi-row wrap controller to measure rows / toggle the wrap class. */ + get tabsListElement(): HTMLElement { + return this.tabsContainer.tabsListElement; + } + /** The panel whose tab owns `element` (the tab itself or a descendant of * it), or `undefined` when the target isn't a tab. The robust inverse of a * tab→panel lookup — no positional/DOM-order assumptions. */ diff --git a/packages/dockview-core/src/dockview/moduleContracts.ts b/packages/dockview-core/src/dockview/moduleContracts.ts index 853e1c790..afff8b3fc 100644 --- a/packages/dockview-core/src/dockview/moduleContracts.ts +++ b/packages/dockview-core/src/dockview/moduleContracts.ts @@ -461,3 +461,37 @@ export interface IAutoHideEdgeGroupService extends IDisposable { /** Auto-hide (collapse to strip) the edge group at `position`. */ autoHide(position: EdgeGroupPosition): void; } + +// --- MultiRowTabs --- + +/** + * The narrow surface the multi-row (wrapping) tabs service needs from the host + * (`DockviewComponent`). The wrap layout itself is CSS; the service only toggles + * the wrap class on a group's tab list, measures the wrapped row count, and asks + * the host to relayout so the now-taller header shrinks the content area + * (the free header-aware content-sizing seam does the actual subtraction). It + * owns no tab model, overflow detection, or panel sizing math. + */ +export interface IMultiRowTabsHost { + readonly options: DockviewComponentOptions; + /** Fires when any group is added — the service attaches a wrap controller. */ + readonly onDidAddGroup: Event; + readonly onDidRemoveGroup: Event; + /** + * The scrollable tab list element (`.dv-tabs-container`) for a group — the + * element the wrap class is toggled on and whose child tab geometry the + * row-count measurement reads. Undefined if the group has no tab header. + */ + getTabsListElement(group: DockviewGroupPanel): HTMLElement | undefined; + /** + * Re-run a group's layout with its current dimensions so a header-height + * change (a new wrapped row) propagates to the content + active panel. Wraps + * the free `DockviewGroupPanel.relayout()`. + */ + relayoutGroup(group: DockviewGroupPanel): void; +} + +export interface IMultiRowTabsService extends IDisposable { + /** Whether wrap mode is currently active (`overflow.mode === 'wrap'`). */ + readonly enabled: boolean; +} diff --git a/packages/dockview-core/src/dockview/modules.ts b/packages/dockview-core/src/dockview/modules.ts index 5941630c4..5bbec0b58 100644 --- a/packages/dockview-core/src/dockview/modules.ts +++ b/packages/dockview-core/src/dockview/modules.ts @@ -25,6 +25,7 @@ import { IDropGuideService, IKeyboardDockingService, ILayoutHistoryService, + IMultiRowTabsService, ISmartGuidesService, ITabGroupChipsService, } from './moduleContracts'; @@ -46,6 +47,7 @@ export interface ServiceCollection { dropGuideService?: IDropGuideService; smartGuidesService?: ISmartGuidesService; autoHideEdgeGroupService?: IAutoHideEdgeGroupService; + multiRowTabsService?: IMultiRowTabsService; } export interface DockviewModule { diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 0e21f2dab..bbbb37066 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -236,6 +236,54 @@ export interface SmartGuidesOptions { className?: string; } +/** + * Per-row content preview for the advanced overflow dropdown. The substrate + * cannot snapshot arbitrary panel content, so the app supplies the preview. + * Reserved for `AdvancedOverflowModule` (see `advanced-overflow.md`); ignored + * until that module is present. + */ +export type OverflowThumbnailRenderer = ( + panel: IDockviewPanel +) => + | HTMLElement + | { element: HTMLElement; dispose?: () => void } + | { src: string } + | undefined; + +/** + * Tab-header overflow behaviour. One shared block across the overflow axis: + * `mode` chooses dropdown vs wrap; the remaining fields enrich the dropdown. + * Each capability names the module it needs — without that module the field is + * ignored and the free single-row strip + dropdown is used. + */ +export interface DockviewOverflowOptions { + /** + * What happens when tabs don't fit. Default `'dropdown'` (today's free + * path). `'wrap'` requires the `MultiRowTabsModule`. + */ + mode?: 'dropdown' | 'wrap'; + /** + * Wrap mode only: cap the number of header rows; the surplus tabs spill to + * the dropdown. Default: unbounded. Requires the `MultiRowTabsModule`. + */ + maxRows?: number; + /** + * Filter input over the group's tabs. Reserved for `AdvancedOverflowModule`; + * ignored until that module is present. Default: false. + */ + search?: boolean | { placeholder?: string; scope?: 'overflow' | 'group' }; + /** + * Order the dropdown by most-recently-activated. Reserved for + * `AdvancedOverflowModule`; ignored until present. Default: false. + */ + mru?: boolean; + /** + * Per-row content preview in the dropdown. Reserved for + * `AdvancedOverflowModule`; ignored until present. + */ + thumbnails?: boolean | OverflowThumbnailRenderer; +} + export interface DockviewOptions { /** * Disable the auto-resizing which is controlled through a `ResizeObserver`. @@ -330,6 +378,20 @@ export interface DockviewOptions { noPanelsOverlay?: 'emptyGroup' | 'watermark'; theme?: DockviewTheme; disableTabsOverflowList?: boolean; + /** + * How the tab header behaves when there are more tabs than fit on one row. + * + * The single-row strip + chevron dropdown is the default and is free. The + * `'wrap'` mode (tabs wrap onto multiple rows and the header grows) requires + * the `MultiRowTabsModule`; without that module `'wrap'` is ignored and the + * dropdown is used. The `search`/`mru`/`thumbnails` fields enrich the + * dropdown and require the `AdvancedOverflowModule`; they are ignored when + * that module is absent. + * + * Omitting `overflow` is identical to today's behaviour + * (`mode: 'dropdown'`). + */ + overflow?: DockviewOverflowOptions; /** * Select `native` to use built-in scrollbar behaviours and `custom` to use an internal implementation * that allows for improved scrollbar overlay UX. @@ -566,6 +628,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => { dndGuide: undefined, theme: undefined, disableTabsOverflowList: undefined, + overflow: undefined, scrollbars: undefined, getTabContextMenuItems: undefined, getTabGroupChipContextMenuItems: undefined, diff --git a/packages/dockview-core/src/index.ts b/packages/dockview-core/src/index.ts index 274314b57..3fa049f84 100644 --- a/packages/dockview-core/src/index.ts +++ b/packages/dockview-core/src/index.ts @@ -209,6 +209,8 @@ export { ILayoutHistoryService, LayoutHistoryChangeEvent, LayoutHistoryKind, + IMultiRowTabsHost, + IMultiRowTabsService, ISmartGuidesHost, ISmartGuidesService, SmartGuidesSnapPosition, From c525099810c564354708bf4716c7cf5c26de60d1 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:29:21 +0100 Subject: [PATCH 02/11] =?UTF-8?q?feat(modules):=20MultiRowTabsModule=20?= =?UTF-8?q?=E2=80=94=20wrapping=20tabs=20(PR-B,=20Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `overflow.mode: 'wrap'`: tabs wrap onto multiple rows and the header grows, instead of clipping into the chevron dropdown. Phase 2 (wrap render mode); true cross-row DnD reorder is a later phase. - `MultiRowTabsModule` / `MultiRowTabsService` in dockview-modules, added to the default `Modules` bundle. Per-group `WrapController` toggles the inert `.dv-tabs-container--wrap` class (PR-A) on the group's tab list and, on integer row-count change (ResizeObserver + panel add/remove), calls the host `relayoutGroup` so the taller header shrinks the content area via the free content-sizing seam. v1 wraps horizontal headers only (hidden/vertical are a no-op). - core: `tabs.ts` skips the x-only smooth in-strip reorder for a wrapped strip (it breaks across rows); drag-to-detach + `default`-mode per-tab drop targets are unaffected. 2-D reorder ships later. - Tests: 6 jsdom unit (class application, gating, vertical no-op, relayout, removability via the shared spec) + 3 Playwright (real wrap geometry: rows, header growth, content shrink). Fixture opts in via `?overflow=wrap`. core 1107 · modules 205 · e2e 29, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/fixtures/index.html | 21 +++ e2e/tests/multi-row-tabs.spec.ts | 86 +++++++++ .../src/dockview/components/titlebar/tabs.ts | 9 + .../src/__tests__/multiRowTabs.spec.ts | 139 +++++++++++++++ packages/dockview-modules/src/index.ts | 3 + .../src/multiRowTabsService.ts | 164 ++++++++++++++++++ 6 files changed, 422 insertions(+) create mode 100644 e2e/tests/multi-row-tabs.spec.ts create mode 100644 packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts create mode 100644 packages/dockview-modules/src/multiRowTabsService.ts diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index 86c72dac7..7534f5f48 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -49,8 +49,16 @@ ]); const el = document.getElementById('app'); + // Opt-in multi-row (wrapping) tabs via `?overflow=wrap` so the + // wrap behaviour is exercised without affecting other specs. + const params = new URLSearchParams(location.search); + const overflow = + params.get('overflow') === 'wrap' + ? { mode: 'wrap' } + : undefined; const dockview = new core.DockviewComponent(el, { keyboardNavigation: true, + overflow, // Record layout history so the harness can exercise undo/redo // (incl. async popout re-open). layoutHistory: { enabled: true }, @@ -108,6 +116,19 @@ // The content-area dimensions a panel was last laid out // with — the seam reports the group box minus the header. panelDimensions: (id) => panelDimensions[id], + // Add `count` long-titled tabs to a single group so the + // strip overflows its width — under `?overflow=wrap` they + // wrap onto multiple rows. + setupWrapTabs: (count) => { + for (let i = 0; i < count; i++) { + const id = `wrap-tab-${i}`; + panels[id] = dockview.addPanel({ + id, + component: 'default', + title: `wrap-tab-long-title-${i}`, + }); + } + }, closePanel: (id) => panels[id] && panels[id].api.close(), // Create a new panel and move it to the right of the // popped-out group — the move lands in the popout's own diff --git a/e2e/tests/multi-row-tabs.spec.ts b/e2e/tests/multi-row-tabs.spec.ts new file mode 100644 index 000000000..d0ba2a255 --- /dev/null +++ b/e2e/tests/multi-row-tabs.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '@playwright/test'; + +/** + * Multi-row (wrapping) tabs — Phase 2 wrap render mode. Real-browser only: it + * depends on the tab strip actually wrapping and the header growing, which jsdom + * (no layout) can't produce. The fixture opts into `overflow.mode: 'wrap'` via + * `?overflow=wrap`. + */ +test.describe('multi-row tabs (wrap mode)', () => { + const setup = async (page) => { + await page.goto('/e2e/fixtures/index.html?overflow=wrap'); + await page.waitForFunction(() => (window as any).__ready === true); + await page.evaluate(() => (window as any).__dv.setupWrapTabs(20)); + }; + + test('tabs wrap onto multiple rows and the header grows', async ({ + page, + }) => { + await setup(page); + + const tabsList = page.locator('.dv-tabs-container').first(); + await expect(tabsList).toHaveClass(/dv-tabs-container--wrap/); + + // the tabs occupy more than one row (more than one distinct offsetTop) + const rowCount = await page.evaluate(() => { + const list = document.querySelector('.dv-tabs-container')!; + const tops = new Set(); + list.querySelectorAll('.dv-tab').forEach((t) => + tops.add(t.offsetTop) + ); + return tops.size; + }); + expect(rowCount).toBeGreaterThan(1); + + // the header grew past a single row (default row height is 44px) + const header = (await page + .locator('.dv-tabs-and-actions-container') + .first() + .boundingBox())!; + expect(header.height).toBeGreaterThan(44); + }); + + test('content shrinks to the area below the multi-row header', async ({ + page, + }) => { + await setup(page); + + const groupBox = (await page + .locator('.dv-groupview') + .first() + .boundingBox())!; + const headerBox = (await page + .locator('.dv-tabs-and-actions-container') + .first() + .boundingBox())!; + const contentBox = (await page + .locator('.dv-content-container') + .first() + .boundingBox())!; + + // multi-row header + expect(headerBox.height).toBeGreaterThan(44); + + // the active panel was laid out with the shrunk content area, not the + // header-inclusive group box (the content-sizing seam + relayout) + const reported = await page.evaluate(() => + (window as any).__dv.panelDimensions('wrap-tab-19') + ); + expect(reported).toBeTruthy(); + expect(reported.height).toBeCloseTo(contentBox.height, 0); + expect(reported.height).toBeCloseTo( + groupBox.height - headerBox.height, + 0 + ); + }); + + test('no wrap class without the opt-in', async ({ page }) => { + await page.goto('/e2e/fixtures/index.html'); + await page.waitForFunction(() => (window as any).__ready === true); + await page.evaluate(() => (window as any).__dv.setupWrapTabs(20)); + + await expect( + page.locator('.dv-tabs-container').first() + ).not.toHaveClass(/dv-tabs-container--wrap/); + }); +}); diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index b6c511392..92bfa5bf5 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -1310,6 +1310,15 @@ export class Tabs extends CompositeDisposable { return false; } + // Multi-row wrap mode: the smooth single-row reorder is x-only and + // breaks across wrapped rows, so skip it for a wrapped strip. Dragging a + // tab out to detach/redock still works (that's a separate path), and the + // `default` animation mode keeps its per-tab drop targets (2-D safe). + // True cross-row reorder ships in a later phase. + if (this._tabsList.classList.contains('dv-tabs-container--wrap')) { + return false; + } + // Stale-state guard: if a previous drag's anim state is still here // but the current drag is a different identity, drop the stale one // so the new drag starts from a clean slate. diff --git a/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts b/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts new file mode 100644 index 000000000..4f2a58338 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts @@ -0,0 +1,139 @@ +import { DockviewComponent } from 'dockview-core'; +import { IContentRenderer } from 'dockview-core'; + +class TestPanel implements IContentRenderer { + element = document.createElement('div'); + constructor() { + this.element.className = 'dv-test-content'; + } + init(): void { + // noop + } + layout(): void { + // noop + } + dispose(): void { + // noop + } +} + +const WRAP_CLASS = 'dv-tabs-container--wrap'; + +/** + * Multi-row (wrapping) tabs — Phase 2 (wrap render mode). The module toggles the + * `.dv-tabs-container--wrap` class on a group's tab list and relayouts on + * row-count change. The actual wrapping geometry (rows, header growth, content + * shrink) is e2e-only — jsdom has no layout — so these tests cover the + * deterministic wiring: class application, gating, and cleanup. + */ +describe('multi-row tabs (wrap mode)', () => { + let container: HTMLElement; + + const make = ( + overflow?: DockviewComponent['options']['overflow'] + ): DockviewComponent => { + container = document.createElement('div'); + document.body.appendChild(container); + const dockview = new DockviewComponent(container, { + createComponent: () => new TestPanel(), + overflow, + }); + dockview.layout(1000, 1000); + return dockview; + }; + + afterEach(() => { + container?.remove(); + }); + + test("wrap mode applies the wrap class to a group's tab list", () => { + const dockview = make({ mode: 'wrap' }); + const panel = dockview.addPanel({ id: 'a', component: 'default' }); + + expect( + panel.group.model.tabsListElement.classList.contains(WRAP_CLASS) + ).toBe(true); + + dockview.dispose(); + }); + + test('default (no overflow) leaves the strip single-row — no wrap class', () => { + const dockview = make(); + const panel = dockview.addPanel({ id: 'a', component: 'default' }); + + expect( + panel.group.model.tabsListElement.classList.contains(WRAP_CLASS) + ).toBe(false); + + dockview.dispose(); + }); + + test("mode: 'dropdown' leaves the strip single-row — no wrap class", () => { + const dockview = make({ mode: 'dropdown' }); + const panel = dockview.addPanel({ id: 'a', component: 'default' }); + + expect( + panel.group.model.tabsListElement.classList.contains(WRAP_CLASS) + ).toBe(false); + + dockview.dispose(); + }); + + test('wrap is a no-op on a vertical header (v1)', () => { + container = document.createElement('div'); + document.body.appendChild(container); + const dockview = new DockviewComponent(container, { + createComponent: () => new TestPanel(), + overflow: { mode: 'wrap' }, + // groups are created with a vertical header + defaultHeaderPosition: 'left', + }); + dockview.layout(1000, 1000); + + const panel = dockview.addPanel({ id: 'a', component: 'default' }); + + expect( + panel.group.model.tabsListElement.classList.contains( + 'dv-tabs-container-vertical' + ) + ).toBe(true); + expect( + panel.group.model.tabsListElement.classList.contains(WRAP_CLASS) + ).toBe(false); + + dockview.dispose(); + }); + + test('wrap relayouts the group when first applied', () => { + const dockview = make({ mode: 'wrap' }); + const group = dockview.addGroup(); + const spy = jest.spyOn(group, 'relayout'); + + dockview.addPanel({ + id: 'a', + component: 'default', + position: { referenceGroup: group }, + }); + + expect(spy).toHaveBeenCalled(); + + dockview.dispose(); + }); + + test('removing a group disposes its wrap controller without throwing', () => { + const dockview = make({ mode: 'wrap' }); + const a = dockview.addPanel({ id: 'a', component: 'default' }); + const b = dockview.addPanel({ + id: 'b', + component: 'default', + position: { direction: 'right' }, + }); + + expect(() => a.group.api.close()).not.toThrow(); + expect( + b.group.model.tabsListElement.classList.contains(WRAP_CLASS) + ).toBe(true); + + dockview.dispose(); + }); +}); diff --git a/packages/dockview-modules/src/index.ts b/packages/dockview-modules/src/index.ts index fe9e2fb20..4082b8a1c 100644 --- a/packages/dockview-modules/src/index.ts +++ b/packages/dockview-modules/src/index.ts @@ -7,6 +7,7 @@ import { LayoutHistoryModule } from './layoutHistoryService'; import { DropGuideModule } from './dropGuideService'; import { SmartGuidesModule } from './smartGuidesService'; import { AutoHideEdgeGroupModule } from './autoHideEdgeGroupService'; +import { MultiRowTabsModule } from './multiRowTabsService'; export { TabGroupChipsService, @@ -28,6 +29,7 @@ export { AutoHideEdgeGroupService, AutoHideEdgeGroupModule, } from './autoHideEdgeGroupService'; +export { MultiRowTabsService, MultiRowTabsModule } from './multiRowTabsService'; // Advanced keyboard docking is an optional module — exported for explicit // registration, but not part of the default `Modules` bundle below. export { @@ -48,4 +50,5 @@ export const Modules: DockviewModule[] = [ DropGuideModule, SmartGuidesModule, AutoHideEdgeGroupModule, + MultiRowTabsModule, ]; diff --git a/packages/dockview-modules/src/multiRowTabsService.ts b/packages/dockview-modules/src/multiRowTabsService.ts new file mode 100644 index 000000000..219746636 --- /dev/null +++ b/packages/dockview-modules/src/multiRowTabsService.ts @@ -0,0 +1,164 @@ +import { + DockviewCompositeDisposable as CompositeDisposable, + DockviewGroupPanel, + DockviewOverflowOptions, + defineModule, +} from 'dockview-core'; +import { IMultiRowTabsHost, IMultiRowTabsService } from 'dockview-core'; + +const WRAP_CLASS = 'dv-tabs-container--wrap'; + +function isWrapMode(overflow: DockviewOverflowOptions | undefined): boolean { + return typeof overflow === 'object' && overflow?.mode === 'wrap'; +} + +/** + * The number of wrapped rows in a tab list — the count of distinct tab + * `offsetTop` values. Zero for an empty strip. (jsdom reports `offsetTop` 0 for + * every tab, so this is 1 there regardless of width — real wrapping is e2e.) + */ +function countRows(list: HTMLElement): number { + const tabs = Array.from(list.querySelectorAll('.dv-tab')); + if (tabs.length === 0) { + return 0; + } + const tops = new Set(); + for (const tab of tabs) { + tops.add(tab.offsetTop); + } + return tops.size; +} + +/** + * Drives wrap layout for one group. The wrap itself is CSS (the inert + * `.dv-tabs-container--wrap` rules in core); this controller only toggles that + * class on the group's tab list and, when the wrapped row count changes, asks + * the host to relayout so the now-taller header shrinks the content area (the + * free header-aware content-sizing seam does the subtraction). v1 wraps only + * horizontal headers — a hidden or vertical header is a no-op. + */ +class WrapController extends CompositeDisposable { + private _wrapped = false; + private _rowCount = 0; + private _observer: ResizeObserver | undefined; + + constructor( + private readonly group: DockviewGroupPanel, + private readonly host: IMultiRowTabsHost + ) { + super(); + + this.addDisposables( + // A tab added/removed can change the row count without a width + // change, so re-measure on panel churn too. + this.group.model.onDidAddPanel(() => this.measure()), + this.group.model.onDidRemovePanel(() => this.measure()), + { dispose: () => this.teardown() } + ); + + this.apply(); + } + + apply(): void { + const list = this.host.getTabsListElement(this.group); + const wrap = + isWrapMode(this.host.options.overflow) && + !!list && + !list.classList.contains('dv-tabs-container-vertical'); + + if (wrap === this._wrapped) { + return; + } + this._wrapped = wrap; + + if (wrap && list) { + list.classList.add(WRAP_CLASS); + this._observer = new ResizeObserver(() => this.measure()); + this._observer.observe(list); + this.measure(); + } else { + this.teardown(); + } + } + + private measure(): void { + if (!this._wrapped) { + return; + } + const list = this.host.getTabsListElement(this.group); + if (!list) { + return; + } + const rows = countRows(list); + if (rows !== this._rowCount) { + this._rowCount = rows; + this.host.relayoutGroup(this.group); + } + } + + private teardown(): void { + this._observer?.disconnect(); + this._observer = undefined; + this._rowCount = 0; + this.host.getTabsListElement(this.group)?.classList.remove(WRAP_CLASS); + } +} + +/** + * Multi-row (wrapping) tabs. Adds `overflow.mode: 'wrap'` — tabs wrap onto + * multiple rows and the header grows, instead of clipping into the chevron + * dropdown. Consumes the free tab-list seam + header-aware content sizing; owns + * no tab model, overflow detection, or sizing math. + */ +export class MultiRowTabsService + extends CompositeDisposable + implements IMultiRowTabsService +{ + private readonly _controllers = new Map< + DockviewGroupPanel, + WrapController + >(); + + constructor(private readonly host: IMultiRowTabsHost) { + super(); + + this.addDisposables( + this.host.onDidAddGroup((group) => this._track(group)), + this.host.onDidRemoveGroup((group) => this._untrack(group)), + { + dispose: () => { + this._controllers.forEach((c) => c.dispose()); + this._controllers.clear(); + }, + } + ); + } + + get enabled(): boolean { + return isWrapMode(this.host.options.overflow); + } + + private _track(group: DockviewGroupPanel): void { + if (this._controllers.has(group)) { + return; + } + this._controllers.set(group, new WrapController(group, this.host)); + } + + private _untrack(group: DockviewGroupPanel): void { + const controller = this._controllers.get(group); + if (controller) { + controller.dispose(); + this._controllers.delete(group); + } + } +} + +export const MultiRowTabsModule = defineModule< + 'multiRowTabsService', + IMultiRowTabsHost +>({ + name: 'MultiRowTabs', + serviceKey: 'multiRowTabsService', + create: (host) => new MultiRowTabsService(host), +}); From 3d1177b0e0fe677eac027852a994dbbd7e3d9ee9 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:58:38 +0100 Subject: [PATCH 03/11] refactor(core): extract tab drag-reorder + animation into TabReorderController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-preserving extraction of the ~900-line tab drag-reorder / smooth-animation subsystem out of the 2,151-line tabs.ts into a dedicated TabReorderController (operating on Tabs via a narrow ITabReorderHost). Zero logic changes — validated by the existing suites (tabsAnimation 2,141 lines, tabs, tabsContainer): 1107 tests / 65 suites green, unchanged. tabs.ts keeps the DOM event wiring (constructor listeners, per-tab drag/drop handlers, void/chip handlers) and delegates the reorder logic + `_animState` to the controller. Isolates the subsystem so the multi-row 2-D cross-row reorder can be added without tangling it into tabs.ts. No public API change; the `dv-tabs-container--wrap` reorder guard is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../titlebar/tabReorderController.ts | 1071 +++++++++++++++++ .../src/dockview/components/titlebar/tabs.ts | 1021 ++-------------- 2 files changed, 1147 insertions(+), 945 deletions(-) create mode 100644 packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts new file mode 100644 index 000000000..2c454f1f1 --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -0,0 +1,1071 @@ +import { getPanelData } from '../../../dnd/dataTransfer'; +import { toggleClass } from '../../../dom'; +import { CompositeDisposable, IValueDisposable } from '../../../lifecycle'; +import { DockviewComponent } from '../../dockviewComponent'; +import { DockviewGroupPanel } from '../../dockviewGroupPanel'; +import { DockviewHeaderDirection } from '../../options'; +import { Tab } from '../tab/tab'; +import { TabDropIndexEvent } from './tabsContainer'; +import { TabGroupManager } from './tabGroups'; + +export interface TabAnimationState { + sourceTabId: string; + sourceIndex: number; + tabPositions: Map; + /** Group-chip widths keyed by group ID, captured before collapse */ + chipPositions: Map; + currentInsertionIndex: number | null; + targetTabGroupId: string | null; + /** When set, the drag source is a group chip (entire group drag) */ + sourceTabGroupId: string | null; + /** Cached panel IDs of the source group (avoids repeated lookups during drag) */ + sourceGroupPanelIds: Set | null; + /** Width of the group chip, captured before collapse */ + sourceChipWidth: number; + /** Distance from cursor to the left edge of the drag image */ + cursorOffsetFromDragLeft: number; + /** Total width of the dragged content (chip + tabs for groups, tab width for singles) */ + sourceGapWidth: number; + /** Left edge of the tabs container at drag start */ + containerLeft: number; +} + +/** + * Narrow view of {@link Tabs} required by {@link TabReorderController}. The + * controller owns the tab drag-reorder + animation subsystem; everything it + * needs to reach back into the owning `Tabs` instance is funnelled through + * this interface so the boundary stays explicit. + */ +export interface ITabReorderHost { + /** Live tab list (the `_tabs` array of the owning `Tabs`). */ + readonly tabItems: IValueDisposable[]; + /** Panel-id → tab lookup (the `_tabMap` of the owning `Tabs`). */ + readonly tabMap: Map>; + /** The scrollable tab strip element (`.dv-tabs-container`). */ + readonly tabsList: HTMLElement; + readonly direction: DockviewHeaderDirection; + readonly groupPanel: DockviewGroupPanel; + readonly component: DockviewComponent; + readonly tabGroupManager: TabGroupManager; + /** Fire the owning `Tabs`' drop event. */ + fireDrop(event: TabDropIndexEvent): void; +} + +/** + * Tab drag-reorder + FLIP animation subsystem extracted from `Tabs`. The DOM + * event wiring remains in `Tabs` and delegates the reorder/animation logic to + * this controller. All access back into `Tabs` goes through + * {@link ITabReorderHost}. + */ +export class TabReorderController extends CompositeDisposable { + private _animState: TabAnimationState | null = null; + private readonly _pendingMarginCleanups = new Map< + HTMLElement, + () => void + >(); + private _pendingCollapse = false; + private _flipTransitionCleanup: (() => void) | null = null; + private _voidContainer: HTMLElement | null = null; + private _extendedDropZone: HTMLElement | null = null; + private _pointerInsideTabsList = false; + + // Field-name mirrors so the extracted method bodies can stay verbatim. + private get _tabs(): IValueDisposable[] { + return this.host.tabItems; + } + private get _tabMap(): Map> { + return this.host.tabMap; + } + private get _tabsList(): HTMLElement { + return this.host.tabsList; + } + private get _direction(): DockviewHeaderDirection { + return this.host.direction; + } + private get group(): DockviewGroupPanel { + return this.host.groupPanel; + } + private get accessor(): DockviewComponent { + return this.host.component; + } + private get _tabGroupManager(): TabGroupManager { + return this.host.tabGroupManager; + } + + get animState(): TabAnimationState | null { + return this._animState; + } + + set animState(value: TabAnimationState | null) { + this._animState = value; + } + + get pendingCollapse(): boolean { + return this._pendingCollapse; + } + + set pendingCollapse(value: boolean) { + this._pendingCollapse = value; + } + + set voidContainerElement(el: HTMLElement | null) { + this._voidContainer = el; + } + + constructor(private readonly host: ITabReorderHost) { + super(); + + this.addDisposables({ + dispose: () => { + this._flipTransitionCleanup?.(); + }, + }); + } + + setExtendedDropZone(el: HTMLElement): void { + this._extendedDropZone = el; + } + + /** + * Allows external elements (e.g. void container, left actions) to push an + * insertion index into the animation while the cursor is outside the tabs + * list itself. Pass `null` to clear the indicator. + */ + setExternalInsertionIndex(index: number | null): void { + if (!this._animState) { + return; + } + if (index === this._animState.currentInsertionIndex) { + return; + } + this._animState.currentInsertionIndex = index; + this.applyDragOverTransforms(); + } + + /** + * Called when the drag cursor leaves the entire header area (not just the + * tabs list). Clears animation state for cross-group drags, which never + * receive a `dragend` event on this tab list. + */ + clearExternalAnimState(): void { + if (!this._animState) { + return; + } + this.resetTabTransforms(); + if (this._animState.sourceIndex === -1) { + this._animState = null; + } else { + this._animState.currentInsertionIndex = null; + } + } + + snapshotTabPositions(): Map { + const positions = new Map(); + for (const tab of this._tabs) { + positions.set( + tab.value.panel.id, + tab.value.element.getBoundingClientRect() + ); + } + return positions; + } + + private getAverageTabWidth(): number { + if (this._tabs.length === 0) { + return 0; + } + const isVertical = this._direction === 'vertical'; + let total = 0; + for (const tab of this._tabs) { + const rect = tab.value.element.getBoundingClientRect(); + total += isVertical ? rect.height : rect.width; + } + return total / this._tabs.length; + } + + /** + * Pointer-event entry point. The HTML5 path enters via the per-element + * `dragover` listener; this one hit-tests the global pointer-drag + * position against the tabs list and routes through the same shared + * `processDragOver` / `processDragLeave` helpers. + */ + handlePointerDragMove(clientX: number, clientY: number): void { + const sourceDoc = this._tabsList.ownerDocument ?? document; + const elAtPoint = sourceDoc.elementFromPoint(clientX, clientY); + const inside = + !!elAtPoint && + (this._tabsList.contains(elAtPoint) || + (!!this._extendedDropZone && + this._extendedDropZone.contains(elAtPoint))); + + if (!inside) { + if (this._pointerInsideTabsList) { + this._pointerInsideTabsList = false; + this.processDragLeave(elAtPoint); + } + return; + } + + this._pointerInsideTabsList = true; + this.processDragOver(clientX); + } + + /** + * Pointer-side cleanup hook: when any pointer drag ends, reset the + * inside-strip flag and tear down any smooth-reorder anim state the + * dragover bridge may have installed. + */ + handlePointerDragEnd(): void { + this._pointerInsideTabsList = false; + this.resetDragAnimation(); + } + + /** + * Shared body of the dragover entry point. Refreshes stale anim state + * for a changed drag identity, initializes anim state for incoming + * cross-group drags, and dispatches to the gap-following math in + * `handleDragOver`. Returns true when this tabs list has taken + * ownership of the drag — HTML5 callers use this to gate + * `event.preventDefault()`. + */ + processDragOver(clientX: number): boolean { + if (this.accessor.options.disableDnd) { + return false; + } + + // Multi-row wrap mode: the smooth single-row reorder is x-only and + // breaks across wrapped rows, so skip it for a wrapped strip. Dragging a + // tab out to detach/redock still works (that's a separate path), and the + // `default` animation mode keeps its per-tab drop targets (2-D safe). + // True cross-row reorder ships in a later phase. + if (this._tabsList.classList.contains('dv-tabs-container--wrap')) { + return false; + } + + // Stale-state guard: if a previous drag's anim state is still here + // but the current drag is a different identity, drop the stale one + // so the new drag starts from a clean slate. + if (this._animState) { + const data = getPanelData(); + if ( + data?.tabGroupId && + data.groupId !== this.group.id && + this._animState.sourceTabGroupId !== data.tabGroupId + ) { + this._animState = null; + } + } + + if (!this._animState) { + const data = getPanelData(); + // In default animation mode, individual tab drops are handled + // by per-tab Droptargets; only chip drags need tabs-list-level + // handling so drops on void space still work. + if ( + this.accessor.options.theme?.tabAnimation === 'default' && + !data?.tabGroupId + ) { + return false; + } + if ( + data && + (data.panelId || data.tabGroupId) && + data.groupId !== this.group.id + ) { + const avgWidth = this.getAverageTabWidth(); + if (data.tabGroupId) { + // External group drag — look up the source group to + // size the gap. + const sourceGroup = this.accessor.getPanel(data.groupId); + const sourceTg = sourceGroup?.model + .getTabGroups() + .find((tg) => tg.id === data.tabGroupId); + const panelCount = sourceTg?.panelIds.length ?? 1; + const groupGapWidth = avgWidth * panelCount + avgWidth; + this._animState = { + sourceTabId: '', + sourceIndex: -1, + tabPositions: this.snapshotTabPositions(), + chipPositions: + this._tabGroupManager.snapshotChipWidths(), + currentInsertionIndex: null, + targetTabGroupId: null, + sourceTabGroupId: data.tabGroupId, + sourceGroupPanelIds: sourceTg + ? new Set(sourceTg.panelIds) + : new Set(), + sourceChipWidth: avgWidth, + cursorOffsetFromDragLeft: groupGapWidth / 2, + sourceGapWidth: groupGapWidth, + containerLeft: + this._tabsList.getBoundingClientRect().left, + }; + } else { + this._animState = { + sourceTabId: data.panelId!, + sourceIndex: -1, + tabPositions: this.snapshotTabPositions(), + chipPositions: + this._tabGroupManager.snapshotChipWidths(), + currentInsertionIndex: null, + targetTabGroupId: null, + sourceTabGroupId: null, + sourceGroupPanelIds: null, + sourceChipWidth: 0, + cursorOffsetFromDragLeft: avgWidth / 2, + sourceGapWidth: avgWidth, + containerLeft: + this._tabsList.getBoundingClientRect().left, + }; + } + } else { + return false; + } + } + + // For intra-group drag (sourceIndex >= 0) the gap animation is the + // sole visual indicator — clear any stale anchor overlay that may + // have been set while the cursor was over the panel content area or + // another zone. External drags (sourceIndex === -1) leave the + // overlay to the individual tab Droptargets so cross-group + // animation is not disrupted. + if (this._animState!.sourceIndex !== -1) { + this.group.model.dropTargetContainer?.model?.clear(); + } + this.handleDragOver({ clientX }); + return true; + } + + /** + * Shared body of the dragleave entry point. Preserves anim state when + * the drag moves between tabs-list children, into the extended drop + * zone, or into the void container; tears it down otherwise. + */ + processDragLeave(related: Element | null): void { + if (!this._animState) { + return; + } + // Moves between children of the tabs list aren't real leaves. + if (related && this._tabsList.contains(related)) { + return; + } + // Moving into the broader drop zone (e.g. void container, left + // actions) — keep anim state alive so external listeners can + // continue the gap animation. + if (related && this._extendedDropZone?.contains(related)) { + this.resetTabTransforms(); + this._animState.currentInsertionIndex = null; + return; + } + // Leaving toward the void container (empty header space to the + // right): keep anim state so a drop can still land at the end. + const isVoid = + this._voidContainer && + related && + (related === this._voidContainer || + this._voidContainer.contains(related)); + if (isVoid) { + return; + } + this.resetTabTransforms(); + if (this._animState.sourceIndex === -1) { + this.group.model.dropTargetContainer?.model?.clear(); + this._animState = null; + } else { + this._animState.currentInsertionIndex = null; + } + } + + handleDragOver(event: { clientX: number }): void { + if (!this._animState) { + return; + } + + const mouseX = event.clientX; + + let insertionIndex: number | null = null; + let targetTabGroupId: string | null = null; + + const sourceGroupPanelIds = this._animState.sourceGroupPanelIds; + + // Accumulation approach: compute where the drag image's left edge + // would be, then walk tabs left-to-right using their original widths. + // A tab fits to the left of the gap if the cumulative width of all + // preceding non-source tabs <= available space. + const dragLeftEdge = mouseX - this._animState.cursorOffsetFromDragLeft; + const availableSpace = dragLeftEdge - this._animState.containerLeft; + let accWidth = 0; + + // Build lookup: first panel ID of each non-source group → group ID + // so we can add chip widths when we encounter a group's first tab. + const firstPanelToGroup = new Map(); + if (this._tabGroupManager.chipRenderers.size > 0) { + const tabGroups = this.group.model.getTabGroups(); + for (const tg of tabGroups) { + if (tg.id === this._animState.sourceTabGroupId) { + continue; + } + if (tg.panelIds.length > 0) { + firstPanelToGroup.set(tg.panelIds[0], tg.id); + } + } + } + + for (let i = 0; i < this._tabs.length; i++) { + const tab = this._tabs[i].value; + if (tab.panel.id === this._animState.sourceTabId) { + continue; + } + if (sourceGroupPanelIds?.has(tab.panel.id)) { + continue; + } + + // If this tab is the first of a non-source group, include + // the chip width (which sits before it in the DOM). + const groupId = firstPanelToGroup.get(tab.panel.id); + if (groupId) { + const chipWidth = + this._animState.chipPositions.get(groupId) ?? 0; + if (accWidth + chipWidth > availableSpace) { + // Chip alone overflows — gap goes before this group + insertionIndex ??= i; + break; + } + accWidth += chipWidth; + } + + // Use original width (before collapse/transforms) + const origRect = this._animState.tabPositions.get(tab.panel.id); + const tabWidth = origRect + ? origRect.width + : tab.element.getBoundingClientRect().width; + + // Shift at the midpoint: a tab moves left once the drag image + // covers half of it (like Chrome's tab drag behavior). + if (accWidth + tabWidth / 2 <= availableSpace) { + accWidth += tabWidth; + insertionIndex = i + 1; + } else { + insertionIndex ??= i; + break; + } + } + + // Determine which tab group (if any) the insertion index falls within. + // + // We use snapshot-based positions (accWidth from the accumulation loop + // above) to compute original chip boundaries. This avoids reading + // getBoundingClientRect() on chips whose live position is shifted by + // the drag gap margin, which caused oscillation / visual jumps. + if ( + insertionIndex !== null && + this._tabGroupManager.chipRenderers.size > 0 + ) { + const isGroupDrag = !!this._animState.sourceTabGroupId; + const tabGroups = this.group.model.getTabGroups(); + + // Rebuild the accumulated width up to insertionIndex so we know + // the original right edge of the chip (if any) that precedes it. + // We walk exactly the same way as the accumulation loop above. + let accUpTo = 0; + for (let i = 0; i < this._tabs.length; i++) { + const tab = this._tabs[i].value; + if (tab.panel.id === this._animState.sourceTabId) { + continue; + } + if (sourceGroupPanelIds?.has(tab.panel.id)) { + continue; + } + if (i >= insertionIndex) { + break; + } + const gid = firstPanelToGroup.get(tab.panel.id); + if (gid) { + accUpTo += this._animState.chipPositions.get(gid) ?? 0; + } + const origRect = this._animState.tabPositions.get(tab.panel.id); + accUpTo += origRect + ? origRect.width + : tab.element.getBoundingClientRect().width; + } + + for (const tg of tabGroups) { + // Build effective panel list: exclude the source tab + // so that dragging a tab out of its own group doesn't + // inflate the group's index range. + const effectivePanelIds = tg.panelIds.filter( + (pid) => + pid !== this._animState!.sourceTabId && + !sourceGroupPanelIds?.has(pid) + ); + if (effectivePanelIds.length === 0) { + continue; + } + const firstIdx = this._tabs.findIndex( + (t) => t.value.panel.id === effectivePanelIds[0] + ); + const lastIdx = this._tabs.findIndex( + (t) => + t.value.panel.id === + effectivePanelIds[effectivePanelIds.length - 1] + ); + if (firstIdx === -1 || lastIdx === -1) { + continue; + } + + const isInsideRange = + insertionIndex >= firstIdx && insertionIndex <= lastIdx; + + const isJustBeforeGroup = + !isInsideRange && insertionIndex === firstIdx - 1; + + if (!isInsideRange && !isJustBeforeGroup) { + continue; + } + + if (isGroupDrag && isInsideRange) { + // A group cannot be dropped inside another group. + // Snap the insertion index to just before or just + // after this group based on cursor position relative + // to the group's midpoint. Only applies when the + // insertion would land *inside* the group — for + // `isJustBeforeGroup`, the index is already outside + // (immediately left of the group) and is a valid + // drop position, so leave it untouched (issue #1264). + const groupMid = (firstIdx + lastIdx + 1) / 2; + if (insertionIndex < groupMid) { + insertionIndex = firstIdx; + } else { + insertionIndex = lastIdx + 1; + } + // targetTabGroupId stays null + break; + } + + if (isGroupDrag && isJustBeforeGroup) { + // Cursor is just before the group — accept this + // index as-is. Groups can be dropped at the slot + // immediately left of another group's first tab. + break; + } + + if (isJustBeforeGroup) { + // Check whether only the source tab (or source group + // tabs) sits between insertionIndex and firstIdx. + // If so, the source is being dragged away from that + // slot, so we ARE effectively "just before" the group + // and should still allow dropping into position 0. + let allInBetweenAreSource = true; + for (let j = insertionIndex; j < firstIdx; j++) { + const pid = this._tabs[j].value.panel.id; + if ( + pid !== this._animState!.sourceTabId && + !sourceGroupPanelIds?.has(pid) + ) { + allInBetweenAreSource = false; + break; + } + } + if (!allInBetweenAreSource) { + continue; + } + + const chipWidth = + this._animState.chipPositions.get(tg.id) ?? 0; + const threshold = tg.collapsed + ? this._animState.containerLeft + + accUpTo + + chipWidth / 2 + : this._animState.containerLeft + accUpTo + chipWidth; + if (mouseX >= threshold) { + insertionIndex = firstIdx; + targetTabGroupId = tg.id; + } + break; + } + + if (isInsideRange) { + const chipWidth = + this._animState.chipPositions.get(tg.id) ?? 0; + const chipOriginalRight = + this._animState.containerLeft + accUpTo + chipWidth; + if (insertionIndex === firstIdx) { + if (mouseX >= chipOriginalRight) { + targetTabGroupId = tg.id; + } + } else { + targetTabGroupId = tg.id; + } + break; + } + } + } + + if ( + insertionIndex === this._animState.currentInsertionIndex && + targetTabGroupId === this._animState.targetTabGroupId + ) { + return; + } + + this._animState.currentInsertionIndex = insertionIndex; + this._animState.targetTabGroupId = targetTabGroupId; + + if (this.accessor.options.theme?.tabAnimation === 'smooth') { + this.applyDragOverTransforms(); + } + } + + /** + * Batch-remove a CSS class from multiple elements instantly, + * forcing only a single reflow for the entire batch. + */ + private _removeClassInstantlyBatch( + elements: HTMLElement[], + cls: string + ): void { + const affected: HTMLElement[] = []; + for (const el of elements) { + if (el.classList.contains(cls)) { + el.style.transition = 'none'; + toggleClass(el, cls, false); + affected.push(el); + } + } + if (affected.length > 0) { + void affected[0].offsetHeight; // single reflow for entire batch + for (const el of affected) { + el.style.removeProperty('transition'); + } + } + } + + /** + * Remove `dv-tab--dragging` from the source tab instantly so it + * regains its real width before FLIP snapshots. + */ + uncollapseSourceTab(sourceTabId: string): void { + const entry = this._tabMap.get(sourceTabId); + if (entry) { + this._removeClassInstantlyBatch( + [entry.value.element], + 'dv-tab--dragging' + ); + } + } + + applyDragOverTransforms(skipTransition = false): void { + if ( + !this._animState || + this._animState.currentInsertionIndex === null + ) { + this.resetTabTransforms(); + return; + } + + // Don't apply transforms until the source tab has been collapsed + // in the rAF callback — otherwise the gap + visible source = jump. + if (this._pendingCollapse) { + return; + } + + const insertionIndex = this._animState.currentInsertionIndex; + + // For group drags, gap = sum of all group member widths + let gapWidth: number; + const sourceGroupPanelIds = this._animState.sourceGroupPanelIds; + if (this._animState.sourceTabGroupId && sourceGroupPanelIds) { + gapWidth = this._animState.sourceGapWidth; + } else { + const sourceRect = this._animState.tabPositions.get( + this._animState.sourceTabId + ); + gapWidth = sourceRect + ? sourceRect.width + : this.getAverageTabWidth(); + } + + // When the insertion lands at or before a group's first tab, shift + // the chip so the gap appears before the entire group. + // + // Two cases: + // 1. targetTabGroupId is null (standalone drop) — always shift chip. + // 2. targetTabGroupId is set AND the group is collapsed — shift chip + // because the collapsed tabs are invisible, so putting the gap on + // them has no visual effect. + let chipToShift: HTMLElement | null = null; + if (this._tabGroupManager.chipRenderers.size > 0) { + const tabGroups = this.group.model.getTabGroups(); + for (const tg of tabGroups) { + if (tg.id === this._animState.sourceTabGroupId) continue; + // Skip the group that the dragged tab belongs to — the + // gap should appear after the chip (where the tab was), + // not before it. + if (tg.panelIds.includes(this._animState.sourceTabId)) continue; + const effectivePids = tg.panelIds.filter( + (pid) => + pid !== this._animState!.sourceTabId && + !sourceGroupPanelIds?.has(pid) + ); + if (effectivePids.length === 0) continue; + const firstIdx = this._tabs.findIndex( + (t) => t.value.panel.id === effectivePids[0] + ); + + // Only consider chip-shifting when dropping outside the + // group, or when dropping inside a collapsed group (whose + // tabs are invisible). + const shouldShiftChip = + !this._animState.targetTabGroupId || + (this._animState.targetTabGroupId === tg.id && + tg.collapsed); + + if (!shouldShiftChip) continue; + + if (firstIdx >= insertionIndex) { + let hasTabs = false; + for (let j = insertionIndex; j < firstIdx; j++) { + const pid = this._tabs[j].value.panel.id; + if (pid === this._animState.sourceTabId) continue; + if (sourceGroupPanelIds?.has(pid)) continue; + hasTabs = true; + break; + } + if (!hasTabs) { + const chipEntry = + this._tabGroupManager.chipRenderers.get(tg.id); + if (chipEntry) { + chipToShift = chipEntry.chip.element; + } + } + break; + } + } + } + + // Helper: pick the correct shifting class for tabs vs chips. + const shiftingClass = (el: HTMLElement): string => + el.classList.contains('dv-tab-group-chip') + ? 'dv-tab-group-chip--shifting' + : 'dv-tab--shifting'; + + // Helper: apply a margin-left value to an element, optionally + // bypassing CSS transitions for instant positioning. + const setMargin = (el: HTMLElement, value: string) => { + if (skipTransition) { + el.style.transition = 'none'; + el.style.marginLeft = value; + void el.offsetHeight; + el.style.removeProperty('transition'); + } else { + el.style.marginLeft = value; + } + toggleClass(el, shiftingClass(el), true); + }; + + const clearMargin = (el: HTMLElement) => { + const cls = shiftingClass(el); + + // Remove any previous pending listener for this element + const prev = this._pendingMarginCleanups.get(el); + if (prev) { + prev(); + } + + if (skipTransition || !el.style.marginLeft) { + el.style.removeProperty('margin-left'); + toggleClass(el, cls, false); + } else { + el.style.marginLeft = '0px'; + toggleClass(el, cls, true); + const onEnd = () => { + el.style.removeProperty('margin-left'); + toggleClass(el, cls, false); + el.removeEventListener('transitionend', onEnd); + clearTimeout(fallbackTimer); + this._pendingMarginCleanups.delete(el); + }; + // Fallback in case transitionend never fires + // (e.g. element removed from DOM mid-transition) + const fallbackTimer = setTimeout(onEnd, 300); + this._pendingMarginCleanups.set(el, onEnd); + el.addEventListener('transitionend', onEnd); + } + }; + + let gapApplied = false; + + // Reset all non-source chip margins first + for (const [groupId, entry] of this._tabGroupManager.chipRenderers) { + if (groupId === this._animState.sourceTabGroupId) continue; + clearMargin(entry.chip.element); + } + + // Apply gap to chip if insertion is before a group + if (chipToShift) { + setMargin(chipToShift, `${gapWidth}px`); + gapApplied = true; + } + + for (let i = 0; i < this._tabs.length; i++) { + const tab = this._tabs[i].value; + if (tab.panel.id === this._animState.sourceTabId) { + continue; + } + if (sourceGroupPanelIds?.has(tab.panel.id)) { + continue; + } + + if (!gapApplied && i >= insertionIndex) { + setMargin(tab.element, `${gapWidth}px`); + gapApplied = true; + } else { + clearMargin(tab.element); + } + } + + // Reposition underlines to follow shifted chips/tabs + this._tabGroupManager.trackUnderlines(); + } + + resetTabTransforms(): void { + // Cancel any pending margin transitionend listeners + for (const [, cleanup] of this._pendingMarginCleanups) { + cleanup(); + } + this._pendingMarginCleanups.clear(); + + for (const tab of this._tabs) { + tab.value.element.style.removeProperty('margin-left'); + tab.value.element.style.removeProperty('margin-right'); + tab.value.element.style.removeProperty('margin-top'); + tab.value.element.style.removeProperty('margin-bottom'); + tab.value.element.style.removeProperty('transform'); + toggleClass(tab.value.element, 'dv-tab--shifting', false); + } + for (const [, entry] of this._tabGroupManager.chipRenderers) { + entry.chip.element.style.removeProperty('margin-left'); + toggleClass( + entry.chip.element, + 'dv-tab-group-chip--shifting', + false + ); + } + this._tabGroupManager.positionUnderlines(); + } + + /** + * Commit a group-drag drop: clear drag classes, move the group + * in the model, and run a FLIP animation. + */ + commitGroupMove(sourceTabGroupId: string, insertionIndex: number): void { + // Read transfer data first. + const data = getPanelData(); + + // Synchronously dispose the source chip's drag sources, which + // clears the panelTransfer payload + iframe shield. Cross-group + // moves dissolve the source chip on a microtask, which is too + // late: a synchronous `getPanelData()` after this method (or any + // sibling dragover handler firing in the same tick) would + // otherwise see stale data still referencing the old tabGroupId. + this._tabGroupManager.disposeChipDrag(sourceTabGroupId); + + // Check if the tab group exists in this group (within-group reorder) + // or in another group (cross-group move). + const isLocal = this.group.model + .getTabGroups() + .some((tg) => tg.id === sourceTabGroupId); + + if (isLocal) { + if (this.accessor.options.theme?.tabAnimation === 'smooth') { + this._clearGroupDragClasses(sourceTabGroupId); + const firstPositions = this.snapshotTabPositions(); + this.resetTabTransforms(); + this.group.model.moveTabGroup(sourceTabGroupId, insertionIndex); + this.runFlipAnimation(firstPositions, '', false); + } else { + this._tabGroupManager.skipNextCollapseAnimation = true; + this.group.model.moveTabGroup(sourceTabGroupId, insertionIndex); + } + } else if (data) { + // Cross-group: delegate to the component-level move which + // handles panel transfer and tab group recreation. + // Use the REAL tab group ID from transfer data, not the + // potentially stale one from _animState. + // + // Clear any inline gap margin / shifting class applied to + // destination tabs during dragover. Cross-group moves don't + // run the FLIP path, and `moveGroupOrPanel` only inserts new + // panels — it doesn't recreate existing destination tabs, so + // their inline `margin-left` would otherwise persist as a + // visible gap (issue #1243). + this.resetTabTransforms(); + this.accessor.moveGroupOrPanel({ + from: { + groupId: data.groupId, + tabGroupId: data.tabGroupId ?? sourceTabGroupId, + }, + to: { + group: this.group, + position: 'center', + index: insertionIndex, + }, + }); + } + } + + private _clearGroupDragClasses(sourceTabGroupId: string): void { + const chipEntry = + this._tabGroupManager.chipRenderers.get(sourceTabGroupId); + if (chipEntry) { + this._removeClassInstantlyBatch( + [chipEntry.chip.element], + 'dv-tab-group-chip--dragging' + ); + } + this._removeClassInstantlyBatch( + this._tabs.map((t) => t.value.element), + 'dv-tab--dragging' + ); + // Restore underline + const underline = + this._tabGroupManager.groupUnderlines.get(sourceTabGroupId); + if (underline) { + underline.style.removeProperty('display'); + } + // The subsequent moveTabGroup will re-create tabs and call + // updateTabGroups → _updateTabGroupClasses. For collapsed groups + // the new tabs don't have dv-tab--group-collapsed yet, which + // would trigger the collapse animation. Skip it. + this._tabGroupManager.skipNextCollapseAnimation = true; + } + + resetDragAnimation(): void { + this._pendingCollapse = false; + + // After a drop, `tab.onDrop` consumes _animState (sets it to null) + // and immediately calls `runFlipAnimation`, which sets transforms + // and queues an rAF to trigger the CSS transition. dragend fires + // synchronously on the source element BEFORE that rAF runs — if + // we cleared transforms here we'd clobber the in-flight FLIP, so + // gate the cleanup on _animState still being set (i.e. drag was + // cancelled rather than dropped). + if (this._animState) { + this.resetTabTransforms(); + if (this._animState.sourceTabGroupId) { + this._clearGroupDragClasses(this._animState.sourceTabGroupId); + } else { + this._removeClassInstantlyBatch( + this._tabs.map((t) => t.value.element), + 'dv-tab--dragging' + ); + } + this._animState = null; + // Restore any hidden underlines from group drags. + for (const [, el] of this._tabGroupManager.groupUnderlines) { + el.style.removeProperty('display'); + } + } + } + + runFlipAnimation( + firstPositions: Map, + sourceTabId: string, + isCrossGroup: boolean = false, + animRange?: { from: number; to: number } + ): void { + const isVertical = this._direction === 'vertical'; + let hasAnimation = false; + + for (let i = 0; i < this._tabs.length; i++) { + const tab = this._tabs[i]; + const panelId = tab.value.panel.id; + + if (panelId === sourceTabId) { + if (isCrossGroup) { + // Newly inserted tab: slide in from the end + const rect = tab.value.element.getBoundingClientRect(); + tab.value.element.style.transform = isVertical + ? `translateY(${rect.height}px)` + : `translateX(${rect.width}px)`; + toggleClass(tab.value.element, 'dv-tab--shifting', true); + hasAnimation = true; + } + continue; + } + + // Skip tabs outside the affected range (they don't logically move) + if ( + animRange !== undefined && + (i < animRange.from || i > animRange.to) + ) { + continue; + } + + const firstRect = firstPositions.get(panelId); + if (!firstRect) { + continue; + } + + const lastRect = tab.value.element.getBoundingClientRect(); + const delta = isVertical + ? firstRect.top - lastRect.top + : firstRect.left - lastRect.left; + + if (Math.abs(delta) < 1) { + continue; + } + + tab.value.element.style.transform = isVertical + ? `translateY(${delta}px)` + : `translateX(${delta}px)`; + toggleClass(tab.value.element, 'dv-tab--shifting', true); + hasAnimation = true; + } + + if (!hasAnimation) { + return; + } + + requestAnimationFrame(() => { + for (const tab of this._tabs) { + if (tab.value.element.style.transform) { + tab.value.element.style.transform = ''; + } + } + + // Track underlines during the FLIP transition so they + // follow tabs as they slide to their final positions. + this._tabGroupManager.trackUnderlines(); + + // Clean up any previous flip transition listener + this._flipTransitionCleanup?.(); + + const onTransitionEnd = (event: TransitionEvent) => { + if (event.propertyName === 'transform') { + cleanup(); + for (const tab of this._tabs) { + toggleClass( + tab.value.element, + 'dv-tab--shifting', + false + ); + } + // Final reposition after animation settles + this._tabGroupManager.positionUnderlines(); + } + }; + + const cleanup = () => { + this._tabsList.removeEventListener( + 'transitionend', + onTransitionEnd + ); + this._flipTransitionCleanup = null; + }; + + this._flipTransitionCleanup = cleanup; + this._tabsList.addEventListener('transitionend', onTransitionEnd); + }); + } +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index 92bfa5bf5..930e6ea0d 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -33,30 +33,13 @@ import { TabGroupChip } from './tabGroupChip'; import { TabGroupManager } from './tabGroups'; import { ITabGroupChipRenderer } from '../../framework'; import { DroptargetEvent } from '../../../dnd/droptarget'; +import { + ITabReorderHost, + TabAnimationState, + TabReorderController, +} from './tabReorderController'; -interface TabAnimationState { - sourceTabId: string; - sourceIndex: number; - tabPositions: Map; - /** Group-chip widths keyed by group ID, captured before collapse */ - chipPositions: Map; - currentInsertionIndex: number | null; - targetTabGroupId: string | null; - /** When set, the drag source is a group chip (entire group drag) */ - sourceTabGroupId: string | null; - /** Cached panel IDs of the source group (avoids repeated lookups during drag) */ - sourceGroupPanelIds: Set | null; - /** Width of the group chip, captured before collapse */ - sourceChipWidth: number; - /** Distance from cursor to the left edge of the drag image */ - cursorOffsetFromDragLeft: number; - /** Total width of the dragged content (chip + tabs for groups, tab width for singles) */ - sourceGapWidth: number; - /** Left edge of the tabs container at drag start */ - containerLeft: number; -} - -export class Tabs extends CompositeDisposable { +export class Tabs extends CompositeDisposable implements ITabReorderHost { private readonly _element: HTMLElement; private readonly _tabsList: HTMLElement; private readonly _observerDisposable = new MutableDisposable(); @@ -67,19 +50,49 @@ export class Tabs extends CompositeDisposable { private selectedIndex = -1; private _showTabsOverflowControl = false; private _direction: DockviewHeaderDirection = 'horizontal'; - private _animState: TabAnimationState | null = null; - private readonly _pendingMarginCleanups = new Map< - HTMLElement, - () => void - >(); - private _pendingCollapse = false; - private _flipTransitionCleanup: (() => void) | null = null; - private _voidContainer: HTMLElement | null = null; private _voidContainerListeners: IDisposable | null = null; - private _extendedDropZone: HTMLElement | null = null; - private _pointerInsideTabsList = false; private readonly _tabGroupManager: TabGroupManager; + private readonly _reorder: TabReorderController; + + // The reorder/animation state lives in the controller; these accessors + // bridge the (many) `Tabs` call sites — and the test suite, which reads + // `(tabs as any)._animState` / `_pendingCollapse` — onto it. + private get _animState(): TabAnimationState | null { + return this._reorder.animState; + } + private set _animState(value: TabAnimationState | null) { + this._reorder.animState = value; + } + private get _pendingCollapse(): boolean { + return this._reorder.pendingCollapse; + } + private set _pendingCollapse(value: boolean) { + this._reorder.pendingCollapse = value; + } + + // --- ITabReorderHost --- + get tabItems(): IValueDisposable[] { + return this._tabs; + } + get tabMap(): Map> { + return this._tabMap; + } + get tabsList(): HTMLElement { + return this._tabsList; + } + get groupPanel(): DockviewGroupPanel { + return this.group; + } + get component(): DockviewComponent { + return this.accessor; + } + get tabGroupManager(): TabGroupManager { + return this._tabGroupManager; + } + fireDrop(event: TabDropIndexEvent): void { + this._onDrop.fire(event); + } private readonly _onTabDragStart = new Emitter(); readonly onTabDragStart: Event = this._onTabDragStart.event; @@ -148,7 +161,7 @@ export class Tabs extends CompositeDisposable { set voidContainer(el: HTMLElement | null) { this._voidContainerListeners?.dispose(); this._voidContainerListeners = null; - this._voidContainer = el; + this._reorder.voidContainerElement = el; if (el) { this._voidContainerListeners = new CompositeDisposable( @@ -292,7 +305,7 @@ export class Tabs extends CompositeDisposable { // disposed. resetDragAnimation is a no-op after a // successful drop (anim state already null) thanks to // the gating inside it. - this.resetDragAnimation(); + this._reorder.resetDragAnimation(); }, onChipDrop: (tabGroup, event) => { this._handleChipDrop(tabGroup, event); @@ -300,30 +313,27 @@ export class Tabs extends CompositeDisposable { } ); + this._reorder = new TabReorderController(this); + this.addDisposables( this._onOverflowTabsChange, this._observerDisposable, this._onWillShowOverlay, this._onDrop, this._onTabDragStart, - { - dispose: () => { - this._flipTransitionCleanup?.(); - }, - }, + this._reorder, // Pointer-side cleanup: when any pointer drag ends, tear // down smooth-reorder anim state the dragover bridge may // have installed. The chip's pointer drag source handles // its own transfer payload + iframe-shield cleanup. PointerDragController.getInstance().onDragEnd(() => { - this._pointerInsideTabsList = false; - this.resetDragAnimation(); + this._reorder.handlePointerDragEnd(); }), // Pointer-event mirror of the HTML5 dragover / dragleave handlers // below. Drives smooth-reorder for `dndStrategy: 'pointer'` and // for touch drags in `'auto'`. PointerDragController.getInstance().onDragMove((e) => { - this._handlePointerDragMove(e.clientX, e.clientY); + this._reorder.handlePointerDragMove(e.clientX, e.clientY); }), addDisposableListener(this.element, 'pointerdown', (event) => { if (event.defaultPrevented) { @@ -400,7 +410,7 @@ export class Tabs extends CompositeDisposable { this._tabsList, 'dragover', (event) => { - if (this._processDragOver(event.clientX)) { + if (this._reorder.processDragOver(event.clientX)) { // Allow `drop` to fire on the tabs list container. event.preventDefault(); } @@ -411,14 +421,14 @@ export class Tabs extends CompositeDisposable { this._tabsList, 'dragleave', (event) => { - this._processDragLeave( + this._reorder.processDragLeave( event.relatedTarget as Element | null ); }, true ), addDisposableListener(this._tabsList, 'dragend', () => { - this.resetDragAnimation(); + this._reorder.resetDragAnimation(); }), addDisposableListener( this._tabsList, @@ -481,12 +491,14 @@ export class Tabs extends CompositeDisposable { !animState.targetTabGroupId && !sourceCurrentGroup ) { - this._uncollapsSourceTab(animState.sourceTabId); + this._reorder.uncollapseSourceTab( + animState.sourceTabId + ); this.resetTabTransforms(); return; } - this._uncollapsSourceTab(animState.sourceTabId); + this._reorder.uncollapseSourceTab(animState.sourceTabId); const firstPositions = this.snapshotTabPositions(); this.resetTabTransforms(); @@ -509,7 +521,7 @@ export class Tabs extends CompositeDisposable { ), Disposable.from(() => { this._voidContainerListeners?.dispose(); - this.resetDragAnimation(); + this._reorder.resetDragAnimation(); this._tabGroupManager.disposeAll(); for (const { value, disposable } of this._tabs) { @@ -840,7 +852,7 @@ export class Tabs extends CompositeDisposable { return; } - this._uncollapsSourceTab(animState.sourceTabId); + this._reorder.uncollapseSourceTab(animState.sourceTabId); const firstPositions = this.snapshotTabPositions(); this.resetTabTransforms(); @@ -1210,7 +1222,7 @@ export class Tabs extends CompositeDisposable { * that external dragover listeners can continue the animation. */ setExtendedDropZone(el: HTMLElement): void { - this._extendedDropZone = el; + this._reorder.setExtendedDropZone(el); } /** @@ -1219,14 +1231,7 @@ export class Tabs extends CompositeDisposable { * list itself. Pass `null` to clear the indicator. */ setExternalInsertionIndex(index: number | null): void { - if (!this._animState) { - return; - } - if (index === this._animState.currentInsertionIndex) { - return; - } - this._animState.currentInsertionIndex = index; - this.applyDragOverTransforms(); + this._reorder.setExternalInsertionIndex(index); } /** @@ -1235,817 +1240,30 @@ export class Tabs extends CompositeDisposable { * receive a `dragend` event on this tab list. */ clearExternalAnimState(): void { - if (!this._animState) { - return; - } - this.resetTabTransforms(); - if (this._animState.sourceIndex === -1) { - this._animState = null; - } else { - this._animState.currentInsertionIndex = null; - } + this._reorder.clearExternalAnimState(); } private snapshotTabPositions(): Map { - const positions = new Map(); - for (const tab of this._tabs) { - positions.set( - tab.value.panel.id, - tab.value.element.getBoundingClientRect() - ); - } - return positions; - } - - private getAverageTabWidth(): number { - if (this._tabs.length === 0) { - return 0; - } - const isVertical = this._direction === 'vertical'; - let total = 0; - for (const tab of this._tabs) { - const rect = tab.value.element.getBoundingClientRect(); - total += isVertical ? rect.height : rect.width; - } - return total / this._tabs.length; - } - - /** - * Pointer-event entry point. The HTML5 path enters via the per-element - * `dragover` listener; this one hit-tests the global pointer-drag - * position against the tabs list and routes through the same shared - * `_processDragOver` / `_processDragLeave` helpers. - */ - private _handlePointerDragMove(clientX: number, clientY: number): void { - const sourceDoc = this._tabsList.ownerDocument ?? document; - const elAtPoint = sourceDoc.elementFromPoint(clientX, clientY); - const inside = - !!elAtPoint && - (this._tabsList.contains(elAtPoint) || - (!!this._extendedDropZone && - this._extendedDropZone.contains(elAtPoint))); - - if (!inside) { - if (this._pointerInsideTabsList) { - this._pointerInsideTabsList = false; - this._processDragLeave(elAtPoint); - } - return; - } - - this._pointerInsideTabsList = true; - this._processDragOver(clientX); - } - - /** - * Shared body of the dragover entry point. Refreshes stale anim state - * for a changed drag identity, initializes anim state for incoming - * cross-group drags, and dispatches to the gap-following math in - * `handleDragOver`. Returns true when this tabs list has taken - * ownership of the drag — HTML5 callers use this to gate - * `event.preventDefault()`. - */ - private _processDragOver(clientX: number): boolean { - if (this.accessor.options.disableDnd) { - return false; - } - - // Multi-row wrap mode: the smooth single-row reorder is x-only and - // breaks across wrapped rows, so skip it for a wrapped strip. Dragging a - // tab out to detach/redock still works (that's a separate path), and the - // `default` animation mode keeps its per-tab drop targets (2-D safe). - // True cross-row reorder ships in a later phase. - if (this._tabsList.classList.contains('dv-tabs-container--wrap')) { - return false; - } - - // Stale-state guard: if a previous drag's anim state is still here - // but the current drag is a different identity, drop the stale one - // so the new drag starts from a clean slate. - if (this._animState) { - const data = getPanelData(); - if ( - data?.tabGroupId && - data.groupId !== this.group.id && - this._animState.sourceTabGroupId !== data.tabGroupId - ) { - this._animState = null; - } - } - - if (!this._animState) { - const data = getPanelData(); - // In default animation mode, individual tab drops are handled - // by per-tab Droptargets; only chip drags need tabs-list-level - // handling so drops on void space still work. - if ( - this.accessor.options.theme?.tabAnimation === 'default' && - !data?.tabGroupId - ) { - return false; - } - if ( - data && - (data.panelId || data.tabGroupId) && - data.groupId !== this.group.id - ) { - const avgWidth = this.getAverageTabWidth(); - if (data.tabGroupId) { - // External group drag — look up the source group to - // size the gap. - const sourceGroup = this.accessor.getPanel(data.groupId); - const sourceTg = sourceGroup?.model - .getTabGroups() - .find((tg) => tg.id === data.tabGroupId); - const panelCount = sourceTg?.panelIds.length ?? 1; - const groupGapWidth = avgWidth * panelCount + avgWidth; - this._animState = { - sourceTabId: '', - sourceIndex: -1, - tabPositions: this.snapshotTabPositions(), - chipPositions: - this._tabGroupManager.snapshotChipWidths(), - currentInsertionIndex: null, - targetTabGroupId: null, - sourceTabGroupId: data.tabGroupId, - sourceGroupPanelIds: sourceTg - ? new Set(sourceTg.panelIds) - : new Set(), - sourceChipWidth: avgWidth, - cursorOffsetFromDragLeft: groupGapWidth / 2, - sourceGapWidth: groupGapWidth, - containerLeft: - this._tabsList.getBoundingClientRect().left, - }; - } else { - this._animState = { - sourceTabId: data.panelId!, - sourceIndex: -1, - tabPositions: this.snapshotTabPositions(), - chipPositions: - this._tabGroupManager.snapshotChipWidths(), - currentInsertionIndex: null, - targetTabGroupId: null, - sourceTabGroupId: null, - sourceGroupPanelIds: null, - sourceChipWidth: 0, - cursorOffsetFromDragLeft: avgWidth / 2, - sourceGapWidth: avgWidth, - containerLeft: - this._tabsList.getBoundingClientRect().left, - }; - } - } else { - return false; - } - } - - // For intra-group drag (sourceIndex >= 0) the gap animation is the - // sole visual indicator — clear any stale anchor overlay that may - // have been set while the cursor was over the panel content area or - // another zone. External drags (sourceIndex === -1) leave the - // overlay to the individual tab Droptargets so cross-group - // animation is not disrupted. - if (this._animState!.sourceIndex !== -1) { - this.group.model.dropTargetContainer?.model?.clear(); - } - this.handleDragOver({ clientX }); - return true; - } - - /** - * Shared body of the dragleave entry point. Preserves anim state when - * the drag moves between tabs-list children, into the extended drop - * zone, or into the void container; tears it down otherwise. - */ - private _processDragLeave(related: Element | null): void { - if (!this._animState) { - return; - } - // Moves between children of the tabs list aren't real leaves. - if (related && this._tabsList.contains(related)) { - return; - } - // Moving into the broader drop zone (e.g. void container, left - // actions) — keep anim state alive so external listeners can - // continue the gap animation. - if (related && this._extendedDropZone?.contains(related)) { - this.resetTabTransforms(); - this._animState.currentInsertionIndex = null; - return; - } - // Leaving toward the void container (empty header space to the - // right): keep anim state so a drop can still land at the end. - const isVoid = - this._voidContainer && - related && - (related === this._voidContainer || - this._voidContainer.contains(related)); - if (isVoid) { - return; - } - this.resetTabTransforms(); - if (this._animState.sourceIndex === -1) { - this.group.model.dropTargetContainer?.model?.clear(); - this._animState = null; - } else { - this._animState.currentInsertionIndex = null; - } + return this._reorder.snapshotTabPositions(); } private handleDragOver(event: { clientX: number }): void { - if (!this._animState) { - return; - } - - const mouseX = event.clientX; - - let insertionIndex: number | null = null; - let targetTabGroupId: string | null = null; - - const sourceGroupPanelIds = this._animState.sourceGroupPanelIds; - - // Accumulation approach: compute where the drag image's left edge - // would be, then walk tabs left-to-right using their original widths. - // A tab fits to the left of the gap if the cumulative width of all - // preceding non-source tabs <= available space. - const dragLeftEdge = mouseX - this._animState.cursorOffsetFromDragLeft; - const availableSpace = dragLeftEdge - this._animState.containerLeft; - let accWidth = 0; - - // Build lookup: first panel ID of each non-source group → group ID - // so we can add chip widths when we encounter a group's first tab. - const firstPanelToGroup = new Map(); - if (this._tabGroupManager.chipRenderers.size > 0) { - const tabGroups = this.group.model.getTabGroups(); - for (const tg of tabGroups) { - if (tg.id === this._animState.sourceTabGroupId) { - continue; - } - if (tg.panelIds.length > 0) { - firstPanelToGroup.set(tg.panelIds[0], tg.id); - } - } - } - - for (let i = 0; i < this._tabs.length; i++) { - const tab = this._tabs[i].value; - if (tab.panel.id === this._animState.sourceTabId) { - continue; - } - if (sourceGroupPanelIds?.has(tab.panel.id)) { - continue; - } - - // If this tab is the first of a non-source group, include - // the chip width (which sits before it in the DOM). - const groupId = firstPanelToGroup.get(tab.panel.id); - if (groupId) { - const chipWidth = - this._animState.chipPositions.get(groupId) ?? 0; - if (accWidth + chipWidth > availableSpace) { - // Chip alone overflows — gap goes before this group - insertionIndex ??= i; - break; - } - accWidth += chipWidth; - } - - // Use original width (before collapse/transforms) - const origRect = this._animState.tabPositions.get(tab.panel.id); - const tabWidth = origRect - ? origRect.width - : tab.element.getBoundingClientRect().width; - - // Shift at the midpoint: a tab moves left once the drag image - // covers half of it (like Chrome's tab drag behavior). - if (accWidth + tabWidth / 2 <= availableSpace) { - accWidth += tabWidth; - insertionIndex = i + 1; - } else { - insertionIndex ??= i; - break; - } - } - - // Determine which tab group (if any) the insertion index falls within. - // - // We use snapshot-based positions (accWidth from the accumulation loop - // above) to compute original chip boundaries. This avoids reading - // getBoundingClientRect() on chips whose live position is shifted by - // the drag gap margin, which caused oscillation / visual jumps. - if ( - insertionIndex !== null && - this._tabGroupManager.chipRenderers.size > 0 - ) { - const isGroupDrag = !!this._animState.sourceTabGroupId; - const tabGroups = this.group.model.getTabGroups(); - - // Rebuild the accumulated width up to insertionIndex so we know - // the original right edge of the chip (if any) that precedes it. - // We walk exactly the same way as the accumulation loop above. - let accUpTo = 0; - for (let i = 0; i < this._tabs.length; i++) { - const tab = this._tabs[i].value; - if (tab.panel.id === this._animState.sourceTabId) { - continue; - } - if (sourceGroupPanelIds?.has(tab.panel.id)) { - continue; - } - if (i >= insertionIndex) { - break; - } - const gid = firstPanelToGroup.get(tab.panel.id); - if (gid) { - accUpTo += this._animState.chipPositions.get(gid) ?? 0; - } - const origRect = this._animState.tabPositions.get(tab.panel.id); - accUpTo += origRect - ? origRect.width - : tab.element.getBoundingClientRect().width; - } - - for (const tg of tabGroups) { - // Build effective panel list: exclude the source tab - // so that dragging a tab out of its own group doesn't - // inflate the group's index range. - const effectivePanelIds = tg.panelIds.filter( - (pid) => - pid !== this._animState!.sourceTabId && - !sourceGroupPanelIds?.has(pid) - ); - if (effectivePanelIds.length === 0) { - continue; - } - const firstIdx = this._tabs.findIndex( - (t) => t.value.panel.id === effectivePanelIds[0] - ); - const lastIdx = this._tabs.findIndex( - (t) => - t.value.panel.id === - effectivePanelIds[effectivePanelIds.length - 1] - ); - if (firstIdx === -1 || lastIdx === -1) { - continue; - } - - const isInsideRange = - insertionIndex >= firstIdx && insertionIndex <= lastIdx; - - const isJustBeforeGroup = - !isInsideRange && insertionIndex === firstIdx - 1; - - if (!isInsideRange && !isJustBeforeGroup) { - continue; - } - - if (isGroupDrag && isInsideRange) { - // A group cannot be dropped inside another group. - // Snap the insertion index to just before or just - // after this group based on cursor position relative - // to the group's midpoint. Only applies when the - // insertion would land *inside* the group — for - // `isJustBeforeGroup`, the index is already outside - // (immediately left of the group) and is a valid - // drop position, so leave it untouched (issue #1264). - const groupMid = (firstIdx + lastIdx + 1) / 2; - if (insertionIndex < groupMid) { - insertionIndex = firstIdx; - } else { - insertionIndex = lastIdx + 1; - } - // targetTabGroupId stays null - break; - } - - if (isGroupDrag && isJustBeforeGroup) { - // Cursor is just before the group — accept this - // index as-is. Groups can be dropped at the slot - // immediately left of another group's first tab. - break; - } - - if (isJustBeforeGroup) { - // Check whether only the source tab (or source group - // tabs) sits between insertionIndex and firstIdx. - // If so, the source is being dragged away from that - // slot, so we ARE effectively "just before" the group - // and should still allow dropping into position 0. - let allInBetweenAreSource = true; - for (let j = insertionIndex; j < firstIdx; j++) { - const pid = this._tabs[j].value.panel.id; - if ( - pid !== this._animState!.sourceTabId && - !sourceGroupPanelIds?.has(pid) - ) { - allInBetweenAreSource = false; - break; - } - } - if (!allInBetweenAreSource) { - continue; - } - - const chipWidth = - this._animState.chipPositions.get(tg.id) ?? 0; - const threshold = tg.collapsed - ? this._animState.containerLeft + - accUpTo + - chipWidth / 2 - : this._animState.containerLeft + accUpTo + chipWidth; - if (mouseX >= threshold) { - insertionIndex = firstIdx; - targetTabGroupId = tg.id; - } - break; - } - - if (isInsideRange) { - const chipWidth = - this._animState.chipPositions.get(tg.id) ?? 0; - const chipOriginalRight = - this._animState.containerLeft + accUpTo + chipWidth; - if (insertionIndex === firstIdx) { - if (mouseX >= chipOriginalRight) { - targetTabGroupId = tg.id; - } - } else { - targetTabGroupId = tg.id; - } - break; - } - } - } - - if ( - insertionIndex === this._animState.currentInsertionIndex && - targetTabGroupId === this._animState.targetTabGroupId - ) { - return; - } - - this._animState.currentInsertionIndex = insertionIndex; - this._animState.targetTabGroupId = targetTabGroupId; - - if (this.accessor.options.theme?.tabAnimation === 'smooth') { - this.applyDragOverTransforms(); - } - } - - /** - * Batch-remove a CSS class from multiple elements instantly, - * forcing only a single reflow for the entire batch. - */ - private _removeClassInstantlyBatch( - elements: HTMLElement[], - cls: string - ): void { - const affected: HTMLElement[] = []; - for (const el of elements) { - if (el.classList.contains(cls)) { - el.style.transition = 'none'; - toggleClass(el, cls, false); - affected.push(el); - } - } - if (affected.length > 0) { - void affected[0].offsetHeight; // single reflow for entire batch - for (const el of affected) { - el.style.removeProperty('transition'); - } - } - } - - /** - * Remove `dv-tab--dragging` from the source tab instantly so it - * regains its real width before FLIP snapshots. - */ - private _uncollapsSourceTab(sourceTabId: string): void { - const entry = this._tabMap.get(sourceTabId); - if (entry) { - this._removeClassInstantlyBatch( - [entry.value.element], - 'dv-tab--dragging' - ); - } + this._reorder.handleDragOver(event); } private applyDragOverTransforms(skipTransition = false): void { - if ( - !this._animState || - this._animState.currentInsertionIndex === null - ) { - this.resetTabTransforms(); - return; - } - - // Don't apply transforms until the source tab has been collapsed - // in the rAF callback — otherwise the gap + visible source = jump. - if (this._pendingCollapse) { - return; - } - - const insertionIndex = this._animState.currentInsertionIndex; - - // For group drags, gap = sum of all group member widths - let gapWidth: number; - const sourceGroupPanelIds = this._animState.sourceGroupPanelIds; - if (this._animState.sourceTabGroupId && sourceGroupPanelIds) { - gapWidth = this._animState.sourceGapWidth; - } else { - const sourceRect = this._animState.tabPositions.get( - this._animState.sourceTabId - ); - gapWidth = sourceRect - ? sourceRect.width - : this.getAverageTabWidth(); - } - - // When the insertion lands at or before a group's first tab, shift - // the chip so the gap appears before the entire group. - // - // Two cases: - // 1. targetTabGroupId is null (standalone drop) — always shift chip. - // 2. targetTabGroupId is set AND the group is collapsed — shift chip - // because the collapsed tabs are invisible, so putting the gap on - // them has no visual effect. - let chipToShift: HTMLElement | null = null; - if (this._tabGroupManager.chipRenderers.size > 0) { - const tabGroups = this.group.model.getTabGroups(); - for (const tg of tabGroups) { - if (tg.id === this._animState.sourceTabGroupId) continue; - // Skip the group that the dragged tab belongs to — the - // gap should appear after the chip (where the tab was), - // not before it. - if (tg.panelIds.includes(this._animState.sourceTabId)) continue; - const effectivePids = tg.panelIds.filter( - (pid) => - pid !== this._animState!.sourceTabId && - !sourceGroupPanelIds?.has(pid) - ); - if (effectivePids.length === 0) continue; - const firstIdx = this._tabs.findIndex( - (t) => t.value.panel.id === effectivePids[0] - ); - - // Only consider chip-shifting when dropping outside the - // group, or when dropping inside a collapsed group (whose - // tabs are invisible). - const shouldShiftChip = - !this._animState.targetTabGroupId || - (this._animState.targetTabGroupId === tg.id && - tg.collapsed); - - if (!shouldShiftChip) continue; - - if (firstIdx >= insertionIndex) { - let hasTabs = false; - for (let j = insertionIndex; j < firstIdx; j++) { - const pid = this._tabs[j].value.panel.id; - if (pid === this._animState.sourceTabId) continue; - if (sourceGroupPanelIds?.has(pid)) continue; - hasTabs = true; - break; - } - if (!hasTabs) { - const chipEntry = - this._tabGroupManager.chipRenderers.get(tg.id); - if (chipEntry) { - chipToShift = chipEntry.chip.element; - } - } - break; - } - } - } - - // Helper: pick the correct shifting class for tabs vs chips. - const shiftingClass = (el: HTMLElement): string => - el.classList.contains('dv-tab-group-chip') - ? 'dv-tab-group-chip--shifting' - : 'dv-tab--shifting'; - - // Helper: apply a margin-left value to an element, optionally - // bypassing CSS transitions for instant positioning. - const setMargin = (el: HTMLElement, value: string) => { - if (skipTransition) { - el.style.transition = 'none'; - el.style.marginLeft = value; - void el.offsetHeight; - el.style.removeProperty('transition'); - } else { - el.style.marginLeft = value; - } - toggleClass(el, shiftingClass(el), true); - }; - - const clearMargin = (el: HTMLElement) => { - const cls = shiftingClass(el); - - // Remove any previous pending listener for this element - const prev = this._pendingMarginCleanups.get(el); - if (prev) { - prev(); - } - - if (skipTransition || !el.style.marginLeft) { - el.style.removeProperty('margin-left'); - toggleClass(el, cls, false); - } else { - el.style.marginLeft = '0px'; - toggleClass(el, cls, true); - const onEnd = () => { - el.style.removeProperty('margin-left'); - toggleClass(el, cls, false); - el.removeEventListener('transitionend', onEnd); - clearTimeout(fallbackTimer); - this._pendingMarginCleanups.delete(el); - }; - // Fallback in case transitionend never fires - // (e.g. element removed from DOM mid-transition) - const fallbackTimer = setTimeout(onEnd, 300); - this._pendingMarginCleanups.set(el, onEnd); - el.addEventListener('transitionend', onEnd); - } - }; - - let gapApplied = false; - - // Reset all non-source chip margins first - for (const [groupId, entry] of this._tabGroupManager.chipRenderers) { - if (groupId === this._animState.sourceTabGroupId) continue; - clearMargin(entry.chip.element); - } - - // Apply gap to chip if insertion is before a group - if (chipToShift) { - setMargin(chipToShift, `${gapWidth}px`); - gapApplied = true; - } - - for (let i = 0; i < this._tabs.length; i++) { - const tab = this._tabs[i].value; - if (tab.panel.id === this._animState.sourceTabId) { - continue; - } - if (sourceGroupPanelIds?.has(tab.panel.id)) { - continue; - } - - if (!gapApplied && i >= insertionIndex) { - setMargin(tab.element, `${gapWidth}px`); - gapApplied = true; - } else { - clearMargin(tab.element); - } - } - - // Reposition underlines to follow shifted chips/tabs - this._tabGroupManager.trackUnderlines(); + this._reorder.applyDragOverTransforms(skipTransition); } private resetTabTransforms(): void { - // Cancel any pending margin transitionend listeners - for (const [, cleanup] of this._pendingMarginCleanups) { - cleanup(); - } - this._pendingMarginCleanups.clear(); - - for (const tab of this._tabs) { - tab.value.element.style.removeProperty('margin-left'); - tab.value.element.style.removeProperty('margin-right'); - tab.value.element.style.removeProperty('margin-top'); - tab.value.element.style.removeProperty('margin-bottom'); - tab.value.element.style.removeProperty('transform'); - toggleClass(tab.value.element, 'dv-tab--shifting', false); - } - for (const [, entry] of this._tabGroupManager.chipRenderers) { - entry.chip.element.style.removeProperty('margin-left'); - toggleClass( - entry.chip.element, - 'dv-tab-group-chip--shifting', - false - ); - } - this._tabGroupManager.positionUnderlines(); + this._reorder.resetTabTransforms(); } - /** - * Commit a group-drag drop: clear drag classes, move the group - * in the model, and run a FLIP animation. - */ private _commitGroupMove( sourceTabGroupId: string, insertionIndex: number ): void { - // Read transfer data first. - const data = getPanelData(); - - // Synchronously dispose the source chip's drag sources, which - // clears the panelTransfer payload + iframe shield. Cross-group - // moves dissolve the source chip on a microtask, which is too - // late: a synchronous `getPanelData()` after this method (or any - // sibling dragover handler firing in the same tick) would - // otherwise see stale data still referencing the old tabGroupId. - this._tabGroupManager.disposeChipDrag(sourceTabGroupId); - - // Check if the tab group exists in this group (within-group reorder) - // or in another group (cross-group move). - const isLocal = this.group.model - .getTabGroups() - .some((tg) => tg.id === sourceTabGroupId); - - if (isLocal) { - if (this.accessor.options.theme?.tabAnimation === 'smooth') { - this._clearGroupDragClasses(sourceTabGroupId); - const firstPositions = this.snapshotTabPositions(); - this.resetTabTransforms(); - this.group.model.moveTabGroup(sourceTabGroupId, insertionIndex); - this.runFlipAnimation(firstPositions, '', false); - } else { - this._tabGroupManager.skipNextCollapseAnimation = true; - this.group.model.moveTabGroup(sourceTabGroupId, insertionIndex); - } - } else if (data) { - // Cross-group: delegate to the component-level move which - // handles panel transfer and tab group recreation. - // Use the REAL tab group ID from transfer data, not the - // potentially stale one from _animState. - // - // Clear any inline gap margin / shifting class applied to - // destination tabs during dragover. Cross-group moves don't - // run the FLIP path, and `moveGroupOrPanel` only inserts new - // panels — it doesn't recreate existing destination tabs, so - // their inline `margin-left` would otherwise persist as a - // visible gap (issue #1243). - this.resetTabTransforms(); - this.accessor.moveGroupOrPanel({ - from: { - groupId: data.groupId, - tabGroupId: data.tabGroupId ?? sourceTabGroupId, - }, - to: { - group: this.group, - position: 'center', - index: insertionIndex, - }, - }); - } - } - - private _clearGroupDragClasses(sourceTabGroupId: string): void { - const chipEntry = - this._tabGroupManager.chipRenderers.get(sourceTabGroupId); - if (chipEntry) { - this._removeClassInstantlyBatch( - [chipEntry.chip.element], - 'dv-tab-group-chip--dragging' - ); - } - this._removeClassInstantlyBatch( - this._tabs.map((t) => t.value.element), - 'dv-tab--dragging' - ); - // Restore underline - const underline = - this._tabGroupManager.groupUnderlines.get(sourceTabGroupId); - if (underline) { - underline.style.removeProperty('display'); - } - // The subsequent moveTabGroup will re-create tabs and call - // updateTabGroups → _updateTabGroupClasses. For collapsed groups - // the new tabs don't have dv-tab--group-collapsed yet, which - // would trigger the collapse animation. Skip it. - this._tabGroupManager.skipNextCollapseAnimation = true; - } - - private resetDragAnimation(): void { - this._pendingCollapse = false; - - // After a drop, `tab.onDrop` consumes _animState (sets it to null) - // and immediately calls `runFlipAnimation`, which sets transforms - // and queues an rAF to trigger the CSS transition. dragend fires - // synchronously on the source element BEFORE that rAF runs — if - // we cleared transforms here we'd clobber the in-flight FLIP, so - // gate the cleanup on _animState still being set (i.e. drag was - // cancelled rather than dropped). - if (this._animState) { - this.resetTabTransforms(); - if (this._animState.sourceTabGroupId) { - this._clearGroupDragClasses(this._animState.sourceTabGroupId); - } else { - this._removeClassInstantlyBatch( - this._tabs.map((t) => t.value.element), - 'dv-tab--dragging' - ); - } - this._animState = null; - // Restore any hidden underlines from group drags. - for (const [, el] of this._tabGroupManager.groupUnderlines) { - el.style.removeProperty('display'); - } - } + this._reorder.commitGroupMove(sourceTabGroupId, insertionIndex); } private runFlipAnimation( @@ -2054,98 +1272,11 @@ export class Tabs extends CompositeDisposable { isCrossGroup: boolean = false, animRange?: { from: number; to: number } ): void { - const isVertical = this._direction === 'vertical'; - let hasAnimation = false; - - for (let i = 0; i < this._tabs.length; i++) { - const tab = this._tabs[i]; - const panelId = tab.value.panel.id; - - if (panelId === sourceTabId) { - if (isCrossGroup) { - // Newly inserted tab: slide in from the end - const rect = tab.value.element.getBoundingClientRect(); - tab.value.element.style.transform = isVertical - ? `translateY(${rect.height}px)` - : `translateX(${rect.width}px)`; - toggleClass(tab.value.element, 'dv-tab--shifting', true); - hasAnimation = true; - } - continue; - } - - // Skip tabs outside the affected range (they don't logically move) - if ( - animRange !== undefined && - (i < animRange.from || i > animRange.to) - ) { - continue; - } - - const firstRect = firstPositions.get(panelId); - if (!firstRect) { - continue; - } - - const lastRect = tab.value.element.getBoundingClientRect(); - const delta = isVertical - ? firstRect.top - lastRect.top - : firstRect.left - lastRect.left; - - if (Math.abs(delta) < 1) { - continue; - } - - tab.value.element.style.transform = isVertical - ? `translateY(${delta}px)` - : `translateX(${delta}px)`; - toggleClass(tab.value.element, 'dv-tab--shifting', true); - hasAnimation = true; - } - - if (!hasAnimation) { - return; - } - - requestAnimationFrame(() => { - for (const tab of this._tabs) { - if (tab.value.element.style.transform) { - tab.value.element.style.transform = ''; - } - } - - // Track underlines during the FLIP transition so they - // follow tabs as they slide to their final positions. - this._tabGroupManager.trackUnderlines(); - - // Clean up any previous flip transition listener - this._flipTransitionCleanup?.(); - - const onTransitionEnd = (event: TransitionEvent) => { - if (event.propertyName === 'transform') { - cleanup(); - for (const tab of this._tabs) { - toggleClass( - tab.value.element, - 'dv-tab--shifting', - false - ); - } - // Final reposition after animation settles - this._tabGroupManager.positionUnderlines(); - } - }; - - const cleanup = () => { - this._tabsList.removeEventListener( - 'transitionend', - onTransitionEnd - ); - this._flipTransitionCleanup = null; - }; - - this._flipTransitionCleanup = cleanup; - this._tabsList.addEventListener('transitionend', onTransitionEnd); - }); + this._reorder.runFlipAnimation( + firstPositions, + sourceTabId, + isCrossGroup, + animRange + ); } } From 222b52f0a41d8d9f339fc61d4238eca60cb521fa Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:20:54 +0100 Subject: [PATCH 04/11] feat(core): 2-D cross-row tab reorder for multi-row tabs (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag-reorder tabs across wrapped rows. Built on the extracted TabReorderController; the 1-D smooth path is untouched (all wrap logic is gated on the `dv-tabs-container--wrap` class). - `handleDragOver` gains a wrap branch: `computeWrappedInsertionIndex` maps the pointer to (row-by-y, slot-by-x-midpoint) → a flat insertion index, and a discrete drop indicator (`dv-tab--reorder-before/after`) replaces the gap animation. `clientY` is threaded through processDragOver / the HTML5 + pointer dragover entry points. - The 1-D gap (`applyDragOverTransforms`) and FLIP (`runFlipAnimation`) both early-return in wrap — they animate single-axis deltas that fight the flow layout. - Commit: in smooth wrap the per-tab pointer drop target delegates to the content override and doesn't reorder, so `handlePointerDragEnd` commits the reorder from the computed 2-D index when the drag ends over the strip. HTML5 wrap drops commit via the existing tabs-list `drop` listener (`currentInsertionIndex`, now 2-D). Tests: e2e drags a tab from a lower row to the front (smooth mode) and asserts the reorder. core 1107 / e2e 30 green; the single-row drag suites (tabsAnimation 2,141 lines) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/fixtures/index.html | 7 + e2e/tests/multi-row-tabs.spec.ts | 53 +++++ .../titlebar/tabReorderController.ts | 218 ++++++++++++++++-- .../dockview/components/titlebar/tabs.scss | 21 ++ .../src/dockview/components/titlebar/tabs.ts | 11 +- 5 files changed, 293 insertions(+), 17 deletions(-) diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index 7534f5f48..886594549 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -56,9 +56,16 @@ params.get('overflow') === 'wrap' ? { mode: 'wrap' } : undefined; + // `?smooth=1` turns on the smooth tab reorder animation (needed + // to exercise the 2-D cross-row wrap reorder path). + const theme = + params.get('smooth') === '1' + ? { ...core.themeAbyss, tabAnimation: 'smooth' } + : undefined; const dockview = new core.DockviewComponent(el, { keyboardNavigation: true, overflow, + theme, // Record layout history so the harness can exercise undo/redo // (incl. async popout re-open). layoutHistory: { enabled: true }, diff --git a/e2e/tests/multi-row-tabs.spec.ts b/e2e/tests/multi-row-tabs.spec.ts index d0ba2a255..e44b60fae 100644 --- a/e2e/tests/multi-row-tabs.spec.ts +++ b/e2e/tests/multi-row-tabs.spec.ts @@ -83,4 +83,57 @@ test.describe('multi-row tabs (wrap mode)', () => { page.locator('.dv-tabs-container').first() ).not.toHaveClass(/dv-tabs-container--wrap/); }); + + // Phase 3: 2-D cross-row drag reorder (smooth mode). A tab dragged from a + // lower row to a slot on an upper row reorders across the row boundary. + test('a tab drags across rows to reorder (smooth mode)', async ({ + page, + }) => { + await page.goto('/e2e/fixtures/index.html?overflow=wrap&smooth=1'); + await page.waitForFunction(() => (window as any).__ready === true); + await page.evaluate(() => (window as any).__dv.setupWrapTabs(20)); + + const tabOrder = () => + page.evaluate(() => + Array.from( + document.querySelectorAll('.dv-tabs-container .dv-tab') + ).map((t) => (t as HTMLElement).innerText.trim()) + ); + + const before = await tabOrder(); + expect(before.length).toBe(20); + // sanity: tabs really wrapped to more than one row + const rows = await page.evaluate(() => { + const tops = new Set(); + document + .querySelectorAll('.dv-tabs-container .dv-tab') + .forEach((t) => tops.add(t.offsetTop)); + return tops.size; + }); + expect(rows).toBeGreaterThan(1); + + // drag the last tab (a lower row) to the front (first tab, upper row) + const source = page.locator('.dv-tab', { + hasText: 'wrap-tab-long-title-19', + }); + const target = page.locator('.dv-tab', { + hasText: 'wrap-tab-long-title-0', + }); + const s = (await source.boundingBox())!; + const t = (await target.boundingBox())!; + + await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2); + await page.mouse.down(); + // nudge to start the drag + await page.mouse.move(s.x + s.width / 2 + 6, s.y + s.height / 2); + // move onto the left half of the first tab (insert before it) + await page.mouse.move(t.x + 3, t.y + t.height / 2, { steps: 20 }); + await page.mouse.up(); + + const after = await tabOrder(); + // the dragged tab moved to the front and the set is unchanged + expect(after[0]).toContain('wrap-tab-long-title-19'); + expect([...after].sort()).toEqual([...before].sort()); + expect(after).not.toEqual(before); + }); }); diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts index 2c454f1f1..92157dba1 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -68,6 +68,8 @@ export class TabReorderController extends CompositeDisposable { private _voidContainer: HTMLElement | null = null; private _extendedDropZone: HTMLElement | null = null; private _pointerInsideTabsList = false; + /** The tab element currently carrying a wrap-mode drop indicator, if any. */ + private _wrapIndicatorEl: HTMLElement | null = null; // Field-name mirrors so the extracted method bodies can stay verbatim. private get _tabs(): IValueDisposable[] { @@ -92,6 +94,13 @@ export class TabReorderController extends CompositeDisposable { return this.host.tabGroupManager; } + /** Whether the tab strip is in multi-row wrap layout + * (`MultiRowTabsModule`, `overflow.mode: 'wrap'`). In wrap, the 1-D + * gap/FLIP animation is suppressed and reorder uses a 2-D hit-test. */ + private get _wrapMode(): boolean { + return this._tabsList.classList.contains('dv-tabs-container--wrap'); + } + get animState(): TabAnimationState | null { return this._animState; } @@ -207,7 +216,7 @@ export class TabReorderController extends CompositeDisposable { } this._pointerInsideTabsList = true; - this.processDragOver(clientX); + this.processDragOver(clientX, clientY); } /** @@ -215,11 +224,63 @@ export class TabReorderController extends CompositeDisposable { * inside-strip flag and tear down any smooth-reorder anim state the * dragover bridge may have installed. */ - handlePointerDragEnd(): void { + handlePointerDragEnd(e?: { + clientX: number; + clientY: number; + pointerEvent: PointerEvent; + }): void { + // Multi-row wrap intra-group reorder: in smooth wrap the per-tab pointer + // drop target delegates to the content override and doesn't reorder, so + // commit the reorder from the computed 2-D insertion index when the drag + // ends over the strip. (HTML5 wrap drops commit via the tabs-list `drop` + // listener, which already reads `currentInsertionIndex`.) + if ( + this._wrapMode && + e && + this._animState && + this._animState.sourceIndex !== -1 && + this._animState.currentInsertionIndex !== null && + this.isPointInsideTabsList(e.clientX, e.clientY) + ) { + this.commitWrapReorder(e.pointerEvent); + } this._pointerInsideTabsList = false; this.resetDragAnimation(); } + private isPointInsideTabsList(clientX: number, clientY: number): boolean { + const doc = this._tabsList.ownerDocument ?? document; + const el = doc.elementFromPoint(clientX, clientY); + return !!el && this._tabsList.contains(el); + } + + /** Commit a wrap-mode intra-group reorder from the current 2-D insertion + * index (adjusted for the source's own position), then clear drag state. */ + private commitWrapReorder(event: PointerEvent): void { + const animState = this._animState; + if (!animState || animState.currentInsertionIndex === null) { + return; + } + const insertionIndex = animState.currentInsertionIndex; + const sourceIndex = animState.sourceIndex; + const adjustedIndex = + insertionIndex - + (sourceIndex !== -1 && sourceIndex < insertionIndex ? 1 : 0); + + this._animState = null; + this.clearWrapDropIndicator(); + this.uncollapseSourceTab(animState.sourceTabId); + + if (adjustedIndex === sourceIndex) { + return; + } + this.host.fireDrop({ + event, + index: adjustedIndex, + targetTabGroupId: null, + }); + } + /** * Shared body of the dragover entry point. Refreshes stale anim state * for a changed drag identity, initializes anim state for incoming @@ -228,20 +289,11 @@ export class TabReorderController extends CompositeDisposable { * ownership of the drag — HTML5 callers use this to gate * `event.preventDefault()`. */ - processDragOver(clientX: number): boolean { + processDragOver(clientX: number, clientY?: number): boolean { if (this.accessor.options.disableDnd) { return false; } - // Multi-row wrap mode: the smooth single-row reorder is x-only and - // breaks across wrapped rows, so skip it for a wrapped strip. Dragging a - // tab out to detach/redock still works (that's a separate path), and the - // `default` animation mode keeps its per-tab drop targets (2-D safe). - // True cross-row reorder ships in a later phase. - if (this._tabsList.classList.contains('dv-tabs-container--wrap')) { - return false; - } - // Stale-state guard: if a previous drag's anim state is still here // but the current drag is a different identity, drop the stale one // so the new drag starts from a clean slate. @@ -332,7 +384,7 @@ export class TabReorderController extends CompositeDisposable { if (this._animState!.sourceIndex !== -1) { this.group.model.dropTargetContainer?.model?.clear(); } - this.handleDragOver({ clientX }); + this.handleDragOver({ clientX, clientY }); return true; } @@ -376,11 +428,19 @@ export class TabReorderController extends CompositeDisposable { } } - handleDragOver(event: { clientX: number }): void { + handleDragOver(event: { clientX: number; clientY?: number }): void { if (!this._animState) { return; } + // Multi-row wrap: the 1-D x-accumulation below assumes a single row. + // In wrap use a 2-D hit-test (row by y, slot by x within the row) and + // a discrete drop indicator instead of the gap animation. + if (this._wrapMode) { + this.handleWrappedDragOver(event.clientX, event.clientY ?? 0); + return; + } + const mouseX = event.clientX; let insertionIndex: number | null = null; @@ -616,6 +676,123 @@ export class TabReorderController extends CompositeDisposable { } } + /** + * Multi-row wrap drag-over: resolve the 2-D insertion slot and show a + * discrete drop indicator. No gap/FLIP animation (that's 1-D and fights the + * flow layout — `applyDragOverTransforms`/`runFlipAnimation` no-op in wrap). + */ + private handleWrappedDragOver(clientX: number, clientY: number): void { + if (!this._animState) { + return; + } + const index = this.computeWrappedInsertionIndex(clientX, clientY); + // targetTabGroupId stays null — wrap reorder is single-tab within the + // group; group-chip drops keep the 1-D path (chips don't wrap in v1). + if ( + index === this._animState.currentInsertionIndex && + this._animState.targetTabGroupId === null + ) { + return; + } + this._animState.currentInsertionIndex = index; + this._animState.targetTabGroupId = null; + this.updateWrapDropIndicator(index); + } + + /** + * The insertion slot (index into the full tab list) for a pointer at + * `(clientX, clientY)` over a wrapped strip: pick the row whose vertical + * span contains `clientY` (clamped to first/last), then the slot within that + * row by x-midpoint. Excludes the drag source so its own position doesn't + * bias the result; the drop path adjusts for the source offset. + */ + private computeWrappedInsertionIndex( + clientX: number, + clientY: number + ): number { + const sourceId = this._animState?.sourceTabId; + const sourceGroupIds = this._animState?.sourceGroupPanelIds; + + const entries: { index: number; rect: DOMRect }[] = []; + for (let i = 0; i < this._tabs.length; i++) { + const tab = this._tabs[i].value; + if (tab.panel.id === sourceId) { + continue; + } + if (sourceGroupIds?.has(tab.panel.id)) { + continue; + } + entries.push({ + index: i, + rect: tab.element.getBoundingClientRect(), + }); + } + if (entries.length === 0) { + return this._tabs.length; + } + + // Bucket into rows by top edge (2px tolerance for sub-pixel rounding). + const rows: { top: number; bottom: number; items: typeof entries }[] = + []; + for (const entry of entries) { + const row = rows.find((r) => Math.abs(r.top - entry.rect.top) <= 2); + if (row) { + row.items.push(entry); + row.bottom = Math.max(row.bottom, entry.rect.bottom); + } else { + rows.push({ + top: entry.rect.top, + bottom: entry.rect.bottom, + items: [entry], + }); + } + } + rows.sort((a, b) => a.top - b.top); + + // Pick the row: the one whose vertical span contains clientY, else + // clamp above→first / below→last. + let row = rows.find((r) => clientY >= r.top && clientY <= r.bottom); + if (!row) { + row = clientY < rows[0].top ? rows[0] : rows[rows.length - 1]; + } + + // Within the row, insert before the first tab whose horizontal midpoint + // is past the pointer; past all of them → after the row's last tab. + const items = row.items.sort((a, b) => a.rect.left - b.rect.left); + for (const item of items) { + const midpoint = item.rect.left + item.rect.width / 2; + if (clientX < midpoint) { + return item.index; + } + } + return items[items.length - 1].index + 1; + } + + private updateWrapDropIndicator(index: number): void { + this.clearWrapDropIndicator(); + const count = this._tabs.length; + if (count === 0) { + return; + } + if (index < count) { + const el = this._tabs[index].value.element; + el.classList.add('dv-tab--reorder-before'); + this._wrapIndicatorEl = el; + } else { + const el = this._tabs[count - 1].value.element; + el.classList.add('dv-tab--reorder-after'); + this._wrapIndicatorEl = el; + } + } + + private clearWrapDropIndicator(): void { + this._wrapIndicatorEl?.classList.remove( + 'dv-tab--reorder-before', + 'dv-tab--reorder-after' + ); + this._wrapIndicatorEl = null; + } + /** * Batch-remove a CSS class from multiple elements instantly, * forcing only a single reflow for the entire batch. @@ -655,6 +832,11 @@ export class TabReorderController extends CompositeDisposable { } applyDragOverTransforms(skipTransition = false): void { + // The gap animation is 1-D (single-row margin shifting) and fights the + // wrap flow layout — wrap uses a discrete drop indicator instead. + if (this._wrapMode) { + return; + } if ( !this._animState || this._animState.currentInsertionIndex === null @@ -829,6 +1011,8 @@ export class TabReorderController extends CompositeDisposable { } resetTabTransforms(): void { + this.clearWrapDropIndicator(); + // Cancel any pending margin transitionend listeners for (const [, cleanup] of this._pendingMarginCleanups) { cleanup(); @@ -974,6 +1158,12 @@ export class TabReorderController extends CompositeDisposable { isCrossGroup: boolean = false, animRange?: { from: number; to: number } ): void { + // The FLIP is a 1-D (single-axis) slide; across wrapped rows it would + // animate wrong deltas, so wrap does a discrete reorder (no FLIP). + if (this._wrapMode) { + return; + } + const isVertical = this._direction === 'vertical'; let hasAnimation = false; diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss index f7b30367c..6e98239be 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss @@ -17,6 +17,27 @@ flex-wrap: wrap; height: auto; overflow: visible; + + // Discrete drop indicator for 2-D cross-row reorder (the 1-D gap + // animation is suppressed in wrap). A bar on the leading/trailing edge + // of the tab the drop would land before/after. + .dv-tab--reorder-before::after, + .dv-tab--reorder-after::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 2px; + z-index: 10; + pointer-events: none; + background-color: var(--dv-drag-over-border-color); + } + .dv-tab--reorder-before::after { + left: 0; + } + .dv-tab--reorder-after::after { + right: 0; + } } /* GPU optimizations for smooth scrolling */ diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index 930e6ea0d..38660996c 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -326,8 +326,8 @@ export class Tabs extends CompositeDisposable implements ITabReorderHost { // down smooth-reorder anim state the dragover bridge may // have installed. The chip's pointer drag source handles // its own transfer payload + iframe-shield cleanup. - PointerDragController.getInstance().onDragEnd(() => { - this._reorder.handlePointerDragEnd(); + PointerDragController.getInstance().onDragEnd((e) => { + this._reorder.handlePointerDragEnd(e); }), // Pointer-event mirror of the HTML5 dragover / dragleave handlers // below. Drives smooth-reorder for `dndStrategy: 'pointer'` and @@ -410,7 +410,12 @@ export class Tabs extends CompositeDisposable implements ITabReorderHost { this._tabsList, 'dragover', (event) => { - if (this._reorder.processDragOver(event.clientX)) { + if ( + this._reorder.processDragOver( + event.clientX, + event.clientY + ) + ) { // Allow `drop` to fire on the tabs list container. event.preventDefault(); } From ee801a7061975a30a4e0543a653c7c7597104cf1 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:27:19 +0100 Subject: [PATCH 05/11] docs(core): correct wrap reorder commit rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior comment claimed the per-tab pointer drop target "delegates to the content override and doesn't reorder" — that's wrong: the override still fires onDrop. The accurate reason the per-tab path doesn't commit in smooth wrap is that its drop target never latches a drop state for the intra-group drag, so onDrop isn't fired at all. Reword the comment to say that, and to make the no-double-commit guarantee explicit (tab.onDrop nulls _animState before handlePointerDragEnd runs). Comment-only; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/titlebar/tabReorderController.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts index 92157dba1..5a0c2bd19 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -229,11 +229,16 @@ export class TabReorderController extends CompositeDisposable { clientY: number; pointerEvent: PointerEvent; }): void { - // Multi-row wrap intra-group reorder: in smooth wrap the per-tab pointer - // drop target delegates to the content override and doesn't reorder, so - // commit the reorder from the computed 2-D insertion index when the drag - // ends over the strip. (HTML5 wrap drops commit via the tabs-list `drop` - // listener, which already reads `currentInsertionIndex`.) + // Multi-row wrap intra-group reorder — the pointer-backend analog of the + // HTML5 tabs-list `drop` commit. In smooth wrap the per-tab pointer drop + // target doesn't latch a drop state for the intra-group drag, so its + // `onDrop` never fires; commit the reorder from the computed 2-D + // insertion index when the drag ends over the strip. This can't + // double-commit: `tab.onDrop` nulls `_animState` before this runs (the + // backend calls `handleDrop` before `onDragEnd`), so if the per-tab path + // *did* fire, `_animState` is already null and this is skipped. (HTML5 + // wrap drops commit via the tabs-list `drop` listener, which likewise + // reads `currentInsertionIndex`.) if ( this._wrapMode && e && From d09d74e8d602729260a2ea8203070d9fa03ec3d5 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:36:02 +0100 Subject: [PATCH 06/11] fix(core): review fixes for multi-row wrap reorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from PR review: - Guard group-chip drags out of the 2-D wrap hit-test. A chip drag carries group-move semantics ("can't drop inside another group") the single-tab hit-test doesn't model; it now stays on the 1-D path (whose gap animation no-ops in wrap anyway) instead of having `targetTabGroupId` forced to null. - Cover default-mode wrap reorder with e2e. `tabAnimation` defaults to 'default' (per-tab drop targets), a different commit path from smooth; only smooth was tested. Refactor the reorder e2e into a shared helper and run it for both modes. core 1107 · multi-row e2e 5, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/tests/multi-row-tabs.spec.ts | 35 ++++++++++++------- .../titlebar/tabReorderController.ts | 8 +++-- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/e2e/tests/multi-row-tabs.spec.ts b/e2e/tests/multi-row-tabs.spec.ts index e44b60fae..6afca80de 100644 --- a/e2e/tests/multi-row-tabs.spec.ts +++ b/e2e/tests/multi-row-tabs.spec.ts @@ -86,10 +86,11 @@ test.describe('multi-row tabs (wrap mode)', () => { // Phase 3: 2-D cross-row drag reorder (smooth mode). A tab dragged from a // lower row to a slot on an upper row reorders across the row boundary. - test('a tab drags across rows to reorder (smooth mode)', async ({ - page, - }) => { - await page.goto('/e2e/fixtures/index.html?overflow=wrap&smooth=1'); + // Covers both animation modes: default (per-tab drop targets — the common + // case) and smooth (the tabs-list / pointer-end commit path). `tabAnimation` + // defaults to 'default', so both must work. + const reorderAcrossRows = async (page, query: string) => { + await page.goto(`/e2e/fixtures/index.html?${query}`); await page.waitForFunction(() => (window as any).__ready === true); await page.evaluate(() => (window as any).__dv.setupWrapTabs(20)); @@ -113,14 +114,12 @@ test.describe('multi-row tabs (wrap mode)', () => { expect(rows).toBeGreaterThan(1); // drag the last tab (a lower row) to the front (first tab, upper row) - const source = page.locator('.dv-tab', { - hasText: 'wrap-tab-long-title-19', - }); - const target = page.locator('.dv-tab', { - hasText: 'wrap-tab-long-title-0', - }); - const s = (await source.boundingBox())!; - const t = (await target.boundingBox())!; + const s = (await page + .locator('.dv-tab', { hasText: 'wrap-tab-long-title-19' }) + .boundingBox())!; + const t = (await page + .locator('.dv-tab', { hasText: 'wrap-tab-long-title-0' }) + .boundingBox())!; await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2); await page.mouse.down(); @@ -135,5 +134,17 @@ test.describe('multi-row tabs (wrap mode)', () => { expect(after[0]).toContain('wrap-tab-long-title-19'); expect([...after].sort()).toEqual([...before].sort()); expect(after).not.toEqual(before); + }; + + test('a tab drags across rows to reorder (default mode)', async ({ + page, + }) => { + await reorderAcrossRows(page, 'overflow=wrap'); + }); + + test('a tab drags across rows to reorder (smooth mode)', async ({ + page, + }) => { + await reorderAcrossRows(page, 'overflow=wrap&smooth=1'); }); }); diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts index 5a0c2bd19..38e4645a5 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -440,8 +440,12 @@ export class TabReorderController extends CompositeDisposable { // Multi-row wrap: the 1-D x-accumulation below assumes a single row. // In wrap use a 2-D hit-test (row by y, slot by x within the row) and - // a discrete drop indicator instead of the gap animation. - if (this._wrapMode) { + // a discrete drop indicator instead of the gap animation. Group-chip + // drags are excluded — they carry group-move semantics ("can't drop + // inside another group") the single-tab hit-test doesn't model, and + // chips don't wrap in v1, so they keep the 1-D path (whose gap animation + // no-ops in wrap anyway). + if (this._wrapMode && !this._animState.sourceTabGroupId) { this.handleWrappedDragOver(event.clientX, event.clientY ?? 0); return; } From 3e361f8abe6804100e21c94fc59ce93879f67dbc Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:46:33 +0100 Subject: [PATCH 07/11] fix(core): second-pass review fixes for multi-row wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an independent review of the PR: - Group-chip drags: my earlier handleDragOver guard wasn't enough — the pointer wrap-commit only checked `sourceIndex !== -1`, but a chip drag's sourceIndex is its firstIdx (>= 0). So a chip pointer-drag in wrap could fire commitWrapReorder (a bogus single-tab drop with sourceTabId '') AND the chip drop target (which doesn't null _animState) → double-commit. Also gate the commit on `!sourceTabGroupId`. - computeWrappedInsertionIndex: a pointer in the gap *between* two rows was clamped to the last row; pick the nearest row by vertical distance instead. - Runtime `overflow.mode` change is now honored — MultiRowTabsService subscribes to onDidOptionsChange and re-applies wrap to every group (was construction-time only, while `enabled` already read live options). New host member `onDidOptionsChange`; unit test added. - `maxRows` is documented as NOT YET IMPLEMENTED (it was inert but read like a working option). - Share the wrap CSS class as `OVERFLOW_WRAP_TABS_CLASS` (core + module) so the cross-package seam can't drift on a rename. core 1107 · modules 206 · e2e 31, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- __generated__/dockview-core-exports.txt | 1 + .../titlebar/tabReorderController.ts | 25 +++++++++++++++---- .../src/dockview/moduleContracts.ts | 3 +++ .../dockview-core/src/dockview/options.ts | 14 ++++++++++- .../src/__tests__/multiRowTabs.spec.ts | 17 +++++++++++++ .../src/multiRowTabsService.ts | 7 ++++-- 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/__generated__/dockview-core-exports.txt b/__generated__/dockview-core-exports.txt index f13515e91..08a6dbb09 100644 --- a/__generated__/dockview-core-exports.txt +++ b/__generated__/dockview-core-exports.txt @@ -196,6 +196,7 @@ MeasuredValue MovePanelEvent MovementOptions MovementOptions2 +OVERFLOW_WRAP_TABS_CLASS Orientation OverflowThumbnailRenderer PROPERTY_KEYS_DOCKVIEW diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts index 38e4645a5..4a8481902 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -3,7 +3,10 @@ import { toggleClass } from '../../../dom'; import { CompositeDisposable, IValueDisposable } from '../../../lifecycle'; import { DockviewComponent } from '../../dockviewComponent'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; -import { DockviewHeaderDirection } from '../../options'; +import { + DockviewHeaderDirection, + OVERFLOW_WRAP_TABS_CLASS, +} from '../../options'; import { Tab } from '../tab/tab'; import { TabDropIndexEvent } from './tabsContainer'; import { TabGroupManager } from './tabGroups'; @@ -98,7 +101,7 @@ export class TabReorderController extends CompositeDisposable { * (`MultiRowTabsModule`, `overflow.mode: 'wrap'`). In wrap, the 1-D * gap/FLIP animation is suppressed and reorder uses a 2-D hit-test. */ private get _wrapMode(): boolean { - return this._tabsList.classList.contains('dv-tabs-container--wrap'); + return this._tabsList.classList.contains(OVERFLOW_WRAP_TABS_CLASS); } get animState(): TabAnimationState | null { @@ -243,7 +246,13 @@ export class TabReorderController extends CompositeDisposable { this._wrapMode && e && this._animState && + // intra-group single-tab reorder only: `sourceIndex === -1` is a + // cross-group drag (handled by the cross-group machinery), and + // `sourceTabGroupId` is a group-chip drag (handled by the chip drop + // target — which does NOT null `_animState`, so committing here too + // would double-commit). this._animState.sourceIndex !== -1 && + !this._animState.sourceTabGroupId && this._animState.currentInsertionIndex !== null && this.isPointInsideTabsList(e.clientX, e.clientY) ) { @@ -758,11 +767,17 @@ export class TabReorderController extends CompositeDisposable { } rows.sort((a, b) => a.top - b.top); - // Pick the row: the one whose vertical span contains clientY, else - // clamp above→first / below→last. + // Pick the row whose vertical span contains clientY; if the pointer is + // in an inter-row gap (or above/below all rows), pick the nearest row by + // vertical distance — NOT a blanket clamp to the last row, which would + // misroute a between-rows hover to the bottom row. let row = rows.find((r) => clientY >= r.top && clientY <= r.bottom); if (!row) { - row = clientY < rows[0].top ? rows[0] : rows[rows.length - 1]; + const distance = (r: (typeof rows)[number]) => + clientY < r.top ? r.top - clientY : clientY - r.bottom; + row = rows.reduce((nearest, r) => + distance(r) < distance(nearest) ? r : nearest + ); } // Within the row, insert before the first tab whose horizontal midpoint diff --git a/packages/dockview-core/src/dockview/moduleContracts.ts b/packages/dockview-core/src/dockview/moduleContracts.ts index afff8b3fc..47b332dbc 100644 --- a/packages/dockview-core/src/dockview/moduleContracts.ts +++ b/packages/dockview-core/src/dockview/moduleContracts.ts @@ -477,6 +477,9 @@ export interface IMultiRowTabsHost { /** Fires when any group is added — the service attaches a wrap controller. */ readonly onDidAddGroup: Event; readonly onDidRemoveGroup: Event; + /** Fires after `updateOptions` — the service re-applies wrap to every group + * so a runtime `overflow.mode` change takes effect. */ + readonly onDidOptionsChange: Event; /** * The scrollable tab list element (`.dv-tabs-container`) for a group — the * element the wrap class is toggled on and whose child tab geometry the diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index bbbb37066..0eed1908a 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -250,6 +250,15 @@ export type OverflowThumbnailRenderer = ( | { src: string } | undefined; +/** + * The CSS class the `MultiRowTabsModule` toggles on a group's tab list + * (`.dv-tabs-container`) to switch it into wrap layout. Shared here so core + * (the reorder controller's wrap detection + the SCSS rules) and the module + * agree on the one string — renaming it in one place would otherwise silently + * break the seam across the package boundary. + */ +export const OVERFLOW_WRAP_TABS_CLASS = 'dv-tabs-container--wrap'; + /** * Tab-header overflow behaviour. One shared block across the overflow axis: * `mode` chooses dropdown vs wrap; the remaining fields enrich the dropdown. @@ -264,7 +273,10 @@ export interface DockviewOverflowOptions { mode?: 'dropdown' | 'wrap'; /** * Wrap mode only: cap the number of header rows; the surplus tabs spill to - * the dropdown. Default: unbounded. Requires the `MultiRowTabsModule`. + * the dropdown. + * + * NOT YET IMPLEMENTED — reserved for a follow-up. Wrap is currently + * unbounded regardless of this value; setting it has no effect today. */ maxRows?: number; /** diff --git a/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts b/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts index 4f2a58338..c87ebcf90 100644 --- a/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts +++ b/packages/dockview-modules/src/__tests__/multiRowTabs.spec.ts @@ -120,6 +120,23 @@ describe('multi-row tabs (wrap mode)', () => { dockview.dispose(); }); + test('a runtime overflow.mode change is applied to existing groups', () => { + const dockview = make(); // starts as dropdown (no wrap) + const panel = dockview.addPanel({ id: 'a', component: 'default' }); + const list = panel.group.model.tabsListElement; + expect(list.classList.contains(WRAP_CLASS)).toBe(false); + + // turn wrap on at runtime + dockview.updateOptions({ overflow: { mode: 'wrap' } }); + expect(list.classList.contains(WRAP_CLASS)).toBe(true); + + // ...and back off + dockview.updateOptions({ overflow: { mode: 'dropdown' } }); + expect(list.classList.contains(WRAP_CLASS)).toBe(false); + + dockview.dispose(); + }); + test('removing a group disposes its wrap controller without throwing', () => { const dockview = make({ mode: 'wrap' }); const a = dockview.addPanel({ id: 'a', component: 'default' }); diff --git a/packages/dockview-modules/src/multiRowTabsService.ts b/packages/dockview-modules/src/multiRowTabsService.ts index 219746636..837b23084 100644 --- a/packages/dockview-modules/src/multiRowTabsService.ts +++ b/packages/dockview-modules/src/multiRowTabsService.ts @@ -2,12 +2,11 @@ import { DockviewCompositeDisposable as CompositeDisposable, DockviewGroupPanel, DockviewOverflowOptions, + OVERFLOW_WRAP_TABS_CLASS as WRAP_CLASS, defineModule, } from 'dockview-core'; import { IMultiRowTabsHost, IMultiRowTabsService } from 'dockview-core'; -const WRAP_CLASS = 'dv-tabs-container--wrap'; - function isWrapMode(overflow: DockviewOverflowOptions | undefined): boolean { return typeof overflow === 'object' && overflow?.mode === 'wrap'; } @@ -125,6 +124,10 @@ export class MultiRowTabsService this.addDisposables( this.host.onDidAddGroup((group) => this._track(group)), this.host.onDidRemoveGroup((group) => this._untrack(group)), + // Re-apply wrap to every group on a runtime `overflow.mode` change. + this.host.onDidOptionsChange(() => + this._controllers.forEach((c) => c.apply()) + ), { dispose: () => { this._controllers.forEach((c) => c.dispose()); From 447d892c8507dad418b251d7bb2eb409d7f8fcfd Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:04:49 +0100 Subject: [PATCH 08/11] polish(core): multi-row wrap drag/observer nits from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining non-blocking review nits: - Skip the source-tab collapse in wrap smooth drag. Collapsing it (width→0) reflowed the wrapped rows mid-drag under the 2-D hit-test; the source now stays in place (the 1-D gap it fed no-ops in wrap anyway). - Defer the ResizeObserver-driven relayout to requestAnimationFrame, avoiding the browser's "ResizeObserver loop" console warning when a row is added/ removed. Pending frame is cancelled on teardown; uses the list's own document (popout-safe). - Don't draw the drop indicator on the dragged source tab (the slot at its own position is a no-op drop). - Document that a runtime header-direction flip isn't re-evaluated (no core signal; the CSS guard still prevents a vertical header from wrapping). core 1107 · modules 206 · e2e 31, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../titlebar/tabReorderController.ts | 6 +++ .../src/dockview/components/titlebar/tabs.ts | 23 +++++++++--- .../src/multiRowTabsService.ts | 37 ++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts index 4a8481902..7dbf2be5f 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -798,8 +798,14 @@ export class TabReorderController extends CompositeDisposable { if (count === 0) { return; } + const sourceId = this._animState?.sourceTabId; if (index < count) { const el = this._tabs[index].value.element; + // The slot at the source's own position is a no-op drop; don't draw + // the indicator on the dragged tab itself. + if (this._tabs[index].value.panel.id === sourceId) { + return; + } el.classList.add('dv-tab--reorder-before'); this._wrapIndicatorEl = el; } else { diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index 38660996c..d1c9ecca4 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -25,7 +25,10 @@ import { DockviewComponent } from '../../dockviewComponent'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; import { DockviewWillShowOverlayLocationEvent } from '../../events'; import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel'; -import { DockviewHeaderDirection } from '../../options'; +import { + DockviewHeaderDirection, + OVERFLOW_WRAP_TABS_CLASS, +} from '../../options'; import { Tab } from '../tab/tab'; import { TabDragEvent, TabDropIndexEvent } from './tabsContainer'; import { ITabGroup } from '../../tabGroup'; @@ -750,19 +753,29 @@ export class Tabs extends CompositeDisposable implements ITabReorderHost { // drag image, then open the gap at the source position in // the same paint frame — no visual jump. // Both collapse and gap must be instant (no transition). + // In wrap mode there is no gap (the 1-D transforms no-op), + // and collapsing the source would reflow the wrapped rows + // mid-drag under the 2-D hit-test — so skip the collapse and + // leave the source in place. this._pendingCollapse = true; requestAnimationFrame(() => { this._pendingCollapse = false; if (!this._animState) { return; } - // Collapse source tab instantly (no transition) - tab.element.style.transition = 'none'; - toggleClass(tab.element, 'dv-tab--dragging', true); - void tab.element.offsetHeight; // force reflow + const wrap = this._tabsList.classList.contains( + OVERFLOW_WRAP_TABS_CLASS + ); + if (!wrap) { + // Collapse source tab instantly (no transition) + tab.element.style.transition = 'none'; + toggleClass(tab.element, 'dv-tab--dragging', true); + void tab.element.offsetHeight; // force reflow + } this._animState.currentInsertionIndex ??= sourceIndex; // Apply gap with transitions disabled on the target + // (no-op in wrap). this.applyDragOverTransforms(true); // Re-enable transitions for subsequent moves diff --git a/packages/dockview-modules/src/multiRowTabsService.ts b/packages/dockview-modules/src/multiRowTabsService.ts index 837b23084..4a178e7e8 100644 --- a/packages/dockview-modules/src/multiRowTabsService.ts +++ b/packages/dockview-modules/src/multiRowTabsService.ts @@ -35,11 +35,18 @@ function countRows(list: HTMLElement): number { * the host to relayout so the now-taller header shrinks the content area (the * free header-aware content-sizing seam does the subtraction). v1 wraps only * horizontal headers — a hidden or vertical header is a no-op. + * + * Wrap is (re)evaluated on construction and on `overflow` option changes. A + * runtime header-direction flip (`setHeaderPosition` horizontal↔vertical) is + * NOT re-evaluated — core exposes no direction-change signal today; the CSS + * guard (`:not(.dv-tabs-container-vertical)`) still prevents a vertical header + * from visually wrapping, so this is a stale-class edge, not a broken layout. */ class WrapController extends CompositeDisposable { private _wrapped = false; private _rowCount = 0; private _observer: ResizeObserver | undefined; + private _pendingMeasure: { win: Window; handle: number } | undefined; constructor( private readonly group: DockviewGroupPanel, @@ -72,7 +79,13 @@ class WrapController extends CompositeDisposable { if (wrap && list) { list.classList.add(WRAP_CLASS); - this._observer = new ResizeObserver(() => this.measure()); + // Mutating layout synchronously inside the ResizeObserver callback + // trips the browser's "ResizeObserver loop" warning, so the RO + // schedules the measure on the next frame. The initial measure runs + // synchronously (it's not inside an RO callback). + this._observer = new ResizeObserver(() => + this.scheduleMeasure(list) + ); this._observer.observe(list); this.measure(); } else { @@ -80,6 +93,24 @@ class WrapController extends CompositeDisposable { } } + private scheduleMeasure(list: HTMLElement): void { + if (this._pendingMeasure) { + return; + } + const win = list.ownerDocument.defaultView; + if (!win) { + this.measure(); + return; + } + this._pendingMeasure = { + win, + handle: win.requestAnimationFrame(() => { + this._pendingMeasure = undefined; + this.measure(); + }), + }; + } + private measure(): void { if (!this._wrapped) { return; @@ -96,6 +127,10 @@ class WrapController extends CompositeDisposable { } private teardown(): void { + this._pendingMeasure?.win.cancelAnimationFrame( + this._pendingMeasure.handle + ); + this._pendingMeasure = undefined; this._observer?.disconnect(); this._observer = undefined; this._rowCount = 0; From 500cd7b22f2fb450c337589939c13c14c7d8e018 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:17:11 +0100 Subject: [PATCH 09/11] fix(core): multi-row wrap header/underline rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual fixes found while testing wrap in the demo: - Tab-group underline: the single horizontal-bar model (pinned `bottom:0`, first-left→last-right span) is wrong across wrapped rows. Add a wrap-aware path in `_positionUnderlinesSync` that draws one straight segment under each row's run of a group's tabs (horizontal + wrapped only; single-row path unchanged). Unit-tested with stubbed multi-row rects. - Wrapped row height: switching the container to `height:auto` for wrapping collapsed each flex line to the tab's content height, leaving a gap below the tabs. Give wrapped tabs the base row height so every row matches the single-row header height. - Header chrome: on a multi-row header the prefix/left/right actions + drag void stretched and centered across the full height. Top-align them and size them to the first row so they stay put; only the tab list grows downward. e2e asserts the no-gap row height + first-row action alignment. core 1108 · modules 206 · e2e 32, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/tests/multi-row-tabs.spec.ts | 38 ++++++ .../titlebar/tabGroupIndicator.spec.ts | 48 +++++++ .../components/titlebar/tabGroupIndicator.ts | 125 +++++++++++++++++- .../dockview/components/titlebar/tabs.scss | 12 ++ .../components/titlebar/tabsContainer.scss | 11 ++ 5 files changed, 232 insertions(+), 2 deletions(-) diff --git a/e2e/tests/multi-row-tabs.spec.ts b/e2e/tests/multi-row-tabs.spec.ts index 6afca80de..a9bc0c63f 100644 --- a/e2e/tests/multi-row-tabs.spec.ts +++ b/e2e/tests/multi-row-tabs.spec.ts @@ -40,6 +40,44 @@ test.describe('multi-row tabs (wrap mode)', () => { expect(header.height).toBeGreaterThan(44); }); + test('wrapped rows fill the row height (no gap) and header chrome stays on the first row', async ({ + page, + }) => { + await setup(page); + + const m = await page.evaluate(() => { + const header = document.querySelector( + '.dv-tabs-and-actions-container' + ) as HTMLElement; + const tab = document.querySelector( + '.dv-tabs-container .dv-tab' + ) as HTMLElement; + const right = document.querySelector( + '.dv-right-actions-container' + ) as HTMLElement; + const rowH = parseFloat( + getComputedStyle(header).getPropertyValue( + '--dv-tabs-and-actions-container-height' + ) + ); + return { + rowH, + headerH: header.offsetHeight, + tabH: tab.offsetHeight, + rightH: right ? right.offsetHeight : 0, + }; + }); + + expect(m.rowH).toBeGreaterThan(0); // the row-height var resolves + // header actually wrapped to more than one row + expect(m.headerH).toBeGreaterThan(m.rowH); + // each wrapped tab fills a full row — no gap below the tab + expect(m.tabH).toBe(m.rowH); + // header actions sit on the first row, not stretched across all rows + expect(m.rightH).toBe(m.rowH); + expect(m.rightH).toBeLessThan(m.headerH); + }); + test('content shrinks to the area below the multi-row header', async ({ page, }) => { diff --git a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts index 6652933bb..a642f36b3 100644 --- a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts @@ -231,6 +231,54 @@ describe('WrapTabGroupIndicator', () => { indicator.dispose(); expect(indicator.underlines.size).toBe(0); }); + + test('wrapped strip draws one underline segment per row a group spans', () => { + const tabsList = document.createElement('div'); + tabsList.classList.add('dv-tabs-container--wrap'); + + const makeTab = (top: number) => { + const el = document.createElement('div'); + el.getBoundingClientRect = () => + ({ + top, + bottom: top + 26, + left: 0, + right: 50, + width: 50, + height: 26, + }) as DOMRect; + return { value: { element: el } }; + }; + // two tabs of the group on different rows (top 0 and top 30) + const tabMap = new Map([ + ['a', makeTab(0)], + ['b', makeTab(30)], + ]); + + const tg = new TabGroup('tg-1', { label: 'Test', color: 'blue' }); + tg.addPanel('a'); + tg.addPanel('b'); + + const ctx = createContext({ + tabsList, + getTabGroups: () => [tg], + getTabMap: () => tabMap as any, + getActivePanelId: () => 'a', + getHeaderPosition: () => 'top', + }); + const indicator = new WrapTabGroupIndicator(ctx); + indicator.syncUnderlineElements(new Set(['tg-1'])); + (indicator as any)._positionUnderlinesSync(); + + const underline = indicator.getUnderline('tg-1')!; + const path = underline.querySelector('path')!; + const d = path.getAttribute('d') ?? ''; + // one straight segment per row → two `M` (move) commands + const segments = (d.match(/M /g) ?? []).length; + expect(segments).toBe(2); + + indicator.dispose(); + }); }); describe('indicator type identity', () => { diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts b/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts index 0a8d767ef..a2e7148a4 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts @@ -1,5 +1,9 @@ import { IValueDisposable } from '../../../lifecycle'; -import { DockviewHeaderDirection, DockviewHeaderPosition } from '../../options'; +import { + DockviewHeaderDirection, + DockviewHeaderPosition, + OVERFLOW_WRAP_TABS_CLASS, +} from '../../options'; import { Tab } from '../tab/tab'; import { ITabGroup } from '../../tabGroup'; import { @@ -126,6 +130,12 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { const containerRect = this._ctx.tabsList.getBoundingClientRect(); const tabGroups = this._ctx.getTabGroups(); const isVertical = this._ctx.getDirection() === 'vertical'; + // Multi-row wrap (`MultiRowTabsModule`): a group's tabs can span rows, + // which the single horizontal-bar model below can't represent. Draw a + // per-row segment instead. Horizontal headers only (wrap is horizontal). + const wrapped = + !isVertical && + this._ctx.tabsList.classList.contains(OVERFLOW_WRAP_TABS_CLASS); const containerCrossSize = isVertical ? containerRect.width : containerRect.height; @@ -146,6 +156,16 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { underline.style.display = ''; + if (wrapped) { + this._positionWrappedUnderline( + underline, + tg, + containerRect, + tabMap + ); + continue; + } + const chipEl = this._ctx.getChipElement(tg.id); // In vertical mode, compute top/bottom edges; in horizontal, left/right. @@ -255,9 +275,10 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { } else { underline.style.left = `${startEdge}px`; underline.style.width = `${Math.max(0, span)}px`; - // Clear vertical properties + // Clear vertical properties (incl. any wrap-mode overrides) underline.style.top = ''; underline.style.height = ''; + underline.style.bottom = ''; } this.applyShape( @@ -272,6 +293,106 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { ); } } + + /** + * Position a group's underline across a multi-row (wrapped) tab strip. The + * single horizontal-bar model can't span rows, so the element is sized to + * cover the group's row span and an SVG draws one straight segment under + * each row's run of the group's tabs. (The active-tab wrap-around bump is + * omitted in wrap for now — the per-row lines still convey membership.) + */ + private _positionWrappedUnderline( + underline: HTMLElement, + tg: ITabGroup, + containerRect: DOMRect, + tabMap: Map> + ): void { + const t = 2; // line thickness + const color = resolveTabGroupAccent( + tg.color, + this._ctx.getColorPalette() + ); + if (color === undefined) { + underline.style.display = 'none'; + return; + } + + // Bucket the group's tabs into rows by top edge (2px tolerance). + type Row = { top: number; bottom: number; left: number; right: number }; + const rows: Row[] = []; + for (const pid of tg.panelIds) { + const te = tabMap.get(pid); + if (!te) { + continue; + } + const r = te.value.element.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) { + continue; // hidden / collapsed + } + const top = r.top - containerRect.top; + const row = rows.find((x) => Math.abs(x.top - top) <= 2); + if (row) { + row.left = Math.min(row.left, r.left - containerRect.left); + row.right = Math.max(row.right, r.right - containerRect.left); + row.bottom = Math.max(row.bottom, r.bottom - containerRect.top); + } else { + rows.push({ + top, + bottom: r.bottom - containerRect.top, + left: r.left - containerRect.left, + right: r.right - containerRect.left, + }); + } + } + + if (rows.length === 0) { + underline.style.display = 'none'; + return; + } + + const overline = this._ctx.getHeaderPosition() === 'bottom'; + const minTop = Math.min(...rows.map((r) => r.top)); + const maxBottom = Math.max(...rows.map((r) => r.bottom)); + const width = containerRect.width; + const height = Math.max(0, maxBottom - minTop); + + // Cover the group's row span; the SVG inside draws the per-row lines. + underline.style.left = '0px'; + underline.style.top = `${minTop}px`; + underline.style.bottom = 'auto'; + underline.style.width = `${width}px`; + underline.style.height = `${height}px`; + + let svg = underline.firstElementChild as SVGSVGElement | null; + let path: SVGPathElement; + if (!svg || svg.tagName !== 'svg') { + underline.replaceChildren(); + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.style.display = 'block'; + path = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path' + ); + path.setAttribute('fill', 'none'); + svg.appendChild(path); + underline.appendChild(svg); + } else { + path = svg.firstElementChild as SVGPathElement; + } + svg.setAttribute('width', String(width)); + svg.setAttribute('height', String(height)); + path.setAttribute('stroke', color); + path.setAttribute('stroke-width', String(t)); + + let d = ''; + for (const row of rows) { + const y = overline + ? row.top - minTop + t / 2 + : row.bottom - minTop - t / 2; + d += `M ${row.left},${y} L ${row.right},${y} `; + } + path.setAttribute('d', d.trim()); + } } /** diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss index 6e98239be..b25e8ffac 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss @@ -17,6 +17,18 @@ flex-wrap: wrap; height: auto; overflow: visible; + // Pack wrapped rows from the top so a partial last row doesn't spread. + align-content: flex-start; + + // Single-row height comes from the container filling the header + // (`height: 100%`); once the container is `height: auto` for wrapping, + // each flex line collapses to the tab's content height, leaving a gap + // below the tabs. Give each wrapped tab the base row height so every + // row matches the single-row header height (no gap, and the header + // grows in whole-row increments). + .dv-tab { + height: var(--dv-tabs-and-actions-container-height); + } // Discrete drop indicator for 2-D cross-row reorder (the 1-D gap // animation is suppressed in wrap). A bar on the leading/trailing edge diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss index 30a29f61f..c238570c6 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss @@ -15,6 +15,17 @@ &:has(.dv-tabs-container--wrap) { height: auto; min-height: var(--dv-tabs-and-actions-container-height); + // Keep the header chrome (prefix / left / right actions, drag void) on + // the first row instead of stretching + centering across the full + // multi-row height — only the tab list grows downward. + align-items: flex-start; + + > .dv-pre-actions-container, + > .dv-left-actions-container, + > .dv-right-actions-container, + > .dv-void-container { + height: var(--dv-tabs-and-actions-container-height); + } } &.dv-single-tab.dv-full-width-single-tab { From bcc31784a47967bd46623f4664a4c2b8f2caff03 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:25:27 +0100 Subject: [PATCH 10/11] fix(core): give computeWrappedInsertionIndex's reduce an explicit initial value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar flags Array.reduce() without an initial value. Pass rows[0] as the seed — behaviour-identical (reduce already used the first element as the initial accumulator), no-throw on the guaranteed-non-empty rows array. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/dockview/components/titlebar/tabReorderController.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts index 7dbf2be5f..f941380ba 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabReorderController.ts @@ -775,8 +775,9 @@ export class TabReorderController extends CompositeDisposable { if (!row) { const distance = (r: (typeof rows)[number]) => clientY < r.top ? r.top - clientY : clientY - r.bottom; - row = rows.reduce((nearest, r) => - distance(r) < distance(nearest) ? r : nearest + row = rows.reduce( + (nearest, r) => (distance(r) < distance(nearest) ? r : nearest), + rows[0] ); } From 9aca62ad1809301344ab856c792c57d33b44be70 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:19:06 +0100 Subject: [PATCH 11/11] fix(core): review fixes for the multi-row wrap underline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an independent review of the rendering diff: - Bug: `_positionWrappedUnderline` didn't clear `background-color`. With `theme.tabGroupIndicator: 'none'` (which paints the underline element itself) + a runtime wrap toggle, the stale background showed as a colored block behind the per-row SVG segments. Reset it in the wrap path. Regression test added. - Extract the duplicated SVG-ensure block (wrap path + applyShape) into a shared `ensureSvgPath` helper (DRY + clears the Sonar duplication). - Clear `bottom` on the vertical non-wrap branch too, for symmetry with the horizontal branch's wrap-override reset. core 1108 · multi-row e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../titlebar/tabGroupIndicator.spec.ts | 4 ++ .../components/titlebar/tabGroupIndicator.ts | 72 ++++++++++--------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts index a642f36b3..95e92d367 100644 --- a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabGroupIndicator.spec.ts @@ -268,6 +268,8 @@ describe('WrapTabGroupIndicator', () => { }); const indicator = new WrapTabGroupIndicator(ctx); indicator.syncUnderlineElements(new Set(['tg-1'])); + // simulate a leftover background from a prior non-wrap `none` render + indicator.getUnderline('tg-1')!.style.backgroundColor = 'red'; (indicator as any)._positionUnderlinesSync(); const underline = indicator.getUnderline('tg-1')!; @@ -276,6 +278,8 @@ describe('WrapTabGroupIndicator', () => { // one straight segment per row → two `M` (move) commands const segments = (d.match(/M /g) ?? []).length; expect(segments).toBe(2); + // stale non-wrap background is cleared (no colored block behind the SVG) + expect(underline.style.backgroundColor).toBe(''); indicator.dispose(); }); diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts b/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts index a2e7148a4..928a4731d 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabGroupIndicator.ts @@ -269,9 +269,10 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { if (isVertical) { underline.style.top = `${startEdge}px`; underline.style.height = `${Math.max(0, span)}px`; - // Clear horizontal properties + // Clear horizontal properties (incl. any wrap-mode overrides) underline.style.left = ''; underline.style.width = ''; + underline.style.bottom = ''; } else { underline.style.left = `${startEdge}px`; underline.style.width = `${Math.max(0, span)}px`; @@ -362,23 +363,12 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { underline.style.bottom = 'auto'; underline.style.width = `${width}px`; underline.style.height = `${height}px`; + // The `none` indicator paints the underline element itself; clear any + // background left over from a non-wrap render so it doesn't show as a + // block behind the per-row SVG segments after a runtime wrap toggle. + underline.style.backgroundColor = ''; - let svg = underline.firstElementChild as SVGSVGElement | null; - let path: SVGPathElement; - if (!svg || svg.tagName !== 'svg') { - underline.replaceChildren(); - svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.style.display = 'block'; - path = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); - path.setAttribute('fill', 'none'); - svg.appendChild(path); - underline.appendChild(svg); - } else { - path = svg.firstElementChild as SVGPathElement; - } + const { svg, path } = this.ensureSvgPath(underline); svg.setAttribute('width', String(width)); svg.setAttribute('height', String(height)); path.setAttribute('stroke', color); @@ -393,6 +383,37 @@ abstract class BaseTabGroupIndicator implements ITabGroupIndicator { } path.setAttribute('d', d.trim()); } + + /** + * Ensure the underline element holds a single reusable `` + * (created once, reused across frames) and return them. + */ + protected ensureSvgPath(underline: HTMLElement): { + svg: SVGSVGElement; + path: SVGPathElement; + } { + const existing = underline.firstElementChild as SVGSVGElement | null; + if (existing && existing.tagName === 'svg') { + return { + svg: existing, + path: existing.firstElementChild as SVGPathElement, + }; + } + underline.replaceChildren(); + const svg = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg' + ); + svg.style.display = 'block'; + const path = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path' + ); + path.setAttribute('fill', 'none'); + svg.appendChild(path); + underline.appendChild(svg); + return { svg, path }; + } } /** @@ -461,22 +482,7 @@ export class WrapTabGroupIndicator extends BaseTabGroupIndicator { } // Ensure SVG + path child exists (created once, reused) - let svg = underline.firstElementChild as SVGSVGElement | null; - let path: SVGPathElement; - if (!svg || svg.tagName !== 'svg') { - underline.replaceChildren(); - svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.style.display = 'block'; - path = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); - path.setAttribute('fill', 'none'); - svg.appendChild(path); - underline.appendChild(svg); - } else { - path = svg.firstElementChild as SVGPathElement; - } + const { svg, path } = this.ensureSvgPath(underline); path.setAttribute('stroke', color); path.setAttribute('stroke-width', String(t));