Source code

Revision control

Copy as Markdown

Other Tools

/* 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";
var EXPORTED_SYMBOLS = ["FinderHighlighter"];
const { XPCOMUtils } = ChromeUtils.importESModule(
);
const lazy = {};
ChromeUtils.defineModuleGetter(
lazy,
"Color",
);
ChromeUtils.defineModuleGetter(
lazy,
"Rect",
);
XPCOMUtils.defineLazyGetter(lazy, "kDebug", () => {
const kDebugPref = "findbar.modalHighlight.debug";
return (
Services.prefs.getPrefType(kDebugPref) &&
Services.prefs.getBoolPref(kDebugPref)
);
});
const kContentChangeThresholdPx = 5;
const kBrightTextSampleSize = 5;
// This limit is arbitrary and doesn't scale for low-powered machines or
// high-powered machines. Netbooks will probably need a much lower limit, for
// example. Though getting something out there is better than nothing.
const kPageIsTooBigPx = 500000;
const kModalHighlightRepaintLoFreqMs = 100;
const kModalHighlightRepaintHiFreqMs = 16;
const kHighlightAllPref = "findbar.highlightAll";
const kModalHighlightPref = "findbar.modalHighlight";
const kFontPropsCSS = [
"color",
"font-family",
"font-kerning",
"font-size",
"font-size-adjust",
"font-stretch",
"font-variant",
"font-weight",
"line-height",
"letter-spacing",
"text-emphasis",
"text-orientation",
"text-transform",
"word-spacing",
];
const kFontPropsCamelCase = kFontPropsCSS.map(prop => {
let parts = prop.split("-");
return (
parts.shift() +
parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("")
);
});
const kRGBRE = /^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*/i;
// This uuid is used to prefix HTML element IDs in order to make them unique and
// hard to clash with IDs content authors come up with.
const kModalIdPrefix = "cedee4d0-74c5-4f2d-ab43-4d37c0f9d463";
const kModalOutlineId = kModalIdPrefix + "-findbar-modalHighlight-outline";
const kOutlineBoxColor = "255,197,53";
const kOutlineBoxBorderSize = 1;
const kOutlineBoxBorderRadius = 2;
const kModalStyles = {
outlineNode: [
["background-color", `rgb(${kOutlineBoxColor})`],
["background-clip", "padding-box"],
["border", `${kOutlineBoxBorderSize}px solid rgba(${kOutlineBoxColor},.7)`],
["border-radius", `${kOutlineBoxBorderRadius}px`],
["box-shadow", `0 2px 0 0 rgba(0,0,0,.1)`],
["color", "#000"],
["display", "-moz-box"],
[
"margin",
`-${kOutlineBoxBorderSize}px 0 0 -${kOutlineBoxBorderSize}px !important`,
],
["overflow", "hidden"],
["pointer-events", "none"],
["position", "absolute"],
["white-space", "nowrap"],
["will-change", "transform"],
["z-index", 2],
],
outlineNodeDebug: [["z-index", 2147483647]],
outlineText: [
["margin", "0 !important"],
["padding", "0 !important"],
["vertical-align", "top !important"],
],
maskNode: [
["background", "rgba(0,0,0,.25)"],
["pointer-events", "none"],
["position", "absolute"],
["z-index", 1],
],
maskNodeTransition: [["transition", "background .2s ease-in"]],
maskNodeDebug: [
["z-index", 2147483646],
["top", 0],
["left", 0],
],
maskNodeBrightText: [["background", "rgba(255,255,255,.25)"]],
};
const kModalOutlineAnim = {
keyframes: [
{ transform: "scaleX(1) scaleY(1)" },
{ transform: "scaleX(1.5) scaleY(1.5)", offset: 0.5, easing: "ease-in" },
{ transform: "scaleX(1) scaleY(1)" },
],
duration: 50,
};
const kNSHTML = "http://www.w3.org/1999/xhtml";
const kRepaintSchedulerStopped = 1;
const kRepaintSchedulerPaused = 2;
const kRepaintSchedulerRunning = 3;
function mockAnonymousContentNode(domNode) {
return {
setTextContentForElement(id, text) {
(domNode.querySelector("#" + id) || domNode).textContent = text;
},
getAttributeForElement(id, attrName) {
let node = domNode.querySelector("#" + id) || domNode;
if (!node.hasAttribute(attrName)) {
return undefined;
}
return node.getAttribute(attrName);
},
setAttributeForElement(id, attrName, attrValue) {
(domNode.querySelector("#" + id) || domNode).setAttribute(
attrName,
attrValue
);
},
removeAttributeForElement(id, attrName) {
let node = domNode.querySelector("#" + id) || domNode;
if (!node.hasAttribute(attrName)) {
return;
}
node.removeAttribute(attrName);
},
remove() {
try {
domNode.remove();
} catch (ex) {}
},
setAnimationForElement(id, keyframes, duration) {
return (domNode.querySelector("#" + id) || domNode).animate(
keyframes,
duration
);
},
setCutoutRectsForElement(id, rects) {
// no-op for now.
},
};
}
let gWindows = new WeakMap();
/**
* FinderHighlighter class that is used by Finder.jsm to take care of the
* 'Highlight All' feature, which can highlight all find occurrences in a page.
*
* @param {Finder} finder Finder.jsm instance
* @param {boolean} useTop check and use top-level windows for rectangle
* computation, if possible.
*/
function FinderHighlighter(finder, useTop = false) {
this._highlightAll = Services.prefs.getBoolPref(kHighlightAllPref);
this._modal = Services.prefs.getBoolPref(kModalHighlightPref);
this._useSubFrames = false;
this._useTop = useTop;
this._marksListener = null;
this._testing = false;
this.finder = finder;
}
FinderHighlighter.prototype = {
get iterator() {
return this.finder.iterator;
},
enableTesting(enable) {
this._testing = enable;
},
// Get the top-most window when allowed. When out-of-process frames are used,
// this will usually be the same as the passed-in window. The checkUseTop
// argument can be used to instead check the _useTop flag which can be used
// to enable rectangle coordinate checks.
getTopWindow(window, checkUseTop) {
if (this._useSubFrames || (checkUseTop && this._useTop)) {
try {
return window.top;
} catch (ex) {}
}
return window;
},
useModal() {
// Modal highlighting is currently only enabled when there are no
// out-of-process subframes.
return this._modal && this._useSubFrames;
},
/**
* Each window is unique, globally, and the relation between an active
* highlighting session and a window is 1:1.
* For each window we track a number of properties which _at least_ consist of
* - {Boolean} detectedGeometryChange Whether the geometry of the found ranges'
* rectangles has changed substantially
* - {Set} dynamicRangesSet Set of ranges that may move around, depending
* on page layout changes and user input
* - {Map} frames Collection of frames that were encountered
* when inspecting the found ranges
* - {Map} modalHighlightRectsMap Collection of ranges and their corresponding
* Rects and texts
*
* @param {nsIDOMWindow} window
* @return {Object}
*/
getForWindow(window, propName = null) {
if (!gWindows.has(window)) {
gWindows.set(window, {
detectedGeometryChange: false,
dynamicRangesSet: new Set(),
frames: new Map(),
lastWindowDimensions: { width: 0, height: 0 },
modalHighlightRectsMap: new Map(),
previousRangeRectsAndTexts: { rectList: [], textList: [] },
repaintSchedulerState: kRepaintSchedulerStopped,
});
}
return gWindows.get(window);
},
/**
* Notify all registered listeners that the 'Highlight All' operation finished.
*
* @param {Boolean} highlight Whether highlighting was turned on
*/
notifyFinished(highlight) {
for (let l of this.finder._listeners) {
try {
l.onHighlightFinished(highlight);
} catch (ex) {}
}
},
/**
* Toggle highlighting all occurrences of a word in a page. This method will
* be called recursively for each (i)frame inside a page.
*
* @param {Booolean} highlight Whether highlighting should be turned on
* @param {String} word Needle to search for and highlight when found
* @param {Boolean} linksOnly Only consider nodes that are links for the search
* @param {Boolean} drawOutline Whether found links should be outlined.
* @param {Boolean} useSubFrames Whether to iterate over subframes.
* @yield {Promise} that resolves once the operation has finished
*/
async highlight(highlight, word, linksOnly, drawOutline, useSubFrames) {
let window = this.finder._getWindow();
let dict = this.getForWindow(window);
let controller = this.finder._getSelectionController(window);
let doc = window.document;
this._found = false;
this._useSubFrames = useSubFrames;
let result = { searchString: word, highlight, found: false };
if (!controller || !doc || !doc.documentElement) {
// Without the selection controller,
// we are unable to (un)highlight any matches
return result;
}
if (highlight) {
let params = {
allowDistance: 1,
caseSensitive: this.finder._fastFind.caseSensitive,
entireWord: this.finder._fastFind.entireWord,
linksOnly,
word,
finder: this.finder,
listener: this,
matchDiacritics: this.finder._fastFind.matchDiacritics,
useCache: true,
useSubFrames,
window,
};
if (
this.iterator.isAlreadyRunning(params) ||
(this.useModal() &&
this.iterator._areParamsEqual(params, dict.lastIteratorParams))
) {
return result;
}
if (!this.useModal()) {
dict.visible = true;
}
await this.iterator.start(params);
if (this._found) {
this.finder._outlineLink(drawOutline);
}
} else {
this.hide(window);
// Removing the highlighting always succeeds, so return true.
this._found = true;
}
result.found = this._found;
this.notifyFinished(result);
return result;
},
// FinderIterator listener implementation
onIteratorRangeFound(range) {
this.highlightRange(range);
this._found = true;
},
onIteratorReset() {},
onIteratorRestart() {
this.clear(this.finder._getWindow());
},
onIteratorStart(params) {
let window = this.finder._getWindow();
let dict = this.getForWindow(window);
// Save a clean params set for use later in the `update()` method.
dict.lastIteratorParams = params;
if (!this.useModal()) {
this.hide(window, this.finder._fastFind.getFoundRange());
}
this.clear(window);
},
/**
* Add a range to the find selection, i.e. highlight it, and if it's inside an
* editable node, track it.
*
* @param {Range} range Range object to be highlighted
*/
highlightRange(range) {
let node = range.startContainer;
let editableNode = this._getEditableNode(node);
let window = node.ownerGlobal;
let controller = this.finder._getSelectionController(window);
if (editableNode) {
controller = editableNode.editor.selectionController;
}
if (this.useModal()) {
this._modalHighlight(range, controller, window);
} else {
let findSelection = controller.getSelection(
Ci.nsISelectionController.SELECTION_FIND
);
findSelection.addRange(range);
// Check if the range is inside an (i)frame.
if (window != this.getTopWindow(window)) {
let dict = this.getForWindow(this.getTopWindow(window));
// Add this frame to the list, so that we'll be able to find it later
// when we need to clear its selection(s).
dict.frames.set(window, {});
}
}
if (editableNode) {
// Highlighting added, so cache this editor, and hook up listeners
// to ensure we deal properly with edits within the highlighting
this._addEditorListeners(editableNode.editor);
}
},
/**
* If modal highlighting is enabled, show the dimmed background that will overlay
* the page.
*
* @param {nsIDOMWindow} window The dimmed background will overlay this window.
* Optional, defaults to the finder window.
*/
show(window = null) {
window = this.getTopWindow(window || this.finder._getWindow());
let dict = this.getForWindow(window);
if (!this.useModal() || dict.visible) {
return;
}
dict.visible = true;
this._maybeCreateModalHighlightNodes(window);
this._addModalHighlightListeners(window);
},
/**
* Clear all highlighted matches. If modal highlighting is enabled and
* the outline + dimmed background is currently visible, both will be hidden.
*
* @param {nsIDOMWindow} window The dimmed background will overlay this window.
* Optional, defaults to the finder window.
* @param {Range} skipRange A range that should not be removed from the
* find selection.
* @param {Event} event When called from an event handler, this will
* be the triggering event.
*/
hide(window, skipRange = null, event = null) {
try {
window = this.getTopWindow(window);
} catch (ex) {
Cu.reportError(ex);
return;
}
let dict = this.getForWindow(window);
let isBusySelecting = dict.busySelecting;
dict.busySelecting = false;
// Do not hide on anything but a left-click.
if (
event &&
event.type == "click" &&
(event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.relatedTarget ||
isBusySelecting ||
(event.target.localName == "a" && event.target.href))
) {
return;
}
this._clearSelection(
this.finder._getSelectionController(window),
skipRange
);
for (let frame of dict.frames.keys()) {
this._clearSelection(
this.finder._getSelectionController(frame),
skipRange
);
}
// Next, check our editor cache, for editors belonging to this
// document
if (this._editors) {
let doc = window.document;
for (let x = this._editors.length - 1; x >= 0; --x) {
if (this._editors[x].document == doc) {
this._clearSelection(this._editors[x].selectionController, skipRange);
// We don't need to listen to this editor any more
this._unhookListenersAtIndex(x);
}
}
}
if (dict.modalRepaintScheduler) {
window.clearTimeout(dict.modalRepaintScheduler);
dict.modalRepaintScheduler = null;
dict.repaintSchedulerState = kRepaintSchedulerStopped;
}
dict.lastWindowDimensions = { width: 0, height: 0 };
this._removeRangeOutline(window);
this._removeHighlightAllMask(window);
this._removeModalHighlightListeners(window);
dict.visible = false;
},
/**
* Called by the Finder after a find result comes in; update the position and
* content of the outline to the newly found occurrence.
* To make sure that the outline covers the found range completely, all the
* CSS styles that influence the text are copied and applied to the outline.
*
* @param {Object} data Dictionary coming from Finder that contains the
* following properties:
* {Number} result One of the nsITypeAheadFind.FIND_* constants
* indicating the result of a search operation.
* {Boolean} findBackwards If TRUE, the search was performed backwards,
* FALSE if forwards.
* {Boolean} findAgain If TRUE, the search was performed using the same
* search string as before.
* {String} linkURL If a link was hit, this will contain a URL string.
* {Rect} rect An object with top, left, width and height
* coordinates of the current selection.
* {String} searchString The string the search was performed with.
* {Boolean} storeResult Indicator if the search string should be stored
* by the consumer of the Finder.
*/
async update(data, foundInThisFrame) {
let window = this.finder._getWindow();
let dict = this.getForWindow(window);
let foundRange = this.finder._fastFind.getFoundRange();
if (
data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
!data.searchString ||
(foundInThisFrame && !foundRange)
) {
this.hide(window);
return;
}
this._useSubFrames = data.useSubFrames;
if (!this.useModal()) {
if (this._highlightAll) {
dict.previousFoundRange = dict.currentFoundRange;
dict.currentFoundRange = foundRange;
let params = this.iterator.params;
if (
dict.visible &&
this.iterator._areParamsEqual(params, dict.lastIteratorParams)
) {
return;
}
if (!dict.visible && !params) {
params = { word: data.searchString, linksOnly: data.linksOnly };
}
if (params) {
await this.highlight(
true,
params.word,
params.linksOnly,
params.drawOutline,
data.useSubFrames
);
}
}
return;
}
dict.animateOutline = true;
// Immediately finish running animations, if any.
this._finishOutlineAnimations(dict);
if (foundRange !== dict.currentFoundRange || data.findAgain) {
dict.previousFoundRange = dict.currentFoundRange;
dict.currentFoundRange = foundRange;
if (!dict.visible) {
this.show(window);
} else {
this._maybeCreateModalHighlightNodes(window);
}
}
if (this._highlightAll) {
await this.highlight(
true,
data.searchString,
data.linksOnly,
data.drawOutline,
data.useSubFrames
);
}
},
/**
* Invalidates the list by clearing the map of highlighted ranges that we
* keep to build the mask for.
*/
clear(window = null) {
if (!window || !this.getTopWindow(window)) {
return;
}
let dict = this.getForWindow(this.getTopWindow(window));
this._finishOutlineAnimations(dict);
dict.dynamicRangesSet.clear();
dict.frames.clear();
dict.modalHighlightRectsMap.clear();
dict.brightText = null;
},
/**
* Removes the outline from a single window. This is done when
* switching the current search to a new frame.
*/
clearCurrentOutline(window = null) {
let dict = this.getForWindow(this.getTopWindow(window));
this._finishOutlineAnimations(dict);
this._removeRangeOutline(window);
},
// Update the tick marks that should appear on the page's scrollbar(s).
updateScrollMarks() {
// Only show scrollbar marks when normal highlighting is enabled.
if (this.useModal() || !this._highlightAll) {
this.removeScrollMarks();
return;
}
let marks = new Set(); // Use a set so duplicate values are removed.
let window = this.finder._getWindow();
// Show the marks on the horizontal scrollbar for vertical writing modes.
let onHorizontalScrollbar = !window
.getComputedStyle(window.document.body || window.document.documentElement)
.writingMode.startsWith("horizontal");
let yStart = window.scrollY - window.scrollMinY;
let xStart = window.scrollX - window.scrollMinX;
let hasRanges = false;
if (window) {
let controllers = [this.finder._getSelectionController(window)];
let editors = this.editors;
if (editors) {
// Add the selection controllers from any input fields.
controllers.push(...editors.map(editor => editor.selectionController));
}
for (let controller of controllers) {
let findSelection = controller.getSelection(
Ci.nsISelectionController.SELECTION_FIND
);
let rangeCount = findSelection.rangeCount;
if (rangeCount > 0) {
hasRanges = true;
}
// No need to calculate the mark positions if there is no visible scrollbar.
if (window.scrollMaxY > window.scrollMinY && !onHorizontalScrollbar) {
// Use the body's scrollHeight if available.
let scrollHeight =
window.document.body?.scrollHeight ||
window.document.documentElement.scrollHeight;
let yAdj = (window.scrollMaxY - window.scrollMinY) / scrollHeight;
for (let r = 0; r < rangeCount; r++) {
let rect = findSelection.getRangeAt(r).getBoundingClientRect();
let yPos = Math.round((yStart + rect.y + rect.height / 2) * yAdj); // use the midpoint
marks.add(yPos);
}
} else if (
window.scrollMaxX > window.scrollMinX &&
onHorizontalScrollbar
) {
// Use the body's scrollWidth if available.
let scrollWidth =
window.document.body?.scrollWidth ||
window.document.documentElement.scrollWidth;
let xAdj = (window.scrollMaxX - window.scrollMinX) / scrollWidth;
for (let r = 0; r < rangeCount; r++) {
let rect = findSelection.getRangeAt(r).getBoundingClientRect();
let xPos = Math.round((xStart + rect.x + rect.width / 2) * xAdj);
marks.add(xPos);
}
}
}
}
if (hasRanges) {
// Assign the marks to the window and add a listener for the MozScrolledAreaChanged
// event which fires whenever the scrollable area's size is updated.
this.setScrollMarks(window, Array.from(marks), onHorizontalScrollbar);
if (!this._marksListener) {
this._marksListener = event => {
this.updateScrollMarks();
};
window.addEventListener(
"MozScrolledAreaChanged",
this._marksListener,
true
);
window.addEventListener("resize", this._marksListener);
}
} else if (this._marksListener) {
// No results were found so remove any existing ones and the MozScrolledAreaChanged listener.
this.removeScrollMarks();
}
},
removeScrollMarks() {
let window;
try {
window = this.finder._getWindow();
} catch (ex) {
// An exception can happen after changing remoteness but this
// would have deleted the marks anyway.
return;
}
if (this._marksListener) {
window.removeEventListener(
"MozScrolledAreaChanged",
this._marksListener,
true
);
window.removeEventListener("resize", this._marksListener);
this._marksListener = null;
}
this.setScrollMarks(window, []);
},
/**
* Set the scrollbar marks for a current search. If testing mode is enabled, fire a
* find-scrollmarks-changed event at the window.
*
* @param window window to set the scrollbar marks on
* @param marks array of integer scrollbar mark positions
* @param onHorizontalScrollbar whether to display the marks on the horizontal scrollbar
*/
setScrollMarks(window, marks, onHorizontalScrollbar = false) {
window.setScrollMarks(marks, onHorizontalScrollbar);
// Fire an event containing the found mark values if testing mode is enabled.
if (this._testing) {
window.dispatchEvent(
new CustomEvent("find-scrollmarks-changed", {
detail: {
marks: Array.from(marks),
onHorizontalScrollbar,
},
})
);
}
},
/**
* When the current page is refreshed or navigated away from, the CanvasFrame
* contents is not valid anymore, i.e. all anonymous content is destroyed.
* We need to clear the references we keep, which'll make sure we redraw
* everything when the user starts to find in page again.
*/
onLocationChange() {
let window = this.finder._getWindow();
if (!window || !this.getTopWindow(window)) {
return;
}
this.hide(window);
this.clear(window);
this._removeRangeOutline(window);
gWindows.delete(this.getTopWindow(window));
},
/**
* When `kModalHighlightPref` pref changed during a session, this callback is
* invoked. When modal highlighting is turned off, we hide the CanvasFrame
* contents.
*
* @param {Boolean} useModalHighlight
*/
onModalHighlightChange(useModalHighlight) {
let window = this.finder._getWindow();
if (window && this.useModal() && !useModalHighlight) {
this.hide(window);
this.clear(window);
}
this._modal = useModalHighlight;
this.updateScrollMarks();
},
/**
* When 'Highlight All' is toggled during a session, this callback is invoked
* and when it's turned off, the found occurrences will be removed from the mask.
*
* @param {Boolean} highlightAll
*/
onHighlightAllChange(highlightAll) {
this._highlightAll = highlightAll;
if (!highlightAll) {
let window = this.finder._getWindow();
if (!this.useModal()) {
this.hide(window);
}
this.clear(window);
this._scheduleRepaintOfMask(window);
}
this.updateScrollMarks();
},
/**
* Utility; removes all ranges from the find selection that belongs to a
* controller. Optionally skips a specific range.
*
* @param {nsISelectionController} controller
* @param {Range} restoreRange
*/
_clearSelection(controller, restoreRange = null) {
if (!controller) {
return;
}
let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
sel.removeAllRanges();
if (restoreRange) {
sel = controller.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
sel.addRange(restoreRange);
controller.setDisplaySelection(
Ci.nsISelectionController.SELECTION_ATTENTION
);
controller.repaintSelection(Ci.nsISelectionController.SELECTION_NORMAL);
}
},
/**
* Utility; get the nsIDOMWindowUtils for a window.
*
* @param {nsIDOMWindow} window Optional, defaults to the finder window.
* @return {nsIDOMWindowUtils}
*/
_getDWU(window = null) {
return (window || this.finder._getWindow()).windowUtils;
},
/**
* Utility; returns the bounds of the page relative to the viewport.
* If the pages is part of a frameset or inside an iframe of any kind, its
* offset is accounted for.
* Geometry.jsm takes care of the DOMRect calculations.
*
* @param {nsIDOMWindow} window Window to read the boundary rect from
* @param {Boolean} [includeScroll] Whether to ignore the scroll offset,
* which is useful for comparing DOMRects.
* Optional, defaults to `true`
* @return {Rect}
*/
_getRootBounds(window, includeScroll = true) {
let dwu = this._getDWU(this.getTopWindow(window, true));
let cssPageRect = lazy.Rect.fromRect(dwu.getRootBounds());
let scrollX = {};
let scrollY = {};
if (includeScroll && window == this.getTopWindow(window, true)) {
dwu.getScrollXY(false, scrollX, scrollY);
cssPageRect.translate(scrollX.value, scrollY.value);
}
// If we're in a frame, update the position of the rect (top/ left).
let currWin = window;
while (currWin != this.getTopWindow(window, true)) {
let frameOffsets = this._getFrameElementOffsets(currWin);
cssPageRect.translate(frameOffsets.x, frameOffsets.y);
// Since the frame is an element inside a parent window, we'd like to
// learn its position relative to it.
let el = currWin.browsingContext.embedderElement;
currWin = currWin.parent;
dwu = this._getDWU(currWin);
let parentRect = lazy.Rect.fromRect(dwu.getBoundsWithoutFlushing(el));
if (includeScroll) {
dwu.getScrollXY(false, scrollX, scrollY);
parentRect.translate(scrollX.value, scrollY.value);
// If the current window is an iframe with scrolling="no" and its parent
// is also an iframe the scroll offsets from the parents' documentElement
// (inverse scroll position) needs to be subtracted from the parent
// window rect.
if (
el.getAttribute("scrolling") == "no" &&
currWin != this.getTopWindow(window, true)
) {
let docEl = currWin.document.documentElement;
parentRect.translate(-docEl.scrollLeft, -docEl.scrollTop);
}
}
cssPageRect.translate(parentRect.left, parentRect.top);
}
let frameOffsets = this._getFrameElementOffsets(currWin);
cssPageRect.translate(frameOffsets.x, frameOffsets.y);
return cssPageRect;
},
/**
* (I)Frame elements may have a border and/ or padding set, which is not
* included in the bounds returned by nsDOMWindowUtils#getRootBounds() for the
* window it hosts.
* This method fetches this offset of the frame element to the respective window.
*
* @param {nsIDOMWindow} window Window to read the boundary rect from
* @return {Object} Simple object that contains the following two properties:
* - {Number} x Offset along the horizontal axis.
* - {Number} y Offset along the vertical axis.
*/
_getFrameElementOffsets(window) {
let frame = window.frameElement;
if (!frame) {
return { x: 0, y: 0 };
}
// Getting style info is super expensive, causing reflows, so let's cache
// frame border widths and padding values aggressively.
let dict = this.getForWindow(this.getTopWindow(window, true));
let frameData = dict.frames.get(window);
if (!frameData) {
dict.frames.set(window, (frameData = {}));
}
if (frameData.offset) {
return frameData.offset;
}
let style = frame.ownerGlobal.getComputedStyle(frame);
// We only need to left sides, because ranges are offset from point 0,0 in
// the top-left corner.
let borderOffset = [
parseInt(style.borderLeftWidth, 10) || 0,
parseInt(style.borderTopWidth, 10) || 0,
];
let paddingOffset = [
parseInt(style.paddingLeft, 10) || 0,
parseInt(style.paddingTop, 10) || 0,
];
return (frameData.offset = {
x: borderOffset[0] + paddingOffset[0],
y: borderOffset[1] + paddingOffset[1],
});
},
/**
* Utility; fetch the full width and height of the current window, excluding
* scrollbars.
*
* @param {nsiDOMWindow} window The current finder window.
* @return {Object} The current full page dimensions with `width` and `height`
* properties
*/
_getWindowDimensions(window) {
// First we'll try without flushing layout, because it's way faster.
let dwu = this._getDWU(window);
let { width, height } = dwu.getRootBounds();
if (!width || !height) {
// We need a flush after all :'(
width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
let scrollbarHeight = {};
let scrollbarWidth = {};
dwu.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
width -= scrollbarWidth.value;
height -= scrollbarHeight.value;
}
return { width, height };
},
/**
* Utility; get all available font styles as applied to the content of a given
* range. The CSS properties we look for can be found in `kFontPropsCSS`.
*
* @param {Range} range Range to fetch style info from.
* @return {Object} Dictionary consisting of the styles that were found.
*/
_getRangeFontStyle(range) {
let node = range.startContainer;
while (node.nodeType != 1) {
node = node.parentNode;
}
let style = node.ownerGlobal.getComputedStyle(node);
let props = {};
for (let prop of kFontPropsCamelCase) {
if (prop in style && style[prop]) {
props[prop] = style[prop];
}
}
return props;
},
/**
* Utility; transform a dictionary object as returned by `_getRangeFontStyle`
* above into a HTML style attribute value.
*
* @param {Object} fontStyle
* @return {String}
*/
_getHTMLFontStyle(fontStyle) {
let style = [];
for (let prop of Object.getOwnPropertyNames(fontStyle)) {
let idx = kFontPropsCamelCase.indexOf(prop);
if (idx == -1) {
continue;
}
style.push(`${kFontPropsCSS[idx]}: ${fontStyle[prop]}`);
}
return style.join("; ");
},
/**
* Transform a style definition array as defined in `kModalStyles` into a CSS
* string that can be used to set the 'style' property of a DOM node.
*
* @param {Array} stylePairs Two-dimensional array of style pairs
* @param {...Array} [additionalStyles] Optional set of style pairs that will
* augment or override the styles defined
* by `stylePairs`
* @return {String}
*/
_getStyleString(stylePairs, ...additionalStyles) {
let baseStyle = new Map(stylePairs);
for (let additionalStyle of additionalStyles) {
for (let [prop, value] of additionalStyle) {
baseStyle.set(prop, value);
}
}
return [...baseStyle]
.map(([cssProp, cssVal]) => `${cssProp}: ${cssVal}`)
.join("; ");
},
/**
* Checks whether a CSS RGB color value can be classified as being 'bright'.
*
* @param {String} cssColor RGB color value in the default format rgb[a](r,g,b)
* @return {Boolean}
*/
_isColorBright(cssColor) {
cssColor = cssColor.match(kRGBRE);
if (!cssColor || !cssColor.length) {
return false;
}
cssColor.shift();
return !new lazy.Color(...cssColor).useBrightText;
},
/**
* Detects if the overall text color in the page can be described as bright.
* This is done according to the following algorithm:
* 1. With the entire set of ranges that we have found thusfar;
* 2. Get an odd-numbered `sampleSize`, with a maximum of `kBrightTextSampleSize`
* ranges,
* 3. Slice the set of ranges into `sampleSize` number of equal parts,
* 4. Grab the first range for each slice and inspect the brightness of the
* color of its text content.
* 5. When the majority of ranges are counted as contain bright colored text,
* the page is considered to contain bright text overall.
*
* @param {Object} dict Dictionary of properties belonging to the
* currently active window. The page text color property
* will be recorded in `dict.brightText` as `true` or `false`.
*/
_detectBrightText(dict) {
let sampleSize = Math.min(
dict.modalHighlightRectsMap.size,
kBrightTextSampleSize
);
let ranges = [...dict.modalHighlightRectsMap.keys()];
let rangesCount = ranges.length;
// Make sure the sample size is an odd number.
if (sampleSize % 2 == 0) {
// Make the previously or currently found range weigh heavier.
if (dict.previousFoundRange || dict.currentFoundRange) {
ranges.push(dict.previousFoundRange || dict.currentFoundRange);
++sampleSize;
++rangesCount;
} else {
--sampleSize;
}
}
let brightCount = 0;
for (let i = 0; i < sampleSize; ++i) {
let range = ranges[Math.floor((rangesCount / sampleSize) * i)];
let fontStyle = this._getRangeFontStyle(range);
if (this._isColorBright(fontStyle.color)) {
++brightCount;
}
}
dict.brightText = brightCount >= Math.ceil(sampleSize / 2);
},
/**
* Checks if a range is inside a DOM node that's positioned in a way that it
* doesn't scroll along when the document is scrolled and/ or zoomed. This
* is the case for 'fixed' and 'sticky' positioned elements, elements inside
* (i)frames and elements that have their overflow styles set to 'auto' or
* 'scroll'.
*
* @param {Range} range Range that be enclosed in a dynamic container
* @return {Boolean}
*/
_isInDynamicContainer(range) {
const kFixed = new Set(["fixed", "sticky", "scroll", "auto"]);
let node = range.startContainer;
while (node.nodeType != 1) {
node = node.parentNode;
}
let document = node.ownerDocument;
let window = document.defaultView;
let dict = this.getForWindow(this.getTopWindow(window));
// Check if we're in a frameset (including iframes).
if (window != this.getTopWindow(window)) {
if (!dict.frames.has(window)) {
dict.frames.set(window, {});
}
return true;
}
do {
let style = window.getComputedStyle(node);
if (
kFixed.has(style.position) ||
kFixed.has(style.overflow) ||
kFixed.has(style.overflowX) ||
kFixed.has(style.overflowY)
) {
return true;
}
node = node.parentNode;
} while (node && node != document.documentElement);
return false;
},
/**
* Read and store the rectangles that encompass the entire region of a range
* for use by the drawing function of the highlighter.
*
* @param {Range} range Range to fetch the rectangles from
* @param {Object} [dict] Dictionary of properties belonging to
* the currently active window
* @return {Set} Set of rects that were found for the range
*/
_getRangeRectsAndTexts(range, dict = null) {
let window = range.startContainer.ownerGlobal;
let bounds;
// If the window is part of a frameset, try to cache the bounds query.
if (dict && dict.frames.has(window)) {
let frameData = dict.frames.get(window);
bounds = frameData.bounds;
if (!bounds) {
bounds = frameData.bounds = this._getRootBounds(window);
}
} else {
bounds = this._getRootBounds(window);
}
let topBounds = this._getRootBounds(this.getTopWindow(window, true), false);
let rects = [];
// A range may consist of multiple rectangles, we can also do these kind of
// precise cut-outs. range.getBoundingClientRect() returns the fully
// encompassing rectangle, which is too much for our purpose here.
let { rectList, textList } = range.getClientRectsAndTexts();
for (let rect of rectList) {
rect = lazy.Rect.fromRect(rect);
rect.x += bounds.x;
rect.y += bounds.y;
// If the rect is not even visible from the top document, we can ignore it.
if (rect.intersects(topBounds)) {
rects.push(rect);
}
}
return { rectList: rects, textList };
},
/**
* Read and store the rectangles that encompass the entire region of a range
* for use by the drawing function of the highlighter and store them in the
* cache.
*
* @param {Range} range Range to fetch the rectangles from
* @param {Boolean} [checkIfDynamic] Whether we should check if the range
* is dynamic as per the rules in
* `_isInDynamicContainer()`. Optional,
* defaults to `true`
* @param {Object} [dict] Dictionary of properties belonging to
* the currently active window
* @return {Set} Set of rects that were found for the range
*/
_updateRangeRects(range, checkIfDynamic = true, dict = null) {
let window = range.startContainer.ownerGlobal;
let rectsAndTexts = this._getRangeRectsAndTexts(range, dict);
// Only fetch the rect at this point, if not passed in as argument.
dict = dict || this.getForWindow(this.getTopWindow(window));
let oldRectsAndTexts = dict.modalHighlightRectsMap.get(range);
dict.modalHighlightRectsMap.set(range, rectsAndTexts);
// Check here if we suddenly went down to zero rects from more than zero before,
// which indicates that we should re-iterate the document.
if (
oldRectsAndTexts &&
oldRectsAndTexts.rectList.length &&
!rectsAndTexts.rectList.length
) {
dict.detectedGeometryChange = true;
}
if (checkIfDynamic && this._isInDynamicContainer(range)) {
dict.dynamicRangesSet.add(range);
}
return rectsAndTexts;
},
/**
* Re-read the rectangles of the ranges that we keep track of separately,
* because they're enclosed by a position: fixed container DOM node or (i)frame.
*
* @param {Object} dict Dictionary of properties belonging to the currently
* active window
*/
_updateDynamicRangesRects(dict) {
// Reset the frame bounds cache.
for (let frameData of dict.frames.values()) {
frameData.bounds = null;
}
for (let range of dict.dynamicRangesSet) {
this._updateRangeRects(range, false, dict);
}
},
/**
* Update the content, position and style of the yellow current found range
* outline that floats atop the mask with the dimmed background.
* Rebuild it, if necessary, This will deactivate the animation between
* occurrences.
*
* @param {Object} dict Dictionary of properties belonging to the currently
* active window
*/
_updateRangeOutline(dict) {
let range = dict.currentFoundRange;
if (!range) {
return;
}
let fontStyle = this._getRangeFontStyle(range);
// Text color in the outline is determined by kModalStyles.
delete fontStyle.color;
let rectsAndTexts = this._updateRangeRects(range, true, dict);
let outlineAnonNode = dict.modalHighlightOutline;
let rectCount = rectsAndTexts.rectList.length;
let previousRectCount = dict.previousRangeRectsAndTexts.rectList.length;
// (re-)Building the outline is conditional and happens when one of the
// following conditions is met:
// 1. No outline nodes were built before, or
// 2. When the amount of rectangles to draw is different from before, or
// 3. When there's more than one rectangle to draw, because it's impossible
// to animate that consistently with AnonymousContent nodes.
let rebuildOutline =
!outlineAnonNode || rectCount !== previousRectCount || rectCount != 1;
dict.previousRangeRectsAndTexts = rectsAndTexts;
let window = this.getTopWindow(range.startContainer.ownerGlobal);
let document = window.document;
// First see if we need to and can remove the previous outline nodes.
if (rebuildOutline) {
this._removeRangeOutline(window);
}
// Abort when there's no text to highlight OR when it's the exact same range
// as the previous call and isn't inside a dynamic container.
if (
!rectsAndTexts.textList.length ||
(!rebuildOutline &&
dict.previousUpdatedRange == range &&
!dict.dynamicRangesSet.has(range))
) {
return;
}
let outlineBox;
if (rebuildOutline) {
// Create the main (yellow) highlight outline box.
outlineBox = document.createElementNS(kNSHTML, "div");
outlineBox.setAttribute("id", kModalOutlineId);
}
const kModalOutlineTextId = kModalOutlineId + "-text";
let i = 0;
for (let rect of rectsAndTexts.rectList) {
let text = rectsAndTexts.textList[i];
// Next up is to check of the outline box' borders will not overlap with
// rects that we drew before or will draw after this one.
// We're taking the width of the border into account, which is
// `kOutlineBoxBorderSize` pixels.
// When left and/ or right sides will overlap with the current, previous
// or next rect, make sure to make the necessary adjustments to the style.
// These adjustments will override the styles as defined in `kModalStyles.outlineNode`.
let intersectingSides = new Set();
let previous = rectsAndTexts.rectList[i - 1];
if (previous && rect.left - previous.right <= 2 * kOutlineBoxBorderSize) {
intersectingSides.add("left");
}
let next = rectsAndTexts.rectList[i + 1];
if (next && next.left - rect.right <= 2 * kOutlineBoxBorderSize) {
intersectingSides.add("right");
}
let borderStyles = [...intersectingSides].map(side => [
"border-" + side,
0,
]);
if (intersectingSides.size) {
borderStyles.push([
"margin",
`-${kOutlineBoxBorderSize}px 0 0 ${
intersectingSides.has("left") ? 0 : -kOutlineBoxBorderSize
}px !important`,
]);
borderStyles.push([
"border-radius",
(intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
"px " +
(intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
"px " +
(intersectingSides.has("right") ? 0 : kOutlineBoxBorderRadius) +
"px " +
(intersectingSides.has("left") ? 0 : kOutlineBoxBorderRadius) +
"px",
]);
}
let outlineStyle = this._getStyleString(
kModalStyles.outlineNode,
[
["top", rect.top + "px"],
["left", rect.left + "px"],
["height", rect.height + "px"],
["width", rect.width + "px"],
],
borderStyles,
lazy.kDebug ? kModalStyles.outlineNodeDebug : []
);
fontStyle.lineHeight = rect.height + "px";
let textStyle =
this._getStyleString(kModalStyles.outlineText) +
"; " +
this._getHTMLFontStyle(fontStyle);
if (rebuildOutline) {
let textBoxParent = outlineBox.appendChild(
document.createElementNS(kNSHTML, "div")
);
textBoxParent.setAttribute("id", kModalOutlineId + i);
textBoxParent.setAttribute("style", outlineStyle);
let textBox = document.createElementNS(kNSHTML, "span");
textBox.setAttribute("id", kModalOutlineTextId + i);
textBox.setAttribute("style", textStyle);
textBox.textContent = text;
textBoxParent.appendChild(textBox);
} else {
// Set the appropriate properties on the existing nodes, which will also
// activate the transitions.
outlineAnonNode.setAttributeForElement(
kModalOutlineId + i,
"style",
outlineStyle
);
outlineAnonNode.setAttributeForElement(
kModalOutlineTextId + i,
"style",
textStyle
);
outlineAnonNode.setTextContentForElement(kModalOutlineTextId + i, text);
}
++i;
}
if (rebuildOutline) {
dict.modalHighlightOutline = lazy.kDebug
? mockAnonymousContentNode(
(document.body || document.documentElement).appendChild(outlineBox)
)
: document.insertAnonymousContent(outlineBox);
}
if (dict.animateOutline && !this._isPageTooBig(dict)) {
let animation;
dict.animations = new Set();
for (let i = rectsAndTexts.rectList.length - 1; i >= 0; --i) {
animation = dict.modalHighlightOutline.setAnimationForElement(
kModalOutlineId + i,
Cu.cloneInto(kModalOutlineAnim.keyframes, window),
kModalOutlineAnim.duration
);
animation.onfinish = function() {
dict.animations.delete(this);
};
dict.animations.add(animation);
}
}
dict.animateOutline = false;
dict.ignoreNextContentChange = true;
dict.previousUpdatedRange = range;
},
/**
* Finish any currently playing animations on the found range outline node.
*
* @param {Object} dict Dictionary of properties belonging to the currently
* active window
*/
_finishOutlineAnimations(dict) {
if (!dict.animations) {
return;
}
for (let animation of dict.animations) {
animation.finish();
}
},
/**
* Safely remove the outline AnoymousContent node from the CanvasFrame.
*
* @param {nsIDOMWindow} window
*/
_removeRangeOutline(window) {
let dict = this.getForWindow(window);
if (!dict.modalHighlightOutline) {
return;
}
if (lazy.kDebug) {
dict.modalHighlightOutline.remove();
} else {
try {
window.document.removeAnonymousContent(dict.modalHighlightOutline);
} catch (ex) {}
}
dict.modalHighlightOutline = null;
},
/**
* Add a range to the list of ranges to highlight on, or cut out of, the dimmed
* background.
*
* @param {Range} range Range object that should be inspected
* @param {nsIDOMWindow} window Window object, whose DOM tree is being traversed
*/
_modalHighlight(range, controller, window) {
this._updateRangeRects(range);
this.show(window);
// We don't repaint the mask right away, but pass it off to a render loop of
// sorts.
this._scheduleRepaintOfMask(window);
},
/**
* Lazily insert the nodes we need as anonymous content into the CanvasFrame
* of a window.
*
* @param {nsIDOMWindow} window Window to draw in.
*/
_maybeCreateModalHighlightNodes(window) {
window = this.getTopWindow(window);
let dict = this.getForWindow(window);
if (dict.modalHighlightOutline) {
if (!dict.modalHighlightAllMask) {
// Make sure to at least show the dimmed background.
this._repaintHighlightAllMask(window, false);
this._scheduleRepaintOfMask(window);
} else {
this._scheduleRepaintOfMask(window, { contentChanged: true });
}
return;
}
let document = window.document;
// A hidden document doesn't accept insertAnonymousContent calls yet.
if (document.hidden) {
let onVisibilityChange = () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
this._maybeCreateModalHighlightNodes(window);
};
document.addEventListener("visibilitychange", onVisibilityChange);
return;
}
// Make sure to at least show the dimmed background.
this._repaintHighlightAllMask(window, false);
},
/**
* Build and draw the mask that takes care of the dimmed background that
* overlays the current page and the mask that cuts out all the rectangles of
* the ranges that were found.
*
* @param {nsIDOMWindow} window Window to draw in.
* @param {Boolean} [paintContent]
*/
_repaintHighlightAllMask(window, paintContent = true) {
window = this.getTopWindow(window);
let dict = this.getForWindow(window);
const kMaskId = kModalIdPrefix + "-findbar-modalHighlight-outlineMask";
if (!dict.modalHighlightAllMask) {
let document = window.document;
let maskNode = document.createElementNS(kNSHTML, "div");
maskNode.setAttribute("id", kMaskId);
dict.modalHighlightAllMask = lazy.kDebug
? mockAnonymousContentNode(
(document.body || document.documentElement).appendChild(maskNode)
)
: document.insertAnonymousContent(maskNode);
}
// Make sure the dimmed mask node takes the full width and height that's available.
let {
width,
height,
} = (dict.lastWindowDimensions = this._getWindowDimensions(window));
if (typeof dict.brightText != "boolean" || dict.updateAllRanges) {
this._detectBrightText(dict);
}
let maskStyle = this._getStyleString(
kModalStyles.maskNode,
[
["width", width + "px"],
["height", height + "px"],
],
dict.brightText ? kModalStyles.maskNodeBrightText : [],
paintContent ? kModalStyles.maskNodeTransition : [],
lazy.kDebug ? kModalStyles.maskNodeDebug : []
);
dict.modalHighlightAllMask.setAttributeForElement(
kMaskId,
"style",
maskStyle
);
this._updateRangeOutline(dict);
let allRects = [];
// When the user's busy scrolling the document, don't bother cutting out rectangles,
// because they're not going to keep up with scrolling speed anyway.
if (!dict.busyScrolling && (paintContent || dict.modalHighlightAllMask)) {
// No need to update dynamic ranges separately when we already about to
// update all of them anyway.
if (!dict.updateAllRanges) {
this._updateDynamicRangesRects(dict);
}
let DOMRect = window.DOMRect;
for (let [range, rectsAndTexts] of dict.modalHighlightRectsMap) {
if (!this.finder._fastFind.isRangeVisible(range, false)) {
continue;
}
if (dict.updateAllRanges) {
rectsAndTexts = this._updateRangeRects(range);
}
// If a geometry change was detected, we bail out right away here, because
// the current set of ranges has been invalidated.
if (dict.detectedGeometryChange) {
return;
}
for (let rect of rectsAndTexts.rectList) {
allRects.push(new DOMRect(rect.x, rect.y, rect.width, rect.height));
}
}
dict.updateAllRanges = false;
}
// We may also want to cut out zero rects, which effectively clears out the mask.
dict.modalHighlightAllMask.setCutoutRectsForElement(kMaskId, allRects);
// The reflow observer may ignore the reflow we cause ourselves here.
dict.ignoreNextContentChange = true;
},
/**
* Safely remove the mask AnoymousContent node from the CanvasFrame.
*
* @param {nsIDOMWindow} window
*/
_removeHighlightAllMask(window) {
window = this.getTopWindow(window);
let dict = this.getForWindow(window);
if (!dict.modalHighlightAllMask) {
return;
}
// If the current window isn't the one the content was inserted into, this
// will fail, but that's fine.
if (lazy.kDebug) {
dict.modalHighlightAllMask.remove();
} else {
try {
window.document.removeAnonymousContent(dict.modalHighlightAllMask);
} catch (ex) {}
}
dict.modalHighlightAllMask = null;
},
/**
* Check if the width or height of the current document is too big to handle
* for certain operations. This allows us to degrade gracefully when we expect
* the performance to be negatively impacted due to drawing-intensive operations.
*
* @param {Object} dict Dictionary of properties belonging to the currently
* active window
* @return {Boolean}
*/
_isPageTooBig(dict) {
let { height, width } = dict.lastWindowDimensions;
return height >= kPageIsTooBigPx || width >= kPageIsTooBigPx;
},
/**
* Doing a full repaint each time a range is delivered by the highlight iterator
* is way too costly, thus we pipe the frequency down to every
* `kModalHighlightRepaintLoFreqMs` milliseconds. If there are dynamic ranges
* found (see `_isInDynamicContainer()` for the definition), the frequency
* will be upscaled to `kModalHighlightRepaintHiFreqMs`.
*
* @param {nsIDOMWindow} window
* @param {Object} options Dictionary of painter hints that contains the
* following properties:
* {Boolean} contentChanged Whether the documents' content changed in the
* meantime. This happens when the DOM is updated
* whilst the page is loaded.
* {Boolean} scrollOnly TRUE when the page has scrolled in the meantime,
* which means that the dynamically positioned
* elements need to be repainted.
* {Boolean} updateAllRanges Whether to recalculate the rects of all ranges
* that were found up until now.
*/
_scheduleRepaintOfMask(
window,
{ contentChanged = false, scrollOnly = false, updateAllRanges = false } = {}
) {
if (!this.useModal()) {
return;
}
window = this.getTopWindow(window);
let dict = this.getForWindow(window);
// Bail out early if the repaint scheduler is paused or when we're supposed
// to ignore the next paint (i.e. content change).
if (
dict.repaintSchedulerState == kRepaintSchedulerPaused ||
(contentChanged && dict.ignor