diff options
Diffstat (limited to 'components/jetpack/sdk/ui/toolbar')
-rw-r--r-- | components/jetpack/sdk/ui/toolbar/model.js | 151 | ||||
-rw-r--r-- | components/jetpack/sdk/ui/toolbar/view.js | 248 |
2 files changed, 399 insertions, 0 deletions
diff --git a/components/jetpack/sdk/ui/toolbar/model.js b/components/jetpack/sdk/ui/toolbar/model.js new file mode 100644 index 000000000..5c5428606 --- /dev/null +++ b/components/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/components/jetpack/sdk/ui/toolbar/view.js b/components/jetpack/sdk/ui/toolbar/view.js new file mode 100644 index 000000000..4ef0c3d46 --- /dev/null +++ b/components/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); |