diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index eaa0cd72ffbe..062eefd46e0b 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -2,6 +2,156 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as Strings from "../../src/ts/utils/strings"; describe("string utils", () => { + describe("highlightMatches", () => { + const shouldHighlight = [ + { + description: "word at the beginning", + text: "Start here.", + matches: ["Start"], + expected: 'Start here.', + }, + { + description: "word at the end", + text: "reach the end", + matches: ["end"], + expected: 'reach the end', + }, + { + description: "mutliple matches", + text: "one two three", + matches: ["one", "three"], + expected: + 'one two three', + }, + { + description: "repeated matches", + text: "one two two", + matches: ["two"], + expected: + 'one two two', + }, + { + description: "longest possible match", + text: "abc ab", + matches: ["ab", "abc"], + expected: + 'abc ab', + }, + { + description: "if wrapped in parenthesis", + text: "(test)", + matches: ["test"], + expected: '(test)', + }, + { + description: "if wrapped in commas", + text: ",test,", + matches: ["test"], + expected: ',test,', + }, + { + description: "if wrapped in underscores", + text: "_test_", + matches: ["test"], + expected: '_test_', + }, + { + description: "words in russian", + text: "Привет, мир!", + matches: ["Привет", "мир"], + expected: + 'Привет, мир!', + }, + { + description: "words with chinese punctuation", + text: "你好,世界!", + matches: ["你好", "世界"], + expected: + '你好世界!', + }, + { + description: "words with arabic punctuation", + text: "؟مرحبا، بكم؛", + matches: ["مرحبا", "بكم"], + expected: + '؟مرحبا، بكم؛', + }, + { + description: "standalone numbers", + text: "My number is 1234.", + matches: ["1234"], + expected: 'My number is 1234.', + }, + ]; + const shouldNotHighlight = [ + { + description: "a match within a longer word", + text: "together", + matches: ["get"], + }, + { + description: "a match with leading letters", + text: "welcome", + matches: ["come"], + }, + { + description: "a match with trailing letters", + text: "comets", + matches: ["come"], + }, + { + description: "japanese matches within longer words", + text: "こんにちは世界", + matches: ["こんにちは"], + }, + { + description: "numbers within words", + text: "abc1234def", + matches: ["1234"], + }, + ]; + const returnOriginal = [ + { + description: "if matches is an empty array", + text: "Nothing to match.", + matches: [], + }, + { + description: "if matches has an empty string only", + text: "Nothing to match.", + matches: [""], + }, + { + description: "if no matches found in text", + text: "Hello world.", + matches: ["absent"], + }, + { + description: "if text is empty", + text: "", + matches: ["anything"], + }, + ]; + it.each(shouldHighlight)( + "should highlight $description", + ({ text, matches, expected }) => { + expect(Strings.highlightMatches(text, matches)).toBe(expected); + } + ); + it.each(shouldNotHighlight)( + "should not highlight $description", + ({ text, matches }) => { + expect(Strings.highlightMatches(text, matches)).toBe(text); + } + ); + it.each(returnOriginal)( + "should return original text $description", + ({ text, matches }) => { + expect(Strings.highlightMatches(text, matches)).toBe(text); + } + ); + }); + describe("splitIntoCharacters", () => { it("splits regular characters", () => { expect(Strings.splitIntoCharacters("abc")).toEqual(["a", "b", "c"]); diff --git a/frontend/src/ts/modals/quote-search.ts b/frontend/src/ts/modals/quote-search.ts index 04880f0b6068..1d2c15df679d 100644 --- a/frontend/src/ts/modals/quote-search.ts +++ b/frontend/src/ts/modals/quote-search.ts @@ -10,7 +10,6 @@ import { SearchService, TextExtractor, } from "../utils/search-service"; -import { splitByAndKeep } from "../utils/strings"; import QuotesController, { Quote } from "../controllers/quotes-controller"; import { isAuthenticated } from "../firebase"; import { debounce } from "throttle-debounce"; @@ -21,6 +20,7 @@ import * as TestState from "../test/test-state"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import * as TestLogic from "../test/test-logic"; import { createErrorMessage } from "../utils/misc"; +import { highlightMatches } from "../utils/strings"; const searchServiceCache: Record> = {}; @@ -43,23 +43,6 @@ function getSearchService( return newSearchService; } -function highlightMatches(text: string, matchedText: string[]): string { - if (matchedText.length === 0) { - return text; - } - const words = splitByAndKeep(text, `.,"/#!$%^&*;:{}=-_\`~() `.split("")); - - const normalizedWords = words.map((word) => { - const shouldHighlight = - matchedText.find((match) => { - return word.startsWith(match); - }) !== undefined; - return shouldHighlight ? `${word}` : word; - }); - - return normalizedWords.join(""); -} - function applyQuoteLengthFilter(quotes: Quote[]): Quote[] { if (!modal.isOpen()) return []; const quoteLengthFilterValue = $( diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 669b2503e6c5..264d54560633 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -96,6 +96,28 @@ export function splitByAndKeep(text: string, delimiters: string[]): string[] { return splitString; } +/** + * Highlights all occurrences of specified words within a given text. + * Each match is wrapped in a element. + * Matches are ignored if they appear as part of a larger word + * not included in the matches array. + * @param text The full text in which to highlight words. + * @param matches An array of words to highlight. + * @return The full text with all matching words highlighted. + */ +export function highlightMatches(text: string, matches: string[]): string { + matches = matches.filter((match) => match !== ""); + if (matches.length === 0) return text; + + // matches that don't have a letter before or after them + const pattern = new RegExp( + `(?$&'); +} + /** * Returns a display string for the given language, optionally removing the size indicator. * @param language The language string.