dockview-core
Version:
Zero dependency layout manager supporting tabs, grids and splitviews
635 lines (634 loc) • 25.7 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/grid
*--------------------------------------------------------------------------------------------*/
import { Orientation, Sizing, } from '../splitview/splitview';
import { tail } from '../array';
import { LeafNode } from './leafNode';
import { BranchNode } from './branchNode';
import { Emitter } from '../events';
import { MutableDisposable } from '../lifecycle';
function findLeaf(candiateNode, last) {
if (candiateNode instanceof LeafNode) {
return candiateNode;
}
if (candiateNode instanceof BranchNode) {
return findLeaf(candiateNode.children[last ? candiateNode.children.length - 1 : 0], last);
}
throw new Error('invalid node');
}
function flipNode(node, size, orthogonalSize) {
if (node instanceof BranchNode) {
const result = new BranchNode(orthogonal(node.orientation), node.proportionalLayout, node.styles, size, orthogonalSize, node.disabled, node.margin);
let totalSize = 0;
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
const childSize = child instanceof BranchNode ? child.orthogonalSize : child.size;
let newSize = node.size === 0
? 0
: Math.round((size * childSize) / node.size);
totalSize += newSize;
// The last view to add should adjust to rounding errors
if (i === 0) {
newSize += size - totalSize;
}
result.addChild(flipNode(child, orthogonalSize, newSize), newSize, 0, true);
}
return result;
}
else {
return new LeafNode(node.view, orthogonal(node.orientation), orthogonalSize);
}
}
export function indexInParent(element) {
const parentElement = element.parentElement;
if (!parentElement) {
throw new Error('Invalid grid element');
}
let el = parentElement.firstElementChild;
let index = 0;
while (el !== element && el !== parentElement.lastElementChild && el) {
el = el.nextElementSibling;
index++;
}
return index;
}
/**
* Find the grid location of a specific DOM element by traversing the parent
* chain and finding each child index on the way.
*
* This will break as soon as DOM structures of the Splitview or Gridview change.
*/
export function getGridLocation(element) {
const parentElement = element.parentElement;
if (!parentElement) {
throw new Error('Invalid grid element');
}
if (/\bdv-grid-view\b/.test(parentElement.className)) {
return [];
}
const index = indexInParent(parentElement);
const ancestor = parentElement.parentElement.parentElement.parentElement;
return [...getGridLocation(ancestor), index];
}
export function getRelativeLocation(rootOrientation, location, direction) {
const orientation = getLocationOrientation(rootOrientation, location);
const directionOrientation = getDirectionOrientation(direction);
if (orientation === directionOrientation) {
const [rest, _index] = tail(location);
let index = _index;
if (direction === 'right' || direction === 'bottom') {
index += 1;
}
return [...rest, index];
}
else {
const index = direction === 'right' || direction === 'bottom' ? 1 : 0;
return [...location, index];
}
}
export function getDirectionOrientation(direction) {
return direction === 'top' || direction === 'bottom'
? Orientation.VERTICAL
: Orientation.HORIZONTAL;
}
export function getLocationOrientation(rootOrientation, location) {
return location.length % 2 === 0
? orthogonal(rootOrientation)
: rootOrientation;
}
export const orthogonal = (orientation) => orientation === Orientation.HORIZONTAL
? Orientation.VERTICAL
: Orientation.HORIZONTAL;
export function isGridBranchNode(node) {
return !!node.children;
}
const serializeBranchNode = (node, orientation) => {
const size = orientation === Orientation.VERTICAL ? node.box.width : node.box.height;
if (!isGridBranchNode(node)) {
if (typeof node.cachedVisibleSize === 'number') {
return {
type: 'leaf',
data: node.view.toJSON(),
size: node.cachedVisibleSize,
visible: false,
};
}
return { type: 'leaf', data: node.view.toJSON(), size };
}
return {
type: 'branch',
data: node.children.map((c) => serializeBranchNode(c, orthogonal(orientation))),
size,
};
};
export class Gridview {
get length() {
return this._root ? this._root.children.length : 0;
}
get orientation() {
return this.root.orientation;
}
set orientation(orientation) {
if (this.root.orientation === orientation) {
return;
}
const { size, orthogonalSize } = this.root;
this.root = flipNode(this.root, orthogonalSize, size);
this.root.layout(size, orthogonalSize);
}
get width() {
return this.root.width;
}
get height() {
return this.root.height;
}
get minimumWidth() {
return this.root.minimumWidth;
}
get minimumHeight() {
return this.root.minimumHeight;
}
get maximumWidth() {
return this.root.maximumHeight;
}
get maximumHeight() {
return this.root.maximumHeight;
}
get locked() {
return this._locked;
}
set locked(value) {
this._locked = value;
const branch = [this.root];
/**
* simple depth-first-search to cover all nodes
*
* @see https://en.wikipedia.org/wiki/Depth-first_search
*/
while (branch.length > 0) {
const node = branch.pop();
if (node instanceof BranchNode) {
node.disabled = value;
branch.push(...node.children);
}
}
}
get margin() {
return this._margin;
}
set margin(value) {
this._margin = value;
this.root.margin = value;
}
maximizedView() {
var _a;
return (_a = this._maximizedNode) === null || _a === void 0 ? void 0 : _a.leaf.view;
}
hasMaximizedView() {
return this._maximizedNode !== undefined;
}
maximizeView(view) {
var _a;
const location = getGridLocation(view.element);
const [_, node] = this.getNode(location);
if (!(node instanceof LeafNode)) {
return;
}
if (((_a = this._maximizedNode) === null || _a === void 0 ? void 0 : _a.leaf) === node) {
return;
}
if (this.hasMaximizedView()) {
this.exitMaximizedView();
}
serializeBranchNode(this.getView(), this.orientation);
const hiddenOnMaximize = [];
function hideAllViewsBut(parent, exclude) {
for (let i = 0; i < parent.children.length; i++) {
const child = parent.children[i];
if (child instanceof LeafNode) {
if (child !== exclude) {
if (parent.isChildVisible(i)) {
parent.setChildVisible(i, false);
}
else {
hiddenOnMaximize.push(child);
}
}
}
else {
hideAllViewsBut(child, exclude);
}
}
}
hideAllViewsBut(this.root, node);
this._maximizedNode = { leaf: node, hiddenOnMaximize };
this._onDidMaximizedNodeChange.fire({
view: node.view,
isMaximized: true,
});
}
exitMaximizedView() {
if (!this._maximizedNode) {
return;
}
const hiddenOnMaximize = this._maximizedNode.hiddenOnMaximize;
function showViewsInReverseOrder(parent) {
for (let index = parent.children.length - 1; index >= 0; index--) {
const child = parent.children[index];
if (child instanceof LeafNode) {
if (!hiddenOnMaximize.includes(child)) {
parent.setChildVisible(index, true);
}
}
else {
showViewsInReverseOrder(child);
}
}
}
showViewsInReverseOrder(this.root);
const tmp = this._maximizedNode.leaf;
this._maximizedNode = undefined;
this._onDidMaximizedNodeChange.fire({
view: tmp.view,
isMaximized: false,
});
}
serialize() {
const maximizedView = this.maximizedView();
let maxmizedViewLocation;
if (maximizedView) {
/**
* The minimum information we can get away with in order to serialize a maxmized view is it's location within the grid
* which is represented as a branch of indices
*/
maxmizedViewLocation = getGridLocation(maximizedView.element);
}
if (this.hasMaximizedView()) {
/**
* the saved layout cannot be in its maxmized state otherwise all of the underlying
* view dimensions will be wrong
*
* To counteract this we temporaily remove the maximized view to compute the serialized output
* of the grid before adding back the maxmized view as to not alter the layout from the users
* perspective when `.toJSON()` is called
*/
this.exitMaximizedView();
}
const root = serializeBranchNode(this.getView(), this.orientation);
const resullt = {
root,
width: this.width,
height: this.height,
orientation: this.orientation,
};
if (maxmizedViewLocation) {
resullt.maximizedNode = {
location: maxmizedViewLocation,
};
}
if (maximizedView) {
// replace any maximzied view that was removed for serialization purposes
this.maximizeView(maximizedView);
}
return resullt;
}
dispose() {
this.disposable.dispose();
this._onDidChange.dispose();
this._onDidMaximizedNodeChange.dispose();
this._onDidViewVisibilityChange.dispose();
this.root.dispose();
this._maximizedNode = undefined;
this.element.remove();
}
clear() {
const orientation = this.root.orientation;
this.root = new BranchNode(orientation, this.proportionalLayout, this.styles, this.root.size, this.root.orthogonalSize, this.locked, this.margin);
}
deserialize(json, deserializer) {
const orientation = json.orientation;
const height = orientation === Orientation.VERTICAL ? json.height : json.width;
this._deserialize(json.root, orientation, deserializer, height);
/**
* The deserialied layout must be positioned through this.layout(...)
* before any maximizedNode can be positioned
*/
this.layout(json.width, json.height);
if (json.maximizedNode) {
const location = json.maximizedNode.location;
const [_, node] = this.getNode(location);
if (!(node instanceof LeafNode)) {
return;
}
this.maximizeView(node.view);
}
}
_deserialize(root, orientation, deserializer, orthogonalSize) {
this.root = this._deserializeNode(root, orientation, deserializer, orthogonalSize);
}
_deserializeNode(node, orientation, deserializer, orthogonalSize) {
var _a;
let result;
if (node.type === 'branch') {
const serializedChildren = node.data;
const children = serializedChildren.map((serializedChild) => {
return {
node: this._deserializeNode(serializedChild, orthogonal(orientation), deserializer, node.size),
visible: serializedChild.visible,
};
});
result = new BranchNode(orientation, this.proportionalLayout, this.styles, node.size, // <- orthogonal size - flips at each depth
orthogonalSize, // <- size - flips at each depth,
this.locked, this.margin, children);
}
else {
const view = deserializer.fromJSON(node);
if (typeof node.visible === 'boolean') {
(_a = view.setVisible) === null || _a === void 0 ? void 0 : _a.call(view, node.visible);
}
result = new LeafNode(view, orientation, orthogonalSize, node.size);
}
return result;
}
get root() {
return this._root;
}
set root(root) {
const oldRoot = this._root;
if (oldRoot) {
oldRoot.dispose();
this._maximizedNode = undefined;
this.element.removeChild(oldRoot.element);
}
this._root = root;
this.element.appendChild(this._root.element);
this.disposable.value = this._root.onDidChange((e) => {
this._onDidChange.fire(e);
});
}
/**
* If the root is orientated as a VERTICAL node then nest the existing root within a new HORIZIONTAL root node
* If the root is orientated as a HORIZONTAL node then nest the existing root within a new VERITCAL root node
*/
insertOrthogonalSplitviewAtRoot() {
if (!this._root) {
return;
}
const oldRoot = this.root;
oldRoot.element.remove();
this._root = new BranchNode(orthogonal(oldRoot.orientation), this.proportionalLayout, this.styles, this.root.orthogonalSize, this.root.size, this.locked, this.margin);
if (oldRoot.children.length === 0) {
// no data so no need to add anything back in
}
else if (oldRoot.children.length === 1) {
// can remove one level of redundant branching if there is only a single child
const childReference = oldRoot.children[0];
const child = oldRoot.removeChild(0); // remove to prevent disposal when disposing of unwanted root
child.dispose();
oldRoot.dispose();
this._root.addChild(
/**
* the child node will have the same orientation as the new root since
* we are removing the inbetween node.
* the entire 'tree' must be flipped recursively to ensure that the orientation
* flips at each level
*/
flipNode(childReference, childReference.orthogonalSize, childReference.size), Sizing.Distribute, 0);
}
else {
this._root.addChild(oldRoot, Sizing.Distribute, 0);
}
this.element.appendChild(this._root.element);
this.disposable.value = this._root.onDidChange((e) => {
this._onDidChange.fire(e);
});
}
next(location) {
return this.progmaticSelect(location);
}
previous(location) {
return this.progmaticSelect(location, true);
}
getView(location) {
const node = location ? this.getNode(location)[1] : this.root;
return this._getViews(node, this.orientation);
}
_getViews(node, orientation, cachedVisibleSize) {
const box = { height: node.height, width: node.width };
if (node instanceof LeafNode) {
return { box, view: node.view, cachedVisibleSize };
}
const children = [];
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const nodeCachedVisibleSize = node.getChildCachedVisibleSize(i);
children.push(this._getViews(child, orthogonal(orientation), nodeCachedVisibleSize));
}
return { box, children };
}
progmaticSelect(location, reverse = false) {
const [path, node] = this.getNode(location);
if (!(node instanceof LeafNode)) {
throw new Error('invalid location');
}
for (let i = path.length - 1; i > -1; i--) {
const n = path[i];
const l = location[i] || 0;
const canProgressInCurrentLevel = reverse
? l - 1 > -1
: l + 1 < n.children.length;
if (canProgressInCurrentLevel) {
return findLeaf(n.children[reverse ? l - 1 : l + 1], reverse);
}
}
return findLeaf(this.root, reverse);
}
constructor(proportionalLayout, styles, orientation, locked, margin) {
this.proportionalLayout = proportionalLayout;
this.styles = styles;
this._locked = false;
this._margin = 0;
this._maximizedNode = undefined;
this.disposable = new MutableDisposable();
this._onDidChange = new Emitter();
this.onDidChange = this._onDidChange.event;
this._onDidViewVisibilityChange = new Emitter();
this.onDidViewVisibilityChange = this._onDidViewVisibilityChange.event;
this._onDidMaximizedNodeChange = new Emitter();
this.onDidMaximizedNodeChange = this._onDidMaximizedNodeChange.event;
this.element = document.createElement('div');
this.element.className = 'dv-grid-view';
this._locked = locked !== null && locked !== void 0 ? locked : false;
this._margin = margin !== null && margin !== void 0 ? margin : 0;
this.root = new BranchNode(orientation, proportionalLayout, styles, 0, 0, this.locked, this.margin);
}
isViewVisible(location) {
const [rest, index] = tail(location);
const [, parent] = this.getNode(rest);
if (!(parent instanceof BranchNode)) {
throw new Error('Invalid from location');
}
return parent.isChildVisible(index);
}
setViewVisible(location, visible) {
if (this.hasMaximizedView()) {
this.exitMaximizedView();
}
const [rest, index] = tail(location);
const [, parent] = this.getNode(rest);
if (!(parent instanceof BranchNode)) {
throw new Error('Invalid from location');
}
this._onDidViewVisibilityChange.fire();
parent.setChildVisible(index, visible);
}
moveView(parentLocation, from, to) {
if (this.hasMaximizedView()) {
this.exitMaximizedView();
}
const [, parent] = this.getNode(parentLocation);
if (!(parent instanceof BranchNode)) {
throw new Error('Invalid location');
}
parent.moveChild(from, to);
}
addView(view, size, location) {
if (this.hasMaximizedView()) {
this.exitMaximizedView();
}
const [rest, index] = tail(location);
const [pathToParent, parent] = this.getNode(rest);
if (parent instanceof BranchNode) {
const node = new LeafNode(view, orthogonal(parent.orientation), parent.orthogonalSize);
parent.addChild(node, size, index);
}
else {
const [grandParent, ..._] = [...pathToParent].reverse();
const [parentIndex, ...__] = [...rest].reverse();
let newSiblingSize = 0;
const newSiblingCachedVisibleSize = grandParent.getChildCachedVisibleSize(parentIndex);
if (typeof newSiblingCachedVisibleSize === 'number') {
newSiblingSize = Sizing.Invisible(newSiblingCachedVisibleSize);
}
const child = grandParent.removeChild(parentIndex);
child.dispose();
const newParent = new BranchNode(parent.orientation, this.proportionalLayout, this.styles, parent.size, parent.orthogonalSize, this.locked, this.margin);
grandParent.addChild(newParent, parent.size, parentIndex);
const newSibling = new LeafNode(parent.view, grandParent.orientation, parent.size);
newParent.addChild(newSibling, newSiblingSize, 0);
if (typeof size !== 'number' && size.type === 'split') {
size = { type: 'split', index: 0 };
}
const node = new LeafNode(view, grandParent.orientation, parent.size);
newParent.addChild(node, size, index);
}
}
remove(view, sizing) {
const location = getGridLocation(view.element);
return this.removeView(location, sizing);
}
removeView(location, sizing) {
if (this.hasMaximizedView()) {
this.exitMaximizedView();
}
const [rest, index] = tail(location);
const [pathToParent, parent] = this.getNode(rest);
if (!(parent instanceof BranchNode)) {
throw new Error('Invalid location');
}
const nodeToRemove = parent.children[index];
if (!(nodeToRemove instanceof LeafNode)) {
throw new Error('Invalid location');
}
parent.removeChild(index, sizing);
nodeToRemove.dispose();
if (parent.children.length !== 1) {
return nodeToRemove.view;
}
// if the parent has only one child and we know the parent is a BranchNode we can make the tree
// more efficiently spaced by replacing the parent BranchNode with the child.
// if that child is a LeafNode then we simply replace the BranchNode with the child otherwise if the child
// is a BranchNode too we should spread it's children into the grandparent.
// refer to the remaining child as the sibling
const sibling = parent.children[0];
if (pathToParent.length === 0) {
// if the parent is root
if (sibling instanceof LeafNode) {
// if the sibling is a leaf node no action is required
return nodeToRemove.view;
}
// otherwise the sibling is a branch node. since the parent is the root and the root has only one child
// which is a branch node we can just set this branch node to be the new root node
// for good housekeeping we'll removing the sibling from it's existing tree
parent.removeChild(0, sizing);
// and set that sibling node to be root
this.root = sibling;
return nodeToRemove.view;
}
// otherwise the parent is apart of a large sub-tree
const [grandParent, ..._] = [...pathToParent].reverse();
const [parentIndex, ...__] = [...rest].reverse();
const isSiblingVisible = parent.isChildVisible(0);
// either way we need to remove the sibling from it's existing tree
parent.removeChild(0, sizing);
// note the sizes of all of the grandparents children
const sizes = grandParent.children.map((_size, i) => grandParent.getChildSize(i));
// remove the parent from the grandparent since we are moving the sibling to take the parents place
// this parent is no longer used and can be disposed of
grandParent.removeChild(parentIndex, sizing).dispose();
if (sibling instanceof BranchNode) {
// replace the parent with the siblings children
sizes.splice(parentIndex, 1, ...sibling.children.map((c) => c.size));
// and add those siblings to the grandparent
for (let i = 0; i < sibling.children.length; i++) {
const child = sibling.children[i];
grandParent.addChild(child, child.size, parentIndex + i);
}
/**
* clean down the branch node since we need to dipose of it and
* when .dispose() it called on a branch it will dispose of any
* views it is holding onto.
*/
while (sibling.children.length > 0) {
sibling.removeChild(0);
}
}
else {
// otherwise create a new leaf node and add that to the grandparent
const newSibling = new LeafNode(sibling.view, orthogonal(sibling.orientation), sibling.size);
const siblingSizing = isSiblingVisible
? sibling.orthogonalSize
: Sizing.Invisible(sibling.orthogonalSize);
grandParent.addChild(newSibling, siblingSizing, parentIndex);
}
// the containing node of the sibling is no longer required and can be disposed of
sibling.dispose();
// resize everything
for (let i = 0; i < sizes.length; i++) {
grandParent.resizeChild(i, sizes[i]);
}
return nodeToRemove.view;
}
layout(width, height) {
const [size, orthogonalSize] = this.root.orientation === Orientation.HORIZONTAL
? [height, width]
: [width, height];
this.root.layout(size, orthogonalSize);
}
getNode(location, node = this.root, path = []) {
if (location.length === 0) {
return [path, node];
}
if (!(node instanceof BranchNode)) {
throw new Error('Invalid location');
}
const [index, ...rest] = location;
if (index < 0 || index >= node.children.length) {
throw new Error('Invalid location');
}
const child = node.children[index];
path.push(node);
return this.getNode(rest, child, path);
}
}