summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/FinderHighlighter.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/FinderHighlighter.sys.mjs')
-rw-r--r--toolkit/modules/FinderHighlighter.sys.mjs2137
1 files changed, 2137 insertions, 0 deletions
diff --git a/toolkit/modules/FinderHighlighter.sys.mjs b/toolkit/modules/FinderHighlighter.sys.mjs
new file mode 100644
index 0000000000..2ebf718bd0
--- /dev/null
+++ b/toolkit/modules/FinderHighlighter.sys.mjs
@@ -0,0 +1,2137 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Color: "resource://gre/modules/Color.sys.mjs",
+ Rect: "resource://gre/modules/Geometry.sys.mjs",
+});
+ChromeUtils.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", "flex"],
+ [
+ "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.sys.mjs to take care of the
+ * 'Highlight All' feature, which can highlight all find occurrences in a page.
+ *
+ * @param {Finder} finder Finder.sys.mjs instance
+ * @param {boolean} useTop check and use top-level windows for rectangle
+ * computation, if possible.
+ */
+export 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) {
+ console.error(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.sys.mjs 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.ignoreNextContentChange)
+ ) {
+ dict.ignoreNextContentChange = false;
+ return;
+ }
+
+ let hasDynamicRanges = !!dict.dynamicRangesSet.size;
+ let pageIsTooBig = this._isPageTooBig(dict);
+ let repaintDynamicRanges =
+ (scrollOnly || contentChanged) && hasDynamicRanges && !pageIsTooBig;
+
+ // Determine scroll behavior and keep that state around.
+ let startedScrolling = !dict.busyScrolling && scrollOnly;
+ // When the user started scrolling the document, hide the other highlights.
+ if (startedScrolling) {
+ dict.busyScrolling = startedScrolling;
+ this._repaintHighlightAllMask(window);
+ }
+ // Whilst scrolling, suspend the repaint scheduler, but only when the page is
+ // too big or the find results contains ranges that are inside dynamic
+ // containers.
+ if (dict.busyScrolling && (pageIsTooBig || hasDynamicRanges)) {
+ dict.ignoreNextContentChange = true;
+ this._updateRangeOutline(dict);
+ // NB: we're not using `kRepaintSchedulerPaused` on purpose here, otherwise
+ // we'd break the `busyScrolling` detection (re-)using the timer.
+ if (dict.modalRepaintScheduler) {
+ window.clearTimeout(dict.modalRepaintScheduler);
+ dict.modalRepaintScheduler = null;
+ }
+ }
+
+ // When we request to repaint unconditionally, we mean to call
+ // `_repaintHighlightAllMask()` right after the timeout.
+ if (!dict.unconditionalRepaintRequested) {
+ dict.unconditionalRepaintRequested =
+ !contentChanged || repaintDynamicRanges;
+ }
+ // Some events, like a resize, call for recalculation of all the rects of all ranges.
+ if (!dict.updateAllRanges) {
+ dict.updateAllRanges = updateAllRanges;
+ }
+
+ if (dict.modalRepaintScheduler) {
+ return;
+ }
+
+ let timeoutMs =
+ hasDynamicRanges && !dict.busyScrolling
+ ? kModalHighlightRepaintHiFreqMs
+ : kModalHighlightRepaintLoFreqMs;
+ dict.modalRepaintScheduler = window.setTimeout(() => {
+ dict.modalRepaintScheduler = null;
+ dict.repaintSchedulerState = kRepaintSchedulerStopped;
+ dict.busyScrolling = false;
+
+ let pageContentChanged = dict.detectedGeometryChange;
+ if (!pageContentChanged && !pageIsTooBig) {
+ let { width: previousWidth, height: previousHeight } =
+ dict.lastWindowDimensions;
+ let { width, height } = (dict.lastWindowDimensions =
+ this._getWindowDimensions(window));
+ pageContentChanged =
+ dict.detectedGeometryChange ||
+ Math.abs(previousWidth - width) > kContentChangeThresholdPx ||
+ Math.abs(previousHeight - height) > kContentChangeThresholdPx;
+ }
+ dict.detectedGeometryChange = false;
+ // When the page has changed significantly enough in size, we'll restart
+ // the iterator with the same parameters as before to find us new ranges.
+ if (pageContentChanged && !pageIsTooBig) {
+ this.iterator.restart(this.finder);
+ }
+
+ if (
+ dict.unconditionalRepaintRequested ||
+ (dict.modalHighlightRectsMap.size && pageContentChanged)
+ ) {
+ dict.unconditionalRepaintRequested = false;
+ this._repaintHighlightAllMask(window);
+ }
+ }, timeoutMs);
+ dict.repaintSchedulerState = kRepaintSchedulerRunning;
+ },
+
+ /**
+ * Add event listeners to the content which will cause the modal highlight
+ * AnonymousContent to be re-painted or hidden.
+ *
+ * @param {nsIDOMWindow} window
+ */
+ _addModalHighlightListeners(window) {
+ window = this.getTopWindow(window);
+ let dict = this.getForWindow(window);
+ if (dict.highlightListeners) {
+ return;
+ }
+
+ dict.highlightListeners = [
+ this._scheduleRepaintOfMask.bind(this, window, { contentChanged: true }),
+ this._scheduleRepaintOfMask.bind(this, window, { updateAllRanges: true }),
+ this._scheduleRepaintOfMask.bind(this, window, { scrollOnly: true }),
+ this.hide.bind(this, window, null),
+ () => (dict.busySelecting = true),
+ () => {
+ if (window.document.hidden) {
+ dict.repaintSchedulerState = kRepaintSchedulerPaused;
+ } else if (dict.repaintSchedulerState == kRepaintSchedulerPaused) {
+ dict.repaintSchedulerState = kRepaintSchedulerRunning;
+ this._scheduleRepaintOfMask(window);
+ }
+ },
+ ];
+ let target = this.iterator._getDocShell(window).chromeEventHandler;
+ target.addEventListener("MozAfterPaint", dict.highlightListeners[0]);
+ target.addEventListener("resize", dict.highlightListeners[1]);
+ target.addEventListener("scroll", dict.highlightListeners[2], {
+ capture: true,
+ passive: true,
+ });
+ target.addEventListener("click", dict.highlightListeners[3]);
+ target.addEventListener("selectstart", dict.highlightListeners[4]);
+ window.document.addEventListener(
+ "visibilitychange",
+ dict.highlightListeners[5]
+ );
+ },
+
+ /**
+ * Remove event listeners from content.
+ *
+ * @param {nsIDOMWindow} window
+ */
+ _removeModalHighlightListeners(window) {
+ window = this.getTopWindow(window);
+ let dict = this.getForWindow(window);
+ if (!dict.highlightListeners) {
+ return;
+ }
+
+ let target = this.iterator._getDocShell(window).chromeEventHandler;
+ target.removeEventListener("MozAfterPaint", dict.highlightListeners[0]);
+ target.removeEventListener("resize", dict.highlightListeners[1]);
+ target.removeEventListener("scroll", dict.highlightListeners[2], {
+ capture: true,
+ passive: true,
+ });
+ target.removeEventListener("click", dict.highlightListeners[3]);
+ target.removeEventListener("selectstart", dict.highlightListeners[4]);
+ window.document.removeEventListener(
+ "visibilitychange",
+ dict.highlightListeners[5]
+ );
+
+ dict.highlightListeners = null;
+ },
+
+ /**
+ * For a given node returns its editable parent or null if there is none.
+ * It's enough to check if node is a text node and its parent's parent is
+ * an input or textarea.
+ *
+ * @param node the node we want to check
+ * @returns the first node in the parent chain that is editable,
+ * null if there is no such node
+ */
+ _getEditableNode(node) {
+ if (
+ node.nodeType === node.TEXT_NODE &&
+ node.parentNode &&
+ node.parentNode.parentNode &&
+ (ChromeUtils.getClassName(node.parentNode.parentNode) ===
+ "HTMLInputElement" ||
+ ChromeUtils.getClassName(node.parentNode.parentNode) ===
+ "HTMLTextAreaElement")
+ ) {
+ return node.parentNode.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Add ourselves as an nsIEditActionListener and nsIDocumentStateListener for
+ * a given editor
+ *
+ * @param editor the editor we'd like to listen to
+ */
+ _addEditorListeners(editor) {
+ if (!this._editors) {
+ this._editors = [];
+ this._stateListeners = [];
+ }
+
+ let existingIndex = this._editors.indexOf(editor);
+ if (existingIndex == -1) {
+ let x = this._editors.length;
+ this._editors[x] = editor;
+ this._stateListeners[x] = this._createStateListener();
+ this._editors[x].addEditActionListener(this);
+ this._editors[x].addDocumentStateListener(this._stateListeners[x]);
+ }
+ },
+
+ /**
+ * Helper method to unhook listeners, remove cached editors
+ * and keep the relevant arrays in sync
+ *
+ * @param idx the index into the array of editors/state listeners
+ * we wish to remove
+ */
+ _unhookListenersAtIndex(idx) {
+ this._editors[idx].removeEditActionListener(this);
+ this._editors[idx].removeDocumentStateListener(this._stateListeners[idx]);
+ this._editors.splice(idx, 1);
+ this._stateListeners.splice(idx, 1);
+ if (!this._editors.length) {
+ delete this._editors;
+ delete this._stateListeners;
+ }
+ },
+
+ /**
+ * Remove ourselves as an nsIEditActionListener and
+ * nsIDocumentStateListener from a given cached editor
+ *
+ * @param editor the editor we no longer wish to listen to
+ */
+ _removeEditorListeners(editor) {
+ // editor is an editor that we listen to, so therefore must be
+ // cached. Find the index of this editor
+ let idx = this._editors.indexOf(editor);
+ if (idx == -1) {
+ return;
+ }
+ // Now unhook ourselves, and remove our cached copy
+ this._unhookListenersAtIndex(idx);
+ },
+
+ /*
+ * nsIEditActionListener logic follows
+ *
+ * We implement this interface to allow us to catch the case where
+ * the findbar found a match in a HTML <input> or <textarea>. If the
+ * user adjusts the text in some way, it will no longer match, so we
+ * want to remove the highlight, rather than have it expand/contract
+ * when letters are added or removed.
+ */
+
+ /**
+ * Helper method used to check whether a selection intersects with
+ * some highlighting
+ *
+ * @param selectionRange the range from the selection to check
+ * @param findRange the highlighted range to check against
+ * @returns true if they intersect, false otherwise
+ */
+ _checkOverlap(selectionRange, findRange) {
+ if (!selectionRange || !findRange) {
+ return false;
+ }
+ // The ranges overlap if one of the following is true:
+ // 1) At least one of the endpoints of the deleted selection
+ // is in the find selection
+ // 2) At least one of the endpoints of the find selection
+ // is in the deleted selection
+ if (
+ findRange.isPointInRange(
+ selectionRange.startContainer,
+ selectionRange.startOffset
+ )
+ ) {
+ return true;
+ }
+ if (
+ findRange.isPointInRange(
+ selectionRange.endContainer,
+ selectionRange.endOffset
+ )
+ ) {
+ return true;
+ }
+ if (
+ selectionRange.isPointInRange(
+ findRange.startContainer,
+ findRange.startOffset
+ )
+ ) {
+ return true;
+ }
+ if (
+ selectionRange.isPointInRange(findRange.endContainer, findRange.endOffset)
+ ) {
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Helper method to determine if an edit occurred within a highlight
+ *
+ * @param selection the selection we wish to check
+ * @param node the node we want to check is contained in selection
+ * @param offset the offset into node that we want to check
+ * @returns the range containing (node, offset) or null if no ranges
+ * in the selection contain it
+ */
+ _findRange(selection, node, offset) {
+ let rangeCount = selection.rangeCount;
+ let rangeidx = 0;
+ let foundContainingRange = false;
+ let range = null;
+
+ // Check to see if this node is inside one of the selection's ranges
+ while (!foundContainingRange && rangeidx < rangeCount) {
+ range = selection.getRangeAt(rangeidx);
+ if (range.isPointInRange(node, offset)) {
+ foundContainingRange = true;
+ break;
+ }
+ rangeidx++;
+ }
+
+ if (foundContainingRange) {
+ return range;
+ }
+
+ return null;
+ },
+
+ // Start of nsIEditActionListener implementations
+
+ WillDeleteText(textNode, offset, length) {
+ let editor = this._getEditableNode(textNode).editor;
+ let controller = editor.selectionController;
+ let fSelection = controller.getSelection(
+ Ci.nsISelectionController.SELECTION_FIND
+ );
+ let range = this._findRange(fSelection, textNode, offset);
+
+ if (range) {
+ // Don't remove the highlighting if the deleted text is at the
+ // end of the range
+ if (textNode != range.endContainer || offset != range.endOffset) {
+ // Text within the highlight is being removed - the text can
+ // no longer be a match, so remove the highlighting
+ fSelection.removeRange(range);
+ if (fSelection.rangeCount == 0) {
+ this._removeEditorListeners(editor);
+ }
+ }
+ }
+ },
+
+ DidInsertText(textNode, offset, aString) {
+ let editor = this._getEditableNode(textNode).editor;
+ let controller = editor.selectionController;
+ let fSelection = controller.getSelection(
+ Ci.nsISelectionController.SELECTION_FIND
+ );
+ let range = this._findRange(fSelection, textNode, offset);
+
+ if (range) {
+ // If the text was inserted before the highlight
+ // adjust the highlight's bounds accordingly
+ if (textNode == range.startContainer && offset == range.startOffset) {
+ range.setStart(
+ range.startContainer,
+ range.startOffset + aString.length
+ );
+ } else if (textNode != range.endContainer || offset != range.endOffset) {
+ // The edit occurred within the highlight - any addition of text
+ // will result in the text no longer being a match,
+ // so remove the highlighting
+ fSelection.removeRange(range);
+ if (fSelection.rangeCount == 0) {
+ this._removeEditorListeners(editor);
+ }
+ }
+ }
+ },
+
+ WillDeleteRanges(rangesToDelete) {
+ let { editor } = this._getEditableNode(rangesToDelete[0].startContainer);
+ let controller = editor.selectionController;
+ let fSelection = controller.getSelection(
+ Ci.nsISelectionController.SELECTION_FIND
+ );
+
+ let shouldDelete = {};
+ let numberOfDeletedSelections = 0;
+ let numberOfMatches = fSelection.rangeCount;
+
+ // We need to test if any ranges to be deleted
+ // are in any of the ranges of the find selection
+ // Usually both selections will only contain one range, however
+ // either may contain more than one.
+
+ for (let fIndex = 0; fIndex < numberOfMatches; fIndex++) {
+ shouldDelete[fIndex] = false;
+ let fRange = fSelection.getRangeAt(fIndex);
+
+ for (let selRange of rangesToDelete) {
+ if (shouldDelete[fIndex]) {
+ continue;
+ }
+
+ let doesOverlap = this._checkOverlap(selRange, fRange);
+ if (doesOverlap) {
+ shouldDelete[fIndex] = true;
+ numberOfDeletedSelections++;
+ }
+ }
+ }
+
+ // OK, so now we know what matches (if any) are in the selection
+ // that is being deleted. Time to remove them.
+ if (!numberOfDeletedSelections) {
+ return;
+ }
+
+ for (let i = numberOfMatches - 1; i >= 0; i--) {
+ if (shouldDelete[i]) {
+ fSelection.removeRange(fSelection.getRangeAt(i));
+ }
+ }
+
+ // Remove listeners if no more highlights left
+ if (!fSelection.rangeCount) {
+ this._removeEditorListeners(editor);
+ }
+ },
+
+ /*
+ * nsIDocumentStateListener logic follows
+ *
+ * When attaching nsIEditActionListeners, there are no guarantees
+ * as to whether the findbar or the documents in the browser will get
+ * destructed first. This leads to the potential to either leak, or to
+ * hold on to a reference an editable element's editor for too long,
+ * preventing it from being destructed.
+ *
+ * However, when an editor's owning node is being destroyed, the editor
+ * sends out a DocumentWillBeDestroyed notification. We can use this to
+ * clean up our references to the object, to allow it to be destroyed in a
+ * timely fashion.
+ */
+
+ /**
+ * Unhook ourselves when one of our state listeners has been called.
+ * This can happen in 4 cases:
+ * 1) The document the editor belongs to is navigated away from, and
+ * the document is not being cached
+ *
+ * 2) The document the editor belongs to is expired from the cache
+ *
+ * 3) The tab containing the owning document is closed
+ *
+ * 4) The <input> or <textarea> that owns the editor is explicitly
+ * removed from the DOM
+ *
+ * @param the listener that was invoked
+ */
+ _onEditorDestruction(aListener) {
+ // First find the index of the editor the given listener listens to.
+ // The listeners and editors arrays must always be in sync.
+ // The listener will be in our array of cached listeners, as this
+ // method could not have been called otherwise.
+ let idx = 0;
+ while (this._stateListeners[idx] != aListener) {
+ idx++;
+ }
+
+ // Unhook both listeners
+ this._unhookListenersAtIndex(idx);
+ },
+
+ /**
+ * Creates a unique document state listener for an editor.
+ *
+ * It is not possible to simply have the findbar implement the
+ * listener interface itself, as it wouldn't have sufficient information
+ * to work out which editor was being destroyed. Therefore, we create new
+ * listeners on the fly, and cache them in sync with the editors they
+ * listen to.
+ */
+ _createStateListener() {
+ return {
+ findbar: this,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIDocumentStateListener"]),
+
+ NotifyDocumentWillBeDestroyed() {
+ this.findbar._onEditorDestruction(this);
+ },
+
+ // Unimplemented
+ notifyDocumentStateChanged(aDirty) {},
+ };
+ },
+};