@kpi4me/golden-layout
Version:
A multi-screen javascript Layout manager https://golden-layout.com
1,810 lines (1,564 loc) • 153 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.getHashValue = function( key ) {
var matches = location.hash.match( new RegExp( key + '=([^&]*)' ) );
return matches ? matches[ 1 ] : null;
};
lm.utils.getQueryStringParam = function( param ) {
if( window.location.hash ) {
return lm.utils.getHashValue( param );
} else 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 );
var subs = this._mSubscriptions[ sEvent ];
if( subs ) {
subs = subs.slice();
for( i = 0; i < subs.length; i++ ) {
ctx = subs[ i ].ctx || {};
subs[ i ].fn.apply( ctx, args );
}
}
args.unshift( sEvent );
var allEventSubs = this._mSubscriptions[ lm.utils.EventEmitter.ALL_EVENT ].slice()
for( i = 0; i <allEventSubs.length; i++ ) {
ctx = allEventSubs[ i ].ctx || {};
allEventSubs[ 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 );
this._oDocument.unbind( 'mouseup touchend', this._fUp );
this._eElement = null;
this._oDocument = null;
this._eBody = null;
},
onMouseDown: function( oEvent ) {
oEvent.preventDefault();
if( oEvent.button == 0 || oEvent.type === "touchstart" ) {
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 );
this._oDocument.unbind( 'mouseup touchend', this._fUp );
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 ) {
event = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[ 0 ] : event;
return {
x: event.pageX,
y: event.pageY
};
}
} );
/**
* 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._dragSources = [];
this._updatingColumnsResponsive = false;
this._firstLoad = true;
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._adjustColumnsResponsive();
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', [ this.width, this.height ] );
if( this._maximisedItem ) {
this._maximisedItem.element.width( this.container.width() );
this._maximisedItem.element.height( this.container.height() );
this._maximisedItem.callDownwards( 'setSize' );
}
this._adjustColumnsResponsive();
}
},
/**
* 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();
this._dragSources.forEach( function( dragSource ) {
dragSource._dragListener.destroy();
dragSource._element = null;
dragSource._itemConfig = null;
dragSource._dragListener = null;
} );
this._dragSources = [];
},
/**
* 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;
var dragSource = new lm.controls.DragSource( $( element ), itemConfig, this );
this._dragSources.push( dragSource );
return dragSource;
},
/**
* 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;
},
_$createRootItemAreas: function() {
var areaSize = 50;
var sides = { y2: 0, x2: 0, y1: 'y2', x1: 'x2' };
for( var side in sides ) {
var area = this.root._$getArea();
area.side = side;
if( sides [ side ] )
area[ side ] = area[ sides [ side ] ] - areaSize;
else
area[ side ] = areaSize;
area.surface = ( area.x2 - area.x1 ) * ( area.y2 - area.y1 );
this._itemAreas.push( area );
}
},
_$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;
}
this._$createRootItemAreas();
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 );
var header = {};
lm.utils.copy( header, area );
lm.utils.copy( header, area.contentItem._contentAreaDimensions.header.highlightArea );
header.surface = ( header.x2 - header.x1 ) * ( header.y2 - header.y1 );
this._itemAreas.push( header );
}
}
},
/**
* 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();
}
}
},
/**
* Adjusts the number of columns to be lower to fit the screen and still maintain minItemWidth.
*
* @returns {void}
*/
_adjustColumnsResponsive: function() {
// If there is no min width set, or not content items, do nothing.
if( !this._useResponsiveLayout() || this._updatingColumnsResponsive || !this.config.dimensions || !this.config.dimensions.minItemWidth || this.root.contentItems.length === 0 || !this.root.contentItems[ 0 ].isRow ) {
this._firstLoad = false;
return;
}
this._firstLoad = false;
// If there is only one column, do nothing.
var columnCount = this.root.contentItems[ 0 ].contentItems.length;
if( columnCount <= 1 ) {
return;
}
// If they all still fit, do nothing.
var minItemWidth = this.config.dimensions.minItemWidth;
var totalMinWidth = columnCount * minItemWidth;
if( totalMinWidth <= this.width ) {
return;
}
// Prevent updates while it is already happening.
this._updatingColumnsResponsive = true;
// Figure out how many columns to stack, and put them all in the first stack container.
var finalColumnCount = Math.max( Math.floor( this.width / minItemWidth ), 1 );
var stackColumnCount = columnCount - finalColumnCount;
var rootContentItem = this.root.contentItems[ 0 ];
var firstStackContainer = this._findAllStackContainers()[ 0 ];
for( var i = 0; i < stackColumnCount; i++ ) {
// Stack from right.
var column = rootContentItem.contentItems[ rootContentItem.contentItems.length - 1 ];
this._addChildContentItemsToContainer( firstStackContainer, column );
}
this._updatingColumnsResponsive = false;
},
/**
* Determines if responsive layout should be used.
*
* @returns {bool} - True if responsive layout should be used; otherwise false.
*/
_useResponsiveLayout: function() {
return this.config.settings && ( this.config.settings.responsiveMode == 'always' || ( this.config.settings.responsiveMode == 'onload' && this._firstLoad ) );
},
/**
* Adds all children of a node to another container recursively.
* @param {object} container - Container to add child content items to.
* @param {object} node - Node to search for content items.
* @returns {void}
*/
_addChildContentItemsToContainer: function( container, node ) {
if( node.type === 'stack' ) {
node.contentItems.forEach( function( item ) {
container.addChild( item );
node.removeChild( item, true );
} );
}
else {
node.contentItems.forEach( lm.utils.fnBind( function( item ) {
this._addChildContentItemsToContainer( container, item );
}, this ) );
}
},
/**
* Finds all the stack containers.
* @returns {array} - The found stack containers.
*/
_findAllStackContainers: function() {
var stackContainers = [];
this._findAllStackContainersRecursive( stackContainers, this.root );
return stackContainers;
},
/**
* Finds all the stack containers.
*
* @param {array} - Set of containers to populate.
* @param {object} - Current node to process.
*
* @returns {void}
*/
_findAllStackContainersRecursive: function( stackContainers, node ) {
node.contentItems.forEach( lm.utils.fnBind( function( item ) {
if( item.type == 'stack' ) {
stackContainers.push( item );
}
else if( !item.isComponent ) {
this._findAllStackContainersRecursive( stackContainers, item );
}
}, this ) );
}
} );
/**
* 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.defaultConfig = {
openPopouts: [],
settings: {
hasHeaders: true,
constrainDragToContainer: true,
reorderEnabled: true,
selectionEnabled: false,
popoutWholeStack: false,
blockedPopoutsThrowError: true,
closePopoutsOnUnload: true,
showPopoutIcon: true,
showMaximiseIcon: true,
showCloseIcon: true,
responsiveMode: 'onload', // Can be onload, always, or none.
tabOverlapAllowance: 0, // maximum pixel overlap per tab
reorderOnTabMenuClick: true,
tabControlOffset: 10
},
dimensions: {
borderWidth: 5,
borderGrabWidth: 15,
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',
tabDropdown: 'additional tabs'
}
};
lm.config.itemDefaultConfig = {
isClosable: true,
reorderEnabled: true,
title: ''
};
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 - 1);
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;
var cl = this._contentElement[0];
var hdelta = cl.offsetWidth - cl.clientWidth;
var vdelta = cl.offsetHeight - cl.clientHeight;
this._contentElement.width( this.width-hdelta )
.height( this.height-vdelta );
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() {
if( this.isInitialised === false ) {
throw new Error( 'Can\'t create config, layout not yet initialised' );
return;
}
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._par