(function ($) { //if a tab is removed from the DOM and reinserted somewhere else all nested scrollTops seems to get reset to 0. (golden layout does this when a row structure needs to be converted to col or vice-versa) const saveScrollTops = (element) => { const children = Array.from(element.children()); while (children.length) { const child = children.pop(); if (child.children) children.push(...(Array.from(child.children))); if (child.scrollTop) child.preScrollTop = child.scrollTop; } } const restoreScrollTops = (element) => { const children = Array.from(element.children()); while (children.length) { const child = children.pop(); if (child.children) children.push(...(Array.from(child.children))); if (child.preScrollTop) { child.scrollTop = child.preScrollTop; } } } 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(/]+)>)/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._bAborting = 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(); // don't prevent deafult as this will stop text selection if (oEvent.target && oEvent.target.className === "lm_title") return; // if the title is active and receiving events, then don't do tab dragging. 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.on('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); } } }, AbortDrag: function () { 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._bAborting = true; this.emit('dragStop', { pageX: 0, pageY: 0 }, this._nOriginalX + this._nX); this._bAborting = false; } } }, 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); } else { // make title receive pointer events to allow setting insertion position or selecting texst range const classname = oEvent.target ? (typeof oEvent.target.className === "string" ? oEvent.target.className : ""): ""; if (classname.includes("lm_title_wrap")) { oEvent.target.children[0].style.pointerEvents = "all"; oEvent.target.children[0].focus(); } } } }, _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 = $('
'); this._creationTimeoutPassed = false; this._subWindowsCreated = false; this._dragSources = []; this._updatingColumnsResponsive = false; this._firstLoad = true; this.saveScrollTops = saveScrollTops; this.restoreScrollTops = restoreScrollTops; 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 = $('
'); 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 = $('
' + '
' + '
' + '
'); 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: false, //do not reorder! - horizontal scroll tabControlOffset: 10 }, dimensions: { borderWidth: 3, borderGrabWidth: 5, minItemHeight: 10, minItemWidth: 20, headerHeight: 27, dragProxyWidth: 300, dragProxyHeight: 200 }, labels: { close: 'close tab stack', maximise: 'maximize stack', minimise: 'minimise', popout: 'create new collection tab', 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 = $([ '
', '
', '
' ].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'); saveScrollTops(contentItem.element); this.childElementContainer.append(contentItem.element); this._updateTree(); this._layoutManager._$calculateItemAreas(); this._setDimensions(); $(document.body).append(this.element); restoreScrollTops(contentItem.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 = '
' + '
' + '' + '
' + '
' + '
'; 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(); let abortedDrop = this._dragListener._bAborting; /* * Valid drop area found */ if (!abortedDrop && 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 (!abortedDrop && 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 (!abortedDrop && 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(); } restoreScrollTops(this._contentItem.element) this.element.remove(); !abortedDrop && 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); }, destroy: function () { this._dragListener.destroy(); this._element = null; this._itemConfig = null; this._dragListener = null; const index = this._layoutManager._dragSources.indexOf(this); if (index > -1) { this._layoutManager._dragSources.splice(index, 1); } }, /** * 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 = '
'; 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 = [ '
', '', '', '
' ].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; } } // glr: removed for new tab manager // 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; } 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 = $('
  • '); 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 = $('
    '); var element = $('
    '); 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.css('cursor', 'row-resize'); element.addClass('lm_vertical'); element['height'](this._size); } else { dragHandle.css('left', -handleExcessPos); dragHandle.css('width', this._size + handleExcessSize); element.css('cursor', 'col-resize'); 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.titleElement[0].style.pointerEvents = "none"; // don't let title receive pointer events by default -- need to click on it to make it editable -- this allows the tab to be dragged this.titleElement[0].onblur = () => this.titleElement[0].style.pointerEvents = "none"; 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 = '
  • ' + '
    ' + '
  • '; 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._layoutManager.emit('tabDestroyed', this); 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(); } let proxy = new lm.controls.DragProxy( x, y, this._dragListener, this._layoutManager, this.contentItem, this.header.parent ); this._layoutManager.emit('dragStart', proxy); }, /** * 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 = $('
    '); $(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) { const stack = this; const rowOrCol = stack.parent; const parRowOrCol = rowOrCol.parent; const canDelete = rowOrCol && !rowOrCol.isRoot && (rowOrCol.contentItems.length > 1 || (parRowOrCol && parRowOrCol.contentItems.length > 1)); // bcz: added test for last stack if (canDelete) { rowOrCol.removeChild(stack); } } }, /** * 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 = $('
    '); 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 = $('
    '); 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) { if (["row","column"].includes(this.contentItems[0].type) || ["row","column"].includes(this.parent.type)) { let parent = this.parent; let correctRowOrCol = this.contentItems[0]; saveScrollTops(correctRowOrCol.element); parent.replaceChild(this, correctRowOrCol); restoreScrollTops(correctRowOrCol.element); } // bcz: this has the effect of removing children from the DOM and then re-adding them above where they were before. // in the case of things like an iFrame with a YouTube video, the video will reload for now reason. So let's try leaving these "empty" rows alone. // 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 = $( '
    ' + '

    Click to create a new tab

    ' + '
    ' ); 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 = $('
    '); 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[Math.min(this.contentItems.length - 1, 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'; if (this.parent.contentItems.length === 1) { let grandparent = this.parent.parent; let correctRowOrCol = this.layoutManager.createContentItem({ type: type }, this); grandparent.replaceChild(this.parent, correctRowOrCol); correctRowOrCol.addChild(contentItem, 0, true); let newstack = this.contentItems[0]; if (newstack.isComponent) { newstack = this.layoutManager.createContentItem({ type: 'stack', header: contentItem.config.header || {} }, this); newstack._$init(); newstack.addChild(this.contentItems[0]); } correctRowOrCol.addChild(newstack, !insertBefore ? 0 : undefined, true); newstack.config[dimension] = 50; contentItem.config[dimension] = 50; correctRowOrCol.callDownwards('setSize'); } else { 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() - 25, 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 tab stack', 'maximize stack', 'minimise', 'create new collection tab' ]; }; 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} 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.createRoot(this._container.getElement()[0]) this._reactComponent.render(this._getReactComponent()); this._originalComponentWillUpdate = this._reactComponent.componentDidUpdate || function () { }; this._reactComponent.componentDidUpdate = 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 () { this._reactComponent.unmount(); // 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.$);