summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/framescript/context-menu.js
blob: 3915b7cd89f5b2cce602d35958e577665cb4196e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
/* 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 { query, constant, cache } = require("sdk/lang/functional");
const { pairs, each, map, object } = require("sdk/util/sequence");
const { nodeToMessageManager } = require("./util");

// Decorator function that takes `f` function and returns one that attempts
// to run `f` with given arguments. In case of exception error is logged
// and `fallback` is returned instead.
const Try = (fn, fallback=null) => (...args) => {
  try {
    return fn(...args);
  } catch(error) {
    console.error(error);
    return fallback;
  }
};

// Decorator funciton that takes `f` function and returns one that returns
// JSON cloned result of whatever `f` returns for given arguments.
const JSONReturn = f => (...args) => JSON.parse(JSON.stringify(f(...args)));

const Null = constant(null);

// Table of readers mapped to field names they're going to be reading.
const readers = Object.create(null);
// Read function takes "contextmenu" event target `node` and returns table of
// read field names mapped to appropriate values. Read uses above defined read
// table to read data for all registered readers.
const read = node =>
  object(...map(([id, read]) => [id, read(node, id)], pairs(readers)));

// Table of built-in readers, each takes a descriptor and returns a reader:
// descriptor -> node -> JSON
const parsers = Object.create(null)
// Function takes a descriptor of the remotely defined reader and parsese it
// to construct a local reader that's going to read out data from context menu
// target.
const parse = descriptor => {
  const parser = parsers[descriptor.category];
  if (!parser) {
    console.error("Unknown reader descriptor was received", descriptor, `"${descriptor.category}"`);
    return Null
  }
  return Try(parser(descriptor));
}

// TODO: Test how chrome's mediaType behaves to try and match it's behavior.
const HTML_NS = "http://www.w3.org/1999/xhtml";
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const SVG_NS = "http://www.w3.org/2000/svg";

// Firefox always creates a HTMLVideoElement when loading an ogg file
// directly. If the media is actually audio, be smarter and provide a
// context menu with audio operations.
// Source: https://github.com/mozilla/gecko-dev/blob/28c2fca3753c5371643843fc2f2f205146b083b7/browser/base/content/nsContextMenu.js#L632-L637
const isVideoLoadingAudio = node =>
  node.readyState >= node.HAVE_METADATA &&
    (node.videoWidth == 0 || node.videoHeight == 0)

const isVideo = node =>
  node instanceof node.ownerDocument.defaultView.HTMLVideoElement &&
  !isVideoLoadingAudio(node);

const isAudio = node => {
  const {HTMLVideoElement, HTMLAudioElement} = node.ownerDocument.defaultView;
  return node instanceof HTMLAudioElement ? true :
         node instanceof HTMLVideoElement ? isVideoLoadingAudio(node) :
         false;
};

const isImage = ({namespaceURI, localName}) =>
  namespaceURI === HTML_NS && localName === "img" ? true :
  namespaceURI === XUL_NS && localName === "image" ? true :
  namespaceURI === SVG_NS && localName === "image" ? true :
  false;

parsers["reader/MediaType()"] = constant(node =>
  isImage(node) ? "image" :
  isAudio(node) ? "audio" :
  isVideo(node) ? "video" :
  null);


const readLink = node =>
  node.namespaceURI === HTML_NS && node.localName === "a" ? node.href :
  readLink(node.parentNode);

parsers["reader/LinkURL()"] = constant(node =>
  node.matches("a, a *") ? readLink(node) : null);

// Reader that reads out `true` if "contextmenu" `event.target` matches
// `descriptor.selector` and `false` if it does not.
parsers["reader/SelectorMatch()"] = ({selector}) =>
  node => node.matches(selector);

// Accessing `selectionStart` and `selectionEnd` properties on non
// editable input nodes throw exceptions, there for we need this util
// function to guard us against them.
const getInputSelection = node => {
  try {
    if ("selectionStart" in node && "selectionEnd" in node) {
      const {selectionStart, selectionEnd} = node;
      return {selectionStart, selectionEnd}
    }
  }
  catch(_) {}

  return null;
}

// Selection reader does not really cares about descriptor so it is
// a constant function returning selection reader. Selection reader
// returns string of the selected text or `null` if there is no selection.
parsers["reader/Selection()"] = constant(node => {
  const selection = node.ownerDocument.getSelection();
  if (!selection.isCollapsed) {
    return selection.toString();
  }
  // If target node is editable (text, input, textarea, etc..) document does
  // not really handles selections there. There for we fallback to checking
  // `selectionStart` `selectionEnd` properties and if they are present we
  // extract selections manually from the `node.value`.
  else {
    const selection = getInputSelection(node);
    const isSelected = selection &&
                       Number.isInteger(selection.selectionStart) &&
                       Number.isInteger(selection.selectionEnd) &&
                       selection.selectionStart !== selection.selectionEnd;
    return  isSelected ? node.value.substring(selection.selectionStart,
                                              selection.selectionEnd) :
            null;
  }
});

// Query reader just reads out properties from the node, so we just use `query`
// utility function.
parsers["reader/Query()"] = ({path}) => JSONReturn(query(path));
// Attribute reader just reads attribute of the event target node.
parsers["reader/Attribute()"] = ({name}) => node => node.getAttribute(name);

// Extractor reader defines generates a reader out of serialized function, who's
// return value is JSON cloned. Note: We do know source will evaluate to function
// as that's what we serialized on the other end, it's also ok if generated function
// is going to throw as registered readers are wrapped in try catch to avoid breakting
// unrelated readers.
parsers["reader/Extractor()"] = ({source}) =>
  JSONReturn(new Function("return (" + source + ")")());

// If the context-menu target node or any of its ancestors is one of these,
// Firefox uses a tailored context menu, and so the page context doesn't apply.
// There for `reader/isPage()` will read `false` in that case otherwise it's going
// to read `true`.
const nonPageElements = ["a", "applet", "area", "button", "canvas", "object",
                         "embed", "img", "input", "map", "video", "audio", "menu",
                         "option", "select", "textarea", "[contenteditable=true]"];
const nonPageSelector = nonPageElements.
                          concat(nonPageElements.map(tag => `${tag} *`)).
                          join(", ");

// Note: isPageContext implementation could have actually used SelectorMatch reader,
// but old implementation was also checked for collapsed selection there for to keep
// the behavior same we end up implementing a new reader.
parsers["reader/isPage()"] = constant(node =>
  node.ownerDocument.defaultView.getSelection().isCollapsed &&
  !node.matches(nonPageSelector));

// Reads `true` if node is in an iframe otherwise returns true.
parsers["reader/isFrame()"] = constant(node =>
  !!node.ownerDocument.defaultView.frameElement);

parsers["reader/isEditable()"] = constant(node => {
  const selection = getInputSelection(node);
  return selection ? !node.readOnly && !node.disabled : node.isContentEditable;
});


// TODO: Add some reader to read out tab id.

const onReadersUpdate = message => {
  each(([id, descriptor]) => {
    if (descriptor) {
      readers[id] = parse(descriptor);
    }
    else {
      delete readers[id];
    }
  }, pairs(message.data));
};
exports.onReadersUpdate = onReadersUpdate;


const onContextMenu = event => {
  if (!event.defaultPrevented) {
    const manager = nodeToMessageManager(event.target);
    manager.sendSyncMessage("sdk/context-menu/read", read(event.target), readers);
  }
};
exports.onContextMenu = onContextMenu;


const onContentFrame = (frame) => {
  // Listen for contextmenu events in on this frame.
  frame.addEventListener("contextmenu", onContextMenu);
  // Listen to registered reader changes and update registry.
  frame.addMessageListener("sdk/context-menu/readers", onReadersUpdate);

  // Request table of readers (if this is loaded in a new process some table
  // changes may be missed, this is way to sync up).
  frame.sendAsyncMessage("sdk/context-menu/readers?");
};
exports.onContentFrame = onContentFrame;