diff options
author | Tyler Schicke <tyler_schicke@brown.edu> | 2019-05-07 16:29:02 -0400 |
---|---|---|
committer | Tyler Schicke <tyler_schicke@brown.edu> | 2019-05-07 16:29:02 -0400 |
commit | 14c776b6d30e0bc0d5b3712f28e4b9f1170eae3b (patch) | |
tree | 5255d8cce8a72a5b09cc1ad58661e2176295467a /src/client/goldenLayout.js | |
parent | e19fdbba4cf672aee5bfb59b91b6162431d146d3 (diff) | |
parent | 26141a697ae52a7edf3cc6845ce2153111f8860e (diff) |
Merge branch 'master' of github-tsch-brown:browngraphicslab/Dash-Web into new_search
Diffstat (limited to 'src/client/goldenLayout.js')
-rw-r--r-- | src/client/goldenLayout.js | 5359 |
1 files changed, 5359 insertions, 0 deletions
diff --git a/src/client/goldenLayout.js b/src/client/goldenLayout.js new file mode 100644 index 000000000..56a71f1ac --- /dev/null +++ b/src/client/goldenLayout.js @@ -0,0 +1,5359 @@ +(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.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, + 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.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._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); + if (originalParent && originalParent._side) { + this._sided = originalParent._sided; + this.element.addClass('lm_' + originalParent._side); + if (['right', 'bottom'].indexOf(originalParent._side) >= 0) + this.element.find('.lm_content').after(this.element.find('.lm_header')); + } + 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._layoutManager.container.height() + this._minY; + this._width = this.element.width(); + this._height = this.element.height(); + + this._setDropPosition(x, y); + }; + + lm.controls.DragProxy._template = '<div class="lm_dragProxy">' + + '<div class="lm_header">' + + '<ul class="lm_tabs">' + + '<li class="lm_tab lm_active"><i class="lm_left"></i>' + + '<span class="lm_title"></span>' + + '<i class="lm_right"></i></li>' + + '</ul>' + + '</div>' + + '<div class="lm_content"></div>' + + '</div>'; + + lm.utils.copy(lm.controls.DragProxy.prototype, { + + /** + * Callback on every mouseMove event during a drag. Determines if the drag is + * still within the valid drag area and calls the layoutManager to highlight the + * current drop area + * + * @param {Number} offsetX The difference from the original x position in px + * @param {Number} offsetY The difference from the original y position in px + * @param {jQuery DOM event} event + * + * @private + * + * @returns {void} + */ + _onDrag: function (offsetX, offsetY, event) { + + event = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0] : event; + + var x = event.pageX, + y = event.pageY, + isWithinContainer = x > this._minX && x < this._maxX && y > this._minY && y < this._maxY; + + if (!isWithinContainer && this._layoutManager.config.settings.constrainDragToContainer === true) { + return; + } + + this._setDropPosition(x, y); + }, + + /** + * Sets the target position, highlighting the appropriate area + * + * @param {Number} x The x position in px + * @param {Number} y The y position in px + * + * @private + * + * @returns {void} + */ + _setDropPosition: function (x, y) { + this.element.css({ left: x, top: y }); + this._area = this._layoutManager._$getArea(x, y); + + if (this._area !== null) { + this._lastValidArea = this._area; + this._area.contentItem._$highlightDropZone(x, y, this._area); + } + }, + + /** + * Callback when the drag has finished. Determines the drop area + * and adds the child to it + * + * @private + * + * @returns {void} + */ + _onDrop: function () { + this._layoutManager.dropTargetIndicator.hide(); + + /* + * Valid drop area found + */ + if (this._area !== null) { + this._area.contentItem._$onDrop(this._contentItem, this._area); + + /** + * No valid drop area available at present, but one has been found before. + * Use it + */ + } else if (this._lastValidArea !== null) { + this._lastValidArea.contentItem._$onDrop(this._contentItem, this._lastValidArea); + + /** + * No valid drop area found during the duration of the drag. Return + * content item to its original position if a original parent is provided. + * (Which is not the case if the drag had been initiated by createDragSource) + */ + } else if (this._originalParent) { + this._originalParent.addChild(this._contentItem); + + /** + * The drag didn't ultimately end up with adding the content item to + * any container. In order to ensure clean up happens, destroy the + * content item. + */ + } else { + this._contentItem._$destroy(); + } + + this.element.remove(); + + this._layoutManager.emit('itemDropped', this._contentItem); + }, + + /** + * Removes the item from its original position within the tree + * + * @private + * + * @returns {void} + */ + _updateTree: function () { + + /** + * parent is null if the drag had been initiated by a external drag source + */ + if (this._contentItem.parent) { + this._contentItem.parent.removeChild(this._contentItem, true); + } + + this._contentItem._$setParent(this); + }, + + /** + * Updates the Drag Proxie's dimensions + * + * @private + * + * @returns {void} + */ + _setDimensions: function () { + var dimensions = this._layoutManager.config.dimensions, + width = dimensions.dragProxyWidth, + height = dimensions.dragProxyHeight; + + this.element.width(width); + this.element.height(height); + width -= (this._sided ? dimensions.headerHeight : 0); + height -= (!this._sided ? dimensions.headerHeight : 0); + this.childElementContainer.width(width); + this.childElementContainer.height(height); + this._contentItem.element.width(width); + this._contentItem.element.height(height); + this._contentItem.callDownwards('_$show'); + this._contentItem.callDownwards('setSize'); + } + }); + + /** + * Allows for any DOM item to create a component on drag + * start tobe dragged into the Layout + * + * @param {jQuery element} element + * @param {Object} itemConfig the configuration for the contentItem that will be created + * @param {LayoutManager} layoutManager + * + * @constructor + */ + lm.controls.DragSource = function (element, itemConfig, layoutManager) { + this._element = element; + this._itemConfig = itemConfig; + this._layoutManager = layoutManager; + this._dragListener = null; + + this._createDragListener(); + }; + + lm.utils.copy(lm.controls.DragSource.prototype, { + + /** + * Called initially and after every drag + * + * @returns {void} + */ + _createDragListener: function () { + if (this._dragListener !== null) { + this._dragListener.destroy(); + } + + this._dragListener = new lm.utils.DragListener(this._element); + this._dragListener.on('dragStart', this._onDragStart, this); + this._dragListener.on('dragStop', this._createDragListener, this); + }, + + /** + * Callback for the DragListener's dragStart event + * + * @param {int} x the x position of the mouse on dragStart + * @param {int} y the x position of the mouse on dragStart + * + * @returns {void} + */ + _onDragStart: function (x, y) { + var itemConfig = this._itemConfig; + if (lm.utils.isFunction(itemConfig)) { + itemConfig = itemConfig(); + } + var contentItem = this._layoutManager._$normalizeContentItem($.extend(true, {}, itemConfig)), + dragProxy = new lm.controls.DragProxy(x, y, this._dragListener, this._layoutManager, contentItem, null); + + this._layoutManager.transitionIndicator.transitionElements(this._element, dragProxy.element); + } + }); + + lm.controls.DropTargetIndicator = function () { + this.element = $(lm.controls.DropTargetIndicator._template); + $(document.body).append(this.element); + }; + + lm.controls.DropTargetIndicator._template = '<div class="lm_dropTargetIndicator"><div class="lm_inner"></div></div>'; + + lm.utils.copy(lm.controls.DropTargetIndicator.prototype, { + destroy: function () { + this.element.remove(); + }, + + highlight: function (x1, y1, x2, y2) { + this.highlightArea({ x1: x1, y1: y1, x2: x2, y2: y2 }); + }, + + highlightArea: function (area) { + this.element.css({ + left: area.x1, + top: area.y1, + width: area.x2 - area.x1, + height: area.y2 - area.y1 + }).show(); + }, + + hide: function () { + this.element.hide(); + } + }); + /** + * This class represents a header above a Stack ContentItem. + * + * @param {lm.LayoutManager} layoutManager + * @param {lm.item.AbstractContentItem} parent + */ + lm.controls.Header = function (layoutManager, parent) { + lm.utils.EventEmitter.call(this); + + this.layoutManager = layoutManager; + this.element = $(lm.controls.Header._template); + + if (this.layoutManager.config.settings.selectionEnabled === true) { + this.element.addClass('lm_selectable'); + this.element.on('click touchstart', lm.utils.fnBind(this._onHeaderClick, this)); + } + + this.tabsContainer = this.element.find('.lm_tabs'); + this.tabDropdownContainer = this.element.find('.lm_tabdropdown_list'); + this.tabDropdownContainer.hide(); + this.controlsContainer = this.element.find('.lm_controls'); + this.parent = parent; + this.parent.on('resize', this._updateTabSizes, this); + this.tabs = []; + this.activeContentItem = null; + this.closeButton = null; + this.tabDropdownButton = null; + this.hideAdditionalTabsDropdown = lm.utils.fnBind(this._hideAdditionalTabsDropdown, this); + $(document).mouseup(this.hideAdditionalTabsDropdown); + + this._lastVisibleTabIndex = -1; + this._tabControlOffset = this.layoutManager.config.settings.tabControlOffset; + this._createControls(); + }; + + lm.controls.Header._template = [ + '<div class="lm_header">', + '<ul class="lm_tabs"></ul>', + '<ul class="lm_controls"></ul>', + '<ul class="lm_tabdropdown_list"></ul>', + '</div>' + ].join(''); + + lm.utils.copy(lm.controls.Header.prototype, { + + /** + * Creates a new tab and associates it with a contentItem + * + * @param {lm.item.AbstractContentItem} contentItem + * @param {Integer} index The position of the tab + * + * @returns {void} + */ + createTab: function (contentItem, index) { + var tab, i; + + //If there's already a tab relating to the + //content item, don't do anything + for (i = 0; i < this.tabs.length; i++) { + if (this.tabs[i].contentItem === contentItem) { + return; + } + } + + tab = new lm.controls.Tab(this, contentItem); + + if (this.tabs.length === 0) { + this.tabs.push(tab); + this.tabsContainer.append(tab.element); + return; + } + + if (index === undefined) { + index = this.tabs.length; + } + + if (index > 0) { + this.tabs[index - 1].element.after(tab.element); + } else { + this.tabs[0].element.before(tab.element); + } + + this.tabs.splice(index, 0, tab); + this._updateTabSizes(); + }, + + /** + * Finds a tab based on the contentItem its associated with and removes it. + * + * @param {lm.item.AbstractContentItem} contentItem + * + * @returns {void} + */ + removeTab: function (contentItem) { + for (var i = 0; i < this.tabs.length; i++) { + if (this.tabs[i].contentItem === contentItem) { + this.tabs[i]._$destroy(); + this.tabs.splice(i, 1); + return; + } + } + + throw new Error('contentItem is not controlled by this header'); + }, + + /** + * The programmatical equivalent of clicking a Tab. + * + * @param {lm.item.AbstractContentItem} contentItem + */ + setActiveContentItem: function (contentItem) { + var i, j, isActive, activeTab; + + for (i = 0; i < this.tabs.length; i++) { + isActive = this.tabs[i].contentItem === contentItem; + this.tabs[i].setActive(isActive); + if (isActive === true) { + this.activeContentItem = contentItem; + this.parent.config.activeItemIndex = i; + } + } + + if (this.layoutManager.config.settings.reorderOnTabMenuClick) { + /** + * If the tab selected was in the dropdown, move everything down one to make way for this one to be the first. + * This will make sure the most used tabs stay visible. + */ + if (this._lastVisibleTabIndex !== -1 && this.parent.config.activeItemIndex > this._lastVisibleTabIndex) { + activeTab = this.tabs[this.parent.config.activeItemIndex]; + for (j = this.parent.config.activeItemIndex; j > 0; j--) { + this.tabs[j] = this.tabs[j - 1]; + } + this.tabs[0] = activeTab; + this.parent.config.activeItemIndex = 0; + } + } + + this._updateTabSizes(); + this.parent.emitBubblingEvent('stateChanged'); + }, + + /** + * Programmatically operate with header position. + * + * @param {string} position one of ('top','left','right','bottom') to set or empty to get it. + * + * @returns {string} previous header position + */ + position: function (position) { + var previous = this.parent._header.show; + if (previous && !this.parent._side) + previous = 'top'; + if (position !== undefined && this.parent._header.show != position) { + this.parent._header.show = position; + this.parent._setupHeaderPosition(); + } + return previous; + }, + + /** + * Programmatically set closability. + * + * @package private + * @param {Boolean} isClosable Whether to enable/disable closability. + * + * @returns {Boolean} Whether the action was successful + */ + _$setClosable: function (isClosable) { + if (this.closeButton && this._isClosable()) { + this.closeButton.element[isClosable ? "show" : "hide"](); + return true; + } + + return false; + }, + + /** + * Destroys the entire header + * + * @package private + * + * @returns {void} + */ + _$destroy: function () { + this.emit('destroy', this); + + for (var i = 0; i < this.tabs.length; i++) { + this.tabs[i]._$destroy(); + } + $(document).off('mouseup', this.hideAdditionalTabsDropdown); + this.element.remove(); + }, + + /** + * get settings from header + * + * @returns {string} when exists + */ + _getHeaderSetting: function (name) { + if (name in this.parent._header) + return this.parent._header[name]; + }, + /** + * Creates the popout, maximise and close buttons in the header's top right corner + * + * @returns {void} + */ + _createControls: function () { + var closeStack, + popout, + label, + maximiseLabel, + minimiseLabel, + maximise, + maximiseButton, + tabDropdownLabel, + showTabDropdown; + + /** + * Dropdown to show additional tabs. + */ + showTabDropdown = lm.utils.fnBind(this._showAdditionalTabsDropdown, this); + tabDropdownLabel = this.layoutManager.config.labels.tabDropdown; + this.tabDropdownButton = new lm.controls.HeaderButton(this, tabDropdownLabel, 'lm_tabdropdown', showTabDropdown); + this.tabDropdownButton.element.hide(); + + /** + * Popout control to launch component in new window. + */ + if (this._getHeaderSetting('popout')) { + popout = lm.utils.fnBind(this._onPopoutClick, this); + label = this._getHeaderSetting('popout'); + new lm.controls.HeaderButton(this, label, 'lm_popout', popout); + } + + /** + * Maximise control - set the component to the full size of the layout + */ + if (this._getHeaderSetting('maximise')) { + maximise = lm.utils.fnBind(this.parent.toggleMaximise, this.parent); + maximiseLabel = this._getHeaderSetting('maximise'); + minimiseLabel = this._getHeaderSetting('minimise'); + maximiseButton = new lm.controls.HeaderButton(this, maximiseLabel, 'lm_maximise', maximise); + + this.parent.on('maximised', function () { + maximiseButton.element.attr('title', minimiseLabel); + }); + + this.parent.on('minimised', function () { + maximiseButton.element.attr('title', maximiseLabel); + }); + } + + /** + * Close button + */ + if (this._isClosable()) { + closeStack = lm.utils.fnBind(this.parent.remove, this.parent); + label = this._getHeaderSetting('close'); + this.closeButton = new lm.controls.HeaderButton(this, label, 'lm_close', closeStack); + } + }, + + /** + * Shows drop down for additional tabs when there are too many to display. + * + * @returns {void} + */ + _showAdditionalTabsDropdown: function () { + this.tabDropdownContainer.show(); + }, + + /** + * Hides drop down for additional tabs when there are too many to display. + * + * @returns {void} + */ + _hideAdditionalTabsDropdown: function (e) { + this.tabDropdownContainer.hide(); + }, + + /** + * Checks whether the header is closable based on the parent config and + * the global config. + * + * @returns {Boolean} Whether the header is closable. + */ + _isClosable: function () { + return this.parent.config.isClosable && this.layoutManager.config.settings.showCloseIcon; + }, + + _onPopoutClick: function () { + if (this.layoutManager.config.settings.popoutWholeStack === true) { + this.parent.popout(); + } else { + this.activeContentItem.popout(); + } + }, + + + /** + * Invoked when the header's background is clicked (not it's tabs or controls) + * + * @param {jQuery DOM event} event + * + * @returns {void} + */ + _onHeaderClick: function (event) { + if (event.target === this.element[0]) { + this.parent.select(); + } + }, + + /** + * Pushes the tabs to the tab dropdown if the available space is not sufficient + * + * @returns {void} + */ + _updateTabSizes: function (showTabMenu) { + if (this.tabs.length === 0) { + return; + } + + //Show the menu based on function argument + this.tabDropdownButton.element.toggle(showTabMenu === true); + + var size = function (val) { + return val ? 'width' : 'height'; + }; + this.element.css(size(!this.parent._sided), ''); + this.element[size(this.parent._sided)](this.layoutManager.config.dimensions.headerHeight); + var availableWidth = this.element.outerWidth() - this.controlsContainer.outerWidth() - this._tabControlOffset, + cumulativeTabWidth = 0, + visibleTabWidth = 0, + tabElement, + i, + j, + marginLeft, + overlap = 0, + tabWidth, + tabOverlapAllowance = this.layoutManager.config.settings.tabOverlapAllowance, + tabOverlapAllowanceExceeded = false, + activeIndex = (this.activeContentItem ? this.tabs.indexOf(this.activeContentItem.tab) : 0), + activeTab = this.tabs[activeIndex]; + if (this.parent._sided) + availableWidth = this.element.outerHeight() - this.controlsContainer.outerHeight() - this._tabControlOffset; + this._lastVisibleTabIndex = -1; + + for (i = 0; i < this.tabs.length; i++) { + tabElement = this.tabs[i].element; + + //Put the tab in the tabContainer so its true width can be checked + this.tabsContainer.append(tabElement); + tabWidth = tabElement.outerWidth() + parseInt(tabElement.css('margin-right'), 10); + + cumulativeTabWidth += tabWidth; + + //Include the active tab's width if it isn't already + //This is to ensure there is room to show the active tab + if (activeIndex <= i) { + visibleTabWidth = cumulativeTabWidth; + } else { + visibleTabWidth = cumulativeTabWidth + activeTab.element.outerWidth() + parseInt(activeTab.element.css('margin-right'), 10); + } + + // If the tabs won't fit, check the overlap allowance. + if (visibleTabWidth > availableWidth) { + + //Once allowance is exceeded, all remaining tabs go to menu. + if (!tabOverlapAllowanceExceeded) { + + //No overlap for first tab or active tab + //Overlap spreads among non-active, non-first tabs + if (activeIndex > 0 && activeIndex <= i) { + overlap = (visibleTabWidth - availableWidth) / (i - 1); + } else { + overlap = (visibleTabWidth - availableWidth) / i; + } + + //Check overlap against allowance. + if (overlap < tabOverlapAllowance) { + for (j = 0; j <= i; j++) { + marginLeft = (j !== activeIndex && j !== 0) ? '-' + overlap + 'px' : ''; + this.tabs[j].element.css({ 'z-index': i - j, 'margin-left': marginLeft }); + } + this._lastVisibleTabIndex = i; + this.tabsContainer.append(tabElement); + } else { + tabOverlapAllowanceExceeded = true; + } + + } else if (i === activeIndex) { + //Active tab should show even if allowance exceeded. (We left room.) + tabElement.css({ 'z-index': 'auto', 'margin-left': '' }); + this.tabsContainer.append(tabElement); + } + + if (tabOverlapAllowanceExceeded && i !== activeIndex) { + if (showTabMenu) { + //Tab menu already shown, so we just add to it. + tabElement.css({ 'z-index': 'auto', 'margin-left': '' }); + this.tabDropdownContainer.append(tabElement); + } else { + //We now know the tab menu must be shown, so we have to recalculate everything. + this._updateTabSizes(true); + return; + } + } + + } + else { + this._lastVisibleTabIndex = i; + tabElement.css({ 'z-index': 'auto', 'margin-left': '' }); + this.tabsContainer.append(tabElement); + } + } + + } + }); + + + lm.controls.HeaderButton = function (header, label, cssClass, action) { + this._header = header; + this.element = $('<li class="' + cssClass + '" title="' + label + '"></li>'); + this._header.on('destroy', this._$destroy, this); + this._action = action; + this.element.on('click touchstart', this._action); + this._header.controlsContainer.append(this.element); + }; + + lm.utils.copy(lm.controls.HeaderButton.prototype, { + _$destroy: function () { + this.element.off(); + this.element.remove(); + } + }); + lm.controls.Splitter = function (isVertical, size, grabSize) { + this._isVertical = isVertical; + this._size = size; + this._grabSize = grabSize < size ? size : grabSize; + + this.element = this._createElement(); + this._dragListener = new lm.utils.DragListener(this.element); + }; + + lm.utils.copy(lm.controls.Splitter.prototype, { + on: function (event, callback, context) { + this._dragListener.on(event, callback, context); + }, + + _$destroy: function () { + this.element.remove(); + }, + + _createElement: function () { + var dragHandle = $('<div class="lm_drag_handle"></div>'); + var element = $('<div class="lm_splitter"></div>'); + element.append(dragHandle); + + var handleExcessSize = this._grabSize - this._size; + var handleExcessPos = handleExcessSize / 2; + + if (this._isVertical) { + dragHandle.css('top', -handleExcessPos); + dragHandle.css('height', this._size + handleExcessSize); + element.addClass('lm_vertical'); + element['height'](this._size); + } else { + dragHandle.css('left', -handleExcessPos); + dragHandle.css('width', this._size + handleExcessSize); + element.addClass('lm_horizontal'); + element['width'](this._size); + } + + return element; + } + }); + + /** + * Represents an individual tab within a Stack's header + * + * @param {lm.controls.Header} header + * @param {lm.items.AbstractContentItem} contentItem + * + * @constructor + */ + lm.controls.Tab = function (header, contentItem) { + this.header = header; + this.contentItem = contentItem; + this.element = $(lm.controls.Tab._template); + this.titleElement = this.element.find('.lm_title'); + this.closeElement = this.element.find('.lm_close_tab'); + this.closeElement[contentItem.config.isClosable ? 'show' : 'hide'](); + this.isActive = false; + + this.setTitle(contentItem.config.title); + this.contentItem.on('titleChanged', this.setTitle, this); + + this._layoutManager = this.contentItem.layoutManager; + + if ( + this._layoutManager.config.settings.reorderEnabled === true && + contentItem.config.reorderEnabled === true + ) { + this._dragListener = new lm.utils.DragListener(this.element); + this._dragListener.on('dragStart', this._onDragStart, this); + this.contentItem.on('destroy', this._dragListener.destroy, this._dragListener); + } + + this._onTabClickFn = lm.utils.fnBind(this._onTabClick, this); + this._onCloseClickFn = lm.utils.fnBind(this._onCloseClick, this); + + this.element.on('mousedown touchstart', this._onTabClickFn); + + if (this.contentItem.config.isClosable) { + this.closeElement.on('click touchstart', this._onCloseClickFn); + this.closeElement.on('mousedown', this._onCloseMousedown); + } else { + this.closeElement.remove(); + } + + this.contentItem.tab = this; + this.contentItem.emit('tab', this); + this.contentItem.layoutManager.emit('tabCreated', this); + + if (this.contentItem.isComponent) { + this.contentItem.container.tab = this; + this.contentItem.container.emit('tab', this); + } + }; + + /** + * The tab's html template + * + * @type {String} + */ + lm.controls.Tab._template = '<li class="lm_tab"><i class="lm_left"></i>' + + '<span class="lm_title"></span><div class="lm_close_tab"></div>' + + '<i class="lm_right"></i></li>'; + + lm.utils.copy(lm.controls.Tab.prototype, { + + /** + * Sets the tab's title to the provided string and sets + * its title attribute to a pure text representation (without + * html tags) of the same string. + * + * @public + * @param {String} title can contain html + */ + setTitle: function (title) { + this.element.attr('title', lm.utils.stripTags(title)); + this.titleElement.html(title); + }, + + /** + * Sets this tab's active state. To programmatically + * switch tabs, use header.setActiveContentItem( item ) instead. + * + * @public + * @param {Boolean} isActive + */ + setActive: function (isActive) { + if (isActive === this.isActive) { + return; + } + this.isActive = isActive; + + if (isActive) { + this.element.addClass('lm_active'); + } else { + this.element.removeClass('lm_active'); + } + }, + + /** + * Destroys the tab + * + * @private + * @returns {void} + */ + _$destroy: function () { + this.element.off('mousedown touchstart', this._onTabClickFn); + this.closeElement.off('click touchstart', this._onCloseClickFn); + if (this._dragListener) { + this.contentItem.off('destroy', this._dragListener.destroy, this._dragListener); + this._dragListener.off('dragStart', this._onDragStart); + this._dragListener = null; + } + this.element.remove(); + }, + + /** + * Callback for the DragListener + * + * @param {Number} x The tabs absolute x position + * @param {Number} y The tabs absolute y position + * + * @private + * @returns {void} + */ + _onDragStart: function (x, y) { + if (this.contentItem.parent.isMaximised === true) { + this.contentItem.parent.toggleMaximise(); + } + new lm.controls.DragProxy( + x, + y, + this._dragListener, + this._layoutManager, + this.contentItem, + this.header.parent + ); + }, + + /** + * Callback when the tab is clicked + * + * @param {jQuery DOM event} event + * + * @private + * @returns {void} + */ + _onTabClick: function (event) { + // left mouse button or tap + if (event.button === 0 || event.type === 'touchstart') { + var activeContentItem = this.header.parent.getActiveContentItem(); + if (this.contentItem !== activeContentItem) { + this.header.parent.setActiveContentItem(this.contentItem); + } + + // middle mouse button + } else if (event.button === 1 && this.contentItem.config.isClosable) { + this._onCloseClick(event); + } + }, + + /** + * Callback when the tab's close button is + * clicked + * + * @param {jQuery DOM event} event + * + * @private + * @returns {void} + */ + _onCloseClick: function (event) { + event.stopPropagation(); + this.header.parent.removeChild(this.contentItem); + }, + + + /** + * Callback to capture tab close button mousedown + * to prevent tab from activating. + * + * @param (jQuery DOM event) event + * + * @private + * @returns {void} + */ + _onCloseMousedown: function (event) { + event.stopPropagation(); + } + }); + + lm.controls.TransitionIndicator = function () { + this._element = $('<div class="lm_transition_indicator"></div>'); + $(document.body).append(this._element); + + this._toElement = null; + this._fromDimensions = null; + this._totalAnimationDuration = 200; + this._animationStartTime = null; + }; + + lm.utils.copy(lm.controls.TransitionIndicator.prototype, { + destroy: function () { + this._element.remove(); + }, + + transitionElements: function (fromElement, toElement) { + /** + * TODO - This is not quite as cool as expected. Review. + */ + return; + this._toElement = toElement; + this._animationStartTime = lm.utils.now(); + this._fromDimensions = this._measure(fromElement); + this._fromDimensions.opacity = 0.8; + this._element.show().css(this._fromDimensions); + lm.utils.animFrame(lm.utils.fnBind(this._nextAnimationFrame, this)); + }, + + _nextAnimationFrame: function () { + var toDimensions = this._measure(this._toElement), + animationProgress = (lm.utils.now() - this._animationStartTime) / this._totalAnimationDuration, + currentFrameStyles = {}, + cssProperty; + + if (animationProgress >= 1) { + this._element.hide(); + return; + } + + toDimensions.opacity = 0; + + for (cssProperty in this._fromDimensions) { + currentFrameStyles[cssProperty] = this._fromDimensions[cssProperty] + + (toDimensions[cssProperty] - this._fromDimensions[cssProperty]) * + animationProgress; + } + + this._element.css(currentFrameStyles); + lm.utils.animFrame(lm.utils.fnBind(this._nextAnimationFrame, this)); + }, + + _measure: function (element) { + var offset = element.offset(); + + return { + left: offset.left, + top: offset.top, + width: element.outerWidth(), + height: element.outerHeight() + }; + } + }); + lm.errors.ConfigurationError = function (message, node) { + Error.call(this); + + this.name = 'Configuration Error'; + this.message = message; + this.node = node; + }; + + lm.errors.ConfigurationError.prototype = new Error(); + + /** + * This is the baseclass that all content items inherit from. + * Most methods provide a subset of what the sub-classes do. + * + * It also provides a number of functions for tree traversal + * + * @param {lm.LayoutManager} layoutManager + * @param {item node configuration} config + * @param {lm.item} parent + * + * @event stateChanged + * @event beforeItemDestroyed + * @event itemDestroyed + * @event itemCreated + * @event componentCreated + * @event rowCreated + * @event columnCreated + * @event stackCreated + * + * @constructor + */ + lm.items.AbstractContentItem = function (layoutManager, config, parent) { + lm.utils.EventEmitter.call(this); + + this.config = this._extendItemNode(config); + this.type = config.type; + this.contentItems = []; + this.parent = parent; + + this.isInitialised = false; + this.isMaximised = false; + this.isRoot = false; + this.isRow = false; + this.isColumn = false; + this.isStack = false; + this.isComponent = false; + + this.layoutManager = layoutManager; + this._pendingEventPropagations = {}; + this._throttledEvents = ['stateChanged']; + + this.on(lm.utils.EventEmitter.ALL_EVENT, this._propagateEvent, this); + + if (config.content) { + this._createContentItems(config); + } + }; + + lm.utils.copy(lm.items.AbstractContentItem.prototype, { + + /** + * Set the size of the component and its children, called recursively + * + * @abstract + * @returns void + */ + setSize: function () { + throw new Error('Abstract Method'); + }, + + /** + * Calls a method recursively downwards on the tree + * + * @param {String} functionName the name of the function to be called + * @param {[Array]}functionArguments optional arguments that are passed to every function + * @param {[bool]} bottomUp Call methods from bottom to top, defaults to false + * @param {[bool]} skipSelf Don't invoke the method on the class that calls it, defaults to false + * + * @returns {void} + */ + callDownwards: function (functionName, functionArguments, bottomUp, skipSelf) { + var i; + + if (bottomUp !== true && skipSelf !== true) { + this[functionName].apply(this, functionArguments || []); + } + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].callDownwards(functionName, functionArguments, bottomUp); + } + if (bottomUp === true && skipSelf !== true) { + this[functionName].apply(this, functionArguments || []); + } + }, + + /** + * Removes a child node (and its children) from the tree + * + * @param {lm.items.ContentItem} contentItem + * + * @returns {void} + */ + removeChild: function (contentItem, keepChild) { + + /* + * Get the position of the item that's to be removed within all content items this node contains + */ + var index = lm.utils.indexOf(contentItem, this.contentItems); + + /* + * Make sure the content item to be removed is actually a child of this item + */ + if (index === -1) { + throw new Error('Can\'t remove child item. Unknown content item'); + } + + /** + * Call ._$destroy on the content item. This also calls ._$destroy on all its children + */ + if (keepChild !== true) { + this.contentItems[index]._$destroy(); + } + + /** + * Remove the content item from this nodes array of children + */ + this.contentItems.splice(index, 1); + + /** + * Remove the item from the configuration + */ + this.config.content.splice(index, 1); + + /** + * If this node still contains other content items, adjust their size + */ + if (this.contentItems.length > 0) { + this.callDownwards('setSize'); + + /** + * If this was the last content item, remove this node as well + */ + } else if (!(this instanceof lm.items.Root) && this.config.isClosable === true) { + this.parent.removeChild(this); + } + }, + + /** + * Sets up the tree structure for the newly added child + * The responsibility for the actual DOM manipulations lies + * with the concrete item + * + * @param {lm.items.AbstractContentItem} contentItem + * @param {[Int]} index If omitted item will be appended + */ + addChild: function (contentItem, index) { + if (index === undefined) { + index = this.contentItems.length; + } + + this.contentItems.splice(index, 0, contentItem); + + if (this.config.content === undefined) { + this.config.content = []; + } + + this.config.content.splice(index, 0, contentItem.config); + contentItem.parent = this; + + if (contentItem.parent.isInitialised === true && contentItem.isInitialised === false) { + contentItem._$init(); + } + }, + + /** + * Replaces oldChild with newChild. This used to use jQuery.replaceWith... which for + * some reason removes all event listeners, so isn't really an option. + * + * @param {lm.item.AbstractContentItem} oldChild + * @param {lm.item.AbstractContentItem} newChild + * + * @returns {void} + */ + replaceChild: function (oldChild, newChild, _$destroyOldChild) { + + newChild = this.layoutManager._$normalizeContentItem(newChild); + + var index = lm.utils.indexOf(oldChild, this.contentItems), + parentNode = oldChild.element[0].parentNode; + + if (index === -1) { + throw new Error('Can\'t replace child. oldChild is not child of this'); + } + + parentNode.replaceChild(newChild.element[0], oldChild.element[0]); + + /* + * Optionally destroy the old content item + */ + if (_$destroyOldChild === true) { + oldChild.parent = null; + oldChild._$destroy(); + } + + /* + * Wire the new contentItem into the tree + */ + this.contentItems[index] = newChild; + newChild.parent = this; + + /* + * Update tab reference + */ + if (this.isStack) { + this.header.tabs[index].contentItem = newChild; + } + + //TODO This doesn't update the config... refactor to leave item nodes untouched after creation + if (newChild.parent.isInitialised === true && newChild.isInitialised === false) { + newChild._$init(); + } + + this.callDownwards('setSize'); + }, + + /** + * Convenience method. + * Shorthand for this.parent.removeChild( this ) + * + * @returns {void} + */ + remove: function () { + this.parent.removeChild(this); + }, + + /** + * Removes the component from the layout and creates a new + * browser window with the component and its children inside + * + * @returns {lm.controls.BrowserPopout} + */ + popout: function () { + var browserPopout = this.layoutManager.createPopout(this); + this.emitBubblingEvent('stateChanged'); + return browserPopout; + }, + + /** + * Maximises the Item or minimises it if it is already maximised + * + * @returns {void} + */ + toggleMaximise: function (e) { + e && e.preventDefault(); + if (this.isMaximised === true) { + this.layoutManager._$minimiseItem(this); + } else { + this.layoutManager._$maximiseItem(this); + } + + this.isMaximised = !this.isMaximised; + this.emitBubblingEvent('stateChanged'); + }, + + /** + * Selects the item if it is not already selected + * + * @returns {void} + */ + select: function () { + if (this.layoutManager.selectedItem !== this) { + this.layoutManager.selectItem(this, true); + this.element.addClass('lm_selected'); + } + }, + + /** + * De-selects the item if it is selected + * + * @returns {void} + */ + deselect: function () { + if (this.layoutManager.selectedItem === this) { + this.layoutManager.selectedItem = null; + this.element.removeClass('lm_selected'); + } + }, + + /** + * Set this component's title + * + * @public + * @param {String} title + * + * @returns {void} + */ + setTitle: function (title) { + this.config.title = title; + this.emit('titleChanged', title); + this.emit('stateChanged'); + }, + + /** + * Checks whether a provided id is present + * + * @public + * @param {String} id + * + * @returns {Boolean} isPresent + */ + hasId: function (id) { + if (!this.config.id) { + return false; + } else if (typeof this.config.id === 'string') { + return this.config.id === id; + } else if (this.config.id instanceof Array) { + return lm.utils.indexOf(id, this.config.id) !== -1; + } + }, + + /** + * Adds an id. Adds it as a string if the component doesn't + * have an id yet or creates/uses an array + * + * @public + * @param {String} id + * + * @returns {void} + */ + addId: function (id) { + if (this.hasId(id)) { + return; + } + + if (!this.config.id) { + this.config.id = id; + } else if (typeof this.config.id === 'string') { + this.config.id = [this.config.id, id]; + } else if (this.config.id instanceof Array) { + this.config.id.push(id); + } + }, + + /** + * Removes an existing id. Throws an error + * if the id is not present + * + * @public + * @param {String} id + * + * @returns {void} + */ + removeId: function (id) { + if (!this.hasId(id)) { + throw new Error('Id not found'); + } + + if (typeof this.config.id === 'string') { + delete this.config.id; + } else if (this.config.id instanceof Array) { + var index = lm.utils.indexOf(id, this.config.id); + this.config.id.splice(index, 1); + } + }, + + /**************************************** + * SELECTOR + ****************************************/ + getItemsByFilter: function (filter) { + var result = [], + next = function (contentItem) { + for (var i = 0; i < contentItem.contentItems.length; i++) { + + if (filter(contentItem.contentItems[i]) === true) { + result.push(contentItem.contentItems[i]); + } + + next(contentItem.contentItems[i]); + } + }; + + next(this); + return result; + }, + + getItemsById: function (id) { + return this.getItemsByFilter(function (item) { + if (item.config.id instanceof Array) { + return lm.utils.indexOf(id, item.config.id) !== -1; + } else { + return item.config.id === id; + } + }); + }, + + getItemsByType: function (type) { + return this._$getItemsByProperty('type', type); + }, + + getComponentsByName: function (componentName) { + var components = this._$getItemsByProperty('componentName', componentName), + instances = [], + i; + + for (i = 0; i < components.length; i++) { + instances.push(components[i].instance); + } + + return instances; + }, + + /**************************************** + * PACKAGE PRIVATE + ****************************************/ + _$getItemsByProperty: function (key, value) { + return this.getItemsByFilter(function (item) { + return item[key] === value; + }); + }, + + _$setParent: function (parent) { + this.parent = parent; + }, + + _$highlightDropZone: function (x, y, area) { + this.layoutManager.dropTargetIndicator.highlightArea(area); + }, + + _$onDrop: function (contentItem) { + this.addChild(contentItem); + }, + + _$hide: function () { + this._callOnActiveComponents('hide'); + this.element.hide(); + this.layoutManager.updateSize(); + }, + + _$show: function () { + this._callOnActiveComponents('show'); + this.element.show(); + this.layoutManager.updateSize(); + }, + + _callOnActiveComponents: function (methodName) { + var stacks = this.getItemsByType('stack'), + activeContentItem, + i; + + for (i = 0; i < stacks.length; i++) { + activeContentItem = stacks[i].getActiveContentItem(); + + if (activeContentItem && activeContentItem.isComponent) { + activeContentItem.container[methodName](); + } + } + }, + + /** + * Destroys this item ands its children + * + * @returns {void} + */ + _$destroy: function () { + this.emitBubblingEvent('beforeItemDestroyed'); + this.callDownwards('_$destroy', [], true, true); + this.element.remove(); + this.emitBubblingEvent('itemDestroyed'); + }, + + /** + * Returns the area the component currently occupies in the format + * + * { + * x1: int + * xy: int + * y1: int + * y2: int + * contentItem: contentItem + * } + */ + _$getArea: function (element) { + element = element || this.element; + + var offset = element.offset(), + width = element.width(), + height = element.height(); + + return { + x1: offset.left, + y1: offset.top, + x2: offset.left + width, + y2: offset.top + height, + surface: width * height, + contentItem: this + }; + }, + + /** + * The tree of content items is created in two steps: First all content items are instantiated, + * then init is called recursively from top to bottem. This is the basic init function, + * it can be used, extended or overwritten by the content items + * + * Its behaviour depends on the content item + * + * @package private + * + * @returns {void} + */ + _$init: function () { + var i; + this.setSize(); + + for (i = 0; i < this.contentItems.length; i++) { + this.childElementContainer.append(this.contentItems[i].element); + } + + this.isInitialised = true; + this.emitBubblingEvent('itemCreated'); + this.emitBubblingEvent(this.type + 'Created'); + }, + + /** + * Emit an event that bubbles up the item tree. + * + * @param {String} name The name of the event + * + * @returns {void} + */ + emitBubblingEvent: function (name) { + var event = new lm.utils.BubblingEvent(name, this); + this.emit(name, event); + }, + + /** + * Private method, creates all content items for this node at initialisation time + * PLEASE NOTE, please see addChild for adding contentItems add runtime + * @private + * @param {configuration item node} config + * + * @returns {void} + */ + _createContentItems: function (config) { + var oContentItem, i; + + if (!(config.content instanceof Array)) { + throw new lm.errors.ConfigurationError('content must be an Array', config); + } + + for (i = 0; i < config.content.length; i++) { + oContentItem = this.layoutManager.createContentItem(config.content[i], this); + this.contentItems.push(oContentItem); + } + }, + + /** + * Extends an item configuration node with default settings + * @private + * @param {configuration item node} config + * + * @returns {configuration item node} extended config + */ + _extendItemNode: function (config) { + + for (var key in lm.config.itemDefaultConfig) { + if (config[key] === undefined) { + config[key] = lm.config.itemDefaultConfig[key]; + } + } + + return config; + }, + + /** + * Called for every event on the item tree. Decides whether the event is a bubbling + * event and propagates it to its parent + * + * @param {String} name the name of the event + * @param {lm.utils.BubblingEvent} event + * + * @returns {void} + */ + _propagateEvent: function (name, event) { + if (event instanceof lm.utils.BubblingEvent && + event.isPropagationStopped === false && + this.isInitialised === true) { + + /** + * In some cases (e.g. if an element is created from a DragSource) it + * doesn't have a parent and is not below root. If that's the case + * propagate the bubbling event from the top level of the substree directly + * to the layoutManager + */ + if (this.isRoot === false && this.parent) { + this.parent.emit.apply(this.parent, Array.prototype.slice.call(arguments, 0)); + } else { + this._scheduleEventPropagationToLayoutManager(name, event); + } + } + }, + + /** + * All raw events bubble up to the root element. Some events that + * are propagated to - and emitted by - the layoutManager however are + * only string-based, batched and sanitized to make them more usable + * + * @param {String} name the name of the event + * + * @private + * @returns {void} + */ + _scheduleEventPropagationToLayoutManager: function (name, event) { + if (lm.utils.indexOf(name, this._throttledEvents) === -1) { + this.layoutManager.emit(name, event.origin); + } else { + if (this._pendingEventPropagations[name] !== true) { + this._pendingEventPropagations[name] = true; + lm.utils.animFrame(lm.utils.fnBind(this._propagateEventToLayoutManager, this, [name, event])); + } + } + + }, + + /** + * Callback for events scheduled by _scheduleEventPropagationToLayoutManager + * + * @param {String} name the name of the event + * + * @private + * @returns {void} + */ + _propagateEventToLayoutManager: function (name, event) { + this._pendingEventPropagations[name] = false; + this.layoutManager.emit(name, event); + } + }); + + /** + * @param {[type]} layoutManager [description] + * @param {[type]} config [description] + * @param {[type]} parent [description] + */ + lm.items.Component = function (layoutManager, config, parent) { + lm.items.AbstractContentItem.call(this, layoutManager, config, parent); + + var ComponentConstructor = layoutManager.getComponent(this.config.componentName), + componentConfig = $.extend(true, {}, this.config.componentState || {}); + + componentConfig.componentName = this.config.componentName; + this.componentName = this.config.componentName; + + if (this.config.title === '') { + this.config.title = this.config.componentName; + } + + this.isComponent = true; + this.container = new lm.container.ItemContainer(this.config, this, layoutManager); + this.instance = new ComponentConstructor(this.container, componentConfig); + this.element = this.container._element; + }; + + lm.utils.extend(lm.items.Component, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.Component.prototype, { + + close: function () { + this.parent.removeChild(this); + }, + + setSize: function () { + if (this.element.is(':visible')) { + // Do not update size of hidden components to prevent unwanted reflows + this.container._$setSize(this.element.width(), this.element.height()); + } + }, + + _$init: function () { + lm.items.AbstractContentItem.prototype._$init.call(this); + this.container.emit('open'); + }, + + _$hide: function () { + this.container.hide(); + lm.items.AbstractContentItem.prototype._$hide.call(this); + }, + + _$show: function () { + this.container.show(); + lm.items.AbstractContentItem.prototype._$show.call(this); + }, + + _$shown: function () { + this.container.shown(); + lm.items.AbstractContentItem.prototype._$shown.call(this); + }, + + _$destroy: function () { + this.container.emit('destroy', this); + lm.items.AbstractContentItem.prototype._$destroy.call(this); + }, + + /** + * Dragging onto a component directly is not an option + * + * @returns null + */ + _$getArea: function () { + return null; + } + }); + + lm.items.Root = function (layoutManager, config, containerElement) { + lm.items.AbstractContentItem.call(this, layoutManager, config, null); + this.isRoot = true; + this.type = 'root'; + this.element = $('<div class="lm_goldenlayout lm_item lm_root"></div>'); + this.childElementContainer = this.element; + this._containerElement = containerElement; + this._containerElement.append(this.element); + }; + + lm.utils.extend(lm.items.Root, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.Root.prototype, { + addChild: function (contentItem) { + if (this.contentItems.length > 0) { + throw new Error('Root node can only have a single child'); + } + + contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); + this.childElementContainer.append(contentItem.element); + lm.items.AbstractContentItem.prototype.addChild.call(this, contentItem); + + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + }, + + setSize: function (width, height) { + width = (typeof width === 'undefined') ? this._containerElement.width() : width; + height = (typeof height === 'undefined') ? this._containerElement.height() : height; + + this.element.width(width); + this.element.height(height); + + /* + * Root can be empty + */ + if (this.contentItems[0]) { + this.contentItems[0].element.width(width); + this.contentItems[0].element.height(height); + } + }, + _$highlightDropZone: function (x, y, area) { + this.layoutManager.tabDropPlaceholder.remove(); + lm.items.AbstractContentItem.prototype._$highlightDropZone.apply(this, arguments); + }, + + _$onDrop: function (contentItem, area) { + var stack; + + if (contentItem.isComponent) { + stack = this.layoutManager.createContentItem({ + type: 'stack', + header: contentItem.config.header || {} + }, this); + stack._$init(); + stack.addChild(contentItem); + contentItem = stack; + } + + if (!this.contentItems.length) { + this.addChild(contentItem); + } else { + var type = area.side[0] == 'x' ? 'row' : 'column'; + var dimension = area.side[0] == 'x' ? 'width' : 'height'; + var insertBefore = area.side[1] == '2'; + var column = this.contentItems[0]; + if (!column instanceof lm.items.RowOrColumn || column.type != type) { + var rowOrColumn = this.layoutManager.createContentItem({ type: type }, this); + this.replaceChild(column, rowOrColumn); + rowOrColumn.addChild(contentItem, insertBefore ? 0 : undefined, true); + rowOrColumn.addChild(column, insertBefore ? undefined : 0, true); + column.config[dimension] = 50; + contentItem.config[dimension] = 50; + rowOrColumn.callDownwards('setSize'); + } else { + var sibbling = column.contentItems[insertBefore ? 0 : column.contentItems.length - 1] + column.addChild(contentItem, insertBefore ? 0 : undefined, true); + sibbling.config[dimension] *= 0.5; + contentItem.config[dimension] = sibbling.config[dimension]; + column.callDownwards('setSize'); + } + } + } + }); + + + + lm.items.RowOrColumn = function (isColumn, layoutManager, config, parent) { + lm.items.AbstractContentItem.call(this, layoutManager, config, parent); + + this.isRow = !isColumn; + this.isColumn = isColumn; + + this.element = $('<div class="lm_item lm_' + (isColumn ? 'column' : 'row') + '"></div>'); + this.childElementContainer = this.element; + this._splitterSize = layoutManager.config.dimensions.borderWidth; + this._splitterGrabSize = layoutManager.config.dimensions.borderGrabWidth; + this._isColumn = isColumn; + this._dimension = isColumn ? 'height' : 'width'; + this._splitter = []; + this._splitterPosition = null; + this._splitterMinPosition = null; + this._splitterMaxPosition = null; + }; + + lm.utils.extend(lm.items.RowOrColumn, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.RowOrColumn.prototype, { + + /** + * Add a new contentItem to the Row or Column + * + * @param {lm.item.AbstractContentItem} contentItem + * @param {[int]} index The position of the new item within the Row or Column. + * If no index is provided the item will be added to the end + * @param {[bool]} _$suspendResize If true the items won't be resized. This will leave the item in + * an inconsistent state and is only intended to be used if multiple + * children need to be added in one go and resize is called afterwards + * + * @returns {void} + */ + addChild: function (contentItem, index, _$suspendResize) { + + var newItemSize, itemSize, i, splitterElement; + + contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); + + if (index === undefined) { + index = this.contentItems.length; + } + + if (this.contentItems.length > 0) { + splitterElement = this._createSplitter(Math.max(0, index - 1)).element; + + if (index > 0) { + this.contentItems[index - 1].element.after(splitterElement); + splitterElement.after(contentItem.element); + } else { + this.contentItems[0].element.before(splitterElement); + splitterElement.before(contentItem.element); + } + } else { + this.childElementContainer.append(contentItem.element); + } + + lm.items.AbstractContentItem.prototype.addChild.call(this, contentItem, index); + + let fixedItemSize = 0; + let variableItemCount = 0; + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + fixedItemSize += this.contentItems[i].config[this._dimension]; + else variableItemCount++; + } + + newItemSize = (1 / variableItemCount) * (100 - fixedItemSize); + + if (_$suspendResize === true) { + this.emitBubblingEvent('stateChanged'); + return; + } + + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + ; + else if (this.contentItems[i] === contentItem) { + contentItem.config[this._dimension] = newItemSize; + } else { + itemSize = this.contentItems[i].config[this._dimension] *= (100 - newItemSize - fixedItemSize) / (100 - fixedItemSize); + this.contentItems[i].config[this._dimension] = itemSize; + } + } + + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + + }, + + /** + * Removes a child of this element + * + * @param {lm.items.AbstractContentItem} contentItem + * @param {boolean} keepChild If true the child will be removed, but not destroyed + * + * @returns {void} + */ + removeChild: function (contentItem, keepChild) { + var removedItemSize = contentItem.config[this._dimension], + index = lm.utils.indexOf(contentItem, this.contentItems), + splitterIndex = Math.max(index - 1, 0), + i, + childItem; + + if (index === -1) { + throw new Error('Can\'t remove child. ContentItem is not child of this Row or Column'); + } + + /** + * Remove the splitter before the item or after if the item happens + * to be the first in the row/column + */ + if (this._splitter[splitterIndex]) { + this._splitter[splitterIndex]._$destroy(); + this._splitter.splice(splitterIndex, 1); + } + + let fixedItemSize = 0; + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + fixedItemSize += this.contentItems[i].config[this._dimension]; + } + /** + * Allocate the space that the removed item occupied to the remaining items + */ + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + ; + else if (this.contentItems[i] !== contentItem) { + this.contentItems[i].config[this._dimension] *= (100 - fixedItemSize) / (100 - removedItemSize - fixedItemSize); + } + } + + lm.items.AbstractContentItem.prototype.removeChild.call(this, contentItem, keepChild); + + if (this.contentItems.length === 1 && this.config.isClosable === true) { + childItem = this.contentItems[0]; + this.contentItems = []; + this.parent.replaceChild(this, childItem, true); + } else { + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + } + }, + + /** + * Replaces a child of this Row or Column with another contentItem + * + * @param {lm.items.AbstractContentItem} oldChild + * @param {lm.items.AbstractContentItem} newChild + * + * @returns {void} + */ + replaceChild: function (oldChild, newChild) { + var size = oldChild.config[this._dimension]; + lm.items.AbstractContentItem.prototype.replaceChild.call(this, oldChild, newChild); + newChild.config[this._dimension] = size; + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + }, + + /** + * Called whenever the dimensions of this item or one of its parents change + * + * @returns {void} + */ + setSize: function () { + if (this.contentItems.length > 0) { + this._calculateRelativeSizes(); + this._setAbsoluteSizes(); + } + this.emitBubblingEvent('stateChanged'); + this.emit('resize'); + }, + + /** + * Invoked recursively by the layout manager. AbstractContentItem.init appends + * the contentItem's DOM elements to the container, RowOrColumn init adds splitters + * in between them + * + * @package private + * @override AbstractContentItem._$init + * @returns {void} + */ + _$init: function () { + if (this.isInitialised === true) return; + + var i; + + lm.items.AbstractContentItem.prototype._$init.call(this); + + for (i = 0; i < this.contentItems.length - 1; i++) { + this.contentItems[i].element.after(this._createSplitter(i).element); + } + }, + + /** + * Turns the relative sizes calculated by _calculateRelativeSizes into + * absolute pixel values and applies them to the children's DOM elements + * + * Assigns additional pixels to counteract Math.floor + * + * @private + * @returns {void} + */ + _setAbsoluteSizes: function () { + var i, + sizeData = this._calculateAbsoluteSizes(); + + for (i = 0; i < this.contentItems.length; i++) { + if (sizeData.additionalPixel - i > 0) { + sizeData.itemSizes[i]++; + } + + if (this._isColumn) { + this.contentItems[i].element.width(sizeData.totalWidth); + this.contentItems[i].element.height(sizeData.itemSizes[i]); + } else { + this.contentItems[i].element.width(sizeData.itemSizes[i]); + this.contentItems[i].element.height(sizeData.totalHeight); + } + } + }, + + /** + * Calculates the absolute sizes of all of the children of this Item. + * @returns {object} - Set with absolute sizes and additional pixels. + */ + _calculateAbsoluteSizes: function () { + var i, + totalSplitterSize = (this.contentItems.length - 1) * this._splitterSize, + totalWidth = this.element.width(), + totalHeight = this.element.height(), + totalAssigned = 0, + additionalPixel, + itemSize, + itemSizes = []; + + if (this._isColumn) { + totalHeight -= totalSplitterSize; + } else { + totalWidth -= totalSplitterSize; + } + + for (i = 0; i < this.contentItems.length; i++) { + if (this._isColumn) { + itemSize = Math.floor(totalHeight * (this.contentItems[i].config.height / 100)); + } else { + itemSize = Math.floor(totalWidth * (this.contentItems[i].config.width / 100)); + } + + totalAssigned += itemSize; + itemSizes.push(itemSize); + } + + additionalPixel = Math.floor((this._isColumn ? totalHeight : totalWidth) - totalAssigned); + + return { + itemSizes: itemSizes, + additionalPixel: additionalPixel, + totalWidth: totalWidth, + totalHeight: totalHeight + }; + }, + + /** + * Calculates the relative sizes of all children of this Item. The logic + * is as follows: + * + * - Add up the total size of all items that have a configured size + * + * - If the total == 100 (check for floating point errors) + * Excellent, job done + * + * - If the total is > 100, + * set the size of items without set dimensions to 1/3 and add this to the total + * set the size off all items so that the total is hundred relative to their original size + * + * - If the total is < 100 + * If there are items without set dimensions, distribute the remainder to 100 evenly between them + * If there are no items without set dimensions, increase all items sizes relative to + * their original size so that they add up to 100 + * + * @private + * @returns {void} + */ + _calculateRelativeSizes: function () { + + var i, + total = 0, + itemsWithoutSetDimension = [], + dimension = this._isColumn ? 'height' : 'width'; + + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config[dimension] !== undefined) { + total += this.contentItems[i].config[dimension]; + } else { + itemsWithoutSetDimension.push(this.contentItems[i]); + } + } + + /** + * Everything adds up to hundred, all good :-) + */ + if (Math.round(total) === 100) { + this._respectMinItemWidth(); + return; + } + + /** + * Allocate the remaining size to the items without a set dimension + */ + if (Math.round(total) < 100 && itemsWithoutSetDimension.length > 0) { + for (i = 0; i < itemsWithoutSetDimension.length; i++) { + itemsWithoutSetDimension[i].config[dimension] = (100 - total) / itemsWithoutSetDimension.length; + } + this._respectMinItemWidth(); + return; + } + + /** + * If the total is > 100, but there are also items without a set dimension left, assing 50 + * as their dimension and add it to the total + * + * This will be reset in the next step + */ + if (Math.round(total) > 100) { + for (i = 0; i < itemsWithoutSetDimension.length; i++) { + itemsWithoutSetDimension[i].config[dimension] = 50; + total += 50; + } + } + + /** + * Set every items size relative to 100 relative to its size to total + */ + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].config[dimension] = (this.contentItems[i].config[dimension] / total) * 100; + } + + this._respectMinItemWidth(); + }, + + /** + * Adjusts the column widths to respect the dimensions minItemWidth if set. + * @returns {} + */ + _respectMinItemWidth: function () { + var minItemWidth = this.layoutManager.config.dimensions ? (this.layoutManager.config.dimensions.minItemWidth || 0) : 0, + sizeData = null, + entriesOverMin = [], + totalOverMin = 0, + totalUnderMin = 0, + remainingWidth = 0, + itemSize = 0, + contentItem = null, + reducePercent, + reducedWidth, + allEntries = [], + entry; + + if (this._isColumn || !minItemWidth || this.contentItems.length <= 1) { + return; + } + + sizeData = this._calculateAbsoluteSizes(); + + /** + * Figure out how much we are under the min item size total and how much room we have to use. + */ + for (var i = 0; i < this.contentItems.length; i++) { + + contentItem = this.contentItems[i]; + itemSize = sizeData.itemSizes[i]; + + if (itemSize < minItemWidth) { + totalUnderMin += minItemWidth - itemSize; + entry = { width: minItemWidth }; + + } + else { + totalOverMin += itemSize - minItemWidth; + entry = { width: itemSize }; + entriesOverMin.push(entry); + } + + allEntries.push(entry); + } + + /** + * If there is nothing under min, or there is not enough over to make up the difference, do nothing. + */ + if (totalUnderMin === 0 || totalUnderMin > totalOverMin) { + return; + } + + /** + * Evenly reduce all columns that are over the min item width to make up the difference. + */ + reducePercent = totalUnderMin / totalOverMin; + remainingWidth = totalUnderMin; + for (i = 0; i < entriesOverMin.length; i++) { + entry = entriesOverMin[i]; + reducedWidth = Math.round((entry.width - minItemWidth) * reducePercent); + remainingWidth -= reducedWidth; + entry.width -= reducedWidth; + } + + /** + * Take anything remaining from the last item. + */ + if (remainingWidth !== 0) { + allEntries[allEntries.length - 1].width -= remainingWidth; + } + + /** + * Set every items size relative to 100 relative to its size to total + */ + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].config.width = (allEntries[i].width / sizeData.totalWidth) * 100; + } + }, + + /** + * Instantiates a new lm.controls.Splitter, binds events to it and adds + * it to the array of splitters at the position specified as the index argument + * + * What it doesn't do though is append the splitter to the DOM + * + * @param {Int} index The position of the splitter + * + * @returns {lm.controls.Splitter} + */ + _createSplitter: function (index) { + var splitter; + splitter = new lm.controls.Splitter(this._isColumn, this._splitterSize, this._splitterGrabSize); + splitter.on('drag', lm.utils.fnBind(this._onSplitterDrag, this, [splitter]), this); + splitter.on('dragStop', lm.utils.fnBind(this._onSplitterDragStop, this, [splitter]), this); + splitter.on('dragStart', lm.utils.fnBind(this._onSplitterDragStart, this, [splitter]), this); + this._splitter.splice(index, 0, splitter); + return splitter; + }, + + /** + * Locates the instance of lm.controls.Splitter in the array of + * registered splitters and returns a map containing the contentItem + * before and after the splitters, both of which are affected if the + * splitter is moved + * + * @param {lm.controls.Splitter} splitter + * + * @returns {Object} A map of contentItems that the splitter affects + */ + _getItemsForSplitter: function (splitter) { + var index = lm.utils.indexOf(splitter, this._splitter); + + return { + before: this.contentItems[index], + after: this.contentItems[index + 1] + }; + }, + + /** + * Gets the minimum dimensions for the given item configuration array + * @param item + * @private + */ + _getMinimumDimensions: function (arr) { + var minWidth = 0, minHeight = 0; + + for (var i = 0; i < arr.length; ++i) { + minWidth = Math.max(arr[i].minWidth || 0, minWidth); + minHeight = Math.max(arr[i].minHeight || 0, minHeight); + } + + return { horizontal: minWidth, vertical: minHeight }; + }, + + /** + * Invoked when a splitter's dragListener fires dragStart. Calculates the splitters + * movement area once (so that it doesn't need calculating on every mousemove event) + * + * @param {lm.controls.Splitter} splitter + * + * @returns {void} + */ + _onSplitterDragStart: function (splitter) { + var items = this._getItemsForSplitter(splitter), + minSize = this.layoutManager.config.dimensions[this._isColumn ? 'minItemHeight' : 'minItemWidth']; + + var beforeMinDim = this._getMinimumDimensions(items.before.config.content); + var beforeMinSize = this._isColumn ? beforeMinDim.vertical : beforeMinDim.horizontal; + + var afterMinDim = this._getMinimumDimensions(items.after.config.content); + var afterMinSize = this._isColumn ? afterMinDim.vertical : afterMinDim.horizontal; + + this._splitterPosition = 0; + this._splitterMinPosition = -1 * (items.before.element[this._dimension]() - (beforeMinSize || minSize)); + this._splitterMaxPosition = items.after.element[this._dimension]() - (afterMinSize || minSize); + }, + + /** + * Invoked when a splitter's DragListener fires drag. Updates the splitters DOM position, + * but not the sizes of the elements the splitter controls in order to minimize resize events + * + * @param {lm.controls.Splitter} splitter + * @param {Int} offsetX Relative pixel values to the splitters original position. Can be negative + * @param {Int} offsetY Relative pixel values to the splitters original position. Can be negative + * + * @returns {void} + */ + _onSplitterDrag: function (splitter, offsetX, offsetY) { + var offset = this._isColumn ? offsetY : offsetX; + + if (offset > this._splitterMinPosition && offset < this._splitterMaxPosition) { + this._splitterPosition = offset; + splitter.element.css(this._isColumn ? 'top' : 'left', offset); + } + }, + + /** + * Invoked when a splitter's DragListener fires dragStop. Resets the splitters DOM position, + * and applies the new sizes to the elements before and after the splitter and their children + * on the next animation frame + * + * @param {lm.controls.Splitter} splitter + * + * @returns {void} + */ + _onSplitterDragStop: function (splitter) { + + var items = this._getItemsForSplitter(splitter), + sizeBefore = items.before.element[this._dimension](), + sizeAfter = items.after.element[this._dimension](), + splitterPositionInRange = (this._splitterPosition + sizeBefore) / (sizeBefore + sizeAfter), + totalRelativeSize = items.before.config[this._dimension] + items.after.config[this._dimension]; + + items.before.config[this._dimension] = splitterPositionInRange * totalRelativeSize; + items.after.config[this._dimension] = (1 - splitterPositionInRange) * totalRelativeSize; + + splitter.element.css({ + 'top': 0, + 'left': 0 + }); + + lm.utils.animFrame(lm.utils.fnBind(this.callDownwards, this, ['setSize'])); + } + }); + + lm.items.Stack = function (layoutManager, config, parent) { + lm.items.AbstractContentItem.call(this, layoutManager, config, parent); + + this.element = $('<div class="lm_item lm_stack"></div>'); + this._activeContentItem = null; + var cfg = layoutManager.config; + this._header = { // defaults' reconstruction from old configuration style + show: cfg.settings.hasHeaders === true && config.hasHeaders !== false, + popout: cfg.settings.showPopoutIcon && cfg.labels.popout, + maximise: cfg.settings.showMaximiseIcon && cfg.labels.maximise, + close: cfg.settings.showCloseIcon && cfg.labels.close, + minimise: cfg.labels.minimise, + }; + if (cfg.header) // load simplified version of header configuration (https://github.com/deepstreamIO/golden-layout/pull/245) + lm.utils.copy(this._header, cfg.header); + if (config.header) // load from stack + lm.utils.copy(this._header, config.header); + if (config.content && config.content[0] && config.content[0].header) // load from component if stack omitted + lm.utils.copy(this._header, config.content[0].header); + + this._dropZones = {}; + this._dropSegment = null; + this._contentAreaDimensions = null; + this._dropIndex = null; + + this.isStack = true; + + this.childElementContainer = $('<div class="lm_items"></div>'); + this.header = new lm.controls.Header(layoutManager, this); + + this.element.append(this.header.element); + this.element.append(this.childElementContainer); + this._setupHeaderPosition(); + this._$validateClosability(); + }; + + lm.utils.extend(lm.items.Stack, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.Stack.prototype, { + + setSize: function () { + var i, + headerSize = this._header.show ? this.layoutManager.config.dimensions.headerHeight : 0, + contentWidth = this.element.width() - (this._sided ? headerSize : 0), + contentHeight = this.element.height() - (!this._sided ? headerSize : 0); + + this.childElementContainer.width(contentWidth); + this.childElementContainer.height(contentHeight); + + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].element.width(contentWidth).height(contentHeight); + } + this.emit('resize'); + this.emitBubblingEvent('stateChanged'); + }, + + _$init: function () { + var i, initialItem; + + if (this.isInitialised === true) return; + + lm.items.AbstractContentItem.prototype._$init.call(this); + + for (i = 0; i < this.contentItems.length; i++) { + this.header.createTab(this.contentItems[i]); + this.contentItems[i]._$hide(); + } + + if (this.contentItems.length > 0) { + initialItem = this.contentItems[this.config.activeItemIndex || 0]; + + if (!initialItem) { + throw new Error('Configured activeItemIndex out of bounds'); + } + + this.setActiveContentItem(initialItem); + } + }, + + setActiveContentItem: function (contentItem) { + if (lm.utils.indexOf(contentItem, this.contentItems) === -1) { + throw new Error('contentItem is not a child of this stack'); + } + + if (this._activeContentItem !== null) { + this._activeContentItem._$hide(); + } + + this._activeContentItem = contentItem; + this.header.setActiveContentItem(contentItem); + contentItem._$show(); + this.emit('activeContentItemChanged', contentItem); + this.layoutManager.emit('activeContentItemChanged', contentItem); + this.emitBubblingEvent('stateChanged'); + }, + + getActiveContentItem: function () { + return this.header.activeContentItem; + }, + + addChild: function (contentItem, index) { + contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); + lm.items.AbstractContentItem.prototype.addChild.call(this, contentItem, index); + this.childElementContainer.append(contentItem.element); + this.header.createTab(contentItem, index); + this.setActiveContentItem(contentItem); + this.callDownwards('setSize'); + this._$validateClosability(); + this.emitBubblingEvent('stateChanged'); + }, + + removeChild: function (contentItem, keepChild) { + var index = lm.utils.indexOf(contentItem, this.contentItems); + lm.items.AbstractContentItem.prototype.removeChild.call(this, contentItem, keepChild); + this.header.removeTab(contentItem); + if (this.header.activeContentItem === contentItem) { + if (this.contentItems.length > 0) { + this.setActiveContentItem(this.contentItems[Math.max(index - 1, 0)]); + } else { + this._activeContentItem = null; + } + } + + this._$validateClosability(); + this.emitBubblingEvent('stateChanged'); + }, + + /** + * Validates that the stack is still closable or not. If a stack is able + * to close, but has a non closable component added to it, the stack is no + * longer closable until all components are closable. + * + * @returns {void} + */ + _$validateClosability: function () { + var contentItem, + isClosable, + len, + i; + + isClosable = this.header._isClosable(); + + for (i = 0, len = this.contentItems.length; i < len; i++) { + if (!isClosable) { + break; + } + + isClosable = this.contentItems[i].config.isClosable; + } + + this.header._$setClosable(isClosable); + }, + + _$destroy: function () { + lm.items.AbstractContentItem.prototype._$destroy.call(this); + this.header._$destroy(); + }, + + + /** + * Ok, this one is going to be the tricky one: The user has dropped {contentItem} onto this stack. + * + * It was dropped on either the stacks header or the top, right, bottom or left bit of the content area + * (which one of those is stored in this._dropSegment). Now, if the user has dropped on the header the case + * is relatively clear: We add the item to the existing stack... job done (might be good to have + * tab reordering at some point, but lets not sweat it right now) + * + * If the item was dropped on the content part things are a bit more complicated. If it was dropped on either the + * top or bottom region we need to create a new column and place the items accordingly. + * Unless, of course if the stack is already within a column... in which case we want + * to add the newly created item to the existing column... + * either prepend or append it, depending on wether its top or bottom. + * + * Same thing for rows and left / right drop segments... so in total there are 9 things that can potentially happen + * (left, top, right, bottom) * is child of the right parent (row, column) + header drop + * + * @param {lm.item} contentItem + * + * @returns {void} + */ + _$onDrop: function (contentItem) { + + /* + * The item was dropped on the header area. Just add it as a child of this stack and + * get the hell out of this logic + */ + if (this._dropSegment === 'header') { + this._resetHeaderDropZone(); + this.addChild(contentItem, this._dropIndex); + return; + } + + /* + * The stack is empty. Let's just add the element. + */ + if (this._dropSegment === 'body') { + this.addChild(contentItem); + return; + } + + /* + * The item was dropped on the top-, left-, bottom- or right- part of the content. Let's + * aggregate some conditions to make the if statements later on more readable + */ + var isVertical = this._dropSegment === 'top' || this._dropSegment === 'bottom', + isHorizontal = this._dropSegment === 'left' || this._dropSegment === 'right', + insertBefore = this._dropSegment === 'top' || this._dropSegment === 'left', + hasCorrectParent = (isVertical && this.parent.isColumn) || (isHorizontal && this.parent.isRow), + type = isVertical ? 'column' : 'row', + dimension = isVertical ? 'height' : 'width', + index, + stack, + rowOrColumn; + + /* + * The content item can be either a component or a stack. If it is a component, wrap it into a stack + */ + if (contentItem.isComponent) { + stack = this.layoutManager.createContentItem({ + type: 'stack', + header: contentItem.config.header || {} + }, this); + stack._$init(); + stack.addChild(contentItem); + contentItem = stack; + } + + /* + * If the item is dropped on top or bottom of a column or left and right of a row, it's already + * layd out in the correct way. Just add it as a child + */ + if (hasCorrectParent) { + index = lm.utils.indexOf(this, this.parent.contentItems); + this.parent.addChild(contentItem, insertBefore ? index : index + 1, true); + this.config[dimension] *= 0.5; + contentItem.config[dimension] = this.config[dimension]; + this.parent.callDownwards('setSize'); + /* + * This handles items that are dropped on top or bottom of a row or left / right of a column. We need + * to create the appropriate contentItem for them to live in + */ + } else { + type = isVertical ? 'column' : 'row'; + rowOrColumn = this.layoutManager.createContentItem({ type: type }, this); + this.parent.replaceChild(this, rowOrColumn); + + rowOrColumn.addChild(contentItem, insertBefore ? 0 : undefined, true); + rowOrColumn.addChild(this, insertBefore ? undefined : 0, true); + + this.config[dimension] = 50; + contentItem.config[dimension] = 50; + rowOrColumn.callDownwards('setSize'); + } + }, + + /** + * If the user hovers above the header part of the stack, indicate drop positions for tabs. + * otherwise indicate which segment of the body the dragged item would be dropped on + * + * @param {Int} x Absolute Screen X + * @param {Int} y Absolute Screen Y + * + * @returns {void} + */ + _$highlightDropZone: function (x, y) { + var segment, area; + + for (segment in this._contentAreaDimensions) { + area = this._contentAreaDimensions[segment].hoverArea; + + if (area.x1 < x && area.x2 > x && area.y1 < y && area.y2 > y) { + + if (segment === 'header') { + this._dropSegment = 'header'; + this._highlightHeaderDropZone(this._sided ? y : x); + } else { + this._resetHeaderDropZone(); + this._highlightBodyDropZone(segment); + } + + return; + } + } + }, + + _$getArea: function () { + if (this.element.is(':visible') === false) { + return null; + } + + var getArea = lm.items.AbstractContentItem.prototype._$getArea, + headerArea = getArea.call(this, this.header.element), + contentArea = getArea.call(this, this.childElementContainer), + contentWidth = contentArea.x2 - contentArea.x1, + contentHeight = contentArea.y2 - contentArea.y1; + + this._contentAreaDimensions = { + header: { + hoverArea: { + x1: headerArea.x1, + y1: headerArea.y1, + x2: headerArea.x2, + y2: headerArea.y2 + }, + highlightArea: { + x1: headerArea.x1, + y1: headerArea.y1, + x2: headerArea.x2, + y2: headerArea.y2 + } + } + }; + + /** + * If this Stack is a parent to rows, columns or other stacks only its + * header is a valid dropzone. + */ + if (this._activeContentItem && this._activeContentItem.isComponent === false) { + return headerArea; + } + + /** + * Highlight the entire body if the stack is empty + */ + if (this.contentItems.length === 0) { + + this._contentAreaDimensions.body = { + hoverArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + } + }; + + return getArea.call(this, this.element); + } + + this._contentAreaDimensions.left = { + hoverArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x1 + contentWidth * 0.25, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x1 + contentWidth * 0.5, + y2: contentArea.y2 + } + }; + + this._contentAreaDimensions.top = { + hoverArea: { + x1: contentArea.x1 + contentWidth * 0.25, + y1: contentArea.y1, + x2: contentArea.x1 + contentWidth * 0.75, + y2: contentArea.y1 + contentHeight * 0.5 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y1 + contentHeight * 0.5 + } + }; + + this._contentAreaDimensions.right = { + hoverArea: { + x1: contentArea.x1 + contentWidth * 0.75, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1 + contentWidth * 0.5, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + } + }; + + this._contentAreaDimensions.bottom = { + hoverArea: { + x1: contentArea.x1 + contentWidth * 0.25, + y1: contentArea.y1 + contentHeight * 0.5, + x2: contentArea.x1 + contentWidth * 0.75, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1 + contentHeight * 0.5, + x2: contentArea.x2, + y2: contentArea.y2 + } + }; + + return getArea.call(this, this.element); + }, + + _highlightHeaderDropZone: function (x) { + var i, + tabElement, + tabsLength = this.header.tabs.length, + isAboveTab = false, + tabTop, + tabLeft, + offset, + placeHolderLeft, + headerOffset, + tabWidth, + halfX; + + // Empty stack + if (tabsLength === 0) { + headerOffset = this.header.element.offset(); + + this.layoutManager.dropTargetIndicator.highlightArea({ + x1: headerOffset.left, + x2: headerOffset.left + 100, + y1: headerOffset.top + this.header.element.height() - 20, + y2: headerOffset.top + this.header.element.height() + }); + + return; + } + + for (i = 0; i < tabsLength; i++) { + tabElement = this.header.tabs[i].element; + offset = tabElement.offset(); + if (this._sided) { + tabLeft = offset.top; + tabTop = offset.left; + tabWidth = tabElement.height(); + } else { + tabLeft = offset.left; + tabTop = offset.top; + tabWidth = tabElement.width(); + } + + if (x > tabLeft && x < tabLeft + tabWidth) { + isAboveTab = true; + break; + } + } + + if (isAboveTab === false && x < tabLeft) { + return; + } + + halfX = tabLeft + tabWidth / 2; + + if (x < halfX) { + this._dropIndex = i; + tabElement.before(this.layoutManager.tabDropPlaceholder); + } else { + this._dropIndex = Math.min(i + 1, tabsLength); + tabElement.after(this.layoutManager.tabDropPlaceholder); + } + + + if (this._sided) { + placeHolderTop = this.layoutManager.tabDropPlaceholder.offset().top; + this.layoutManager.dropTargetIndicator.highlightArea({ + x1: tabTop, + x2: tabTop + tabElement.innerHeight(), + y1: placeHolderTop, + y2: placeHolderTop + this.layoutManager.tabDropPlaceholder.width() + }); + return; + } + placeHolderLeft = this.layoutManager.tabDropPlaceholder.offset().left; + + this.layoutManager.dropTargetIndicator.highlightArea({ + x1: placeHolderLeft, + x2: placeHolderLeft + this.layoutManager.tabDropPlaceholder.width(), + y1: tabTop, + y2: tabTop + tabElement.innerHeight() + }); + }, + + _resetHeaderDropZone: function () { + this.layoutManager.tabDropPlaceholder.remove(); + }, + + _setupHeaderPosition: function () { + var side = ['right', 'left', 'bottom'].indexOf(this._header.show) >= 0 && this._header.show; + this.header.element.toggle(!!this._header.show); + this._side = side; + this._sided = ['right', 'left'].indexOf(this._side) >= 0; + this.element.removeClass('lm_left lm_right lm_bottom'); + if (this._side) + this.element.addClass('lm_' + this._side); + if (this.element.find('.lm_header').length && this.childElementContainer) { + var headerPosition = ['right', 'bottom'].indexOf(this._side) >= 0 ? 'before' : 'after'; + this.header.element[headerPosition](this.childElementContainer); + this.callDownwards('setSize'); + } + }, + + _highlightBodyDropZone: function (segment) { + var highlightArea = this._contentAreaDimensions[segment].highlightArea; + this.layoutManager.dropTargetIndicator.highlightArea(highlightArea); + this._dropSegment = segment; + } + }); + + lm.utils.BubblingEvent = function (name, origin) { + this.name = name; + this.origin = origin; + this.isPropagationStopped = false; + }; + + lm.utils.BubblingEvent.prototype.stopPropagation = function () { + this.isPropagationStopped = true; + }; + /** + * Minifies and unminifies configs by replacing frequent keys + * and values with one letter substitutes. Config options must + * retain array position/index, add new options at the end. + * + * @constructor + */ + lm.utils.ConfigMinifier = function () { + this._keys = [ + 'settings', + 'hasHeaders', + 'constrainDragToContainer', + 'selectionEnabled', + 'dimensions', + 'borderWidth', + 'minItemHeight', + 'minItemWidth', + 'headerHeight', + 'dragProxyWidth', + 'dragProxyHeight', + 'labels', + 'close', + 'maximise', + 'minimise', + 'popout', + 'content', + 'componentName', + 'componentState', + 'id', + 'width', + 'type', + 'height', + 'isClosable', + 'title', + 'popoutWholeStack', + 'openPopouts', + 'parentId', + 'activeItemIndex', + 'reorderEnabled', + 'borderGrabWidth', + + + + + //Maximum 36 entries, do not cross this line! + ]; + if (this._keys.length > 36) { + throw new Error('Too many keys in config minifier map'); + } + + this._values = [ + true, + false, + 'row', + 'column', + 'stack', + 'component', + 'close', + 'maximise', + 'minimise', + 'open in new window' + ]; + }; + + lm.utils.copy(lm.utils.ConfigMinifier.prototype, { + + /** + * Takes a GoldenLayout configuration object and + * replaces its keys and values recursively with + * one letter counterparts + * + * @param {Object} config A GoldenLayout config object + * + * @returns {Object} minified config + */ + minifyConfig: function (config) { + var min = {}; + this._nextLevel(config, min, '_min'); + return min; + }, + + /** + * Takes a configuration Object that was previously minified + * using minifyConfig and returns its original version + * + * @param {Object} minifiedConfig + * + * @returns {Object} the original configuration + */ + unminifyConfig: function (minifiedConfig) { + var orig = {}; + this._nextLevel(minifiedConfig, orig, '_max'); + return orig; + }, + + /** + * Recursive function, called for every level of the config structure + * + * @param {Array|Object} orig + * @param {Array|Object} min + * @param {String} translationFn + * + * @returns {void} + */ + _nextLevel: function (from, to, translationFn) { + var key, minKey; + + for (key in from) { + + /** + * For in returns array indices as keys, so let's cast them to numbers + */ + if (from instanceof Array) key = parseInt(key, 10); + + /** + * In case something has extended Object prototypes + */ + if (!from.hasOwnProperty(key)) continue; + + /** + * Translate the key to a one letter substitute + */ + minKey = this[translationFn](key, this._keys); + + /** + * For Arrays and Objects, create a new Array/Object + * on the minified object and recurse into it + */ + if (typeof from[key] === 'object') { + to[minKey] = from[key] instanceof Array ? [] : {}; + this._nextLevel(from[key], to[minKey], translationFn); + + /** + * For primitive values (Strings, Numbers, Boolean etc.) + * minify the value + */ + } else { + to[minKey] = this[translationFn](from[key], this._values); + } + } + }, + + /** + * Minifies value based on a dictionary + * + * @param {String|Boolean} value + * @param {Array<String|Boolean>} dictionary + * + * @returns {String} The minified version + */ + _min: function (value, dictionary) { + /** + * If a value actually is a single character, prefix it + * with ___ to avoid mistaking it for a minification code + */ + if (typeof value === 'string' && value.length === 1) { + return '___' + value; + } + + var index = lm.utils.indexOf(value, dictionary); + + /** + * value not found in the dictionary, return it unmodified + */ + if (index === -1) { + return value; + + /** + * value found in dictionary, return its base36 counterpart + */ + } else { + return index.toString(36); + } + }, + + _max: function (value, dictionary) { + /** + * value is a single character. Assume that it's a translation + * and return the original value from the dictionary + */ + if (typeof value === 'string' && value.length === 1) { + return dictionary[parseInt(value, 36)]; + } + + /** + * value originally was a single character and was prefixed with ___ + * to avoid mistaking it for a translation. Remove the prefix + * and return the original character + */ + if (typeof value === 'string' && value.substr(0, 3) === '___') { + return value[3]; + } + /** + * value was not minified + */ + return value; + } + }); + + /** + * An EventEmitter singleton that propagates events + * across multiple windows. This is a little bit trickier since + * windows are allowed to open childWindows in their own right + * + * This means that we deal with a tree of windows. Hence the rules for event propagation are: + * + * - Propagate events from this layout to both parents and children + * - Propagate events from parent to this and children + * - Propagate events from children to the other children (but not the emitting one) and the parent + * + * @constructor + * + * @param {lm.LayoutManager} layoutManager + */ + lm.utils.EventHub = function (layoutManager) { + lm.utils.EventEmitter.call(this); + this._layoutManager = layoutManager; + this._dontPropagateToParent = null; + this._childEventSource = null; + this.on(lm.utils.EventEmitter.ALL_EVENT, lm.utils.fnBind(this._onEventFromThis, this)); + this._boundOnEventFromChild = lm.utils.fnBind(this._onEventFromChild, this); + $(window).on('gl_child_event', this._boundOnEventFromChild); + }; + + /** + * Called on every event emitted on this eventHub, regardles of origin. + * + * @private + * + * @param {Mixed} + * + * @returns {void} + */ + lm.utils.EventHub.prototype._onEventFromThis = function () { + var args = Array.prototype.slice.call(arguments); + + if (this._layoutManager.isSubWindow && args[0] !== this._dontPropagateToParent) { + this._propagateToParent(args); + } + this._propagateToChildren(args); + + //Reset + this._dontPropagateToParent = null; + this._childEventSource = null; + }; + + /** + * Called by the parent layout. + * + * @param {Array} args Event name + arguments + * + * @returns {void} + */ + lm.utils.EventHub.prototype._$onEventFromParent = function (args) { + this._dontPropagateToParent = args[0]; + this.emit.apply(this, args); + }; + + /** + * Callback for child events raised on the window + * + * @param {DOMEvent} event + * @private + * + * @returns {void} + */ + lm.utils.EventHub.prototype._onEventFromChild = function (event) { + this._childEventSource = event.originalEvent.__gl; + this.emit.apply(this, event.originalEvent.__glArgs); + }; + + /** + * Propagates the event to the parent by emitting + * it on the parent's DOM window + * + * @param {Array} args Event name + arguments + * @private + * + * @returns {void} + */ + lm.utils.EventHub.prototype._propagateToParent = function (args) { + var event, + eventName = 'gl_child_event'; + + if (document.createEvent) { + event = window.opener.document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + } else { + event = window.opener.document.createEventObject(); + event.eventType = eventName; + } + + event.eventName = eventName; + event.__glArgs = args; + event.__gl = this._layoutManager; + + if (document.createEvent) { + window.opener.dispatchEvent(event); + } else { + window.opener.fireEvent('on' + event.eventType, event); + } + }; + + /** + * Propagate events to children + * + * @param {Array} args Event name + arguments + * @private + * + * @returns {void} + */ + lm.utils.EventHub.prototype._propagateToChildren = function (args) { + var childGl, i; + + for (i = 0; i < this._layoutManager.openPopouts.length; i++) { + childGl = this._layoutManager.openPopouts[i].getGlInstance(); + + if (childGl && childGl !== this._childEventSource) { + childGl.eventHub._$onEventFromParent(args); + } + } + }; + + + /** + * Destroys the EventHub + * + * @public + * @returns {void} + */ + + lm.utils.EventHub.prototype.destroy = function () { + $(window).off('gl_child_event', this._boundOnEventFromChild); + }; + /** + * A specialised GoldenLayout component that binds GoldenLayout container + * lifecycle events to react components + * + * @constructor + * + * @param {lm.container.ItemContainer} container + * @param {Object} state state is not required for react components + */ + lm.utils.ReactComponentHandler = function (container, state) { + this._reactComponent = null; + this._originalComponentWillUpdate = null; + this._container = container; + this._initialState = state; + this._reactClass = this._getReactClass(); + this._container.on('open', this._render, this); + this._container.on('destroy', this._destroy, this); + }; + + lm.utils.copy(lm.utils.ReactComponentHandler.prototype, { + + /** + * Creates the react class and component and hydrates it with + * the initial state - if one is present + * + * By default, react's getInitialState will be used + * + * @private + * @returns {void} + */ + _render: function () { + this._reactComponent = ReactDOM.render(this._getReactComponent(), this._container.getElement()[0]); + this._originalComponentWillUpdate = this._reactComponent.componentWillUpdate || function () { + }; + this._reactComponent.componentWillUpdate = this._onUpdate.bind(this); + if (this._container.getState()) { + this._reactComponent.setState(this._container.getState()); + } + }, + + /** + * Removes the component from the DOM and thus invokes React's unmount lifecycle + * + * @private + * @returns {void} + */ + _destroy: function () { + ReactDOM.unmountComponentAtNode(this._container.getElement()[0]); + this._container.off('open', this._render, this); + this._container.off('destroy', this._destroy, this); + }, + + /** + * Hooks into React's state management and applies the componentstate + * to GoldenLayout + * + * @private + * @returns {void} + */ + _onUpdate: function (nextProps, nextState) { + this._container.setState(nextState); + this._originalComponentWillUpdate.call(this._reactComponent, nextProps, nextState); + }, + + /** + * Retrieves the react class from GoldenLayout's registry + * + * @private + * @returns {React.Class} + */ + _getReactClass: function () { + var componentName = this._container._config.component; + var reactClass; + + if (!componentName) { + throw new Error('No react component name. type: react-component needs a field `component`'); + } + + reactClass = this._container.layoutManager.getComponent(componentName); + + if (!reactClass) { + throw new Error('React component "' + componentName + '" not found. ' + + 'Please register all components with GoldenLayout using `registerComponent(name, component)`'); + } + + return reactClass; + }, + + /** + * Copies and extends the properties array and returns the React element + * + * @private + * @returns {React.Element} + */ + _getReactComponent: function () { + var defaultProps = { + glEventHub: this._container.layoutManager.eventHub, + glContainer: this._container, + }; + var props = $.extend(defaultProps, this._container._config.props); + return React.createElement(this._reactClass, props); + } + }); +})(window.$);
\ No newline at end of file |