Dactyloidae/mobile/ios/Client/Assets/FindInPage.js
2026-06-26 21:04:09 -07:00

292 lines
8.7 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
(function() {
"use strict";
var DEBUG_ENABLED = false;
var MATCH_HIGHLIGHT_ACTIVE = "#f19750";
var MATCH_HIGHLIGHT_INACTIVE = "#ffde49";
var SCROLL_INTERVAL_INCREMENT = 5;
var SCROLL_INTERVAL_DURATION = 400;
var SCROLL_OFFSET = 60;
var activeHighlightSpan = null;
var lastSearch;
var scrollInterval;
var activeIndex = 0;
var highlightSpans = [];
function debug(str) {
if (DEBUG_ENABLED) {
console.log("FindInPage: " + str);
}
}
function isElementVisible(elem) {
return getComputedStyle(elem).visibility !== "hidden";
}
function isRectInViewport(rect) {
var left = rect.left + document.body.scrollLeft;
var right = rect.right + document.body.scrollLeft;
var top = rect.top + document.body.scrollTop;
var bottom = rect.bottom + document.body.scrollTop;
return rect.width > 0 &&
rect.height > 0 &&
right >= 0 &&
bottom >= 0 &&
left <= document.body.scrollWidth &&
top <= document.body.scrollHeight;
}
function findMatches(text) {
// For case-insensitive matching.
var lowerText = text.toLocaleLowerCase();
var upperText = text.toLocaleUpperCase();
var matches = [];
var range = document.createRange();
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
var textLength = text.length;
var node;
while (node = walker.nextNode()) {
var textContent = node.textContent;
findString: for (var i = 0; i < textContent.length - textLength + 1; ++i) {
for (var j = 0; j < textLength; ++j) {
var nextChar = textContent[i + j];
if (lowerText[j] !== nextChar && upperText[j] !== nextChar) {
continue findString;
}
}
// This node is a TextNode, not an Element. Its parent is the nearest Element.
var element = node.parentNode;
// Find the rect of just the text for this match.
range.setStart(node, i);
range.setEnd(node, i + textLength);
var textRect = range.getBoundingClientRect();
// We have a match, but we need to make sure it's visible. The condition
// below checks the following cases:
// * If this element or any of its parents has style visibility hidden.
// The visibility style is inherited, so we need to check only this
// element and not all of its ancestors.
// * If the highlight will be outside of the page's bounds. We determine
// this by comparing the bounds of the text rect.
// * If the element style display is set to none. display:none collapses
// the element's space, so this will again be detected by looking at
// the text's rect: if the element is collapsed, the width and height
// will be zero.
if (isElementVisible(element) && isRectInViewport(textRect)) {
matches.push({ node: node, index: i });
// Resume searching after this match to prevent overlapping results.
i += textLength- 1;
}
}
}
return matches;
}
function flattenNode(node) {
var parent = node.parentNode;
if (!parent) {
return;
}
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
node.remove();
parent.normalize();
}
function clearHighlights() {
if (highlightSpans.length > 0) {
for (var span of highlightSpans) {
flattenNode(span);
}
highlightSpans = [];
}
activeHighlightSpan = null;
}
function highlightAllMatches(text) {
debug("Searching: " + text);
clearHighlights();
if (!text.trim()) {
webkit.messageHandlers.findInPageHandler.postMessage({ totalResults: 0 });
return;
}
var range = document.createRange();
var matches = findMatches(text);
var highlightTemplate = document.createElement("span");
highlightTemplate.style.backgroundColor = MATCH_HIGHLIGHT_INACTIVE;
// If there are multiple matches in the same node, inserting a highlight span before other matches
// in that node will invalidate other matches since the node itself changes. By iterating through
// results in reverse, we highlight matches last in the node first so earlier matches are unaffected.
for (var i = matches.length - 1; i >= 0; --i) {
var match = matches[i];
var highlight = highlightTemplate.cloneNode();
range.setStart(match.node, match.index);
range.setEnd(match.node, match.index + text.length);
range.surroundContents(highlight);
highlightSpans.unshift(highlight);
}
debug(matches.length + " highlighted rects created!");
webkit.messageHandlers.findInPageHandler.postMessage({ totalResults: matches.length });
}
function getIDForRect(rect) {
return rect.top + "," + rect.bottom + "," + rect.left + "," + rect.right;
}
function updateActiveHighlight() {
// Reset the color of the previous highlight.
if (activeHighlightSpan) {
activeHighlightSpan.style.backgroundColor = MATCH_HIGHLIGHT_INACTIVE;
}
if (!highlightSpans.length) {
return;
}
activeHighlightSpan = highlightSpans[activeIndex];
activeHighlightSpan.style.backgroundColor = MATCH_HIGHLIGHT_ACTIVE;
// Find the position of the element centered on the screen, then scroll to it.
var rect = activeHighlightSpan.getBoundingClientRect();
var top = SCROLL_OFFSET + rect.top + scrollY - window.innerHeight / 2;
var left = rect.left + scrollX - window.innerWidth / 2;
left = clamp(left, 0, document.body.scrollWidth);
top = clamp(top, 0, document.body.scrollHeight);
scrollToSelection(left, top, SCROLL_INTERVAL_DURATION);
debug("Scrolled to: " + left + ", " + top);
}
function scrollToSelection(left, top, duration) {
var time = 0;
var startX = scrollX;
var startY = scrollY;
clearInterval(scrollInterval);
scrollInterval = setInterval(function() {
var xStep = easeOutCubic(time, startX, left - startX, duration);
var yStep = easeOutCubic(time, startY, top - startY, duration);
window.scrollTo(xStep, yStep);
time += SCROLL_INTERVAL_INCREMENT;
if (time >= duration) {
clearInterval(scrollInterval);
}
}, SCROLL_INTERVAL_INCREMENT);
}
function easeOutCubic(currentTime, startValue, changeInValue, duration) {
return changeInValue * (Math.pow(currentTime / duration - 1, 3) + 1) + startValue;
}
function clamp(number, min, max) {
return Math.max(min, Math.min(number, max));
}
function updateSearch(text) {
if (lastSearch == text) {
// The text is the same, so we're either finding either the next or previous result.
var totalResults = highlightSpans.length;
activeIndex = (activeIndex + totalResults) % totalResults;
} else {
// Store the current active rect to decide which new match should be active.
var activeHighlightRect = null;
if (activeHighlightSpan) {
activeHighlightRect = activeHighlightSpan.getBoundingClientRect();
}
// The search text changed, so scan the page for new results.
highlightAllMatches(text);
// If we found a match at or after the last match, use that position
// instead of starting again from the top.
activeIndex = 0;
if (activeHighlightRect) {
for (var i = 0; i < highlightSpans.length; i++) {
var highlight = highlightSpans[i];
var highlightRect = highlight.getBoundingClientRect();
if ((highlightRect.top == activeHighlightRect.top && highlightRect.left >= activeHighlightRect.left) ||
(highlightRect.top > activeHighlightRect.top)) {
activeIndex = i;
break;
}
}
}
lastSearch = text;
}
// Update the UI with the current match index.
var currentResult = highlightSpans.length ? activeIndex + 1 : 0;
webkit.messageHandlers.findInPageHandler.postMessage({ currentResult: currentResult });
updateActiveHighlight();
}
if (!window.__firefox__) {
Object.defineProperty(window, '__firefox__', {
enumerable: false,
configurable: false,
writable: false,
value: {}
});
}
Object.defineProperty(window.__firefox__, 'find', {
enumerable: false,
configurable: false,
writable: false,
value: function(text) {
updateSearch(text);
}
});
Object.defineProperty(window.__firefox__, 'findNext', {
enumerable: false,
configurable: false,
writable: false,
value: function(text) {
activeIndex++;
updateSearch(text);
}
});
Object.defineProperty(window.__firefox__, 'findPrevious', {
enumerable: false,
configurable: false,
writable: false,
value: function(text) {
activeIndex--;
updateSearch(text);
}
});
Object.defineProperty(window.__firefox__, 'findDone', {
enumerable: false,
configurable: false,
writable: false,
value: function() {
clearHighlights();
lastSearch = null;
}
});
})();