dockview-core
Version:
Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript
627 lines (626 loc) • 26.7 kB
JavaScript
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);
}
}