/** * This module provides the ability to render and edit hierarchical layouts of boxes. Each box may * contain a list of other boxes, and horizontally- and vertically-arranged lists alternating with * the depth in the hierarchy. * * Layout * Layout is a tree of LayoutBoxes (HBoxes and VBoxes). It consists of HBoxes and VBoxes in * alternating levels. The leaves of the tree are LeafBoxes, and those are the only items that * may be moved around, with the structure of Boxes above them changing to accommodate. * * LayoutBox * A LayoutBox is a node in the Layout tree. LayoutBoxes should typically have nothing visual * about them (e.g. no borders) except their dimensions: they serve purely for layout purposes. * * A LayoutBox may be an HBox or a VBox. An HBox may contain multiple VBoxes arranged in a row. * A VBox may contain multiple HBoxes one under the other. Either kind of LayoutBox may contain * a single LeafBox instead of child LayoutBoxes. No LayoutBox may be empty, and no LayoutBox * may contain a single LayoutBox as a child: it must contain either multiple LayoutBox * children, or a single LeafBox. * * LeafBox * A LeafBox is the container for user content, i.e. what needs to be laid out, for example * form elements. LeafBoxes are what the user can drag around to other location in the layout. * All the LeafBoxes in a Layout together fill the entire Layout rectangle. If some parts of * the layout are to be empty, they should still contain an empty LeafBox. * * There is no separate JS class for LeafBoxes, they are simply LayoutBoxes with .layout_leaf * class and set leafId and leafContent member observables. * * Floater * A Floater is a rectangle that floats over the layout with the mouse pointer while the user is * dragging a LeafBox. It contains the content of the LeafBox being dragged, so that the user * can see what is being repositioned. * * DropOverlay * An DropOverlay is a visual aid to the user to indicate area over the current LeafBox where a * drop may be attempted. It also computes the "affinity": which border of the current LeafBox * the user is trying to target as the insertion point. * * DropTargeter * DropTargeter displays a set of rectangles, each of which represents a particular allowed * insertion point for the element being dragged. E.g. dragging an element to the right side of * a LeafBox would display a drop target for each LayoutBox up the tree that allows a sibling * to be inserted on the right. * * Saving Changes * -------------- * We don't attempt to save granular changes to the layout, for each drag operation, because * for the user, it's better to finish editing the layout, and only save the end result. Also, * it's not so easy (the structure changes many times while dragging, and a single drag * operation results in a non-trivial diff of the 'before' and 'after' layouts). So instead, we * just have a way to serialize the layout to and from a JSON blob. */ import dom, {detachNode, findAncestor} from 'app/client/lib/dom'; import koArray, {isKoArray, KoArray} from 'app/client/lib/koArray'; import {cssClass, domData, foreach, scope, style, toggleClass} from 'app/client/lib/koDom'; import {Disposable} from 'app/client/lib/dispose'; import assert from 'assert'; import {Events as BackboneEvents} from 'backbone'; import * as ko from 'knockout'; import {computed, isObservable, observable, utils} from 'knockout'; import {identity, isEqual, last, uniqueId} from 'underscore'; export interface ContentBox { leafId: ko.Observable; leafContent: ko.Observable; dom: HTMLElement|null; } export interface BoxSpec { leaf?: string|number; size?: number; children?: BoxSpec[]; collapsed?: BoxSpec[]; } /** * A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for * rendering as well as for the code editor. Since it may be rendered many times on a page, it's * important for it to be efficient. * @param {Layout} layout: The Layout object that manages this LayoutBox. */ export class LayoutBox extends Disposable implements ContentBox { public layout: Layout; public dom: HTMLElement | null = null; public leafId: ko.Observable; // probably number for section id public parentBox: ko.Observable; public childBoxes: KoArray; public leafContent: ko.Observable; public uniqueId: string; public isVBox: ko.Computed; public isHBox: ko.Computed; public isLeaf: ko.Computed; public isMaximized: ko.Computed; public isHidden: ko.Computed; public flexSize: ko.Observable; private _parentBeingDisposed: boolean; public create(layout: Layout) { this.layout = layout; this.parentBox = observable(null as any); this.childBoxes = koArray(); this.leafId = observable(null); this.leafContent = observable(null as any); this.uniqueId = uniqueId("lb"); // For logging and debugging. this.isVBox = this.autoDispose(computed(() => { return this.parentBox() ? !this.parentBox()!.isVBox() : true; }, this)); this.isHBox = this.autoDispose(computed(() => { return !this.isVBox(); })); this.isLeaf = this.autoDispose(computed(() => { return this.leafId() !== null; }, this)); this.isMaximized = this.autoDispose(ko.pureComputed(() => { const leafId = this.layout?.maximizedLeaf(); if (!leafId) { return false; } if (leafId === this.leafId()) { return true; } return this.childBoxes.all().some(function(child) { return child.isMaximized(); }); }, this)); this.isHidden = this.autoDispose(ko.pureComputed(() => { // If there isn't any maximized box, then no box is hidden. const maximized = this.layout?.maximizedLeaf(); if (!maximized) { return false; } return !this.isMaximized(); }, this)); // flexSize represents flexWidth for VBoxes and flexHeight for HBoxes. // Undesirable transition effects are likely when <1, so we set average value // to 100 so that reduction below 1 is rare. this.flexSize = observable(100); this.dom = null; // This is an optimization to avoid the wasted cost of removeFromParent during disposal. this._parentBeingDisposed = false; this.autoDisposeCallback(() => { if (!this._parentBeingDisposed) { this.removeFromParent(); } this.childBoxes.peek().forEach(function(child) { child._parentBeingDisposed = true; child.dispose(); }); }); } public getDom() { return this.dom || (this.dom = this.autoDispose(this.buildDom())); } public maximize() { if (this.layout.maximizedLeaf.peek() !== this.leafId.peek()) { this.layout.maximizedLeaf(this.leafId()); } else { this.layout.maximizedLeaf(null); } } public buildDom() { const self = this; const wrap = this.layout.needDynamic ? identity : makeStatic; return dom('div.layout_box', toggleClass('layout_leaf', wrap(this.isLeaf)), toggleClass('layout_hidden', this.isHidden), toggleClass(this.layout.leafId, wrap(this.isLeaf)), cssClass(wrap(function() { return self.isVBox() ? "layout_vbox" : "layout_hbox"; })), cssClass(wrap(function() { return (self.layout.fillWindow ? 'layout_fill_window' : (self.isLastChild() ? 'layout_last_child' : null)); })), style('--flex-grow', wrap(function() { return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : ''; })), domData('layoutBox', this), foreach(wrap(this.childBoxes), function(layoutBox: LayoutBox) { return layoutBox.getDom(); }), scope(wrap(this.leafContent), function(leafContent: any) { return leafContent; }) ); } /** * Moves the leaf id and content from another layoutBox, unsetting them in the source one. */ public takeLeafFrom(sourceLayoutBox: ContentBox) { this.leafId(sourceLayoutBox.leafId.peek()); // Note that we detach the node, so that the old box doesn't destroy its DOM. this.leafContent(detachNode(sourceLayoutBox.leafContent.peek())); sourceLayoutBox.leafId(null); sourceLayoutBox.leafContent(null); } public setChildren(children: LayoutBox[]) { children.forEach((child) => child.parentBox(this)); this.childBoxes.assign(children); } public isFirstChild() { return this.parentBox() ? this.parentBox()!.childBoxes.peek()[0] === this : true; } public isLastChild() { // Use .all() rather than .peek() because it's used in kd.toggleClass('layout_last_child'), and // we want it to automatically stay correct when childBoxes array changes. return this.parentBox() ? last(this.parentBox()!.childBoxes.all()) === this : true; } public isDomDetached() { return !(this.dom && this.dom.parentNode); } public getSiblingBox(isAfter: boolean) { if (!this.parentBox()) { return null; } const siblings = this.parentBox()!.childBoxes.peek(); let index = siblings.indexOf(this); if (index < 0) { return null; } index += (isAfter ? 1 : -1); return (index < 0 || index >= siblings.length ? null : siblings[index]); } public _addChild(childBox: LayoutBox, isAfter: boolean, optNextSibling?: LayoutBox) { assert(childBox.parentBox() === null, "LayoutBox._addChild: child already has parentBox set"); let index; if (optNextSibling) { index = this.childBoxes.peek().indexOf(optNextSibling) + (isAfter ? 1 : 0); } else { index = isAfter ? this.childBoxes.peekLength : 0; } childBox.parentBox(this); this.childBoxes.splice(index, 0, childBox); } public addSibling(childBox: LayoutBox, isAfter: boolean) { childBox.removeFromParent(); const parentBox = this.parentBox(); if (parentBox) { // Normally, we just add a sibling as requested. parentBox._addChild(childBox, isAfter, this); } else { // If adding a sibling to the root node (another VBox), we need to create a new root and push // things down two levels (HBox and VBox), and add the sibling to the lower VBox. if (this.childBoxes.peekLength === 1) { // Except when the root has a single child, in which case there is already a good place to // add the new node two levels lower. And we should not create another level because the // root is the only place that can have a single child. const lowerBox = this.childBoxes.peek()[0]; assert(!lowerBox.isLeaf(), 'LayoutBox.addSibling: should not have leaf as a single child'); lowerBox._addChild(childBox, isAfter); } else { // Create a new root, and add the sibling two levels lower. const vbox = LayoutBox.create(this.layout); const hbox = LayoutBox.create(this.layout); // We don't need removeFromParent here because this only runs when there is no parent. vbox._addChild(hbox, false); hbox._addChild(this, false); hbox._addChild(childBox, isAfter); this.layout.setRoot(vbox); } } this.layout.trigger('layoutChanged'); } public addChild(childBox: LayoutBox, isAfter: boolean) { childBox.removeFromParent(); if (this.isLeaf()) { // Move the leaf data into a new child, then add the requested childBox. const newBox = LayoutBox.create(this.layout); newBox.takeLeafFrom(this); this._addChild(newBox, false); } this._addChild(childBox, isAfter); this.layout.trigger('layoutChanged'); } public toString(): string { return this.isDisposed() ? this.uniqueId + "[disposed]" : (this.uniqueId + (this.isHBox() ? "H" : "V") + (this.isLeaf() ? "(" + this.leafId() + ")" : "[" + this.childBoxes.peek().map(function(b) { return b.toString(); }).join(",") + "]") ); } public _removeChildBox(childBox: LayoutBox) { //console.log("_removeChildBox %s from %s", childBox.toString(), this.toString()); let index = this.childBoxes.peek().indexOf(childBox); childBox.parentBox(null); if (index >= 0) { this.childBoxes.splice(index, 1); this.rescaleFlexSizes(); } if (this.childBoxes.peekLength === 1) { // If we now have a single child, then something needs to collapse. const lowerBox = this.childBoxes.peek()[0]; const parentBox = this.parentBox(); if (lowerBox.isLeaf()) { // Move the leaf data into ourselves, and remove the lower box. this.takeLeafFrom(lowerBox); lowerBox.dispose(); } else if (parentBox) { // Move grandchildren into our place within our parent, and collapse two levels. // (Unless we are the root, in which case it's OK for us to have a single non-leaf child.) index = parentBox.childBoxes.peek().indexOf(this); assert(index >= 0, 'LayoutBox._removeChildBox: box not found in parent'); const grandchildBoxes = lowerBox.childBoxes.peek(); grandchildBoxes.forEach(function(box) { box.parentBox(parentBox); }); parentBox.childBoxes.arraySplice(index, 0, grandchildBoxes); lowerBox.childBoxes.splice(0, lowerBox.childBoxes.peekLength); this.removeFromParent(); lowerBox.dispose(); this.dispose(); } } } /** * Helper to detach a box from its parent without disposing it. If you no longer plan to reattach * the box, you should probably call box.dispose(). */ public removeFromParent() { if (this.parentBox()) { this.parentBox()!._removeChildBox(this); this.layout.trigger('layoutChanged'); } } /** * Adjust flexSize values of the children so that they add up to at least 1. * Otherwise, Firefox will not stretch them to the full size of the container. */ public rescaleFlexSizes() { // Just scale so that the smallest value is 1. const children = this.childBoxes.peek(); const minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); })); if (minSize < 1) { children.forEach(function(b) { b.flexSize(b.flexSize() / minSize); }); } } } /** * This helper turns a value, observable, or function (as accepted by koDom functions) into a * plain value. It's used to build a static piece of DOM without subscribing to any of the * observables, to avoid the performance cost of subscribing/unsubscribing. */ function makeStatic(valueOrFunc: any) { if (isObservable(valueOrFunc) || isKoArray(valueOrFunc)) { return valueOrFunc.peek(); } else if (typeof valueOrFunc === 'function') { return valueOrFunc(); } else { return valueOrFunc; } } //---------------------------------------------------------------------- /** * @event layoutChanged: Triggered on changes to the structure of the layout. * @event layoutResized: Triggered on non-structural changes that may affect the size of rootElem. */ export class Layout extends Disposable { /** * You can also find the nearest containing LayoutBox without having the Layout object itself by * using Layout.Layout.getContainingBox. The Layout object is then accessible as box.layout. */ public static getContainingBox(elem: Element|null, optContainer: any) { const boxElem = findAncestor(elem, optContainer, '.layout_box'); return boxElem ? utils.domData.get(boxElem, 'layoutBox') : null; } public listenTo: BackboneEvents["listenTo"]; // set by Backbone public trigger: BackboneEvents["trigger"]; // set by Backbone public stopListening: BackboneEvents["stopListening"]; // set by Backbone public maximizedLeaf: ko.Observable; public rootBox: ko.Observable; public createLeafFunc: (id: string) => HTMLElement; public fillWindow: boolean; public needDynamic: boolean; public rootElem: HTMLElement; public leafId: string; private _leafIdMap: Map|null; public create(boxSpec: BoxSpec, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) { this.maximizedLeaf = observable(null as (string|null)); this.rootBox = observable(null as any); this.createLeafFunc = createLeafFunc; this._leafIdMap = null; this.fillWindow = optFillWindow || false; this.needDynamic = false; this.rootElem = this.autoDispose(this.buildDom()); // Generates a unique id class so boxes can only be placed next to other boxes in this layout. this.leafId = uniqueId('layout_leaf_'); this.buildLayout(boxSpec || {}); // Invalidate the _leafIdMap when the layout is adjusted. this.listenTo(this, 'layoutChanged', () => { this._leafIdMap = null; }); this.autoDisposeCallback(() => { if (this.rootBox()) { this.rootBox()!.dispose(); } }); } /** * Finds and returns the leaf layout box containing the content for the given leafId. */ public getLeafBox(leafId: string|number) { return this.getLeafIdMap().get(leafId); } /** * Returns the list of all leafIds present in this layout. */ public getAllLeafIds() { return Array.from(this.getLeafIdMap().keys()); } public setRoot(layoutBox: LayoutBox) { this.rootBox(layoutBox); } public buildDom() { return dom('div.layout_root', domData('layoutModel', this), toggleClass('layout_fill_window', this.fillWindow), toggleClass('layout_box_maximized', this.maximizedLeaf), scope(this.rootBox, (rootBox: LayoutBox) => { return rootBox ? rootBox.getDom() : null; }) ); } /** * Calls cb on each box in the layout recursively. */ public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) { if (!this.rootBox.peek()) { return; } function iter(box: any) { cb.call(optContext, box); box.childBoxes.peek().forEach(iter); } iter(this.rootBox.peek()); } public buildLayoutBox(boxSpec: BoxSpec) { // Note that this is hot code: it runs when rendering a layout for each record, not only for the // layout editor. const box = LayoutBox.create(this); if (boxSpec.size) { box.flexSize(boxSpec.size); } if (boxSpec.leaf) { box.leafId(boxSpec.leaf); box.leafContent(this.createLeafFunc(box.leafId()!)); } else if (boxSpec.children) { box.setChildren(boxSpec.children.map(this.buildLayoutBox, this)); } return box; } public buildLayout(boxSpec: BoxSpec, needDynamic = false) { if (needDynamic === this.needDynamic && this.rootBox() && isEqual(boxSpec, this.getLayoutSpec())) { // Nothing has changed, and we already have a layout. No need to rebuild. return; } this.needDynamic = needDynamic; const oldRootBox = this.rootBox(); this.rootBox(this.buildLayoutBox(boxSpec)); this.trigger('layoutChanged'); if (oldRootBox) { oldRootBox.dispose(); } } public _getBoxSpec(layoutBox: LayoutBox) { const spec: BoxSpec = {}; if (layoutBox.isDisposed()) { return spec; } if (layoutBox.flexSize() && layoutBox.flexSize() !== 100) { spec.size = layoutBox.flexSize(); } if (layoutBox.isLeaf()) { spec.leaf = layoutBox.leafId(); } else { spec.children = layoutBox.childBoxes.peek().map(this._getBoxSpec, this); } return spec; } public getLayoutSpec() { const rootBox = this.rootBox(); return rootBox ? this._getBoxSpec(rootBox) : {}; } /** * Returns a Map object mapping leafId to its LayoutBox. This gets invalidated on layoutAdjust * events, and rebuilt on next request. */ public getLeafIdMap() { if (!this._leafIdMap) { this._leafIdMap = new Map(); this.forEachBox((box) => { const leafId = box.leafId.peek(); if (leafId !== null) { this._leafIdMap!.set(leafId, box); } }, this); } return this._leafIdMap; } /** * Returns a LayoutBox object containing the given DOM element, or null if not found. */ public getContainingBox(elem: Element|null) { return Layout.getContainingBox(elem, this.rootElem); } } Object.assign(Layout.prototype, BackboneEvents);