diff options
Diffstat (limited to 'toolkit/jetpack/sdk/panel.js')
-rw-r--r-- | toolkit/jetpack/sdk/panel.js | 427 |
1 files changed, 427 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/panel.js b/toolkit/jetpack/sdk/panel.js new file mode 100644 index 000000000..4b625799d --- /dev/null +++ b/toolkit/jetpack/sdk/panel.js @@ -0,0 +1,427 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// The panel module currently supports only Firefox and SeaMonkey. +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps +module.metadata = { + "stability": "stable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cu, Ci } = require("chrome"); +const { setTimeout } = require('./timers'); +const { Class } = require("./core/heritage"); +const { merge } = require("./util/object"); +const { WorkerHost } = require("./content/utils"); +const { Worker } = require("./deprecated/sync-worker"); +const { Disposable } = require("./core/disposable"); +const { WeakReference } = require('./core/reference'); +const { contract: loaderContract } = require("./content/loader"); +const { contract } = require("./util/contract"); +const { on, off, emit, setListeners } = require("./event/core"); +const { EventTarget } = require("./event/target"); +const domPanel = require("./panel/utils"); +const { getDocShell } = require('./frame/utils'); +const { events } = require("./panel/events"); +const systemEvents = require("./system/events"); +const { filter, pipe, stripListeners } = require("./event/utils"); +const { getNodeView, getActiveView } = require("./view/core"); +const { isNil, isObject, isNumber } = require("./lang/type"); +const { getAttachEventType } = require("./content/utils"); +const { number, boolean, object } = require('./deprecated/api-utils'); +const { Style } = require("./stylesheet/style"); +const { attach, detach } = require("./content/mod"); + +var isRect = ({top, right, bottom, left}) => [top, right, bottom, left]. + some(value => isNumber(value) && !isNaN(value)); + +var isSDKObj = obj => obj instanceof Class; + +var rectContract = contract({ + top: number, + right: number, + bottom: number, + left: number +}); + +var position = { + is: object, + map: v => (isNil(v) || isSDKObj(v) || !isObject(v)) ? v : rectContract(v), + ok: v => isNil(v) || isSDKObj(v) || (isObject(v) && isRect(v)), + msg: 'The option "position" must be a SDK object registered as anchor; ' + + 'or an object with one or more of the following keys set to numeric ' + + 'values: top, right, bottom, left.' +} + +var displayContract = contract({ + width: number, + height: number, + focus: boolean, + position: position +}); + +var panelContract = contract(merge({ + // contentStyle* / contentScript* are sharing the same validation constraints, + // so they can be mostly reused, except for the messages. + contentStyle: merge(Object.create(loaderContract.rules.contentScript), { + msg: 'The `contentStyle` option must be a string or an array of strings.' + }), + contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), { + msg: 'The `contentStyleFile` option must be a local URL or an array of URLs' + }), + contextMenu: boolean, + allow: { + is: ['object', 'undefined', 'null'], + map: function (allow) { return { script: !allow || allow.script !== false }} + }, +}, displayContract.rules, loaderContract.rules)); + +function Allow(panel) { + return { + get script() { return getDocShell(viewFor(panel).backgroundFrame).allowJavascript; }, + set script(value) { return setScriptState(panel, value); }, + }; +} + +function setScriptState(panel, value) { + let view = viewFor(panel); + getDocShell(view.backgroundFrame).allowJavascript = value; + getDocShell(view.viewFrame).allowJavascript = value; + view.setAttribute("sdkscriptenabled", "" + value); +} + +function isDisposed(panel) { + return !views.has(panel); +} + +var panels = new WeakMap(); +var models = new WeakMap(); +var views = new WeakMap(); +var workers = new WeakMap(); +var styles = new WeakMap(); + +const viewFor = (panel) => views.get(panel); +const modelFor = (panel) => models.get(panel); +const panelFor = (view) => panels.get(view); +const workerFor = (panel) => workers.get(panel); +const styleFor = (panel) => styles.get(panel); + +function getPanelFromWeakRef(weakRef) { + if (!weakRef) { + return null; + } + let panel = weakRef.get(); + if (!panel) { + return null; + } + if (isDisposed(panel)) { + return null; + } + return panel; +} + +var SinglePanelManager = { + visiblePanel: null, + enqueuedPanel: null, + enqueuedPanelCallback: null, + // Calls |callback| with no arguments when the panel may be shown. + requestOpen: function(panelToOpen, callback) { + let currentPanel = getPanelFromWeakRef(SinglePanelManager.visiblePanel); + if (currentPanel || SinglePanelManager.enqueuedPanel) { + SinglePanelManager.enqueuedPanel = Cu.getWeakReference(panelToOpen); + SinglePanelManager.enqueuedPanelCallback = callback; + if (currentPanel && currentPanel.isShowing) { + currentPanel.hide(); + } + } else { + SinglePanelManager.notifyPanelCanOpen(panelToOpen, callback); + } + }, + notifyPanelCanOpen: function(panel, callback) { + let view = viewFor(panel); + // Can't pass an arrow function as the event handler because we need to be + // able to call |removeEventListener| later. + view.addEventListener("popuphidden", SinglePanelManager.onVisiblePanelHidden, true); + view.addEventListener("popupshown", SinglePanelManager.onVisiblePanelShown, false); + SinglePanelManager.enqueuedPanel = null; + SinglePanelManager.enqueuedPanelCallback = null; + SinglePanelManager.visiblePanel = Cu.getWeakReference(panel); + callback(); + }, + onVisiblePanelShown: function(event) { + let panel = panelFor(event.target); + if (SinglePanelManager.enqueuedPanel) { + // Another panel started waiting for |panel| to close before |panel| was + // even done opening. + panel.hide(); + } + }, + onVisiblePanelHidden: function(event) { + let view = event.target; + let panel = panelFor(view); + let currentPanel = getPanelFromWeakRef(SinglePanelManager.visiblePanel); + if (currentPanel && currentPanel != panel) { + return; + } + SinglePanelManager.visiblePanel = null; + view.removeEventListener("popuphidden", SinglePanelManager.onVisiblePanelHidden, true); + view.removeEventListener("popupshown", SinglePanelManager.onVisiblePanelShown, false); + let nextPanel = getPanelFromWeakRef(SinglePanelManager.enqueuedPanel); + let nextPanelCallback = SinglePanelManager.enqueuedPanelCallback; + if (nextPanel) { + SinglePanelManager.notifyPanelCanOpen(nextPanel, nextPanelCallback); + } + } +}; + +const Panel = Class({ + implements: [ + // Generate accessors for the validated properties that update model on + // set and return values from model on get. + panelContract.properties(modelFor), + EventTarget, + Disposable, + WeakReference + ], + extends: WorkerHost(workerFor), + setup: function setup(options) { + let model = merge({ + defaultWidth: 320, + defaultHeight: 240, + focus: true, + position: Object.freeze({}), + contextMenu: false + }, panelContract(options)); + model.ready = false; + models.set(this, model); + + if (model.contentStyle || model.contentStyleFile) { + styles.set(this, Style({ + uri: model.contentStyleFile, + source: model.contentStyle + })); + } + + // Setup view + let viewOptions = {allowJavascript: !model.allow || (model.allow.script !== false)}; + let view = domPanel.make(null, viewOptions); + panels.set(view, this); + views.set(this, view); + + // Load panel content. + domPanel.setURL(view, model.contentURL); + + // Allow context menu + domPanel.allowContextMenu(view, model.contextMenu); + + // Setup listeners. + setListeners(this, options); + let worker = new Worker(stripListeners(options)); + workers.set(this, worker); + + // pipe events from worker to a panel. + pipe(worker, this); + }, + dispose: function dispose() { + this.hide(); + off(this); + + workerFor(this).destroy(); + detach(styleFor(this)); + + domPanel.dispose(viewFor(this)); + + // Release circular reference between view and panel instance. This + // way view will be GC-ed. And panel as well once all the other refs + // will be removed from it. + views.delete(this); + }, + /* Public API: Panel.width */ + get width() { + return modelFor(this).width; + }, + set width(value) { + this.resize(value, this.height); + }, + /* Public API: Panel.height */ + get height() { + return modelFor(this).height; + }, + set height(value) { + this.resize(this.width, value); + }, + + /* Public API: Panel.focus */ + get focus() { + return modelFor(this).focus; + }, + + /* Public API: Panel.position */ + get position() { + return modelFor(this).position; + }, + + /* Public API: Panel.contextMenu */ + get contextMenu() { + return modelFor(this).contextMenu; + }, + set contextMenu(allow) { + let model = modelFor(this); + model.contextMenu = panelContract({ contextMenu: allow }).contextMenu; + domPanel.allowContextMenu(viewFor(this), model.contextMenu); + }, + + get contentURL() { + return modelFor(this).contentURL; + }, + set contentURL(value) { + let model = modelFor(this); + model.contentURL = panelContract({ contentURL: value }).contentURL; + domPanel.setURL(viewFor(this), model.contentURL); + // Detach worker so that messages send will be queued until it's + // reatached once panel content is ready. + workerFor(this).detach(); + }, + + get allow() { return Allow(this); }, + set allow(value) { + let allowJavascript = panelContract({ allow: value }).allow.script; + return setScriptState(this, value); + }, + + /* Public API: Panel.isShowing */ + get isShowing() { + return !isDisposed(this) && domPanel.isOpen(viewFor(this)); + }, + + /* Public API: Panel.show */ + show: function show(options={}, anchor) { + SinglePanelManager.requestOpen(this, () => { + if (options instanceof Ci.nsIDOMElement) { + [anchor, options] = [options, null]; + } + + if (anchor instanceof Ci.nsIDOMElement) { + console.warn( + "Passing a DOM node to Panel.show() method is an unsupported " + + "feature that will be soon replaced. " + + "See: https://bugzilla.mozilla.org/show_bug.cgi?id=878877" + ); + } + + let model = modelFor(this); + let view = viewFor(this); + let anchorView = getNodeView(anchor || options.position || model.position); + + options = merge({ + position: model.position, + width: model.width, + height: model.height, + defaultWidth: model.defaultWidth, + defaultHeight: model.defaultHeight, + focus: model.focus, + contextMenu: model.contextMenu + }, displayContract(options)); + + if (!isDisposed(this)) { + domPanel.show(view, options, anchorView); + } + }); + return this; + }, + + /* Public API: Panel.hide */ + hide: function hide() { + // Quit immediately if panel is disposed or there is no state change. + domPanel.close(viewFor(this)); + + return this; + }, + + /* Public API: Panel.resize */ + resize: function resize(width, height) { + let model = modelFor(this); + let view = viewFor(this); + let change = panelContract({ + width: width || model.width || model.defaultWidth, + height: height || model.height || model.defaultHeight + }); + + model.width = change.width + model.height = change.height + + domPanel.resize(view, model.width, model.height); + + return this; + } +}); +exports.Panel = Panel; + +// Note must be defined only after value to `Panel` is assigned. +getActiveView.define(Panel, viewFor); + +// Filter panel events to only panels that are create by this module. +var panelEvents = filter(events, ({target}) => panelFor(target)); + +// Panel events emitted after panel has being shown. +var shows = filter(panelEvents, ({type}) => type === "popupshown"); + +// Panel events emitted after panel became hidden. +var hides = filter(panelEvents, ({type}) => type === "popuphidden"); + +// Panel events emitted after content inside panel is ready. For different +// panels ready may mean different state based on `contentScriptWhen` attribute. +// Weather given event represents readyness is detected by `getAttachEventType` +// helper function. +var ready = filter(panelEvents, ({type, target}) => + getAttachEventType(modelFor(panelFor(target))) === type); + +// Panel event emitted when the contents of the panel has been loaded. +var readyToShow = filter(panelEvents, ({type}) => type === "DOMContentLoaded"); + +// Styles should be always added as soon as possible, and doesn't makes them +// depends on `contentScriptWhen` +var start = filter(panelEvents, ({type}) => type === "document-element-inserted"); + +// Forward panel show / hide events to panel's own event listeners. +on(shows, "data", ({target}) => { + let panel = panelFor(target); + if (modelFor(panel).ready) + emit(panel, "show"); +}); + +on(hides, "data", ({target}) => { + let panel = panelFor(target); + if (modelFor(panel).ready) + emit(panel, "hide"); +}); + +on(ready, "data", ({target}) => { + let panel = panelFor(target); + let window = domPanel.getContentDocument(target).defaultView; + + workerFor(panel).attach(window); +}); + +on(readyToShow, "data", ({target}) => { + let panel = panelFor(target); + + if (!modelFor(panel).ready) { + modelFor(panel).ready = true; + + if (viewFor(panel).state == "open") + emit(panel, "show"); + } +}); + +on(start, "data", ({target}) => { + let panel = panelFor(target); + let window = domPanel.getContentDocument(target).defaultView; + + attach(styleFor(panel), window); +}); |