summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/panel.js
blob: 34cde2eddd04d41b237996ce21222839339ef418 (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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
/* 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";

// The panel module currently supports only Firefox and SeaMonkey.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps
module.metadata = {
  "stability": "stable",
  "engines": {
    "Palemoon": "*",
    "Firefox": "*",
    "SeaMonkey": "*"
  }
};

const { Cu, Ci } = require("chrome");
const { setTimeout } = require('./timers');
const { Class } = require("./core/heritage");
const { merge } = require("./util/object");
const { WorkerHost } = require("./content/utils");
const { Worker } = require("./deprecated/sync-worker");
const { Disposable } = require("./core/disposable");
const { WeakReference } = require('./core/reference');
const { contract: loaderContract } = require("./content/loader");
const { contract } = require("./util/contract");
const { on, off, emit, setListeners } = require("./event/core");
const { EventTarget } = require("./event/target");
const domPanel = require("./panel/utils");
const { getDocShell } = require('./frame/utils');
const { events } = require("./panel/events");
const systemEvents = require("./system/events");
const { filter, pipe, stripListeners } = require("./event/utils");
const { getNodeView, getActiveView } = require("./view/core");
const { isNil, isObject, isNumber } = require("./lang/type");
const { getAttachEventType } = require("./content/utils");
const { number, boolean, object } = require('./deprecated/api-utils');
const { Style } = require("./stylesheet/style");
const { attach, detach } = require("./content/mod");

var isRect = ({top, right, bottom, left}) => [top, right, bottom, left].
  some(value => isNumber(value) && !isNaN(value));

var isSDKObj = obj => obj instanceof Class;

var rectContract = contract({
  top: number,
  right: number,
  bottom: number,
  left: number
});

var position = {
  is: object,
  map: v => (isNil(v) || isSDKObj(v) || !isObject(v)) ? v : rectContract(v),
  ok: v => isNil(v) || isSDKObj(v) || (isObject(v) && isRect(v)),
  msg: 'The option "position" must be a SDK object registered as anchor; ' +
        'or an object with one or more of the following keys set to numeric ' +
        'values: top, right, bottom, left.'
}

var displayContract = contract({
  width: number,
  height: number,
  focus: boolean,
  position: position
});

var panelContract = contract(merge({
  // contentStyle* / contentScript* are sharing the same validation constraints,
  // so they can be mostly reused, except for the messages.
  contentStyle: merge(Object.create(loaderContract.rules.contentScript), {
    msg: 'The `contentStyle` option must be a string or an array of strings.'
  }),
  contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), {
    msg: 'The `contentStyleFile` option must be a local URL or an array of URLs'
  }),
  contextMenu: boolean,
  allow: {
    is: ['object', 'undefined', 'null'],
    map: function (allow) { return { script: !allow || allow.script !== false }}
  },
}, displayContract.rules, loaderContract.rules));

function Allow(panel) {
  return {
    get script() { return getDocShell(viewFor(panel).backgroundFrame).allowJavascript; },
    set script(value) { return setScriptState(panel, value); },
  };
}

function setScriptState(panel, value) {
  let view = viewFor(panel);
  getDocShell(view.backgroundFrame).allowJavascript = value;
  getDocShell(view.viewFrame).allowJavascript = value;
  view.setAttribute("sdkscriptenabled", "" + value);
}

function isDisposed(panel) {
  return !views.has(panel);
}

var panels = new WeakMap();
var models = new WeakMap();
var views = new WeakMap();
var workers = new WeakMap();
var styles = new WeakMap();

const viewFor = (panel) => views.get(panel);
const modelFor = (panel) => models.get(panel);
const panelFor = (view) => panels.get(view);
const workerFor = (panel) => workers.get(panel);
const styleFor = (panel) => styles.get(panel);

function getPanelFromWeakRef(weakRef) {
  if (!weakRef) {
    return null;
  }
  let panel = weakRef.get();
  if (!panel) {
    return null;
  }
  if (isDisposed(panel)) {
    return null;
  }
  return panel;
}

var SinglePanelManager = {
  visiblePanel: null,
  enqueuedPanel: null,
  enqueuedPanelCallback: null,
  // Calls |callback| with no arguments when the panel may be shown.
  requestOpen: function(panelToOpen, callback) {
    let currentPanel = getPanelFromWeakRef(SinglePanelManager.visiblePanel);
    if (currentPanel || SinglePanelManager.enqueuedPanel) {
      SinglePanelManager.enqueuedPanel = Cu.getWeakReference(panelToOpen);
      SinglePanelManager.enqueuedPanelCallback = callback;
      if (currentPanel && currentPanel.isShowing) {
        currentPanel.hide();
      }
    } else {
      SinglePanelManager.notifyPanelCanOpen(panelToOpen, callback);
    }
  },
  notifyPanelCanOpen: function(panel, callback) {
    let view = viewFor(panel);
    // Can't pass an arrow function as the event handler because we need to be
    // able to call |removeEventListener| later.
    view.addEventListener("popuphidden", SinglePanelManager.onVisiblePanelHidden, true);
    view.addEventListener("popupshown", SinglePanelManager.onVisiblePanelShown, false);
    SinglePanelManager.enqueuedPanel = null;
    SinglePanelManager.enqueuedPanelCallback = null;
    SinglePanelManager.visiblePanel = Cu.getWeakReference(panel);
    callback();
  },
  onVisiblePanelShown: function(event) {
    let panel = panelFor(event.target);
    if (SinglePanelManager.enqueuedPanel) {
      // Another panel started waiting for |panel| to close before |panel| was
      // even done opening.
      panel.hide();
    }
  },
  onVisiblePanelHidden: function(event) {
    let view = event.target;
    let panel = panelFor(view);
    let currentPanel = getPanelFromWeakRef(SinglePanelManager.visiblePanel);
    if (currentPanel && currentPanel != panel) {
      return;
    }
    SinglePanelManager.visiblePanel = null;
    view.removeEventListener("popuphidden", SinglePanelManager.onVisiblePanelHidden, true);
    view.removeEventListener("popupshown", SinglePanelManager.onVisiblePanelShown, false);
    let nextPanel = getPanelFromWeakRef(SinglePanelManager.enqueuedPanel);
    let nextPanelCallback = SinglePanelManager.enqueuedPanelCallback;
    if (nextPanel) {
      SinglePanelManager.notifyPanelCanOpen(nextPanel, nextPanelCallback);
    }
  }
};

const Panel = Class({
  implements: [
    // Generate accessors for the validated properties that update model on
    // set and return values from model on get.
    panelContract.properties(modelFor),
    EventTarget,
    Disposable,
    WeakReference
  ],
  extends: WorkerHost(workerFor),
  setup: function setup(options) {
    let model = merge({
      defaultWidth: 320,
      defaultHeight: 240,
      focus: true,
      position: Object.freeze({}),
      contextMenu: false
    }, panelContract(options));
    model.ready = false;
    models.set(this, model);

    if (model.contentStyle || model.contentStyleFile) {
      styles.set(this, Style({
        uri: model.contentStyleFile,
        source: model.contentStyle
      }));
    }

    // Setup view
    let viewOptions = {allowJavascript: !model.allow || (model.allow.script !== false)};
    let view = domPanel.make(null, viewOptions);
    panels.set(view, this);
    views.set(this, view);

    // Load panel content.
    domPanel.setURL(view, model.contentURL);

    // Allow context menu
    domPanel.allowContextMenu(view, model.contextMenu);

    // Setup listeners.
    setListeners(this, options);
    let worker = new Worker(stripListeners(options));
    workers.set(this, worker);

    // pipe events from worker to a panel.
    pipe(worker, this);
  },
  dispose: function dispose() {
    this.hide();
    off(this);

    workerFor(this).destroy();
    detach(styleFor(this));

    domPanel.dispose(viewFor(this));

    // Release circular reference between view and panel instance. This
    // way view will be GC-ed. And panel as well once all the other refs
    // will be removed from it.
    views.delete(this);
  },
  /* Public API: Panel.width */
  get width() {
    return modelFor(this).width;
  },
  set width(value) {
    this.resize(value, this.height);
  },
  /* Public API: Panel.height */
  get height() {
    return modelFor(this).height;
  },
  set height(value) {
    this.resize(this.width, value);
  },

  /* Public API: Panel.focus */
  get focus() {
    return modelFor(this).focus;
  },

  /* Public API: Panel.position */
  get position() {
    return modelFor(this).position;
  },

  /* Public API: Panel.contextMenu */
  get contextMenu() {
    return modelFor(this).contextMenu;
  },
  set contextMenu(allow) {
    let model = modelFor(this);
    model.contextMenu = panelContract({ contextMenu: allow }).contextMenu;
    domPanel.allowContextMenu(viewFor(this), model.contextMenu);
  },

  get contentURL() {
    return modelFor(this).contentURL;
  },
  set contentURL(value) {
    let model = modelFor(this);
    model.contentURL = panelContract({ contentURL: value }).contentURL;
    domPanel.setURL(viewFor(this), model.contentURL);
    // Detach worker so that messages send will be queued until it's
    // reatached once panel content is ready.
    workerFor(this).detach();
  },

  get allow() { return Allow(this); },
  set allow(value) {
    let allowJavascript = panelContract({ allow: value }).allow.script;
    return setScriptState(this, value);
  },

  /* Public API: Panel.isShowing */
  get isShowing() {
    return !isDisposed(this) && domPanel.isOpen(viewFor(this));
  },

  /* Public API: Panel.show */
  show: function show(options={}, anchor) {
    SinglePanelManager.requestOpen(this, () => {
      if (options instanceof Ci.nsIDOMElement) {
        [anchor, options] = [options, null];
      }

      if (anchor instanceof Ci.nsIDOMElement) {
        console.warn(
          "Passing a DOM node to Panel.show() method is an unsupported " +
          "feature that will be soon replaced. " +
          "See: https://bugzilla.mozilla.org/show_bug.cgi?id=878877"
        );
      }

      let model = modelFor(this);
      let view = viewFor(this);
      let anchorView = getNodeView(anchor || options.position || model.position);

      options = merge({
        position: model.position,
        width: model.width,
        height: model.height,
        defaultWidth: model.defaultWidth,
        defaultHeight: model.defaultHeight,
        focus: model.focus,
        contextMenu: model.contextMenu
      }, displayContract(options));

      if (!isDisposed(this)) {
        domPanel.show(view, options, anchorView);
      }
    });
    return this;
  },

  /* Public API: Panel.hide */
  hide: function hide() {
    // Quit immediately if panel is disposed or there is no state change.
    domPanel.close(viewFor(this));

    return this;
  },

  /* Public API: Panel.resize */
  resize: function resize(width, height) {
    let model = modelFor(this);
    let view = viewFor(this);
    let change = panelContract({
      width: width || model.width || model.defaultWidth,
      height: height || model.height || model.defaultHeight
    });

    model.width = change.width
    model.height = change.height

    domPanel.resize(view, model.width, model.height);

    return this;
  }
});
exports.Panel = Panel;

