|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 "use strict"; |
|
5 |
|
6 // The panel module currently supports only Firefox. |
|
7 // See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps |
|
8 module.metadata = { |
|
9 "stability": "stable", |
|
10 "engines": { |
|
11 "Firefox": "*" |
|
12 } |
|
13 }; |
|
14 |
|
15 const { Ci } = require("chrome"); |
|
16 const { setTimeout } = require('./timers'); |
|
17 const { isPrivateBrowsingSupported } = require('./self'); |
|
18 const { isWindowPBSupported } = require('./private-browsing/utils'); |
|
19 const { Class } = require("./core/heritage"); |
|
20 const { merge } = require("./util/object"); |
|
21 const { WorkerHost } = require("./content/utils"); |
|
22 const { Worker } = require("./content/worker"); |
|
23 const { Disposable } = require("./core/disposable"); |
|
24 const { WeakReference } = require('./core/reference'); |
|
25 const { contract: loaderContract } = require("./content/loader"); |
|
26 const { contract } = require("./util/contract"); |
|
27 const { on, off, emit, setListeners } = require("./event/core"); |
|
28 const { EventTarget } = require("./event/target"); |
|
29 const domPanel = require("./panel/utils"); |
|
30 const { events } = require("./panel/events"); |
|
31 const systemEvents = require("./system/events"); |
|
32 const { filter, pipe, stripListeners } = require("./event/utils"); |
|
33 const { getNodeView, getActiveView } = require("./view/core"); |
|
34 const { isNil, isObject, isNumber } = require("./lang/type"); |
|
35 const { getAttachEventType } = require("./content/utils"); |
|
36 const { number, boolean, object } = require('./deprecated/api-utils'); |
|
37 const { Style } = require("./stylesheet/style"); |
|
38 const { attach, detach } = require("./content/mod"); |
|
39 |
|
40 let isRect = ({top, right, bottom, left}) => [top, right, bottom, left]. |
|
41 some(value => isNumber(value) && !isNaN(value)); |
|
42 |
|
43 let isSDKObj = obj => obj instanceof Class; |
|
44 |
|
45 let rectContract = contract({ |
|
46 top: number, |
|
47 right: number, |
|
48 bottom: number, |
|
49 left: number |
|
50 }); |
|
51 |
|
52 let position = { |
|
53 is: object, |
|
54 map: v => (isNil(v) || isSDKObj(v) || !isObject(v)) ? v : rectContract(v), |
|
55 ok: v => isNil(v) || isSDKObj(v) || (isObject(v) && isRect(v)), |
|
56 msg: 'The option "position" must be a SDK object registered as anchor; ' + |
|
57 'or an object with one or more of the following keys set to numeric ' + |
|
58 'values: top, right, bottom, left.' |
|
59 } |
|
60 |
|
61 let displayContract = contract({ |
|
62 width: number, |
|
63 height: number, |
|
64 focus: boolean, |
|
65 position: position |
|
66 }); |
|
67 |
|
68 let panelContract = contract(merge({ |
|
69 // contentStyle* / contentScript* are sharing the same validation constraints, |
|
70 // so they can be mostly reused, except for the messages. |
|
71 contentStyle: merge(Object.create(loaderContract.rules.contentScript), { |
|
72 msg: 'The `contentStyle` option must be a string or an array of strings.' |
|
73 }), |
|
74 contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), { |
|
75 msg: 'The `contentStyleFile` option must be a local URL or an array of URLs' |
|
76 }) |
|
77 }, displayContract.rules, loaderContract.rules)); |
|
78 |
|
79 |
|
80 function isDisposed(panel) !views.has(panel); |
|
81 |
|
82 let panels = new WeakMap(); |
|
83 let models = new WeakMap(); |
|
84 let views = new WeakMap(); |
|
85 let workers = new WeakMap(); |
|
86 let styles = new WeakMap(); |
|
87 |
|
88 const viewFor = (panel) => views.get(panel); |
|
89 const modelFor = (panel) => models.get(panel); |
|
90 const panelFor = (view) => panels.get(view); |
|
91 const workerFor = (panel) => workers.get(panel); |
|
92 const styleFor = (panel) => styles.get(panel); |
|
93 |
|
94 // Utility function takes `panel` instance and makes sure it will be |
|
95 // automatically hidden as soon as other panel is shown. |
|
96 let setupAutoHide = new function() { |
|
97 let refs = new WeakMap(); |
|
98 |
|
99 return function setupAutoHide(panel) { |
|
100 // Create system event listener that reacts to any panel showing and |
|
101 // hides given `panel` if it's not the one being shown. |
|
102 function listener({subject}) { |
|
103 // It could be that listener is not GC-ed in the same cycle as |
|
104 // panel in such case we remove listener manually. |
|
105 let view = viewFor(panel); |
|
106 if (!view) systemEvents.off("popupshowing", listener); |
|
107 else if (subject !== view) panel.hide(); |
|
108 } |
|
109 |
|
110 // system event listener is intentionally weak this way we'll allow GC |
|
111 // to claim panel if it's no longer referenced by an add-on code. This also |
|
112 // helps minimizing cleanup required on unload. |
|
113 systemEvents.on("popupshowing", listener); |
|
114 // To make sure listener is not claimed by GC earlier than necessary we |
|
115 // associate it with `panel` it's associated with. This way it won't be |
|
116 // GC-ed earlier than `panel` itself. |
|
117 refs.set(panel, listener); |
|
118 } |
|
119 } |
|
120 |
|
121 const Panel = Class({ |
|
122 implements: [ |
|
123 // Generate accessors for the validated properties that update model on |
|
124 // set and return values from model on get. |
|
125 panelContract.properties(modelFor), |
|
126 EventTarget, |
|
127 Disposable, |
|
128 WeakReference |
|
129 ], |
|
130 extends: WorkerHost(workerFor), |
|
131 setup: function setup(options) { |
|
132 let model = merge({ |
|
133 defaultWidth: 320, |
|
134 defaultHeight: 240, |
|
135 focus: true, |
|
136 position: Object.freeze({}), |
|
137 }, panelContract(options)); |
|
138 models.set(this, model); |
|
139 |
|
140 if (model.contentStyle || model.contentStyleFile) { |
|
141 styles.set(this, Style({ |
|
142 uri: model.contentStyleFile, |
|
143 source: model.contentStyle |
|
144 })); |
|
145 } |
|
146 |
|
147 // Setup view |
|
148 let view = domPanel.make(); |
|
149 panels.set(view, this); |
|
150 views.set(this, view); |
|
151 |
|
152 // Load panel content. |
|
153 domPanel.setURL(view, model.contentURL); |
|
154 |
|
155 setupAutoHide(this); |
|
156 |
|
157 // Setup listeners. |
|
158 setListeners(this, options); |
|
159 let worker = new Worker(stripListeners(options)); |
|
160 workers.set(this, worker); |
|
161 |
|
162 // pipe events from worker to a panel. |
|
163 pipe(worker, this); |
|
164 }, |
|
165 dispose: function dispose() { |
|
166 this.hide(); |
|
167 off(this); |
|
168 |
|
169 workerFor(this).destroy(); |
|
170 detach(styleFor(this)); |
|
171 |
|
172 domPanel.dispose(viewFor(this)); |
|
173 |
|
174 // Release circular reference between view and panel instance. This |
|
175 // way view will be GC-ed. And panel as well once all the other refs |
|
176 // will be removed from it. |
|
177 views.delete(this); |
|
178 }, |
|
179 /* Public API: Panel.width */ |
|
180 get width() modelFor(this).width, |
|
181 set width(value) this.resize(value, this.height), |
|
182 /* Public API: Panel.height */ |
|
183 get height() modelFor(this).height, |
|
184 set height(value) this.resize(this.width, value), |
|
185 |
|
186 /* Public API: Panel.focus */ |
|
187 get focus() modelFor(this).focus, |
|
188 |
|
189 /* Public API: Panel.position */ |
|
190 get position() modelFor(this).position, |
|
191 |
|
192 get contentURL() modelFor(this).contentURL, |
|
193 set contentURL(value) { |
|
194 let model = modelFor(this); |
|
195 model.contentURL = panelContract({ contentURL: value }).contentURL; |
|
196 domPanel.setURL(viewFor(this), model.contentURL); |
|
197 // Detach worker so that messages send will be queued until it's |
|
198 // reatached once panel content is ready. |
|
199 workerFor(this).detach(); |
|
200 }, |
|
201 |
|
202 /* Public API: Panel.isShowing */ |
|
203 get isShowing() !isDisposed(this) && domPanel.isOpen(viewFor(this)), |
|
204 |
|
205 /* Public API: Panel.show */ |
|
206 show: function show(options={}, anchor) { |
|
207 if (options instanceof Ci.nsIDOMElement) { |
|
208 [anchor, options] = [options, null]; |
|
209 } |
|
210 |
|
211 if (anchor instanceof Ci.nsIDOMElement) { |
|
212 console.warn( |
|
213 "Passing a DOM node to Panel.show() method is an unsupported " + |
|
214 "feature that will be soon replaced. " + |
|
215 "See: https://bugzilla.mozilla.org/show_bug.cgi?id=878877" |
|
216 ); |
|
217 } |
|
218 |
|
219 let model = modelFor(this); |
|
220 let view = viewFor(this); |
|
221 let anchorView = getNodeView(anchor || options.position || model.position); |
|
222 |
|
223 options = merge({ |
|
224 position: model.position, |
|
225 width: model.width, |
|
226 height: model.height, |
|
227 defaultWidth: model.defaultWidth, |
|
228 defaultHeight: model.defaultHeight, |
|
229 focus: model.focus |
|
230 }, displayContract(options)); |
|
231 |
|
232 if (!isDisposed(this)) |
|
233 domPanel.show(view, options, anchorView); |
|
234 |
|
235 return this; |
|
236 }, |
|
237 |
|
238 /* Public API: Panel.hide */ |
|
239 hide: function hide() { |
|
240 // Quit immediately if panel is disposed or there is no state change. |
|
241 domPanel.close(viewFor(this)); |
|
242 |
|
243 return this; |
|
244 }, |
|
245 |
|
246 /* Public API: Panel.resize */ |
|
247 resize: function resize(width, height) { |
|
248 let model = modelFor(this); |
|
249 let view = viewFor(this); |
|
250 let change = panelContract({ |
|
251 width: width || model.width || model.defaultWidth, |
|
252 height: height || model.height || model.defaultHeight |
|
253 }); |
|
254 |
|
255 model.width = change.width |
|
256 model.height = change.height |
|
257 |
|
258 domPanel.resize(view, model.width, model.height); |
|
259 |
|
260 return this; |
|
261 } |
|
262 }); |
|
263 exports.Panel = Panel; |
|
264 |
|
265 // Note must be defined only after value to `Panel` is assigned. |
|
266 getActiveView.define(Panel, viewFor); |
|
267 |
|
268 // Filter panel events to only panels that are create by this module. |
|
269 let panelEvents = filter(events, ({target}) => panelFor(target)); |
|
270 |
|
271 // Panel events emitted after panel has being shown. |
|
272 let shows = filter(panelEvents, ({type}) => type === "popupshown"); |
|
273 |
|
274 // Panel events emitted after panel became hidden. |
|
275 let hides = filter(panelEvents, ({type}) => type === "popuphidden"); |
|
276 |
|
277 // Panel events emitted after content inside panel is ready. For different |
|
278 // panels ready may mean different state based on `contentScriptWhen` attribute. |
|
279 // Weather given event represents readyness is detected by `getAttachEventType` |
|
280 // helper function. |
|
281 let ready = filter(panelEvents, ({type, target}) => |
|
282 getAttachEventType(modelFor(panelFor(target))) === type); |
|
283 |
|
284 // Styles should be always added as soon as possible, and doesn't makes them |
|
285 // depends on `contentScriptWhen` |
|
286 let start = filter(panelEvents, ({type}) => type === "document-element-inserted"); |
|
287 |
|
288 // Forward panel show / hide events to panel's own event listeners. |
|
289 on(shows, "data", ({target}) => emit(panelFor(target), "show")); |
|
290 |
|
291 on(hides, "data", ({target}) => emit(panelFor(target), "hide")); |
|
292 |
|
293 on(ready, "data", ({target}) => { |
|
294 let panel = panelFor(target); |
|
295 let window = domPanel.getContentDocument(target).defaultView; |
|
296 |
|
297 workerFor(panel).attach(window); |
|
298 }); |
|
299 |
|
300 on(start, "data", ({target}) => { |
|
301 let panel = panelFor(target); |
|
302 let window = domPanel.getContentDocument(target).defaultView; |
|
303 |
|
304 attach(styleFor(panel), window); |
|
305 }); |