/* 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 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}) => { 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", "toolbar-primary 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);