summaryrefslogtreecommitdiff
path: root/components/jetpack/sdk/ui/toolbar
diff options
context:
space:
mode:
Diffstat (limited to 'components/jetpack/sdk/ui/toolbar')
-rw-r--r--components/jetpack/sdk/ui/toolbar/model.js151
-rw-r--r--components/jetpack/sdk/ui/toolbar/view.js248
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);