// Note must be defined only after value to `Panel` is assigned.
getActiveView.define(Panel, viewFor);

// Filter panel events to only panels that are create by this module.
var panelEvents = filter(events, ({target}) => panelFor(target));

// Panel events emitted after panel has being shown.
var shows = filter(panelEvents, ({type}) => type === "popupshown");

// Panel events emitted after panel became hidden.
var hides = filter(panelEvents, ({type}) => type === "popuphidden");

// Panel events emitted after content inside panel is ready. For different
// panels ready may mean different state based on `contentScriptWhen` attribute.
// Weather given event represents readyness is detected by `getAttachEventType`
// helper function.
var ready = filter(panelEvents, ({type, target}) =>
  getAttachEventType(modelFor(panelFor(target))) === type);

// Panel event emitted when the contents of the panel has been loaded.
var readyToShow = filter(panelEvents, ({type}) => type === "DOMContentLoaded");

// Styles should be always added as soon as possible, and doesn't makes them
// depends on `contentScriptWhen`
var start = filter(panelEvents, ({type}) => type === "document-element-inserted");

// Forward panel show / hide events to panel's own event listeners.
on(shows, "data", ({target}) => {
  let panel = panelFor(target);
  if (modelFor(panel).ready)
    emit(panel, "show");
});

on(hides, "data", ({target}) => {
  let panel = panelFor(target);
  if (modelFor(panel).ready)
    emit(panel, "hide");
});

on(ready, "data", ({target}) => {
  let panel = panelFor(target);
  let window = domPanel.getContentDocument(target).defaultView;

  workerFor(panel).attach(window);
});

on(readyToShow, "data", ({target}) => {
  let panel = panelFor(target);

  if (!modelFor(panel).ready) {
    modelFor(panel).ready = true;

    if (viewFor(panel).state == "open")
      emit(panel, "show");
  }
});

on(start, "data", ({target}) => {
  let panel = panelFor(target);
  let window = domPanel.getContentDocument(target).defaultView;

  attach(styleFor(panel), window);
});