flexlayout-react
Version:
A multi-tab docking layout manager
705 lines (632 loc) • 28.9 kB
text/typescript
import { Attribute } from "../Attribute";
import { AttributeDefinitions } from "../AttributeDefinitions";
import { DockLocation } from "../DockLocation";
import { DropInfo } from "../DropInfo";
import { Rect } from "../Rect";
import { Action } from "./Action";
import { Actions } from "./Actions";
import { BorderNode } from "./BorderNode";
import { BorderSet } from "./BorderSet";
import { IDraggable } from "./IDraggable";
import { IDropTarget } from "./IDropTarget";
import { IJsonModel, IJsonPopout, ITabSetAttributes } from "./IJsonModel";
import { Node } from "./Node";
import { RowNode } from "./RowNode";
import { TabNode } from "./TabNode";
import { TabSetNode } from "./TabSetNode";
import { randomUUID } from "./Utils";
import { LayoutWindow } from "./LayoutWindow";
/** @internal */
export const DefaultMin = 0;
/** @internal */
export const DefaultMax = 99999;
/**
* Class containing the Tree of Nodes used by the FlexLayout component
*/
export class Model {
static MAIN_WINDOW_ID = "__main_window_id__";
/** @internal */
private static attributeDefinitions: AttributeDefinitions = Model.createAttributeDefinitions();
/** @internal */
private attributes: Record<string, any>;
/** @internal */
private idMap: Map<string, Node>;
/** @internal */
private changeListeners: ((action: Action) => void)[];
/** @internal */
private borders: BorderSet;
/** @internal */
private onAllowDrop?: (dragNode: Node, dropInfo: DropInfo) => boolean;
/** @internal */
private onCreateTabSet?: (tabNode?: TabNode) => ITabSetAttributes;
/** @internal */
private windows: Map<string, LayoutWindow>;
/** @internal */
private rootWindow: LayoutWindow;
/**
* 'private' constructor. Use the static method Model.fromJson(json) to create a model
* @internal
*/
protected constructor() {
this.attributes = {};
this.idMap = new Map();
this.borders = new BorderSet(this);
this.windows = new Map<string, LayoutWindow>();
this.rootWindow = new LayoutWindow(Model.MAIN_WINDOW_ID, Rect.empty());
this.windows.set(Model.MAIN_WINDOW_ID, this.rootWindow);
this.changeListeners = [];
}
/**
* Update the node tree by performing the given action,
* Actions should be generated via static methods on the Actions class
* @param action the action to perform
* @returns added Node for Actions.addNode, windowId for createWindow
*/
doAction(action: Action): any {
let returnVal = undefined;
// console.log(action);
switch (action.type) {
case Actions.ADD_NODE: {
const newNode = new TabNode(this, action.data.json, true);
const toNode = this.idMap.get(action.data.toNode) as Node & IDraggable;
if (toNode instanceof TabSetNode || toNode instanceof BorderNode || toNode instanceof RowNode) {
toNode.drop(newNode, DockLocation.getByName(action.data.location), action.data.index, action.data.select);
returnVal = newNode;
}
break;
}
case Actions.MOVE_NODE: {
const fromNode = this.idMap.get(action.data.fromNode) as Node & IDraggable;
if (fromNode instanceof TabNode || fromNode instanceof TabSetNode || fromNode instanceof RowNode) {
if (fromNode === this.getMaximizedTabset(fromNode.getWindowId())) {
const fromWindow = this.windows.get(fromNode.getWindowId())!;
fromWindow.maximizedTabSet = undefined;
}
const toNode = this.idMap.get(action.data.toNode) as Node & IDropTarget;
if (toNode instanceof TabSetNode || toNode instanceof BorderNode || toNode instanceof RowNode) {
toNode.drop(fromNode, DockLocation.getByName(action.data.location), action.data.index, action.data.select);
}
}
this.removeEmptyWindows();
break;
}
case Actions.DELETE_TAB: {
const node = this.idMap.get(action.data.node);
if (node instanceof TabNode) {
node.delete();
}
this.removeEmptyWindows();
break;
}
case Actions.DELETE_TABSET: {
const node = this.idMap.get(action.data.node);
if (node instanceof TabSetNode) {
// first delete all child tabs that are closeable
const children = [...node.getChildren()];
for (let i = 0; i < children.length; i++) {
const child = children[i];
if ((child as TabNode).isEnableClose()) {
(child as TabNode).delete();
}
}
if (node.getChildren().length === 0) {
node.delete();
}
this.tidy();
}
this.removeEmptyWindows();
break;
}
case Actions.POPOUT_TABSET: {
const node = this.idMap.get(action.data.node);
if (node instanceof TabSetNode) {
const isMaximized = node.isMaximized();
const oldLayoutWindow = this.windows.get(node.getWindowId())!;
const windowId = randomUUID()
const layoutWindow = new LayoutWindow(windowId, oldLayoutWindow.toScreenRectFunction(node.getRect()));
const json = {
type: "row",
children: []
}
const row = RowNode.fromJson(json, this, layoutWindow);
layoutWindow.root = row;
this.windows.set(windowId, layoutWindow);
row.drop(node, DockLocation.CENTER, 0);
if (isMaximized) {
this.rootWindow.maximizedTabSet = undefined;
}
}
this.removeEmptyWindows();
break;
}
case Actions.POPOUT_TAB: {
const node = this.idMap.get(action.data.node);
if (node instanceof TabNode) {
const windowId = randomUUID()
let r = Rect.empty();
if (node.getParent() instanceof TabSetNode) {
r = node.getParent()!.getRect();
} else {
r = (node.getParent() as BorderNode).getContentRect();
}
const oldLayoutWindow = this.windows.get(node.getWindowId())!;
const layoutWindow = new LayoutWindow(windowId, oldLayoutWindow.toScreenRectFunction(r));
const tabsetId = randomUUID();
const json = {
type: "row",
children: [
{ type: "tabset", id: tabsetId }
]
}
const row = RowNode.fromJson(json, this, layoutWindow);
layoutWindow.root = row;
this.windows.set(windowId, layoutWindow);
const tabset = this.idMap.get(tabsetId) as TabSetNode & IDropTarget;
tabset.drop(node, DockLocation.CENTER, 0, true);
}
this.removeEmptyWindows();
break;
}
case Actions.CLOSE_WINDOW: {
const window = this.windows.get(action.data.windowId);
if (window) {
this.rootWindow.root?.drop(window?.root!, DockLocation.CENTER, -1);
this.rootWindow.visitNodes((node, level) => {
if (node instanceof RowNode) {
node.setWindowId(Model.MAIN_WINDOW_ID);
}
})
// this.getFirstTabSet().drop(window?.root!,DockLocation.CENTER, -1);
this.windows.delete(action.data.windowId);
}
break;
}
case Actions.CREATE_WINDOW: {
const windowId = randomUUID();
const layoutWindow = new LayoutWindow(windowId, Rect.fromJson(action.data.rect));
const row = RowNode.fromJson(action.data.layout, this, layoutWindow);
layoutWindow.root = row;
this.windows.set(windowId, layoutWindow);
returnVal = windowId;
break;
}
case Actions.RENAME_TAB: {
const node = this.idMap.get(action.data.node);
if (node instanceof TabNode) {
node.setName(action.data.text);
}
break;
}
case Actions.SELECT_TAB: {
const tabNode = this.idMap.get(action.data.tabNode);
const windowId = action.data.windowId ? action.data.windowId : Model.MAIN_WINDOW_ID;
const window = this.windows.get(windowId)!;
if (tabNode instanceof TabNode) {
const parent = tabNode.getParent() as Node;
const pos = parent.getChildren().indexOf(tabNode);
if (parent instanceof BorderNode) {
if (parent.getSelected() === pos) {
parent.setSelected(-1);
} else {
parent.setSelected(pos);
}
} else if (parent instanceof TabSetNode) {
if (parent.getSelected() !== pos) {
parent.setSelected(pos);
}
window.activeTabSet = parent;
}
}
break;
}
case Actions.SET_ACTIVE_TABSET: {
const windowId = action.data.windowId ? action.data.windowId : Model.MAIN_WINDOW_ID;
const window = this.windows.get(windowId)!;
if (action.data.tabsetNode === undefined) {
window.activeTabSet = undefined;
} else {
const tabsetNode = this.idMap.get(action.data.tabsetNode);
if (tabsetNode instanceof TabSetNode) {
window.activeTabSet = tabsetNode;
}
}
break;
}
case Actions.ADJUST_WEIGHTS: {
const row = this.idMap.get(action.data.nodeId) as RowNode;
const c = row.getChildren();
for (let i = 0; i < c.length; i++) {
const n = c[i] as TabSetNode | RowNode;
n.setWeight(action.data.weights[i]);
}
break;
}
case Actions.ADJUST_BORDER_SPLIT: {
const node = this.idMap.get(action.data.node);
if (node instanceof BorderNode) {
node.setSize(action.data.pos);
}
break;
}
case Actions.MAXIMIZE_TOGGLE: {
const windowId = action.data.windowId ? action.data.windowId : Model.MAIN_WINDOW_ID;
const window = this.windows.get(windowId)!;
const node = this.idMap.get(action.data.node);
if (node instanceof TabSetNode) {
if (node === window.maximizedTabSet) {
window.maximizedTabSet = undefined;
} else {
window.maximizedTabSet = node;
window.activeTabSet = node;
}
}
break;
}
case Actions.UPDATE_MODEL_ATTRIBUTES: {
this.updateAttrs(action.data.json);
break;
}
case Actions.UPDATE_NODE_ATTRIBUTES: {
const node = this.idMap.get(action.data.node)!;
node.updateAttrs(action.data.json);
break;
}
default:
break;
}
this.updateIdMap();
for (const listener of this.changeListeners) {
listener(action);
}
return returnVal;
}
/**
* Get the currently active tabset node
*/
getActiveTabset(windowId: string = Model.MAIN_WINDOW_ID) {
const window = this.windows.get(windowId);
if (window && window.activeTabSet && this.getNodeById(window.activeTabSet.getId())) {
return window.activeTabSet;
} else {
return undefined;
}
}
/**
* Get the currently maximized tabset node
*/
getMaximizedTabset(windowId: string = Model.MAIN_WINDOW_ID) {
return this.windows.get(windowId)!.maximizedTabSet;
}
/**
* Gets the root RowNode of the model
* @returns {RowNode}
*/
getRoot(windowId: string = Model.MAIN_WINDOW_ID) {
return this.windows.get(windowId)!.root!;
}
isRootOrientationVertical() {
return this.attributes.rootOrientationVertical as boolean;
}
isEnableRotateBorderIcons() {
return this.attributes.enableRotateBorderIcons as boolean;
}
/**
* Gets the
* @returns {BorderSet|*}
*/
getBorderSet() {
return this.borders;
}
getwindowsMap() {
return this.windows;
}
/**
* Visits all the nodes in the model and calls the given function for each
* @param fn a function that takes visited node and a integer level as parameters
*/
visitNodes(fn: (node: Node, level: number) => void) {
this.borders.forEachNode(fn);
for (const [_, w] of this.windows) {
w.root!.forEachNode(fn, 0);
}
}
visitWindowNodes(windowId: string, fn: (node: Node, level: number) => void) {
if (this.windows.has(windowId)) {
if (windowId === Model.MAIN_WINDOW_ID) {
this.borders.forEachNode(fn);
}
this.windows.get(windowId)!.visitNodes(fn);
}
}
/**
* Gets a node by its id
* @param id the id to find
*/
getNodeById(id: string): Node | undefined {
return this.idMap.get(id);
}
/**
* Finds the first/top left tab set of the given node.
* @param node The top node you want to begin searching from, deafults to the root node
* @returns The first Tab Set
*/
getFirstTabSet(node = this.windows.get(Model.MAIN_WINDOW_ID)!.root as Node): TabSetNode {
const child = node.getChildren()[0];
if (child instanceof TabSetNode) {
return child;
}
else {
return this.getFirstTabSet(child);
}
}
/**
* Loads the model from the given json object
* @param json the json model to load
* @returns {Model} a new Model object
*/
static fromJson(json: IJsonModel) {
const model = new Model();
Model.attributeDefinitions.fromJson(json.global, model.attributes);
if (json.borders) {
model.borders = BorderSet.fromJson(json.borders, model);
}
if (json.popouts) {
for (const windowId in json.popouts) {
const windowJson = json.popouts[windowId];
const layoutWindow = LayoutWindow.fromJson(windowJson, model, windowId);
model.windows.set(windowId, layoutWindow);
}
}
model.rootWindow.root = RowNode.fromJson(json.layout, model, model.getwindowsMap().get(Model.MAIN_WINDOW_ID)!);
model.tidy(); // initial tidy of node tree
return model;
}
/**
* Converts the model to a json object
* @returns {IJsonModel} json object that represents this model
*/
toJson(): IJsonModel {
const global: any = {};
Model.attributeDefinitions.toJson(global, this.attributes);
// save state of nodes
this.visitNodes((node) => {
node.fireEvent("save", {});
});
const windows: Record<string, IJsonPopout> = {};
for (const [id, window] of this.windows) {
if (id !== Model.MAIN_WINDOW_ID) {
windows[id] = window.toJson();
}
}
return {
global,
borders: this.borders.toJson(),
layout: this.rootWindow.root!.toJson(),
popouts: windows
};
}
getSplitterSize() {
return this.attributes.splitterSize as number;
}
getSplitterExtra() {
return this.attributes.splitterExtra as number;
}
isEnableEdgeDock() {
return this.attributes.enableEdgeDock as boolean;
}
isSplitterEnableHandle() {
return this.attributes.splitterEnableHandle as boolean;
}
/**
* Sets a function to allow/deny dropping a node
* @param onAllowDrop function that takes the drag node and DropInfo and returns true if the drop is allowed
*/
setOnAllowDrop(onAllowDrop: (dragNode: Node, dropInfo: DropInfo) => boolean) {
this.onAllowDrop = onAllowDrop;
}
/**
* set callback called when a new TabSet is created.
* The tabNode can be undefined if it's the auto created first tabset in the root row (when the last
* tab is deleted, the root tabset can be recreated)
* @param onCreateTabSet
*/
setOnCreateTabSet(onCreateTabSet: (tabNode?: TabNode) => ITabSetAttributes) {
this.onCreateTabSet = onCreateTabSet;
}
addChangeListener(listener: ((action: Action) => void)) {
this.changeListeners.push(listener);
}
removeChangeListener(listener: ((action: Action) => void)) {
const pos = this.changeListeners.findIndex(l => l === listener);
if (pos !== -1) {
this.changeListeners.splice(pos, 1);
}
}
toString() {
return JSON.stringify(this.toJson());
}
/***********************internal ********************************/
/** @internal */
removeEmptyWindows() {
const emptyWindows = new Set<string>();
for (const [windowId] of this.windows) {
if (windowId !== Model.MAIN_WINDOW_ID) {
let count = 0;
this.visitWindowNodes(windowId, (node) => {
if (node instanceof TabNode) {
count++;
}
});
if (count === 0) {
emptyWindows.add(windowId);
}
}
}
for (const windowId of emptyWindows) {
this.windows.delete(windowId);
}
}
/** @internal */
setActiveTabset(tabsetNode: TabSetNode | undefined, windowId: string) {
const window = this.windows.get(windowId);
if (window) {
if (tabsetNode) {
window.activeTabSet = tabsetNode;
} else {
window.activeTabSet = undefined;
}
}
}
/** @internal */
setMaximizedTabset(tabsetNode: (TabSetNode | undefined), windowId: string) {
const window = this.windows.get(windowId);
if (window) {
if (tabsetNode) {
window.maximizedTabSet = tabsetNode;
} else {
window.maximizedTabSet = undefined;
}
}
}
/** @internal */
updateIdMap() {
// regenerate idMap to stop it building up
this.idMap.clear();
this.visitNodes((node) => {
this.idMap.set(node.getId(), node)
// if (node instanceof RowNode) {
// node.normalizeWeights();
// }
});
// console.log(JSON.stringify(Object.keys(this._idMap)));
}
/** @internal */
addNode(node: Node) {
const id = node.getId();
if (this.idMap.has(id)) {
throw new Error(`Error: each node must have a unique id, duplicate id:${node.getId()}`);
}
this.idMap.set(id, node);
}
/** @internal */
findDropTargetNode(windowId: string, dragNode: Node & IDraggable, x: number, y: number) {
let node = (this.windows.get(windowId)!.root as RowNode).findDropTargetNode(windowId, dragNode, x, y);
if (node === undefined && windowId === Model.MAIN_WINDOW_ID) {
node = this.borders.findDropTargetNode(dragNode, x, y);
}
return node;
}
/** @internal */
tidy() {
// console.log("before _tidy", this.toString());
for (const [_, window] of this.windows) {
window.root!.tidy();
}
// console.log("after _tidy", this.toString());
}
/** @internal */
updateAttrs(json: any) {
Model.attributeDefinitions.update(json, this.attributes);
}
/** @internal */
nextUniqueId() {
return '#' + randomUUID();
}
/** @internal */
getAttribute(name: string): any {
return this.attributes[name];
}
/** @internal */
getOnAllowDrop() {
return this.onAllowDrop;
}
/** @internal */
getOnCreateTabSet() {
return this.onCreateTabSet;
}
static toTypescriptInterfaces() {
Model.attributeDefinitions.pairAttributes("RowNode", RowNode.getAttributeDefinitions());
Model.attributeDefinitions.pairAttributes("TabSetNode", TabSetNode.getAttributeDefinitions());
Model.attributeDefinitions.pairAttributes("TabNode", TabNode.getAttributeDefinitions());
Model.attributeDefinitions.pairAttributes("BorderNode", BorderNode.getAttributeDefinitions());
let sb = [];
sb.push(Model.attributeDefinitions.toTypescriptInterface("Global", undefined));
sb.push(RowNode.getAttributeDefinitions().toTypescriptInterface("Row", Model.attributeDefinitions));
sb.push(TabSetNode.getAttributeDefinitions().toTypescriptInterface("TabSet", Model.attributeDefinitions));
sb.push(TabNode.getAttributeDefinitions().toTypescriptInterface("Tab", Model.attributeDefinitions));
sb.push(BorderNode.getAttributeDefinitions().toTypescriptInterface("Border", Model.attributeDefinitions));
console.log(sb.join("\n"));
}
/** @internal */
private static createAttributeDefinitions(): AttributeDefinitions {
const attributeDefinitions = new AttributeDefinitions();
attributeDefinitions.add("enableEdgeDock", true).setType(Attribute.BOOLEAN).setDescription(
`enable docking to the edges of the layout, this will show the edge indicators`
);
attributeDefinitions.add("rootOrientationVertical", false).setType(Attribute.BOOLEAN).setDescription(
`the top level 'row' will layout horizontally by default, set this option true to make it layout vertically`
);
attributeDefinitions.add("enableRotateBorderIcons", true).setType(Attribute.BOOLEAN).setDescription(
`boolean indicating if tab icons should rotate with the text in the left and right borders`
);
// splitter
attributeDefinitions.add("splitterSize", 8).setType(Attribute.NUMBER).setDescription(
`width in pixels of all splitters between tabsets/borders`
);
attributeDefinitions.add("splitterExtra", 0).setType(Attribute.NUMBER).setDescription(
`additional width in pixels of the splitter hit test area`
);
attributeDefinitions.add("splitterEnableHandle", false).setType(Attribute.BOOLEAN).setDescription(
`enable a small centralized handle on all splitters`
);
// tab
attributeDefinitions.add("tabEnableClose", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabCloseType", 1).setType("ICloseType");
attributeDefinitions.add("tabEnablePopout", false).setType(Attribute.BOOLEAN).setAlias("tabEnableFloat");
attributeDefinitions.add("tabEnablePopoutIcon", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabEnablePopoutOverlay", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabEnableDrag", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabEnableRename", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabContentClassName", undefined).setType(Attribute.STRING);
attributeDefinitions.add("tabClassName", undefined).setType(Attribute.STRING);
attributeDefinitions.add("tabIcon", undefined).setType(Attribute.STRING);
attributeDefinitions.add("tabEnableRenderOnDemand", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabDragSpeed", 0.3).setType(Attribute.NUMBER);
attributeDefinitions.add("tabBorderWidth", -1).setType(Attribute.NUMBER);
attributeDefinitions.add("tabBorderHeight", -1).setType(Attribute.NUMBER);
// tabset
attributeDefinitions.add("tabSetEnableDeleteWhenEmpty", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableDrop", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableDrag", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableDivide", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableMaximize", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableClose", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableSingleTabStretch", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetAutoSelectTab", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableActiveIcon", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetClassNameTabStrip", undefined).setType(Attribute.STRING);
attributeDefinitions.add("tabSetEnableTabStrip", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetEnableTabWrap", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("tabSetTabLocation", "top").setType("ITabLocation");
attributeDefinitions.add("tabMinWidth", DefaultMin).setType(Attribute.NUMBER);
attributeDefinitions.add("tabMinHeight", DefaultMin).setType(Attribute.NUMBER);
attributeDefinitions.add("tabSetMinWidth", DefaultMin).setType(Attribute.NUMBER);
attributeDefinitions.add("tabSetMinHeight", DefaultMin).setType(Attribute.NUMBER);
attributeDefinitions.add("tabMaxWidth", DefaultMax).setType(Attribute.NUMBER);
attributeDefinitions.add("tabMaxHeight", DefaultMax).setType(Attribute.NUMBER);
attributeDefinitions.add("tabSetMaxWidth", DefaultMax).setType(Attribute.NUMBER);
attributeDefinitions.add("tabSetMaxHeight", DefaultMax).setType(Attribute.NUMBER);
attributeDefinitions.add("tabSetEnableTabScrollbar", false).setType(Attribute.BOOLEAN);
// border
attributeDefinitions.add("borderSize", 200).setType(Attribute.NUMBER);
attributeDefinitions.add("borderMinSize", DefaultMin).setType(Attribute.NUMBER);
attributeDefinitions.add("borderMaxSize", DefaultMax).setType(Attribute.NUMBER);
attributeDefinitions.add("borderEnableDrop", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("borderAutoSelectTabWhenOpen", true).setType(Attribute.BOOLEAN);
attributeDefinitions.add("borderAutoSelectTabWhenClosed", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("borderClassName", undefined).setType(Attribute.STRING);
attributeDefinitions.add("borderEnableAutoHide", false).setType(Attribute.BOOLEAN);
attributeDefinitions.add("borderEnableTabScrollbar", false).setType(Attribute.BOOLEAN);
return attributeDefinitions;
}
}