dockview-core
Version:
Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript
1,092 lines • 72.1 kB
JavaScript
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