From a0f2c08c1ae793b8ca7df7fa917a5cb1ea8e2f6a Mon Sep 17 00:00:00 2001 From: maxno Date: Mon, 6 Jul 2026 16:16:33 +0200 Subject: [PATCH] Add option to use regular expressions in find popup --- .../components/view-popup/find-popup.js | 41 ++++++++++- src/common/defines.js | 3 + src/common/lib/find-pattern.js | 10 +++ src/common/reader.js | 2 + .../stylesheets/components/_view-popup.scss | 17 +++-- src/common/types.ts | 1 + src/common/view.js | 3 + src/dom/common/lib/find/index.ts | 4 +- src/dom/common/lib/find/worker.ts | 30 +++++++- src/dom/epub/epub-view.ts | 1 + src/dom/snapshot/snapshot-view.ts | 1 + src/pdf/pdf-find-controller.js | 70 +++++++++++++------ src/pdf/pdf-view.js | 4 ++ 13 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 src/common/lib/find-pattern.js diff --git a/src/common/components/view-popup/find-popup.js b/src/common/components/view-popup/find-popup.js index 2f4abcd4d..e17a8c677 100644 --- a/src/common/components/view-popup/find-popup.js +++ b/src/common/components/view-popup/find-popup.js @@ -8,6 +8,7 @@ import IconChevronUp from '../../../../res/icons/20/chevron-up.svg'; import IconChevronDown from '../../../../res/icons/20/chevron-down.svg'; import IconClose from '../../../../res/icons/20/x.svg'; import { getCodeCombination, getKeyCombination } from '../../lib/utilities'; +import { compileFindRegExp } from '../../lib/find-pattern'; function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotation, tools }) { const { l10n } = useLocalization(); @@ -18,15 +19,23 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati currentParamsRef.current = params; + let invalid = !!(params.useRegex && query && !compileFindRegExp(query)); + const debounceInputChange = useCallback(debounce(value => { if (!inputRef.current) { return; } let query = inputRef.current.value; if (query !== currentParamsRef.current.query && !(query.length === 1 && RegExp(/^\p{Script=Latin}/, 'u').test(query))) { + if (currentParamsRef.current.useRegex && !compileFindRegExp(query)) { + // Record the query but don't search until the pattern is a valid regular expression + onChange({ ...currentParamsRef.current, query, active: false, result: null }); + return; + } onChange({ ...currentParamsRef.current, query, active: true, result: null }); } - }, DEBOUNCE_FIND_POPUP_INPUT), [onChange]); + // Wait longer in regex mode -- half-typed patterns can be very broad + }, params.useRegex ? DEBOUNCE_FIND_POPUP_INPUT * 3 : DEBOUNCE_FIND_POPUP_INPUT), [onChange, params.useRegex]); useLayoutEffect(() => { if (params.popupOpen) { @@ -56,6 +65,9 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati let key = getKeyCombination(event); let code = getCodeCombination(event); if (key === 'Enter') { + if (invalid) { + return; + } if (params.active) { onFindNext(); } @@ -64,6 +76,9 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati } } else if (key === 'Shift-Enter') { + if (invalid) { + return; + } if (params.active) { onFindPrevious(); } @@ -110,6 +125,18 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati onChange({ ...params, entireWord: event.currentTarget.checked, result: null }); } + function handleUseRegexChange(event) { + let useRegex = event.currentTarget.checked; + if (useRegex && query && !compileFindRegExp(query)) { + // Deactivate the search until the pattern is a valid regular expression + onChange({ ...params, useRegex, active: false, result: null }); + return; + } + // Reactivate the search if it was deactivated because of an invalid pattern + let wasInvalid = params.useRegex && !!params.query && !compileFindRegExp(params.query); + onChange({ ...params, useRegex, active: params.active || wasInvalid, result: null }); + } + return (
@@ -118,7 +145,7 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati
+
+ + +
{params.result &&
diff --git a/src/common/defines.js b/src/common/defines.js index fab092bfe..c2622f15f 100644 --- a/src/common/defines.js +++ b/src/common/defines.js @@ -36,6 +36,9 @@ export const FIND_RESULT_COLOR_ALL_LIGHT = 'rgba(180,0,170,0.3)'; export const FIND_RESULT_COLOR_CURRENT_LIGHT = 'rgba(0,100,0,0.3)'; export const FIND_RESULT_COLOR_ALL_DARK = 'rgba(180,0,170,0.6)'; export const FIND_RESULT_COLOR_CURRENT_DARK = 'rgba(0,100,0,0.6)'; +// Stop collecting find matches beyond this total -- highlighting and snippet +// generation for unbounded match counts (e.g. a regex like '.') freezes the UI +export const FIND_MAX_TOTAL_MATCHES = 5000; export const ANNOTATION_POSITION_MAX_SIZE = 65000; diff --git a/src/common/lib/find-pattern.js b/src/common/lib/find-pattern.js new file mode 100644 index 000000000..d15e4c0b4 --- /dev/null +++ b/src/common/lib/find-pattern.js @@ -0,0 +1,10 @@ +// Find patterns use the strict Unicode-mode regex grammar, both for validation +// in the find popup and for matching in the view-specific find implementations +export function compileFindRegExp(source, flags = '') { + try { + return new RegExp(source, flags + 'u'); + } + catch (e) { + return null; + } +} diff --git a/src/common/reader.js b/src/common/reader.js index 65948dfd4..06812521b 100644 --- a/src/common/reader.js +++ b/src/common/reader.js @@ -245,6 +245,7 @@ class Reader { highlightAll: true, caseSensitive: false, entireWord: false, + useRegex: false, index: null, result: null, }, @@ -260,6 +261,7 @@ class Reader { highlightAll: true, caseSensitive: false, entireWord: false, + useRegex: false, index: null, result: null }, diff --git a/src/common/stylesheets/components/_view-popup.scss b/src/common/stylesheets/components/_view-popup.scss index 081f41e39..ee4e83304 100644 --- a/src/common/stylesheets/components/_view-popup.scss +++ b/src/common/stylesheets/components/_view-popup.scss @@ -22,6 +22,14 @@ .row.input { gap: 8px; + .toolbar-text-input.invalid { + border-color: var(--accent-red); + + &:focus { + box-shadow: 0 0 0 var(--width-focus-border) var(--accent-red); + } + } + .input-box { width: 100%; display: flex; @@ -56,10 +64,11 @@ } .row.options { - height: 16px; - display: flex; - align-items: flex-start; - gap: 12px; + display: grid; + grid-template-columns: auto auto; + justify-content: start; + align-items: center; + gap: 6px 12px; .option { display: flex; diff --git a/src/common/types.ts b/src/common/types.ts index e1adb4d38..f557c6106 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -166,6 +166,7 @@ export type FindState = { highlightAll: boolean; caseSensitive: boolean; entireWord: boolean; + useRegex: boolean; // For mobile app to focus specific result index: number | null, result: { diff --git a/src/common/view.js b/src/common/view.js index 39d2312d6..33cdeab27 100644 --- a/src/common/view.js +++ b/src/common/view.js @@ -20,6 +20,7 @@ class View { highlightAll: true, caseSensitive: false, entireWord: false, + useRegex: false, index: null, result: null, // View can be created with an active search @@ -165,6 +166,7 @@ class View { * @param {String} [params.highlightAll] * @param {String} [params.caseSensitive] * @param {String} [params.entireWord] + * @param {String} [params.useRegex] * @param {String} [params.index] Focus specific result */ find(params) { @@ -182,6 +184,7 @@ class View { highlightAll: true, caseSensitive: false, entireWord: false, + useRegex: false, index: null, result: null, ...(params || {}) diff --git a/src/dom/common/lib/find/index.ts b/src/dom/common/lib/find/index.ts index 28a8b04a6..4578cd980 100644 --- a/src/dom/common/lib/find/index.ts +++ b/src/dom/common/lib/find/index.ts @@ -59,7 +59,8 @@ class DefaultFindProcessor implements FindProcessor { this.findState.query, { caseSensitive: this.findState.caseSensitive, - entireWord: this.findState.entireWord + entireWord: this.findState.entireWord, + useRegex: this.findState.useRegex } ); for (let internalOutputRange of ranges) { @@ -231,6 +232,7 @@ class DefaultFindProcessor implements FindProcessor { options: { caseSensitive: boolean, entireWord: boolean, + useRegex?: boolean, } ): Promise { if (this._worker) { diff --git a/src/dom/common/lib/find/worker.ts b/src/dom/common/lib/find/worker.ts index 9ea510abd..b291d4712 100644 --- a/src/dom/common/lib/find/worker.ts +++ b/src/dom/common/lib/find/worker.ts @@ -1,4 +1,6 @@ import type { InternalCharDataRange, InternalOutputRange, InternalSearchContext } from "./internal-types"; +import { FIND_MAX_TOTAL_MATCHES } from "../../../../common/defines"; +import { compileFindRegExp } from "../../../../common/lib/find-pattern"; onmessage = async (event) => { let { context, term, options } = event.data; @@ -11,6 +13,7 @@ export function executeSearch( options: { caseSensitive: boolean, entireWord: boolean, + useRegex?: boolean, } ): InternalOutputRange[] { if (!term) { @@ -21,14 +24,32 @@ export function executeSearch( let ranges: InternalOutputRange[] = []; // https://stackoverflow.com/a/6969486 - let termRe = normalize(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let termRe = options.useRegex + ? normalize(term) + : normalize(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (options.entireWord) { - termRe = '\\b' + termRe + '\\b'; + termRe = '\\b(?:' + termRe + ')\\b'; + } + let flags = 'g' + (options.caseSensitive ? '' : 'i'); + let re; + if (options.useRegex) { + re = compileFindRegExp(termRe, flags); + if (!re) { + return []; + } + } + else { + re = new RegExp(termRe, flags); } - let re = new RegExp(termRe, 'g' + (options.caseSensitive ? '' : 'i')); let matches; while ((matches = re.exec(text))) { let [match] = matches; + if (!match.length) { + // A pattern like 'a*' can match an empty string, which would + // otherwise loop forever + re.lastIndex++; + continue; + } let { charDataID: startCharDataID, start: startOffset } = binarySearch(internalCharDataRanges, matches.index)!; let { charDataID: endCharDataID, start: endOffset } = binarySearch(internalCharDataRanges, matches.index + match.length)!; ranges.push({ @@ -37,6 +58,9 @@ export function executeSearch( endCharDataID, endIndex: matches.index + match.length - endOffset, }); + if (ranges.length >= FIND_MAX_TOTAL_MATCHES) { + break; + } } return ranges; diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts index 5d64ac502..9ffadc07e 100644 --- a/src/dom/epub/epub-view.ts +++ b/src/dom/epub/epub-view.ts @@ -1222,6 +1222,7 @@ class EPUBView extends DOMView { || previousState.query !== state.query || previousState.caseSensitive !== state.caseSensitive || previousState.entireWord !== state.entireWord + || previousState.useRegex !== state.useRegex || previousState.active !== state.active) { console.log('Initiating new search', state); this._find?.cancel(); diff --git a/src/dom/snapshot/snapshot-view.ts b/src/dom/snapshot/snapshot-view.ts index f317acda2..f475b5f05 100644 --- a/src/dom/snapshot/snapshot-view.ts +++ b/src/dom/snapshot/snapshot-view.ts @@ -585,6 +585,7 @@ class SnapshotView extends DOMView { || previousState.query !== state.query || previousState.caseSensitive !== state.caseSensitive || previousState.entireWord !== state.entireWord + || previousState.useRegex !== state.useRegex || previousState.active !== state.active) { console.log('Initiating new search', state); this._find = new DefaultFindProcessor({ diff --git a/src/pdf/pdf-find-controller.js b/src/pdf/pdf-find-controller.js index 824b2c7d4..29642c949 100644 --- a/src/pdf/pdf-find-controller.js +++ b/src/pdf/pdf-find-controller.js @@ -26,6 +26,8 @@ Promise.withResolvers || (Promise.withResolvers = function withResolvers() { }); import { getRangeRects } from './lib/utilities'; +import { FIND_MAX_TOTAL_MATCHES } from '../common/defines'; +import { compileFindRegExp } from '../common/lib/find-pattern'; // pdf_find_utils.js [ const CharacterType = { @@ -536,8 +538,14 @@ function getSnippet(text, phraseStart, phraseEnd, numWordsAround) { let phrase = text.substring(phraseStart, phraseEnd); - let leftText = text.substring(0, phraseStart).trim(); - let rightText = text.substring(phraseEnd).trim(); + // Only tokenize a bounded window around the phrase -- splitting the whole + // page text for every match freezes the UI on high-match-count searches + const windowSize = numWordsAround * 20; + let windowStart = Math.max(0, phraseStart - windowSize); + let windowEnd = Math.min(text.length, phraseEnd + windowSize); + + let leftText = text.substring(windowStart, phraseStart).trim(); + let rightText = text.substring(phraseEnd, windowEnd).trim(); let leftWords = leftText ? leftText.split(/\s+/) : []; let rightWords = rightText ? rightText.split(/\s+/) : []; @@ -549,8 +557,8 @@ function getSnippet(text, phraseStart, phraseEnd, numWordsAround) { let rightSnippet = rightSnippetWords.join(' '); // Optionally add ellipses if there are more words that we didn't include. - let prefix = leftWords.length > numWordsAround ? '…' : ''; - let suffix = rightWords.length > numWordsAround ? '…' : ''; + let prefix = leftWords.length > numWordsAround || windowStart > 0 ? '…' : ''; + let suffix = rightWords.length > numWordsAround || windowEnd < text.length ? '…' : ''; // Determine if the phrase starts or ends mid-word. // If phraseStart is in the middle of a word (i.e. the character immediately before is not whitespace) @@ -904,9 +912,19 @@ class PDFFindController { // been stripped out. return; } + const maxMatches = FIND_MAX_TOTAL_MATCHES - this._matchesCountTotal; + if (maxMatches <= 0) { + return; + } const diffs = this._pageDiffs[pageIndex]; let match; while ((match = query.exec(pageContent)) !== null) { + if (match[0].length === 0) { + // A pattern like 'a*' can match an empty string, which would + // otherwise loop forever + query.lastIndex++; + continue; + } if ( entireWord && !this._isEntireWord(pageContent, match.index, match[0].length) @@ -923,6 +941,9 @@ class PDFFindController { if (matchLen) { matches.push(matchPos); matchesLength.push(matchLen); + if (matches.length >= maxMatches) { + break; + } } } } @@ -1002,29 +1023,38 @@ class PDFFindController { if (query.length === 0) { return; // Do nothing: the matches should be wiped out already. } - const { caseSensitive, entireWord } = this._state; + const { caseSensitive, entireWord, useRegex } = this._state; const pageContent = this._pageContents[pageIndex]; const hasDiacritics = this._hasDiacritics[pageIndex]; let isUnicode = false; - if (typeof query === "string") { - [isUnicode, query] = this._convertToRegExpString(query, hasDiacritics); + if (useRegex) { + if (Array.isArray(query)) { + query = query.map(q => `(?:${q})`).join("|"); + } + // Use the pattern as-is; null if invalid + query = compileFindRegExp(query, `g${caseSensitive ? "" : "i"}`); } else { - // Words are sorted in reverse order to be sure that "foobar" is matched - // before "foo" in case the query is "foobar foo". - query = query.sort().reverse().map(q => { - const [isUnicodePart, queryPart] = this._convertToRegExpString( - q, - hasDiacritics - ); - isUnicode ||= isUnicodePart; - return `(${queryPart})`; - }).join("|"); - } + if (typeof query === "string") { + [isUnicode, query] = this._convertToRegExpString(query, hasDiacritics); + } + else { + // Words are sorted in reverse order to be sure that "foobar" is matched + // before "foo" in case the query is "foobar foo". + query = query.sort().reverse().map(q => { + const [isUnicodePart, queryPart] = this._convertToRegExpString( + q, + hasDiacritics + ); + isUnicode ||= isUnicodePart; + return `(${queryPart})`; + }).join("|"); + } - const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`; - query = query ? new RegExp(query, flags) : null; + const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`; + query = query ? new RegExp(query, flags) : null; + } this._calculateRegExpMatch(query, entireWord, pageIndex, pageContent); diff --git a/src/pdf/pdf-view.js b/src/pdf/pdf-view.js index 4faf68328..541a5fd01 100644 --- a/src/pdf/pdf-view.js +++ b/src/pdf/pdf-view.js @@ -1384,6 +1384,7 @@ class PDFView { || this._findState.highlightAll !== state.highlightAll || this._findState.caseSensitive !== state.caseSensitive || this._findState.entireWord !== state.entireWord + || this._findState.useRegex !== state.useRegex || this._findState.active !== state.active) { // Immediately update find state because pdf.js find will trigger _updateFindMatchesCount // and _updateFindControlState that update the current find state @@ -1395,6 +1396,7 @@ class PDFView { phraseSearch: true, caseSensitive: state.caseSensitive, entireWord: state.entireWord, + useRegex: state.useRegex, highlightAll: state.highlightAll, findPrevious: false }); @@ -1416,6 +1418,7 @@ class PDFView { phraseSearch: true, caseSensitive: this._findState.caseSensitive, entireWord: this._findState.entireWord, + useRegex: this._findState.useRegex, highlightAll: this._findState.highlightAll, findPrevious: false }); @@ -1431,6 +1434,7 @@ class PDFView { phraseSearch: true, caseSensitive: this._findState.caseSensitive, entireWord: this._findState.entireWord, + useRegex: this._findState.useRegex, highlightAll: this._findState.highlightAll, findPrevious: true });