/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "Color", "resource://gre/modules/Color.jsm" ); ChromeUtils.defineModuleGetter( this, "Rect", "resource://gre/modules/Geometry.jsm" ); XPCOMUtils.defineLazyGetter(this, "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.finder = finder; } FinderHighlighter.prototype = { get iterator() { return this.finder.iterator; }, // 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); }, /** * 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; }, /** * 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); } }, /** * 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 = 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 = 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 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 = 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, 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 = 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 (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 = 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 : [], 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 (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 or