UNPKG

windowmanager

Version:

A framework to manage multiple dockable, HTML windows

807 lines (697 loc) 26.7 kB
import windowmanager from './global'; import { EventHandler, getUniqueWindowName } from '../../utils/index'; import { BoundingBox, Position, Size, Vector } from '../../geometry/index'; const defaultConfig = { width: 600, height: 600, frame: false, resizable: true, saveWindowState: false, autoShow: true, icon: location.href + 'favicon.ico', url: '.', minWidth: 100, minHeight: 100, maxWidth: Infinity, maxHeight: Infinity }; const configMap = { }; const acceptedEventHandlers = [ 'ready', 'drag-start', 'drag-before', 'drag-stop', 'dock-before', 'move', 'move-before', 'resize-before', 'close', 'minimize']; const transformPropNames = ['-ms-transform', '-moz-transform', '-o-transform', '-webkit-transform', 'transform']; /** * @callback Callback * @param {String|null} error - String on error, or null if no error * @param {Object|null} result - Object on success, or null if error */ /** * A Window class. * @extends EventHandler */ class Window extends EventHandler { /** * Wraps a window object. * @param {Object} config - Configuration */ constructor(config) { // Call the parent constructor: super(acceptedEventHandlers); config = config || {}; // If no arguments are passed, assume we are creating a default blank window const isArgConfig = !(config instanceof window.Window); // Setup private variables: this._ready = false; // TODO: Identify current states. this._isClosed = false; this._isHidden = false; this._isMinimized = false; this._isMaximized = false; this._dockedGroup = [this]; this._children = []; // TODO: Add way to remove or change heirarchy. this._parent = undefined; this._title = undefined; this._id = getUniqueWindowName(); if (isArgConfig) { for (const prop in config) { if (config.hasOwnProperty(prop) && configMap[prop] !== undefined) { config[configMap[prop]] = config[prop]; delete config[prop]; } } for (const prop in defaultConfig) { if (defaultConfig.hasOwnProperty(prop)) { config[prop] = config[prop] || defaultConfig[prop]; } } this._title = config.title == null ? this._id : config.title; if (config.parent) { config.parent._children.push(this); this._parent = config.parent; // TODO: Emit event 'child-added' on parent delete config.parent; } this._minSize = new BoundingBox(config.minWidth, config.minHeight); this._maxSize = new BoundingBox(config.maxWidth, config.maxHeight); let newWindow = windowmanager._launcher.document.createElement('iframe'); newWindow.src = config.url; newWindow.style.position = 'absolute'; if (!Number.isFinite(config.left)) { config.left = (windowmanager._launcher.innerWidth - config.width) / 2; } newWindow.style.left = config.left + 'px'; if (!Number.isFinite(config.top)) { config.top = (windowmanager._launcher.innerHeight - config.height) / 2; } newWindow.style.top = config.top + 'px'; newWindow.style.width = config.width + 'px'; newWindow.style.height = config.height + 'px'; newWindow.style.minWidth = this._minSize.left + 'px'; newWindow.style.minHeight = this._minSize.top + 'px'; newWindow.style.maxWidth = this._maxSize.left + 'px'; newWindow.style.maxHeight = this._maxSize.top + 'px'; newWindow.style.margin = 0; newWindow.style.padding = 0; newWindow.style.border = 0; newWindow.style.resize = 'both'; newWindow.style.overflow = 'auto'; windowmanager._launcher.document.body.appendChild(newWindow); this._window = newWindow; windowmanager._windows.set(this._id, this); this._ready = true; this.emit('ready'); windowmanager._internalBus.emit('window-create', this); this.bringToFront(); this.focus(); } else { this._minSize = new BoundingBox(defaultConfig.minWidth, defaultConfig.minHeight); this._maxSize = new BoundingBox(defaultConfig.maxWidth, defaultConfig.maxHeight); this._window = config.document.body; windowmanager._windows.set(this._id, this); this._ready = true; } } /** * Returns true if the {@link Window} instance is created, not closed, and ready for method calls. * @returns {Boolean} */ isReady() { return this._ready; } /** * Calls a callback when window is ready and setup. * @param {Callback=} */ onReady(callback) { if (this.isClosed()) { throw new Error('onReady can\'t be called on a closed window'); } if (this.isReady()) { return callback.call(this); } this.once('ready', callback); } /** * Returns whether window has been closed already. * @returns {Boolean} */ isClosed() { return this._isClosed; } /** * Returns window's current position. * @returns {Vector} */ getPosition() { return new Position(this._window.getBoundingClientRect()); } getMinWidth() { return this._minSize.left; } /** * Returns window's width. * @returns {Number} */ getWidth() { return this._window.getBoundingClientRect().width; } getMaxWidth() { return this._maxSize.left; } getMinHeight() { return this._minSize.top; } /** * Returns window's height. * @returns {Number} */ getHeight() { return this._window.getBoundingClientRect().height; } getMaxHeight() { return this._maxSize.top; } getMinSize() { return this._minSize.clone(); } /** * Returns window's size. * @returns {Size} */ getSize() { let box = this._window.getBoundingClientRect(); return new Size(box.width, box.height); } getMaxSize() { return this._maxSize.clone(); } /** * Returns window's bounding box. * @returns {BoundingBox} */ getBounds() { return new BoundingBox(this._window.getBoundingClientRect()); } getParent() { return this._parent; } setParent(parent) { // TODO: Execute appropriate checks (if not closed, and is this new parent a window) if (parent === this._parent) { return; } if (this._parent) { const index = this._parent._children.indexOf(this); if (index >= 0) { this._parent._children.splice(index, 1); } // TODO: Emit event 'child-removed' on current parent. } if (parent) { this._parent = parent; this._parent._children.push(this); // TODO: Emit event 'child-added on parent'. } } getChildren() { return this._children.slice(); } addChild(child) { child.setParent(this); } /** * Returns window's title. * @returns {String} */ getTitle() { return this._title; } /** * Sets window's title. * @param {String} */ setTitle(title) { if (!title) { throw new Error('setTitle requires one argument of type String'); } this._title = title; } /** * Returns true if window is hidden. * @returns {Boolean} */ isHidden() { return this._isHidden; } /** * Returns true if window is not hidden. * @returns {Boolean} */ isShown() { return !this._isHidden; } /** * Returns true if window is minimized. * @returns {Boolean} */ isMinimized() { return this._isMinimized; } /** * Returns true if window is maximized. * @returns {Boolean} */ isMaximized() { return this._isMaximized; } /** * Returns true if window is not hidden or minimize or maximized. * @returns {Boolean} */ isRestored() { return this.isShown() && !this.isMinimized() && !this.isMaximized(); } /** * Closes the window instance. * @param {Callback=} */ close(callback) { if (this.isClosed()) { return callback && callback(); } this._window.parentElement.removeChild(this._window); windowmanager._windows.delete(this._id); // Undock: this.undock(); // Move children to parent: const parent = this.getParent(); for (const child of this.getChildren()) { // We use getChildren to have a copy of the list, so child.setParent doesn't modify this loop's list! // TODO: Optimize this loop, by not making a copy of children, and not executing splice in each setParent! child.setParent(parent); } this.setParent(undefined); // Remove from parent this._isClosed = true; if (callback) { callback(); } this.emit('close'); windowmanager._internalBus.emit('window-close', this); } /** * Minimizes the window instance. * @param {Callback=} */ minimize(callback) { if (!this._ready) { throw new Error('minimize can\'t be called on an unready window'); } // TODO: What do we do on minimize in this runtime? for (let window of this._dockedGroup) { window._isMinimized = true; window.emit('minimize'); } } /** * Maximizes the window instance. * @param {Callback=} */ maximize(callback) { if (!this._ready) { throw new Error('maximize can\'t be called on an unready window'); } this._restoreBounds = this.getBounds(); this._window.style.left = 0; this._window.style.top = 0; this._window.style.width = '100%'; this._window.style.height = '100%'; this._isMaximized = true; if (callback) { callback(); } } /** * Unhides the window instance. * @param {Callback=} */ show(callback) { if (!this._ready) { throw new Error('show can\'t be called on an unready window'); } for (let window of this._dockedGroup) { window._window.style.display = ''; window._isHidden = false; } if (callback) { callback(); } } /** * Hides the window instance. * @param {Callback=} */ hide(callback) { if (!this._ready) { throw new Error('hide can\'t be called on an unready window'); } for (let window of this._dockedGroup) { window._window.style.display = 'none'; window._isHidden = true; } if (callback) { callback(); } } /** * Restores the window instance from the minimized or maximized states. * @param {Callback=} */ restore(callback) { if (!this._ready) { throw new Error('restore can\'t be called on an unready window'); } for (let window of this._dockedGroup) { if (window._isMaximized) { window._window.style.left = window._restoreBounds.left + 'px'; window._window.style.top = window._restoreBounds.top + 'px'; window._window.style.width = window._restoreBounds.getWidth() + 'px'; window._window.style.height = window._restoreBounds.getHeight() + 'px'; window._isHidden = false; window._isMinimized = false; window._isMaximized = false; } } if (callback) { callback(); } } /** * Brings the window instance to the front of all windows. * @param {Callback=} */ bringToFront(callback) { if (!this._ready) { throw new Error('bringToFront can\'t be called on an unready window'); } for (let window of this._dockedGroup) { if (window !== this) { window._window.style['z-index'] = windowmanager._getNextZIndex(); } } this._window.style['z-index'] = windowmanager._getNextZIndex(); if (callback) { callback(); } } /** * Sets focus to the window instance. * @param {Callback=} */ focus(callback) { if (!this._ready) { throw new Error('focus can\'t be called on an unready window'); } for (let window of this._dockedGroup) { if (window !== this) { window._window.contentWindow.focus(); } } this._window.contentWindow.focus(); if (callback) { callback(); } } /** * Resizes the window instance. * @param {Number} width * @param {Number} height * @param {Callback=} */ resizeTo(width, height, callback) { if (!this._ready) { throw new Error('resizeTo can\'t be called on an unready window'); } if (!this.emit('resize-before')) { return; } // Allow preventing resize let size = new Position(width, height); this.undock(); this._window.width = size.left + 'px'; this._window.height = size.top + 'px'; if (callback) { callback(); } } /** * Moves the window instance. * @param {Number} left * @param {Number} top * @param {Callback=} */ moveTo(left, top, callback) { if (!this._ready) { throw new Error('moveTo can\'t be called on an unready window'); } if (!this.emit('move-before')) { return; } // Allow preventing move let deltaPos = (new Position(left, top)).subtract(this.getPosition()); for (let window of this._dockedGroup) { let pos = window.getPosition().add(deltaPos); window._window.style.left = pos.left + 'px'; window._window.style.top = pos.top + 'px'; } if (callback) { callback(); } } /** * Moves the window instance relative to its current position. * @param {Number} deltaLeft * @param {Number} deltaTop * @param {Callback=} */ moveBy(deltaLeft, deltaTop, callback) { if (!this._ready) { throw new Error('moveBy can\'t be called on an unready window'); } if (!this.emit('move-before')) { return; } // Allow preventing move let deltaPos = new Position(deltaLeft, deltaTop); for (let window of this._dockedGroup) { let pos = window.getPosition().add(deltaPos); window._window.style.left = pos.left + 'px'; window._window.style.top = pos.top + 'px'; } if (callback) { callback(); } for (let window of this._dockedGroup) { window.emit('move'); } } setMinSize(width, height, callback) { if (!this._ready) { throw new Error('setMinSize can\'t be called on an unready window'); } const size = new Size(width, height); this.undock(); // TODO: Support changing size when docked. this._minSize.left = size.left; this._minSize.top = size.top; this._window.style.minWidth = this._minSize.left + 'px'; this._window.style.minHeight = this._minSize.top + 'px'; if (this.getWidth() < size.left || this.getHeight() < size.top) { // Resize window to meet new min size: // TODO: Take into account transform? this._window.style.width = Math.max(this.getWidth(), size.left) + 'px'; this._window.style.height = Math.max(this.getHeight(), size.top) + 'px'; if (callback) { callback(); } this.emit('resize'); } else { if (callback) { callback(); } } } setSize(width, height, callback) { if (!this._ready) { throw new Error('setMaxSize can\'t be called on an unready window'); } const size = new Size(width, height); this.undock(); // TODO: Support changing size when docked. this._window.style.width = Math.min(this._maxSize.left, Math.max(this._minSize.left, size.left)) + 'px'; this._window.style.height = Math.min(this._maxSize.top, Math.max(this._minSize.top, size.top)) + 'px'; // Clear transform: for (let transformPropName of transformPropNames) { this._window.style[transformPropName] = ''; } if (callback) { callback(); } this.emit('resize'); } forceScaledSize(width, height, callback) { if (!this._ready) { throw new Error('setMaxSize can\'t be called on an unready window'); } const size = new Size(Math.min(this._maxSize.left, Math.max(this._minSize.left, width)), Math.min(this._maxSize.top, Math.max(this._minSize.top, height))); this.undock(); // TODO: Support changing size when docked. this._window.style.width = size.left + 'px'; this._window.style.height = size.top + 'px'; // TODO: Calc transform: let transform = Math.min(width / size.left, height / size.top); for (let transformPropName of transformPropNames) { this._window.style[transformPropName] = 'scale(' + transform + ')'; } if (callback) { callback(); } this.emit('resize'); } setMaxSize(width, height, callback) { if (!this._ready) { throw new Error('setMaxSize can\'t be called on an unready window'); } const size = new Size(width, height); this.undock(); // TODO: Support changing size when docked. this._maxSize.left = size.left; this._maxSize.top = size.top; this._window.style.maxWidth = this._maxSize.left + 'px'; this._window.style.maxHeight = this._maxSize.top + 'px'; if (this.getWidth() > size.left || this.getHeight() > size.top) { // Resize window to meet new min size: // TODO: Take into account transform? this._window.style.width = Math.min(this.getWidth(), size.left) + 'px'; this._window.style.height = Math.min(this.getHeight(), size.top) + 'px'; // Clear transform: for (let transformPropName of transformPropNames) { this._window.style[transformPropName] = ''; } if (callback) { callback(); } this.emit('resize'); } else { if (callback) { callback(); } } } /** * Sets the bounds of the window instance. * @param {Number} left * @param {Number} top * @param {Number} right * @param {Number} bottom * @param {Callback=} */ setBounds(left, top, right, bottom, callback) { if (!this._ready) { throw new Error('resizeTo can\'t be called on an unready window'); } let bounds = new BoundingBox(left, top, right, bottom); this.undock(); // TODO: Support changing size when docked. this._window.style.left = bounds.left + 'px'; this._window.style.top = bounds.top + 'px'; // TODO: Take into account transform? this._window.style.width = Math.min(this._maxSize.left, Math.max(this._minSize.left, bounds.getWidth())) + 'px'; this._window.style.height = Math.min(this._maxSize.top, Math.max(this._minSize.top, bounds.getHeight())) + 'px'; // Clear transform: for (let transformPropName of transformPropNames) { this._window.style[transformPropName] = ''; } // TODO: Events if (callback) { callback(); } } /** * Force docking this window to another. They don't need to be touching. * @param {Window} * @param {Callback=} */ dock(other) { // TODO: Check if otherGroup is touching if (!this.emit('dock-before')) { return; } // Allow preventing dock if (other === undefined) { return; } // Failed to find other. TODO: Return error // If other is already in the group, return: if (this._dockedGroup.indexOf(other) >= 0) { return; } // Loop through all windows in otherGroup and add them to this's group: for (let otherWin of other._dockedGroup) { this._dockedGroup.push(otherWin); // Sharing the array between window objects makes it easier to manage: otherWin._dockedGroup = otherWin._dockedGroup; } } /** * Force undocking this window from it's group.<br> * TODO: Redock those still touching, EXCEPT 'this'. * @param {Window} * @param {Callback=} */ undock(other) { // Check to see if window is already undocked: if (this._dockedGroup.length === 1) { return; } // Undock this: this._dockedGroup.splice(this._dockedGroup.indexOf(this), 1); this._dockedGroup = [this]; // TODO: Redock those still touching, EXCEPT 'this'. } _dragStart() { if (!this.emit('drag-start')) { return; } // Allow preventing drag this.restore(); for (let window of this._dockedGroup) { window._dragStartPos = window.getPosition(); } } _dragBy(deltaLeft, deltaTop) { if (!this.emit('drag-before')) { return; } // Allow preventing drag // Perform Snap: const thisBounds = this.getBounds().moveTo(this._dragStartPos.left + deltaLeft, this._dragStartPos.top + deltaTop); let snapDelta = new Vector(NaN, NaN); for (const other of windowmanager._windows.values()) { if (other._dockedGroup !== this._dockedGroup) { snapDelta.setMin(thisBounds.getSnapDelta(other.getBounds())); } } deltaLeft += snapDelta.left || 0; deltaTop += snapDelta.top || 0; for (let other of this._dockedGroup) { let pos = other._dragStartPos; // If other doesn't have a drag position, start it: if (pos === undefined) { pos = other._dragStartPos = other.getPosition(); pos.left -= deltaLeft; pos.top -= deltaTop; } other._window.style.left = (pos.left + deltaLeft) + 'px'; other._window.style.top = (pos.top + deltaTop) + 'px'; other.emit('move'); } } _dragStop() { // Dock to those it snapped to: const thisBounds = this.getBounds(); for (const other of windowmanager._windows.values()) { if (thisBounds.isTouching(other.getBounds())) { this.dock(other); } } for (let window of this._dockedGroup) { delete window._dragStartPos; } this.emit('drag-stop'); } /** * Returns a list of all {@link Window} instances open. * @returns {Window[]} */ static getAll() { return Array.from(windowmanager._windows.values()); } /** * Returns the {@link Window} instance that has `id`. * @param {String|Number} * @returns {Window|undefined} */ static getByID(id) { return windowmanager._windows.get(id); } /** * Returns the {@link Window} instance that calls this function. * @returns {Window} */ static getCurrent() { return Window.current; } } // Add launcher to list of windows: if (windowmanager.runtime.isMain) { window.document.body.contentWindow = window; Window.current = new Window(window); // Force add launcher to window list } else { // No need to do this for child windows, since _windows is shared across windows. // Handle current window in this context: Window.current = (function () { for (let win of windowmanager._windows.values()) { if (win._window.contentWindow === window) { return win; } } })(); } if (!windowmanager.runtime.isMain) { // Setup handlers on this child window: let wX = 0; let wY = 0; let dragging = false; window.addEventListener('focus', function () { Window.current.bringToFront(); }); window.addEventListener('mousedown', function onDragStart(event) { if (event.target.classList && event.target.classList.contains('window-drag')) { dragging = true; wX = event.screenX; wY = event.screenY; Window.current._dragStart(); } }); window.addEventListener('touchstart', function (event) { if (event.target.classList && event.target.classList.contains('window-drag')) { event.preventDefault(); dragging = true; wX = event.touches[0].screenX; wY = event.touches[0].screenY; Window.current._dragStart(); } }); window.addEventListener('mousemove', function (event) { if (dragging) { Window.current._dragBy(event.screenX - wX, event.screenY - wY); } }); window.addEventListener('touchmove', function (event) { if (dragging) { event.preventDefault(); Window.current._dragBy(event.touches[0].screenX - wX, event.touches[0].screenY - wY); } }); window.addEventListener('mouseup', function (event) { if (dragging) { dragging = false; Window.current._dragStop(); } }); window.addEventListener('touchend', function (event) { if (dragging) { event.preventDefault(); dragging = false; Window.current._dragStop(); } }); } windowmanager.Window = Window; export default Window;