golden-layout
Version:
A multi-screen javascript Layout manager https://golden-layout.com
941 lines (809 loc) • 24.9 kB
JavaScript
/**
* The main class that will be exposed as GoldenLayout.
*
* @public
* @constructor
* @param {GoldenLayout config} config
* @param {[DOM element container]} container Can be a jQuery selector string or a Dom element. Defaults to body
*
* @returns {VOID}
*/
lm.LayoutManager = function( config, container ) {
if( !$ || typeof $.noConflict !== 'function' ) {
var errorMsg = 'jQuery is missing as dependency for GoldenLayout. ';
errorMsg += 'Please either expose $ on GoldenLayout\'s scope (e.g. window) or add "jquery" to ';
errorMsg += 'your paths when using RequireJS/AMD';
throw new Error( errorMsg );
}
lm.utils.EventEmitter.call( this );
this.isInitialised = false;
this._isFullPage = false;
this._resizeTimeoutId = null;
this._components = { 'lm-react-component': lm.utils.ReactComponentHandler };
this._itemAreas = [];
this._resizeFunction = lm.utils.fnBind( this._onResize, this );
this._unloadFunction = lm.utils.fnBind( this._onUnload, this );
this._maximisedItem = null;
this._maximisePlaceholder = $( '<div class="lm_maximise_place"></div>' );
this._creationTimeoutPassed = false;
this._subWindowsCreated = false;
this.width = null;
this.height = null;
this.root = null;
this.openPopouts = [];
this.selectedItem = null;
this.isSubWindow = false;
this.eventHub = new lm.utils.EventHub( this );
this.config = this._createConfig( config );
this.container = container;
this.dropTargetIndicator = null;
this.transitionIndicator = null;
this.tabDropPlaceholder = $( '<div class="lm_drop_tab_placeholder"></div>' );
if( this.isSubWindow === true ) {
$( 'body' ).css( 'visibility', 'hidden' );
}
this._typeToItem = {
'column': lm.utils.fnBind( lm.items.RowOrColumn, this, [ true ] ),
'row': lm.utils.fnBind( lm.items.RowOrColumn, this, [ false ] ),
'stack': lm.items.Stack,
'component': lm.items.Component
};
};
/**
* Hook that allows to access private classes
*/
lm.LayoutManager.__lm = lm;
/**
* Takes a GoldenLayout configuration object and
* replaces its keys and values recursively with
* one letter codes
*
* @static
* @public
* @param {Object} config A GoldenLayout config object
*
* @returns {Object} minified config
*/
lm.LayoutManager.minifyConfig = function( config ) {
return ( new lm.utils.ConfigMinifier() ).minifyConfig( config );
};
/**
* Takes a configuration Object that was previously minified
* using minifyConfig and returns its original version
*
* @static
* @public
* @param {Object} minifiedConfig
*
* @returns {Object} the original configuration
*/
lm.LayoutManager.unminifyConfig = function( config ) {
return ( new lm.utils.ConfigMinifier() ).unminifyConfig( config );
};
lm.utils.copy( lm.LayoutManager.prototype, {
/**
* Register a component with the layout manager. If a configuration node
* of type component is reached it will look up componentName and create the
* associated component
*
* {
* type: "component",
* componentName: "EquityNewsFeed",
* componentState: { "feedTopic": "us-bluechips" }
* }
*
* @public
* @param {String} name
* @param {Function} constructor
*
* @returns {void}
*/
registerComponent: function( name, constructor ) {
if( typeof constructor !== 'function' ) {
throw new Error( 'Please register a constructor function' );
}
if( this._components[ name ] !== undefined ) {
throw new Error( 'Component ' + name + ' is already registered' );
}
this._components[ name ] = constructor;
},
/**
* Creates a layout configuration object based on the the current state
*
* @public
* @returns {Object} GoldenLayout configuration
*/
toConfig: function( root ) {
var config, next, i;
if( this.isInitialised === false ) {
throw new Error( 'Can\'t create config, layout not yet initialised' );
}
if( root && !( root instanceof lm.items.AbstractContentItem ) ){
throw new Error( 'Root must be a ContentItem' );
}
/*
* settings & labels
*/
config = {
settings: lm.utils.copy( {}, this.config.settings ),
dimensions: lm.utils.copy( {}, this.config.dimensions ),
labels: lm.utils.copy( {}, this.config.labels )
};
/*
* Content
*/
config.content = [];
next = function( configNode, item ) {
var key, i;
for( key in item.config ) {
if( key !== 'content' ) {
configNode[ key ] = item.config[ key ];
}
}
if( item.contentItems.length ) {
configNode.content = [];
for( i = 0; i < item.contentItems.length; i++ ) {
configNode.content[ i ] = {};
next( configNode.content[ i ], item.contentItems[ i ] );
}
}
};
if( root ) {
next( config, { contentItems: [ root ] } );
} else {
next( config, this.root );
}
/*
* Retrieve config for subwindows
*/
this._$reconcilePopoutWindows();
config.openPopouts = [];
for( i = 0; i < this.openPopouts.length; i++ ) {
config.openPopouts.push( this.openPopouts[ i ].toConfig() );
}
/*
* Add maximised item
*/
config.maximisedItemId = this._maximisedItem ? '__glMaximised' : null;
return config;
},
/**
* Returns a previously registered component
*
* @public
* @param {String} name The name used
*
* @returns {Function}
*/
getComponent: function( name ) {
if( this._components[ name ] === undefined ) {
throw new lm.errors.ConfigurationError( 'Unknown component "' + name + '"' );
}
return this._components[ name ];
},
/**
* Creates the actual layout. Must be called after all initial components
* are registered. Recurses through the configuration and sets up
* the item tree.
*
* If called before the document is ready it adds itself as a listener
* to the document.ready event
*
* @public
*
* @returns {void}
*/
init: function() {
/**
* Create the popout windows straight away. If popouts are blocked
* an error is thrown on the same 'thread' rather than a timeout and can
* be caught. This also prevents any further initilisation from taking place.
*/
if( this._subWindowsCreated === false ) {
this._createSubWindows();
this._subWindowsCreated = true;
}
/**
* If the document isn't ready yet, wait for it.
*/
if( document.readyState === 'loading' || document.body === null ) {
$(document).ready( lm.utils.fnBind( this.init, this ));
return;
}
/**
* If this is a subwindow, wait a few milliseconds for the original
* page's js calls to be executed, then replace the bodies content
* with GoldenLayout
*/
if( this.isSubWindow === true && this._creationTimeoutPassed === false ) {
setTimeout( lm.utils.fnBind( this.init, this ), 7 );
this._creationTimeoutPassed = true;
return;
}
if( this.isSubWindow === true ) {
this._adjustToWindowMode();
}
this._setContainer();
this.dropTargetIndicator = new lm.controls.DropTargetIndicator( this.container );
this.transitionIndicator = new lm.controls.TransitionIndicator();
this.updateSize();
this._create( this.config );
this._bindEvents();
this.isInitialised = true;
this.emit( 'initialised' );
},
/**
* Updates the layout managers size
*
* @public
* @param {[int]} width height in pixels
* @param {[int]} height width in pixels
*
* @returns {void}
*/
updateSize: function( width, height ) {
if( arguments.length === 2 ) {
this.width = width;
this.height = height;
} else {
this.width = this.container.width();
this.height = this.container.height();
}
if( this.isInitialised === true ) {
this.root.callDownwards( 'setSize' );
if( this._maximisedItem ) {
this._maximisedItem.element.width( this.container.width() );
this._maximisedItem.element.height( this.container.height() );
this._maximisedItem.callDownwards( 'setSize' );
}
}
},
/**
* Destroys the LayoutManager instance itself as well as every ContentItem
* within it. After this is called nothing should be left of the LayoutManager.
*
* @public
* @returns {void}
*/
destroy: function() {
if( this.isInitialised === false ) {
return;
}
this._onUnload();
$( window ).off( 'resize', this._resizeFunction );
$( window ).off( 'unload beforeunload', this._unloadFunction );
this.root.callDownwards( '_$destroy', [], true );
this.root.contentItems = [];
this.tabDropPlaceholder.remove();
this.dropTargetIndicator.destroy();
this.transitionIndicator.destroy();
this.eventHub.destroy();
},
/**
* Recursively creates new item tree structures based on a provided
* ItemConfiguration object
*
* @public
* @param {Object} config ItemConfig
* @param {[ContentItem]} parent The item the newly created item should be a child of
*
* @returns {lm.items.ContentItem}
*/
createContentItem: function( config, parent ) {
var typeErrorMsg, contentItem;
if( typeof config.type !== 'string' ) {
throw new lm.errors.ConfigurationError( 'Missing parameter \'type\'', config );
}
if (config.type === 'react-component') {
config.type = 'component';
config.componentName = 'lm-react-component';
}
if( !this._typeToItem[ config.type ] ) {
typeErrorMsg = 'Unknown type \'' + config.type + '\'. ' +
'Valid types are ' + lm.utils.objectKeys( this._typeToItem ).join( ',' );
throw new lm.errors.ConfigurationError( typeErrorMsg );
}
/**
* We add an additional stack around every component that's not within a stack anyways.
*/
if(
// If this is a component
config.type === 'component' &&
// and it's not already within a stack
!( parent instanceof lm.items.Stack ) &&
// and we have a parent
!!parent &&
// and it's not the topmost item in a new window
!( this.isSubWindow === true && parent instanceof lm.items.Root )
) {
config = {
type: 'stack',
width: config.width,
height: config.height,
content: [ config ]
};
}
contentItem = new this._typeToItem[ config.type ]( this, config, parent );
return contentItem;
},
/**
* Creates a popout window with the specified content and dimensions
*
* @param {Object|lm.itemsAbstractContentItem} configOrContentItem
* @param {[Object]} dimensions A map with width, height, left and top
* @param {[String]} parentId the id of the element this item will be appended to
* when popIn is called
* @param {[Number]} indexInParent The position of this item within its parent element
* @returns {lm.controls.BrowserPopout}
*/
createPopout: function( configOrContentItem, dimensions, parentId, indexInParent ) {
var config = configOrContentItem,
isItem = configOrContentItem instanceof lm.items.AbstractContentItem,
self = this,
windowLeft,
windowTop,
offset,
parent,
child,
browserPopout;
parentId = parentId || null;
if( isItem ) {
config = this.toConfig( configOrContentItem ).content;
parentId = lm.utils.getUniqueId();
/**
* If the item is the only component within a stack or for some
* other reason the only child of its parent the parent will be destroyed
* when the child is removed.
*
* In order to support this we move up the tree until we find something
* that will remain after the item is being popped out
*/
parent = configOrContentItem.parent;
child = configOrContentItem;
while( parent.contentItems.length === 1 && !parent.isRoot ) {
parent = parent.parent;
child = child.parent;
}
parent.addId( parentId );
if( isNaN( indexInParent ) ) {
indexInParent = lm.utils.indexOf( child, parent.contentItems );
}
} else {
if( !( config instanceof Array ) ) {
config = [ config ];
}
}
if( !dimensions && isItem ) {
windowLeft = window.screenX || window.screenLeft;
windowTop = window.screenY || window.screenTop;
offset = configOrContentItem.element.offset();
dimensions = {
left: windowLeft + offset.left,
top: windowTop + offset.top,
width: configOrContentItem.element.width(),
height: configOrContentItem.element.height()
};
}
if( !dimensions && !isItem ) {
dimensions = {
left: window.screenX || window.screenLeft + 20,
top: window.screenY || window.screenTop + 20,
width: 500,
height: 309
};
}
if( isItem ) {
configOrContentItem.remove();
}
browserPopout = new lm.controls.BrowserPopout( config, dimensions, parentId, indexInParent, this );
browserPopout.on( 'initialised', function(){
self.emit( 'windowOpened', browserPopout );
});
browserPopout.on( 'closed', function(){
self._$reconcilePopoutWindows();
});
this.openPopouts.push( browserPopout );
return browserPopout;
},
/**
* Attaches DragListener to any given DOM element
* and turns it into a way of creating new ContentItems
* by 'dragging' the DOM element into the layout
*
* @param {jQuery DOM element} element
* @param {Object|Function} itemConfig for the new item to be created, or a function which will provide it
*
* @returns {void}
*/
createDragSource: function( element, itemConfig ) {
this.config.settings.constrainDragToContainer = false;
new lm.controls.DragSource( $( element ), itemConfig, this );
},
/**
* Programmatically selects an item. This deselects
* the currently selected item, selects the specified item
* and emits a selectionChanged event
*
* @param {lm.item.AbstractContentItem} item#
* @param {[Boolean]} _$silent Wheather to notify the item of its selection
* @event selectionChanged
*
* @returns {VOID}
*/
selectItem: function( item, _$silent ) {
if( this.config.settings.selectionEnabled !== true ) {
throw new Error( 'Please set selectionEnabled to true to use this feature' );
}
if( item === this.selectedItem ) {
return;
}
if( this.selectedItem !== null ) {
this.selectedItem.deselect();
}
if( item && _$silent !== true ) {
item.select();
}
this.selectedItem = item;
this.emit( 'selectionChanged', item );
},
/*************************
* PACKAGE PRIVATE
*************************/
_$maximiseItem: function( contentItem ) {
if( this._maximisedItem !== null ) {
this._$minimiseItem( this._maximisedItem );
}
this._maximisedItem = contentItem;
this._maximisedItem.addId( '__glMaximised' );
contentItem.element.addClass( 'lm_maximised' );
contentItem.element.after( this._maximisePlaceholder );
this.root.element.prepend( contentItem.element );
contentItem.element.width( this.container.width() );
contentItem.element.height( this.container.height() );
contentItem.callDownwards( 'setSize' );
this._maximisedItem.emit( 'maximised' );
this.emit( 'stateChanged' );
},
_$minimiseItem: function( contentItem ) {
contentItem.element.removeClass( 'lm_maximised' );
contentItem.removeId( '__glMaximised' );
this._maximisePlaceholder.after( contentItem.element );
this._maximisePlaceholder.remove();
contentItem.parent.callDownwards( 'setSize' );
this._maximisedItem = null;
contentItem.emit( 'minimised' );
this.emit( 'stateChanged' );
},
/**
* This method is used to get around sandboxed iframe restrictions.
* If 'allow-top-navigation' is not specified in the iframe's 'sandbox' attribute
* (as is the case with codepens) the parent window is forbidden from calling certain
* methods on the child, such as window.close() or setting document.location.href.
*
* This prevented GoldenLayout popouts from popping in in codepens. The fix is to call
* _$closeWindow on the child window's gl instance which (after a timeout to disconnect
* the invoking method from the close call) closes itself.
*
* @packagePrivate
*
* @returns {void}
*/
_$closeWindow: function() {
window.setTimeout(function(){
window.close();
}, 1);
},
_$getArea: function( x, y ) {
var i, area, smallestSurface = Infinity, mathingArea = null;
for( i = 0; i < this._itemAreas.length; i++ ) {
area = this._itemAreas[ i ];
if(
x > area.x1 &&
x < area.x2 &&
y > area.y1 &&
y < area.y2 &&
smallestSurface > area.surface
){
smallestSurface = area.surface;
mathingArea = area;
}
}
return mathingArea;
},
_$calculateItemAreas: function() {
var i, area, allContentItems = this._getAllContentItems();
this._itemAreas = [];
/**
* If the last item is dragged out, highlight the entire container size to
* allow to re-drop it. allContentItems[ 0 ] === this.root at this point
*
* Don't include root into the possible drop areas though otherwise since it
* will used for every gap in the layout, e.g. splitters
*/
if( allContentItems.length === 1 ) {
this._itemAreas.push( this.root._$getArea() );
return;
}
for( i = 0; i < allContentItems.length; i++ ) {
if( !( allContentItems[ i ].isStack ) ) {
continue;
}
area = allContentItems[ i ]._$getArea();
if( area === null ) {
continue;
} else if( area instanceof Array ) {
this._itemAreas = this._itemAreas.concat( area );
} else {
this._itemAreas.push( area );
}
}
},
/**
* Takes a contentItem or a configuration and optionally a parent
* item and returns an initialised instance of the contentItem.
* If the contentItem is a function, it is first called
*
* @packagePrivate
*
* @param {lm.items.AbtractContentItem|Object|Function} contentItemOrConfig
* @param {lm.items.AbtractContentItem} parent Only necessary when passing in config
*
* @returns {lm.items.AbtractContentItem}
*/
_$normalizeContentItem: function( contentItemOrConfig, parent ) {
if( !contentItemOrConfig ) {
throw new Error( 'No content item defined' );
}
if( lm.utils.isFunction( contentItemOrConfig ) ) {
contentItemOrConfig = contentItemOrConfig();
}
if( contentItemOrConfig instanceof lm.items.AbstractContentItem ) {
return contentItemOrConfig;
}
if( $.isPlainObject( contentItemOrConfig ) && contentItemOrConfig.type ) {
var newContentItem = this.createContentItem( contentItemOrConfig, parent );
newContentItem.callDownwards( '_$init' );
return newContentItem;
} else {
throw new Error( 'Invalid contentItem' );
}
},
/**
* Iterates through the array of open popout windows and removes the ones
* that are effectively closed. This is necessary due to the lack of reliably
* listening for window.close / unload events in a cross browser compatible fashion.
*
* @packagePrivate
*
* @returns {void}
*/
_$reconcilePopoutWindows: function() {
var openPopouts = [], i;
for( i = 0; i < this.openPopouts.length; i++ ) {
if( this.openPopouts[ i ].getWindow().closed === false ) {
openPopouts.push( this.openPopouts[ i ] );
} else {
this.emit( 'windowClosed', this.openPopouts[ i ] );
}
}
if( this.openPopouts.length !== openPopouts.length ) {
this.emit( 'stateChanged' );
this.openPopouts = openPopouts;
}
},
/***************************
* PRIVATE
***************************/
/**
* Returns a flattened array of all content items,
* regardles of level or type
*
* @private
*
* @returns {void}
*/
_getAllContentItems: function() {
var allContentItems = [];
var addChildren = function( contentItem ) {
allContentItems.push( contentItem );
if( contentItem.contentItems instanceof Array ) {
for( var i = 0; i < contentItem.contentItems.length; i++ ) {
addChildren( contentItem.contentItems[ i ] );
}
}
};
addChildren( this.root );
return allContentItems;
},
/**
* Binds to DOM/BOM events on init
*
* @private
*
* @returns {void}
*/
_bindEvents: function() {
if( this._isFullPage ) {
$(window).resize( this._resizeFunction );
}
$(window).on( 'unload beforeunload', this._unloadFunction );
},
/**
* Debounces resize events
*
* @private
*
* @returns {void}
*/
_onResize: function() {
clearTimeout( this._resizeTimeoutId );
this._resizeTimeoutId = setTimeout(lm.utils.fnBind( this.updateSize, this ), 100 );
},
/**
* Extends the default config with the user specific settings and applies
* derivations. Please note that there's a seperate method (AbstractContentItem._extendItemNode)
* that deals with the extension of item configs
*
* @param {Object} config
* @static
* @returns {Object} config
*/
_createConfig: function( config ) {
var windowConfigKey = lm.utils.getQueryStringParam( 'gl-window' );
if( windowConfigKey ) {
this.isSubWindow = true;
config = localStorage.getItem( windowConfigKey );
config = JSON.parse( config );
config = ( new lm.utils.ConfigMinifier() ).unminifyConfig( config );
localStorage.removeItem( windowConfigKey );
}
config = $.extend( true, {}, lm.config.defaultConfig, config );
var nextNode = function( node ) {
for( var key in node ) {
if( key !== 'props' && typeof node[ key ] === 'object' ) {
nextNode( node[ key ] );
}
else if( key === 'type' && node[ key ] === 'react-component' ) {
node.type = 'component';
node.componentName = 'lm-react-component';
}
}
}
nextNode( config );
if( config.settings.hasHeaders === false ) {
config.dimensions.headerHeight = 0;
}
return config;
},
/**
* This is executed when GoldenLayout detects that it is run
* within a previously opened popout window.
*
* @private
*
* @returns {void}
*/
_adjustToWindowMode: function() {
var popInButton = $( '<div class="lm_popin" title="' + this.config.labels.popin + '">' +
'<div class="lm_icon"></div>' +
'<div class="lm_bg"></div>' +
'</div>');
popInButton.click(lm.utils.fnBind(function(){
this.emit( 'popIn' );
}, this));
document.title = lm.utils.stripTags( this.config.content[ 0 ].title );
$( 'head' ).append( $( 'body link, body style, template, .gl_keep' ) );
this.container = $( 'body' )
.html( '' )
.css( 'visibility', 'visible' )
.append( popInButton );
/*
* This seems a bit pointless, but actually causes a reflow/re-evaluation getting around
* slickgrid's "Cannot find stylesheet." bug in chrome
*/
var x = document.body.offsetHeight; // jshint ignore:line
/*
* Expose this instance on the window object
* to allow the opening window to interact with
* it
*/
window.__glInstance = this;
},
/**
* Creates Subwindows (if there are any). Throws an error
* if popouts are blocked.
*
* @returns {void}
*/
_createSubWindows: function() {
var i, popout;
for( i = 0; i < this.config.openPopouts.length; i++ ) {
popout = this.config.openPopouts[ i ];
this.createPopout(
popout.content,
popout.dimensions,
popout.parentId,
popout.indexInParent
);
}
},
/**
* Determines what element the layout will be created in
*
* @private
*
* @returns {void}
*/
_setContainer: function() {
var container = $( this.container || 'body' );
if( container.length === 0 ) {
throw new Error( 'GoldenLayout container not found' );
}
if( container.length > 1 ) {
throw new Error( 'GoldenLayout more than one container element specified' );
}
if( container[ 0 ] === document.body ) {
this._isFullPage = true;
$( 'html, body' ).css({
height: '100%',
margin:0,
padding: 0,
overflow: 'hidden'
});
}
this.container = container;
},
/**
* Kicks of the initial, recursive creation chain
*
* @param {Object} config GoldenLayout Config
*
* @returns {void}
*/
_create: function( config ) {
var errorMsg;
if( !( config.content instanceof Array ) ) {
if( config.content === undefined ) {
errorMsg = 'Missing setting \'content\' on top level of configuration';
} else {
errorMsg = 'Configuration parameter \'content\' must be an array';
}
throw new lm.errors.ConfigurationError( errorMsg, config );
}
if( config.content.length > 1 ) {
errorMsg = 'Top level content can\'t contain more then one element.';
throw new lm.errors.ConfigurationError( errorMsg, config );
}
this.root = new lm.items.Root( this, { content: config.content }, this.container );
this.root.callDownwards( '_$init' );
if( config.maximisedItemId === '__glMaximised' ) {
this.root.getItemsById( config.maximisedItemId )[ 0 ].toggleMaximise();
}
},
/**
* Called when the window is closed or the user navigates away
* from the page
*
* @returns {void}
*/
_onUnload: function() {
if( this.config.settings.closePopoutsOnUnload === true ) {
for( var i = 0; i < this.openPopouts.length; i++ ) {
this.openPopouts[ i ].close();
}
}
}
});
/**
* Expose the Layoutmanager as the single entrypoint using UMD
*/
(function () {
/* global define */
if ( typeof define === 'function' && define.amd) {
define([ 'jquery' ], function( jquery ){ $ = jquery; return lm.LayoutManager; }); // jshint ignore:line
} else if (typeof exports === 'object') {
module.exports = lm.LayoutManager;
} else {
window.GoldenLayout = lm.LayoutManager;
}
})();