зеркало из https://github.com/mozilla/gecko-dev.git
558 строки
17 KiB
JavaScript
558 строки
17 KiB
JavaScript
/* 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/. */
|
|
|
|
/* eslint-env mozilla/frame-script */
|
|
|
|
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
|
|
var EXPORTED_SYMBOLS = ["ViewSourcePageChild"];
|
|
|
|
XPCOMUtils.defineLazyGlobalGetters(this, ["NodeFilter"]);
|
|
|
|
const NS_XHTML = "http://www.w3.org/1999/xhtml";
|
|
const BUNDLE_URL = "chrome://global/locale/viewSource.properties";
|
|
|
|
// These are markers used to delimit the selection during processing. They
|
|
// are removed from the final rendering.
|
|
// We use noncharacter Unicode codepoints to minimize the risk of clashing
|
|
// with anything that might legitimately be present in the document.
|
|
// U+FDD0..FDEF <noncharacters>
|
|
const MARK_SELECTION_START = "\uFDD0";
|
|
const MARK_SELECTION_END = "\uFDEF";
|
|
|
|
/**
|
|
* When showing selection source, chrome will construct a page fragment to
|
|
* show, and then instruct content to draw a selection after load. This is
|
|
* set true when there is a pending request to draw selection.
|
|
*/
|
|
let gNeedsDrawSelection = false;
|
|
|
|
/**
|
|
* Start at a specific line number.
|
|
*/
|
|
let gInitialLineNumber = -1;
|
|
|
|
/**
|
|
* In-page context menu items that are injected after page load.
|
|
*/
|
|
let gContextMenuItems = [
|
|
{
|
|
id: "goToLine",
|
|
accesskey: true,
|
|
handler(actor) {
|
|
actor.sendAsyncMessage("ViewSource:PromptAndGoToLine");
|
|
},
|
|
},
|
|
{
|
|
id: "wrapLongLines",
|
|
get checked() {
|
|
return Services.prefs.getBoolPref("view_source.wrap_long_lines");
|
|
},
|
|
handler(actor) {
|
|
actor.toggleWrapping();
|
|
},
|
|
},
|
|
{
|
|
id: "highlightSyntax",
|
|
get checked() {
|
|
return Services.prefs.getBoolPref("view_source.syntax_highlight");
|
|
},
|
|
handler(actor) {
|
|
actor.toggleSyntaxHighlighting();
|
|
},
|
|
},
|
|
];
|
|
|
|
class ViewSourcePageChild extends JSWindowActorChild {
|
|
constructor() {
|
|
super();
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "bundle", function() {
|
|
return Services.strings.createBundle(BUNDLE_URL);
|
|
});
|
|
}
|
|
|
|
static setNeedsDrawSelection(value) {
|
|
gNeedsDrawSelection = value;
|
|
}
|
|
|
|
static setInitialLineNumber(value) {
|
|
gInitialLineNumber = value;
|
|
}
|
|
|
|
receiveMessage(msg) {
|
|
if (msg.name == "ViewSource:GoToLine") {
|
|
this.goToLine(msg.data.lineNumber);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Any events should get handled here, and should get dispatched to
|
|
* a specific function for the event type.
|
|
*/
|
|
handleEvent(event) {
|
|
switch (event.type) {
|
|
case "pageshow":
|
|
this.onPageShow(event);
|
|
break;
|
|
case "click":
|
|
this.onClick(event);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A shortcut to the nsISelectionController for the content.
|
|
*/
|
|
get selectionController() {
|
|
return this.docShell
|
|
.QueryInterface(Ci.nsIInterfaceRequestor)
|
|
.getInterface(Ci.nsISelectionDisplay)
|
|
.QueryInterface(Ci.nsISelectionController);
|
|
}
|
|
|
|
/**
|
|
* A shortcut to the nsIWebBrowserFind for the content.
|
|
*/
|
|
get webBrowserFind() {
|
|
return this.docShell
|
|
.QueryInterface(Ci.nsIInterfaceRequestor)
|
|
.getInterface(Ci.nsIWebBrowserFind);
|
|
}
|
|
|
|
/**
|
|
* This handler is for click events from:
|
|
* * error page content, which can show up if the user attempts to view the
|
|
* source of an attack page.
|
|
* * in-page context menu actions
|
|
*/
|
|
onClick(event) {
|
|
let target = event.originalTarget;
|
|
// Check for content menu actions
|
|
if (target.id) {
|
|
gContextMenuItems.forEach(itemSpec => {
|
|
if (itemSpec.id !== target.id) {
|
|
return;
|
|
}
|
|
itemSpec.handler(this);
|
|
event.stopPropagation();
|
|
});
|
|
}
|
|
|
|
// Don't trust synthetic events
|
|
if (!event.isTrusted || event.target.localName != "button") {
|
|
return;
|
|
}
|
|
|
|
let errorDoc = target.ownerDocument;
|
|
|
|
if (/^about:blocked/.test(errorDoc.documentURI)) {
|
|
// The event came from a button on a malware/phishing block page
|
|
|
|
if (target == errorDoc.getElementById("goBackButton")) {
|
|
// Instead of loading some safe page, just close the window
|
|
this.sendAsyncMessage("ViewSource:Close");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for the pageshow event.
|
|
*
|
|
* @param event
|
|
* The pageshow event being handled.
|
|
*/
|
|
onPageShow(event) {
|
|
this.contentWindow.focus();
|
|
|
|
// If we need to draw the selection, wait until an actual view source page
|
|
// has loaded, instead of about:blank.
|
|
if (
|
|
gNeedsDrawSelection &&
|
|
this.document.documentURI.startsWith("view-source:")
|
|
) {
|
|
gNeedsDrawSelection = false;
|
|
this.drawSelection();
|
|
}
|
|
|
|
if (gInitialLineNumber >= 0) {
|
|
this.goToLine(gInitialLineNumber);
|
|
gInitialLineNumber = -1;
|
|
}
|
|
|
|
if (this.document.body) {
|
|
this.injectContextMenu();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempts to go to a particular line in the source code being
|
|
* shown. If it succeeds in finding the line, it will fire a
|
|
* "ViewSource:GoToLine:Success" message, passing up an object
|
|
* with the lineNumber we just went to. If it cannot find the line,
|
|
* it will fire a "ViewSource:GoToLine:Failed" message.
|
|
*
|
|
* @param lineNumber
|
|
* The line number to attempt to go to.
|
|
*/
|
|
goToLine(lineNumber) {
|
|
let body = this.document.body;
|
|
|
|
// The source document is made up of a number of pre elements with
|
|
// id attributes in the format <pre id="line123">, meaning that
|
|
// the first line in the pre element is number 123.
|
|
// Do binary search to find the pre element containing the line.
|
|
// However, in the plain text case, we have only one pre without an
|
|
// attribute, so assume it begins on line 1.
|
|
let pre;
|
|
for (let lbound = 0, ubound = body.childNodes.length; ; ) {
|
|
let middle = (lbound + ubound) >> 1;
|
|
pre = body.childNodes[middle];
|
|
|
|
let firstLine = pre.id ? parseInt(pre.id.substring(4)) : 1;
|
|
|
|
if (lbound == ubound - 1) {
|
|
break;
|
|
}
|
|
|
|
if (lineNumber >= firstLine) {
|
|
lbound = middle;
|
|
} else {
|
|
ubound = middle;
|
|
}
|
|
}
|
|
|
|
let result = {};
|
|
let found = this.findLocation(pre, lineNumber, null, -1, false, result);
|
|
|
|
if (!found) {
|
|
this.sendAsyncMessage("ViewSource:GoToLine:Failed");
|
|
return;
|
|
}
|
|
|
|
let selection = this.document.defaultView.getSelection();
|
|
selection.removeAllRanges();
|
|
|
|
// In our case, the range's startOffset is after "\n" on the previous line.
|
|
// Tune the selection at the beginning of the next line and do some tweaking
|
|
// to position the focusNode and the caret at the beginning of the line.
|
|
selection.interlinePosition = true;
|
|
|
|
selection.addRange(result.range);
|
|
|
|
if (!selection.isCollapsed) {
|
|
selection.collapseToEnd();
|
|
|
|
let offset = result.range.startOffset;
|
|
let node = result.range.startContainer;
|
|
if (offset < node.data.length) {
|
|
// The same text node spans across the "\n", just focus where we were.
|
|
selection.extend(node, offset);
|
|
} else {
|
|
// There is another tag just after the "\n", hook there. We need
|
|
// to focus a safe point because there are edgy cases such as
|
|
// <span>...\n</span><span>...</span> vs.
|
|
// <span>...\n<span>...</span></span><span>...</span>
|
|
node = node.nextSibling
|
|
? node.nextSibling
|
|
: node.parentNode.nextSibling;
|
|
selection.extend(node, 0);
|
|
}
|
|
}
|
|
|
|
let selCon = this.selectionController;
|
|
selCon.setDisplaySelection(Ci.nsISelectionController.SELECTION_ON);
|
|
selCon.setCaretVisibilityDuringSelection(true);
|
|
|
|
// Scroll the beginning of the line into view.
|
|
selCon.scrollSelectionIntoView(
|
|
Ci.nsISelectionController.SELECTION_NORMAL,
|
|
Ci.nsISelectionController.SELECTION_FOCUS_REGION,
|
|
true
|
|
);
|
|
|
|
this.sendAsyncMessage("ViewSource:GoToLine:Success", { lineNumber });
|
|
}
|
|
|
|
/**
|
|
* Some old code from the original view source implementation. Original
|
|
* documentation follows:
|
|
*
|
|
* "Loops through the text lines in the pre element. The arguments are either
|
|
* (pre, line) or (node, offset, interlinePosition). result is an out
|
|
* argument. If (pre, line) are specified (and node == null), result.range is
|
|
* a range spanning the specified line. If the (node, offset,
|
|
* interlinePosition) are specified, result.line and result.col are the line
|
|
* and column number of the specified offset in the specified node relative to
|
|
* the whole file."
|
|
*/
|
|
findLocation(pre, lineNumber, node, offset, interlinePosition, result) {
|
|
if (node && !pre) {
|
|
// Look upwards to find the current pre element.
|
|
// eslint-disable-next-line no-empty
|
|
for (pre = node; pre.nodeName != "PRE"; pre = pre.parentNode) {}
|
|
}
|
|
|
|
// The source document is made up of a number of pre elements with
|
|
// id attributes in the format <pre id="line123">, meaning that
|
|
// the first line in the pre element is number 123.
|
|
// However, in the plain text case, there is only one <pre> without an id,
|
|
// so assume line 1.
|
|
let curLine = pre.id ? parseInt(pre.id.substring(4)) : 1;
|
|
|
|
// Walk through each of the text nodes and count newlines.
|
|
let treewalker = this.document.createTreeWalker(
|
|
pre,
|
|
NodeFilter.SHOW_TEXT,
|
|
null
|
|
);
|
|
|
|
// The column number of the first character in the current text node.
|
|
let firstCol = 1;
|
|
|
|
let found = false;
|
|
for (
|
|
let textNode = treewalker.firstChild();
|
|
textNode && !found;
|
|
textNode = treewalker.nextNode()
|
|
) {
|
|
// \r is not a valid character in the DOM, so we only check for \n.
|
|
let lineArray = textNode.data.split(/\n/);
|
|
let lastLineInNode = curLine + lineArray.length - 1;
|
|
|
|
// Check if we can skip the text node without further inspection.
|
|
if (node ? textNode != node : lastLineInNode < lineNumber) {
|
|
if (lineArray.length > 1) {
|
|
firstCol = 1;
|
|
}
|
|
firstCol += lineArray[lineArray.length - 1].length;
|
|
curLine = lastLineInNode;
|
|
continue;
|
|
}
|
|
|
|
// curPos is the offset within the current text node of the first
|
|
// character in the current line.
|
|
for (
|
|
var i = 0, curPos = 0;
|
|
i < lineArray.length;
|
|
curPos += lineArray[i++].length + 1
|
|
) {
|
|
if (i > 0) {
|
|
curLine++;
|
|
}
|
|
|
|
if (node) {
|
|
if (offset >= curPos && offset <= curPos + lineArray[i].length) {
|
|
// If we are right after the \n of a line and interlinePosition is
|
|
// false, the caret looks as if it were at the end of the previous
|
|
// line, so we display that line and column instead.
|
|
|
|
if (i > 0 && offset == curPos && !interlinePosition) {
|
|
result.line = curLine - 1;
|
|
var prevPos = curPos - lineArray[i - 1].length;
|
|
result.col = (i == 1 ? firstCol : 1) + offset - prevPos;
|
|
} else {
|
|
result.line = curLine;
|
|
result.col = (i == 0 ? firstCol : 1) + offset - curPos;
|
|
}
|
|
found = true;
|
|
|
|
break;
|
|
}
|
|
} else if (curLine == lineNumber && !("range" in result)) {
|
|
result.range = this.document.createRange();
|
|
result.range.setStart(textNode, curPos);
|
|
|
|
// This will always be overridden later, except when we look for
|
|
// the very last line in the file (this is the only line that does
|
|
// not end with \n).
|
|
result.range.setEndAfter(pre.lastChild);
|
|
} else if (curLine == lineNumber + 1) {
|
|
result.range.setEnd(textNode, curPos - 1);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return found || "range" in result;
|
|
}
|
|
|
|
/**
|
|
* Toggles the "wrap" class on the document body, which sets whether
|
|
* or not long lines are wrapped. Notifies parent to update the pref.
|
|
*/
|
|
toggleWrapping() {
|
|
let body = this.document.body;
|
|
let state = body.classList.toggle("wrap");
|
|
this.sendAsyncMessage("ViewSource:StoreWrapping", { state });
|
|
}
|
|
|
|
/**
|
|
* Toggles the "highlight" class on the document body, which sets whether
|
|
* or not syntax highlighting is displayed. Notifies parent to update the
|
|
* pref.
|
|
*/
|
|
toggleSyntaxHighlighting() {
|
|
let body = this.document.body;
|
|
let state = body.classList.toggle("highlight");
|
|
this.sendAsyncMessage("ViewSource:StoreSyntaxHighlighting", { state });
|
|
}
|
|
|
|
/**
|
|
* Using special markers left in the serialized source, this helper makes the
|
|
* underlying markup of the selected fragment to automatically appear as
|
|
* selected on the inflated view-source DOM.
|
|
*/
|
|
drawSelection() {
|
|
this.document.title = this.bundle.GetStringFromName(
|
|
"viewSelectionSourceTitle"
|
|
);
|
|
|
|
// find the special selection markers that we added earlier, and
|
|
// draw the selection between the two...
|
|
var findService = null;
|
|
try {
|
|
// get the find service which stores the global find state
|
|
findService = Cc["@mozilla.org/find/find_service;1"].getService(
|
|
Ci.nsIFindService
|
|
);
|
|
} catch (e) {}
|
|
if (!findService) {
|
|
return;
|
|
}
|
|
|
|
// cache the current global find state
|
|
var matchCase = findService.matchCase;
|
|
var entireWord = findService.entireWord;
|
|
var wrapFind = findService.wrapFind;
|
|
var findBackwards = findService.findBackwards;
|
|
var searchString = findService.searchString;
|
|
var replaceString = findService.replaceString;
|
|
|
|
// setup our find instance
|
|
var findInst = this.webBrowserFind;
|
|
findInst.matchCase = true;
|
|
findInst.entireWord = false;
|
|
findInst.wrapFind = true;
|
|
findInst.findBackwards = false;
|
|
|
|
// ...lookup the start mark
|
|
findInst.searchString = MARK_SELECTION_START;
|
|
var startLength = MARK_SELECTION_START.length;
|
|
findInst.findNext();
|
|
|
|
var selection = this.document.defaultView.getSelection();
|
|
if (!selection.rangeCount) {
|
|
return;
|
|
}
|
|
|
|
var range = selection.getRangeAt(0);
|
|
|
|
var startContainer = range.startContainer;
|
|
var startOffset = range.startOffset;
|
|
|
|
// ...lookup the end mark
|
|
findInst.searchString = MARK_SELECTION_END;
|
|
var endLength = MARK_SELECTION_END.length;
|
|
findInst.findNext();
|
|
|
|
var endContainer = selection.anchorNode;
|
|
var endOffset = selection.anchorOffset;
|
|
|
|
// reset the selection that find has left
|
|
selection.removeAllRanges();
|
|
|
|
// delete the special markers now...
|
|
endContainer.deleteData(endOffset, endLength);
|
|
startContainer.deleteData(startOffset, startLength);
|
|
if (startContainer == endContainer) {
|
|
endOffset -= startLength;
|
|
} // has shrunk if on same text node...
|
|
range.setEnd(endContainer, endOffset);
|
|
|
|
// show the selection and scroll it into view
|
|
selection.addRange(range);
|
|
// the default behavior of the selection is to scroll at the end of
|
|
// the selection, whereas in this situation, it is more user-friendly
|
|
// to scroll at the beginning. So we override the default behavior here
|
|
try {
|
|
this.selectionController.scrollSelectionIntoView(
|
|
Ci.nsISelectionController.SELECTION_NORMAL,
|
|
Ci.nsISelectionController.SELECTION_ANCHOR_REGION,
|
|
true
|
|
);
|
|
} catch (e) {}
|
|
|
|
// restore the current find state
|
|
findService.matchCase = matchCase;
|
|
findService.entireWord = entireWord;
|
|
findService.wrapFind = wrapFind;
|
|
findService.findBackwards = findBackwards;
|
|
findService.searchString = searchString;
|
|
findService.replaceString = replaceString;
|
|
|
|
findInst.matchCase = matchCase;
|
|
findInst.entireWord = entireWord;
|
|
findInst.wrapFind = wrapFind;
|
|
findInst.findBackwards = findBackwards;
|
|
findInst.searchString = searchString;
|
|
}
|
|
|
|
/**
|
|
* Add context menu items for view source specific actions.
|
|
*/
|
|
injectContextMenu() {
|
|
let doc = this.document;
|
|
|
|
let menu = doc.createElementNS(NS_XHTML, "menu");
|
|
menu.setAttribute("type", "context");
|
|
menu.setAttribute("id", "actions");
|
|
doc.body.appendChild(menu);
|
|
doc.body.setAttribute("contextmenu", "actions");
|
|
|
|
gContextMenuItems.forEach(itemSpec => {
|
|
let item = doc.createElementNS(NS_XHTML, "menuitem");
|
|
item.setAttribute("id", itemSpec.id);
|
|
let labelName = `context_${itemSpec.id}_label`;
|
|
let label = this.bundle.GetStringFromName(labelName);
|
|
item.setAttribute("label", label);
|
|
if ("checked" in itemSpec) {
|
|
item.setAttribute("type", "checkbox");
|
|
}
|
|
if (itemSpec.accesskey) {
|
|
let accesskeyName = `context_${itemSpec.id}_accesskey`;
|
|
item.setAttribute(
|
|
"accesskey",
|
|
this.bundle.GetStringFromName(accesskeyName)
|
|
);
|
|
}
|
|
menu.appendChild(item);
|
|
});
|
|
|
|
this.updateContextMenu();
|
|
}
|
|
|
|
/**
|
|
* Update state of checkbox-style context menu items.
|
|
*/
|
|
updateContextMenu() {
|
|
let doc = this.document;
|
|
gContextMenuItems.forEach(itemSpec => {
|
|
if (!("checked" in itemSpec)) {
|
|
return;
|
|
}
|
|
let item = doc.getElementById(itemSpec.id);
|
|
if (itemSpec.checked) {
|
|
item.setAttribute("checked", true);
|
|
} else {
|
|
item.removeAttribute("checked");
|
|
}
|
|
});
|
|
}
|
|
}
|