golden-layout
Version:
A multi-screen javascript Layout manager https://golden-layout.com
1,916 lines (1,652 loc) • 128 kB
JavaScript
(function($){var lm={"config":{},"container":{},"controls":{},"errors":{},"items":{},"utils":{}};
lm.utils.F = function () {};
lm.utils.extend = function( subClass, superClass ) {
subClass.prototype = lm.utils.createObject( superClass.prototype );
subClass.prototype.contructor = subClass;
};
lm.utils.createObject = function( prototype ) {
if( typeof Object.create === 'function' ) {
return Object.create( prototype );
} else {
lm.utils.F.prototype = prototype;
return new lm.utils.F();
}
};
lm.utils.objectKeys = function( object ) {
var keys, key;
if( typeof Object.keys === 'function' ) {
return Object.keys( object );
} else {
keys = [];
for( key in object ) {
keys.push( key );
}
return keys;
}
};
lm.utils.getQueryStringParam = function( param ) {
if( !window.location.search ) {
return null;
}
var keyValuePairs = window.location.search.substr( 1 ).split( '&' ),
params = {},
pair,
i;
for( i = 0; i < keyValuePairs.length; i++ ) {
pair = keyValuePairs[ i ].split( '=' );
params[ pair[ 0 ] ] = pair[ 1 ];
}
return params[ param ] || null;
};
lm.utils.copy = function( target, source ) {
for( var key in source ) {
target[ key ] = source[ key ];
}
return target;
};
/**
* This is based on Paul Irish's shim, but looks quite odd in comparison. Why?
* Because
* a) it shouldn't affect the global requestAnimationFrame function
* b) it shouldn't pass on the time that has passed
*
* @param {Function} fn
*
* @returns {void}
*/
lm.utils.animFrame = function( fn ){
return ( window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
function( callback ){
window.setTimeout(callback, 1000 / 60);
})(function(){
fn();
});
};
lm.utils.indexOf = function( needle, haystack ) {
if( !( haystack instanceof Array ) ) {
throw new Error( 'Haystack is not an Array' );
}
if( haystack.indexOf ) {
return haystack.indexOf( needle );
} else {
for( var i = 0; i < haystack.length; i++ ) {
if( haystack[ i ] === needle ) {
return i;
}
}
return -1;
}
};
if ( typeof /./ != 'function' && typeof Int8Array != 'object' ) {
lm.utils.isFunction = function ( obj ) {
return typeof obj == 'function' || false;
};
} else {
lm.utils.isFunction = function ( obj ) {
return toString.call(obj) === '[object Function]';
};
}
lm.utils.fnBind = function( fn, context, boundArgs ) {
if( Function.prototype.bind !== undefined ) {
return Function.prototype.bind.apply( fn, [ context ].concat( boundArgs || [] ) );
}
var bound = function () {
// Join the already applied arguments to the now called ones (after converting to an array again).
var args = ( boundArgs || [] ).concat(Array.prototype.slice.call(arguments, 0));
// If not being called as a constructor
if (!(this instanceof bound)){
// return the result of the function called bound to target and partially applied.
return fn.apply(context, args);
}
// If being called as a constructor, apply the function bound to self.
fn.apply(this, args);
};
// Attach the prototype of the function to our newly created function.
bound.prototype = fn.prototype;
return bound;
};
lm.utils.removeFromArray = function( item, array ) {
var index = lm.utils.indexOf( item, array );
if( index === -1 ) {
throw new Error( 'Can\'t remove item from array. Item is not in the array' );
}
array.splice( index, 1 );
};
lm.utils.now = function() {
if( typeof Date.now === 'function' ) {
return Date.now();
} else {
return ( new Date() ).getTime();
}
};
lm.utils.getUniqueId = function() {
return ( Math.random() * 1000000000000000 )
.toString(36)
.replace( '.', '' );
};
/**
* A basic XSS filter. It is ultimately up to the
* implementing developer to make sure their particular
* applications and usecases are save from cross site scripting attacks
*
* @param {String} input
* @param {Boolean} keepTags
*
* @returns {String} filtered input
*/
lm.utils.filterXss = function( input, keepTags ) {
var output = input
.replace( /javascript/gi, 'javascript' )
.replace( /expression/gi, 'expression' )
.replace( /onload/gi, 'onload' )
.replace( /script/gi, 'script' )
.replace( /onerror/gi, 'onerror' );
if( keepTags === true ) {
return output;
} else {
return output
.replace( />/g, '>' )
.replace( /</g, '<' );
}
};
/**
* Removes html tags from a string
*
* @param {String} input
*
* @returns {String} input without tags
*/
lm.utils.stripTags = function( input ) {
return $.trim( input.replace( /(<([^>]+)>)/ig, '' ) );
};
/**
* A generic and very fast EventEmitter
* implementation. On top of emitting the
* actual event it emits an
*
* lm.utils.EventEmitter.ALL_EVENT
*
* event for every event triggered. This allows
* to hook into it and proxy events forwards
*
* @constructor
*/
lm.utils.EventEmitter = function()
{
this._mSubscriptions = { };
this._mSubscriptions[ lm.utils.EventEmitter.ALL_EVENT ] = [];
/**
* Listen for events
*
* @param {String} sEvent The name of the event to listen to
* @param {Function} fCallback The callback to execute when the event occurs
* @param {[Object]} oContext The value of the this pointer within the callback function
*
* @returns {void}
*/
this.on = function( sEvent, fCallback, oContext )
{
if ( !lm.utils.isFunction(fCallback) ) {
throw new Error( 'Tried to listen to event ' + sEvent + ' with non-function callback ' + fCallback );
}
if( !this._mSubscriptions[ sEvent ] )
{
this._mSubscriptions[ sEvent ] = [];
}
this._mSubscriptions[ sEvent ].push({ fn: fCallback, ctx: oContext });
};
/**
* Emit an event and notify listeners
*
* @param {String} sEvent The name of the event
* @param {Mixed} various additional arguments that will be passed to the listener
*
* @returns {void}
*/
this.emit = function( sEvent )
{
var i, ctx, args;
args = Array.prototype.slice.call( arguments, 1 );
if( this._mSubscriptions[ sEvent ] ) {
for( i = 0; i < this._mSubscriptions[ sEvent ].length; i++ )
{
ctx = this._mSubscriptions[ sEvent ][ i ].ctx || {};
this._mSubscriptions[ sEvent ][ i ].fn.apply( ctx, args );
}
}
args.unshift( sEvent );
for( i = 0; i < this._mSubscriptions[ lm.utils.EventEmitter.ALL_EVENT ].length; i++ )
{
ctx = this._mSubscriptions[ lm.utils.EventEmitter.ALL_EVENT ][ i ].ctx || {};
this._mSubscriptions[ lm.utils.EventEmitter.ALL_EVENT ][ i ].fn.apply( ctx, args );
}
};
/**
* Removes a listener for an event, or all listeners if no callback and context is provided.
*
* @param {String} sEvent The name of the event
* @param {Function} fCallback The previously registered callback method (optional)
* @param {Object} oContext The previously registered context (optional)
*
* @returns {void}
*/
this.unbind = function( sEvent, fCallback, oContext )
{
if( !this._mSubscriptions[ sEvent ] ) {
throw new Error( 'No subscribtions to unsubscribe for event ' + sEvent );
}
var i, bUnbound = false;
for( i = 0; i < this._mSubscriptions[ sEvent ].length; i++ )
{
if
(
( !fCallback || this._mSubscriptions[ sEvent ][ i ].fn === fCallback ) &&
( !oContext || oContext === this._mSubscriptions[ sEvent ][ i ].ctx )
)
{
this._mSubscriptions[ sEvent ].splice( i, 1 );
bUnbound = true;
}
}
if( bUnbound === false )
{
throw new Error( 'Nothing to unbind for ' + sEvent );
}
};
/**
* Alias for unbind
*/
this.off = this.unbind;
/**
* Alias for emit
*/
this.trigger = this.emit;
};
/**
* The name of the event that's triggered for every other event
*
* usage
*
* myEmitter.on( lm.utils.EventEmitter.ALL_EVENT, function( eventName, argsArray ){
* //do stuff
* });
*
* @type {String}
*/
lm.utils.EventEmitter.ALL_EVENT = '__all';
lm.utils.DragListener = function(eElement, nButtonCode)
{
lm.utils.EventEmitter.call(this);
this._eElement = $(eElement);
this._oDocument = $(document);
this._eBody = $(document.body);
this._nButtonCode = nButtonCode || 0;
/**
* The delay after which to start the drag in milliseconds
*/
this._nDelay = 200;
/**
* The distance the mouse needs to be moved to qualify as a drag
*/
this._nDistance = 10;//TODO - works better with delay only
this._nX = 0;
this._nY = 0;
this._nOriginalX = 0;
this._nOriginalY = 0;
this._bDragging = false;
this._fMove = lm.utils.fnBind( this.onMouseMove, this );
this._fUp = lm.utils.fnBind( this.onMouseUp, this );
this._fDown = lm.utils.fnBind( this.onMouseDown, this );
this._eElement.on( 'mousedown touchstart', this._fDown );
};
lm.utils.DragListener.timeout = null;
lm.utils.copy( lm.utils.DragListener.prototype, {
destroy: function() {
this._eElement.unbind( 'mousedown touchstart', this._fDown );
},
onMouseDown: function(oEvent)
{
oEvent.preventDefault();
if (oEvent.button == 0) {
var coordinates = this._getCoordinates( oEvent );
this._nOriginalX = coordinates.x;
this._nOriginalY = coordinates.y;
this._oDocument.on( 'mousemove touchmove', this._fMove );
this._oDocument.one( 'mouseup touchend', this._fUp );
this._timeout = setTimeout( lm.utils.fnBind( this._startDrag, this ), this._nDelay );
}
},
onMouseMove: function(oEvent)
{
if (this._timeout != null) {
oEvent.preventDefault();
var coordinates = this._getCoordinates(oEvent);
this._nX = coordinates.x - this._nOriginalX;
this._nY = coordinates.y - this._nOriginalY;
if (this._bDragging === false) {
if (
Math.abs(this._nX) > this._nDistance ||
Math.abs(this._nY) > this._nDistance
) {
clearTimeout(this._timeout);
this._startDrag();
}
}
if (this._bDragging) {
this.emit('drag', this._nX, this._nY, oEvent);
}
}
},
onMouseUp: function(oEvent)
{
if(this._timeout != null) {
clearTimeout( this._timeout );
this._eBody.removeClass( 'lm_dragging' );
this._eElement.removeClass( 'lm_dragging' );
this._oDocument.find( 'iframe' ).css( 'pointer-events', '' );
this._oDocument.unbind( 'mousemove touchmove', this._fMove );
if( this._bDragging === true ) {
this._bDragging = false;
this.emit( 'dragStop', oEvent, this._nOriginalX + this._nX );
}
}
},
_startDrag: function()
{
this._bDragging = true;
this._eBody.addClass( 'lm_dragging' );
this._eElement.addClass( 'lm_dragging' );
this._oDocument.find( 'iframe' ).css( 'pointer-events', 'none' );
this.emit('dragStart', this._nOriginalX, this._nOriginalY);
},
_getCoordinates: function( event ) {
var coordinates = {};
if( event.type.substr( 0, 5 ) === 'touch' ) {
coordinates.x = event.originalEvent.targetTouches[ 0 ].pageX;
coordinates.y = event.originalEvent.targetTouches[ 0 ].pageY;
} else {
coordinates.x = event.pageX;
coordinates.y = event.pageY;
}
return coordinates;
}
});
/**
* 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;
}
})();
lm.config.itemDefaultConfig = {
isClosable: true,
reorderEnabled: true,
title: ''
};
lm.config.defaultConfig = {
openPopouts:[],
settings:{
hasHeaders: true,
constrainDragToContainer: true,
reorderEnabled: true,
selectionEnabled: false,
popoutWholeStack: false,
blockedPopoutsThrowError: true,
closePopoutsOnUnload: true,
showPopoutIcon: true,
showMaximiseIcon: true,
showCloseIcon: true
},
dimensions: {
borderWidth: 5,
minItemHeight: 10,
minItemWidth: 10,
headerHeight: 20,
dragProxyWidth: 300,
dragProxyHeight: 200
},
labels: {
close: 'close',
maximise: 'maximise',
minimise: 'minimise',
popout: 'open in new window',
popin: 'pop in'
}
};
lm.container.ItemContainer = function( config, parent, layoutManager ) {
lm.utils.EventEmitter.call( this );
this.width = null;
this.height = null;
this.title = config.componentName;
this.parent = parent;
this.layoutManager = layoutManager;
this.isHidden = false;
this._config = config;
this._element = $([
'<div class="lm_item_container">',
'<div class="lm_content"></div>',
'</div>'
].join( '' ));
this._contentElement = this._element.find( '.lm_content' );
};
lm.utils.copy( lm.container.ItemContainer.prototype, {
/**
* Get the inner DOM element the container's content
* is intended to live in
*
* @returns {DOM element}
*/
getElement: function() {
return this._contentElement;
},
/**
* Hide the container. Notifies the containers content first
* and then hides the DOM node. If the container is already hidden
* this should have no effect
*
* @returns {void}
*/
hide: function() {
this.emit( 'hide' );
this.isHidden = true;
this._element.hide();
},
/**
* Shows a previously hidden container. Notifies the
* containers content first and then shows the DOM element.
* If the container is already visible this has no effect.
*
* @returns {void}
*/
show: function() {
this.emit( 'show' );
this.isHidden = false;
this._element.show();
// call shown only if the container has a valid size
if(this.height != 0 || this.width != 0) {
this.emit( 'shown' );
}
},
/**
* Set the size from within the container. Traverses up
* the item tree until it finds a row or column element
* and resizes its items accordingly.
*
* If this container isn't a descendant of a row or column
* it returns false
* @todo Rework!!!
* @param {Number} width The new width in pixel
* @param {Number} height The new height in pixel
*
* @returns {Boolean} resizeSuccesful
*/
setSize: function( width, height ) {
var rowOrColumn = this.parent,
rowOrColumnChild = this,
totalPixel,
percentage,
direction,
newSize,
delta,
i;
while( !rowOrColumn.isColumn && !rowOrColumn.isRow ) {
rowOrColumnChild = rowOrColumn;
rowOrColumn = rowOrColumn.parent;
/**
* No row or column has been found
*/
if( rowOrColumn.isRoot ) {
return false;
}
}
direction = rowOrColumn.isColumn ? "height" : "width";
newSize = direction === "height" ? height : width;
totalPixel = this[direction] * ( 1 / ( rowOrColumnChild.config[direction] / 100 ) );
percentage = ( newSize / totalPixel ) * 100;
delta = ( rowOrColumnChild.config[direction] - percentage ) / rowOrColumn.contentItems.length;
for( i = 0; i < rowOrColumn.contentItems.length; i++ ) {
if( rowOrColumn.contentItems[ i ] === rowOrColumnChild ) {
rowOrColumn.contentItems[ i ].config[direction] = percentage;
} else {
rowOrColumn.contentItems[ i ].config[direction] += delta;
}
}
rowOrColumn.callDownwards( 'setSize' );
return true;
},
/**
* Closes the container if it is closable. Can be called by
* both the component within at as well as the contentItem containing
* it. Emits a close event before the container itself is closed.
*
* @returns {void}
*/
close: function() {
if( this._config.isClosable ) {
this.emit( 'close' );
this.parent.close();
}
},
/**
* Returns the current state object
*
* @returns {Object} state
*/
getState: function() {
return this._config.componentState;
},
/**
* Merges the provided state into the current one
*
* @param {Object} state
*
* @returns {void}
*/
extendState: function( state ) {
this.setState( $.extend( true, this.getState(), state ) );
},
/**
* Notifies the layout manager of a stateupdate
*
* @param {serialisable} state
*/
setState: function( state ) {
this._config.componentState = state;
this.parent.emitBubblingEvent( 'stateChanged' );
},
/**
* Set's the components title
*
* @param {String} title
*/
setTitle: function( title ) {
this.parent.setTitle( title );
},
/**
* Set's the containers size. Called by the container's component.
* To set the size programmatically from within the container please
* use the public setSize method
*
* @param {[Int]} width in px
* @param {[Int]} height in px
*
* @returns {void}
*/
_$setSize: function( width, height ) {
if( width !== this.width || height !== this.height ) {
this.width = width;
this.height = height;
this._contentElement.width( this.width ).height( this.height );
this.emit( 'resize' );
}
}
});
/**
* Pops a content item out into a new browser window.
* This is achieved by
*
* - Creating a new configuration with the content item as root element
* - Serializing and minifying the configuration
* - Opening the current window's URL with the configuration as a GET parameter
* - GoldenLayout when opened in the new window will look for the GET parameter
* and use it instead of the provided configuration
*
* @param {Object} config GoldenLayout item config
* @param {Object} dimensions A map with width, height, top and left
* @param {String} parentId The id of the element the item will be appended to on popIn
* @param {Number} indexInParent The position of this element within its parent
* @param {lm.LayoutManager} layoutManager
*/
lm.controls.BrowserPopout = function( config, dimensions, parentId, indexInParent, layoutManager ) {
lm.utils.EventEmitter.call( this );
this.isInitialised = false;
this._config = config;
this._dimensions = dimensions;
this._parentId = parentId;
this._indexInParent = indexInParent;
this._layoutManager = layoutManager;
this._popoutWindow = null;
this._id = null;
this._createWindow();
};
lm.utils.copy( lm.controls.BrowserPopout.prototype, {
toConfig: function() {
return {
dimensions:{
width: this.getGlInstance().width,
height: this.getGlInstance().height,
left: this._popoutWindow.screenX || this._popoutWindow.screenLeft,
top: this._popoutWindow.screenY || this._popoutWindow.screenTop
},
content: this.getGlInstance().toConfig().content,
parentId: this._parentId,
indexInParent: this._indexInParent
};
},
getGlInstance: function() {
return this._popoutWindow.__glInstance;
},
getWindow: function() {
return this._popoutWindow;
},
close: function() {
if( this.getGlInstance() ) {
this.getGlInstance()._$closeWindow();
} else {
try{
this.getWindow().close();
} catch( e ){}
}
},
/**
* Returns the popped out item to its original position. If the original
* parent isn't available anymore it falls back to the layout's topmost element
*/
popIn: function() {
var childConfig,
parentItem,
index = this._indexInParent;
if( this._parentId ) {
/*
* The $.extend call seems a bit pointless, but it's crucial to
* copy the config returned by this.getGlInstance().toConfig()
* onto a new object. Internet Explorer keeps the references
* to objects on the child window, resulting in the following error
* once the child window is closed:
*
* The callee (server [not server application]) is not available and disappeared
*/
childConfig = $.extend( true, {}, this.getGlInstance().toConfig() ).content[ 0 ];
parentItem = this._layoutManager.root.getItemsById( this._parentId )[ 0 ];
/*
* Fallback if parentItem is not available. Either add it to the topmost
* item or make it the topmost item if the layout is empty
*/
if( !parentItem ) {
if( this._layoutManager.root.contentItems.length > 0 ) {
parentItem = this._layoutManager.root.contentItems[ 0 ];
} else {
parentItem = this._layoutManager.root;
}
index = 0;
}
}
parentItem.addChild( childConfig, this._indexInParent );
this.close();
},
/**
* Creates the URL and window parameter
* and opens a new window
*
* @private
*
* @returns {void}
*/
_createWindow: function() {
var checkReadyInterval,
url = this._createUrl(),
/**
* Bogus title to prevent re-usage of existing window with the
* same title. The actual title will be set by the new window's
* GoldenLayout instance if it detects that it is in subWindowMode
*/
title = Math.floor( Math.random() * 1000000 ).toString( 36 ),
/**
* The options as used in the window.open string
*/
options = this._serializeWindowOptions({
width: this._dimensions.width,
height: this._dimensions.height,
innerWidth: this._dimensions.width,
innerHeight: this._dimensions.height,
menubar: 'no',
toolbar: 'no',
location: 'no',
personalbar: 'no',
resizable: 'yes',
scrollbars: 'no',
status: 'no'
});
this._popoutWindow = window.open( url, title, options );
if( !this._popoutWindow ) {
if( this._layoutManager.config.settings.blockedPopoutsThrowError === true ) {
var error = new Error( 'Popout blocked' );
error.type = 'popoutBlocked';
throw error;
} else {
return;
}
}
$( this._popoutWindow )
.on( 'load', lm.utils.fnBind( this._positionWindow, this ) )
.on( 'unload beforeunload', lm.utils.fnBind( this._onClose, this ) );
/**
* Polling the childwindow to find out if GoldenLayout has been initialised
* doesn't seem optimal, but the alternatives - adding a callback to the parent
* window or raising an event on the window object - both would introduce knowledge
* about the parent to the child window which we'd rather avoid
*/
checkReadyInterval = setInterval(lm.utils.fnBind(function(){
if( this._popoutWindow.__glInstance && this._popoutWindow.__glInstance.isInitialised ) {
this._onInitialised();
clearInterval( checkReadyInterval );
}
}, this ), 10 );
},
/**
* Serialises a map of key:values to a window options string
*
* @param {Object} windowOptions
*
* @returns {String} serialised window options
*/
_serializeWindowOptions: function( windowOptions ) {
var windowOptionsString = [], key;
for( key in windowOptions ) {
windowOptionsString.push( key + '=' + windowOptions[ key ] );
}
return windowOptionsString.join( ',' );
},
/**
* Creates the URL for the new window, including the
* config GET parameter
*
* @returns {String} URL
*/
_createUrl: function() {
var config = { content: this._config },
storageKey = 'gl-window-config-' + lm.utils.getUniqueId(),
urlParts;
config = ( new lm.utils.ConfigMinifier() ).minifyConfig( config );
try{
localStorage.setItem( storageKey, JSON.stringify( config ) );
} catch( e ) {
throw new Error( 'Error while writing to localStorage ' + e.toString() );
}
urlParts = document.location.href.split( '?' );
// URL doesn't contain GET-parameters
if( urlParts.length === 1 ) {
return urlParts[ 0 ] + '?gl-window=' + storageKey;
// URL contains GET-parameters
} else {
return document.location.href + '&gl-window=' + storageKey;
}
},
/**
* Move the newly created window roughly to
* where the component used to be.
*
* @private
*
* @returns {void}
*/
_positionWindow: function() {
this._popoutWindow.moveTo( this._dimensions.left, this._dimensions.top );
this._popoutWindow.focus();
},
/**
* Callback when the new window is opened and the GoldenLayout instance
* within it is initialised
*
* @returns {void}
*/
_onInitialised: function() {
this.isInitialised = true;
this.getGlInstance().on( 'popIn', this.popIn, this );
this.emit( 'initialised' );
},
/**
* Invoked 50ms after the window unload event
*
* @private
*
* @returns {void}
*/
_onClose: function() {
setTimeout( lm.utils.fnBind( this.emit, this, [ 'closed' ] ), 50 );
}
});
/**
* This class creates a temporary container
* for the component whilst it is being dragged
* and handles drag events
*
* @constructor
* @private
*
* @param {Number} x The initial x position
* @param {Number} y The initial y position
* @param {lm.utils.DragListener} dragListener
* @param {lm.LayoutManager} layoutManager
* @param {lm.item.AbstractContentItem} contentItem
* @param {lm.item.AbstractContentItem} originalParent
*/
lm.controls.DragProxy = function( x, y, dragListener, layoutManager, contentItem, originalParent ) {
lm.utils.EventEmitter.call( this );
this._dragListener = dragListener;
this._layoutManager = layoutManager;
this._contentItem = contentItem;
this._originalParent = originalParent;
this._area = null;
this._lastValidArea = null;
this._dragListener.on( 'drag', this._onDrag, this );
this._dragListener.on( 'dragStop', this._onDrop, this );
this.element = $( lm.controls.DragProxy._template );
this.element.css({ left: x, top: y });
this.element.find( '.lm_tab' ).attr( 'title', lm.utils.stripTags( this._contentItem.config.title ) );
this.element.find( '.lm_title' ).html( this._contentItem.config.title );
this.childElementContainer = this.element.find( '.lm_content' );
this.childElementContainer.append( contentItem.element );
this._updateTree();
this._layoutManager._$calculateItemAreas();
this._setDimensions();
$( document.body ).append( this.element );
var offset = this._layoutManager.container.offset();
this._minX = offset.left;
this._minY = offset.top;
this._maxX = this._layoutManager.container.width() + this._minX;
this._maxY = this._l