toolkit/devtools/server/actors/styleeditor.js

branch
TOR_BUG_3246
changeset 7
129ffea94266
equal deleted inserted replaced
-1:000000000000 0:0b18778ced8f
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
5 "use strict";
6
7 let { components, Cc, Ci, Cu } = require("chrome");
8 let Services = require("Services");
9
10 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
11 Cu.import("resource://gre/modules/NetUtil.jsm");
12 Cu.import("resource://gre/modules/FileUtils.jsm");
13 Cu.import("resource://gre/modules/devtools/SourceMap.jsm");
14
15 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
16 const events = require("sdk/event/core");
17 const protocol = require("devtools/server/protocol");
18 const {Arg, Option, method, RetVal, types} = protocol;
19 const {LongStringActor, ShortLongString} = require("devtools/server/actors/string");
20
21 loader.lazyGetter(this, "CssLogic", () => require("devtools/styleinspector/css-logic").CssLogic);
22
23 let TRANSITION_CLASS = "moz-styleeditor-transitioning";
24 let TRANSITION_DURATION_MS = 500;
25 let TRANSITION_RULE = "\
26 :root.moz-styleeditor-transitioning, :root.moz-styleeditor-transitioning * {\
27 transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \
28 transition-delay: 0ms !important;\
29 transition-timing-function: ease-out !important;\
30 transition-property: all !important;\
31 }";
32
33 let LOAD_ERROR = "error-load";
34
35 exports.register = function(handle) {
36 handle.addTabActor(StyleEditorActor, "styleEditorActor");
37 handle.addGlobalActor(StyleEditorActor, "styleEditorActor");
38 };
39
40 exports.unregister = function(handle) {
41 handle.removeTabActor(StyleEditorActor);
42 handle.removeGlobalActor(StyleEditorActor);
43 };
44
45 types.addActorType("old-stylesheet");
46
47 /**
48 * Creates a StyleEditorActor. StyleEditorActor provides remote access to the
49 * stylesheets of a document.
50 */
51 let StyleEditorActor = protocol.ActorClass({
52 typeName: "styleeditor",
53
54 /**
55 * The window we work with, taken from the parent actor.
56 */
57 get window() this.parentActor.window,
58
59 /**
60 * The current content document of the window we work with.
61 */
62 get document() this.window.document,
63
64 events: {
65 "document-load" : {
66 type: "documentLoad",
67 styleSheets: Arg(0, "array:old-stylesheet")
68 }
69 },
70
71 form: function()
72 {
73 return { actor: this.actorID };
74 },
75
76 initialize: function (conn, tabActor) {
77 protocol.Actor.prototype.initialize.call(this, null);
78
79 this.parentActor = tabActor;
80
81 // keep a map of sheets-to-actors so we don't create two actors for one sheet
82 this._sheets = new Map();
83 },
84
85 /**
86 * Destroy the current StyleEditorActor instance.
87 */
88 destroy: function()
89 {
90 this._sheets.clear();
91 },
92
93 /**
94 * Called by client when target navigates to a new document.
95 * Adds load listeners to document.
96 */
97 newDocument: method(function() {
98 // delete previous document's actors
99 this._clearStyleSheetActors();
100
101 // Note: listening for load won't be necessary once
102 // https://bugzilla.mozilla.org/show_bug.cgi?id=839103 is fixed
103 if (this.document.readyState == "complete") {
104 this._onDocumentLoaded();
105 }
106 else {
107 this.window.addEventListener("load", this._onDocumentLoaded, false);
108 }
109 return {};
110 }),
111
112 /**
113 * Event handler for document loaded event. Add actor for each stylesheet
114 * and send an event notifying of the load
115 */
116 _onDocumentLoaded: function(event) {
117 if (event) {
118 this.window.removeEventListener("load", this._onDocumentLoaded, false);
119 }
120
121 let documents = [this.document];
122 var forms = [];
123 for (let doc of documents) {
124 let sheetForms = this._addStyleSheets(doc.styleSheets);
125 forms = forms.concat(sheetForms);
126 // Recursively handle style sheets of the documents in iframes.
127 for (let iframe of doc.getElementsByTagName("iframe")) {
128 documents.push(iframe.contentDocument);
129 }
130 }
131
132 events.emit(this, "document-load", forms);
133 },
134
135 /**
136 * Add all the stylesheets to the map and create an actor for each one
137 * if not already created. Send event that there are new stylesheets.
138 *
139 * @param {[DOMStyleSheet]} styleSheets
140 * Stylesheets to add
141 * @return {[object]}
142 * Array of actors for each StyleSheetActor created
143 */
144 _addStyleSheets: function(styleSheets)
145 {
146 let sheets = [];
147 for (let i = 0; i < styleSheets.length; i++) {
148 let styleSheet = styleSheets[i];
149 sheets.push(styleSheet);
150
151 // Get all sheets, including imported ones
152 let imports = this._getImported(styleSheet);
153 sheets = sheets.concat(imports);
154 }
155 let actors = sheets.map(this._createStyleSheetActor.bind(this));
156
157 return actors;
158 },
159
160 /**
161 * Create a new actor for a style sheet, if it hasn't already been created.
162 *
163 * @param {DOMStyleSheet} styleSheet
164 * The style sheet to create an actor for.
165 * @return {StyleSheetActor}
166 * The actor for this style sheet
167 */
168 _createStyleSheetActor: function(styleSheet)
169 {
170 if (this._sheets.has(styleSheet)) {
171 return this._sheets.get(styleSheet);
172 }
173 let actor = new OldStyleSheetActor(styleSheet, this);
174
175 this.manage(actor);
176 this._sheets.set(styleSheet, actor);
177
178 return actor;
179 },
180
181 /**
182 * Get all the stylesheets @imported from a stylesheet.
183 *
184 * @param {DOMStyleSheet} styleSheet
185 * Style sheet to search
186 * @return {array}
187 * All the imported stylesheets
188 */
189 _getImported: function(styleSheet) {
190 let imported = [];
191
192 for (let i = 0; i < styleSheet.cssRules.length; i++) {
193 let rule = styleSheet.cssRules[i];
194 if (rule.type == Ci.nsIDOMCSSRule.IMPORT_RULE) {
195 // Associated styleSheet may be null if it has already been seen due to
196 // duplicate @imports for the same URL.
197 if (!rule.styleSheet) {
198 continue;
199 }
200 imported.push(rule.styleSheet);
201
202 // recurse imports in this stylesheet as well
203 imported = imported.concat(this._getImported(rule.styleSheet));
204 }
205 else if (rule.type != Ci.nsIDOMCSSRule.CHARSET_RULE) {
206 // @import rules must precede all others except @charset
207 break;
208 }
209 }
210 return imported;
211 },
212
213 /**
214 * Clear all the current stylesheet actors in map.
215 */
216 _clearStyleSheetActors: function() {
217 for (let actor in this._sheets) {
218 this.unmanage(this._sheets[actor]);
219 }
220 this._sheets.clear();
221 },
222
223 /**
224 * Create a new style sheet in the document with the given text.
225 * Return an actor for it.
226 *
227 * @param {object} request
228 * Debugging protocol request object, with 'text property'
229 * @return {object}
230 * Object with 'styelSheet' property for form on new actor.
231 */
232 newStyleSheet: method(function(text) {
233 let parent = this.document.documentElement;
234 let style = this.document.createElementNS("http://www.w3.org/1999/xhtml", "style");
235 style.setAttribute("type", "text/css");
236
237 if (text) {
238 style.appendChild(this.document.createTextNode(text));
239 }
240 parent.appendChild(style);
241
242 let actor = this._createStyleSheetActor(style.sheet);
243 return actor;
244 }, {
245 request: { text: Arg(0, "string") },
246 response: { styleSheet: RetVal("old-stylesheet") }
247 })
248 });
249
250 /**
251 * The corresponding Front object for the StyleEditorActor.
252 */
253 let StyleEditorFront = protocol.FrontClass(StyleEditorActor, {
254 initialize: function(client, tabForm) {
255 protocol.Front.prototype.initialize.call(this, client);
256 this.actorID = tabForm.styleEditorActor;
257
258 client.addActorPool(this);
259 this.manage(this);
260 },
261
262 getStyleSheets: function() {
263 let deferred = promise.defer();
264
265 events.once(this, "document-load", (styleSheets) => {
266 deferred.resolve(styleSheets);
267 });
268 this.newDocument();
269
270 return deferred.promise;
271 },
272
273 addStyleSheet: function(text) {
274 return this.newStyleSheet(text);
275 }
276 });
277
278 /**
279 * A StyleSheetActor represents a stylesheet on the server.
280 */
281 let OldStyleSheetActor = protocol.ActorClass({
282 typeName: "old-stylesheet",
283
284 events: {
285 "property-change" : {
286 type: "propertyChange",
287 property: Arg(0, "string"),
288 value: Arg(1, "json")
289 },
290 "source-load" : {
291 type: "sourceLoad",
292 source: Arg(0, "string")
293 },
294 "style-applied" : {
295 type: "styleApplied"
296 }
297 },
298
299 toString: function() {
300 return "[OldStyleSheetActor " + this.actorID + "]";
301 },
302
303 /**
304 * Window of target
305 */
306 get window() this._window || this.parentActor.window,
307
308 /**
309 * Document of target.
310 */
311 get document() this.window.document,
312
313 /**
314 * URL of underlying stylesheet.
315 */
316 get href() this.rawSheet.href,
317
318 /**
319 * Retrieve the index (order) of stylesheet in the document.
320 *
321 * @return number
322 */
323 get styleSheetIndex()
324 {
325 if (this._styleSheetIndex == -1) {
326 for (let i = 0; i < this.document.styleSheets.length; i++) {
327 if (this.document.styleSheets[i] == this.rawSheet) {
328 this._styleSheetIndex = i;
329 break;
330 }
331 }
332 }
333 return this._styleSheetIndex;
334 },
335
336 initialize: function(aStyleSheet, aParentActor, aWindow) {
337 protocol.Actor.prototype.initialize.call(this, null);
338
339 this.rawSheet = aStyleSheet;
340 this.parentActor = aParentActor;
341 this.conn = this.parentActor.conn;
342
343 this._window = aWindow;
344
345 // text and index are unknown until source load
346 this.text = null;
347 this._styleSheetIndex = -1;
348
349 this._transitionRefCount = 0;
350
351 // if this sheet has an @import, then it's rules are loaded async
352 let ownerNode = this.rawSheet.ownerNode;
353 if (ownerNode) {
354 let onSheetLoaded = function(event) {
355 ownerNode.removeEventListener("load", onSheetLoaded, false);
356 this._notifyPropertyChanged("ruleCount");
357 }.bind(this);
358
359 ownerNode.addEventListener("load", onSheetLoaded, false);
360 }
361 },
362
363 /**
364 * Get the current state of the actor
365 *
366 * @return {object}
367 * With properties of the underlying stylesheet, plus 'text',
368 * 'styleSheetIndex' and 'parentActor' if it's @imported
369 */
370 form: function(detail) {
371 if (detail === "actorid") {
372 return this.actorID;
373 }
374
375 let docHref;
376 if (this.rawSheet.ownerNode) {
377 if (this.rawSheet.ownerNode instanceof Ci.nsIDOMHTMLDocument) {
378 docHref = this.rawSheet.ownerNode.location.href;
379 }
380 if (this.rawSheet.ownerNode.ownerDocument) {
381 docHref = this.rawSheet.ownerNode.ownerDocument.location.href;
382 }
383 }
384
385 let form = {
386 actor: this.actorID, // actorID is set when this actor is added to a pool
387 href: this.href,
388 nodeHref: docHref,
389 disabled: this.rawSheet.disabled,
390 title: this.rawSheet.title,
391 system: !CssLogic.isContentStylesheet(this.rawSheet),
392 styleSheetIndex: this.styleSheetIndex
393 }
394
395 try {
396 form.ruleCount = this.rawSheet.cssRules.length;
397 }
398 catch(e) {
399 // stylesheet had an @import rule that wasn't loaded yet
400 }
401 return form;
402 },
403
404 /**
405 * Toggle the disabled property of the style sheet
406 *
407 * @return {object}
408 * 'disabled' - the disabled state after toggling.
409 */
410 toggleDisabled: method(function() {
411 this.rawSheet.disabled = !this.rawSheet.disabled;
412 this._notifyPropertyChanged("disabled");
413
414 return this.rawSheet.disabled;
415 }, {
416 response: { disabled: RetVal("boolean")}
417 }),
418
419 /**
420 * Send an event notifying that a property of the stylesheet
421 * has changed.
422 *
423 * @param {string} property
424 * Name of the changed property
425 */
426 _notifyPropertyChanged: function(property) {
427 events.emit(this, "property-change", property, this.form()[property]);
428 },
429
430 /**
431 * Fetch the source of the style sheet from its URL. Send a "sourceLoad"
432 * event when it's been fetched.
433 */
434 fetchSource: method(function() {
435 this._getText().then((content) => {
436 events.emit(this, "source-load", this.text);
437 });
438 }),
439
440 /**
441 * Fetch the text for this stylesheet from the cache or network. Return
442 * cached text if it's already been fetched.
443 *
444 * @return {Promise}
445 * Promise that resolves with a string text of the stylesheet.
446 */
447 _getText: function() {
448 if (this.text) {
449 return promise.resolve(this.text);
450 }
451
452 if (!this.href) {
453 // this is an inline <style> sheet
454 let content = this.rawSheet.ownerNode.textContent;
455 this.text = content;
456 return promise.resolve(content);
457 }
458
459 let options = {
460 window: this.window,
461 charset: this._getCSSCharset()
462 };
463
464 return fetch(this.href, options).then(({ content }) => {
465 this.text = content;
466 return content;
467 });
468 },
469
470 /**
471 * Get the charset of the stylesheet according to the character set rules
472 * defined in <http://www.w3.org/TR/CSS2/syndata.html#charset>.
473 *
474 * @param string channelCharset
475 * Charset of the source string if set by the HTTP channel.
476 */
477 _getCSSCharset: function(channelCharset)
478 {
479 // StyleSheet's charset can be specified from multiple sources
480 if (channelCharset && channelCharset.length > 0) {
481 // step 1 of syndata.html: charset given in HTTP header.
482 return channelCharset;
483 }
484
485 let sheet = this.rawSheet;
486 if (sheet) {
487 // Do we have a @charset rule in the stylesheet?
488 // step 2 of syndata.html (without the BOM check).
489 if (sheet.cssRules) {
490 let rules = sheet.cssRules;
491 if (rules.length
492 && rules.item(0).type == Ci.nsIDOMCSSRule.CHARSET_RULE) {
493 return rules.item(0).encoding;
494 }
495 }
496
497 // step 3: charset attribute of <link> or <style> element, if it exists
498 if (sheet.ownerNode && sheet.ownerNode.getAttribute) {
499 let linkCharset = sheet.ownerNode.getAttribute("charset");
500 if (linkCharset != null) {
501 return linkCharset;
502 }
503 }
504
505 // step 4 (1 of 2): charset of referring stylesheet.
506 let parentSheet = sheet.parentStyleSheet;
507 if (parentSheet && parentSheet.cssRules &&
508 parentSheet.cssRules[0].type == Ci.nsIDOMCSSRule.CHARSET_RULE) {
509 return parentSheet.cssRules[0].encoding;
510 }
511
512 // step 4 (2 of 2): charset of referring document.
513 if (sheet.ownerNode && sheet.ownerNode.ownerDocument.characterSet) {
514 return sheet.ownerNode.ownerDocument.characterSet;
515 }
516 }
517
518 // step 5: default to utf-8.
519 return "UTF-8";
520 },
521
522 /**
523 * Update the style sheet in place with new text.
524 *
525 * @param {object} request
526 * 'text' - new text
527 * 'transition' - whether to do CSS transition for change.
528 */
529 update: method(function(text, transition) {
530 DOMUtils.parseStyleSheet(this.rawSheet, text);
531
532 this.text = text;
533
534 this._notifyPropertyChanged("ruleCount");
535
536 if (transition) {
537 this._insertTransistionRule();
538 }
539 else {
540 this._notifyStyleApplied();
541 }
542 }, {
543 request: {
544 text: Arg(0, "string"),
545 transition: Arg(1, "boolean")
546 }
547 }),
548
549 /**
550 * Insert a catch-all transition rule into the document. Set a timeout
551 * to remove the rule after a certain time.
552 */
553 _insertTransistionRule: function() {
554 // Insert the global transition rule
555 // Use a ref count to make sure we do not add it multiple times.. and remove
556 // it only when all pending StyleEditor-generated transitions ended.
557 if (this._transitionRefCount == 0) {
558 this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length);
559 this.document.documentElement.classList.add(TRANSITION_CLASS);
560 }
561
562 this._transitionRefCount++;
563
564 // Set up clean up and commit after transition duration (+10% buffer)
565 // @see _onTransitionEnd
566 this.window.setTimeout(this._onTransitionEnd.bind(this),
567 Math.floor(TRANSITION_DURATION_MS * 1.1));
568 },
569
570 /**
571 * This cleans up class and rule added for transition effect and then
572 * notifies that the style has been applied.
573 */
574 _onTransitionEnd: function()
575 {
576 if (--this._transitionRefCount == 0) {
577 this.document.documentElement.classList.remove(TRANSITION_CLASS);
578 this.rawSheet.deleteRule(this.rawSheet.cssRules.length - 1);
579 }
580
581 events.emit(this, "style-applied");
582 }
583 })
584
585 /**
586 * StyleSheetFront is the client-side counterpart to a StyleSheetActor.
587 */
588 var OldStyleSheetFront = protocol.FrontClass(OldStyleSheetActor, {
589 initialize: function(conn, form, ctx, detail) {
590 protocol.Front.prototype.initialize.call(this, conn, form, ctx, detail);
591
592 this._onPropertyChange = this._onPropertyChange.bind(this);
593 events.on(this, "property-change", this._onPropertyChange);
594 },
595
596 destroy: function() {
597 events.off(this, "property-change", this._onPropertyChange);
598
599 protocol.Front.prototype.destroy.call(this);
600 },
601
602 _onPropertyChange: function(property, value) {
603 this._form[property] = value;
604 },
605
606 form: function(form, detail) {
607 if (detail === "actorid") {
608 this.actorID = form;
609 return;
610 }
611 this.actorID = form.actor;
612 this._form = form;
613 },
614
615 getText: function() {
616 let deferred = promise.defer();
617
618 events.once(this, "source-load", (source) => {
619 let longStr = new ShortLongString(source);
620 deferred.resolve(longStr);
621 });
622 this.fetchSource();
623
624 return deferred.promise;
625 },
626
627 getOriginalSources: function() {
628 return promise.resolve([]);
629 },
630
631 get href() this._form.href,
632 get nodeHref() this._form.nodeHref,
633 get disabled() !!this._form.disabled,
634 get title() this._form.title,
635 get isSystem() this._form.system,
636 get styleSheetIndex() this._form.styleSheetIndex,
637 get ruleCount() this._form.ruleCount
638 });
639
640 XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () {
641 return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
642 });
643
644 exports.StyleEditorActor = StyleEditorActor;
645 exports.StyleEditorFront = StyleEditorFront;
646
647 exports.OldStyleSheetActor = OldStyleSheetActor;
648 exports.OldStyleSheetFront = OldStyleSheetFront;
649
650
651 /**
652 * Performs a request to load the desired URL and returns a promise.
653 *
654 * @param aURL String
655 * The URL we will request.
656 * @returns Promise
657 * A promise of the document at that URL, as a string.
658 */
659 function fetch(aURL, aOptions={ loadFromCache: true, window: null,
660 charset: null}) {
661 let deferred = promise.defer();
662 let scheme;
663 let url = aURL.split(" -> ").pop();
664 let charset;
665 let contentType;
666
667 try {
668 scheme = Services.io.extractScheme(url);
669 } catch (e) {
670 // In the xpcshell tests, the script url is the absolute path of the test
671 // file, which will make a malformed URI error be thrown. Add the file
672 // scheme prefix ourselves.
673 url = "file://" + url;
674 scheme = Services.io.extractScheme(url);
675 }
676
677 switch (scheme) {
678 case "file":
679 case "chrome":
680 case "resource":
681 try {
682 NetUtil.asyncFetch(url, function onFetch(aStream, aStatus, aRequest) {
683 if (!components.isSuccessCode(aStatus)) {
684 deferred.reject(new Error("Request failed with status code = "
685 + aStatus
686 + " after NetUtil.asyncFetch for url = "
687 + url));
688 return;
689 }
690
691 let source = NetUtil.readInputStreamToString(aStream, aStream.available());
692 contentType = aRequest.contentType;
693 deferred.resolve(source);
694 aStream.close();
695 });
696 } catch (ex) {
697 deferred.reject(ex);
698 }
699 break;
700
701 default:
702 let channel;
703 try {
704 channel = Services.io.newChannel(url, null, null);
705 } catch (e if e.name == "NS_ERROR_UNKNOWN_PROTOCOL") {
706 // On Windows xpcshell tests, c:/foo/bar can pass as a valid URL, but
707 // newChannel won't be able to handle it.
708 url = "file:///" + url;
709 channel = Services.io.newChannel(url, null, null);
710 }
711 let chunks = [];
712 let streamListener = {
713 onStartRequest: function(aRequest, aContext, aStatusCode) {
714 if (!components.isSuccessCode(aStatusCode)) {
715 deferred.reject(new Error("Request failed with status code = "
716 + aStatusCode
717 + " in onStartRequest handler for url = "
718 + url));
719 }
720 },
721 onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
722 chunks.push(NetUtil.readInputStreamToString(aStream, aCount));
723 },
724 onStopRequest: function(aRequest, aContext, aStatusCode) {
725 if (!components.isSuccessCode(aStatusCode)) {
726 deferred.reject(new Error("Request failed with status code = "
727 + aStatusCode
728 + " in onStopRequest handler for url = "
729 + url));
730 return;
731 }
732
733 charset = channel.contentCharset || charset;
734 contentType = channel.contentType;
735 deferred.resolve(chunks.join(""));
736 }
737 };
738
739 if (aOptions.window) {
740 // respect private browsing
741 channel.loadGroup = aOptions.window.QueryInterface(Ci.nsIInterfaceRequestor)
742 .getInterface(Ci.nsIWebNavigation)
743 .QueryInterface(Ci.nsIDocumentLoader)
744 .loadGroup;
745 }
746 channel.loadFlags = aOptions.loadFromCache
747 ? channel.LOAD_FROM_CACHE
748 : channel.LOAD_BYPASS_CACHE;
749 channel.asyncOpen(streamListener, null);
750 break;
751 }
752
753 return deferred.promise.then(source => {
754 return {
755 content: convertToUnicode(source, charset),
756 contentType: contentType
757 };
758 });
759 }
760
761 /**
762 * Convert a given string, encoded in a given character set, to unicode.
763 *
764 * @param string aString
765 * A string.
766 * @param string aCharset
767 * A character set.
768 */
769 function convertToUnicode(aString, aCharset=null) {
770 // Decoding primitives.
771 let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
772 .createInstance(Ci.nsIScriptableUnicodeConverter);
773 try {
774 converter.charset = aCharset || "UTF-8";
775 return converter.ConvertToUnicode(aString);
776 } catch(e) {
777 return aString;
778 }
779 }
780
781 /**
782 * Normalize multiple relative paths towards the base paths on the right.
783 */
784 function normalize(...aURLs) {
785 let base = Services.io.newURI(aURLs.pop(), null, null);
786 let url;
787 while ((url = aURLs.pop())) {
788 base = Services.io.newURI(url, null, base);
789 }
790 return base.spec;
791 }
792
793 function dirname(aPath) {
794 return Services.io.newURI(
795 ".", null, Services.io.newURI(aPath, null, null)).spec;
796 }

mercurial