UNPKG

golden-layout

Version:

A multi-screen javascript Layout manager https://golden-layout.com

1,916 lines (1,652 loc) 128 kB
(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, 'j&#97;vascript' ) .replace( /expression/gi, 'expr&#101;ssion' ) .replace( /onload/gi, 'onlo&#97;d' ) .replace( /script/gi, '&#115;cript' ) .replace( /onerror/gi, 'on&#101;rror' ); if( keepTags === true ) { return output; } else { return output .replace( />/g, '&gt;' ) .replace( /</g, '&lt;' ); } }; /** * 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