summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/context-menu
diff options
context:
space:
mode:
authorMatt A. Tobin <email@mattatobin.com>2018-02-09 06:46:43 -0500
committerMatt A. Tobin <email@mattatobin.com>2018-02-09 06:46:43 -0500
commitac46df8daea09899ce30dc8fd70986e258c746bf (patch)
tree2750d3125fc253fd5b0671e4bd268eff1fd97296 /toolkit/jetpack/sdk/context-menu
parent8cecf8d5208f3945b35f879bba3015bb1a11bec6 (diff)
downloadUXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.gz
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.lz
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.xz
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.zip
Move Add-on SDK source to toolkit/jetpack
Diffstat (limited to 'toolkit/jetpack/sdk/context-menu')
-rw-r--r--toolkit/jetpack/sdk/context-menu/context.js147
-rw-r--r--toolkit/jetpack/sdk/context-menu/core.js384
-rw-r--r--toolkit/jetpack/sdk/context-menu/readers.js112
3 files changed, 643 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/context-menu/context.js b/toolkit/jetpack/sdk/context-menu/context.js
new file mode 100644
index 000000000..fc5aea500
--- /dev/null
+++ b/toolkit/jetpack/sdk/context-menu/context.js
@@ -0,0 +1,147 @@
+/* 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/. */
+
+const { Class } = require("../core/heritage");
+const { extend } = require("../util/object");
+const { MatchPattern } = require("../util/match-pattern");
+const readers = require("./readers");
+
+// Context class is required to implement a single `isCurrent(target)` method
+// that must return boolean value indicating weather given target matches a
+// context or not. Most context implementations below will have an associated
+// reader that way context implementation can setup a reader to extract necessary
+// information to make decision if target is matching a context.
+const Context = Class({
+ isRequired: false,
+ isCurrent(target) {
+ throw Error("Context class must implement isCurrent(target) method");
+ },
+ get required() {
+ Object.defineProperty(this, "required", {
+ value: Object.assign(Object.create(Object.getPrototypeOf(this)),
+ this,
+ {isRequired: true})
+ });
+ return this.required;
+ }
+});
+Context.required = function(...params) {
+ return Object.assign(new this(...params), {isRequired: true});
+};
+exports.Context = Context;
+
+
+// Next few context implementations use an associated reader to extract info
+// from the context target and story it to a private symbol associtaed with
+// a context implementation. That way name collisions are avoided while required
+// information is still carried along.
+const isPage = Symbol("context/page?")
+const PageContext = Class({
+ extends: Context,
+ read: {[isPage]: new readers.isPage()},
+ isCurrent: target => target[isPage]
+});
+exports.Page = PageContext;
+
+const isFrame = Symbol("context/frame?");
+const FrameContext = Class({
+ extends: Context,
+ read: {[isFrame]: new readers.isFrame()},
+ isCurrent: target => target[isFrame]
+});
+exports.Frame = FrameContext;
+
+const selection = Symbol("context/selection")
+const SelectionContext = Class({
+ read: {[selection]: new readers.Selection()},
+ isCurrent: target => !!target[selection]
+});
+exports.Selection = SelectionContext;
+
+const link = Symbol("context/link");
+const LinkContext = Class({
+ extends: Context,
+ read: {[link]: new readers.LinkURL()},
+ isCurrent: target => !!target[link]
+});
+exports.Link = LinkContext;
+
+const isEditable = Symbol("context/editable?")
+const EditableContext = Class({
+ extends: Context,
+ read: {[isEditable]: new readers.isEditable()},
+ isCurrent: target => target[isEditable]
+});
+exports.Editable = EditableContext;
+
+
+const mediaType = Symbol("context/mediaType")
+
+const ImageContext = Class({
+ extends: Context,
+ read: {[mediaType]: new readers.MediaType()},
+ isCurrent: target => target[mediaType] === "image"
+});
+exports.Image = ImageContext;
+
+
+const VideoContext = Class({
+ extends: Context,
+ read: {[mediaType]: new readers.MediaType()},
+ isCurrent: target => target[mediaType] === "video"
+});
+exports.Video = VideoContext;
+
+
+const AudioContext = Class({
+ extends: Context,
+ read: {[mediaType]: new readers.MediaType()},
+ isCurrent: target => target[mediaType] === "audio"
+});
+exports.Audio = AudioContext;
+
+const isSelectorMatch = Symbol("context/selector/mathches?")
+const SelectorContext = Class({
+ extends: Context,
+ initialize(selector) {
+ this.selector = selector;
+ // Each instance of selector context will need to store read
+ // data into different field, so that case with multilpe selector
+ // contexts won't cause a conflicts.
+ this[isSelectorMatch] = Symbol(selector);
+ this.read = {[this[isSelectorMatch]]: new readers.SelectorMatch(selector)};
+ },
+ isCurrent(target) {
+ return target[this[isSelectorMatch]];
+ }
+});
+exports.Selector = SelectorContext;
+
+const url = Symbol("context/url");
+const URLContext = Class({
+ extends: Context,
+ initialize(pattern) {
+ this.pattern = new MatchPattern(pattern);
+ },
+ read: {[url]: new readers.PageURL()},
+ isCurrent(target) {
+ return this.pattern.test(target[url]);
+ }
+});
+exports.URL = URLContext;
+
+var PredicateContext = Class({
+ extends: Context,
+ initialize(isMatch) {
+ if (typeof(isMatch) !== "function") {
+ throw TypeError("Predicate context mus be passed a function");
+ }
+
+ this.isMatch = isMatch
+ },
+ isCurrent(target) {
+ return this.isMatch(target);
+ }
+});
+exports.Predicate = PredicateContext;
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);
diff --git a/toolkit/jetpack/sdk/context-menu/readers.js b/toolkit/jetpack/sdk/context-menu/readers.js
new file mode 100644
index 000000000..5078f8f29
--- /dev/null
+++ b/toolkit/jetpack/sdk/context-menu/readers.js
@@ -0,0 +1,112 @@
+/* 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/. */
+const { Class } = require("../core/heritage");
+const { extend } = require("../util/object");
+const { memoize, method, identity } = require("../lang/functional");
+
+const serializeCategory = ({type}) => ({ category: `reader/${type}()` });
+
+const Reader = Class({
+ initialize() {
+ this.id = `reader/${this.type}()`
+ },
+ toJSON() {
+ return serializeCategory(this);
+ }
+});
+
+
+const MediaTypeReader = Class({ extends: Reader, type: "MediaType" });
+exports.MediaType = MediaTypeReader;
+
+const LinkURLReader = Class({ extends: Reader, type: "LinkURL" });
+exports.LinkURL = LinkURLReader;
+
+const SelectionReader = Class({ extends: Reader, type: "Selection" });
+exports.Selection = SelectionReader;
+
+const isPageReader = Class({ extends: Reader, type: "isPage" });
+exports.isPage = isPageReader;
+
+const isFrameReader = Class({ extends: Reader, type: "isFrame" });
+exports.isFrame = isFrameReader;
+
+const isEditable = Class({ extends: Reader, type: "isEditable"});
+exports.isEditable = isEditable;
+
+
+
+const ParameterizedReader = Class({
+ extends: Reader,
+ readParameter: function(value) {
+ return value;
+ },
+ toJSON: function() {
+ var json = serializeCategory(this);
+ json[this.parameter] = this[this.parameter];
+ return json;
+ },
+ initialize(...params) {
+ if (params.length) {
+ this[this.parameter] = this.readParameter(...params);
+ }
+ this.id = `reader/${this.type}(${JSON.stringify(this[this.parameter])})`;
+ }
+});
+exports.ParameterizedReader = ParameterizedReader;
+
+
+const QueryReader = Class({
+ extends: ParameterizedReader,
+ type: "Query",
+ parameter: "path"
+});
+exports.Query = QueryReader;
+
+
+const AttributeReader = Class({
+ extends: ParameterizedReader,
+ type: "Attribute",
+ parameter: "name"
+});
+exports.Attribute = AttributeReader;
+
+const SrcURLReader = Class({
+ extends: AttributeReader,
+ name: "src",
+});
+exports.SrcURL = SrcURLReader;
+
+const PageURLReader = Class({
+ extends: QueryReader,
+ path: "ownerDocument.URL",
+});
+exports.PageURL = PageURLReader;
+
+const SelectorMatchReader = Class({
+ extends: ParameterizedReader,
+ type: "SelectorMatch",
+ parameter: "selector"
+});
+exports.SelectorMatch = SelectorMatchReader;
+
+const extractors = new WeakMap();
+extractors.id = 0;
+
+
+var Extractor = Class({
+ extends: ParameterizedReader,
+ type: "Extractor",
+ parameter: "source",
+ initialize: function(f) {
+ this[this.parameter] = String(f);
+ if (!extractors.has(f)) {
+ extractors.id = extractors.id + 1;
+ extractors.set(f, extractors.id);
+ }
+
+ this.id = `reader/${this.type}.for(${extractors.get(f)})`
+ }
+});
+exports.Extractor = Extractor;