summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/context-menu/core.js
diff options
context:
space:
mode:
authorMatt A. Tobin <email@mattatobin.com>2018-02-10 02:51:36 -0500
committerMatt A. Tobin <email@mattatobin.com>2018-02-10 02:51:36 -0500
commit37d5300335d81cecbecc99812747a657588c63eb (patch)
tree765efa3b6a56bb715d9813a8697473e120436278 /toolkit/jetpack/sdk/context-menu/core.js
parentb2bdac20c02b12f2057b9ef70b0a946113a00e00 (diff)
parent4fb11cd5966461bccc3ed1599b808237be6b0de9 (diff)
downloadUXP-37d5300335d81cecbecc99812747a657588c63eb.tar
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.gz
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.lz
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.xz
UXP-37d5300335d81cecbecc99812747a657588c63eb.zip
Merge branch 'ext-work'
Diffstat (limited to 'toolkit/jetpack/sdk/context-menu/core.js')
-rw-r--r--toolkit/jetpack/sdk/context-menu/core.js384
1 files changed, 384 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/context-menu/core.js b/toolkit/jetpack/sdk/context-menu/core.js
new file mode 100644
index 000000000..c64cddfe8
--- /dev/null
+++ b/toolkit/jetpack/sdk/context-menu/core.js
@@ -0,0 +1,384 @@
+/* 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 Contexts = require("./context");
+const Readers = require("./readers");
+const Component = require("../ui/component");
+const { Class } = require("../core/heritage");
+const { map, filter, object, reduce, keys, symbols,
+ pairs, values, each, some, isEvery, count } = require("../util/sequence");
+const { loadModule } = require("framescript/manager");
+const { Cu, Cc, Ci } = require("chrome");
+const prefs = require("sdk/preferences/service");
+
+const globalMessageManager = Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+const preferencesService = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefService).
+ getBranch(null);
+
+
+const readTable = Symbol("context-menu/read-table");
+const nameTable = Symbol("context-menu/name-table");
+const onContext = Symbol("context-menu/on-context");
+const isMatching = Symbol("context-menu/matching-handler?");
+
+exports.onContext = onContext;
+exports.readTable = readTable;
+exports.nameTable = nameTable;
+
+
+const propagateOnContext = (item, data) =>
+ each(child => child[onContext](data), item.state.children);
+
+const isContextMatch = item => !item[isMatching] || item[isMatching]();
+
+// For whatever reason addWeakMessageListener does not seems to work as our
+// instance seems to dropped even though it's alive. This is simple workaround
+// to avoid dead object excetptions.
+const WeakMessageListener = function(receiver, handler="receiveMessage") {
+ this.receiver = receiver
+ this.handler = handler
+};
+WeakMessageListener.prototype = {
+ constructor: WeakMessageListener,
+ receiveMessage(message) {
+ if (Cu.isDeadWrapper(this.receiver)) {
+ message.target.messageManager.removeMessageListener(message.name, this);
+ }
+ else {
+ this.receiver[this.handler](message);
+ }
+ }
+};
+
+const OVERFLOW_THRESH = "extensions.addon-sdk.context-menu.overflowThreshold";
+const onMessage = Symbol("context-menu/message-listener");
+const onPreferceChange = Symbol("context-menu/preference-change");
+const ContextMenuExtension = Class({
+ extends: Component,
+ initialize: Component,
+ setup() {
+ const messageListener = new WeakMessageListener(this, onMessage);
+ loadModule(globalMessageManager, "framescript/context-menu", true, "onContentFrame");
+ globalMessageManager.addMessageListener("sdk/context-menu/read", messageListener);
+ globalMessageManager.addMessageListener("sdk/context-menu/readers?", messageListener);
+
+ preferencesService.addObserver(OVERFLOW_THRESH, this, false);
+ },
+ observe(_, __, name) {
+ if (name === OVERFLOW_THRESH) {
+ const overflowThreshold = prefs.get(OVERFLOW_THRESH, 10);
+ this[Component.patch]({overflowThreshold});
+ }
+ },
+ [onMessage]({name, data, target}) {
+ if (name === "sdk/context-menu/read")
+ this[onContext]({target, data});
+ if (name === "sdk/context-menu/readers?")
+ target.messageManager.sendAsyncMessage("sdk/context-menu/readers",
+ JSON.parse(JSON.stringify(this.state.readers)));
+ },
+ [Component.initial](options={}, children) {
+ const element = options.element || null;
+ const target = options.target || null;
+ const readers = Object.create(null);
+ const users = Object.create(null);
+ const registry = new WeakSet();
+ const overflowThreshold = prefs.get(OVERFLOW_THRESH, 10);
+
+ return { target, children: [], readers, users, element,
+ registry, overflowThreshold };
+ },
+ [Component.isUpdated](before, after) {
+ // Update only if target changed, since there is no point in re-rendering
+ // when children are. Also new items added won't be in sync with a latest
+ // context target so we should really just render before drawing context
+ // menu.
+ return before.target !== after.target;
+ },
+ [Component.render]({element, children, overflowThreshold}) {
+ if (!element) return null;
+
+ const items = children.filter(isContextMatch);
+ const body = items.length === 0 ? items :
+ items.length < overflowThreshold ? [new Separator(),
+ ...items] :
+ [{tagName: "menu",
+ className: "sdk-context-menu-overflow-menu",
+ label: "Add-ons",
+ accesskey: "A",
+ children: [{tagName: "menupopup",
+ children: items}]}];
+ return {
+ element: element,
+ tagName: "menugroup",
+ style: "-moz-box-orient: vertical;",
+ className: "sdk-context-menu-extension",
+ children: body
+ }
+ },
+ // Adds / remove child to it's own list.
+ add(item) {
+ this[Component.patch]({children: this.state.children.concat(item)});
+ },
+ remove(item) {
+ this[Component.patch]({
+ children: this.state.children.filter(x => x !== item)
+ });
+ },
+ register(item) {
+ const { users, registry } = this.state;
+ if (registry.has(item)) return;
+ registry.add(item);
+
+ // Each (ContextHandler) item has a readTable that is a
+ // map of keys to readers extracting them from the content.
+ // During the registraction we update intrnal record of unique
+ // readers and users per reader. Most context will have a reader
+ // shared across all instances there for map of users per reader
+ // is stored separately from the reader so that removing reader
+ // will occur only when no users remain.
+ const table = item[readTable];
+ // Context readers store data in private symbols so we need to
+ // collect both table keys and private symbols.
+ const names = [...keys(table), ...symbols(table)];
+ const readers = map(name => table[name], names);
+ // Create delta for registered readers that will be merged into
+ // internal readers table.
+ const added = filter(x => !users[x.id], readers);
+ const delta = object(...map(x => [x.id, x], added));
+
+ const update = reduce((update, reader) => {
+ const n = update[reader.id] || 0;
+ update[reader.id] = n + 1;
+ return update;
+ }, Object.assign({}, users), readers);
+
+ // Patch current state with a changes that registered item caused.
+ this[Component.patch]({users: update,
+ readers: Object.assign(this.state.readers, delta)});
+
+ if (count(added)) {
+ globalMessageManager.broadcastAsyncMessage("sdk/context-menu/readers",
+ JSON.parse(JSON.stringify(delta)));
+ }
+ },
+ unregister(item) {
+ const { users, registry } = this.state;
+ if (!registry.has(item)) return;
+ registry.delete(item);
+
+ const table = item[readTable];
+ const names = [...keys(table), ...symbols(table)];
+ const readers = map(name => table[name], names);
+ const update = reduce((update, reader) => {
+ update[reader.id] = update[reader.id] - 1;
+ return update;
+ }, Object.assign({}, users), readers);
+ const removed = filter(id => !update[id], keys(update));
+ const delta = object(...map(x => [x, null], removed));
+
+ this[Component.patch]({users: update,
+ readers: Object.assign(this.state.readers, delta)});
+
+ if (count(removed)) {
+ globalMessageManager.broadcastAsyncMessage("sdk/context-menu/readers",
+ JSON.parse(JSON.stringify(delta)));
+ }
+ },
+
+ [onContext]({data, target}) {
+ propagateOnContext(this, data);
+ const document = target.ownerDocument;
+ const element = document.getElementById("contentAreaContextMenu");
+
+ this[Component.patch]({target: data, element: element});
+ }
+});this,
+exports.ContextMenuExtension = ContextMenuExtension;
+
+// Takes an item options and
+const makeReadTable = ({context, read}) => {
+ // Result of this function is a tuple of all readers &
+ // name, reader id pairs.
+
+ // Filter down to contexts that have a reader associated.
+ const contexts = filter(context => context.read, context);
+ // Merge all contexts read maps to a single hash, note that there should be
+ // no name collisions as context implementations expect to use private
+ // symbols for storing it's read data.
+ return Object.assign({}, ...map(({read}) => read, contexts), read);
+}
+
+const readTarget = (nameTable, data) =>
+ object(...map(([name, id]) => [name, data[id]], nameTable))
+
+const ContextHandler = Class({
+ extends: Component,
+ initialize: Component,
+ get context() {
+ return this.state.options.context;
+ },
+ get read() {
+ return this.state.options.read;
+ },
+ [Component.initial](options) {
+ return {
+ table: makeReadTable(options),
+ requiredContext: filter(context => context.isRequired, options.context),
+ optionalContext: filter(context => !context.isRequired, options.context)
+ }
+ },
+ [isMatching]() {
+ const {target, requiredContext, optionalContext} = this.state;
+ return isEvery(context => context.isCurrent(target), requiredContext) &&
+ (count(optionalContext) === 0 ||
+ some(context => context.isCurrent(target), optionalContext));
+ },
+ setup() {
+ const table = makeReadTable(this.state.options);
+ this[readTable] = table;
+ this[nameTable] = [...map(symbol => [symbol, table[symbol].id], symbols(table)),
+ ...map(name => [name, table[name].id], keys(table))];
+
+
+ contextMenu.register(this);
+
+ each(child => contextMenu.remove(child), this.state.children);
+ contextMenu.add(this);
+ },
+ dispose() {
+ contextMenu.remove(this);
+
+ each(child => contextMenu.unregister(child), this.state.children);
+ contextMenu.unregister(this);
+ },
+ // Internal `Symbol("onContext")` method is invoked when "contextmenu" event
+ // occurs in content process. Context handles with children delegate to each
+ // child and patch it's internal state to reflect new contextmenu target.
+ [onContext](data) {
+ propagateOnContext(this, data);
+ this[Component.patch]({target: readTarget(this[nameTable], data)});
+ }
+});
+const isContextHandler = item => item instanceof ContextHandler;
+
+exports.ContextHandler = ContextHandler;
+
+const Menu = Class({
+ extends: ContextHandler,
+ [isMatching]() {
+ return ContextHandler.prototype[isMatching].call(this) &&
+ this.state.children.filter(isContextHandler)
+ .some(isContextMatch);
+ },
+ [Component.render]({children, options}) {
+ const items = children.filter(isContextMatch);
+ return {tagName: "menu",
+ className: "sdk-context-menu menu-iconic",
+ label: options.label,
+ accesskey: options.accesskey,
+ image: options.icon,
+ children: [{tagName: "menupopup",
+ children: items}]};
+ }
+});
+exports.Menu = Menu;
+
+const onCommand = Symbol("context-menu/item/onCommand");
+const Item = Class({
+ extends: ContextHandler,
+ get onClick() {
+ return this.state.options.onClick;
+ },
+ [Component.render]({options}) {
+ const {label, icon, accesskey} = options;
+ return {tagName: "menuitem",
+ className: "sdk-context-menu-item menuitem-iconic",
+ label,
+ accesskey,
+ image: icon,
+ oncommand: this};
+ },
+ handleEvent(event) {
+ if (this.onClick)
+ this.onClick(this.state.target);
+ }
+});
+exports.Item = Item;
+
+var Separator = Class({
+ extends: Component,
+ initialize: Component,
+ [Component.render]() {
+ return {tagName: "menuseparator",
+ className: "sdk-context-menu-separator"}
+ },
+ [onContext]() {
+
+ }
+});
+exports.Separator = Separator;
+
+exports.Contexts = Contexts;
+exports.Readers = Readers;
+
+const createElement = (vnode, {document}) => {
+ const node = vnode.namespace ?
+ document.createElementNS(vnode.namespace, vnode.tagName) :
+ document.createElement(vnode.tagName);
+
+ node.setAttribute("data-component-path", vnode[Component.path]);
+
+ each(([key, value]) => {
+ if (key === "tagName") {
+ return;
+ }
+ if (key === "children") {
+ return;
+ }
+
+ if (key.startsWith("on")) {
+ node.addEventListener(key.substr(2), value)
+ return;
+ }
+
+ if (typeof(value) !== "object" &&
+ typeof(value) !== "function" &&
+ value !== void(0) &&
+ value !== null)
+ {
+ if (key === "className") {
+ node[key] = value;
+ }
+ else {
+ node.setAttribute(key, value);
+ }
+ return;
+ }
+ }, pairs(vnode));
+
+ each(child => node.appendChild(createElement(child, {document})), vnode.children);
+ return node;
+};
+
+const htmlWriter = tree => {
+ if (tree !== null) {
+ const root = tree.element;
+ const node = createElement(tree, {document: root.ownerDocument});
+ const before = root.querySelector("[data-component-path='/']");
+ if (before) {
+ root.replaceChild(node, before);
+ } else {
+ root.appendChild(node);
+ }
+ }
+};
+
+
+const contextMenu = ContextMenuExtension();
+exports.contextMenu = contextMenu;
+Component.mount(contextMenu, htmlWriter);