Bug 1852740: add tests for the `fetchpriority` attribute in Link headers. r=necko...
[gecko.git] / toolkit / actors / ViewSourcePageChild.sys.mjs
blobd5c2ba46cd94c9aed466a6b588bc5dca726e68c5
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/. */
5 const BUNDLE_URL = "chrome://global/locale/viewSource.properties";
7 // These are markers used to delimit the selection during processing. They
8 // are removed from the final rendering.
9 // We use noncharacter Unicode codepoints to minimize the risk of clashing
10 // with anything that might legitimately be present in the document.
11 // U+FDD0..FDEF <noncharacters>
12 const MARK_SELECTION_START = "\uFDD0";
13 const MARK_SELECTION_END = "\uFDEF";
15 /**
16  * When showing selection source, chrome will construct a page fragment to
17  * show, and then instruct content to draw a selection after load.  This is
18  * set true when there is a pending request to draw selection.
19  */
20 let gNeedsDrawSelection = false;
22 /**
23  * Start at a specific line number.
24  */
25 let gInitialLineNumber = -1;
27 export class ViewSourcePageChild extends JSWindowActorChild {
28   constructor() {
29     super();
31     ChromeUtils.defineLazyGetter(this, "bundle", function () {
32       return Services.strings.createBundle(BUNDLE_URL);
33     });
34   }
36   static setNeedsDrawSelection(value) {
37     gNeedsDrawSelection = value;
38   }
40   static setInitialLineNumber(value) {
41     gInitialLineNumber = value;
42   }
44   receiveMessage(msg) {
45     switch (msg.name) {
46       case "ViewSource:GoToLine":
47         this.goToLine(msg.data.lineNumber);
48         break;
49       case "ViewSource:IsWrapping":
50         return this.isWrapping;
51       case "ViewSource:IsSyntaxHighlighting":
52         return this.isSyntaxHighlighting;
53       case "ViewSource:ToggleWrapping":
54         this.toggleWrapping();
55         break;
56       case "ViewSource:ToggleSyntaxHighlighting":
57         this.toggleSyntaxHighlighting();
58         break;
59     }
60     return undefined;
61   }
63   /**
64    * Any events should get handled here, and should get dispatched to
65    * a specific function for the event type.
66    */
67   handleEvent(event) {
68     switch (event.type) {
69       case "pageshow":
70         this.onPageShow(event);
71         break;
72       case "click":
73         this.onClick(event);
74         break;
75     }
76   }
78   /**
79    * A shortcut to the nsISelectionController for the content.
80    */
81   get selectionController() {
82     return this.docShell
83       .QueryInterface(Ci.nsIInterfaceRequestor)
84       .getInterface(Ci.nsISelectionDisplay)
85       .QueryInterface(Ci.nsISelectionController);
86   }
88   /**
89    * A shortcut to the nsIWebBrowserFind for the content.
90    */
91   get webBrowserFind() {
92     return this.docShell
93       .QueryInterface(Ci.nsIInterfaceRequestor)
94       .getInterface(Ci.nsIWebBrowserFind);
95   }
97   /**
98    * This handler is for click events from:
99    *   * error page content, which can show up if the user attempts to view the
100    *     source of an attack page.
101    */
102   onClick(event) {
103     let target = event.originalTarget;
105     // Don't trust synthetic events
106     if (!event.isTrusted || event.target.localName != "button") {
107       return;
108     }
110     let errorDoc = target.ownerDocument;
112     if (/^about:blocked/.test(errorDoc.documentURI)) {
113       // The event came from a button on a malware/phishing block page
115       if (target == errorDoc.getElementById("goBackButton")) {
116         // Instead of loading some safe page, just close the window
117         this.sendAsyncMessage("ViewSource:Close");
118       }
119     }
120   }
122   /**
123    * Handler for the pageshow event.
124    *
125    * @param event
126    *        The pageshow event being handled.
127    */
128   onPageShow(event) {
129     // If we need to draw the selection, wait until an actual view source page
130     // has loaded, instead of about:blank.
131     if (
132       gNeedsDrawSelection &&
133       this.document.documentURI.startsWith("view-source:")
134     ) {
135       gNeedsDrawSelection = false;
136       this.drawSelection();
137     }
139     if (gInitialLineNumber >= 0) {
140       this.goToLine(gInitialLineNumber);
141       gInitialLineNumber = -1;
142     }
143   }
145   /**
146    * Attempts to go to a particular line in the source code being
147    * shown. If it succeeds in finding the line, it will fire a
148    * "ViewSource:GoToLine:Success" message, passing up an object
149    * with the lineNumber we just went to. If it cannot find the line,
150    * it will fire a "ViewSource:GoToLine:Failed" message.
151    *
152    * @param lineNumber
153    *        The line number to attempt to go to.
154    */
155   goToLine(lineNumber) {
156     let body = this.document.body;
158     // The source document is made up of a number of pre elements with
159     // id attributes in the format <pre id="line123">, meaning that
160     // the first line in the pre element is number 123.
161     // Do binary search to find the pre element containing the line.
162     // However, in the plain text case, we have only one pre without an
163     // attribute, so assume it begins on line 1.
164     let pre;
165     for (let lbound = 0, ubound = body.childNodes.length; ; ) {
166       let middle = (lbound + ubound) >> 1;
167       pre = body.childNodes[middle];
169       let firstLine = pre.id ? parseInt(pre.id.substring(4)) : 1;
171       if (lbound == ubound - 1) {
172         break;
173       }
175       if (lineNumber >= firstLine) {
176         lbound = middle;
177       } else {
178         ubound = middle;
179       }
180     }
182     let result = {};
183     let found = this.findLocation(pre, lineNumber, null, -1, false, result);
185     if (!found) {
186       this.sendAsyncMessage("ViewSource:GoToLine:Failed");
187       return;
188     }
190     let selection = this.document.defaultView.getSelection();
191     selection.removeAllRanges();
193     // In our case, the range's startOffset is after "\n" on the previous line.
194     // Tune the selection at the beginning of the next line and do some tweaking
195     // to position the focusNode and the caret at the beginning of the line.
196     selection.interlinePosition = true;
198     selection.addRange(result.range);
200     if (!selection.isCollapsed) {
201       selection.collapseToEnd();
203       let offset = result.range.startOffset;
204       let node = result.range.startContainer;
205       if (offset < node.data.length) {
206         // The same text node spans across the "\n", just focus where we were.
207         selection.extend(node, offset);
208       } else {
209         // There is another tag just after the "\n", hook there. We need
210         // to focus a safe point because there are edgy cases such as
211         // <span>...\n</span><span>...</span> vs.
212         // <span>...\n<span>...</span></span><span>...</span>
213         node = node.nextSibling
214           ? node.nextSibling
215           : node.parentNode.nextSibling;
216         selection.extend(node, 0);
217       }
218     }
220     let selCon = this.selectionController;
221     selCon.setDisplaySelection(Ci.nsISelectionController.SELECTION_ON);
222     selCon.setCaretVisibilityDuringSelection(true);
224     // Scroll the beginning of the line into view.
225     selCon.scrollSelectionIntoView(
226       Ci.nsISelectionController.SELECTION_NORMAL,
227       Ci.nsISelectionController.SELECTION_FOCUS_REGION,
228       true
229     );
231     this.sendAsyncMessage("ViewSource:GoToLine:Success", { lineNumber });
232   }
234   /**
235    * Some old code from the original view source implementation. Original
236    * documentation follows:
237    *
238    * "Loops through the text lines in the pre element. The arguments are either
239    *  (pre, line) or (node, offset, interlinePosition). result is an out
240    *  argument. If (pre, line) are specified (and node == null), result.range is
241    *  a range spanning the specified line. If the (node, offset,
242    *  interlinePosition) are specified, result.line and result.col are the line
243    *  and column number of the specified offset in the specified node relative to
244    *  the whole file."
245    */
246   findLocation(pre, lineNumber, node, offset, interlinePosition, result) {
247     if (node && !pre) {
248       // Look upwards to find the current pre element.
249       // eslint-disable-next-line no-empty
250       for (pre = node; pre.nodeName != "PRE"; pre = pre.parentNode) {}
251     }
253     // The source document is made up of a number of pre elements with
254     // id attributes in the format <pre id="line123">, meaning that
255     // the first line in the pre element is number 123.
256     // However, in the plain text case, there is only one <pre> without an id,
257     // so assume line 1.
258     let curLine = pre.id ? parseInt(pre.id.substring(4)) : 1;
260     // Walk through each of the text nodes and count newlines.
261     let treewalker = this.document.createTreeWalker(
262       pre,
263       NodeFilter.SHOW_TEXT,
264       null
265     );
267     // The column number of the first character in the current text node.
268     let firstCol = 1;
270     let found = false;
271     for (
272       let textNode = treewalker.firstChild();
273       textNode && !found;
274       textNode = treewalker.nextNode()
275     ) {
276       // \r is not a valid character in the DOM, so we only check for \n.
277       let lineArray = textNode.data.split(/\n/);
278       let lastLineInNode = curLine + lineArray.length - 1;
280       // Check if we can skip the text node without further inspection.
281       if (node ? textNode != node : lastLineInNode < lineNumber) {
282         if (lineArray.length > 1) {
283           firstCol = 1;
284         }
285         firstCol += lineArray[lineArray.length - 1].length;
286         curLine = lastLineInNode;
287         continue;
288       }
290       // curPos is the offset within the current text node of the first
291       // character in the current line.
292       for (
293         var i = 0, curPos = 0;
294         i < lineArray.length;
295         curPos += lineArray[i++].length + 1
296       ) {
297         if (i > 0) {
298           curLine++;
299         }
301         if (node) {
302           if (offset >= curPos && offset <= curPos + lineArray[i].length) {
303             // If we are right after the \n of a line and interlinePosition is
304             // false, the caret looks as if it were at the end of the previous
305             // line, so we display that line and column instead.
307             if (i > 0 && offset == curPos && !interlinePosition) {
308               result.line = curLine - 1;
309               var prevPos = curPos - lineArray[i - 1].length;
310               result.col = (i == 1 ? firstCol : 1) + offset - prevPos;
311             } else {
312               result.line = curLine;
313               result.col = (i == 0 ? firstCol : 1) + offset - curPos;
314             }
315             found = true;
317             break;
318           }
319         } else if (curLine == lineNumber && !("range" in result)) {
320           result.range = this.document.createRange();
321           result.range.setStart(textNode, curPos);
323           // This will always be overridden later, except when we look for
324           // the very last line in the file (this is the only line that does
325           // not end with \n).
326           result.range.setEndAfter(pre.lastChild);
327         } else if (curLine == lineNumber + 1) {
328           result.range.setEnd(textNode, curPos - 1);
329           found = true;
330           break;
331         }
332       }
333     }
335     return found || "range" in result;
336   }
338   /**
339    * @return {boolean} whether the "wrap" class exists on the document body.
340    */
341   get isWrapping() {
342     return this.document.body.classList.contains("wrap");
343   }
345   /**
346    * @return {boolean} whether the "highlight" class exists on the document body.
347    */
348   get isSyntaxHighlighting() {
349     return this.document.body.classList.contains("highlight");
350   }
352   /**
353    * Toggles the "wrap" class on the document body, which sets whether
354    * or not long lines are wrapped.  Notifies parent to update the pref.
355    */
356   toggleWrapping() {
357     let body = this.document.body;
358     let state = body.classList.toggle("wrap");
359     this.sendAsyncMessage("ViewSource:StoreWrapping", { state });
360   }
362   /**
363    * Toggles the "highlight" class on the document body, which sets whether
364    * or not syntax highlighting is displayed.  Notifies parent to update the
365    * pref.
366    */
367   toggleSyntaxHighlighting() {
368     let body = this.document.body;
369     let state = body.classList.toggle("highlight");
370     this.sendAsyncMessage("ViewSource:StoreSyntaxHighlighting", { state });
371   }
373   /**
374    * Using special markers left in the serialized source, this helper makes the
375    * underlying markup of the selected fragment to automatically appear as
376    * selected on the inflated view-source DOM.
377    */
378   drawSelection() {
379     this.document.title = this.bundle.GetStringFromName(
380       "viewSelectionSourceTitle"
381     );
383     // find the special selection markers that we added earlier, and
384     // draw the selection between the two...
385     var findService = null;
386     try {
387       // get the find service which stores the global find state
388       findService = Cc["@mozilla.org/find/find_service;1"].getService(
389         Ci.nsIFindService
390       );
391     } catch (e) {}
392     if (!findService) {
393       return;
394     }
396     // cache the current global find state
397     var matchCase = findService.matchCase;
398     var entireWord = findService.entireWord;
399     var wrapFind = findService.wrapFind;
400     var findBackwards = findService.findBackwards;
401     var searchString = findService.searchString;
402     var replaceString = findService.replaceString;
404     // setup our find instance
405     var findInst = this.webBrowserFind;
406     findInst.matchCase = true;
407     findInst.entireWord = false;
408     findInst.wrapFind = true;
409     findInst.findBackwards = false;
411     // ...lookup the start mark
412     findInst.searchString = MARK_SELECTION_START;
413     var startLength = MARK_SELECTION_START.length;
414     findInst.findNext();
416     var selection = this.document.defaultView.getSelection();
417     if (!selection.rangeCount) {
418       return;
419     }
421     var range = selection.getRangeAt(0);
423     var startContainer = range.startContainer;
424     var startOffset = range.startOffset;
426     // ...lookup the end mark
427     findInst.searchString = MARK_SELECTION_END;
428     var endLength = MARK_SELECTION_END.length;
429     findInst.findNext();
431     var endContainer = selection.anchorNode;
432     var endOffset = selection.anchorOffset;
434     // reset the selection that find has left
435     selection.removeAllRanges();
437     // delete the special markers now...
438     endContainer.deleteData(endOffset, endLength);
439     startContainer.deleteData(startOffset, startLength);
440     if (startContainer == endContainer) {
441       endOffset -= startLength;
442     } // has shrunk if on same text node...
443     range.setEnd(endContainer, endOffset);
445     // show the selection and scroll it into view
446     selection.addRange(range);
447     // the default behavior of the selection is to scroll at the end of
448     // the selection, whereas in this situation, it is more user-friendly
449     // to scroll at the beginning. So we override the default behavior here
450     try {
451       this.selectionController.scrollSelectionIntoView(
452         Ci.nsISelectionController.SELECTION_NORMAL,
453         Ci.nsISelectionController.SELECTION_ANCHOR_REGION,
454         true
455       );
456     } catch (e) {}
458     // restore the current find state
459     findService.matchCase = matchCase;
460     findService.entireWord = entireWord;
461     findService.wrapFind = wrapFind;
462     findService.findBackwards = findBackwards;
463     findService.searchString = searchString;
464     findService.replaceString = replaceString;
466     findInst.matchCase = matchCase;
467     findInst.entireWord = entireWord;
468     findInst.wrapFind = wrapFind;
469     findInst.findBackwards = findBackwards;
470     findInst.searchString = searchString;
471   }