michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: "use strict"; michael@0: michael@0: // The panel module currently supports only Firefox. michael@0: // See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps michael@0: module.metadata = { michael@0: "stability": "stable", michael@0: "engines": { michael@0: "Firefox": "*" michael@0: } michael@0: }; michael@0: michael@0: const { Ci } = require("chrome"); michael@0: const { setTimeout } = require('./timers'); michael@0: const { isPrivateBrowsingSupported } = require('./self'); michael@0: const { isWindowPBSupported } = require('./private-browsing/utils'); michael@0: const { Class } = require("./core/heritage"); michael@0: const { merge } = require("./util/object"); michael@0: const { WorkerHost } = require("./content/utils"); michael@0: const { Worker } = require("./content/worker"); michael@0: const { Disposable } = require("./core/disposable"); michael@0: const { WeakReference } = require('./core/reference'); michael@0: const { contract: loaderContract } = require("./content/loader"); michael@0: const { contract } = require("./util/contract"); michael@0: const { on, off, emit, setListeners } = require("./event/core"); michael@0: const { EventTarget } = require("./event/target"); michael@0: const domPanel = require("./panel/utils"); michael@0: const { events } = require("./panel/events"); michael@0: const systemEvents = require("./system/events"); michael@0: const { filter, pipe, stripListeners } = require("./event/utils"); michael@0: const { getNodeView, getActiveView } = require("./view/core"); michael@0: const { isNil, isObject, isNumber } = require("./lang/type"); michael@0: const { getAttachEventType } = require("./content/utils"); michael@0: const { number, boolean, object } = require('./deprecated/api-utils'); michael@0: const { Style } = require("./stylesheet/style"); michael@0: const { attach, detach } = require("./content/mod"); michael@0: michael@0: let isRect = ({top, right, bottom, left}) => [top, right, bottom, left]. michael@0: some(value => isNumber(value) && !isNaN(value)); michael@0: michael@0: let isSDKObj = obj => obj instanceof Class; michael@0: michael@0: let rectContract = contract({ michael@0: top: number, michael@0: right: number, michael@0: bottom: number, michael@0: left: number michael@0: }); michael@0: michael@0: let position = { michael@0: is: object, michael@0: map: v => (isNil(v) || isSDKObj(v) || !isObject(v)) ? v : rectContract(v), michael@0: ok: v => isNil(v) || isSDKObj(v) || (isObject(v) && isRect(v)), michael@0: msg: 'The option "position" must be a SDK object registered as anchor; ' + michael@0: 'or an object with one or more of the following keys set to numeric ' + michael@0: 'values: top, right, bottom, left.' michael@0: } michael@0: michael@0: let displayContract = contract({ michael@0: width: number, michael@0: height: number, michael@0: focus: boolean, michael@0: position: position michael@0: }); michael@0: michael@0: let panelContract = contract(merge({ michael@0: // contentStyle* / contentScript* are sharing the same validation constraints, michael@0: // so they can be mostly reused, except for the messages. michael@0: contentStyle: merge(Object.create(loaderContract.rules.contentScript), { michael@0: msg: 'The `contentStyle` option must be a string or an array of strings.' michael@0: }), michael@0: contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), { michael@0: msg: 'The `contentStyleFile` option must be a local URL or an array of URLs' michael@0: }) michael@0: }, displayContract.rules, loaderContract.rules)); michael@0: michael@0: michael@0: function isDisposed(panel) !views.has(panel); michael@0: michael@0: let panels = new WeakMap(); michael@0: let models = new WeakMap(); michael@0: let views = new WeakMap(); michael@0: let workers = new WeakMap(); michael@0: let styles = new WeakMap(); michael@0: michael@0: const viewFor = (panel) => views.get(panel); michael@0: const modelFor = (panel) => models.get(panel); michael@0: const panelFor = (view) => panels.get(view); michael@0: const workerFor = (panel) => workers.get(panel); michael@0: const styleFor = (panel) => styles.get(panel); michael@0: michael@0: // Utility function takes `panel` instance and makes sure it will be michael@0: // automatically hidden as soon as other panel is shown. michael@0: let setupAutoHide = new function() { michael@0: let refs = new WeakMap(); michael@0: michael@0: return function setupAutoHide(panel) { michael@0: // Create system event listener that reacts to any panel showing and michael@0: // hides given `panel` if it's not the one being shown. michael@0: function listener({subject}) { michael@0: // It could be that listener is not GC-ed in the same cycle as michael@0: // panel in such case we remove listener manually. michael@0: let view = viewFor(panel); michael@0: if (!view) systemEvents.off("popupshowing", listener); michael@0: else if (subject !== view) panel.hide(); michael@0: } michael@0: michael@0: // system event listener is intentionally weak this way we'll allow GC michael@0: // to claim panel if it's no longer referenced by an add-on code. This also michael@0: // helps minimizing cleanup required on unload. michael@0: systemEvents.on("popupshowing", listener); michael@0: // To make sure listener is not claimed by GC earlier than necessary we michael@0: // associate it with `panel` it's associated with. This way it won't be michael@0: // GC-ed earlier than `panel` itself. michael@0: refs.set(panel, listener); michael@0: } michael@0: } michael@0: michael@0: const Panel = Class({ michael@0: implements: [ michael@0: // Generate accessors for the validated properties that update model on michael@0: // set and return values from model on get. michael@0: panelContract.properties(modelFor), michael@0: EventTarget, michael@0: Disposable, michael@0: WeakReference michael@0: ], michael@0: extends: WorkerHost(workerFor), michael@0: setup: function setup(options) { michael@0: let model = merge({ michael@0: defaultWidth: 320, michael@0: defaultHeight: 240, michael@0: focus: true, michael@0: position: Object.freeze({}), michael@0: }, panelContract(options)); michael@0: models.set(this, model); michael@0: michael@0: if (model.contentStyle || model.contentStyleFile) { michael@0: styles.set(this, Style({ michael@0: uri: model.contentStyleFile, michael@0: source: model.contentStyle michael@0: })); michael@0: } michael@0: michael@0: // Setup view michael@0: let view = domPanel.make(); michael@0: panels.set(view, this); michael@0: views.set(this, view); michael@0: michael@0: // Load panel content. michael@0: domPanel.setURL(view, model.contentURL); michael@0: michael@0: setupAutoHide(this); michael@0: michael@0: // Setup listeners. michael@0: setListeners(this, options); michael@0: let worker = new Worker(stripListeners(options)); michael@0: workers.set(this, worker); michael@0: michael@0: // pipe events from worker to a panel. michael@0: pipe(worker, this); michael@0: }, michael@0: dispose: function dispose() { michael@0: this.hide(); michael@0: off(this); michael@0: michael@0: workerFor(this).destroy(); michael@0: detach(styleFor(this)); michael@0: michael@0: domPanel.dispose(viewFor(this)); michael@0: michael@0: // Release circular reference between view and panel instance. This michael@0: // way view will be GC-ed. And panel as well once all the other refs michael@0: // will be removed from it. michael@0: views.delete(this); michael@0: }, michael@0: /* Public API: Panel.width */ michael@0: get width() modelFor(this).width, michael@0: set width(value) this.resize(value, this.height), michael@0: /* Public API: Panel.height */ michael@0: get height() modelFor(this).height, michael@0: set height(value) this.resize(this.width, value), michael@0: michael@0: /* Public API: Panel.focus */ michael@0: get focus() modelFor(this).focus, michael@0: michael@0: /* Public API: Panel.position */ michael@0: get position() modelFor(this).position, michael@0: michael@0: get contentURL() modelFor(this).contentURL, michael@0: set contentURL(value) { michael@0: let model = modelFor(this); michael@0: model.contentURL = panelContract({ contentURL: value }).contentURL; michael@0: domPanel.setURL(viewFor(this), model.contentURL); michael@0: // Detach worker so that messages send will be queued until it's michael@0: // reatached once panel content is ready. michael@0: workerFor(this).detach(); michael@0: }, michael@0: michael@0: /* Public API: Panel.isShowing */ michael@0: get isShowing() !isDisposed(this) && domPanel.isOpen(viewFor(this)), michael@0: michael@0: /* Public API: Panel.show */ michael@0: show: function show(options={}, anchor) { michael@0: if (options instanceof Ci.nsIDOMElement) { michael@0: [anchor, options] = [options, null]; michael@0: } michael@0: michael@0: if (anchor instanceof Ci.nsIDOMElement) { michael@0: console.warn( michael@0: "Passing a DOM node to Panel.show() method is an unsupported " + michael@0: "feature that will be soon replaced. " + michael@0: "See: https://bugzilla.mozilla.org/show_bug.cgi?id=878877" michael@0: ); michael@0: } michael@0: michael@0: let model = modelFor(this); michael@0: let view = viewFor(this); michael@0: let anchorView = getNodeView(anchor || options.position || model.position); michael@0: michael@0: options = merge({ michael@0: position: model.position, michael@0: width: model.width, michael@0: height: model.height, michael@0: defaultWidth: model.defaultWidth, michael@0: defaultHeight: model.defaultHeight, michael@0: focus: model.focus michael@0: }, displayContract(options)); michael@0: michael@0: if (!isDisposed(this)) michael@0: domPanel.show(view, options, anchorView); michael@0: michael@0: return this; michael@0: }, michael@0: michael@0: /* Public API: Panel.hide */ michael@0: hide: function hide() { michael@0: // Quit immediately if panel is disposed or there is no state change. michael@0: domPanel.close(viewFor(this)); michael@0: michael@0: return this; michael@0: }, michael@0: michael@0: /* Public API: Panel.resize */ michael@0: resize: function resize(width, height) { michael@0: let model = modelFor(this); michael@0: let view = viewFor(this); michael@0: let change = panelContract({ michael@0: width: width || model.width || model.defaultWidth, michael@0: height: height || model.height || model.defaultHeight michael@0: }); michael@0: michael@0: model.width = change.width michael@0: model.height = change.height michael@0: michael@0: domPanel.resize(view, model.width, model.height); michael@0: michael@0: return this; michael@0: } michael@0: }); michael@0: exports.Panel = Panel; michael@0: michael@0: // Note must be defined only after value to `Panel` is assigned. michael@0: getActiveView.define(Panel, viewFor); michael@0: michael@0: // Filter panel events to only panels that are create by this module. michael@0: let panelEvents = filter(events, ({target}) => panelFor(target)); michael@0: michael@0: // Panel events emitted after panel has being shown. michael@0: let shows = filter(panelEvents, ({type}) => type === "popupshown"); michael@0: michael@0: // Panel events emitted after panel became hidden. michael@0: let hides = filter(panelEvents, ({type}) => type === "popuphidden"); michael@0: michael@0: // Panel events emitted after content inside panel is ready. For different michael@0: // panels ready may mean different state based on `contentScriptWhen` attribute. michael@0: // Weather given event represents readyness is detected by `getAttachEventType` michael@0: // helper function. michael@0: let ready = filter(panelEvents, ({type, target}) => michael@0: getAttachEventType(modelFor(panelFor(target))) === type); michael@0: michael@0: // Styles should be always added as soon as possible, and doesn't makes them michael@0: // depends on `contentScriptWhen` michael@0: let start = filter(panelEvents, ({type}) => type === "document-element-inserted"); michael@0: michael@0: // Forward panel show / hide events to panel's own event listeners. michael@0: on(shows, "data", ({target}) => emit(panelFor(target), "show")); michael@0: michael@0: on(hides, "data", ({target}) => emit(panelFor(target), "hide")); michael@0: michael@0: on(ready, "data", ({target}) => { michael@0: let panel = panelFor(target); michael@0: let window = domPanel.getContentDocument(target).defaultView; michael@0: michael@0: workerFor(panel).attach(window); michael@0: }); michael@0: michael@0: on(start, "data", ({target}) => { michael@0: let panel = panelFor(target); michael@0: let window = domPanel.getContentDocument(target).defaultView; michael@0: michael@0: attach(styleFor(panel), window); michael@0: });