From 5f9f23cdadad5a7d89a169c5ce2f5de183c2ff3f Mon Sep 17 00:00:00 2001 From: Sunshine Date: Tue, 27 Oct 2026 11:22:09 -0400 Subject: [PATCH] add ability to search for text within pages --- README.md | 3 +- inc/liquidappfind.hpp | 116 +++++ inc/liquidappwindow.hpp | 8 + inc/lqd.h | 10 + liquid.pro | 2 + res/resources.qrc | 1 + res/scripts/find.js | 1053 +++++++++++++++++++++++++++++++++++++++ src/liquidappfind.cpp | 549 ++++++++++++++++++++ src/liquidappwindow.cpp | 125 +++++ 9 files changed, 1866 insertions(+), 1 deletion(-) create mode 100644 inc/liquidappfind.hpp create mode 100644 res/scripts/find.js create mode 100644 src/liquidappfind.cpp diff --git a/README.md b/README.md index 8e98597..2dd2024 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ What gets stored (if allowed): cookies. | Ability to remove window frame | ✅ | ❌ | | | Minimalistic tabless design | ✅ | ❌ | | | Fine zoom | ✅ | ❌ | | -| Search within the page | ❌ | ✅ | | +| Search within the page | ✅ | ✅ | Liquid keeps finding matches as pages change, and opens it as soon as you start typing | | Permanently mute website | ✅ | ❌ | Browsers automatically unmute, Liquid remembers the state | | Ability to go full-screen | ✅ | ✅ | | | Full control over full-screen capabilities | ✅ | ❌ | Liquid acts more like a mobile device simulator when it comes to full-screen | @@ -71,6 +71,7 @@ What gets stored (if allowed): cookies. | Go back | `Ctrl`+`←` | `Backspace` | | Go forward | `Ctrl`+`→` | | | Open link using default web browser | `Ctrl`+_click_ | | +| Find on page | `Ctrl`+`F` | _start typing_ | | Edit app settings | `Ctrl`+`,` | | Most of these can also be found in the right-click menu. diff --git a/inc/liquidappfind.hpp b/inc/liquidappfind.hpp new file mode 100644 index 0000000..a4572df --- /dev/null +++ b/inc/liquidappfind.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class QLabel; +class QTimer; +class QWebEngineView; + +// Text input of the search, which also takes care of going from one match to another +class LiquidAppFindInput : public QLineEdit +{ + Q_OBJECT + +public: + explicit LiquidAppFindInput(QWidget* parent = Q_NULLPTR); + +signals: + void nextRequested(void); + void previousRequested(void); + +protected: + bool event(QEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; +}; + +// Dots around the search box, where rays come out of (they're drawn on the page, see find.js): this is what makes them +// clickable. It covers the search box, but only its dots take the mouse, everything else goes through to what's below. +class LiquidAppFindDots : public QWidget +{ + Q_OBJECT + +public: + explicit LiquidAppFindDots(QWidget* parent = Q_NULLPTR); + + // In the web view's coordinates (null ones are of matches that have no ray) + void setDots(const QList& dots, const QList& hasDot, const QRect& searchBoxGeometry); + +signals: + void hovered(const int index); + void clicked(const int index); + +protected: + void mouseMoveEvent(QMouseEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + int getDotAt(const QPointF& position) const; + void setHoveredIndex(const int index); + + QList dots; + QList hasDot; + int hoveredIndex = -1; +}; + +// Find in page: a search box floating in the middle of the web view, with every match highlighted, and connected to it +// by a ray, all of which inverts the page underneath, like an X-ray. Keeps on looking for as long as it's open, +// so whatever shows up on the page later (including what gets typed into text inputs) gets found too. +class LiquidAppFind : public QObject +{ + Q_OBJECT + +public: + explicit LiquidAppFind(QWebEngineView* webView); + + // Opens (or focuses) the search box; the text replaces the query, or gets added to it (for find-as-you-type) + void open(const QString& text = QString(), const bool append = false); + void close(void); + bool isOpen(void) const; + + // Snapshots are of the page, not of the search (it stays open meanwhile) + void hideForSnapshot(void); + void showAfterSnapshot(void); + + // The web view has been resized + void updateGeometry(void); + + // Something may have moved (e.g. the page has been scrolled), find out where the matches are now + void scheduleUpdate(void); + + // The search box itself: the input, and how many matches there are + static QWidget* createSearchBox(QWidget* parent, LiquidAppFindInput** input, QLabel** countLabel); + + // Whether the search box (drawn by the page, inverting it) is light, and its text has to be dark + static void setSearchBoxLight(QWidget* searchBox, const bool isLight); + +private: + void update(void); + void onUpdated(const QString& json, const qint64 duration); + void updateCountLabel(const int total, const int current); + void focusPage(void); + void setSearchBoxLight(const bool isLight); + + QWebEngineView* webView; + QWidget* searchBox; + LiquidAppFindInput* input; + QLabel* countLabel; + LiquidAppFindDots* dots; + QTimer* pollTimer; + QTimer* scheduleTimer; + QString script; + + bool isOpenBool = false; + bool isHiddenForSnapshot = false; + bool hadFocusBeforeSnapshot = false; + bool isUpdating = false; + bool isUpdatePending = false; + int pendingMove = 0; + int pendingGoTo = -1; // Match whose dot has been clicked + int hoveredIndex = -1; +}; diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index 1f4ccbf..7d09737 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -17,6 +17,7 @@ class LiquidAppCookieJar; class QLocalSocket; +class LiquidAppFind; class LiquidAppIcon; class LiquidAppIconFetcher; class LiquidAppLocalStorage; @@ -108,6 +109,12 @@ public slots: LiquidAppCookieJar* liquidAppCookieJar = Q_NULLPTR; bool iconFetchAttempted = false; + // Find in page (also opens upon typing while nothing that takes text is focused) + LiquidAppFind* find = Q_NULLPTR; + QString typedText; + QTimer* typeToFindTimer = Q_NULLPTR; + void onTypedOutsideOfInputs(QKeyEvent* event); + // Settings dialog, while it's open QPointer settingsDialog; @@ -142,6 +149,7 @@ public slots: QAction* reloadAction2; QAction* savePageAction; QAction* editSettingsAction; + QAction* findAction; QAction* stopLoadingOrExitFullScreenModeAction; QAction* takeSnapshotAction; QAction* takeSnapshotFullPageAction; diff --git a/inc/lqd.h b/inc/lqd.h index c1b0179..01eb55d 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -57,12 +57,21 @@ #define LQD_UI_TOOLTIP_MAX_LENGTH 2000 // Characters of long values shown in tooltips #define LQD_STORAGE_REFRESH_DELAY 300 // Milliseconds to wait for the .liquid file to settle before re-reading it in settings #define LQD_LOCAL_STORAGE_QUIT_TIMEOUT 1000 // Milliseconds to wait for reading local storage before quitting +#define LQD_FIND_POLL_INTERVAL 150 // Milliseconds between looking for matches again (pages change) +#define LQD_FIND_HIDE_TIMEOUT 500 // Milliseconds to wait for the page to hide what it draws for the search +#define LQD_FIND_REPAINT_DELAY 50 // Milliseconds for the page to get repainted without it +#define LQD_FIND_DOT_HIT_RADIUS 7 // Pixels, how close to a dot clicking it counts +#define LQD_FIND_BOX_WIDTH 440 // Pixels +#define LQD_FIND_BOX_HEIGHT 42 // Pixels +#define LQD_FIND_BOX_MARGIN 16 // Pixels, between the search box and the edges of small windows +#define LQD_TYPE_TO_FIND_DELAY 50 // Milliseconds to wait for the page to handle typed keys first #define LQD_INSTANCE_STOP_TIMEOUT 5000 // Milliseconds to wait for a Liquid app to quit /* Names of scripts Liquid injects into web pages */ #define LQD_SCRIPT_NAME_ADDITIONAL_CSS "liquid-additional-css" #define LQD_SCRIPT_NAME_ADDITIONAL_JS "liquid-additional-js" #define LQD_SCRIPT_NAME_LOCAL_STORAGE "liquid-local-storage" +#define LQD_SCRIPT_NAME_KEY_RECORDER "liquid-key-recorder" /* Textual icons */ #define LQD_ICON_ADD "➕" @@ -140,6 +149,7 @@ #define LQD_KBD_SEQ_QUIT_2 "Ctrl+W" #define LQD_KBD_SEQ_SAVE_PAGE "Ctrl+S" #define LQD_KBD_SEQ_EDIT_SETTINGS "Ctrl+," +#define LQD_KBD_SEQ_FIND "Ctrl+F" #define LQD_KBD_SEQ_TAKE_SNAPSHOT_VECTOR "Ctrl+Shift+S" #define LQD_KBD_SEQ_ZOOM_LVL_INC "Ctrl+=" #define LQD_KBD_SEQ_ZOOM_LVL_INC_FINE "Ctrl+Shift+=" diff --git a/liquid.pro b/liquid.pro index e2dbcda..89a4a37 100644 --- a/liquid.pro +++ b/liquid.pro @@ -25,6 +25,7 @@ INCLUDEPATH += $${INC_DIR} HEADERS += inc/lqd.h \ inc/liquid.hpp \ inc/liquidappfile.hpp \ + inc/liquidappfind.hpp \ inc/liquidappicon.hpp \ inc/liquidapplocalstorage.hpp \ inc/liquidappcookiejar.hpp \ @@ -37,6 +38,7 @@ HEADERS += inc/lqd.h \ SOURCES += src/liquid.cpp \ src/liquidappfile.cpp \ + src/liquidappfind.cpp \ src/liquidappicon.cpp \ src/liquidapplocalstorage.cpp \ src/liquidappcookiejar.cpp \ diff --git a/res/resources.qrc b/res/resources.qrc index 0e14e7c..9b0e497 100644 --- a/res/resources.qrc +++ b/res/resources.qrc @@ -3,6 +3,7 @@ images/checkers.svg images/liquid.svg + scripts/find.js scripts/html2svg.js styles/base.qss styles/dark.qss diff --git a/res/scripts/find.js b/res/scripts/find.js new file mode 100644 index 0000000..3a29fff --- /dev/null +++ b/res/scripts/find.js @@ -0,0 +1,1053 @@ +// Liquid's find-in-page engine. +// +// Runs in Liquid's own JS world: the page can neither see nor interfere with it, and it works even if JavaScript +// is disabled for the page itself. +// +// Matching is smart-case (case-insensitive unless the query has capital letters) and smart-accents (ignores diacritics +// unless the query has some); whitespace is collapsed (so line breaks in the source don't matter), matches may span +// across inline elements (e.g. "Liquid"), but not across blocks (e.g. paragraphs), and open shadow roots +// get searched too, as do the current values of text inputs (except for passwords). Matches that aren't rendered +// (e.g. hidden, collapsed) don't count. +// +// Matches get highlighted, and connected to the search box (Liquid's, floating above the page) by rays, all of which +// invert whatever's underneath them, like an X-ray: they're drawn in a layer of their own on top of the page, +// isolated in a closed shadow root (the only thing that gets added to the document, and removed once done). + +(function () { + if (window.__liquidFind) { + return; + } + + // Elements whose text is never shown as such (text inputs get searched separately, through their values) + const SKIPPED_ELEMENTS = new Set([ + 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'TITLE', 'IFRAME', 'OBJECT', 'EMBED', + 'CANVAS', 'VIDEO', 'AUDIO', 'SELECT', 'OPTION', 'TEXTAREA', 'INPUT', + ]); + + // Elements text doesn't flow across (assumed from their tag rather than from their computed style, which is costly) + const BLOCK_ELEMENTS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BODY', 'BUTTON', 'CAPTION', 'DD', 'DETAILS', 'DIALOG', 'DIV', 'DL', + 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', + 'HR', 'HTML', 'LI', 'LEGEND', 'MAIN', 'MENU', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', + 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL', + ]); + + // Inputs that take text (passwords are never searched) + const TEXT_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number']); + + const SEPARATOR = '\u0000'; // Between blocks (queries never contain it, so matches never cross blocks) + const MAX_MATCHES = 5000; + const MAX_REPORTED = 500; // Matches drawn (the ones nearest to the middle of the viewport) + const MIN_RESCAN_INTERVAL = 250; // Milliseconds, for pages that keep changing all the time + const MAX_RESCAN_SHARE = 0.2; // Pages that take long to search get searched less often (at most 20% of the time) + const SAFETY_RESCAN_INTERVAL = 3000; // Milliseconds, in case some change has gone unnoticed + const CURRENT_MATCH_POSITION = 0.3; // Going to a match puts it there (vertically), above the search box + const DOT_OFFSET = 7; // CSS pixels between the edge of the search box and the dots rays come out of + const BACKDROP_BLUR = 12; // CSS pixels, how much the search box blurs what's behind it + const DIACRITICS = /\p{M}/gu; + const WHITESPACE = /\s/; + const SVG = 'http://www.w3.org/2000/svg'; + + const state = { + key: null, // Query, to tell whether the matches have to be found again + fold: null, + needle: '', + entries: [], // Ranges of text matches, and text inputs, in document order + current: null, // The current match (an object that stays the same while the match is there) + reported: [], // Matches drawn last time, in that order + drawn: null, // What's been drawn last time (for redrawing, e.g. upon hovering a dot) + isDirty: true, + lastScanTime: 0, + lastScanDuration: 0, + }; + + // Any change to the document may add or remove matches + const observer = new MutationObserver(function () { + state.isDirty = true; + }); + observer.observe(document, { subtree: true, childList: true, characterData: true }); + + /////////////// + // Matching // + /////////////// + + function getFolder(query) { + const isCaseSensitive = query !== query.toLowerCase(); + const isAccentSensitive = query.normalize('NFD') !== query.normalize('NFD').replace(DIACRITICS, ''); + + return function (text) { + // Fast path for ASCII, which is most of what there is + if (text.length === 1 && text.charCodeAt(0) < 128) { + return isCaseSensitive ? text : text.toLowerCase(); + } + + let folded = text; + if (!isAccentSensitive) { + folded = folded.normalize('NFD').replace(DIACRITICS, ''); + } + if (!isCaseSensitive) { + folded = folded.toLowerCase(); + } + + return folded; + }; + } + + // Folds the text, collapsing whitespace, calling emit(character, start, end) for every resulting character, + // with where it comes from in the original text; returns whether it ended with a space + function foldText(text, fold, isAfterSpace, emit) { + for (let i = 0; i < text.length; i++) { + let character = text[i]; + const code = text.charCodeAt(i); + + // Keep surrogate pairs together + if (code >= 0xD800 && code <= 0xDBFF && i + 1 < text.length) { + character = text.substr(i, 2); + } + + if (WHITESPACE.test(character)) { + if (!isAfterSpace) { + emit(' ', i, i + 1); + isAfterSpace = true; + } + } else { + const folded = fold(character); + for (let j = 0; j < folded.length; j++) { + emit(folded[j], i, i + character.length); + } + isAfterSpace = false; + } + + i += character.length - 1; + } + + return isAfterSpace; + } + + function getParent(element) { + return element.parentElement || (element.getRootNode() instanceof ShadowRoot ? element.getRootNode().host : null); + } + + function getBlock(element, cache) { + let block = cache.get(element); + + if (block === undefined) { + block = element; + while (block && !BLOCK_ELEMENTS.has(block.tagName)) { + block = getParent(block); + } + cache.set(element, block); + } + + return block; + } + + function isTextInput(element) { + if (element.tagName === 'TEXTAREA') { + return true; + } + + return element.tagName === 'INPUT' && TEXT_INPUT_TYPES.has((element.getAttribute('type') || '').toLowerCase()); + } + + // Puts all of the document's text into one string (folded, whitespace collapsed), remembering where each of its + // characters came from, and where text inputs are + function index(fold) { + const textNodes = []; + const nodeIndexes = []; + const startOffsets = []; + const endOffsets = []; + const parts = []; + const inputs = []; // [element, position] + const blockCache = new Map(); + let lastBlock = null; + let isAfterSpace = true; + + function separate() { + parts.push(SEPARATOR); + nodeIndexes.push(-1); + startOffsets.push(0); + endOffsets.push(0); + isAfterSpace = true; + } + + // Depth-first, in document order, into open shadow roots too + const stack = [document.documentElement]; + while (stack.length > 0) { + const node = stack.pop(); + + if (node.nodeType === Node.TEXT_NODE) { + if (node.data.length === 0 || node.parentElement === null) { + continue; + } + + const block = getBlock(node.parentElement, blockCache); + if (block !== lastBlock && parts.length > 0) { + separate(); + } + lastBlock = block; + + const nodeIndex = textNodes.length; + textNodes.push(node); + isAfterSpace = foldText(node.data, fold, isAfterSpace, function (character, start, end) { + parts.push(character); + nodeIndexes.push(nodeIndex); + startOffsets.push(start); + endOffsets.push(end); + }); + } else if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + if (node.nodeType === Node.ELEMENT_NODE) { + if (node === layer.host) { + continue; + } + + if (SKIPPED_ELEMENTS.has(node.tagName)) { + if (isTextInput(node)) { + inputs.push([node, parts.length]); + } + continue; + } + + // Line breaks end lines, text doesn't flow across them + if (node.tagName === 'BR') { + separate(); + continue; + } + } + + const children = node.childNodes; + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } + + // Shadow trees get shown in place of their hosts' children (roughly) + if (node.shadowRoot) { + stack.push(node.shadowRoot); + } + } + } + + return { text: parts.join(''), textNodes, nodeIndexes, startOffsets, endOffsets, inputs }; + } + + function getNeedle(query, fold) { + return Array.from(query).map(fold).join('').replace(/\s+/g, ' '); + } + + // Text matches (as ranges) and text inputs (whose values get searched every time, as they change), in document order + function find(query, fold) { + const needle = getNeedle(query, fold); + const entries = []; + + if (needle.trim().length === 0) { + return entries; + } + + const haystack = index(fold); + let inputIndex = 0; + let count = 0; + + let position = haystack.text.indexOf(needle); + while (position >= 0 && count < MAX_MATCHES) { + while (inputIndex < haystack.inputs.length && haystack.inputs[inputIndex][1] <= position) { + entries.push({ input: haystack.inputs[inputIndex++][0] }); + } + + const last = position + needle.length - 1; + try { + const range = document.createRange(); + range.setStart(haystack.textNodes[haystack.nodeIndexes[position]], haystack.startOffsets[position]); + range.setEnd(haystack.textNodes[haystack.nodeIndexes[last]], haystack.endOffsets[last]); + entries.push({ range }); + count++; + } catch (e) { + // The document has changed in the meantime + } + + position = haystack.text.indexOf(needle, position + needle.length); + } + + while (inputIndex < haystack.inputs.length) { + entries.push({ input: haystack.inputs[inputIndex++][0] }); + } + + return entries; + } + + // Matches within the current value of a text input (the same object stands for the same match every time) + const inputMatches = new WeakMap(); + + function findInInput(input, needle, fold) { + const value = input.value; + const matches = []; + + if (!value || needle.trim().length === 0) { + return matches; + } + + const parts = []; + const starts = []; + const ends = []; + foldText(value, fold, true, function (character, start, end) { + parts.push(character); + starts.push(start); + ends.push(end); + }); + const text = parts.join(''); + + let known = inputMatches.get(input); + if (!known) { + known = new Map(); + inputMatches.set(input, known); + } + + let position = text.indexOf(needle); + while (position >= 0) { + const start = starts[position]; + const end = ends[position + needle.length - 1]; + const key = start + ':' + end + ':' + value.length; + + if (!known.has(key)) { + known.set(key, { input, start, end }); + } + matches.push(known.get(key)); + + position = text.indexOf(needle, position + needle.length); + } + + return matches; + } + + // The text match among those just found that's the same as the given one (matches in text inputs stay the same) + function remap(match, byStartNode) { + if (!match || !match.range) { + return match; + } + + const range = match.range; + for (const entry of byStartNode.get(range.startContainer) || []) { + if (entry.range.startOffset === range.startOffset && + entry.range.endContainer === range.endContainer && entry.range.endOffset === range.endOffset) { + return entry; + } + } + + return null; + } + + ///////////////////////////// + // Where matches are shown // + ///////////////////////////// + + // Where the element's content can be seen (clipped by scrolled containers, but not by the viewport: what's outside + // of it gets scrolled to), and whether it can be seen at all + const UNCLIPPED = { hidden: false, left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; + + function getClipRect(element, cache) { + let clip = cache.get(element); + + if (clip === undefined) { + const parent = getParent(element); + clip = parent ? getClipRect(parent, cache) : UNCLIPPED; + + const style = getComputedStyle(element); + + // Fixed elements are positioned relative to the viewport, whatever they're in + if (style.position === 'fixed') { + clip = { hidden: clip.hidden, left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; + } + + // Visibility gets inherited, but can be overridden by descendants + if (style.visibility !== 'visible' || clip.hidden) { + clip = { hidden: style.visibility !== 'visible', left: clip.left, top: clip.top, right: clip.right, bottom: clip.bottom }; + } + + if (style.overflowX !== 'visible' || style.overflowY !== 'visible') { + const rect = element.getBoundingClientRect(); + clip = { + hidden: clip.hidden, + left: style.overflowX !== 'visible' ? Math.max(clip.left, rect.left) : clip.left, + top: style.overflowY !== 'visible' ? Math.max(clip.top, rect.top) : clip.top, + right: style.overflowX !== 'visible' ? Math.min(clip.right, rect.right) : clip.right, + bottom: style.overflowY !== 'visible' ? Math.min(clip.bottom, rect.bottom) : clip.bottom, + }; + } + + cache.set(element, clip); + } + + return clip; + } + + // Text inputs don't expose where their text is, so it's measured (using their font), and wrapped like they wrap it + const measuringContext = document.createElement('canvas').getContext('2d'); + + function wrapLines(value, maxWidth, measure) { + const lines = []; + let offset = 0; + + for (const hardLine of value.split('\n')) { + let lineStart = offset; + let x = 0; + let position = offset; + + for (const token of hardLine.match(/\s+|\S+/g) || []) { + const isSpace = WHITESPACE.test(token[0]); + const tokenWidth = measure(token); + + if (!isSpace && x > 0 && x + tokenWidth > maxWidth) { + lines.push([lineStart, position]); + lineStart = position; + x = 0; + } + + // Words too long for a line get broken anywhere + if (!isSpace && tokenWidth > maxWidth) { + for (let i = 0; i < token.length; i++) { + const characterWidth = measure(token[i]); + if (x > 0 && x + characterWidth > maxWidth) { + lines.push([lineStart, position + i]); + lineStart = position + i; + x = 0; + } + x += characterWidth; + } + } else { + x += tokenWidth; + } + + position += token.length; + } + + lines.push([lineStart, offset + hardLine.length]); + offset += hardLine.length + 1; + } + + return lines; + } + + function getInputMatchRects(match) { + const input = match.input; + const rect = input.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return []; + } + + const style = getComputedStyle(input); + const px = function (value) { return parseFloat(value) || 0; }; + measuringContext.font = [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(' '); + const measure = function (text) { return measuringContext.measureText(text).width; }; + + const left = rect.left + px(style.borderLeftWidth) + px(style.paddingLeft); + const top = rect.top + px(style.borderTopWidth) + px(style.paddingTop); + const contentWidth = input.clientWidth - px(style.paddingLeft) - px(style.paddingRight); + const contentHeight = input.clientHeight - px(style.paddingTop) - px(style.paddingBottom); + const lineHeight = style.lineHeight === 'normal' ? px(style.fontSize) * 1.2 : px(style.lineHeight); + const value = input.value; + const rects = []; + + if (input.tagName === 'INPUT') { + let x = left - input.scrollLeft; + const textWidth = measure(value); + if (textWidth < contentWidth) { + if (style.textAlign === 'center') { + x += (contentWidth - textWidth) / 2; + } else if (style.textAlign === 'right' || (style.textAlign === 'end' && style.direction === 'ltr')) { + x += contentWidth - textWidth; + } + } + + rects.push([x + measure(value.slice(0, match.start)), top + (contentHeight - lineHeight) / 2, + measure(value.slice(match.start, match.end)), lineHeight]); + } else { + const lines = wrapLines(value, contentWidth, measure); + for (let i = 0; i < lines.length; i++) { + const start = Math.max(match.start, lines[i][0]); + const end = Math.min(match.end, lines[i][1]); + + if (start < end) { + rects.push([left - input.scrollLeft + measure(value.slice(lines[i][0], start)), + top - input.scrollTop + i * lineHeight, + measure(value.slice(start, end)), lineHeight]); + } + } + } + + // Text scrolled out of view within the input itself is clipped by it + const contentRight = left + contentWidth; + const contentBottom = top + contentHeight; + return rects.map(function (r) { + const l = Math.max(r[0], left); + const t = Math.max(r[1], top); + const ri = Math.min(r[0] + r[2], contentRight); + const b = Math.min(r[1] + r[3], contentBottom); + return { left: l, top: t, right: Math.max(l, ri), bottom: Math.max(t, b), width: Math.max(0, ri - l), height: Math.max(0, b - t) }; + }); + } + + function getElement(match) { + return match.range ? match.range.startContainer.parentElement : match.input; + } + + function getClientRects(match) { + return match.range ? match.range.getClientRects() : getInputMatchRects(match); + } + + function getBounds(match) { + let bounds = null; + + for (const rect of getClientRects(match)) { + if (rect.width > 0 && rect.height > 0) { + bounds = bounds ? { + left: Math.min(bounds.left, rect.left), + top: Math.min(bounds.top, rect.top), + right: Math.max(bounds.right, rect.right), + bottom: Math.max(bounds.bottom, rect.bottom), + } : { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + } + } + + return bounds; + } + + function intersects(a, b) { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + } + + function getRem() { + return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + } + + // Scrolls the match into view (within whatever scrolls it, and the page itself, in both directions): either to where + // the current match goes, or just enough for it to be in view, with some room around it; never behind the search box + function scrollIntoView(match, isGoingToCurrentPosition, searchBox) { + const element = getElement(match); + if (!element) { + return; + } + + // Where it is before anything gets scrolled (bringing it into view within its containers below may well scroll + // the page too, just enough for it to be at its very edge) + const before = getBounds(match); + const wasOutOfViewVertically = !before || before.bottom <= 0 || before.top >= window.innerHeight; + const wasOutOfViewHorizontally = !before || before.right <= 0 || before.left >= window.innerWidth; + + element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + + // Within a text input that scrolls (e.g. a long text area) + if (match.input && match.input.tagName === 'TEXTAREA') { + const inputRect = match.input.getBoundingClientRect(); + const bounds = getBounds(match); + const lines = (match.input.value.slice(0, match.start).match(/\n/g) || []).length; + if (bounds === null || bounds.top < inputRect.top || bounds.bottom > inputRect.bottom) { + const lineHeight = parseFloat(getComputedStyle(match.input).lineHeight) || 20; + match.input.scrollTop = Math.max(0, lines * lineHeight - match.input.clientHeight / 2); + } + } + + let rect = getBounds(match); + if (rect === null) { + return; + } + + const margin = 2 * getRem(); + + function getScroll(from, to, size, position, wasOutOfView) { + // Matches that were out of view go to where the current match goes + if (isGoingToCurrentPosition && wasOutOfView) { + return (from + to) / 2 - size * position; + } + + // Otherwise, just enough for them to be in view, with some room around them + if (from < margin) { + return from - margin; + } + if (to > size - margin) { + return Math.min(to - (size - margin), from - margin); // The start of long matches stays in view + } + return 0; + } + + // Matches go above the search box (which is in the middle) + window.scrollBy(getScroll(rect.left, rect.right, window.innerWidth, 0.5, wasOutOfViewHorizontally), + getScroll(rect.top, rect.bottom, window.innerHeight, CURRENT_MATCH_POSITION, wasOutOfViewVertically)); + + // Never behind the search box: right above it, or right below it if there's no room above + rect = getBounds(match); + if (searchBox && rect && intersects(rect, { left: searchBox.left - 8, top: searchBox.top - 8, right: searchBox.right + 8, bottom: searchBox.bottom + 8 })) { + let top = searchBox.top - margin - (rect.bottom - rect.top); + if (top < margin) { + top = searchBox.bottom + margin; + } + window.scrollBy(0, rect.top - top); + } + } + + /////////////////////// + // Drawing (X-ray) // + /////////////////////// + + // All white, blended with what's underneath using "difference": everything drawn inverts the page beneath it; + // the search box also blurs what's behind it (in an element of its own, under the layer: anything in the layer + // would only get blended with what's in it, rather than with the page) + const layer = { host: null, backdrop: null }; + + function getLayer() { + if (layer.host && layer.host.isConnected && layer.backdrop && layer.backdrop.isConnected) { + return layer; + } + removeLayer(); + + const backdrop = document.createElement('liquid-find-backdrop'); + backdrop.setAttribute('style', [ + 'all: initial', 'display: block', 'position: fixed', 'z-index: 2147483646', 'pointer-events: none', + 'backdrop-filter: blur(' + BACKDROP_BLUR + 'px)', '-webkit-backdrop-filter: blur(' + BACKDROP_BLUR + 'px)', + ].map(function (rule) { return rule + ' !important;'; }).join('')); + layer.backdrop = backdrop; + document.documentElement.appendChild(backdrop); + + const host = document.createElement('liquid-find'); + host.setAttribute('style', [ + 'all: initial', 'display: block', 'position: fixed', 'inset: 0', 'z-index: 2147483647', 'pointer-events: none', + 'mix-blend-mode: difference', 'contain: strict', 'overflow: hidden', + ].map(function (rule) { return rule + ' !important;'; }).join('')); + + const root = host.attachShadow({ mode: 'closed' }); + const svg = document.createElementNS(SVG, 'svg'); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + svg.setAttribute('style', 'position: absolute; inset: 0; display: block;'); + root.appendChild(svg); + + function add(tag, attributes) { + const element = document.createElementNS(SVG, tag); + for (const name in attributes) { + element.setAttribute(name, attributes[name]); + } + svg.appendChild(element); + return element; + } + + // Back to front + layer.searchBox = add('rect', { fill: '#fff' }); + layer.rays = add('path', { fill: 'none', stroke: '#fff', 'stroke-width': 1.5, 'stroke-linecap': 'round' }); + layer.highlights = add('path', { fill: '#fff' }); + layer.dots = add('path', { fill: '#fff' }); + layer.currentRay = add('path', { fill: 'none', stroke: '#fff', 'stroke-width': 2.5, 'stroke-linecap': 'round' }); + layer.currentHighlight = add('path', { fill: '#fff', stroke: '#fff', 'stroke-width': 2 }); + layer.currentRing = add('path', { fill: 'none', stroke: '#fff', 'stroke-width': 2 }); + layer.currentDot = add('path', { fill: '#fff' }); + // Hovered dots, their rays and matches stand out in color (inverted too, but tinted) + layer.hoveredRay = add('path', { fill: 'none', stroke: '#0af', 'stroke-width': 3, 'stroke-linecap': 'round' }); + layer.hoveredHighlight = add('path', { fill: '#0af' }); + layer.hoveredDot = add('path', { fill: '#0af' }); + + layer.host = host; + document.documentElement.appendChild(host); + + return layer; + } + + function removeLayer() { + for (const name of ['host', 'backdrop']) { + if (layer[name]) { + layer[name].remove(); + layer[name] = null; + } + } + } + + function rectPath(rect, grow) { + const x = rect.left - grow; + const y = rect.top - grow; + return 'M' + x + ' ' + y + 'h' + (rect.right - rect.left + 2 * grow) + 'v' + (rect.bottom - rect.top + 2 * grow) + + 'h' + (-(rect.right - rect.left + 2 * grow)) + 'Z'; + } + + function circlePath(x, y, radius) { + return 'M' + (x - radius) + ' ' + y + 'a' + radius + ' ' + radius + ' 0 1 0 ' + (2 * radius) + ' 0' + + 'a' + radius + ' ' + radius + ' 0 1 0 ' + (-2 * radius) + ' 0'; + } + + // Where the line from the middle of the search box (a pill: a rectangle with round ends) towards the point + // crosses its edge: rays come out of the search box in the direction of their matches + function getPointOnSearchBoxTowards(searchBox, x, y) { + const radius = (searchBox.bottom - searchBox.top) / 2; + const centerX = (searchBox.left + searchBox.right) / 2; + const centerY = (searchBox.top + searchBox.bottom) / 2; + const length = Math.hypot(x - centerX, y - centerY); + + if (length < 0.001) { + return [centerX, centerY, 0, 0]; + } + + const dx = (x - centerX) / length; + const dy = (y - centerY) / length; + const distanceFromSpine = function (px, py) { + const sx = Math.max(searchBox.left + radius, Math.min(px, searchBox.right - radius)); + return Math.hypot(px - sx, py - centerY); + }; + + let inside = 0; + let outside = searchBox.right - searchBox.left + radius; + for (let i = 0; i < 24; i++) { + const middle = (inside + outside) / 2; + if (distanceFromSpine(centerX + dx * middle, centerY + dy * middle) < radius) { + inside = middle; + } else { + outside = middle; + } + } + + return [centerX + dx * outside, centerY + dy * outside, dx, dy]; + } + + function draw(hoveredIndex) { + const drawn = state.drawn; + if (!drawn) { + return; + } + + const target = getLayer(); + const searchBox = drawn.searchBox; + const paths = { + rays: [], highlights: [], dots: [], + currentRay: [], currentHighlight: [], currentRing: [], currentDot: [], + hoveredRay: [], hoveredHighlight: [], hoveredDot: [], + }; + + if (searchBox) { + const height = searchBox.bottom - searchBox.top; + target.searchBox.setAttribute('x', searchBox.left); + target.searchBox.setAttribute('y', searchBox.top); + target.searchBox.setAttribute('width', searchBox.right - searchBox.left); + target.searchBox.setAttribute('height', height); + target.searchBox.setAttribute('rx', height / 2); + + const style = target.backdrop.style; + style.setProperty('left', searchBox.left + 'px', 'important'); + style.setProperty('top', searchBox.top + 'px', 'important'); + style.setProperty('width', (searchBox.right - searchBox.left) + 'px', 'important'); + style.setProperty('height', height + 'px', 'important'); + style.setProperty('border-radius', (height / 2) + 'px', 'important'); + } + + for (let i = 0; i < drawn.matches.length; i++) { + const match = drawn.matches[i]; + const kind = (i === hoveredIndex) ? 'hovered' : (match.isCurrent) ? 'current' : ''; + + for (const rect of match.visibleRects) { + paths[kind ? kind + 'Highlight' : 'highlights'].push(rectPath(rect, kind ? 2 : 1)); + if (kind === 'current') { + paths.currentRing.push(rectPath(rect, 5)); + } + } + + if (match.dot) { + const radius = (kind === 'hovered') ? 6 : (kind === 'current') ? 5 : 3.5; + paths[kind ? kind + 'Ray' : 'rays'].push('M' + match.dot[0] + ' ' + match.dot[1] + 'L' + match.rayEnd[0] + ' ' + match.rayEnd[1]); + paths[kind ? kind + 'Dot' : 'dots'].push(circlePath(match.dot[0], match.dot[1], radius)); + } + } + + for (const name in paths) { + target[name].setAttribute('d', paths[name].join('')); + } + } + + // Whether what the page shows at that point is light (the search box, drawn inverted there, is then dark): + // judged by the first background color that isn't see-through, from there up to the root (images aren't considered) + function isLightAt(x, y) { + let element = document.elementFromPoint(x, y); // Liquid's own layer doesn't take the mouse, so it's never what's there + + while (element) { + const color = getComputedStyle(element).backgroundColor.match(/[\d.]+/g); + if (color && color.length >= 3 && (color.length < 4 || parseFloat(color[3]) > 0.5)) { + return (0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]) / 255 > 0.5; + } + element = getParent(element); + } + + return true; // Pages are white unless they say otherwise + } + + // A match as it is now: [match, visible rects, bounds], or null if it's not rendered + function measure(match, clipCache) { + const element = getElement(match); + if (!element || !element.isConnected) { + return null; + } + + const clip = getClipRect(element, clipCache); + if (clip.hidden) { + return null; + } + + const visibleRects = []; + let bounds = null; + for (const rect of getClientRects(match)) { + if (rect.width === 0 || rect.height === 0) { + continue; + } + + bounds = bounds ? { + left: Math.min(bounds.left, rect.left), + top: Math.min(bounds.top, rect.top), + right: Math.max(bounds.right, rect.right), + bottom: Math.max(bounds.bottom, rect.bottom), + } : { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + + // Clipped out of view within a scrolled container + const visible = { + left: Math.max(rect.left, clip.left), + top: Math.max(rect.top, clip.top), + right: Math.min(rect.right, clip.right), + bottom: Math.min(rect.bottom, clip.bottom), + }; + if (visible.right > visible.left && visible.bottom > visible.top) { + visibleRects.push(visible); + } + } + + return (bounds !== null) ? [match, visibleRects, bounds] : null; + } + + // What gets drawn: rays go from dots just off the edge of the search box (in the direction of their matches), + // to the point of their matches closest to the middle of the search box; returns where the dots are + function layOut(searchBox, items, current) { + const drawn = { searchBox, matches: [] }; + const dots = []; + + for (const item of items) { + const bounds = item[2]; + const match = { isCurrent: item[0] === current, visibleRects: item[1], dot: null, rayEnd: null }; + + if (searchBox) { + const centerX = (searchBox.left + searchBox.right) / 2; + const centerY = (searchBox.top + searchBox.bottom) / 2; + const endX = Math.max(bounds.left, Math.min(centerX, bounds.right)); + const endY = Math.max(bounds.top, Math.min(centerY, bounds.bottom)); + const isUnderSearchBox = endX >= searchBox.left && endX <= searchBox.right && endY >= searchBox.top && endY <= searchBox.bottom; + + if (!isUnderSearchBox) { + const edge = getPointOnSearchBoxTowards(searchBox, endX, endY); + match.dot = [edge[0] + edge[2] * DOT_OFFSET, edge[1] + edge[3] * DOT_OFFSET]; + match.rayEnd = [endX, endY]; + } + } + + drawn.matches.push(match); + dots.push(match.dot); + } + + state.drawn = drawn; + + return dots; + } + + // Liquid's search box stays in the middle of the viewport (as it gets resized, before Liquid says where it is now) + function getSearchBox() { + const base = state.searchBoxBase; + if (!base) { + return null; + } + + const dx = (window.innerWidth - base.viewportWidth) / 2; + const dy = (window.innerHeight - base.viewportHeight) / 2; + return { left: base.left + dx, top: base.top + dy, right: base.right + dx, bottom: base.bottom + dy }; + } + + // Upon scrolling (the page, or anything within it) and resizing, what's drawn follows right away, every frame, + // without looking for matches again; only works if JavaScript is enabled for the page (otherwise, events don't + // reach this world, and Liquid's own updates are all there is) + let isRedrawRequested = false; + + function redraw() { + isRedrawRequested = false; + + if (!state.drawn || !layer.host) { + return; + } + + const clipCache = new Map(); + const items = []; + for (const match of state.reported) { + const item = match ? measure(match, clipCache) : null; + if (item !== null) { + items.push(item); + } + } + + // Reported matches that are gone would make dots (whose order Liquid knows) point at the wrong ones + if (items.length !== state.reported.length) { + return; + } + + layOut(getSearchBox(), items, state.reportedCurrent); + draw(state.hoveredIndex); + } + + function requestRedraw() { + if (!isRedrawRequested && state.drawn) { + isRedrawRequested = true; + requestAnimationFrame(redraw); + } + } + + window.addEventListener('scroll', requestRedraw, { capture: true, passive: true }); + window.addEventListener('resize', requestRedraw, { passive: true }); + + //////////// + // Update // + //////////// + + // move: 0 (stay), 1 (next), -1 (previous); goTo: index of a match drawn last time (whose dot has been clicked), or -1; + // searchBox: [x, y, width, height] of Liquid's search box, in CSS pixels relative to the viewport + function update(query, move, goTo, searchBoxRect, hoveredIndex) { + const now = Date.now(); + const isNewQuery = query !== state.key; + state.searchBoxBase = searchBoxRect ? { + left: searchBoxRect[0], top: searchBoxRect[1], + right: searchBoxRect[0] + searchBoxRect[2], bottom: searchBoxRect[1] + searchBoxRect[3], + viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, + } : null; + const searchBox = getSearchBox(); + + const rescanInterval = Math.max(MIN_RESCAN_INTERVAL, state.lastScanDuration / MAX_RESCAN_SHARE); + if (isNewQuery || + (state.isDirty && now - state.lastScanTime >= rescanInterval) || + now - state.lastScanTime >= Math.max(SAFETY_RESCAN_INTERVAL, rescanInterval)) { + state.fold = getFolder(query); + state.needle = getNeedle(query, state.fold); + state.entries = find(query, state.fold); + + // The same matches, found again, are new objects: the current one (and those drawn last time, + // whose dots may be clicked) have to remain the same + if (!isNewQuery) { + const byStartNode = new Map(); + for (const entry of state.entries) { + if (entry.range) { + const list = byStartNode.get(entry.range.startContainer) || []; + list.push(entry); + byStartNode.set(entry.range.startContainer, list); + } + } + const remapOne = function (match) { return remap(match, byStartNode); }; + state.current = remapOne(state.current); + state.reported = state.reported.map(remapOne); + } + state.key = query; + state.isDirty = false; + state.lastScanTime = now; + state.lastScanDuration = Date.now() - now; + } + + // Matches as they are now: text inputs' values change all the time, and everything moves around + // (the page scrolls, things change, etc.); matches that get rendered at all count, the parts of them that can be + // seen get highlighted, and wherever they are (even if scrolled out of view), rays point at them + const clipCache = new Map(); + const shown = []; // [match, visible rects, bounds] + for (const entry of state.entries) { + const matches = entry.range ? [entry] : findInInput(entry.input, state.needle, state.fold); + + for (const match of matches) { + const item = measure(match, clipCache); + if (item !== null) { + shown.push(item); + } + } + } + + // In view, and not hidden behind the search box + function isInView(item) { + return item[1].some(function (rect) { + const isInViewport = rect.right > 0 && rect.left < window.innerWidth && rect.bottom > 0 && rect.top < window.innerHeight; + const isUnderSearchBox = searchBox !== null && rect.left >= searchBox.left && rect.right <= searchBox.right && + rect.top >= searchBox.top && rect.bottom <= searchBox.bottom; + return isInViewport && !isUnderSearchBox; + }); + } + + // Which one's current + let currentIndex = shown.findIndex(function (item) { return item[0] === state.current; }); + let isGoingTo = false; + if (goTo >= 0 && goTo < state.reported.length) { + const index = shown.findIndex(function (item) { return item[0] === state.reported[goTo]; }); + if (index >= 0) { + currentIndex = index; + isGoingTo = true; + } + } else if (isNewQuery || currentIndex < 0) { + // The first one in view (or below it) + currentIndex = shown.findIndex(function (item) { return isInView(item) || item[2].top >= window.innerHeight; }); + if (currentIndex < 0) { + currentIndex = 0; + } + } + if (shown.length > 0 && move !== 0) { + currentIndex = (currentIndex + move + shown.length) % shown.length; + } + const current = shown.length > 0 ? shown[currentIndex] : null; + state.current = current ? current[0] : null; + + // Go to the current match: whenever its dot gets clicked (just enough for it to be in view), + // or when it's out of view once found, or upon going to the next or previous one + if (current && (isGoingTo || ((move !== 0 || isNewQuery) && !isInView(current)))) { + scrollIntoView(current[0], !isGoingTo, searchBox); + return update(query, 0, -1, searchBoxRect, hoveredIndex); + } + + // Pages may have lots of matches: the nearest ones (and the current one) get drawn, the rest gets counted + let reported = shown.map(function (item, index) { return index; }); + if (reported.length > MAX_REPORTED) { + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + const distance = function (index) { + const bounds = shown[index][2]; + const dx = Math.max(bounds.left - centerX, 0, centerX - bounds.right); + const dy = Math.max(bounds.top - centerY, 0, centerY - bounds.bottom); + return dx * dx + dy * dy; + }; + reported.sort(function (a, b) { return distance(a) - distance(b); }); + reported = reported.slice(0, MAX_REPORTED); + if (current && !reported.includes(currentIndex)) { + reported[reported.length - 1] = currentIndex; + } + reported.sort(function (a, b) { return a - b; }); + } + state.reported = reported.map(function (index) { return shown[index][0]; }); + + state.reportedCurrent = current ? current[0] : null; + state.hoveredIndex = hoveredIndex; + const dots = layOut(searchBox, reported.map(function (index) { return shown[index]; }), state.reportedCurrent); + draw(hoveredIndex); + + // For the text of the search box to be told apart from the inverted page underneath + const isSearchBoxDark = searchBox ? isLightAt((searchBox.left + searchBox.right) / 2, (searchBox.top + searchBox.bottom) / 2) : true; + + return JSON.stringify({ total: shown.length, current: current ? currentIndex + 1 : 0, dots, isSearchBoxDark }); + } + + window.__liquidFind = { + update, + // Hovering a dot only changes how things are drawn + hover: function (index) { + state.hoveredIndex = index; + draw(index); + }, + // Snapshots are of the page, not of the search + setHidden: function (isHidden) { + for (const element of [layer.host, layer.backdrop]) { + if (element) { + element.style.setProperty('visibility', isHidden ? 'hidden' : 'visible', 'important'); + } + } + }, + close: function () { + removeLayer(); + state.drawn = null; + state.current = null; + state.key = null; + }, + }; +})(); diff --git a/src/liquidappfind.cpp b/src/liquidappfind.cpp new file mode 100644 index 0000000..1f6699c --- /dev/null +++ b/src/liquidappfind.cpp @@ -0,0 +1,549 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "liquidappfind.hpp" +#include "lqd.h" + +LiquidAppFindInput::LiquidAppFindInput(QWidget* parent) : QLineEdit(parent) +{ +} + +bool LiquidAppFindInput::event(QEvent* event) +{ + // Keys used for going from one match to another must reach the input, rather than trigger Liquid's shortcuts + if (event->type() == QEvent::ShortcutOverride) { + const QKeyEvent* keyEvent = static_cast(event); + + switch (keyEvent->key()) { + case Qt::Key_Return: + case Qt::Key_Enter: + case Qt::Key_Up: + case Qt::Key_Down: + case Qt::Key_F3: + event->accept(); + return true; + + default: + break; + } + } + + return QLineEdit::event(event); +} + +void LiquidAppFindInput::keyPressEvent(QKeyEvent* event) +{ + const bool isShiftPressed = event->modifiers() & Qt::ShiftModifier; + + switch (event->key()) { + case Qt::Key_Return: + case Qt::Key_Enter: + case Qt::Key_F3: + if (isShiftPressed) { + emit previousRequested(); + } else { + emit nextRequested(); + } + return; + + case Qt::Key_Down: + emit nextRequested(); + return; + + case Qt::Key_Up: + emit previousRequested(); + return; + + default: + QLineEdit::keyPressEvent(event); + } +} + +LiquidAppFindDots::LiquidAppFindDots(QWidget* parent) : QWidget(parent) +{ + // Invisible: it only takes the mouse where the dots are + setAttribute(Qt::WA_NoSystemBackground); + setAttribute(Qt::WA_TranslucentBackground); + setMouseTracking(true); + setFocusPolicy(Qt::NoFocus); // Clicking a dot leaves typing in the search box uninterrupted + setCursor(Qt::PointingHandCursor); +} + +void LiquidAppFindDots::setDots(const QList& newDots, const QList& newHasDot, const QRect& searchBoxGeometry) +{ + dots = newDots; + hasDot = newHasDot; + + // Wherever the dots are (they're further from the search box when the page is zoomed in) + QRectF area(searchBoxGeometry); + for (int i = 0; i < dots.size(); i++) { + if (hasDot[i]) { + area = area.united(QRectF(dots[i], QSizeF(0, 0))); + } + } + const int margin = LQD_FIND_DOT_HIT_RADIUS + 1; + setGeometry(area.toAlignedRect().adjusted(-margin, -margin, margin, margin)); + + // Only the dots take clicks (an empty mask would mean no mask at all, i.e. everything would) + QRegion region; + for (int i = 0; i < dots.size(); i++) { + if (hasDot[i]) { + const QPoint center = (dots[i] - QPointF(pos())).toPoint(); + region += QRegion(center.x() - LQD_FIND_DOT_HIT_RADIUS, center.y() - LQD_FIND_DOT_HIT_RADIUS, + 2 * LQD_FIND_DOT_HIT_RADIUS, 2 * LQD_FIND_DOT_HIT_RADIUS, QRegion::Ellipse); + } + } + + if (region.isEmpty()) { + setHoveredIndex(-1); + hide(); + } else { + setMask(region); + show(); + raise(); + } + + if (hoveredIndex >= dots.size()) { + setHoveredIndex(-1); + } +} + +int LiquidAppFindDots::getDotAt(const QPointF& position) const +{ + // The closest one (dots of matches in about the same direction overlap) + int closestIndex = -1; + qreal closestDistance = LQD_FIND_DOT_HIT_RADIUS * LQD_FIND_DOT_HIT_RADIUS; + + for (int i = 0; i < dots.size(); i++) { + if (hasDot[i]) { + const QPointF delta = dots[i] - QPointF(pos()) - position; + const qreal distance = delta.x() * delta.x() + delta.y() * delta.y(); + + if (distance <= closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + } + + return closestIndex; +} + +void LiquidAppFindDots::setHoveredIndex(const int index) +{ + if (index != hoveredIndex) { + hoveredIndex = index; + emit hovered(index); + } +} + +void LiquidAppFindDots::mouseMoveEvent(QMouseEvent* event) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + setHoveredIndex(getDotAt(event->position())); +#else + setHoveredIndex(getDotAt(event->localPos())); +#endif +} + +void LiquidAppFindDots::mousePressEvent(QMouseEvent* event) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const int index = getDotAt(event->position()); +#else + const int index = getDotAt(event->localPos()); +#endif + + if (event->button() == Qt::LeftButton && index >= 0) { + emit clicked(index); + } +} + +void LiquidAppFindDots::leaveEvent(QEvent* event) +{ + Q_UNUSED(event); + + setHoveredIndex(-1); +} + +QWidget* LiquidAppFind::createSearchBox(QWidget* parent, LiquidAppFindInput** input, QLabel** countLabel) +{ + // See-through: the page draws it, blurring and inverting what's underneath (see find.js); text is white or black, + // whichever stands out from the page there, once inverted + QWidget* searchBox = new QWidget(parent); + searchBox->setObjectName("liquidFindBox"); + searchBox->setAttribute(Qt::WA_TranslucentBackground); + searchBox->setStyleSheet( + "#liquidFindBox { background: transparent; border: none; }" + "#liquidFindBox QLineEdit { background: transparent; border: none; color: white; font-size: 16px; font-weight: 600;" + " selection-background-color: rgba(255, 255, 255, 90); selection-color: white; }" + "#liquidFindBox QLabel { background: transparent; color: rgba(255, 255, 255, 220); font-size: 13px; font-weight: 600; }" + "#liquidFindBox QLabel[noMatches=\"true\"] { color: rgb(255, 150, 130); }" + // Dark pages make for a light search box + "#liquidFindBox[light=\"true\"] QLineEdit { color: black; selection-background-color: rgba(0, 0, 0, 70); selection-color: black; }" + "#liquidFindBox[light=\"true\"] QLabel { color: rgba(0, 0, 0, 210); }" + "#liquidFindBox[light=\"true\"] QLabel[noMatches=\"true\"] { color: rgb(170, 30, 0); }" + ); + + QHBoxLayout* layout = new QHBoxLayout(searchBox); + layout->setContentsMargins(18, 0, 16, 0); + layout->setSpacing(10); + + *input = new LiquidAppFindInput(searchBox); + (*input)->setPlaceholderText(tr("Find on page")); + (*input)->setAttribute(Qt::WA_MacShowFocusRect, false); +#if QT_VERSION >= QT_VERSION_CHECK(5, 12, 0) + QPalette palette = (*input)->palette(); + palette.setColor(QPalette::PlaceholderText, QColor(255, 255, 255, 150)); + (*input)->setPalette(palette); +#endif + layout->addWidget(*input, 1); + + *countLabel = new QLabel(searchBox); + layout->addWidget(*countLabel); + + // The count gets a halo, for it to stand out (the input doesn't: graphics effects keep its caret from being shown, + // and what's behind it is blurred anyway) + QGraphicsDropShadowEffect* halo = new QGraphicsDropShadowEffect(*countLabel); + halo->setBlurRadius(6); + halo->setOffset(0, 0); + halo->setColor(QColor(0, 0, 0, 230)); + (*countLabel)->setGraphicsEffect(halo); + + return searchBox; +} + +LiquidAppFind::LiquidAppFind(QWebEngineView* webView) : QObject(webView), webView(webView) +{ + // The search engine itself (see find.js) + QFile scriptFile(":/scripts/find.js"); + if (scriptFile.open(QFile::ReadOnly | QFile::Text)) { + script = QString::fromUtf8(scriptFile.readAll()); + } + + searchBox = createSearchBox(webView, &input, &countLabel); + searchBox->hide(); + + dots = new LiquidAppFindDots(webView); + dots->hide(); + connect(dots, &LiquidAppFindDots::hovered, this, [this](const int index) { + // Only changes how things are drawn + hoveredIndex = index; + this->webView->page()->runJavaScript(QString("window.__liquidFind && __liquidFind.hover(%1);").arg(index), + QWebEngineScript::ApplicationWorld); + }); + connect(dots, &LiquidAppFindDots::clicked, this, [this](const int index) { + pendingGoTo = index; + scheduleUpdate(); + }); + + // Matches get looked up again whenever the query changes, the page scrolls, and every now and then + // (pages change all the time, and whatever shows up on them gets found) + scheduleTimer = new QTimer(this); + scheduleTimer->setSingleShot(true); + scheduleTimer->setInterval(0); + connect(scheduleTimer, &QTimer::timeout, this, &LiquidAppFind::update); + + pollTimer = new QTimer(this); + pollTimer->setInterval(LQD_FIND_POLL_INTERVAL); + connect(pollTimer, &QTimer::timeout, this, &LiquidAppFind::scheduleUpdate); + + connect(input, &QLineEdit::textChanged, this, &LiquidAppFind::scheduleUpdate); + connect(input, &LiquidAppFindInput::nextRequested, this, [this]() { + pendingMove++; + scheduleUpdate(); + }); + connect(input, &LiquidAppFindInput::previousRequested, this, [this]() { + pendingMove--; + scheduleUpdate(); + }); + + connect(webView->page(), &QWebEnginePage::scrollPositionChanged, this, &LiquidAppFind::scheduleUpdate); + connect(webView->page(), &QWebEnginePage::contentsSizeChanged, this, &LiquidAppFind::scheduleUpdate); +} + +void LiquidAppFind::open(const QString& text, const bool append) +{ + if (!text.isEmpty()) { + if (append && isOpen()) { + input->setText(input->text() + text); + } else { + input->setText(text); + } + input->deselect(); + input->end(false); + } else { + // Ctrl+F brings back the previous query, selected (typing replaces it) + input->selectAll(); + } + + if (!isOpen()) { + isOpenBool = true; + updateGeometry(); + searchBox->show(); + pollTimer->start(); + } + + searchBox->raise(); + dots->raise(); + + // Not the way shortcuts do it: that'd select all of the text, and the next key typed would replace it + input->setFocus(Qt::OtherFocusReason); + scheduleUpdate(); +} + +void LiquidAppFind::focusPage(void) +{ + // Not the way tabbing does it: that'd make the page focus its first focusable element + webView->setFocus(Qt::OtherFocusReason); +} + +void LiquidAppFind::close(void) +{ + if (!isOpen()) { + return; + } + + isOpenBool = false; + pollTimer->stop(); + scheduleTimer->stop(); + pendingMove = 0; + pendingGoTo = -1; + hoveredIndex = -1; + + // Focus has to be handed back to the page before hiding the search box: hiding the focused widget would make Qt + // pass focus on as if by tabbing + if (input->hasFocus()) { + focusPage(); + } + searchBox->hide(); + dots->hide(); + + webView->page()->runJavaScript("window.__liquidFind && __liquidFind.close();", QWebEngineScript::ApplicationWorld); +} + +bool LiquidAppFind::isOpen(void) const +{ + return isOpenBool; +} + +void LiquidAppFind::hideForSnapshot(void) +{ + if (!isOpen() || isHiddenForSnapshot) { + return; + } + + isHiddenForSnapshot = true; + + hadFocusBeforeSnapshot = input->hasFocus(); + if (hadFocusBeforeSnapshot) { + focusPage(); + } + searchBox->hide(); + dots->hide(); + + // What's drawn on the page has to be gone (and the page repainted) before the snapshot gets taken + QEventLoop loop; + QPointer loopPointer(&loop); + const QSharedPointer isDone(new bool(false)); + QTimer::singleShot(LQD_FIND_HIDE_TIMEOUT, &loop, &QEventLoop::quit); + webView->page()->runJavaScript("window.__liquidFind && __liquidFind.setHidden(true);", QWebEngineScript::ApplicationWorld, + [loopPointer, isDone](const QVariant&) { + *isDone = true; + if (loopPointer) { + loopPointer->quit(); + } + }); + if (!*isDone) { + loop.exec(); + } + + QEventLoop repaintLoop; + QTimer::singleShot(LQD_FIND_REPAINT_DELAY, &repaintLoop, &QEventLoop::quit); + repaintLoop.exec(); +} + +void LiquidAppFind::showAfterSnapshot(void) +{ + if (!isHiddenForSnapshot) { + return; + } + + isHiddenForSnapshot = false; + + if (isOpen()) { + webView->page()->runJavaScript("window.__liquidFind && __liquidFind.setHidden(false);", QWebEngineScript::ApplicationWorld); + searchBox->show(); + searchBox->raise(); + if (hadFocusBeforeSnapshot) { + input->setFocus(Qt::OtherFocusReason); + } + scheduleUpdate(); + } +} + +void LiquidAppFind::updateGeometry(void) +{ + // In the very middle of the viewport (dots follow once matches get looked up again) + const QRect viewport = webView->rect(); + const int width = qMin(LQD_FIND_BOX_WIDTH, viewport.width() - 2 * LQD_FIND_BOX_MARGIN); + searchBox->setGeometry(viewport.center().x() - width / 2, viewport.center().y() - LQD_FIND_BOX_HEIGHT / 2, width, LQD_FIND_BOX_HEIGHT); + + if (isOpen()) { + scheduleUpdate(); + } +} + +void LiquidAppFind::scheduleUpdate(void) +{ + if (isOpen() && !scheduleTimer->isActive()) { + scheduleTimer->start(); + } +} + +void LiquidAppFind::update(void) +{ + if (!isOpen() || isHiddenForSnapshot) { + return; + } + + // One at a time (the page may be busy), whatever's been asked for in the meantime gets done right after + if (isUpdating) { + isUpdatePending = true; + return; + } + + const int move = qBound(-1, pendingMove, 1); + pendingMove -= move; + const int goTo = pendingGoTo; + pendingGoTo = -1; + + // The page draws the search box too (in CSS pixels) + const qreal zoom = webView->zoomFactor(); + const QRect box = searchBox->geometry(); + const QString quotedQuery = QString::fromUtf8(QJsonDocument(QJsonArray() << input->text()).toJson(QJsonDocument::Compact)).mid(1).chopped(1); + const QString call = script + QString("\n;__liquidFind.update(%1, %2, %3, [%4, %5, %6, %7], %8);") + .arg(quotedQuery).arg(move).arg(goTo) + .arg(box.x() / zoom).arg(box.y() / zoom).arg(box.width() / zoom).arg(box.height() / zoom) + .arg(hoveredIndex); + + isUpdating = true; + QPointer self(this); + QElapsedTimer timer; + timer.start(); + + // Liquid's own JS world: works even if JavaScript is disabled for the page, and the page can't interfere + webView->page()->runJavaScript(call, QWebEngineScript::ApplicationWorld, [self, timer](const QVariant& result) { + if (self) { + self->onUpdated(result.toString(), timer.elapsed()); + } + }); +} + +void LiquidAppFind::onUpdated(const QString& json, const qint64 duration) +{ + isUpdating = false; + + // Pages with lots of matches take longer to look through, they get looked through less often (keeps them responsive) + pollTimer->setInterval(qMax(LQD_FIND_POLL_INTERVAL, duration * 3)); + + if (isOpen() && !isHiddenForSnapshot) { + const QJsonObject result = QJsonDocument::fromJson(json.toUtf8()).object(); + const qreal zoom = webView->zoomFactor(); + + // Where the dots are (in CSS pixels), for them to be clickable + QList dotPositions; + QList hasDot; + for (const QJsonValue& value : result.value("dots").toArray()) { + const QJsonArray dot = value.toArray(); + hasDot.append(dot.size() == 2); + dotPositions.append((dot.size() == 2) ? QPointF(dot[0].toDouble() * zoom, dot[1].toDouble() * zoom) : QPointF()); + } + dots->setDots(dotPositions, hasDot, searchBox->geometry()); + + // The page may have put something over them in the meantime (e.g. upon going full-screen) + searchBox->raise(); + dots->raise(); + + setSearchBoxLight(!result.value("isSearchBoxDark").toBool(true)); + + if (input->text().trimmed().isEmpty()) { + updateCountLabel(-1, 0); + } else { + updateCountLabel(result.value("total").toInt(), result.value("current").toInt()); + } + } + + if (isUpdatePending || pendingMove != 0 || pendingGoTo >= 0) { + isUpdatePending = false; + scheduleUpdate(); + } +} + +void LiquidAppFind::updateCountLabel(const int total, const int current) +{ + if (total < 0) { + countLabel->clear(); + } else if (total == 0) { + countLabel->setText(tr("No matches")); + } else { + countLabel->setText(tr("%1 of %2").arg(current).arg(total)); + } + + // Repaint according to the stylesheet + const bool hasNoMatches = total == 0; + if (countLabel->property("noMatches").toBool() != hasNoMatches) { + countLabel->setProperty("noMatches", hasNoMatches); + countLabel->style()->unpolish(countLabel); + countLabel->style()->polish(countLabel); + } +} + +void LiquidAppFind::setSearchBoxLight(const bool isLight) +{ + if (searchBox->property("light").toBool() == isLight) { + return; + } + + setSearchBoxLight(searchBox, isLight); +} + +void LiquidAppFind::setSearchBoxLight(QWidget* searchBox, const bool isLight) +{ + searchBox->setProperty("light", isLight); + + // Text and its halo + QPalette palette; + for (QWidget* widget : searchBox->findChildren()) { + widget->style()->unpolish(widget); + widget->style()->polish(widget); + + if (QGraphicsDropShadowEffect* halo = qobject_cast(widget->graphicsEffect())) { + halo->setColor(isLight ? QColor(255, 255, 255, 230) : QColor(0, 0, 0, 230)); + } + +#if QT_VERSION >= QT_VERSION_CHECK(5, 12, 0) + if (QLineEdit* lineEdit = qobject_cast(widget)) { + palette = lineEdit->palette(); + palette.setColor(QPalette::PlaceholderText, isLight ? QColor(0, 0, 0, 140) : QColor(255, 255, 255, 150)); + lineEdit->setPalette(palette); + } +#endif + } +} diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index 2ac400f..1626f8a 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -28,6 +28,7 @@ #include "liquid.hpp" #include "liquidappfile.hpp" +#include "liquidappfind.hpp" #include "liquidappicon.hpp" #include "liquidapplocalstorage.hpp" #include "liquidappconfigwindow.hpp" @@ -229,6 +230,79 @@ LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() activateWindow(); // Connect keyboard shortcuts + // Find in page + find = new LiquidAppFind(this); + typeToFindTimer = new QTimer(this); + typeToFindTimer->setSingleShot(true); + typeToFindTimer->setInterval(LQD_TYPE_TO_FIND_DELAY); + connect(typeToFindTimer, &QTimer::timeout, this, [this]() { + // Typing opens the search, unless it's meant for something that takes text, or the page has handled it + // (e.g. used it as a shortcut); keys typed in the meantime make it into the search too + static const QString script = QStringLiteral( + "(function() {" + "let element = document.activeElement;" + "while (element && element.shadowRoot && element.shadowRoot.activeElement) {" + "element = element.shadowRoot.activeElement;" + "}" + "const tag = element ? element.tagName : '';" + "const nonTextInputTypes = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color'];" + "const isEditable = document.designMode === 'on' || (element !== null && (" + "element.isContentEditable || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'IFRAME' || tag === 'EMBED' || tag === 'OBJECT' ||" + "(tag === 'INPUT' && !nonTextInputTypes.includes((element.type || '').toLowerCase()))" + "));" + "const keys = window.__liquidKeys || [];" + "const isPrevented = keys.some(function(event) { return event.defaultPrevented; });" + "keys.length = 0;" + "return JSON.stringify({ editable: !!isEditable, prevented: isPrevented });" + "})()" + ); + + const QString text = typedText; + typedText.clear(); + + QPointer self(this); + page()->runJavaScript(script, QWebEngineScript::ApplicationWorld, [self, text](const QVariant& result) { + if (!self) { + return; + } + + const QJsonObject state = QJsonDocument::fromJson(result.toString().toUtf8()).object(); + + // Leading spaces scroll the page, they're no part of what's being looked for + QString query = text; + while (!query.isEmpty() && query.at(0).isSpace()) { + query.remove(0, 1); + } + + if (!result.isNull() && !state.value("editable").toBool() && !state.value("prevented").toBool() && !query.isEmpty()) { + self->find->open(query, true); + } + }); + }); + + // Records keys the page receives, so that it can be told whether the page has handled them + // (in Liquid's own JS world, before any of the page's own code runs; it never does anything to the keys) + { + QWebEngineScript script; + script.setName(LQD_SCRIPT_NAME_KEY_RECORDER); + script.setInjectionPoint(QWebEngineScript::DocumentCreation); + script.setRunsOnSubFrames(false); + script.setWorldId(QWebEngineScript::ApplicationWorld); + script.setSourceCode(QStringLiteral( + "(function() {" + "if (window.__liquidKeys) return;" + "const keys = window.__liquidKeys = [];" + "window.addEventListener('keydown', function(event) {" + "if (!event.ctrlKey && !event.altKey && !event.metaKey) {" + "keys.push(event);" + "if (keys.length > 64) keys.shift();" + "}" + "}, true);" + "})()" + )); + liquidAppWebPage->scripts().insert(script); + } + bindKeyboardShortcuts(); // Initialize context menu @@ -432,6 +506,14 @@ void LiquidAppWindow::bindKeyboardShortcuts(void) addAction(takeSnapshotVectorAction); connect(takeSnapshotVectorAction, &QAction::triggered, this, &LiquidAppWindow::takeVectorSnapshot); + // Connect "find" shortcut + findAction = new QAction; + findAction->setShortcut(QKeySequence(tr(LQD_KBD_SEQ_FIND))); + addAction(findAction); + connect(findAction, &QAction::triggered, this, [this]() { + find->open(); + }); + // Connect "edit settings" shortcut editSettingsAction = new QAction; editSettingsAction->setShortcut(QKeySequence(tr(LQD_KBD_SEQ_EDIT_SETTINGS))); @@ -542,6 +624,10 @@ bool LiquidAppWindow::eventFilter(QObject* watched, QEvent* event) liquidAppWebPage->closeJsDialog(); break; + case QEvent::KeyPress: + onTypedOutsideOfInputs(static_cast(event)); + break; + default: break; } @@ -1127,6 +1213,11 @@ void LiquidAppWindow::resizeEvent(QResizeEvent* event) } QWebEngineView::resizeEvent(event); + + // Keep the search box in the middle + if (find != Q_NULLPTR) { + find->updateGeometry(); + } } void LiquidAppWindow::saveLiquidAppConfig(void) @@ -1203,6 +1294,7 @@ void LiquidAppWindow::setupContextMenu(void) present(forwardAction, "go-next", tr("Go Forward")); present(reloadAction, "view-refresh", tr("Refresh")); present(savePageAction, "document-save", tr("Save Page")); + present(findAction, "edit-find", tr("Find…")); present(takeSnapshotAction, "camera-photo", tr("Take Screenshot")); present(takeSnapshotFullPageAction, "camera-photo", tr("Take Full-Page Screenshot")); present(takeSnapshotVectorAction, "image-x-generic", tr("Save as Vector Image")); @@ -1221,6 +1313,7 @@ void LiquidAppWindow::setupContextMenu(void) contextMenu->addSeparator(); contextMenu->addAction(contextMenuCopyUrlAction); contextMenu->addAction(savePageAction); + contextMenu->addAction(findAction); contextMenu->addSeparator(); contextMenu->addAction(takeSnapshotAction); contextMenu->addAction(takeSnapshotFullPageAction); @@ -1276,6 +1369,12 @@ void LiquidAppWindow::setForgiveNextPageLoadError(const bool ok) void LiquidAppWindow::stopLoadingOrExitFullScreenMode(void) { + // Searching goes first: it's what's on top + if (find->isOpen()) { + find->close(); + return; + } + if (pageIsLoading) { triggerPageAction(QWebEnginePage::Stop); } else { @@ -1304,6 +1403,9 @@ QString LiquidAppWindow::getSnapshotsDirPath(void) void LiquidAppWindow::takeSnapshot(const bool fullPage) { + // Of the page, not of the search (if open) + find->hideForSnapshot(); + const int ratio = QPaintDevice::devicePixelRatio(); const QSize snapshotSize = contentsRect().size(); const QString path = getSnapshotsDirPath(); @@ -1380,6 +1482,8 @@ void LiquidAppWindow::takeSnapshot(const bool fullPage) } // TODO: add shutter sound + + find->showAfterSnapshot(); } void LiquidAppWindow::renderSnapshot(QPainter* painter) @@ -1419,6 +1523,9 @@ void LiquidAppWindow::takeVectorSnapshot(void) const QString script = QString::fromUtf8(scriptFile.readAll()); // Ignore user input until done (see eventFilter()), Escape cancels + // Of the page, not of the search (if open) + find->hideForSnapshot(); + isTakingSnapshot = true; snapshotCancelled = false; QApplication::setOverrideCursor(Qt::WaitCursor); @@ -1481,6 +1588,7 @@ void LiquidAppWindow::takeVectorSnapshot(void) QApplication::restoreOverrideCursor(); isTakingSnapshot = false; + find->showAfterSnapshot(); } QImage LiquidAppWindow::captureFullPage(void) @@ -1962,3 +2070,20 @@ void LiquidAppWindow::removeSessionStorageItems(const QString& origin, const QSt { liquidAppLocalStorage->removeSessionStorageItems(origin, keys); } + +void LiquidAppWindow::onTypedOutsideOfInputs(QKeyEvent* event) +{ + // Only text (no shortcuts), and only what goes to the page (the search box gets its own keys) + const Qt::KeyboardModifiers modifiers = event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier); + const QString text = event->text(); + + if (modifiers != Qt::NoModifier || text.isEmpty() || !text.at(0).isPrint()) { + return; + } + + // The key still goes to the page as usual: whether it's meant for finding something can be told only afterwards + typedText += text; + if (!typeToFindTimer->isActive()) { + typeToFindTimer->start(); + } +}