From ac46df8daea09899ce30dc8fd70986e258c746bf Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 9 Feb 2018 06:46:43 -0500 Subject: Move Add-on SDK source to toolkit/jetpack --- toolkit/jetpack/sdk/ui/button/action.js | 114 ++++++++++ toolkit/jetpack/sdk/ui/button/contract.js | 73 +++++++ toolkit/jetpack/sdk/ui/button/toggle.js | 127 +++++++++++ toolkit/jetpack/sdk/ui/button/view.js | 243 +++++++++++++++++++++ toolkit/jetpack/sdk/ui/button/view/events.js | 18 ++ toolkit/jetpack/sdk/ui/component.js | 182 ++++++++++++++++ toolkit/jetpack/sdk/ui/frame.js | 16 ++ toolkit/jetpack/sdk/ui/frame/model.js | 154 +++++++++++++ toolkit/jetpack/sdk/ui/frame/view.html | 18 ++ toolkit/jetpack/sdk/ui/frame/view.js | 150 +++++++++++++ toolkit/jetpack/sdk/ui/id.js | 27 +++ toolkit/jetpack/sdk/ui/sidebar.js | 311 +++++++++++++++++++++++++++ toolkit/jetpack/sdk/ui/sidebar/actions.js | 10 + toolkit/jetpack/sdk/ui/sidebar/contract.js | 27 +++ toolkit/jetpack/sdk/ui/sidebar/namespace.js | 15 ++ toolkit/jetpack/sdk/ui/sidebar/utils.js | 8 + toolkit/jetpack/sdk/ui/sidebar/view.js | 214 ++++++++++++++++++ toolkit/jetpack/sdk/ui/state.js | 239 ++++++++++++++++++++ toolkit/jetpack/sdk/ui/state/events.js | 18 ++ toolkit/jetpack/sdk/ui/toolbar.js | 16 ++ toolkit/jetpack/sdk/ui/toolbar/model.js | 151 +++++++++++++ toolkit/jetpack/sdk/ui/toolbar/view.js | 248 +++++++++++++++++++++ 22 files changed, 2379 insertions(+) create mode 100644 toolkit/jetpack/sdk/ui/button/action.js create mode 100644 toolkit/jetpack/sdk/ui/button/contract.js create mode 100644 toolkit/jetpack/sdk/ui/button/toggle.js create mode 100644 toolkit/jetpack/sdk/ui/button/view.js create mode 100644 toolkit/jetpack/sdk/ui/button/view/events.js create mode 100644 toolkit/jetpack/sdk/ui/component.js create mode 100644 toolkit/jetpack/sdk/ui/frame.js create mode 100644 toolkit/jetpack/sdk/ui/frame/model.js create mode 100644 toolkit/jetpack/sdk/ui/frame/view.html create mode 100644 toolkit/jetpack/sdk/ui/frame/view.js create mode 100644 toolkit/jetpack/sdk/ui/id.js create mode 100644 toolkit/jetpack/sdk/ui/sidebar.js create mode 100644 toolkit/jetpack/sdk/ui/sidebar/actions.js create mode 100644 toolkit/jetpack/sdk/ui/sidebar/contract.js create mode 100644 toolkit/jetpack/sdk/ui/sidebar/namespace.js create mode 100644 toolkit/jetpack/sdk/ui/sidebar/utils.js create mode 100644 toolkit/jetpack/sdk/ui/sidebar/view.js create mode 100644 toolkit/jetpack/sdk/ui/state.js create mode 100644 toolkit/jetpack/sdk/ui/state/events.js create mode 100644 toolkit/jetpack/sdk/ui/toolbar.js create mode 100644 toolkit/jetpack/sdk/ui/toolbar/model.js create mode 100644 toolkit/jetpack/sdk/ui/toolbar/view.js (limited to 'toolkit/jetpack/sdk/ui') diff --git a/toolkit/jetpack/sdk/ui/button/action.js b/toolkit/jetpack/sdk/ui/button/action.js new file mode 100644 index 000000000..dfb092d0c --- /dev/null +++ b/toolkit/jetpack/sdk/ui/button/action.js @@ -0,0 +1,114 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +const { Class } = require('../../core/heritage'); +const { merge } = require('../../util/object'); +const { Disposable } = require('../../core/disposable'); +const { on, off, emit, setListeners } = require('../../event/core'); +const { EventTarget } = require('../../event/target'); +const { getNodeView } = require('../../view/core'); + +const view = require('./view'); +const { buttonContract, stateContract } = require('./contract'); +const { properties, render, state, register, unregister, + getDerivedStateFor } = require('../state'); +const { events: stateEvents } = require('../state/events'); +const { events: viewEvents } = require('./view/events'); +const events = require('../../event/utils'); + +const { getActiveTab } = require('../../tabs/utils'); + +const { id: addonID } = require('../../self'); +const { identify } = require('../id'); + +const buttons = new Map(); + +const toWidgetId = id => + ('action-button--' + addonID.toLowerCase()+ '-' + id). + replace(/[^a-z0-9_-]/g, ''); + +const ActionButton = Class({ + extends: EventTarget, + implements: [ + properties(stateContract), + state(stateContract), + Disposable + ], + setup: function setup(options) { + let state = merge({ + disabled: false + }, buttonContract(options)); + + let id = toWidgetId(options.id); + + register(this, state); + + // Setup listeners. + setListeners(this, options); + + buttons.set(id, this); + + view.create(merge({}, state, { id: id })); + }, + + dispose: function dispose() { + let id = toWidgetId(this.id); + buttons.delete(id); + + off(this); + + view.dispose(id); + + unregister(this); + }, + + get id() { + return this.state().id; + }, + + click: function click() { view.click(toWidgetId(this.id)) } +}); +exports.ActionButton = ActionButton; + +identify.define(ActionButton, ({id}) => toWidgetId(id)); + +getNodeView.define(ActionButton, button => + view.nodeFor(toWidgetId(button.id)) +); + +var actionButtonStateEvents = events.filter(stateEvents, + e => e.target instanceof ActionButton); + +var actionButtonViewEvents = events.filter(viewEvents, + e => buttons.has(e.target)); + +var clickEvents = events.filter(actionButtonViewEvents, e => e.type === 'click'); +var updateEvents = events.filter(actionButtonViewEvents, e => e.type === 'update'); + +on(clickEvents, 'data', ({target: id, window}) => { + let button = buttons.get(id); + let state = getDerivedStateFor(button, getActiveTab(window)); + + emit(button, 'click', state); +}); + +on(updateEvents, 'data', ({target: id, window}) => { + render(buttons.get(id), window); +}); + +on(actionButtonStateEvents, 'data', ({target, window, state}) => { + let id = toWidgetId(target.id); + view.setIcon(id, window, state.icon); + view.setLabel(id, window, state.label); + view.setDisabled(id, window, state.disabled); + view.setBadge(id, window, state.badge, state.badgeColor); +}); diff --git a/toolkit/jetpack/sdk/ui/button/contract.js b/toolkit/jetpack/sdk/ui/button/contract.js new file mode 100644 index 000000000..ce6e33d95 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/button/contract.js @@ -0,0 +1,73 @@ +/* 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'; + +const { contract } = require('../../util/contract'); +const { isLocalURL } = require('../../url'); +const { isNil, isObject, isString } = require('../../lang/type'); +const { required, either, string, boolean, object, number } = require('../../deprecated/api-utils'); +const { merge } = require('../../util/object'); +const { freeze } = Object; + +const isIconSet = (icons) => + Object.keys(icons). + every(size => String(size >>> 0) === size && isLocalURL(icons[size])); + +var iconSet = { + is: either(object, string), + map: v => isObject(v) ? freeze(merge({}, v)) : v, + ok: v => (isString(v) && isLocalURL(v)) || (isObject(v) && isIconSet(v)), + msg: 'The option "icon" must be a local URL or an object with ' + + 'numeric keys / local URL values pair.' +} + +var id = { + is: string, + ok: v => /^[a-z-_][a-z0-9-_]*$/i.test(v), + msg: 'The option "id" must be a valid alphanumeric id (hyphens and ' + + 'underscores are allowed).' +}; + +var label = { + is: string, + ok: v => isNil(v) || v.trim().length > 0, + msg: 'The option "label" must be a non empty string' +} + +var badge = { + is: either(string, number), + msg: 'The option "badge" must be a string or a number' +} + +var badgeColor = { + is: string, + msg: 'The option "badgeColor" must be a string' +} + +var stateContract = contract({ + label: label, + icon: iconSet, + disabled: boolean, + badge: badge, + badgeColor: badgeColor +}); + +exports.stateContract = stateContract; + +var buttonContract = contract(merge({}, stateContract.rules, { + id: required(id), + label: required(label), + icon: required(iconSet) +})); + +exports.buttonContract = buttonContract; + +exports.toggleStateContract = contract(merge({ + checked: boolean +}, stateContract.rules)); + +exports.toggleButtonContract = contract(merge({ + checked: boolean +}, buttonContract.rules)); + diff --git a/toolkit/jetpack/sdk/ui/button/toggle.js b/toolkit/jetpack/sdk/ui/button/toggle.js new file mode 100644 index 000000000..a226b3212 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/button/toggle.js @@ -0,0 +1,127 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +const { Class } = require('../../core/heritage'); +const { merge } = require('../../util/object'); +const { Disposable } = require('../../core/disposable'); +const { on, off, emit, setListeners } = require('../../event/core'); +const { EventTarget } = require('../../event/target'); +const { getNodeView } = require('../../view/core'); + +const view = require('./view'); +const { toggleButtonContract, toggleStateContract } = require('./contract'); +const { properties, render, state, register, unregister, + setStateFor, getStateFor, getDerivedStateFor } = require('../state'); +const { events: stateEvents } = require('../state/events'); +const { events: viewEvents } = require('./view/events'); +const events = require('../../event/utils'); + +const { getActiveTab } = require('../../tabs/utils'); + +const { id: addonID } = require('../../self'); +const { identify } = require('../id'); + +const buttons = new Map(); + +const toWidgetId = id => + ('toggle-button--' + addonID.toLowerCase()+ '-' + id). + replace(/[^a-z0-9_-]/g, ''); + +const ToggleButton = Class({ + extends: EventTarget, + implements: [ + properties(toggleStateContract), + state(toggleStateContract), + Disposable + ], + setup: function setup(options) { + let state = merge({ + disabled: false, + checked: false + }, toggleButtonContract(options)); + + let id = toWidgetId(options.id); + + register(this, state); + + // Setup listeners. + setListeners(this, options); + + buttons.set(id, this); + + view.create(merge({ type: 'checkbox' }, state, { id: id })); + }, + + dispose: function dispose() { + let id = toWidgetId(this.id); + buttons.delete(id); + + off(this); + + view.dispose(id); + + unregister(this); + }, + + get id() { + return this.state().id; + }, + + click: function click() { + return view.click(toWidgetId(this.id)); + } +}); +exports.ToggleButton = ToggleButton; + +identify.define(ToggleButton, ({id}) => toWidgetId(id)); + +getNodeView.define(ToggleButton, button => + view.nodeFor(toWidgetId(button.id)) +); + +var toggleButtonStateEvents = events.filter(stateEvents, + e => e.target instanceof ToggleButton); + +var toggleButtonViewEvents = events.filter(viewEvents, + e => buttons.has(e.target)); + +var clickEvents = events.filter(toggleButtonViewEvents, e => e.type === 'click'); +var updateEvents = events.filter(toggleButtonViewEvents, e => e.type === 'update'); + +on(toggleButtonStateEvents, 'data', ({target, window, state}) => { + let id = toWidgetId(target.id); + + view.setIcon(id, window, state.icon); + view.setLabel(id, window, state.label); + view.setDisabled(id, window, state.disabled); + view.setChecked(id, window, state.checked); + view.setBadge(id, window, state.badge, state.badgeColor); +}); + +on(clickEvents, 'data', ({target: id, window, checked }) => { + let button = buttons.get(id); + let windowState = getStateFor(button, window); + + let newWindowState = merge({}, windowState, { checked: checked }); + + setStateFor(button, window, newWindowState); + + let state = getDerivedStateFor(button, getActiveTab(window)); + + emit(button, 'click', state); + + emit(button, 'change', state); +}); + +on(updateEvents, 'data', ({target: id, window}) => { + render(buttons.get(id), window); +}); diff --git a/toolkit/jetpack/sdk/ui/button/view.js b/toolkit/jetpack/sdk/ui/button/view.js new file mode 100644 index 000000000..63b7aea31 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/button/view.js @@ -0,0 +1,243 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '> 28' + } +}; + +const { Cu } = require('chrome'); +const { on, off, emit } = require('../../event/core'); + +const { data } = require('sdk/self'); + +const { isObject, isNil } = require('../../lang/type'); + +const { getMostRecentBrowserWindow } = require('../../window/utils'); +const { ignoreWindow } = require('../../private-browsing/utils'); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { AREA_PANEL, AREA_NAVBAR } = CustomizableUI; + +const { events: viewEvents } = require('./view/events'); + +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +const views = new Map(); +const customizedWindows = new WeakMap(); + +const buttonListener = { + onCustomizeStart: window => { + for (let [id, view] of views) { + setIcon(id, window, view.icon); + setLabel(id, window, view.label); + } + + customizedWindows.set(window, true); + }, + onCustomizeEnd: window => { + customizedWindows.delete(window); + + for (let [id, ] of views) { + let placement = CustomizableUI.getPlacementOfWidget(id); + + if (placement) + emit(viewEvents, 'data', { type: 'update', target: id, window: window }); + } + }, + onWidgetAfterDOMChange: (node, nextNode, container) => { + let { id } = node; + let view = views.get(id); + let window = node.ownerDocument.defaultView; + + if (view) { + emit(viewEvents, 'data', { type: 'update', target: id, window: window }); + } + } +}; + +CustomizableUI.addListener(buttonListener); + +require('../../system/unload').when( _ => + CustomizableUI.removeListener(buttonListener) +); + +function getNode(id, window) { + return !views.has(id) || ignoreWindow(window) + ? null + : CustomizableUI.getWidget(id).forWindow(window).node +}; + +function isInToolbar(id) { + let placement = CustomizableUI.getPlacementOfWidget(id); + + return placement && CustomizableUI.getAreaType(placement.area) === 'toolbar'; +} + + +function getImage(icon, isInToolbar, pixelRatio) { + let targetSize = (isInToolbar ? 18 : 32) * pixelRatio; + let bestSize = 0; + let image = icon; + + if (isObject(icon)) { + for (let size of Object.keys(icon)) { + size = +size; + let offset = targetSize - size; + + if (offset === 0) { + bestSize = size; + break; + } + + let delta = Math.abs(offset) - Math.abs(targetSize - bestSize); + + if (delta < 0) + bestSize = size; + } + + image = icon[bestSize]; + } + + if (image.indexOf('./') === 0) + return data.url(image.substr(2)); + + return image; +} + +function nodeFor(id, window=getMostRecentBrowserWindow()) { + return customizedWindows.has(window) ? null : getNode(id, window); +}; +exports.nodeFor = nodeFor; + +function create(options) { + let { id, label, icon, type, badge } = options; + + if (views.has(id)) + throw new Error('The ID "' + id + '" seems already used.'); + + CustomizableUI.createWidget({ + id: id, + type: 'custom', + removable: true, + defaultArea: AREA_NAVBAR, + allowedAreas: [ AREA_PANEL, AREA_NAVBAR ], + + onBuild: function(document) { + let window = document.defaultView; + + let node = document.createElementNS(XUL_NS, 'toolbarbutton'); + + let image = getImage(icon, true, window.devicePixelRatio); + + if (ignoreWindow(window)) + node.style.display = 'none'; + + node.setAttribute('id', this.id); + node.setAttribute('class', 'toolbarbutton-1 chromeclass-toolbar-additional badged-button'); + node.setAttribute('type', type); + node.setAttribute('label', label); + node.setAttribute('tooltiptext', label); + node.setAttribute('image', image); + node.setAttribute('constrain-size', 'true'); + + views.set(id, { + area: this.currentArea, + icon: icon, + label: label + }); + + node.addEventListener('command', function(event) { + if (views.has(id)) { + emit(viewEvents, 'data', { + type: 'click', + target: id, + window: event.view, + checked: node.checked + }); + } + }); + + return node; + } + }); +}; +exports.create = create; + +function dispose(id) { + if (!views.has(id)) return; + + views.delete(id); + CustomizableUI.destroyWidget(id); +} +exports.dispose = dispose; + +function setIcon(id, window, icon) { + let node = getNode(id, window); + + if (node) { + icon = customizedWindows.has(window) ? views.get(id).icon : icon; + let image = getImage(icon, isInToolbar(id), window.devicePixelRatio); + + node.setAttribute('image', image); + } +} +exports.setIcon = setIcon; + +function setLabel(id, window, label) { + let node = nodeFor(id, window); + + if (node) { + node.setAttribute('label', label); + node.setAttribute('tooltiptext', label); + } +} +exports.setLabel = setLabel; + +function setDisabled(id, window, disabled) { + let node = nodeFor(id, window); + + if (node) + node.disabled = disabled; +} +exports.setDisabled = setDisabled; + +function setChecked(id, window, checked) { + let node = nodeFor(id, window); + + if (node) + node.checked = checked; +} +exports.setChecked = setChecked; + +function setBadge(id, window, badge, color) { + let node = nodeFor(id, window); + + if (node) { + // `Array.from` is needed to handle unicode symbol properly: + // '𝐀𝐁'.length is 4 where Array.from('𝐀𝐁').length is 2 + let text = isNil(badge) + ? '' + : Array.from(String(badge)).slice(0, 4).join(''); + + node.setAttribute('badge', text); + + let badgeNode = node.ownerDocument.getAnonymousElementByAttribute(node, + 'class', 'toolbarbutton-badge'); + + if (badgeNode) + badgeNode.style.backgroundColor = isNil(color) ? '' : color; + } +} +exports.setBadge = setBadge; + +function click(id) { + let node = nodeFor(id); + + if (node) + node.click(); +} +exports.click = click; diff --git a/toolkit/jetpack/sdk/ui/button/view/events.js b/toolkit/jetpack/sdk/ui/button/view/events.js new file mode 100644 index 000000000..98909656a --- /dev/null +++ b/toolkit/jetpack/sdk/ui/button/view/events.js @@ -0,0 +1,18 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + 'SeaMonkey': '*', + 'Thunderbird': '*' + } +}; + +var channel = {}; + +exports.events = channel; diff --git a/toolkit/jetpack/sdk/ui/component.js b/toolkit/jetpack/sdk/ui/component.js new file mode 100644 index 000000000..d1f12c95e --- /dev/null +++ b/toolkit/jetpack/sdk/ui/component.js @@ -0,0 +1,182 @@ +/* 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"; + +// Internal properties not exposed to the public. +const cache = Symbol("component/cache"); +const writer = Symbol("component/writer"); +const isFirstWrite = Symbol("component/writer/first-write?"); +const currentState = Symbol("component/state/current"); +const pendingState = Symbol("component/state/pending"); +const isWriting = Symbol("component/writing?"); + +const isntNull = x => x !== null; + +const Component = function(options, children) { + this[currentState] = null; + this[pendingState] = null; + this[writer] = null; + this[cache] = null; + this[isFirstWrite] = true; + + this[Component.construct](options, children); +} +Component.Component = Component; +// Constructs component. +Component.construct = Symbol("component/construct"); +// Called with `options` and `children` and must return +// initial state back. +Component.initial = Symbol("component/initial"); + +// Function patches current `state` with a given update. +Component.patch = Symbol("component/patch"); +// Function that replaces current `state` with a passed state. +Component.reset = Symbol("component/reset"); + +// Function that must return render tree from passed state. +Component.render = Symbol("component/render"); + +// Path of the component with in the mount point. +Component.path = Symbol("component/path"); + +Component.isMounted = component => !!component[writer]; +Component.isWriting = component => !!component[isWriting]; + +// Internal method that mounts component to a writer. +// Mounts component to a writer. +Component.mount = (component, write) => { + if (Component.isMounted(component)) { + throw Error("Can not mount already mounted component"); + } + + component[writer] = write; + Component.write(component); + + if (component[Component.mounted]) { + component[Component.mounted](); + } +} + +// Unmounts component from a writer. +Component.unmount = (component) => { + if (Component.isMounted(component)) { + component[writer] = null; + if (component[Component.unmounted]) { + component[Component.unmounted](); + } + } else { + console.warn("Unmounting component that is not mounted is redundant"); + } +}; + // Method invoked once after inital write occurs. +Component.mounted = Symbol("component/mounted"); +// Internal method that unmounts component from the writer. +Component.unmounted = Symbol("component/unmounted"); +// Function that must return true if component is changed +Component.isUpdated = Symbol("component/updated?"); +Component.update = Symbol("component/update"); +Component.updated = Symbol("component/updated"); + +const writeChild = base => (child, index) => Component.write(child, base, index) +Component.write = (component, base, index) => { + if (component === null) { + return component; + } + + if (!(component instanceof Component)) { + const path = base ? `${base}${component.key || index}/` : `/`; + return Object.assign({}, component, { + [Component.path]: path, + children: component.children && component.children. + map(writeChild(path)). + filter(isntNull) + }); + } + + component[isWriting] = true; + + try { + + const current = component[currentState]; + const pending = component[pendingState] || current; + const isUpdated = component[Component.isUpdated]; + const isInitial = component[isFirstWrite]; + + if (isUpdated(current, pending) || isInitial) { + if (!isInitial && component[Component.update]) { + component[Component.update](pending, current) + } + + // Note: [Component.update] could have caused more updates so can't use + // `pending` as `component[pendingState]` may have changed. + component[currentState] = component[pendingState] || current; + component[pendingState] = null; + + const tree = component[Component.render](component[currentState]); + component[cache] = Component.write(tree, base, index); + if (component[writer]) { + component[writer].call(null, component[cache]); + } + + if (!isInitial && component[Component.updated]) { + component[Component.updated](current, pending); + } + } + + component[isFirstWrite] = false; + + return component[cache]; + } finally { + component[isWriting] = false; + } +}; + +Component.prototype = Object.freeze({ + constructor: Component, + + [Component.mounted]: null, + [Component.unmounted]: null, + [Component.update]: null, + [Component.updated]: null, + + get state() { + return this[pendingState] || this[currentState]; + }, + + + [Component.construct](settings, items) { + const initial = this[Component.initial]; + const base = initial(settings, items); + const options = Object.assign(Object.create(null), base.options, settings); + const children = base.children || items || null; + const state = Object.assign(Object.create(null), base, {options, children}); + this[currentState] = state; + + if (this.setup) { + this.setup(state); + } + }, + [Component.initial](options, children) { + return Object.create(null); + }, + [Component.patch](update) { + this[Component.reset](Object.assign({}, this.state, update)); + }, + [Component.reset](state) { + this[pendingState] = state; + if (Component.isMounted(this) && !Component.isWriting(this)) { + Component.write(this); + } + }, + + [Component.isUpdated](before, after) { + return before != after + }, + + [Component.render](state) { + throw Error("Component must implement [Component.render] member"); + } +}); + +module.exports = Component; diff --git a/toolkit/jetpack/sdk/ui/frame.js b/toolkit/jetpack/sdk/ui/frame.js new file mode 100644 index 000000000..566353cdf --- /dev/null +++ b/toolkit/jetpack/sdk/ui/frame.js @@ -0,0 +1,16 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +require("./frame/view"); +const { Frame } = require("./frame/model"); + +exports.Frame = Frame; diff --git a/toolkit/jetpack/sdk/ui/frame/model.js b/toolkit/jetpack/sdk/ui/frame/model.js new file mode 100644 index 000000000..627310874 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/frame/model.js @@ -0,0 +1,154 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Class } = require("../../core/heritage"); +const { EventTarget } = require("../../event/target"); +const { emit, off, setListeners } = require("../../event/core"); +const { Reactor, foldp, send, merges } = require("../../event/utils"); +const { Disposable } = require("../../core/disposable"); +const { OutputPort } = require("../../output/system"); +const { InputPort } = require("../../input/system"); +const { identify } = require("../id"); +const { pairs, object, map, each } = require("../../util/sequence"); +const { patch, diff } = require("diffpatcher/index"); +const { isLocalURL } = require("../../url"); +const { compose } = require("../../lang/functional"); +const { contract } = require("../../util/contract"); +const { id: addonID, data: { url: resolve }} = require("../../self"); +const { Frames } = require("../../input/frame"); + + +const output = new OutputPort({ id: "frame-change" }); +const mailbox = new OutputPort({ id: "frame-mailbox" }); +const input = Frames; + + +const makeID = url => + ("frame-" + addonID + "-" + url). + split("/").join("-"). + split(".").join("-"). + replace(/[^A-Za-z0-9_\-]/g, ""); + +const validate = contract({ + name: { + is: ["string", "undefined"], + ok: x => /^[a-z][a-z0-9-_]+$/i.test(x), + msg: "The `option.name` must be a valid alphanumeric string (hyphens and " + + "underscores are allowed) starting with letter." + }, + url: { + map: x => x.toString(), + is: ["string"], + ok: x => isLocalURL(x), + msg: "The `options.url` must be a valid local URI." + } +}); + +const Source = function({id, ownerID}) { + this.id = id; + this.ownerID = ownerID; +}; +Source.postMessage = ({id, ownerID}, data, origin) => { + send(mailbox, object([id, { + inbox: { + target: {id: id, ownerID: ownerID}, + timeStamp: Date.now(), + data: data, + origin: origin + } + }])); +}; +Source.prototype.postMessage = function(data, origin) { + Source.postMessage(this, data, origin); +}; + +const Message = function({type, data, source, origin, timeStamp}) { + this.type = type; + this.data = data; + this.origin = origin; + this.timeStamp = timeStamp; + this.source = new Source(source); +}; + + +const frames = new Map(); +const sources = new Map(); + +const Frame = Class({ + extends: EventTarget, + implements: [Disposable, Source], + initialize: function(params={}) { + const options = validate(params); + const id = makeID(options.name || options.url); + + if (frames.has(id)) + throw Error("Frame with this id already exists: " + id); + + const initial = { id: id, url: resolve(options.url) }; + this.id = id; + + setListeners(this, params); + + frames.set(this.id, this); + + send(output, object([id, initial])); + }, + get url() { + const state = reactor.value[this.id]; + return state && state.url; + }, + destroy: function() { + send(output, object([this.id, null])); + frames.delete(this.id); + off(this); + }, + // `JSON.stringify` serializes objects based of the return + // value of this method. For convinienc we provide this method + // to serialize actual state data. + toJSON: function() { + return { id: this.id, url: this.url }; + } +}); +identify.define(Frame, frame => frame.id); + +exports.Frame = Frame; + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + each(([id, update]) => { + const frame = frames.get(id); + if (update) { + if (!past[id]) + emit(frame, "register"); + + if (update.outbox) + emit(frame, "message", new Message(present[id].outbox)); + + each(([ownerID, state]) => { + const readyState = state ? state.readyState : "detach"; + const type = readyState === "loading" ? "attach" : + readyState === "interactive" ? "ready" : + readyState === "complete" ? "load" : + readyState; + + // TODO: Cache `Source` instances somewhere to preserve + // identity. + emit(frame, type, {type: type, + source: new Source({id: id, ownerID: ownerID})}); + }, pairs(update.owners)); + } + }, pairs(delta)); + } +}); +reactor.run(input); diff --git a/toolkit/jetpack/sdk/ui/frame/view.html b/toolkit/jetpack/sdk/ui/frame/view.html new file mode 100644 index 000000000..2a405b583 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/frame/view.html @@ -0,0 +1,18 @@ + + + + + + + diff --git a/toolkit/jetpack/sdk/ui/frame/view.js b/toolkit/jetpack/sdk/ui/frame/view.js new file mode 100644 index 000000000..2eb4df2b7 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/frame/view.js @@ -0,0 +1,150 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Cu, Ci } = require("chrome"); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { subscribe, send, Reactor, foldp, lift, merges, keepIf } = require("../../event/utils"); +const { InputPort } = require("../../input/system"); +const { OutputPort } = require("../../output/system"); +const { LastClosed } = require("../../input/browser"); +const { pairs, keys, object, each } = require("../../util/sequence"); +const { curry, compose } = require("../../lang/functional"); +const { getFrameElement, getOuterId, + getByOuterId, getOwnerBrowserWindow } = require("../../window/utils"); +const { patch, diff } = require("diffpatcher/index"); +const { encode } = require("../../base64"); +const { Frames } = require("../../input/frame"); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const OUTER_FRAME_URI = module.uri.replace(/\.js$/, ".html"); + +const mailbox = new OutputPort({ id: "frame-mailbox" }); + +const frameID = frame => frame.id.replace("outer-", ""); +const windowID = compose(getOuterId, getOwnerBrowserWindow); + +const getOuterFrame = (windowID, frameID) => + getByOuterId(windowID).document.getElementById("outer-" + frameID); + +const listener = ({target, source, data, origin, timeStamp}) => { + // And sent received message to outbox so that frame API model + // will deal with it. + if (source && source !== target) { + const frame = getFrameElement(target); + const id = frameID(frame); + send(mailbox, object([id, { + outbox: {type: "message", + source: {id: id, ownerID: windowID(frame)}, + data: data, + origin: origin, + timeStamp: timeStamp}}])); + } +}; + +// Utility function used to create frame with a given `state` and +// inject it into given `window`. +const registerFrame = ({id, url}) => { + CustomizableUI.createWidget({ + id: id, + type: "custom", + removable: true, + onBuild: document => { + let view = document.createElementNS(XUL_NS, "toolbaritem"); + view.setAttribute("id", id); + view.setAttribute("flex", 2); + + let outerFrame = document.createElementNS(XUL_NS, "iframe"); + outerFrame.setAttribute("src", OUTER_FRAME_URI); + outerFrame.setAttribute("id", "outer-" + id); + outerFrame.setAttribute("data-is-sdk-outer-frame", true); + outerFrame.setAttribute("type", "content"); + outerFrame.setAttribute("transparent", true); + outerFrame.setAttribute("flex", 2); + outerFrame.setAttribute("style", "overflow: hidden;"); + outerFrame.setAttribute("scrolling", "no"); + outerFrame.setAttribute("disablehistory", true); + outerFrame.setAttribute("seamless", "seamless"); + outerFrame.addEventListener("load", function onload() { + outerFrame.removeEventListener("load", onload, true); + + let doc = outerFrame.contentDocument; + + let innerFrame = doc.createElementNS(HTML_NS, "iframe"); + innerFrame.setAttribute("id", id); + innerFrame.setAttribute("src", url); + innerFrame.setAttribute("seamless", "seamless"); + innerFrame.setAttribute("sandbox", "allow-scripts"); + innerFrame.setAttribute("scrolling", "no"); + innerFrame.setAttribute("data-is-sdk-inner-frame", true); + innerFrame.setAttribute("style", [ "border:none", + "position:absolute", "width:100%", "top: 0", + "left: 0", "overflow: hidden"].join(";")); + + doc.body.appendChild(innerFrame); + }, true); + + view.appendChild(outerFrame); + + return view; + } + }); +}; + +const unregisterFrame = CustomizableUI.destroyWidget; + +const deliverMessage = curry((frameID, data, windowID) => { + const frame = getOuterFrame(windowID, frameID); + const content = frame && frame.contentWindow; + + if (content) + content.postMessage(data, content.location.origin); +}); + +const updateFrame = (id, {inbox, owners}, present) => { + if (inbox) { + const { data, target:{ownerID}, source } = present[id].inbox; + if (ownerID) + deliverMessage(id, data, ownerID); + else + each(deliverMessage(id, data), keys(present[id].owners)); + } + + each(setupView(id), pairs(owners)); +}; + +const setupView = curry((frameID, [windowID, state]) => { + if (state && state.readyState === "loading") { + const frame = getOuterFrame(windowID, frameID); + // Setup a message listener on contentWindow. + frame.contentWindow.addEventListener("message", listener); + } +}); + + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + // Apply frame changes + each(([id, update]) => { + if (update === null) + unregisterFrame(id); + else if (past[id]) + updateFrame(id, update, present); + else + registerFrame(update); + }, pairs(delta)); + }, + onEnd: state => each(unregisterFrame, keys(state)) +}); +reactor.run(Frames); diff --git a/toolkit/jetpack/sdk/ui/id.js b/toolkit/jetpack/sdk/ui/id.js new file mode 100644 index 000000000..d17eb0a4e --- /dev/null +++ b/toolkit/jetpack/sdk/ui/id.js @@ -0,0 +1,27 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental' +}; + +const method = require('../../method/core'); +const { uuid } = require('../util/uuid'); + +// NOTE: use lang/functional memoize when it is updated to use WeakMap +function memoize(f) { + const memo = new WeakMap(); + + return function memoizer(o) { + let key = o; + if (!memo.has(key)) + memo.set(key, f.apply(this, arguments)); + return memo.get(key); + }; +} + +var identify = method('identify'); +identify.define(Object, memoize(function() { return uuid(); })); +exports.identify = identify; diff --git a/toolkit/jetpack/sdk/ui/sidebar.js b/toolkit/jetpack/sdk/ui/sidebar.js new file mode 100644 index 000000000..59e35ea11 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/sidebar.js @@ -0,0 +1,311 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*' + } +}; + +const { Class } = require('../core/heritage'); +const { merge } = require('../util/object'); +const { Disposable } = require('../core/disposable'); +const { off, emit, setListeners } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { URL } = require('../url'); +const { add, remove, has, clear, iterator } = require('../lang/weak-set'); +const { id: addonID, data } = require('../self'); +const { WindowTracker } = require('../deprecated/window-utils'); +const { isShowing } = require('./sidebar/utils'); +const { isBrowser, getMostRecentBrowserWindow, windows, isWindowPrivate } = require('../window/utils'); +const { ns } = require('../core/namespace'); +const { remove: removeFromArray } = require('../util/array'); +const { show, hide, toggle } = require('./sidebar/actions'); +const { Worker } = require('../deprecated/sync-worker'); +const { contract: sidebarContract } = require('./sidebar/contract'); +const { create, dispose, updateTitle, updateURL, isSidebarShowing, showSidebar, hideSidebar } = require('./sidebar/view'); +const { defer } = require('../core/promise'); +const { models, views, viewsFor, modelFor } = require('./sidebar/namespace'); +const { isLocalURL } = require('../url'); +const { ensure } = require('../system/unload'); +const { identify } = require('./id'); +const { uuid } = require('../util/uuid'); +const { viewFor } = require('../view/core'); + +const resolveURL = (url) => url ? data.url(url) : url; + +const sidebarNS = ns(); + +const WEB_PANEL_BROWSER_ID = 'web-panels-browser'; + +var sidebars = {}; + +const Sidebar = Class({ + implements: [ Disposable ], + extends: EventTarget, + setup: function(options) { + // inital validation for the model information + let model = sidebarContract(options); + + // save the model information + models.set(this, model); + + // generate an id if one was not provided + model.id = model.id || addonID + '-' + uuid(); + + // further validation for the title and url + validateTitleAndURLCombo({}, this.title, this.url); + + const self = this; + const internals = sidebarNS(self); + const windowNS = internals.windowNS = ns(); + + // see bug https://bugzilla.mozilla.org/show_bug.cgi?id=886148 + ensure(this, 'destroy'); + + setListeners(this, options); + + let bars = []; + internals.tracker = WindowTracker({ + onTrack: function(window) { + if (!isBrowser(window)) + return; + + let sidebar = window.document.getElementById('sidebar'); + let sidebarBox = window.document.getElementById('sidebar-box'); + + let bar = create(window, { + id: self.id, + title: self.title, + sidebarurl: self.url + }); + bars.push(bar); + windowNS(window).bar = bar; + + bar.addEventListener('command', function() { + if (isSidebarShowing(window, self)) { + hideSidebar(window, self).catch(() => {}); + return; + } + + showSidebar(window, self); + }, false); + + function onSidebarLoad() { + // check if the sidebar is ready + let isReady = sidebar.docShell && sidebar.contentDocument; + if (!isReady) + return; + + // check if it is a web panel + let panelBrowser = sidebar.contentDocument.getElementById(WEB_PANEL_BROWSER_ID); + if (!panelBrowser) { + bar.removeAttribute('checked'); + return; + } + + let sbTitle = window.document.getElementById('sidebar-title'); + function onWebPanelSidebarCreated() { + if (panelBrowser.contentWindow.location != resolveURL(model.url) || + sbTitle.value != model.title) { + return; + } + + let worker = windowNS(window).worker = Worker({ + window: panelBrowser.contentWindow, + injectInDocument: true + }); + + function onWebPanelSidebarUnload() { + windowNS(window).onWebPanelSidebarUnload = null; + + // uncheck the associated menuitem + bar.setAttribute('checked', 'false'); + + emit(self, 'hide', {}); + emit(self, 'detach', worker); + windowNS(window).worker = null; + } + windowNS(window).onWebPanelSidebarUnload = onWebPanelSidebarUnload; + panelBrowser.contentWindow.addEventListener('unload', onWebPanelSidebarUnload, true); + + // check the associated menuitem + bar.setAttribute('checked', 'true'); + + function onWebPanelSidebarReady() { + panelBrowser.contentWindow.removeEventListener('DOMContentLoaded', onWebPanelSidebarReady, false); + windowNS(window).onWebPanelSidebarReady = null; + + emit(self, 'ready', worker); + } + windowNS(window).onWebPanelSidebarReady = onWebPanelSidebarReady; + panelBrowser.contentWindow.addEventListener('DOMContentLoaded', onWebPanelSidebarReady, false); + + function onWebPanelSidebarLoad() { + panelBrowser.contentWindow.removeEventListener('load', onWebPanelSidebarLoad, true); + windowNS(window).onWebPanelSidebarLoad = null; + + // TODO: decide if returning worker is acceptable.. + //emit(self, 'show', { worker: worker }); + emit(self, 'show', {}); + } + windowNS(window).onWebPanelSidebarLoad = onWebPanelSidebarLoad; + panelBrowser.contentWindow.addEventListener('load', onWebPanelSidebarLoad, true); + + emit(self, 'attach', worker); + } + windowNS(window).onWebPanelSidebarCreated = onWebPanelSidebarCreated; + panelBrowser.addEventListener('DOMWindowCreated', onWebPanelSidebarCreated, true); + } + windowNS(window).onSidebarLoad = onSidebarLoad; + sidebar.addEventListener('load', onSidebarLoad, true); // removed properly + }, + onUntrack: function(window) { + if (!isBrowser(window)) + return; + + // hide the sidebar if it is showing + hideSidebar(window, self).catch(() => {}); + + // kill the menu item + let { bar } = windowNS(window); + if (bar) { + removeFromArray(viewsFor(self), bar); + dispose(bar); + } + + // kill listeners + let sidebar = window.document.getElementById('sidebar'); + + if (windowNS(window).onSidebarLoad) { + sidebar && sidebar.removeEventListener('load', windowNS(window).onSidebarLoad, true) + windowNS(window).onSidebarLoad = null; + } + + let panelBrowser = sidebar && sidebar.contentDocument.getElementById(WEB_PANEL_BROWSER_ID); + if (windowNS(window).onWebPanelSidebarCreated) { + panelBrowser && panelBrowser.removeEventListener('DOMWindowCreated', windowNS(window).onWebPanelSidebarCreated, true); + windowNS(window).onWebPanelSidebarCreated = null; + } + + if (windowNS(window).onWebPanelSidebarReady) { + panelBrowser && panelBrowser.contentWindow.removeEventListener('DOMContentLoaded', windowNS(window).onWebPanelSidebarReady, false); + windowNS(window).onWebPanelSidebarReady = null; + } + + if (windowNS(window).onWebPanelSidebarLoad) { + panelBrowser && panelBrowser.contentWindow.removeEventListener('load', windowNS(window).onWebPanelSidebarLoad, true); + windowNS(window).onWebPanelSidebarLoad = null; + } + + if (windowNS(window).onWebPanelSidebarUnload) { + panelBrowser && panelBrowser.contentWindow.removeEventListener('unload', windowNS(window).onWebPanelSidebarUnload, true); + windowNS(window).onWebPanelSidebarUnload(); + } + } + }); + + views.set(this, bars); + + add(sidebars, this); + }, + get id() { + return (modelFor(this) || {}).id; + }, + get title() { + return (modelFor(this) || {}).title; + }, + set title(v) { + // destroyed? + if (!modelFor(this)) + return; + // validation + if (typeof v != 'string') + throw Error('title must be a string'); + validateTitleAndURLCombo(this, v, this.url); + // do update + updateTitle(this, v); + return modelFor(this).title = v; + }, + get url() { + return (modelFor(this) || {}).url; + }, + set url(v) { + // destroyed? + if (!modelFor(this)) + return; + + // validation + if (!isLocalURL(v)) + throw Error('the url must be a valid local url'); + + validateTitleAndURLCombo(this, this.title, v); + + // do update + updateURL(this, v); + modelFor(this).url = v; + }, + show: function(window) { + return showSidebar(viewFor(window), this); + }, + hide: function(window) { + return hideSidebar(viewFor(window), this); + }, + dispose: function() { + const internals = sidebarNS(this); + + off(this); + + remove(sidebars, this); + + // stop tracking windows + if (internals.tracker) { + internals.tracker.unload(); + } + + internals.tracker = null; + internals.windowNS = null; + + views.delete(this); + models.delete(this); + } +}); +exports.Sidebar = Sidebar; + +function validateTitleAndURLCombo(sidebar, title, url) { + url = resolveURL(url); + + if (sidebar.title == title && sidebar.url == url) { + return false; + } + + for (let window of windows(null, { includePrivate: true })) { + let sidebar = window.document.querySelector('menuitem[sidebarurl="' + url + '"][label="' + title + '"]'); + if (sidebar) { + throw Error('The provided title and url combination is invalid (already used).'); + } + } + + return false; +} + +isShowing.define(Sidebar, isSidebarShowing.bind(null, null)); +show.define(Sidebar, showSidebar.bind(null, null)); +hide.define(Sidebar, hideSidebar.bind(null, null)); + +identify.define(Sidebar, function(sidebar) { + return sidebar.id; +}); + +function toggleSidebar(window, sidebar) { + // TODO: make sure this is not private + window = window || getMostRecentBrowserWindow(); + if (isSidebarShowing(window, sidebar)) { + return hideSidebar(window, sidebar); + } + return showSidebar(window, sidebar); +} +toggle.define(Sidebar, toggleSidebar.bind(null, null)); diff --git a/toolkit/jetpack/sdk/ui/sidebar/actions.js b/toolkit/jetpack/sdk/ui/sidebar/actions.js new file mode 100644 index 000000000..4a52984c9 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/sidebar/actions.js @@ -0,0 +1,10 @@ +/* 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'; + +const method = require('../../../method/core'); + +exports.show = method('show'); +exports.hide = method('hide'); +exports.toggle = method('toggle'); diff --git a/toolkit/jetpack/sdk/ui/sidebar/contract.js b/toolkit/jetpack/sdk/ui/sidebar/contract.js new file mode 100644 index 000000000..b59c37c0b --- /dev/null +++ b/toolkit/jetpack/sdk/ui/sidebar/contract.js @@ -0,0 +1,27 @@ +/* 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'; + +const { contract } = require('../../util/contract'); +const { isValidURI, URL, isLocalURL } = require('../../url'); +const { isNil, isObject, isString } = require('../../lang/type'); + +exports.contract = contract({ + id: { + is: [ 'string', 'undefined' ], + ok: v => /^[a-z0-9-_]+$/i.test(v), + msg: 'The option "id" must be a valid alphanumeric id (hyphens and ' + + 'underscores are allowed).' + }, + title: { + is: [ 'string' ], + ok: v => v.length + }, + url: { + is: [ 'string' ], + ok: v => isLocalURL(v), + map: v => v.toString(), + msg: 'The option "url" must be a valid local URI.' + } +}); diff --git a/toolkit/jetpack/sdk/ui/sidebar/namespace.js b/toolkit/jetpack/sdk/ui/sidebar/namespace.js new file mode 100644 index 000000000..d79725d1a --- /dev/null +++ b/toolkit/jetpack/sdk/ui/sidebar/namespace.js @@ -0,0 +1,15 @@ +/* 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'; + +const models = exports.models = new WeakMap(); +const views = exports.views = new WeakMap(); +exports.buttons = new WeakMap(); + +exports.viewsFor = function viewsFor(sidebar) { + return views.get(sidebar); +}; +exports.modelFor = function modelFor(sidebar) { + return models.get(sidebar); +}; diff --git a/toolkit/jetpack/sdk/ui/sidebar/utils.js b/toolkit/jetpack/sdk/ui/sidebar/utils.js new file mode 100644 index 000000000..d6145c32e --- /dev/null +++ b/toolkit/jetpack/sdk/ui/sidebar/utils.js @@ -0,0 +1,8 @@ +/* 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'; + +const method = require('../../../method/core'); + +exports.isShowing = method('isShowing'); diff --git a/toolkit/jetpack/sdk/ui/sidebar/view.js b/toolkit/jetpack/sdk/ui/sidebar/view.js new file mode 100644 index 000000000..c91e69d3d --- /dev/null +++ b/toolkit/jetpack/sdk/ui/sidebar/view.js @@ -0,0 +1,214 @@ +/* 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'; + +module.metadata = { + 'stability': 'unstable', + 'engines': { + 'Firefox': '*' + } +}; + +const { models, buttons, views, viewsFor, modelFor } = require('./namespace'); +const { isBrowser, getMostRecentBrowserWindow, windows, isWindowPrivate } = require('../../window/utils'); +const { setStateFor } = require('../state'); +const { defer } = require('../../core/promise'); +const { isPrivateBrowsingSupported, data } = require('../../self'); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const WEB_PANEL_BROWSER_ID = 'web-panels-browser'; + +const resolveURL = (url) => url ? data.url(url) : url; + +function create(window, details) { + let id = makeID(details.id); + let { document } = window; + + if (document.getElementById(id)) + throw new Error('The ID "' + details.id + '" seems already used.'); + + let menuitem = document.createElementNS(XUL_NS, 'menuitem'); + menuitem.setAttribute('id', id); + menuitem.setAttribute('label', details.title); + menuitem.setAttribute('sidebarurl', resolveURL(details.sidebarurl)); + menuitem.setAttribute('checked', 'false'); + menuitem.setAttribute('type', 'checkbox'); + menuitem.setAttribute('group', 'sidebar'); + menuitem.setAttribute('autoCheck', 'false'); + + document.getElementById('viewSidebarMenu').appendChild(menuitem); + + return menuitem; +} +exports.create = create; + +function dispose(menuitem) { + menuitem.parentNode.removeChild(menuitem); +} +exports.dispose = dispose; + +function updateTitle(sidebar, title) { + let button = buttons.get(sidebar); + + for (let window of windows(null, { includePrivate: true })) { + let { document } = window; + + // update the button + if (button) { + setStateFor(button, window, { label: title }); + } + + // update the menuitem + let mi = document.getElementById(makeID(sidebar.id)); + if (mi) { + mi.setAttribute('label', title) + } + + // update sidebar, if showing + if (isSidebarShowing(window, sidebar)) { + document.getElementById('sidebar-title').setAttribute('value', title); + } + } +} +exports.updateTitle = updateTitle; + +function updateURL(sidebar, url) { + let eleID = makeID(sidebar.id); + + url = resolveURL(url); + + for (let window of windows(null, { includePrivate: true })) { + // update the menuitem + let mi = window.document.getElementById(eleID); + if (mi) { + mi.setAttribute('sidebarurl', url) + } + + // update sidebar, if showing + if (isSidebarShowing(window, sidebar)) { + showSidebar(window, sidebar, url); + } + } +} +exports.updateURL = updateURL; + +function isSidebarShowing(window, sidebar) { + let win = window || getMostRecentBrowserWindow(); + + // make sure there is a window + if (!win) { + return false; + } + + // make sure there is a sidebar for the window + let sb = win.document.getElementById('sidebar'); + let sidebarTitle = win.document.getElementById('sidebar-title'); + if (!(sb && sidebarTitle)) { + return false; + } + + // checks if the sidebar box is hidden + let sbb = win.document.getElementById('sidebar-box'); + if (!sbb || sbb.hidden) { + return false; + } + + if (sidebarTitle.value == modelFor(sidebar).title) { + let url = resolveURL(modelFor(sidebar).url); + + // checks if the sidebar is loading + if (win.gWebPanelURI == url) { + return true; + } + + // checks if the sidebar loaded already + let ele = sb.contentDocument && sb.contentDocument.getElementById(WEB_PANEL_BROWSER_ID); + if (!ele) { + return false; + } + + if (ele.getAttribute('cachedurl') == url) { + return true; + } + + if (ele && ele.contentWindow && ele.contentWindow.location == url) { + return true; + } + } + + // default + return false; +} +exports.isSidebarShowing = isSidebarShowing; + +function showSidebar(window, sidebar, newURL) { + window = window || getMostRecentBrowserWindow(); + + let { promise, resolve, reject } = defer(); + let model = modelFor(sidebar); + + if (!newURL && isSidebarShowing(window, sidebar)) { + resolve({}); + } + else if (!isPrivateBrowsingSupported && isWindowPrivate(window)) { + reject(Error('You cannot show a sidebar on private windows')); + } + else { + sidebar.once('show', resolve); + + let menuitem = window.document.getElementById(makeID(model.id)); + menuitem.setAttribute('checked', true); + + window.openWebPanel(model.title, resolveURL(newURL || model.url)); + } + + return promise; +} +exports.showSidebar = showSidebar; + + +function hideSidebar(window, sidebar) { + window = window || getMostRecentBrowserWindow(); + + let { promise, resolve, reject } = defer(); + + if (!isSidebarShowing(window, sidebar)) { + reject(Error('The sidebar is already hidden')); + } + else { + sidebar.once('hide', resolve); + + // Below was taken from http://mxr.mozilla.org/mozilla-central/source/browser/base/content/browser.js#4775 + // the code for window.todggleSideBar().. + let { document } = window; + let sidebarEle = document.getElementById('sidebar'); + let sidebarTitle = document.getElementById('sidebar-title'); + let sidebarBox = document.getElementById('sidebar-box'); + let sidebarSplitter = document.getElementById('sidebar-splitter'); + let commandID = sidebarBox.getAttribute('sidebarcommand'); + let sidebarBroadcaster = document.getElementById(commandID); + + sidebarBox.hidden = true; + sidebarSplitter.hidden = true; + + sidebarEle.setAttribute('src', 'about:blank'); + //sidebarEle.docShell.createAboutBlankContentViewer(null); + + sidebarBroadcaster.removeAttribute('checked'); + sidebarBox.setAttribute('sidebarcommand', ''); + sidebarTitle.value = ''; + sidebarBox.hidden = true; + sidebarSplitter.hidden = true; + + // TODO: perhaps this isn't necessary if the window is not most recent? + window.gBrowser.selectedBrowser.focus(); + } + + return promise; +} +exports.hideSidebar = hideSidebar; + +function makeID(id) { + return 'jetpack-sidebar-' + id; +} diff --git a/toolkit/jetpack/sdk/ui/state.js b/toolkit/jetpack/sdk/ui/state.js new file mode 100644 index 000000000..152ce696d --- /dev/null +++ b/toolkit/jetpack/sdk/ui/state.js @@ -0,0 +1,239 @@ +/* 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 Button module currently supports only Firefox. +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + 'SeaMonkey': '*', + 'Thunderbird': '*' + } +}; + +const { Ci } = require('chrome'); + +const events = require('../event/utils'); +const { events: browserEvents } = require('../browser/events'); +const { events: tabEvents } = require('../tab/events'); +const { events: stateEvents } = require('./state/events'); + +const { windows, isInteractive, getFocusedBrowser } = require('../window/utils'); +const { getActiveTab, getOwnerWindow } = require('../tabs/utils'); + +const { ignoreWindow } = require('../private-browsing/utils'); + +const { freeze } = Object; +const { merge } = require('../util/object'); +const { on, off, emit } = require('../event/core'); + +const { add, remove, has, clear, iterator } = require('../lang/weak-set'); +const { isNil } = require('../lang/type'); + +const { viewFor } = require('../view/core'); + +const components = new WeakMap(); + +const ERR_UNREGISTERED = 'The state cannot be set or get. ' + + 'The object may be not be registered, or may already have been unloaded.'; + +const ERR_INVALID_TARGET = 'The state cannot be set or get for this target.' + + 'Only window, tab and registered component are valid targets.'; + +const isWindow = thing => thing instanceof Ci.nsIDOMWindow; +const isTab = thing => thing.tagName && thing.tagName.toLowerCase() === 'tab'; +const isActiveTab = thing => isTab(thing) && thing === getActiveTab(getOwnerWindow(thing)); +const isEnumerable = window => !ignoreWindow(window); +const browsers = _ => + windows('navigator:browser', { includePrivate: true }).filter(isInteractive); +const getMostRecentTab = _ => getActiveTab(getFocusedBrowser()); + +function getStateFor(component, target) { + if (!isRegistered(component)) + throw new Error(ERR_UNREGISTERED); + + if (!components.has(component)) + return null; + + let states = components.get(component); + + if (target) { + if (isTab(target) || isWindow(target) || target === component) + return states.get(target) || null; + else + throw new Error(ERR_INVALID_TARGET); + } + + return null; +} +exports.getStateFor = getStateFor; + +function getDerivedStateFor(component, target) { + if (!isRegistered(component)) + throw new Error(ERR_UNREGISTERED); + + if (!components.has(component)) + return null; + + let states = components.get(component); + + let componentState = states.get(component); + let windowState = null; + let tabState = null; + + if (target) { + // has a target + if (isTab(target)) { + windowState = states.get(getOwnerWindow(target), null); + + if (states.has(target)) { + // we have a tab state + tabState = states.get(target); + } + } + else if (isWindow(target) && states.has(target)) { + // we have a window state + windowState = states.get(target); + } + } + + return freeze(merge({}, componentState, windowState, tabState)); +} +exports.getDerivedStateFor = getDerivedStateFor; + +function setStateFor(component, target, state) { + if (!isRegistered(component)) + throw new Error(ERR_UNREGISTERED); + + let isComponentState = target === component; + let targetWindows = isWindow(target) ? [target] : + isActiveTab(target) ? [getOwnerWindow(target)] : + isComponentState ? browsers() : + isTab(target) ? [] : + null; + + if (!targetWindows) + throw new Error(ERR_INVALID_TARGET); + + // initialize the state's map + if (!components.has(component)) + components.set(component, new WeakMap()); + + let states = components.get(component); + + if (state === null && !isComponentState) // component state can't be deleted + states.delete(target); + else { + let base = isComponentState ? states.get(target) : null; + states.set(target, freeze(merge({}, base, state))); + } + + render(component, targetWindows); +} +exports.setStateFor = setStateFor; + +function render(component, targetWindows) { + targetWindows = targetWindows ? [].concat(targetWindows) : browsers(); + + for (let window of targetWindows.filter(isEnumerable)) { + let tabState = getDerivedStateFor(component, getActiveTab(window)); + + emit(stateEvents, 'data', { + type: 'render', + target: component, + window: window, + state: tabState + }); + + } +} +exports.render = render; + +function properties(contract) { + let { rules } = contract; + let descriptor = Object.keys(rules).reduce(function(descriptor, name) { + descriptor[name] = { + get: function() { return getDerivedStateFor(this)[name] }, + set: function(value) { + let changed = {}; + changed[name] = value; + + setStateFor(this, this, contract(changed)); + } + } + return descriptor; + }, {}); + + return Object.create(Object.prototype, descriptor); +} +exports.properties = properties; + +function state(contract) { + return { + state: function state(target, state) { + let nativeTarget = target === 'window' ? getFocusedBrowser() + : target === 'tab' ? getMostRecentTab() + : target === this ? null + : viewFor(target); + + if (!nativeTarget && target !== this && !isNil(target)) + throw new Error(ERR_INVALID_TARGET); + + target = nativeTarget || target; + + // jquery style + return arguments.length < 2 + ? getDerivedStateFor(this, target) + : setStateFor(this, target, contract(state)) + } + } +} +exports.state = state; + +const register = (component, state) => { + add(components, component); + setStateFor(component, component, state); +} +exports.register = register; + +const unregister = component => { + remove(components, component); +} +exports.unregister = unregister; + +const isRegistered = component => has(components, component); +exports.isRegistered = isRegistered; + +var tabSelect = events.filter(tabEvents, e => e.type === 'TabSelect'); +var tabClose = events.filter(tabEvents, e => e.type === 'TabClose'); +var windowOpen = events.filter(browserEvents, e => e.type === 'load'); +var windowClose = events.filter(browserEvents, e => e.type === 'close'); + +var close = events.merge([tabClose, windowClose]); +var activate = events.merge([windowOpen, tabSelect]); + +on(activate, 'data', ({target}) => { + let [window, tab] = isWindow(target) + ? [target, getActiveTab(target)] + : [getOwnerWindow(target), target]; + + if (ignoreWindow(window)) return; + + for (let component of iterator(components)) { + emit(stateEvents, 'data', { + type: 'render', + target: component, + window: window, + state: getDerivedStateFor(component, tab) + }); + } +}); + +on(close, 'data', function({target}) { + for (let component of iterator(components)) { + components.get(component).delete(target); + } +}); diff --git a/toolkit/jetpack/sdk/ui/state/events.js b/toolkit/jetpack/sdk/ui/state/events.js new file mode 100644 index 000000000..98909656a --- /dev/null +++ b/toolkit/jetpack/sdk/ui/state/events.js @@ -0,0 +1,18 @@ +/* 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'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + 'SeaMonkey': '*', + 'Thunderbird': '*' + } +}; + +var channel = {}; + +exports.events = channel; diff --git a/toolkit/jetpack/sdk/ui/toolbar.js b/toolkit/jetpack/sdk/ui/toolbar.js new file mode 100644 index 000000000..c1becab2d --- /dev/null +++ b/toolkit/jetpack/sdk/ui/toolbar.js @@ -0,0 +1,16 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Toolbar } = require("./toolbar/model"); +require("./toolbar/view"); + +exports.Toolbar = Toolbar; diff --git a/toolkit/jetpack/sdk/ui/toolbar/model.js b/toolkit/jetpack/sdk/ui/toolbar/model.js new file mode 100644 index 000000000..5c5428606 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/toolbar/model.js @@ -0,0 +1,151 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Class } = require("../../core/heritage"); +const { EventTarget } = require("../../event/target"); +const { off, setListeners, emit } = require("../../event/core"); +const { Reactor, foldp, merges, send } = require("../../event/utils"); +const { Disposable } = require("../../core/disposable"); +const { InputPort } = require("../../input/system"); +const { OutputPort } = require("../../output/system"); +const { identify } = require("../id"); +const { pairs, object, map, each } = require("../../util/sequence"); +const { patch, diff } = require("diffpatcher/index"); +const { contract } = require("../../util/contract"); +const { id: addonID } = require("../../self"); + +// Input state is accumulated from the input received form the toolbar +// view code & local output. Merging local output reflects local state +// changes without complete roundloop. +const input = foldp(patch, {}, new InputPort({ id: "toolbar-changed" })); +const output = new OutputPort({ id: "toolbar-change" }); + +// Takes toolbar title and normalizes is to an +// identifier, also prefixes with add-on id. +const titleToId = title => + ("toolbar-" + addonID + "-" + title). + toLowerCase(). + replace(/\s/g, "-"). + replace(/[^A-Za-z0-9_\-]/g, ""); + +const validate = contract({ + title: { + is: ["string"], + ok: x => x.length > 0, + msg: "The `option.title` string must be provided" + }, + items: { + is:["undefined", "object", "array"], + msg: "The `options.items` must be iterable sequence of items" + }, + hidden: { + is: ["boolean", "undefined"], + msg: "The `options.hidden` must be boolean" + } +}); + +// Toolbars is a mapping between `toolbar.id` & `toolbar` instances, +// which is used to find intstance for dispatching events. +var toolbars = new Map(); + +const Toolbar = Class({ + extends: EventTarget, + implements: [Disposable], + initialize: function(params={}) { + const options = validate(params); + const id = titleToId(options.title); + + if (toolbars.has(id)) + throw Error("Toolbar with this id already exists: " + id); + + // Set of the items in the toolbar isn't mutable, as a matter of fact + // it just defines desired set of items, actual set is under users + // control. Conver test to an array and freeze to make sure users won't + // try mess with it. + const items = Object.freeze(options.items ? [...options.items] : []); + + const initial = { + id: id, + title: options.title, + // By default toolbars are visible when add-on is installed, unless + // add-on authors decides it should be hidden. From that point on + // user is in control. + collapsed: !!options.hidden, + // In terms of state only identifiers of items matter. + items: items.map(identify) + }; + + this.id = id; + this.items = items; + + toolbars.set(id, this); + setListeners(this, params); + + // Send initial state to the host so it can reflect it + // into a user interface. + send(output, object([id, initial])); + }, + + get title() { + const state = reactor.value[this.id]; + return state && state.title; + }, + get hidden() { + const state = reactor.value[this.id]; + return state && state.collapsed; + }, + + destroy: function() { + send(output, object([this.id, null])); + }, + // `JSON.stringify` serializes objects based of the return + // value of this method. For convinienc we provide this method + // to serialize actual state data. Note: items will also be + // serialized so they should probably implement `toJSON`. + toJSON: function() { + return { + id: this.id, + title: this.title, + hidden: this.hidden, + items: this.items + }; + } +}); +exports.Toolbar = Toolbar; +identify.define(Toolbar, toolbar => toolbar.id); + +const dispose = toolbar => { + toolbars.delete(toolbar.id); + emit(toolbar, "detach"); + off(toolbar); +}; + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + each(([id, update]) => { + const toolbar = toolbars.get(id); + + // Remove + if (!update) + dispose(toolbar); + // Add + else if (!past[id]) + emit(toolbar, "attach"); + // Update + else + emit(toolbar, update.collapsed ? "hide" : "show", toolbar); + }, pairs(delta)); + } +}); +reactor.run(input); diff --git a/toolkit/jetpack/sdk/ui/toolbar/view.js b/toolkit/jetpack/sdk/ui/toolbar/view.js new file mode 100644 index 000000000..4ef0c3d46 --- /dev/null +++ b/toolkit/jetpack/sdk/ui/toolbar/view.js @@ -0,0 +1,248 @@ +/* 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"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "> 28" + } +}; + +const { Cu } = require("chrome"); +const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {}); +const { subscribe, send, Reactor, foldp, lift, merges } = require("../../event/utils"); +const { InputPort } = require("../../input/system"); +const { OutputPort } = require("../../output/system"); +const { Interactive } = require("../../input/browser"); +const { CustomizationInput } = require("../../input/customizable-ui"); +const { pairs, map, isEmpty, object, + each, keys, values } = require("../../util/sequence"); +const { curry, flip } = require("../../lang/functional"); +const { patch, diff } = require("diffpatcher/index"); +const prefs = require("../../preferences/service"); +const { getByOuterId } = require("../../window/utils"); +const { ignoreWindow } = require('../../private-browsing/utils'); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const PREF_ROOT = "extensions.sdk-toolbar-collapsed."; + + +// There are two output ports one for publishing changes that occured +// and the other for change requests. Later is synchronous and is only +// consumed here. Note: it needs to be synchronous to avoid race conditions +// when `collapsed` attribute changes are caused by user interaction and +// toolbar is destroyed between the ticks. +const output = new OutputPort({ id: "toolbar-changed" }); +const syncoutput = new OutputPort({ id: "toolbar-change", sync: true }); + +// Merge disptached changes and recevied changes from models to keep state up to +// date. +const Toolbars = foldp(patch, {}, merges([new InputPort({ id: "toolbar-changed" }), + new InputPort({ id: "toolbar-change" })])); +const State = lift((toolbars, windows, customizable) => + ({windows: windows, toolbars: toolbars, customizable: customizable}), + Toolbars, Interactive, new CustomizationInput()); + +// Shared event handler that makes `event.target.parent` collapsed. +// Used as toolbar's close buttons click handler. +const collapseToolbar = event => { + const toolbar = event.target.parentNode; + toolbar.collapsed = true; +}; + +const parseAttribute = x => + x === "true" ? true : + x === "false" ? false : + x === "" ? null : + x; + +// Shared mutation observer that is used to observe `toolbar` node's +// attribute mutations. Mutations are aggregated in the `delta` hash +// and send to `ToolbarStateChanged` channel to let model know state +// has changed. +const attributesChanged = mutations => { + const delta = mutations.reduce((changes, {attributeName, target}) => { + const id = target.id; + const field = attributeName === "toolbarname" ? "title" : attributeName; + let change = changes[id] || (changes[id] = {}); + change[field] = parseAttribute(target.getAttribute(attributeName)); + return changes; + }, {}); + + // Calculate what are the updates from the current state and if there are + // any send them. + const updates = diff(reactor.value, patch(reactor.value, delta)); + + if (!isEmpty(pairs(updates))) { + // TODO: Consider sending sync to make sure that there won't be a new + // update doing a delete in the meantime. + send(syncoutput, updates); + } +}; + + +// Utility function creates `toolbar` with a "close" button and returns +// it back. In addition it set's up a listener and observer to communicate +// state changes. +const addView = curry((options, {document, window}) => { + if (ignoreWindow(window)) + return; + + let view = document.createElementNS(XUL_NS, "toolbar"); + view.setAttribute("id", options.id); + view.setAttribute("collapsed", options.collapsed); + view.setAttribute("toolbarname", options.title); + view.setAttribute("pack", "end"); + view.setAttribute("customizable", "false"); + view.setAttribute("style", "padding: 2px 0; max-height: 40px;"); + view.setAttribute("mode", "icons"); + view.setAttribute("iconsize", "small"); + view.setAttribute("context", "toolbar-context-menu"); + view.setAttribute("class", "chromeclass-toolbar"); + + let label = document.createElementNS(XUL_NS, "label"); + label.setAttribute("value", options.title); + label.setAttribute("collapsed", "true"); + view.appendChild(label); + + let closeButton = document.createElementNS(XUL_NS, "toolbarbutton"); + closeButton.setAttribute("id", "close-" + options.id); + closeButton.setAttribute("class", "close-icon"); + closeButton.setAttribute("customizable", false); + closeButton.addEventListener("command", collapseToolbar); + + view.appendChild(closeButton); + + // In order to have a close button not costumizable, aligned on the right, + // leaving the customizable capabilities of Australis, we need to create + // a toolbar inside a toolbar. + // This is should be a temporary hack, we should have a proper XBL for toolbar + // instead. See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=982005 + let toolbar = document.createElementNS(XUL_NS, "toolbar"); + toolbar.setAttribute("id", "inner-" + options.id); + toolbar.setAttribute("defaultset", options.items.join(",")); + toolbar.setAttribute("customizable", "true"); + toolbar.setAttribute("style", "-moz-appearance: none; overflow: hidden"); + toolbar.setAttribute("mode", "icons"); + toolbar.setAttribute("iconsize", "small"); + toolbar.setAttribute("context", "toolbar-context-menu"); + toolbar.setAttribute("flex", "1"); + + view.insertBefore(toolbar, closeButton); + + const observer = new document.defaultView.MutationObserver(attributesChanged); + observer.observe(view, { attributes: true, + attributeFilter: ["collapsed", "toolbarname"] }); + + const toolbox = document.getElementById("navigator-toolbox"); + toolbox.appendChild(view); +}); +const viewAdd = curry(flip(addView)); + +const removeView = curry((id, {document}) => { + const view = document.getElementById(id); + if (view) view.remove(); +}); + +const updateView = curry((id, {title, collapsed, isCustomizing}, {document}) => { + const view = document.getElementById(id); + + if (!view) + return; + + if (title) + view.setAttribute("toolbarname", title); + + if (collapsed !== void(0)) + view.setAttribute("collapsed", Boolean(collapsed)); + + if (isCustomizing !== void(0)) { + view.querySelector("label").collapsed = !isCustomizing; + view.querySelector("toolbar").style.visibility = isCustomizing + ? "hidden" : "visible"; + } +}); + +const viewUpdate = curry(flip(updateView)); + +// Utility function used to register toolbar into CustomizableUI. +const registerToolbar = state => { + // If it's first additon register toolbar as customizableUI component. + CustomizableUI.registerArea("inner-" + state.id, { + type: CustomizableUI.TYPE_TOOLBAR, + legacy: true, + defaultPlacements: [...state.items] + }); +}; +// Utility function used to unregister toolbar from the CustomizableUI. +const unregisterToolbar = CustomizableUI.unregisterArea; + +const reactor = new Reactor({ + onStep: (present, past) => { + const delta = diff(past, present); + + each(([id, update]) => { + // If update is `null` toolbar is removed, in such case + // we unregister toolbar and remove it from each window + // it was added to. + if (update === null) { + unregisterToolbar("inner-" + id); + each(removeView(id), values(past.windows)); + + send(output, object([id, null])); + } + else if (past.toolbars[id]) { + // If `collapsed` state for toolbar was updated, persist + // it for a future sessions. + if (update.collapsed !== void(0)) + prefs.set(PREF_ROOT + id, update.collapsed); + + // Reflect update in each window it was added to. + each(updateView(id, update), values(past.windows)); + + send(output, object([id, update])); + } + // Hack: Mutation observers are invoked async, which means that if + // client does `hide(toolbar)` & then `toolbar.destroy()` by the + // time we'll get update for `collapsed` toolbar will be removed. + // For now we check if `update.id` is present which will be undefined + // in such cases. + else if (update.id) { + // If it is a new toolbar we create initial state by overriding + // `collapsed` filed with value persisted in previous sessions. + const state = patch(update, { + collapsed: prefs.get(PREF_ROOT + id, update.collapsed), + }); + + // Register toolbar and add it each window known in the past + // (note that new windows if any will be handled in loop below). + registerToolbar(state); + each(addView(state), values(past.windows)); + + send(output, object([state.id, state])); + } + }, pairs(delta.toolbars)); + + // Add views to every window that was added. + each(window => { + if (window) + each(viewAdd(window), values(past.toolbars)); + }, values(delta.windows)); + + each(([id, isCustomizing]) => { + each(viewUpdate(getByOuterId(id), {isCustomizing: !!isCustomizing}), + keys(present.toolbars)); + + }, pairs(delta.customizable)) + }, + onEnd: state => { + each(id => { + unregisterToolbar("inner-" + id); + each(removeView(id), values(state.windows)); + }, keys(state.toolbars)); + } +}); +reactor.run(State); -- cgit v1.2.3