diff options
Diffstat (limited to 'toolkit/jetpack/sdk/context-menu')
-rw-r--r-- | toolkit/jetpack/sdk/context-menu/context.js | 147 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/context-menu/core.js | 384 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/context-menu/readers.js | 112 |
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; |