dockview
Version:
Zero dependency layout manager supporting tabs, grids and splitviews with ReactJS support
690 lines (689 loc) • 30 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Accreditation: This file is largly based upon the MIT licenced VSCode sourcecode found at:
* https://github.com/microsoft/vscode/tree/main/src/vs/base/browser/ui/splitview
*--------------------------------------------------------------------------------------------*/
import { removeClasses, addClasses, toggleClass, getElementsByTagName, } from '../../dom';
import { clamp } from '../../math';
import { Emitter } from '../../events';
import { pushToStart, pushToEnd, range, firstIndex } from '../../array';
import { ViewItem } from './viewItem';
export var Orientation;
(function (Orientation) {
Orientation["HORIZONTAL"] = "HORIZONTAL";
Orientation["VERTICAL"] = "VERTICAL";
})(Orientation || (Orientation = {}));
export var SashState;
(function (SashState) {
SashState[SashState["MAXIMUM"] = 0] = "MAXIMUM";
SashState[SashState["MINIMUM"] = 1] = "MINIMUM";
SashState[SashState["DISABLED"] = 2] = "DISABLED";
SashState[SashState["ENABLED"] = 3] = "ENABLED";
})(SashState || (SashState = {}));
export var LayoutPriority;
(function (LayoutPriority) {
LayoutPriority["Low"] = "low";
LayoutPriority["High"] = "high";
LayoutPriority["Normal"] = "normal";
})(LayoutPriority || (LayoutPriority = {}));
export var Sizing;
(function (Sizing) {
Sizing.Distribute = { type: 'distribute' };
function Split(index) {
return { type: 'split', index };
}
Sizing.Split = Split;
function Invisible(cachedVisibleSize) {
return { type: 'invisible', cachedVisibleSize };
}
Sizing.Invisible = Invisible;
})(Sizing || (Sizing = {}));
export class Splitview {
constructor(container, options) {
this.container = container;
this.views = [];
this.sashes = [];
this._size = 0;
this._orthogonalSize = 0;
this.contentSize = 0;
this._proportions = undefined;
this._onDidSashEnd = new Emitter();
this.onDidSashEnd = this._onDidSashEnd.event;
this._onDidAddView = new Emitter();
this.onDidAddView = this._onDidAddView.event;
this._onDidRemoveView = new Emitter();
this.onDidRemoveView = this._onDidAddView.event;
this._startSnappingEnabled = true;
this._endSnappingEnabled = true;
this.resize = (index, delta, sizes = this.views.map((x) => x.size), lowPriorityIndexes, highPriorityIndexes, overloadMinDelta = Number.NEGATIVE_INFINITY, overloadMaxDelta = Number.POSITIVE_INFINITY, snapBefore, snapAfter) => {
if (index < 0 || index > this.views.length) {
return 0;
}
const upIndexes = range(index, -1);
const downIndexes = range(index + 1, this.views.length);
//
if (highPriorityIndexes) {
for (const i of highPriorityIndexes) {
pushToStart(upIndexes, i);
pushToStart(downIndexes, i);
}
}
if (lowPriorityIndexes) {
for (const i of lowPriorityIndexes) {
pushToEnd(upIndexes, i);
pushToEnd(downIndexes, i);
}
}
//
const upItems = upIndexes.map((i) => this.views[i]);
const upSizes = upIndexes.map((i) => sizes[i]);
//
const downItems = downIndexes.map((i) => this.views[i]);
const downSizes = downIndexes.map((i) => sizes[i]);
//
const minDeltaUp = upIndexes.reduce((_, i) => _ + this.views[i].minimumSize - sizes[i], 0);
const maxDeltaUp = upIndexes.reduce((_, i) => _ + this.views[i].maximumSize - sizes[i], 0);
//
const maxDeltaDown = downIndexes.length === 0
? Number.POSITIVE_INFINITY
: downIndexes.reduce((_, i) => _ + sizes[i] - this.views[i].minimumSize, 0);
const minDeltaDown = downIndexes.length === 0
? Number.NEGATIVE_INFINITY
: downIndexes.reduce((_, i) => _ + sizes[i] - this.views[i].maximumSize, 0);
//
const minDelta = Math.max(minDeltaUp, minDeltaDown);
const maxDelta = Math.min(maxDeltaDown, maxDeltaUp);
//
let snapped = false;
if (snapBefore) {
const snapView = this.views[snapBefore.index];
const visible = delta >= snapBefore.limitDelta;
snapped = visible !== snapView.visible;
snapView.setVisible(visible, snapBefore.size);
}
if (!snapped && snapAfter) {
const snapView = this.views[snapAfter.index];
const visible = delta < snapAfter.limitDelta;
snapped = visible !== snapView.visible;
snapView.setVisible(visible, snapAfter.size);
}
if (snapped) {
return this.resize(index, delta, sizes, lowPriorityIndexes, highPriorityIndexes, overloadMinDelta, overloadMaxDelta);
}
//
const tentativeDelta = clamp(delta, minDelta, maxDelta);
let actualDelta = 0;
//
let deltaUp = tentativeDelta;
for (let i = 0; i < upItems.length; i++) {
const item = upItems[i];
const size = clamp(upSizes[i] + deltaUp, item.minimumSize, item.maximumSize);
const viewDelta = size - upSizes[i];
actualDelta += viewDelta;
deltaUp -= viewDelta;
item.size = size;
}
//
let deltaDown = actualDelta;
for (let i = 0; i < downItems.length; i++) {
const item = downItems[i];
const size = clamp(downSizes[i] - deltaDown, item.minimumSize, item.maximumSize);
const viewDelta = size - downSizes[i];
deltaDown += viewDelta;
item.size = size;
}
//
return delta;
};
this._orientation = options.orientation;
this.element = this.createContainer();
this.proportionalLayout =
options.proportionalLayout === undefined
? true
: !!options.proportionalLayout;
this.viewContainer = this.createViewContainer();
this.sashContainer = this.createSashContainer();
this.element.appendChild(this.sashContainer);
this.element.appendChild(this.viewContainer);
this.container.appendChild(this.element);
this.style(options.styles);
// We have an existing set of view, add them now
if (options.descriptor) {
this._size = options.descriptor.size;
options.descriptor.views.forEach((viewDescriptor, index) => {
const sizing = viewDescriptor.visible === undefined ||
viewDescriptor.visible
? viewDescriptor.size
: {
type: 'invisible',
cachedVisibleSize: viewDescriptor.size,
};
const view = viewDescriptor.view;
this.addView(view, sizing, index, true
// true skip layout
);
});
// Initialize content size and proportions for first layout
this.contentSize = this.views.reduce((r, i) => r + i.size, 0);
this.saveProportions();
}
}
get size() {
return this._size;
}
set size(value) {
this._size = value;
}
get orthogonalSize() {
return this._orthogonalSize;
}
set orthogonalSize(value) {
this._orthogonalSize = value;
}
get length() {
return this.views.length;
}
get proportions() {
return this._proportions ? [...this._proportions] : undefined;
}
get orientation() {
return this._orientation;
}
set orientation(value) {
this._orientation = value;
const tmp = this.size;
this.size = this.orthogonalSize;
this.orthogonalSize = tmp;
removeClasses(this.element, 'horizontal', 'vertical');
this.element.classList.add(this.orientation == Orientation.HORIZONTAL
? 'horizontal'
: 'vertical');
}
get minimumSize() {
return this.views.reduce((r, item) => r + item.minimumSize, 0);
}
get maximumSize() {
return this.length === 0
? Number.POSITIVE_INFINITY
: this.views.reduce((r, item) => r + item.maximumSize, 0);
}
get startSnappingEnabled() {
return this._startSnappingEnabled;
}
set startSnappingEnabled(startSnappingEnabled) {
if (this._startSnappingEnabled === startSnappingEnabled) {
return;
}
this._startSnappingEnabled = startSnappingEnabled;
this.updateSashEnablement();
}
get endSnappingEnabled() {
return this._endSnappingEnabled;
}
set endSnappingEnabled(endSnappingEnabled) {
if (this._endSnappingEnabled === endSnappingEnabled) {
return;
}
this._endSnappingEnabled = endSnappingEnabled;
this.updateSashEnablement();
}
style(styles) {
if ((styles === null || styles === void 0 ? void 0 : styles.separatorBorder) === 'transparent') {
removeClasses(this.element, 'separator-border');
this.element.style.removeProperty('--dv-separator-border');
}
else {
addClasses(this.element, 'separator-border');
if (styles === null || styles === void 0 ? void 0 : styles.separatorBorder) {
this.element.style.setProperty('--dv-separator-border', styles.separatorBorder);
}
}
}
isViewVisible(index) {
if (index < 0 || index >= this.views.length) {
throw new Error('Index out of bounds');
}
const viewItem = this.views[index];
return viewItem.visible;
}
setViewVisible(index, visible) {
if (index < 0 || index >= this.views.length) {
throw new Error('Index out of bounds');
}
toggleClass(this.container, 'visible', visible);
const viewItem = this.views[index];
toggleClass(this.container, 'visible', visible);
viewItem.setVisible(visible, viewItem.size);
this.distributeEmptySpace(index);
this.layoutViews();
this.saveProportions();
}
getViewSize(index) {
if (index < 0 || index >= this.views.length) {
return -1;
}
return this.views[index].size;
}
resizeView(index, size) {
if (index < 0 || index >= this.views.length) {
return;
}
const indexes = range(this.views.length).filter((i) => i !== index);
const lowPriorityIndexes = [
...indexes.filter((i) => this.views[i].priority === LayoutPriority.Low),
index,
];
const highPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.High);
const item = this.views[index];
size = Math.round(size);
size = clamp(size, item.minimumSize, Math.min(item.maximumSize, this._size));
item.size = size;
this.relayout(lowPriorityIndexes, highPriorityIndexes);
}
getViews() {
return this.views.map((x) => x.view);
}
onDidChange(item, size) {
const index = this.views.indexOf(item);
if (index < 0 || index >= this.views.length) {
return;
}
size = typeof size === 'number' ? size : item.size;
size = clamp(size, item.minimumSize, item.maximumSize);
item.size = size;
this.relayout([index]);
}
addView(view, size = { type: 'distribute' }, index = this.views.length, skipLayout) {
const container = document.createElement('div');
container.className = 'view';
container.appendChild(view.element);
let viewSize;
if (typeof size === 'number') {
viewSize = size;
}
else if (size.type === 'split') {
viewSize = this.getViewSize(size.index) / 2;
}
else if (size.type === 'invisible') {
viewSize = { cachedVisibleSize: size.cachedVisibleSize };
}
else {
viewSize = view.minimumSize;
}
const disposable = view.onDidChange((newSize) => this.onDidChange(viewItem, newSize));
const dispose = () => {
disposable === null || disposable === void 0 ? void 0 : disposable.dispose();
this.viewContainer.removeChild(container);
};
const viewItem = new ViewItem(container, view, viewSize, { dispose });
if (index === this.views.length) {
this.viewContainer.appendChild(container);
}
else {
this.viewContainer.insertBefore(container, this.viewContainer.children.item(index));
}
this.views.splice(index, 0, viewItem);
if (this.views.length > 1) {
//add sash
const sash = document.createElement('div');
sash.className = 'sash';
const onStart = (event) => {
for (const item of this.views) {
item.enabled = false;
}
const iframes = [
...getElementsByTagName('iframe'),
...getElementsByTagName('webview'),
];
for (const iframe of iframes) {
iframe.style.pointerEvents = 'none';
}
const start = this._orientation === Orientation.HORIZONTAL
? event.clientX
: event.clientY;
const sashIndex = firstIndex(this.sashes, (s) => s.container === sash);
//
const sizes = this.views.map((x) => x.size);
//
let snapBefore;
let snapAfter;
const upIndexes = range(sashIndex, -1);
const downIndexes = range(sashIndex + 1, this.views.length);
const minDeltaUp = upIndexes.reduce((r, i) => r + (this.views[i].minimumSize - sizes[i]), 0);
const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.views[i].viewMaximumSize - sizes[i]), 0);
const maxDeltaDown = downIndexes.length === 0
? Number.POSITIVE_INFINITY
: downIndexes.reduce((r, i) => r + (sizes[i] - this.views[i].minimumSize), 0);
const minDeltaDown = downIndexes.length === 0
? Number.NEGATIVE_INFINITY
: downIndexes.reduce((r, i) => r +
(sizes[i] - this.views[i].viewMaximumSize), 0);
const minDelta = Math.max(minDeltaUp, minDeltaDown);
const maxDelta = Math.min(maxDeltaDown, maxDeltaUp);
const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
const snapAfterIndex = this.findFirstSnapIndex(downIndexes);
if (typeof snapBeforeIndex === 'number') {
const snappedViewItem = this.views[snapBeforeIndex];
const halfSize = Math.floor(snappedViewItem.viewMinimumSize / 2);
snapBefore = {
index: snapBeforeIndex,
limitDelta: snappedViewItem.visible
? minDelta - halfSize
: minDelta + halfSize,
size: snappedViewItem.size,
};
}
if (typeof snapAfterIndex === 'number') {
const snappedViewItem = this.views[snapAfterIndex];
const halfSize = Math.floor(snappedViewItem.viewMinimumSize / 2);
snapAfter = {
index: snapAfterIndex,
limitDelta: snappedViewItem.visible
? maxDelta + halfSize
: maxDelta - halfSize,
size: snappedViewItem.size,
};
}
//
const mousemove = (mousemoveEvent) => {
const current = this._orientation === Orientation.HORIZONTAL
? mousemoveEvent.clientX
: mousemoveEvent.clientY;
const delta = current - start;
this.resize(sashIndex, delta, sizes, undefined, undefined, minDelta, maxDelta, snapBefore, snapAfter);
this.distributeEmptySpace();
this.layoutViews();
};
const end = () => {
for (const item of this.views) {
item.enabled = true;
}
for (const iframe of iframes) {
iframe.style.pointerEvents = 'auto';
}
this.saveProportions();
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', end);
document.removeEventListener('mouseend', end);
this._onDidSashEnd.fire(undefined);
};
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', end);
document.addEventListener('mouseend', end);
};
sash.addEventListener('mousedown', onStart);
const sashItem = {
container: sash,
disposable: () => {
sash.removeEventListener('mousedown', onStart);
this.sashContainer.removeChild(sash);
},
};
this.sashContainer.appendChild(sash);
this.sashes.push(sashItem);
}
if (!skipLayout) {
this.relayout([index]);
}
if (!skipLayout &&
typeof size !== 'number' &&
size.type === 'distribute') {
this.distributeViewSizes();
}
this._onDidAddView.fire(view);
}
distributeViewSizes() {
const flexibleViewItems = [];
let flexibleSize = 0;
for (const item of this.views) {
if (item.maximumSize - item.minimumSize > 0) {
flexibleViewItems.push(item);
flexibleSize += item.size;
}
}
const size = Math.floor(flexibleSize / flexibleViewItems.length);
for (const item of flexibleViewItems) {
item.size = clamp(size, item.minimumSize, item.maximumSize);
}
const indexes = range(this.views.length);
const lowPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.Low);
const highPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.High);
this.relayout(lowPriorityIndexes, highPriorityIndexes);
}
removeView(index, sizing, skipLayout = false) {
// Remove view
const viewItem = this.views.splice(index, 1)[0];
viewItem.dispose();
// Remove sash
if (this.views.length >= 1) {
const sashIndex = Math.max(index - 1, 0);
const sashItem = this.sashes.splice(sashIndex, 1)[0];
sashItem.disposable();
}
if (!skipLayout) {
this.relayout();
}
if (sizing && sizing.type === 'distribute') {
this.distributeViewSizes();
}
this._onDidRemoveView.fire(viewItem.view);
return viewItem.view;
}
getViewCachedVisibleSize(index) {
if (index < 0 || index >= this.views.length) {
throw new Error('Index out of bounds');
}
const viewItem = this.views[index];
return viewItem.cachedVisibleSize;
}
moveView(from, to) {
const cachedVisibleSize = this.getViewCachedVisibleSize(from);
const sizing = typeof cachedVisibleSize === 'undefined'
? this.getViewSize(from)
: Sizing.Invisible(cachedVisibleSize);
const view = this.removeView(from, undefined, true);
this.addView(view, sizing, to);
}
layout(size, orthogonalSize) {
const previousSize = Math.max(this.size, this.contentSize);
this.size = size;
this.orthogonalSize = orthogonalSize;
if (!this.proportions) {
const indexes = range(this.views.length);
const lowPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.Low);
const highPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.High);
this.resize(this.views.length - 1, size - previousSize, undefined, lowPriorityIndexes, highPriorityIndexes);
}
else {
for (let i = 0; i < this.views.length; i++) {
const item = this.views[i];
item.size = clamp(Math.round(this.proportions[i] * size), item.minimumSize, item.maximumSize);
}
}
this.distributeEmptySpace();
this.layoutViews();
}
relayout(lowPriorityIndexes, highPriorityIndexes) {
const contentSize = this.views.reduce((r, i) => r + i.size, 0);
this.resize(this.views.length - 1, this._size - contentSize, undefined, lowPriorityIndexes, highPriorityIndexes);
this.distributeEmptySpace();
this.layoutViews();
this.saveProportions();
}
distributeEmptySpace(lowPriorityIndex) {
const contentSize = this.views.reduce((r, i) => r + i.size, 0);
let emptyDelta = this.size - contentSize;
const indexes = range(this.views.length - 1, -1);
const lowPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.Low);
const highPriorityIndexes = indexes.filter((i) => this.views[i].priority === LayoutPriority.High);
for (const index of highPriorityIndexes) {
pushToStart(indexes, index);
}
for (const index of lowPriorityIndexes) {
pushToEnd(indexes, index);
}
if (typeof lowPriorityIndex === 'number') {
pushToEnd(indexes, lowPriorityIndex);
}
for (let i = 0; emptyDelta !== 0 && i < indexes.length; i++) {
const item = this.views[indexes[i]];
const size = clamp(item.size + emptyDelta, item.minimumSize, item.maximumSize);
const viewDelta = size - item.size;
emptyDelta -= viewDelta;
item.size = size;
}
}
saveProportions() {
if (this.proportionalLayout && this.contentSize > 0) {
this._proportions = this.views.map((i) => i.size / this.contentSize);
}
}
layoutViews() {
this.contentSize = this.views.reduce((r, i) => r + i.size, 0);
let sum = 0;
const x = [];
this.updateSashEnablement();
for (let i = 0; i < this.views.length - 1; i++) {
sum += this.views[i].size;
x.push(sum);
const offset = Math.min(Math.max(0, sum - 2), this.size - 4);
if (this._orientation === Orientation.HORIZONTAL) {
this.sashes[i].container.style.left = `${offset}px`;
this.sashes[i].container.style.top = `0px`;
}
if (this._orientation === Orientation.VERTICAL) {
this.sashes[i].container.style.left = `0px`;
this.sashes[i].container.style.top = `${offset}px`;
}
}
this.views.forEach((view, i) => {
if (this._orientation === Orientation.HORIZONTAL) {
view.container.style.width = `${view.size}px`;
view.container.style.left = i == 0 ? '0px' : `${x[i - 1]}px`;
view.container.style.top = '';
view.container.style.height = '';
}
if (this._orientation === Orientation.VERTICAL) {
view.container.style.height = `${view.size}px`;
view.container.style.top = i == 0 ? '0px' : `${x[i - 1]}px`;
view.container.style.width = '';
view.container.style.left = '';
}
view.view.layout(view.size, this._orthogonalSize);
});
}
findFirstSnapIndex(indexes) {
// visible views first
for (const index of indexes) {
const viewItem = this.views[index];
if (!viewItem.visible) {
continue;
}
if (viewItem.snap) {
return index;
}
}
// then, hidden views
for (const index of indexes) {
const viewItem = this.views[index];
if (viewItem.visible &&
viewItem.maximumSize - viewItem.minimumSize > 0) {
return undefined;
}
if (!viewItem.visible && viewItem.snap) {
return index;
}
}
return undefined;
}
updateSashEnablement() {
let previous = false;
const collapsesDown = this.views.map((i) => (previous = i.size - i.minimumSize > 0 || previous));
previous = false;
const expandsDown = this.views.map((i) => (previous = i.maximumSize - i.size > 0 || previous));
const reverseViews = [...this.views].reverse();
previous = false;
const collapsesUp = reverseViews
.map((i) => (previous = i.size - i.minimumSize > 0 || previous))
.reverse();
previous = false;
const expandsUp = reverseViews
.map((i) => (previous = i.maximumSize - i.size > 0 || previous))
.reverse();
let position = 0;
for (let index = 0; index < this.sashes.length; index++) {
const sash = this.sashes[index];
const viewItem = this.views[index];
position += viewItem.size;
const min = !(collapsesDown[index] && expandsUp[index + 1]);
const max = !(expandsDown[index] && collapsesUp[index + 1]);
if (min && max) {
const upIndexes = range(index, -1);
const downIndexes = range(index + 1, this.views.length);
const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
const snapAfterIndex = this.findFirstSnapIndex(downIndexes);
const snappedBefore = typeof snapBeforeIndex === 'number' &&
!this.views[snapBeforeIndex].visible;
const snappedAfter = typeof snapAfterIndex === 'number' &&
!this.views[snapAfterIndex].visible;
if (snappedBefore &&
collapsesUp[index] &&
(position > 0 || this.startSnappingEnabled)) {
this.updateSash(sash, SashState.MINIMUM);
}
else if (snappedAfter &&
collapsesDown[index] &&
(position < this.contentSize || this.endSnappingEnabled)) {
this.updateSash(sash, SashState.MAXIMUM);
}
else {
this.updateSash(sash, SashState.DISABLED);
}
}
else if (min && !max) {
this.updateSash(sash, SashState.MINIMUM);
}
else if (!min && max) {
this.updateSash(sash, SashState.MAXIMUM);
}
else {
this.updateSash(sash, SashState.ENABLED);
}
}
}
updateSash(sash, state) {
toggleClass(sash.container, 'disabled', state === SashState.DISABLED);
toggleClass(sash.container, 'enabled', state === SashState.ENABLED);
toggleClass(sash.container, 'maximum', state === SashState.MAXIMUM);
toggleClass(sash.container, 'minimum', state === SashState.MINIMUM);
}
createViewContainer() {
const element = document.createElement('div');
element.className = 'view-container';
return element;
}
createSashContainer() {
const element = document.createElement('div');
element.className = 'sash-container';
return element;
}
createContainer() {
const element = document.createElement('div');
const orientationClassname = this._orientation === Orientation.HORIZONTAL
? 'horizontal'
: 'vertical';
element.className = `split-view-container ${orientationClassname}`;
return element;
}
dispose() {
this._onDidSashEnd.dispose();
this._onDidAddView.dispose();
this._onDidRemoveView.dispose();
this.element.remove();
for (let i = 0; i < this.element.children.length; i++) {
if (this.element.children.item(i) === this.element) {
this.element.removeChild(this.element);
break;
}
}
}
}