UNPKG

dockview-core

Version:

Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript

1,092 lines 72.1 kB
import { getPanelData, } from '../../../dnd/dataTransfer'; import { addClasses, isChildEntirelyVisibleWithinParent, OverflowObserver, removeClasses, toggleClass, } from '../../../dom'; import { addDisposableListener, Emitter } from '../../../events'; import { CompositeDisposable, Disposable, MutableDisposable, } from '../../../lifecycle'; import { Scrollbar } from '../../../scrollbar'; import { PointerDragController } from '../../../dnd/pointer/pointerDragController'; import { DockviewWillShowOverlayLocationEvent } from '../../events'; import { Tab } from '../tab/tab'; import { TabGroupManager } from './tabGroups'; export class Tabs extends CompositeDisposable { get showTabsOverflowControl() { return this._showTabsOverflowControl; } set showTabsOverflowControl(value) { if (this._showTabsOverflowControl == value) { return; } this._showTabsOverflowControl = value; if (value) { const observer = new OverflowObserver(this._tabsList); this._observerDisposable.value = new CompositeDisposable(observer, observer.onDidChange((event) => { const hasOverflow = event.hasScrollX || event.hasScrollY; this.toggleDropdown({ reset: !hasOverflow }); if (this._tabGroupManager.groupUnderlines.size > 0) { this._tabGroupManager.positionUnderlines(); } }), addDisposableListener(this._tabsList, 'scroll', () => { this.toggleDropdown({ reset: false }); if (this._tabGroupManager.groupUnderlines.size > 0) { this._tabGroupManager.positionUnderlines(); } })); } } get element() { return this._element; } set voidContainer(el) { var _a; (_a = this._voidContainerListeners) === null || _a === void 0 ? void 0 : _a.dispose(); this._voidContainerListeners = null; this._voidContainer = el; if (el) { this._voidContainerListeners = new CompositeDisposable(addDisposableListener(el, 'dragover', (event) => { if (this._animState) { event.preventDefault(); } }), addDisposableListener(el, 'drop', (event) => { var _a; if (((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabGroupId) && this._animState.currentInsertionIndex !== null) { event.preventDefault(); event.stopPropagation(); this.handleVoidDrop(); } })); } } /** * Handle a drop that occurred on the void container (empty header * space to the right of the tabs). Returns `true` if the drop was * consumed by an active group drag, `false` otherwise. */ handleVoidDrop() { var _a, _b; if (!((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabGroupId)) { return false; } const sourceTabGroupId = this._animState.sourceTabGroupId; const insertionIndex = (_b = this._animState.currentInsertionIndex) !== null && _b !== void 0 ? _b : this._tabs.length; this._animState = null; this._commitGroupMove(sourceTabGroupId, insertionIndex); return true; } get panels() { return this._tabs.map((_) => _.value.panel.id); } get size() { return this._tabs.length; } get tabs() { return this._tabs.map((_) => _.value); } get direction() { return this._direction; } set direction(value) { if (this._direction === value) { return; } this._direction = value; if (this._scrollbar) { this._scrollbar.orientation = value; } removeClasses(this._tabsList, 'dv-horizontal', 'dv-vertical'); if (value === 'vertical') { addClasses(this._tabsList, 'dv-tabs-container-vertical', 'dv-vertical'); } else { removeClasses(this._tabsList, 'dv-tabs-container-vertical'); addClasses(this._tabsList, 'dv-horizontal'); } for (const tab of this._tabs) { tab.value.setDirection(value); } this._tabGroupManager.updateDirection(); } constructor(group, accessor, options) { super(); this.group = group; this.accessor = accessor; this._observerDisposable = new MutableDisposable(); this._scrollbar = null; this._tabs = []; this._tabMap = new Map(); this.selectedIndex = -1; this._showTabsOverflowControl = false; this._direction = 'horizontal'; this._animState = null; this._pendingMarginCleanups = new Map(); this._pendingCollapse = false; this._flipTransitionCleanup = null; this._voidContainer = null; this._voidContainerListeners = null; this._extendedDropZone = null; this._pointerInsideTabsList = false; this._onTabDragStart = new Emitter(); this.onTabDragStart = this._onTabDragStart.event; this._onDrop = new Emitter(); this.onDrop = this._onDrop.event; this._onWillShowOverlay = new Emitter(); this.onWillShowOverlay = this._onWillShowOverlay.event; this._onOverflowTabsChange = new Emitter(); this.onOverflowTabsChange = this._onOverflowTabsChange.event; this._tabsList = document.createElement('div'); this._tabsList.className = 'dv-tabs-container'; this.showTabsOverflowControl = options.showTabsOverflowControl; if (accessor.options.scrollbars === 'native') { this._element = this._tabsList; } else { this._scrollbar = new Scrollbar(this._tabsList); this._scrollbar.orientation = this.direction; this._element = this._scrollbar.element; this.addDisposables(this._scrollbar); } this._tabGroupManager = new TabGroupManager({ group: this.group, accessor: this.accessor, tabsList: this._tabsList, getTabs: () => this._tabs, getTabMap: () => this._tabMap, getDirection: () => this._direction, }, { onChipContextMenu: (tabGroup, event) => { this.accessor.contextMenuController.showForChip(tabGroup, this.group, event); }, onChipDragStart: (tabGroup, chip, event) => { this._handleChipDragStart(tabGroup, chip, event); }, onChipDragEnd: () => { // HTML5 chip dragend (incl. cancels). The Html5DragSource // owns the listener on the chip element, so this fires // even if the chip was detached cross-group — the // element keeps its listeners until the source is // disposed. resetDragAnimation is a no-op after a // successful drop (anim state already null) thanks to // the gating inside it. this.resetDragAnimation(); }, onChipDrop: (tabGroup, event) => { this._handleChipDrop(tabGroup, event); }, }); this.addDisposables(this._onOverflowTabsChange, this._observerDisposable, this._onWillShowOverlay, this._onDrop, this._onTabDragStart, { dispose: () => { var _a; (_a = this._flipTransitionCleanup) === null || _a === void 0 ? void 0 : _a.call(this); }, }, // 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(); }), // 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); }), addDisposableListener(this.element, 'pointerdown', (event) => { if (event.defaultPrevented) { return; } const isLeftClick = event.button === 0; if (isLeftClick) { this.accessor.doSetGroupActive(this.group); } }), // Trackpad / wheel forwarding. The strip scrolls along its own // axis (x for horizontal headers, y for vertical), so deltaY // from a plain mouse wheel maps onto the strip's axis too — // this gives the VS Code-style "scroll over tab bar to page // through tabs" feel. We only consume the event when the strip // is actually overflowing in the direction the user wheeled in, // so a wheel at the edge of a non-overflowing strip still // bubbles up and scrolls the page. `{ passive: false }` is // required because we call preventDefault(). addDisposableListener(this._tabsList, 'wheel', (event) => { const isVertical = this._direction === 'vertical'; const primary = isVertical ? event.deltaY || event.deltaX : event.deltaX || event.deltaY; if (primary === 0) { return; } const max = isVertical ? this._tabsList.scrollHeight - this._tabsList.clientHeight : this._tabsList.scrollWidth - this._tabsList.clientWidth; if (max <= 0) { return; } const current = isVertical ? this._tabsList.scrollTop : this._tabsList.scrollLeft; // At the edge in the wheel direction: let the page // scroll instead of trapping the gesture. if ((primary < 0 && current <= 0) || (primary > 0 && current >= max)) { return; } event.preventDefault(); // Custom-scrollbar mode wraps the tabs list and installs // its own wheel listener that rewrites scrollLeft from a // deltaY-only tracker. Without stopPropagation that // handler would clobber our deltaX-aware update. event.stopPropagation(); if (isVertical) { this._tabsList.scrollTop = current + primary; } else { this._tabsList.scrollLeft = current + primary; } }, { passive: false }), addDisposableListener(this._tabsList, 'dragover', (event) => { if (this._processDragOver(event.clientX)) { // Allow `drop` to fire on the tabs list container. event.preventDefault(); } }, true), addDisposableListener(this._tabsList, 'dragleave', (event) => { this._processDragLeave(event.relatedTarget); }, true), addDisposableListener(this._tabsList, 'dragend', () => { this.resetDragAnimation(); }), addDisposableListener(this._tabsList, 'drop', (event) => { var _a, _b, _c; if (!this._animState || this._animState.currentInsertionIndex === null) { return; } // In non-smooth mode only handle group drags here; // individual tab drops are handled by tab Droptargets. if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) !== 'smooth' && !this._animState.sourceTabGroupId) { return; } event.stopPropagation(); event.preventDefault(); // The capturing stopPropagation above prevents the // individual tab's Droptarget.onDrop from firing, so // the anchor overlay won't be cleared by that path. // Clear it explicitly here before processing the drop. (_c = (_b = this.group.model.dropTargetContainer) === null || _b === void 0 ? void 0 : _b.model) === null || _c === void 0 ? void 0 : _c.clear(); const animState = this._animState; this._animState = null; this._pendingCollapse = false; // Handle group drag (entire group repositioned) if (animState.sourceTabGroupId) { this._commitGroupMove(animState.sourceTabGroupId, animState.currentInsertionIndex); return; } const insertionIndex = animState.currentInsertionIndex; const sourceIndex = animState.sourceIndex; const adjustedIndex = insertionIndex - (sourceIndex !== -1 && sourceIndex < insertionIndex ? 1 : 0); const sourceCurrentGroup = this.group.model.getTabGroupForPanel(animState.sourceTabId); if (adjustedIndex === sourceIndex && !animState.targetTabGroupId && !sourceCurrentGroup) { this._uncollapsSourceTab(animState.sourceTabId); this.resetTabTransforms(); return; } this._uncollapsSourceTab(animState.sourceTabId); const firstPositions = this.snapshotTabPositions(); this.resetTabTransforms(); this._onDrop.fire({ event, index: adjustedIndex, targetTabGroupId: animState.targetTabGroupId, }); this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, { from: Math.min(sourceIndex, adjustedIndex), to: Math.max(sourceIndex, adjustedIndex), }); }, true), Disposable.from(() => { var _a; (_a = this._voidContainerListeners) === null || _a === void 0 ? void 0 : _a.dispose(); this.resetDragAnimation(); this._tabGroupManager.disposeAll(); for (const { value, disposable } of this._tabs) { disposable.dispose(); value.dispose(); } this._tabs = []; this._tabMap.clear(); })); } indexOf(id) { return this._tabs.findIndex((tab) => tab.value.panel.id === id); } isActive(tab) { return (this.selectedIndex > -1 && this._tabs[this.selectedIndex].value === tab); } setActivePanel(panel) { const isVertical = this._direction === 'vertical'; let running = 0; for (const tab of this._tabs) { const isActivePanel = panel.id === tab.value.panel.id; tab.value.setActive(isActivePanel); if (isActivePanel) { const element = tab.value.element; const parentElement = element.parentElement; if (isVertical) { if (running < parentElement.scrollTop || running + element.clientHeight > parentElement.scrollTop + parentElement.clientHeight) { parentElement.scrollTop = running; } } else { if (running < parentElement.scrollLeft || running + element.clientWidth > parentElement.scrollLeft + parentElement.clientWidth) { parentElement.scrollLeft = running; } } } running += isVertical ? tab.value.element.clientHeight : tab.value.element.clientWidth; } // Reposition underlines so the wrap-around follows the new active tab if (this._tabGroupManager.groupUnderlines.size > 0) { this._tabGroupManager.positionUnderlines(); } } openPanel(panel, index = this._tabs.length) { if (this._tabMap.has(panel.id)) { return; } const tab = new Tab(panel, this.accessor, this.group); tab.setContent(panel.view.tab); if (this._direction !== 'horizontal') { tab.setDirection(this._direction); } const disposable = new CompositeDisposable(tab.onDragStart((event) => { var _a; this._onTabDragStart.fire({ nativeEvent: event, panel }); // Both HTML5 and pointer drags initialize _animState. Cleanup // is wired in both paths: HTML5 via dragend/drop on _tabsList, // pointer via PointerDragController.onDragEnd subscriptions. if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) === 'smooth') { const tabWidth = tab.element.getBoundingClientRect().width; const sourceIndex = this._tabs.findIndex((x) => x.value === tab); this._animState = { sourceTabId: panel.id, sourceIndex, tabPositions: this.snapshotTabPositions(), chipPositions: this._tabGroupManager.snapshotChipWidths(), currentInsertionIndex: null, targetTabGroupId: null, sourceTabGroupId: null, sourceGroupPanelIds: null, sourceChipWidth: 0, cursorOffsetFromDragLeft: tabWidth / 2, sourceGapWidth: tabWidth, containerLeft: this._tabsList.getBoundingClientRect().left, }; // Collapse the source tab after the browser captures the // 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). this._pendingCollapse = true; requestAnimationFrame(() => { var _a; var _b; 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 (_a = (_b = this._animState).currentInsertionIndex) !== null && _a !== void 0 ? _a : (_b.currentInsertionIndex = sourceIndex); // Apply gap with transitions disabled on the target this.applyDragOverTransforms(true); // Re-enable transitions for subsequent moves tab.element.style.removeProperty('transition'); }); } }), tab.onTabClick((event) => { if (event.defaultPrevented) { return; } if (this.group.api.location.type !== 'edge') { return; } if (this.group.activePanel === panel) { // Clicking the active tab toggles expansion if (this.group.api.isCollapsed()) { this.group.api.expand(); } else { this.group.api.collapse(); } } else { // Clicking a non-active tab switches the active tab. // If the group is collapsed, also expand it. this.group.model.openPanel(panel); if (this.group.api.isCollapsed()) { this.group.api.expand(); } } }), tab.onPointerDown((event) => { if (event.defaultPrevented) { return; } const isFloatingGroupsEnabled = !this.accessor.options.disableFloatingGroups; const isFloatingWithOnePanel = this.group.api.location.type === 'floating' && this.size === 1; if (isFloatingGroupsEnabled && !isFloatingWithOnePanel && event.shiftKey) { event.preventDefault(); const panel = this.accessor.getGroupPanel(tab.panel.id); const { top, left } = tab.element.getBoundingClientRect(); const { top: rootTop, left: rootLeft } = this.accessor.element.getBoundingClientRect(); this.accessor.addFloatingGroup(panel, { x: left - rootLeft, y: top - rootTop, inDragMode: true, }); return; } switch (event.button) { case 0: if (this.group.api.location.type === 'edge') { // All tab interaction for edge groups is handled by // onTabClick to avoid race conditions with active panel state } else { if (this.group.activePanel !== panel) { this.group.model.openPanel(panel); } } break; } }), tab.onDrop((event) => { var _a, _b, _c, _d; const animState = this._animState; this._animState = null; this._pendingCollapse = false; const tabIndex = this._tabs.findIndex((x) => x.value === tab); if (animState) { const dropIndex = event.position === 'right' ? tabIndex + 1 : tabIndex; if (animState.sourceTabGroupId) { this._commitGroupMove(animState.sourceTabGroupId, (_a = animState.currentInsertionIndex) !== null && _a !== void 0 ? _a : dropIndex); return; } this._uncollapsSourceTab(animState.sourceTabId); const firstPositions = this.snapshotTabPositions(); this.resetTabTransforms(); this._onDrop.fire({ event: event.nativeEvent, index: dropIndex, targetTabGroupId: animState.targetTabGroupId, }); if (((_b = this.accessor.options.theme) === null || _b === void 0 ? void 0 : _b.tabAnimation) === 'smooth') { this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, animState.sourceIndex !== -1 ? { from: Math.min(animState.sourceIndex, dropIndex), to: Math.max(animState.sourceIndex, dropIndex), } : undefined); } } else { // Compute insertion index based on which half of the tab // the pointer is over, then adjust for same-group removal: // when the source tab sits before the insertion point, // removing it shifts all subsequent indices down by one. const afterPosition = this._direction === 'vertical' ? 'bottom' : 'right'; const insertionIndex = event.position === afterPosition ? tabIndex + 1 : tabIndex; const data = getPanelData(); const sourceIndex = data ? this._tabs.findIndex((x) => x.value.panel.id === data.panelId) : -1; const adjustedIndex = insertionIndex - (sourceIndex !== -1 && sourceIndex < insertionIndex ? 1 : 0); const targetTabGroupId = (_d = (_c = this.group.model.getTabGroupForPanel(tab.panel.id)) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null; this._onDrop.fire({ event: event.nativeEvent, index: adjustedIndex, targetTabGroupId, }); } }), tab.onWillShowOverlay((event) => { this._onWillShowOverlay.fire(new DockviewWillShowOverlayLocationEvent(event, { kind: 'tab', panel: this.group.activePanel, api: this.accessor.api, group: this.group, getData: getPanelData, })); })); const value = { value: tab, disposable }; this.addTab(value, index); // A new tab may have been inserted between a chip and its // group's first tab — reposition all chips to stay correct. this._tabGroupManager.positionAllChips(); // If a tab was added during active drag, refresh positions if (this._animState) { this._animState.tabPositions = this.snapshotTabPositions(); this._animState.chipPositions = this._tabGroupManager.snapshotChipWidths(); this.applyDragOverTransforms(); } } delete(id) { var _a; if (((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabId) === id) { this.resetTabTransforms(); this._animState = null; } // Force-clean any pending transitionend listener this._tabGroupManager.cleanupTransition(id); const index = this.indexOf(id); const tabToRemove = this._tabs.splice(index, 1)[0]; this._tabMap.delete(id); if (tabToRemove) { const { value, disposable } = tabToRemove; disposable.dispose(); value.dispose(); value.element.remove(); } // If a non-source tab was removed during active drag, refresh positions if (this._animState) { this._animState.tabPositions = this.snapshotTabPositions(); this._animState.chipPositions = this._tabGroupManager.snapshotChipWidths(); this.applyDragOverTransforms(); } } addTab(tab, index = this._tabs.length) { if (index < 0 || index > this._tabs.length) { throw new Error('invalid location'); } // Use the tab element at `index` as the reference node rather than // `children[index]`, because `_tabsList` may contain non-tab children // (e.g. group chips, underlines) that shift the DOM indices. const refNode = index < this._tabs.length ? this._tabs[index].value.element : null; this._tabsList.insertBefore(tab.value.element, refNode); this._tabs = [ ...this._tabs.slice(0, index), tab, ...this._tabs.slice(index), ]; this._tabMap.set(tab.value.panel.id, tab); if (this.selectedIndex < 0) { this.selectedIndex = index; } } toggleDropdown(options) { if (options.reset) { this._onOverflowTabsChange.fire({ tabs: [], tabGroups: [], reset: true, }); return; } const tabs = this._tabs .filter((tab) => !isChildEntirelyVisibleWithinParent(tab.value.element, this._tabsList)) .map((x) => x.value.panel.id); // Detect tab groups whose chip is clipped or whose tabs are all // in the overflow set (e.g. collapsed groups scrolled out of view). const overflowTabSet = new Set(tabs); const tabGroups = []; for (const tg of this.group.model.getTabGroups()) { const chipEntry = this._tabGroupManager.chipRenderers.get(tg.id); const chipClipped = chipEntry && !isChildEntirelyVisibleWithinParent(chipEntry.chip.element, this._tabsList); // A group is in overflow if its chip is clipped OR all its // visible tabs are in the overflow set. const allTabsOverflow = tg.panelIds.length > 0 && tg.panelIds.every((pid) => overflowTabSet.has(pid)); if (chipClipped || allTabsOverflow) { tabGroups.push(tg.id); // For collapsed groups whose chip is clipped, ensure all // member tabs are included in the overflow list so they // appear in the dropdown. if (tg.collapsed) { for (const pid of tg.panelIds) { if (!overflowTabSet.has(pid)) { overflowTabSet.add(pid); tabs.push(pid); } } } } } this._onOverflowTabsChange.fire({ tabs, tabGroups, reset: false }); } updateDragAndDropState() { for (const tab of this._tabs) { tab.value.updateDragAndDropState(); } this._tabGroupManager.updateDragAndDropState(); } /** * Synchronize chip elements and CSS classes for all tab groups * in the parent group model. Call after any tab group mutation. */ updateTabGroups() { this._tabGroupManager.update(); } refreshTabGroupAccent() { this._tabGroupManager.refreshAccents(); } /** * Tabs-list-specific side effects of a chip drag start. The chip's * drag sources (constructed by `TabGroupManager`) own the transfer * payload, iframe shielding, dataTransfer setup, and the HTML5 drag * image. This method just sets up the smooth-reorder anim state and * collapses the source-group tabs in the tabs list. */ _handleChipDragStart(tabGroup, chip, event) { var _a; const firstPanelId = tabGroup.panelIds[0]; const firstIdx = firstPanelId ? this._tabs.findIndex((t) => t.value.panel.id === firstPanelId) : -1; const chipRect = chip.element.getBoundingClientRect(); // Compute total group width (chip + all tabs) let groupGapWidth = chipRect.width; for (const pid of tabGroup.panelIds) { const tabEntry = this._tabMap.get(pid); if (tabEntry) { groupGapWidth += tabEntry.value.element.getBoundingClientRect().width; } } this._animState = { sourceTabId: '', sourceIndex: firstIdx, tabPositions: this.snapshotTabPositions(), chipPositions: this._tabGroupManager.snapshotChipWidths(), currentInsertionIndex: null, targetTabGroupId: null, sourceTabGroupId: tabGroup.id, sourceGroupPanelIds: new Set(tabGroup.panelIds), sourceChipWidth: chipRect.width, cursorOffsetFromDragLeft: event.clientX - chipRect.left, sourceGapWidth: groupGapWidth, containerLeft: this._tabsList.getBoundingClientRect().left, }; if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) !== 'smooth') { return; } // Collapse group tabs + chip after the browser captures the drag // image, then open the gap at the source position — all instant // (no transitions). const groupPanelIds = new Set(tabGroup.panelIds); this._pendingCollapse = true; requestAnimationFrame(() => { var _a; var _b; this._pendingCollapse = false; if (!this._animState) { return; } // Collapse all group tabs instantly for (const t of this._tabs) { if (groupPanelIds.has(t.value.panel.id)) { t.value.element.style.transition = 'none'; toggleClass(t.value.element, 'dv-tab--dragging', true); } } // Collapse the group chip instantly const chipEntry = this._tabGroupManager.chipRenderers.get(tabGroup.id); if (chipEntry) { chipEntry.chip.element.style.transition = 'none'; toggleClass(chipEntry.chip.element, 'dv-tab-group-chip--dragging', true); } // Single reflow for the entire batch void this._tabsList.offsetHeight; const underline = this._tabGroupManager.groupUnderlines.get(tabGroup.id); if (underline) { underline.style.display = 'none'; } (_a = (_b = this._animState).currentInsertionIndex) !== null && _a !== void 0 ? _a : (_b.currentInsertionIndex = firstIdx); this.applyDragOverTransforms(true); for (const t of this._tabs) { if (groupPanelIds.has(t.value.panel.id)) { t.value.element.style.removeProperty('transition'); } } if (chipEntry) { chipEntry.chip.element.style.removeProperty('transition'); } }); } /** * A drop on a tab group chip means "insert before this group". Resolve to * the index of the group's first tab, adjusting for same-group removal * (when the source tab is currently to the left of the target slot, its * removal shifts the insertion index down by one). Always clears * `targetTabGroupId` so the dropped tab lands outside the group. */ _handleChipDrop(tabGroup, event) { const firstPanelId = tabGroup.panelIds[0]; if (!firstPanelId) { return; } const insertionIndex = this._tabs.findIndex((x) => x.value.panel.id === firstPanelId); if (insertionIndex === -1) { return; } const data = getPanelData(); const sourceIndex = data && data.groupId === this.group.id && data.panelId ? this._tabs.findIndex((x) => x.value.panel.id === data.panelId) : -1; const adjustedIndex = insertionIndex - (sourceIndex !== -1 && sourceIndex < insertionIndex ? 1 : 0); this._onDrop.fire({ event: event.nativeEvent, index: adjustedIndex, targetTabGroupId: null, }); } /** * Sets the broader container that is part of the same logical drop surface * as this tab list (e.g. the full header element). When a dragleave from * the tabs list lands inside this container, `_animState` is preserved so * that external dragover listeners can continue the animation. */ setExtendedDropZone(el) { 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) { 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() { if (!this._animState) { return; } this.resetTabTransforms(); if (this._animState.sourceIndex === -1) { this._animState = null; } else { this._animState.currentInsertionIndex = null; } } snapshotTabPositions() { const positions = new Map(); for (const tab of this._tabs) { positions.set(tab.value.panel.id, tab.value.element.getBoundingClientRect()); } return positions; } getAverageTabWidth() { 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, clientY) { var _a; const sourceDoc = (_a = this._tabsList.ownerDocument) !== null && _a !== void 0 ? _a : 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()`. */ _processDragOver(clientX) { var _a, _b, _c, _d; if (this.accessor.options.disableDnd) { 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 === null || data === void 0 ? void 0 : 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 (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) === 'default' && !(data === null || data === void 0 ? void 0 : 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 === null || sourceGroup === void 0 ? void 0 : sourceGroup.model.getTabGroups().find((tg) => tg.id === data.tabGroupId); const panelCount = (_b = sourceTg === null || sourceTg === void 0 ? void 0 : sourceTg.panelIds.length) !== null && _b !== void 0 ? _b : 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) { (_d = (_c = this.group.model.dropTargetContainer) === null || _c === void 0 ? void 0 : _c.model) === null || _d === void 0 ? void 0 : _d.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) { var _a, _b, _c; 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 && ((_a = this._extendedDropZone) === null || _a === void 0 ? void 0 : _a.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) { (_c = (_b = this.group.model.dropTargetContainer) === null || _b === void 0 ? void 0 : _b.model) === null || _c === void 0 ? void 0 : _c.clear(); this._animState = null; } else { this._animState.currentInsertionIndex = null; } } handleDragOver(event) { var _a, _b, _c, _d, _e; if (!this._animState) { return; } const mouseX = event.clientX; let insertionIndex = null; let targetTabGroupId = 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 === null || sourceGroupPanelIds === void 0 ? void 0 : 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 = (_a = this._animState.chipPositions.get(groupId)) !== null && _a !== void 0 ? _a : 0; if (accWidth + chipWidth > availableSpace) { // Chip alone overflows — gap goes before this group insertionIndex !== null && insertionIndex !== void 0 ? insertionIndex : (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 !== null && insertionIndex !== void 0 ? insertionIndex : (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 === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(tab.panel.id)) { continue; } if (i >= insertionIndex) { break; } const gid = firstPanelToGroup.get(tab.panel.id); if (gid) { accUpTo += (_b = this._animState.chipPositions.get(gid)) !== null && _b !== void 0 ? _b : 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 === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(pid))); if (effectivePanelIds.length === 0) { continue; } co