dockview-core
Version:
Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript
572 lines (571 loc) • 26.6 kB
JavaScript
import { toggleClass } from '../../../dom';
import { addDisposableListener } from '../../../events';
import { LocalSelectionTransfer, PanelTransfer, } from '../../../dnd/dataTransfer';
import { html5Backend, pointerBackend, } from '../../../dnd/backend';
import { LongPressDetector } from '../../../dnd/pointer/longPress';
import { CompositeDisposable, } from '../../../lifecycle';
import { resolveDndCapabilities } from '../../dndCapabilities';
import { applyTabGroupAccent } from '../../tabGroupAccent';
import { TabGroupChip } from './tabGroupChip';
import { Droptarget } from '../../../dnd/droptarget';
import { getPanelData } from '../../../dnd/dataTransfer';
import { NoneTabGroupIndicator, WrapTabGroupIndicator, } from './tabGroupIndicator';
const EMPTY_MAP = new Map();
export class TabGroupManager {
get chipRenderers() {
return this._chipRenderers;
}
get groupUnderlines() {
var _a, _b;
return (_b = (_a = this._indicator) === null || _a === void 0 ? void 0 : _a.underlines) !== null && _b !== void 0 ? _b : EMPTY_MAP;
}
get skipNextCollapseAnimation() {
return this._skipNextCollapseAnimation;
}
set skipNextCollapseAnimation(value) {
this._skipNextCollapseAnimation = value;
}
constructor(_ctx, _callbacks) {
this._ctx = _ctx;
this._callbacks = _callbacks;
this._chipRenderers = new Map();
this._indicator = null;
this._skipNextCollapseAnimation = false;
this._pendingTransitionCleanups = new Map();
}
/**
* Synchronize chip elements and CSS classes for all tab groups
* in the parent group model. Call after any tab group mutation.
*/
update() {
const model = this._ctx.group.model;
const tabGroups = model.getTabGroups();
// Track which group IDs are still active
const activeGroupIds = new Set();
for (const tabGroup of tabGroups) {
activeGroupIds.add(tabGroup.id);
this._ensureChipForGroup(tabGroup);
this._positionChipForGroup(tabGroup);
}
// Remove chips for dissolved/destroyed groups
for (const [groupId, entry] of this._chipRenderers) {
if (!activeGroupIds.has(groupId)) {
entry.chip.element.remove();
entry.chip.dispose();
entry.disposable.dispose();
this._chipRenderers.delete(groupId);
}
}
// Update CSS classes on all tabs
this._updateTabGroupClasses();
}
/**
* Re-read the active palette and re-apply colors to chips, tabs and
* the indicator. Called when `tabGroupColors` / `tabGroupAccent`
* options change at runtime.
*/
refreshAccents() {
var _a, _b;
for (const tabGroup of this._ctx.group.model.getTabGroups()) {
const entry = this._chipRenderers.get(tabGroup.id);
(_b = entry === null || entry === void 0 ? void 0 : (_a = entry.chip).update) === null || _b === void 0 ? void 0 : _b.call(_a, { tabGroup });
}
this._updateTabGroupClasses();
}
positionAllChips() {
if (this._chipRenderers.size === 0) {
return;
}
for (const tabGroup of this._ctx.group.model.getTabGroups()) {
this._positionChipForGroup(tabGroup);
}
}
updateDirection() {
const isVertical = this._ctx.getDirection() === 'vertical';
for (const [, entry] of this._chipRenderers) {
entry.dropTarget.setTargetZones(isVertical ? ['top'] : ['left']);
}
}
snapshotChipWidths() {
const widths = new Map();
for (const [groupId, entry] of this._chipRenderers) {
widths.set(groupId, entry.chip.element.getBoundingClientRect().width);
}
return widths;
}
positionUnderlines() {
var _a;
(_a = this._indicator) === null || _a === void 0 ? void 0 : _a.positionUnderlines();
}
trackUnderlines() {
var _a;
(_a = this._indicator) === null || _a === void 0 ? void 0 : _a.trackUnderlines();
}
setGroupDragImage(event, tabGroup, chipEl) {
if (!event.dataTransfer) {
return;
}
const isVertical = this._ctx.getDirection() === 'vertical';
// Clone the entire tabs list so cloned nodes inherit all
// theme styles, CSS variables and class-based rules.
const clone = this._ctx.tabsList.cloneNode(true);
if (isVertical) {
// Force horizontal orientation for the drag ghost by
// removing vertical CSS classes and overriding writing-mode.
clone.classList.remove('dv-tabs-container-vertical', 'dv-vertical');
clone.classList.add('dv-horizontal');
clone.style.writingMode = 'horizontal-tb';
clone.style.height = `${this._ctx.tabsList.offsetWidth}px`;
}
else {
clone.style.height = `${this._ctx.tabsList.offsetHeight}px`;
}
clone.style.width = 'auto';
clone.style.overflow = 'visible';
clone.style.pointerEvents = 'none';
// Remove all elements except the chip so the drag ghost
// shows only the chip regardless of the group's expanded state.
const children = Array.from(clone.children);
const realChildren = Array.from(this._ctx.tabsList.children);
for (let i = children.length - 1; i >= 0; i--) {
const real = realChildren[i];
if (real === chipEl) {
continue; // keep the chip only
}
children[i].remove();
}
// Wrap the clone in a minimal ancestor chain so that CSS
// selectors like `.dv-groupview.dv-active-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab`
// match the cloned tabs and apply correct color/background.
const wrapper = document.createElement('div');
wrapper.className = 'dv-groupview dv-active-group';
wrapper.style.position = 'fixed';
wrapper.style.top = '-10000px';
wrapper.style.left = '0px';
wrapper.style.height = 'auto';
wrapper.style.width = 'auto';
wrapper.style.pointerEvents = 'none';
const actionsWrapper = document.createElement('div');
actionsWrapper.className = 'dv-tabs-and-actions-container';
actionsWrapper.style.height = 'auto';
actionsWrapper.style.width = 'auto';
wrapper.appendChild(actionsWrapper);
actionsWrapper.appendChild(clone);
// Append inside the dockview root so CSS variables are inherited
this._ctx.accessor.element.appendChild(wrapper);
// Compute cursor offset relative to the wrapper element.
// The cloned chip is the first .dv-tab-group-chip in the clone.
const clonedChip = clone.querySelector('.dv-tab-group-chip');
const chipRect = chipEl.getBoundingClientRect();
const cursorInChipX = event.clientX - chipRect.left;
const cursorInChipY = event.clientY - chipRect.top;
if (clonedChip) {
const clonedChipRect = clonedChip.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
const offsetX = clonedChipRect.left - wrapperRect.left + cursorInChipX;
const offsetY = clonedChipRect.top - wrapperRect.top + cursorInChipY;
event.dataTransfer.setDragImage(wrapper, offsetX, offsetY);
}
else {
event.dataTransfer.setDragImage(wrapper, cursorInChipX, cursorInChipY);
}
// Clean up after the browser captures the image
requestAnimationFrame(() => {
wrapper.remove();
});
}
cleanupTransition(panelId) {
var _a;
(_a = this._pendingTransitionCleanups.get(panelId)) === null || _a === void 0 ? void 0 : _a();
this._pendingTransitionCleanups.delete(panelId);
}
updateDragAndDropState() {
const caps = resolveDndCapabilities(this._ctx.accessor.options);
for (const entry of this._chipRenderers.values()) {
entry.chip.element.draggable = caps.html5;
entry.html5DragSource.setDisabled(!caps.html5);
entry.pointerDragSource.setDisabled(!caps.pointer);
entry.pointerDragSource.setTouchOnly(!caps.pointerHandlesMouse);
}
}
/**
* Synchronously dispose the chip drag sources for an in-flight chip
* drag. Called from `_commitGroupMove` so the transfer payload +
* iframe shield are released BEFORE the cross-group move detaches
* the chip (chip dispose is scheduled on a microtask via
* `_scheduleTabGroupUpdate`, which is too late for callers that read
* `getPanelData()` synchronously after the move). Idempotent — the
* subsequent `update()` will also dispose the sources.
*/
disposeChipDrag(tabGroupId) {
var _a, _b;
const entry = this._chipRenderers.get(tabGroupId);
if (!entry) {
return;
}
// Optional-chained because tests may inject minimal entries
// that skip the manager's normal `_ensureChipForGroup` flow.
(_a = entry.html5DragSource) === null || _a === void 0 ? void 0 : _a.dispose();
(_b = entry.pointerDragSource) === null || _b === void 0 ? void 0 : _b.dispose();
}
/** Cloned chip rect used as the pointer follow-finger ghost. */
_buildChipGhostElement(chipEl) {
const style = getComputedStyle(chipEl);
const clone = chipEl.cloneNode(true);
Array.from(style).forEach((key) => {
clone.style.setProperty(key, style.getPropertyValue(key), style.getPropertyPriority(key));
});
clone.style.position = 'absolute';
return clone;
}
disposeAll() {
var _a;
(_a = this._indicator) === null || _a === void 0 ? void 0 : _a.dispose();
this._indicator = null;
for (const [, cleanup] of this._pendingTransitionCleanups) {
cleanup();
}
this._pendingTransitionCleanups.clear();
for (const [, entry] of this._chipRenderers) {
entry.chip.element.remove();
entry.chip.dispose();
entry.disposable.dispose();
}
this._chipRenderers.clear();
}
_ensureIndicator() {
var _a, _b;
const mode = (_b = (_a = this._ctx.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabGroupIndicator) !== null && _b !== void 0 ? _b : 'wrap';
const Ctor = mode === 'none' ? NoneTabGroupIndicator : WrapTabGroupIndicator;
// Re-create if the indicator type changed (e.g. theme switch)
if (this._indicator && !(this._indicator instanceof Ctor)) {
this._indicator.dispose();
this._indicator = null;
}
if (!this._indicator) {
this._indicator = new Ctor({
tabsList: this._ctx.tabsList,
getTabGroups: () => this._ctx.group.model.getTabGroups(),
getActivePanelId: () => { var _a; return (_a = this._ctx.group.activePanel) === null || _a === void 0 ? void 0 : _a.id; },
getTabMap: () => this._ctx.getTabMap(),
getChipElement: (id) => { var _a; return (_a = this._chipRenderers.get(id)) === null || _a === void 0 ? void 0 : _a.chip.element; },
getDirection: () => this._ctx.getDirection(),
getColorPalette: () => this._ctx.accessor.tabGroupColorPalette,
});
}
}
_ensureChipForGroup(tabGroup) {
if (this._chipRenderers.has(tabGroup.id)) {
return;
}
const createChip = this._ctx.accessor.options.createTabGroupChipComponent;
const chip = createChip
? createChip(tabGroup)
: new TabGroupChip(this._ctx.accessor.tabGroupColorPalette);
chip.init({ tabGroup, api: this._ctx.accessor.api });
const caps = resolveDndCapabilities(this._ctx.accessor.options);
chip.element.draggable = caps.html5;
const panelTransfer = LocalSelectionTransfer.getInstance();
// Shared `getData` for both backends. Sets a group-level
// PanelTransfer (panelId=null, tabGroupId identifies the group).
// The returned disposer clears it on drag end.
const getData = () => {
panelTransfer.setData([
new PanelTransfer(this._ctx.accessor.id, this._ctx.group.id, null, tabGroup.id),
], PanelTransfer.prototype);
return {
dispose: () => {
panelTransfer.clearData(PanelTransfer.prototype);
},
};
};
// The chip's HTML5 drag image is the cloned tabs list (chip only),
// mounted inside the dockview root for CSS-variable inheritance and
// positioned against the chip's in-place rect. Layout-dependent
// offset means we set the drag image directly in `onDragStart`
// (inside the dragstart handler) rather than via the generic
// `createGhost` factory, which only knows about ghost specs that
// can be appended to `document.body`.
const html5DragSource = html5Backend.createDragSource(chip.element, {
getData,
disabled: !caps.html5,
isCancelled: () => !resolveDndCapabilities(this._ctx.accessor.options).html5,
onDragStart: (event) => {
// Type guard via `dataTransfer` — `instanceof DragEvent`
// would throw in jsdom which doesn't ship a DragEvent
// constructor.
if ('dataTransfer' in event && event.dataTransfer) {
this.setGroupDragImage(event, tabGroup, chip.element);
}
this._callbacks.onChipDragStart(tabGroup, chip, event);
},
onDragEnd: (event) => {
var _a, _b;
(_b = (_a = this._callbacks).onChipDragEnd) === null || _b === void 0 ? void 0 : _b.call(_a, tabGroup, chip, event);
},
});
// Synchronous panelTransfer cleanup directly on the chip element.
// `Html5DragSource`'s dragend defers data disposal via `setTimeout(0)`
// so drop handlers can read the payload — but a chip drag that
// ends via `moveGroupOrPanel` (no actual drop event) needs the
// singleton cleared immediately, otherwise a synchronous
// `getPanelData()` after the move still sees the stale chip
// payload. Attached directly (not via `addDisposableListener`) so
// the listener survives chip disposal in the detach-then-dragend
// cross-group path; `once: true` auto-removes after the single
// dragend that we care about. (#1254)
chip.element.addEventListener('dragend', () => {
panelTransfer.clearData(PanelTransfer.prototype);
}, { once: true });
const pointerDragSource = pointerBackend.createDragSource(chip.element, {
getData,
disabled: !caps.pointer,
touchOnly: !caps.pointerHandlesMouse,
isCancelled: () => !resolveDndCapabilities(this._ctx.accessor.options).pointer,
createGhost: () => ({
element: this._buildChipGhostElement(chip.element),
offsetX: 8,
offsetY: 8,
}),
onDragStart: (event) => {
this._callbacks.onChipDragStart(tabGroup, chip, event);
},
});
const disposables = [
tabGroup.onDidChange(() => {
var _a;
(_a = chip.update) === null || _a === void 0 ? void 0 : _a.call(chip, { tabGroup });
this._updateTabGroupClasses();
}),
tabGroup.onDidPanelChange(() => {
this._positionChipForGroup(tabGroup);
this._updateTabGroupClasses();
}),
tabGroup.onDidCollapseChange(() => {
this._updateTabGroupClasses();
}),
html5DragSource,
pointerDragSource,
];
// Context menu: built-in TabGroupChip already aggregates right-click
// + touch long-press into `onContextMenu`. Custom chip renderers
// don't, so attach a long-press detector and contextmenu listener
// directly on their element.
const onContextMenu = (event) => {
// A long-press on a chip should preempt the in-flight pointer
// drag and open the menu instead.
pointerDragSource.cancelPending();
this._callbacks.onChipContextMenu(tabGroup, event);
};
if (chip instanceof TabGroupChip) {
disposables.push(chip.onContextMenu(onContextMenu));
}
else {
disposables.push(new LongPressDetector(chip.element, {
onLongPress: onContextMenu,
}), addDisposableListener(chip.element, 'contextmenu', onContextMenu));
}
// The chip sits before its group's first tab in the DOM, so it
// covers the "drop before the group" position. Without a drop
// target here, dropping a tab over the chip is a dead zone —
// particularly visible when the group is first in the tabs list
// and there's no preceding tab whose right zone covers position 0.
// The smooth animation path already shifts the chip's margin to
// open a gap, so suppress the overlay in that mode.
const isVertical = this._ctx.getDirection() === 'vertical';
const dropTarget = new Droptarget(chip.element, {
acceptedTargetZones: isVertical ? ['top'] : ['left'],
overlayModel: {
activationSize: { value: 100, type: 'percentage' },
},
canDisplayOverlay: (event, position) => {
var _a;
if (this._ctx.group.locked) {
return false;
}
if (this._ctx.accessor.options.disableDnd) {
return false;
}
const data = getPanelData();
if (data && this._ctx.accessor.id === data.viewId) {
if (((_a = this._ctx.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) ===
'smooth') {
return false;
}
return true;
}
return this._ctx.group.model.canDisplayOverlay(event, position, 'tab');
},
});
disposables.push(dropTarget, dropTarget.onDrop((event) => {
this._callbacks.onChipDrop(tabGroup, event);
}));
const disposable = new CompositeDisposable(...disposables);
this._chipRenderers.set(tabGroup.id, {
chip,
html5DragSource,
pointerDragSource,
disposable,
dropTarget,
});
// Group is born collapsed (cross-group drop, layout restore, etc.):
// its tabs are about to be added without the collapsed class. Skip
// the animation in the upcoming _updateTabGroupClasses call so they
// apply the class instantly instead of transitioning from expanded.
if (tabGroup.collapsed) {
this._skipNextCollapseAnimation = true;
}
}
_positionChipForGroup(tabGroup) {
const entry = this._chipRenderers.get(tabGroup.id);
if (!entry) {
return;
}
const chipEl = entry.chip.element;
const panelIds = tabGroup.panelIds;
if (panelIds.length === 0) {
chipEl.remove();
return;
}
// Find the first tab element of this group
const firstPanelId = panelIds[0];
const firstTabEntry = this._ctx.getTabMap().get(firstPanelId);
if (!firstTabEntry) {
chipEl.remove();
return;
}
// Insert chip before the first tab of the group
const firstTabEl = firstTabEntry.value.element;
if (chipEl.nextSibling !== firstTabEl) {
this._ctx.tabsList.insertBefore(chipEl, firstTabEl);
}
}
_updateTabGroupClasses() {
var _a;
const model = this._ctx.group.model;
const tabGroups = model.getTabGroups();
const tabs = this._ctx.getTabs();
const tabMap = this._ctx.getTabMap();
let hasAnimation = false;
// Build a lookup: panelId → tabGroup
const panelGroupMap = new Map();
for (const tg of tabGroups) {
for (const pid of tg.panelIds) {
panelGroupMap.set(pid, tg);
}
}
for (const tabEntry of tabs) {
const tab = tabEntry.value;
const panelId = tab.panel.id;
const tg = panelGroupMap.get(panelId);
const isGrouped = !!tg;
toggleClass(tab.element, 'dv-tab--grouped', isGrouped);
if (tg) {
const ids = tg.panelIds;
const isFirst = ids[0] === panelId;
const isLast = ids[ids.length - 1] === panelId;
toggleClass(tab.element, 'dv-tab--group-first', isFirst);
toggleClass(tab.element, 'dv-tab--group-last', isLast);
// Expose the resolved group color as a CSS custom property
// so pure-CSS themes can use it for borders, backgrounds, etc.
applyTabGroupAccent(tab.element, tg.color, this._ctx.accessor.tabGroupColorPalette);
// Collapse / expand with animation
const isCollapsed = tab.element.classList.contains('dv-tab--group-collapsed');
if (!tg.collapsed && isCollapsed) {
// Collapsed → expanding: animate back
hasAnimation = true;
tab.element.classList.remove('dv-tab--group-collapsed');
tab.element.classList.add('dv-tab--group-expanding');
// Clean up any previous transitionend listener
// from a rapid collapse/expand cycle
(_a = this._pendingTransitionCleanups.get(panelId)) === null || _a === void 0 ? void 0 : _a();
const onEnd = () => {
tab.element.classList.remove('dv-tab--group-expanding');
tab.element.style.removeProperty('width');
tab.element.removeEventListener('transitionend', onEnd);
clearTimeout(fallbackTimer);
this._pendingTransitionCleanups.delete(panelId);
};
// Fallback in case transitionend never fires
// (e.g. element removed from DOM mid-transition)
const fallbackTimer = setTimeout(onEnd, 300);
this._pendingTransitionCleanups.set(panelId, onEnd);
tab.element.addEventListener('transitionend', onEnd);
}
}
else {
toggleClass(tab.element, 'dv-tab--group-first', false);
toggleClass(tab.element, 'dv-tab--group-last', false);
tab.element.classList.remove('dv-tab--group-collapsed', 'dv-tab--group-expanding');
tab.element.style.removeProperty('width');
tab.element.style.removeProperty('--dv-tab-group-color');
}
}
// Track active group IDs for underline/collapse handling
const activeGroupIds = new Set();
// Handle collapse animation per group
for (const tg of tabGroups) {
activeGroupIds.add(tg.id);
// Collapse animation
const hasNewCollapse = tg.collapsed &&
tg.panelIds.some((pid) => {
const te = tabMap.get(pid);
return (te &&
!te.value.element.classList.contains('dv-tab--group-collapsed'));
});
if (hasNewCollapse) {
if (this._skipNextCollapseAnimation) {
// Apply collapsed state instantly (no animation).
// Disable transitions so the CSS transition on
// dv-tab--group-collapsed doesn't fire.
const affected = [];
for (const pid of tg.panelIds) {
const te = tabMap.get(pid);
if (te) {
te.value.element.style.transition = 'none';
te.value.element.classList.add('dv-tab--group-collapsed');
affected.push(te.value.element);
}
}
if (affected.length > 0) {
void affected[0].offsetHeight; // single reflow
for (const el of affected) {
el.style.removeProperty('transition');
}
}
}
else {
hasAnimation = true;
const isVert = this._ctx.getDirection() === 'vertical';
for (const pid of tg.panelIds) {
const te = tabMap.get(pid);
if (te &&
!te.value.element.classList.contains('dv-tab--group-collapsed')) {
const rect = te.value.element.getBoundingClientRect();
if (isVert) {
te.value.element.style.height = `${rect.height}px`;
}
else {
te.value.element.style.width = `${rect.width}px`;
}
void te.value.element.offsetHeight; // force reflow
te.value.element.classList.add('dv-tab--group-collapsed');
}
}
}
}
}
this._skipNextCollapseAnimation = false;
// Sync indicator underlines and position them
this._ensureIndicator();
if (this._indicator) {
this._indicator.syncUnderlineElements(activeGroupIds);
if (hasAnimation) {
this._indicator.trackUnderlines();
}
else {
this._indicator.positionUnderlines();
}
}
}
}