UNPKG

dockview-core

Version:

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

627 lines (626 loc) 26.7 kB
import { Emitter } from '../events'; import { CompositeDisposable } from '../lifecycle'; import { LayoutPriority, Orientation, Splitview, } from '../splitview/splitview'; import { watchElementResize } from '../dom'; export class EdgeGroupView { get minimumSize() { // When collapsed, lock size to collapsedSize so sash can't drag it open return this._isCollapsed ? this._collapsedSize : this._expandedMinimumSize; } get maximumSize() { // When collapsed, lock size to collapsedSize so sash can't drag it open return this._isCollapsed ? this._collapsedSize : this._expandedMaximumSize; } get element() { return this._group.element; } get isCollapsed() { return this._isCollapsed; } get lastExpandedSize() { return this._lastExpandedSize; } get collapsedSize() { return this._collapsedSize; } constructor(options, group, orientation) { var _a, _b, _c; this._onDidChange = new Emitter(); this.onDidChange = this._onDidChange.event; this.snap = false; this.priority = LayoutPriority.Low; this._isCollapsed = false; this._group = group; this._orientation = orientation; group.element.classList.add('dv-edge-group'); group.element.dataset.testid = `dv-edge-group-${options.id}`; this._collapsedSize = (_a = options.collapsedSize) !== null && _a !== void 0 ? _a : 35; this._expandedMaximumSize = (_b = options.maximumSize) !== null && _b !== void 0 ? _b : Number.POSITIVE_INFINITY; // If the caller explicitly provides a minimumSize, respect it. // Otherwise fall back to collapsedSize + 50 so the expanded state is // visually distinguishable from the collapsed state. this._expandedMinimumSize = options.minimumSize !== undefined ? options.minimumSize : this._collapsedSize + 50; this._lastExpandedSize = (_c = options.initialSize) !== null && _c !== void 0 ? _c : 200; if (options.collapsed) { this._isCollapsed = true; group.element.classList.add('dv-edge-collapsed'); } } layout(size, orthogonalSize) { // Track the last expanded size so we can restore it after collapsing if (!this._isCollapsed) { this._lastExpandedSize = size; } // horizontal (left/right): size=width, orthogonalSize=height → layout(width, height) // vertical (top/bottom): size=height, orthogonalSize=width → layout(width, height) if (this._orientation === 'horizontal') { this._group.layout(size, orthogonalSize); } else { this._group.layout(orthogonalSize, size); } } setCollapsed(collapsed) { if (this._isCollapsed === collapsed) { return; } this._isCollapsed = collapsed; this._group.element.classList.toggle('dv-edge-collapsed', collapsed); // ShellManager calls resizeView directly after this; no _onDidChange needed } setVisible(_visible) { // visibility is managed by the parent splitview } /** * Restore the last-expanded size from serialized state without triggering * a layout. Must be called before setCollapsed(true) during fromJSON so * that expanding after deserialization restores the correct size. */ restoreExpandedSize(size) { this._lastExpandedSize = size; } /** * Apply new effective collapsed and expanded-minimum sizes after a theme * or gap change. The caller (ShellManager) is responsible for computing * the correct values from the original config and the new gap. */ updateCollapsedSize(newCollapsedSize, newExpandedMinimumSize) { this._collapsedSize = newCollapsedSize; this._expandedMinimumSize = newExpandedMinimumSize; } dispose() { this._onDidChange.dispose(); } } class CenterView { get element() { return this._dockviewElement; } constructor(_dockviewElement, _layoutDockview) { this._dockviewElement = _dockviewElement; this._layoutDockview = _layoutDockview; this.priority = LayoutPriority.High; this.minimumSize = 100; this.maximumSize = Number.POSITIVE_INFINITY; this._onDidChange = new Emitter(); this.onDidChange = this._onDidChange.event; } layout(size, orthogonalSize) { // Lives in a VERTICAL middle-column splitview: // size = height alloc, orthogonalSize = width this._layoutDockview(orthogonalSize, size); } setVisible(_visible) { // center is always visible } dispose() { this._onDidChange.dispose(); } } /** * The vertical centre column: top (optional) | center | bottom (optional). * This view sits between the left and right edge panels in the outer * horizontal splitview, so its primary axis is width (horizontal). */ class MiddleColumnView { get element() { return this._element; } constructor(centerView, gap = 0) { this._onDidChange = new Emitter(); this.onDidChange = this._onDidChange.event; this.minimumSize = 100; this.maximumSize = Number.POSITIVE_INFINITY; this.priority = LayoutPriority.High; this._element = document.createElement('div'); this._element.className = 'dv-shell-middle-column'; this._element.style.height = '100%'; this._element.style.width = '100%'; this._splitview = new Splitview(this._element, { orientation: Orientation.VERTICAL, proportionalLayout: false, margin: gap, }); this._centerIndex = 0; this._splitview.addView(centerView, { type: 'distribute' }, 0); } addTopView(view, initialSize) { // Insert before center this._splitview.addView(view, initialSize, 0); this._topIndex = 0; this._centerIndex += 1; if (this._bottomIndex !== undefined) { this._bottomIndex += 1; } } addBottomView(view, initialSize) { // Append after center (and any existing bottom — shouldn't happen but safe) const newIndex = this._splitview.length; this._splitview.addView(view, initialSize, newIndex); this._bottomIndex = newIndex; } removeView(position) { const index = position === 'top' ? this._topIndex : this._bottomIndex; if (index === undefined) { return; } this._splitview.removeView(index); if (position === 'top') { this._topIndex = undefined; // center (and bottom if present) shift down by one this._centerIndex -= 1; if (this._bottomIndex !== undefined) { this._bottomIndex -= 1; } } else { this._bottomIndex = undefined; // center and top are unaffected } } layout(size, orthogonalSize) { // Outer horizontal splitview: size = width, orthogonalSize = height // Inner vertical splitview: layout(height, width) this._splitview.layout(orthogonalSize, size); } setVisible(_visible) { // middle column is always visible } setViewVisible(position, visible) { const index = position === 'top' ? this._topIndex : this._bottomIndex; if (index !== undefined) { this._splitview.setViewVisible(index, visible); } } isViewVisible(position) { const index = position === 'top' ? this._topIndex : this._bottomIndex; if (index !== undefined) { return this._splitview.isViewVisible(index); } return false; } getViewSize(position) { const index = position === 'top' ? this._topIndex : this._bottomIndex; if (index !== undefined) { return this._splitview.getViewSize(index); } return 0; } resizeView(position, size) { const index = position === 'top' ? this._topIndex : this._bottomIndex; if (index !== undefined) { this._splitview.resizeView(index, size); } } updateMargin(gap) { this._splitview.margin = gap; } dispose() { this._onDidChange.dispose(); this._splitview.dispose(); } } function adjustedOpts(base, defaultCollapsed, gapAdd) { var _a; const effectiveCollapsed = ((_a = base.collapsedSize) !== null && _a !== void 0 ? _a : defaultCollapsed) + gapAdd; const result = Object.assign(Object.assign({}, base), { collapsedSize: effectiveCollapsed }); if (base.minimumSize !== undefined) { result.minimumSize = base.minimumSize + gapAdd; } return result; } export class ShellManager { constructor(container, dockviewElement, layoutGrid, gap = 0, defaultCollapsedSize = 35) { this._disposables = new CompositeDisposable(); // Retained for updateTheme() recalculations. this._viewConfigs = new Map(); this._currentWidth = 0; this._currentHeight = 0; this._gap = gap; this._defaultCollapsedSize = defaultCollapsedSize; this._shellElement = document.createElement('div'); this._shellElement.className = 'dv-shell'; this._shellElement.style.height = '100%'; this._shellElement.style.width = '100%'; this._shellElement.style.position = 'relative'; container.appendChild(this._shellElement); const centerView = new CenterView(dockviewElement, layoutGrid); this._middleColumn = new MiddleColumnView(centerView, gap); this._outerSplitview = new Splitview(this._shellElement, { orientation: Orientation.HORIZONTAL, proportionalLayout: false, margin: gap, }); this._middleIndex = 0; this._outerSplitview.addView(this._middleColumn, { type: 'distribute' }, 0); this._disposables.addDisposables(watchElementResize(this._shellElement, (entry) => { const width = Math.round(entry.contentRect.width); const height = Math.round(entry.contentRect.height); if (width === this._currentWidth && height === this._currentHeight) { return; } this._currentWidth = width; this._currentHeight = height; this.layout(width, height); }), this._outerSplitview, this._middleColumn, centerView); } get element() { return this._shellElement; } /** * Add an edge group view at the given position. The view wraps the * provided group element inside the shell's splitview layout. * Throws if a group at this position is already registered. */ addEdgeView(position, options, group) { if (this.hasEdgeGroup(position)) { throw new Error(`dockview: edge group already registered at position '${position}'`); } this._viewConfigs.set(position, options); // Recompute gap adjustments now that _viewConfigs has grown. const outerN = 1 + (this._viewConfigs.has('left') ? 1 : 0) + (this._viewConfigs.has('right') ? 1 : 0); const innerN = 1 + (this._viewConfigs.has('top') ? 1 : 0) + (this._viewConfigs.has('bottom') ? 1 : 0); const outerGapAdd = outerN > 1 ? (this._gap * (outerN - 1)) / outerN : 0; const innerGapAdd = innerN > 1 ? (this._gap * (innerN - 1)) / innerN : 0; const isHorizontal = position === 'left' || position === 'right'; const gapAdd = isHorizontal ? outerGapAdd : innerGapAdd; const orientation = isHorizontal ? 'horizontal' : 'vertical'; const view = new EdgeGroupView(adjustedOpts(Object.assign({ collapsedSize: this._defaultCollapsedSize }, options), this._defaultCollapsedSize, gapAdd), group, orientation); const initialSize = view.isCollapsed ? view.collapsedSize : view.lastExpandedSize; switch (position) { case 'left': // Insert before the middle column this._outerSplitview.addView(view, initialSize, 0); this._leftIndex = 0; this._middleIndex += 1; if (this._rightIndex !== undefined) { this._rightIndex += 1; } this._leftView = view; break; case 'right': // Append after the middle column { const idx = this._outerSplitview.length; this._outerSplitview.addView(view, initialSize, idx); this._rightIndex = idx; this._rightView = view; } break; case 'top': this._middleColumn.addTopView(view, initialSize); this._topView = view; break; case 'bottom': this._middleColumn.addBottomView(view, initialSize); this._bottomView = view; break; } this._disposables.addDisposables(view); // Recalculate gap adjustments for all views now that n has changed. // updateTheme already guards the layout() call by _currentWidth/_currentHeight. this.updateTheme(this._gap, this._defaultCollapsedSize); return view; } layout(width, height) { // Outer splitview is HORIZONTAL: layout(size=width, orthogonalSize=height) this._outerSplitview.layout(width, height); } /** * Called when the active theme changes. Updates splitview margins and * edge-group collapsed sizes so the layout matches the new theme's gap * and tab-strip dimensions. */ updateTheme(gap, defaultCollapsedSize) { var _a, _b, _c, _d; this._gap = gap; this._defaultCollapsedSize = defaultCollapsedSize; const outerN = 1 + (this._viewConfigs.has('left') ? 1 : 0) + (this._viewConfigs.has('right') ? 1 : 0); const innerN = 1 + (this._viewConfigs.has('top') ? 1 : 0) + (this._viewConfigs.has('bottom') ? 1 : 0); const outerGapAdd = outerN > 1 ? (gap * (outerN - 1)) / outerN : 0; const innerGapAdd = innerN > 1 ? (gap * (innerN - 1)) / innerN : 0; // Update splitview margins. this._outerSplitview.margin = gap; this._middleColumn.updateMargin(gap); // Recompute effective collapsed sizes from the original config values. const updateView = (view, baseCfg, gapAdd) => { var _a; const baseCS = (_a = baseCfg.collapsedSize) !== null && _a !== void 0 ? _a : defaultCollapsedSize; const newCS = baseCS + gapAdd; const baseMS = baseCfg.minimumSize; const newMS = baseMS !== undefined ? baseMS + gapAdd : newCS + 50; view.updateCollapsedSize(newCS, newMS); }; const topCfg = this._viewConfigs.get('top'); if (this._topView && topCfg) { updateView(this._topView, topCfg, innerGapAdd); } const bottomCfg = this._viewConfigs.get('bottom'); if (this._bottomView && bottomCfg) { updateView(this._bottomView, bottomCfg, innerGapAdd); } const leftCfg = this._viewConfigs.get('left'); if (this._leftView && leftCfg) { updateView(this._leftView, leftCfg, outerGapAdd); } const rightCfg = this._viewConfigs.get('right'); if (this._rightView && rightCfg) { updateView(this._rightView, rightCfg, outerGapAdd); } // Resize currently-collapsed groups to their new collapsed size so // they immediately match the new theme's tab-strip dimensions. if (((_a = this._leftView) === null || _a === void 0 ? void 0 : _a.isCollapsed) && this._leftIndex !== undefined) { this._outerSplitview.resizeView(this._leftIndex, this._leftView.collapsedSize); } if (((_b = this._rightView) === null || _b === void 0 ? void 0 : _b.isCollapsed) && this._rightIndex !== undefined) { this._outerSplitview.resizeView(this._rightIndex, this._rightView.collapsedSize); } if ((_c = this._topView) === null || _c === void 0 ? void 0 : _c.isCollapsed) { this._middleColumn.resizeView('top', this._topView.collapsedSize); } if ((_d = this._bottomView) === null || _d === void 0 ? void 0 : _d.isCollapsed) { this._middleColumn.resizeView('bottom', this._bottomView.collapsedSize); } // Re-run layout with the current shell dimensions. if (this._currentWidth > 0 && this._currentHeight > 0) { this.layout(this._currentWidth, this._currentHeight); } } removeEdgeView(position) { const view = this._getView(position); if (!view) { return; } switch (position) { case 'left': this._outerSplitview.removeView(this._leftIndex); this._leftIndex = undefined; this._leftView = undefined; // middle and right shift left by one this._middleIndex -= 1; if (this._rightIndex !== undefined) { this._rightIndex -= 1; } break; case 'right': this._outerSplitview.removeView(this._rightIndex); this._rightIndex = undefined; this._rightView = undefined; break; case 'top': this._middleColumn.removeView('top'); this._topView = undefined; break; case 'bottom': this._middleColumn.removeView('bottom'); this._bottomView = undefined; break; } // Deregister before disposing to avoid double-dispose when ShellManager // itself is eventually disposed. this._disposables.removeDisposable(view); view.dispose(); this._viewConfigs.delete(position); // Recalculate gap adjustments for remaining views. this.updateTheme(this._gap, this._defaultCollapsedSize); } hasEdgeGroup(position) { switch (position) { case 'top': return this._topView !== undefined; case 'bottom': return this._bottomView !== undefined; case 'left': return this._leftView !== undefined; case 'right': return this._rightView !== undefined; } } setEdgeGroupVisible(position, visible) { switch (position) { case 'left': if (this._leftIndex !== undefined) { this._outerSplitview.setViewVisible(this._leftIndex, visible); } break; case 'right': if (this._rightIndex !== undefined) { this._outerSplitview.setViewVisible(this._rightIndex, visible); } break; case 'top': case 'bottom': this._middleColumn.setViewVisible(position, visible); break; } } isEdgeGroupVisible(position) { switch (position) { case 'left': if (this._leftIndex !== undefined) { return this._outerSplitview.isViewVisible(this._leftIndex); } return false; case 'right': if (this._rightIndex !== undefined) { return this._outerSplitview.isViewVisible(this._rightIndex); } return false; case 'top': case 'bottom': return this._middleColumn.isViewVisible(position); } } setEdgeGroupCollapsed(position, collapsed) { const view = this._getView(position); if (!view) { return; } view.setCollapsed(collapsed); const targetSize = collapsed ? view.collapsedSize : view.lastExpandedSize; switch (position) { case 'left': if (this._leftIndex !== undefined) { this._outerSplitview.resizeView(this._leftIndex, targetSize); } break; case 'right': if (this._rightIndex !== undefined) { this._outerSplitview.resizeView(this._rightIndex, targetSize); } break; case 'top': case 'bottom': this._middleColumn.resizeView(position, targetSize); break; } } isEdgeGroupCollapsed(position) { var _a, _b; return (_b = (_a = this._getView(position)) === null || _a === void 0 ? void 0 : _a.isCollapsed) !== null && _b !== void 0 ? _b : false; } _getView(position) { switch (position) { case 'top': return this._topView; case 'bottom': return this._bottomView; case 'left': return this._leftView; case 'right': return this._rightView; } } toJSON() { const edgeGroups = {}; if (this._leftView && this._leftIndex !== undefined) { edgeGroups.left = { size: this._leftView.isCollapsed ? this._leftView.lastExpandedSize : this._outerSplitview.getViewSize(this._leftIndex), visible: this._outerSplitview.isViewVisible(this._leftIndex), collapsed: this._leftView.isCollapsed || undefined, }; } if (this._rightView && this._rightIndex !== undefined) { edgeGroups.right = { size: this._rightView.isCollapsed ? this._rightView.lastExpandedSize : this._outerSplitview.getViewSize(this._rightIndex), visible: this._outerSplitview.isViewVisible(this._rightIndex), collapsed: this._rightView.isCollapsed || undefined, }; } if (this._topView) { edgeGroups.top = { size: this._topView.isCollapsed ? this._topView.lastExpandedSize : this._middleColumn.getViewSize('top'), visible: this._middleColumn.isViewVisible('top'), collapsed: this._topView.isCollapsed || undefined, }; } if (this._bottomView) { edgeGroups.bottom = { size: this._bottomView.isCollapsed ? this._bottomView.lastExpandedSize : this._middleColumn.getViewSize('bottom'), visible: this._middleColumn.isViewVisible('bottom'), collapsed: this._bottomView.isCollapsed || undefined, }; } return edgeGroups; } fromJSON(data) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v; if (data.left && this._leftIndex !== undefined) { // Always restore the expanded size first. toJSON always records the // expanded size (even when collapsed), so restoredExpandedSize must // be applied before setCollapsed locks min/max to collapsedSize. (_a = this._leftView) === null || _a === void 0 ? void 0 : _a.restoreExpandedSize(data.left.size); (_b = this._leftView) === null || _b === void 0 ? void 0 : _b.setCollapsed((_c = data.left.collapsed) !== null && _c !== void 0 ? _c : false); this._outerSplitview.resizeView(this._leftIndex, data.left.collapsed ? ((_e = (_d = this._leftView) === null || _d === void 0 ? void 0 : _d.collapsedSize) !== null && _e !== void 0 ? _e : data.left.size) : data.left.size); if (!data.left.visible) { this._outerSplitview.setViewVisible(this._leftIndex, false); } } if (data.right && this._rightIndex !== undefined) { (_f = this._rightView) === null || _f === void 0 ? void 0 : _f.restoreExpandedSize(data.right.size); (_g = this._rightView) === null || _g === void 0 ? void 0 : _g.setCollapsed((_h = data.right.collapsed) !== null && _h !== void 0 ? _h : false); this._outerSplitview.resizeView(this._rightIndex, data.right.collapsed ? ((_k = (_j = this._rightView) === null || _j === void 0 ? void 0 : _j.collapsedSize) !== null && _k !== void 0 ? _k : data.right.size) : data.right.size); if (!data.right.visible) { this._outerSplitview.setViewVisible(this._rightIndex, false); } } if (data.top) { (_l = this._topView) === null || _l === void 0 ? void 0 : _l.restoreExpandedSize(data.top.size); (_m = this._topView) === null || _m === void 0 ? void 0 : _m.setCollapsed((_o = data.top.collapsed) !== null && _o !== void 0 ? _o : false); this._middleColumn.resizeView('top', data.top.collapsed ? ((_q = (_p = this._topView) === null || _p === void 0 ? void 0 : _p.collapsedSize) !== null && _q !== void 0 ? _q : data.top.size) : data.top.size); if (!data.top.visible) { this._middleColumn.setViewVisible('top', false); } } if (data.bottom) { (_r = this._bottomView) === null || _r === void 0 ? void 0 : _r.restoreExpandedSize(data.bottom.size); (_s = this._bottomView) === null || _s === void 0 ? void 0 : _s.setCollapsed((_t = data.bottom.collapsed) !== null && _t !== void 0 ? _t : false); this._middleColumn.resizeView('bottom', data.bottom.collapsed ? ((_v = (_u = this._bottomView) === null || _u === void 0 ? void 0 : _u.collapsedSize) !== null && _v !== void 0 ? _v : data.bottom.size) : data.bottom.size); if (!data.bottom.visible) { this._middleColumn.setViewVisible('bottom', false); } } } dispose() { var _a; this._disposables.dispose(); (_a = this._shellElement.parentElement) === null || _a === void 0 ? void 0 : _a.removeChild(this._shellElement); } }