UNPKG

dockview-core

Version:

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

572 lines (571 loc) 26.6 kB
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(); } } } }