summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/content/context-menu.js
blob: 2955e2f094c1ee0031531deb481adbf614c60fc3 (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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
/* 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 { Class } = require("../core/heritage");
const self = require("../self");
const { WorkerChild } = require("./worker-child");
const { getInnerId } = require("../window/utils");
const { Ci } = require("chrome");
const { Services } = require("resource://gre/modules/Services.jsm");
const system = require('../system/events');
const { process } = require('../remote/child');

// These functions are roughly copied from sdk/selection which doesn't work
// in the content process
function getElementWithSelection(window) {
  let element = Services.focus.getFocusedElementForWindow(window, false, {});
  if (!element)
    return null;

  try {
    // Accessing selectionStart and selectionEnd on e.g. a button
    // results in an exception thrown as per the HTML5 spec.  See
    // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection

    let { value, selectionStart, selectionEnd } = element;

    let hasSelection = typeof value === "string" &&
                      !isNaN(selectionStart) &&
                      !isNaN(selectionEnd) &&
                      selectionStart !== selectionEnd;

    return hasSelection ? element : null;
  }
  catch (err) {
    console.exception(err);
    return null;
  }
}

function safeGetRange(selection, rangeNumber) {
  try {
    let { rangeCount } = selection;
    let range = null;

    for (let rangeNumber = 0; rangeNumber < rangeCount; rangeNumber++ ) {
      range = selection.getRangeAt(rangeNumber);

      if (range && range.toString())
        break;

      range = null;
    }

    return range;
  }
  catch (e) {
    return null;
  }
}

function getSelection(window) {
  let selection = window.getSelection();
  let range = safeGetRange(selection);
  if (range)
    return range.toString();

  let node = getElementWithSelection(window);
  if (!node)
    return null;

  return node.value.substring(node.selectionStart, node.selectionEnd);
}

//These are used by PageContext.isCurrent below. If the popupNode or any of
//its ancestors is one of these, Firefox uses a tailored context menu, and so
//the page context doesn't apply.
const NON_PAGE_CONTEXT_ELTS = [
  Ci.nsIDOMHTMLAnchorElement,
  Ci.nsIDOMHTMLAppletElement,
  Ci.nsIDOMHTMLAreaElement,
  Ci.nsIDOMHTMLButtonElement,
  Ci.nsIDOMHTMLCanvasElement,
  Ci.nsIDOMHTMLEmbedElement,
  Ci.nsIDOMHTMLImageElement,
  Ci.nsIDOMHTMLInputElement,
  Ci.nsIDOMHTMLMapElement,
  Ci.nsIDOMHTMLMediaElement,
  Ci.nsIDOMHTMLMenuElement,
  Ci.nsIDOMHTMLObjectElement,
  Ci.nsIDOMHTMLOptionElement,
  Ci.nsIDOMHTMLSelectElement,
  Ci.nsIDOMHTMLTextAreaElement,
];

// List all editable types of inputs.  Or is it better to have a list
// of non-editable inputs?
var editableInputs = {
  email: true,
  number: true,
  password: true,
  search: true,
  tel: true,
  text: true,
  textarea: true,
  url: true
};

var CONTEXTS = {};

var Context = Class({
  initialize: function(id) {
    this.id = id;
  },

  adjustPopupNode: function adjustPopupNode(popupNode) {
    return popupNode;
  },

  // Gets state to pass through to the parent process for the node the user
  // clicked on
  getState: function(popupNode) {
    return false;
  }
});

// Matches when the context-clicked node doesn't have any of
// NON_PAGE_CONTEXT_ELTS in its ancestors
CONTEXTS.PageContext = Class({
  extends: Context,

  getState: function(popupNode) {
    // If there is a selection in the window then this context does not match
    if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
      return false;

    // If the clicked node or any of its ancestors is one of the blocked
    // NON_PAGE_CONTEXT_ELTS then this context does not match
    while (!(popupNode instanceof Ci.nsIDOMDocument)) {
      if (NON_PAGE_CONTEXT_ELTS.some(type => popupNode instanceof type))
        return false;

      popupNode = popupNode.parentNode;
    }

    return true;
  }
});

// Matches when there is an active selection in the window
CONTEXTS.SelectionContext = Class({
  extends: Context,

  getState: function(popupNode) {
    if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
      return true;

    try {
      // The node may be a text box which has selectionStart and selectionEnd
      // properties. If not this will throw.
      let { selectionStart, selectionEnd } = popupNode;
      return !isNaN(selectionStart) && !isNaN(selectionEnd) &&
             selectionStart !== selectionEnd;
    }
    catch (e) {
      return false;
    }
  }
});

// Matches when the context-clicked node or any of its ancestors matches the
// selector given
CONTEXTS.SelectorContext = Class({
  extends: Context,

  initialize: function initialize(id, selector) {
    Context.prototype.initialize.call(this, id);
    this.selector = selector;
  },

  adjustPopupNode: function adjustPopupNode(popupNode) {
    let selector = this.selector;

    while (!(popupNode instanceof Ci.nsIDOMDocument)) {
      if (popupNode.matches(selector))
        return popupNode;

      popupNode = popupNode.parentNode;
    }

    return null;
  },

  getState: function(popupNode) {
    return !!this.adjustPopupNode(popupNode);
  }
});

// Matches when the page url matches any of the patterns given
CONTEXTS.URLContext = Class({
  extends: Context,

  getState: function(popupNode) {
    return popupNode.ownerDocument.URL;
  }
});

// Matches when the user-supplied predicate returns true
CONTEXTS.PredicateContext = Class({
  extends: Context,

  getState: function(node) {
    let window = node.ownerDocument.defaultView;
    let data = {};

    data.documentType = node.ownerDocument.contentType;

    data.documentURL = node.ownerDocument.location.href;
    data.targetName = node.nodeName.toLowerCase();
    data.targetID = node.id || null ;

    if ((data.targetName === 'input' && editableInputs[node.type]) ||
        data.targetName === 'textarea') {
      data.isEditable = !node.readOnly && !node.disabled;
    }
    else {
      data.isEditable = node.isContentEditable;
    }

    data.selectionText = getSelection(window, "TEXT");

    data.srcURL = node.src || null;
    data.value = node.value || null;

    while (!data.linkURL && node) {
      data.linkURL = node.href || null;
      node = node.parentNode;
    }

    return data;
  },
});

function instantiateContext({ id, type, args }) {
  if (!(type in CONTEXTS)) {
    console.error("Attempt to use unknown context " + type);
    return;
  }
  return new CONTEXTS[type](id, ...args);
}

var ContextWorker = Class({
  implements: [ WorkerChild ],

  // Calls the context workers context listeners and returns the first result
  // that is either a string or a value that evaluates to true. If all of the
  // listeners returned false then returns false. If there are no listeners,
  // returns true (show the menu item by default).
  getMatchedContext: function getCurrentContexts(popupNode) {
    let results = this.sandbox.emitSync("context", popupNode);
    if (!results.length)
      return true;
    return results.reduce((val, result) => val || result);
  },

  // Emits a click event in the worker's port. popupNode is the node that was
  // context-clicked, and clickedItemData is the data of the item that was
  // clicked.
  fireClick: function fireClick(popupNode, clickedItemData) {
    this.sandbox.emitSync("click", popupNode, clickedItemData);
  }
});

// Gets the item's content script worker for a window, creating one if necessary
// Once created it will be automatically destroyed when the window unloads.
// If there is not content scripts for the item then null will be returned.
function getItemWorkerForWindow(item, window) {
  if (!item.contentScript && !item.contentScriptFile)
    return null;

  let id = getInnerId(window);
  let worker = item.workerMap.get(id);

  if (worker)
    return worker;

  worker = ContextWorker({
    id: item.id,
    window,
    manager: item.manager,
    contentScript: item.contentScript,
    contentScriptFile: item.contentScriptFile,
    onDetach: function() {
      item.workerMap.delete(id);
    }
  });

  item.workerMap.set(id, worker);

  return worker;
}

// A very simple remote proxy for every item. It's job is to provide data for
// the main process to use to determine visibility state and to call into
// content scripts when clicked.
var RemoteItem = Class({
  initialize: function(options, manager) {
    this.id = options.id;
    this.contexts = options.contexts.map(instantiateContext);
    this.contentScript = options.contentScript;
    this.contentScriptFile = options.contentScriptFile;

    this.manager = manager;

    this.workerMap = new Map();
    keepAlive.set(this.id, this);
  },

  destroy: function() {
    for (let worker of this.workerMap.values()) {
      worker.destroy();
    }
    keepAlive.delete(this.id);
  },

  activate: function(popupNode, data) {
    let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView);
    if (!worker)
      return;

    for (let context of this.contexts)
      popupNode = context.adjustPopupNode(popupNode);

    worker.fireClick(popupNode, data);
  },

  // Fills addonInfo with state data to send through to the main process
  getContextState: function(popupNode, addonInfo) {
    if (!(self.id in addonInfo)) {
      addonInfo[self.id] = {
        processID: process.id,
        items: {}
      };
    }

    let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView);
    let contextStates = {};
    for (let context of this.contexts)
      contextStates[context.id] = context.getState(popupNode);

    addonInfo[self.id].items[this.id] = {
      // It isn't ideal to create a PageContext for every item but there isn't
      // a good shared place to do it.
      pageContext: (new CONTEXTS.PageContext()).getState(popupNode),
      contextStates,
      hasWorker: !!worker,
      workerContext: worker ? worker.getMatchedContext(popupNode) : true
    }
  }
});
exports.RemoteItem = RemoteItem;

// Holds remote items for this frame.
var keepAlive = new Map();

// Called to create remote proxies for items. If they already exist we destroy
// and recreate. This can happen if the item changes in some way or in odd
// timing cases where the frame script is create around the same time as the
// item is created in the main process
process.port.on('sdk/contextmenu/createitems', (process, items) => {
  for (let itemoptions of items) {
    let oldItem = keepAlive.get(itemoptions.id);
    if (oldItem) {
      oldItem.destroy();
    }

    let item = new RemoteItem(itemoptions, this);
  }
});

process.port.on('sdk/contextmenu/destroyitems', (process, items) => {
  for (let id of items) {
    let item = keepAlive.get(id);
    item.destroy();
  }
});

var lastPopupNode = null;

system.on('content-contextmenu', ({ subject }) => {
  let { event: { target: popupNode }, addonInfo } = subject.wrappedJSObject;
  lastPopupNode = popupNode;

  for (let item of keepAlive.values()) {
    item.getContextState(popupNode, addonInfo);
  }
}, true);

process.port.on('sdk/contextmenu/activateitems', (process, items, data) => {
  for (let id of items) {
    let item = keepAlive.get(id);
    if (!item)
      continue;

    item.activate(lastPopupNode, data);
  }
});