diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx index 01a041682c14..a16dc8cf63b5 100644 --- a/frontend/src/ts/components/modals/WordFilterModal.tsx +++ b/frontend/src/ts/components/modals/WordFilterModal.tsx @@ -3,10 +3,17 @@ import type { LayoutObject } from "@monkeytype/schemas/layouts"; import { tryCatch } from "@monkeytype/util/trycatch"; import { createForm } from "@tanstack/solid-form"; -import { createSignal, JSXElement, Setter } from "solid-js"; +import { + createMemo, + createResource, + createSignal, + JSXElement, + Setter, +} from "solid-js"; import { LanguageList } from "../../constants/languages"; import { LayoutsList } from "../../constants/layouts"; +import { createDebouncedSignal } from "../../hooks/createDebouncedSignal"; import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { hideModal } from "../../states/modals"; import { @@ -104,6 +111,74 @@ const presetOptions = Object.entries(presets).map(([id, preset]) => ({ text: preset.display, })); +type FilterFormValues = { + include: string; + exclude: string; + minLength: string; + maxLength: string; + regex: string; + exactMatch: boolean; +}; + +type FilterResult = { words: string[] } | { error: string }; + +function filterWordList( + value: FilterFormValues, + words: string[], +): FilterResult { + const exactMatchOnly = value.exactMatch; + + // Source - https://stackoverflow.com/a/874742 + // Retrieved 2026-06-23, License - CC BY-SA 3.0 + // Separates string into regex expression + let reglit = new RegExp(""); + try { + const flags = value.regex.replace(/.*\/([gimy]*)$/, "$1"); + const pattern = value.regex.replace(new RegExp(`^/(.*?)/${flags}$`), "$1"); + reglit = new RegExp(pattern, flags); + } catch (error) { + if (error instanceof SyntaxError) { + return { error: "Invaid Regex Expression" }; + } + return { error: String(error) }; + } + + let filterin = Misc.escapeRegExp(value.include.trim()); + filterin = filterin.replace(/\s+/gi, "|"); + + if (exactMatchOnly && filterin === "") { + return { error: "Include field is required for exact match" }; + } + const regincl = exactMatchOnly + ? new RegExp(`^[${filterin}]+$`, "i") + : new RegExp(filterin, "i"); + + let filterout = Misc.escapeRegExp(value.exclude.trim()); + filterout = filterout.replace(/\s+/gi, "|"); + const regexcl = new RegExp(filterout, "i"); + + const max = value.maxLength === "" ? 999 : parseInt(value.maxLength); + const min = value.minLength === "" ? 1 : parseInt(value.minLength); + + const filteredWords: string[] = []; + for (const word of words) { + const testincl = regincl.test(word); + const testexcl = + exactMatchOnly || filterout === "" ? false : regexcl.test(word); + const testlit = exactMatchOnly ? true : reglit.test(word); + if ( + testincl && + !testexcl && + testlit && + word.length <= max && + word.length >= min + ) { + filteredWords.push(word); + } + } + return { words: filteredWords }; +} + export function WordFilterModal(props: { setChainedData: Setter; }): JSXElement { @@ -120,29 +195,13 @@ export function WordFilterModal(props: { exclude: "", minLength: "", maxLength: "", + regex: "", exactMatch: false, }, onSubmit: async ({ value }) => { setLoading(true); showLoaderBar(); try { - const exactMatchOnly = value.exactMatch; - let filterin = Misc.escapeRegExp(value.include.trim()); - filterin = filterin.replace(/\s+/gi, "|"); - - if (exactMatchOnly && filterin === "") { - showNoticeNotification("Include field is required for exact match"); - return; - } - - const regincl = exactMatchOnly - ? new RegExp(`^[${filterin}]+$`, "i") - : new RegExp(filterin, "i"); - - let filterout = Misc.escapeRegExp(value.exclude.trim()); - filterout = filterout.replace(/\s+/gi, "|"); - const regexcl = new RegExp(filterout, "i"); - const { data: languageWordList, error } = await tryCatch( JSONData.getLanguage(language() as Language), ); @@ -152,28 +211,18 @@ export function WordFilterModal(props: { return; } - const max = value.maxLength === "" ? 999 : parseInt(value.maxLength); - const min = value.minLength === "" ? 1 : parseInt(value.minLength); - - const filteredWords: string[] = []; - for (const word of languageWordList.words) { - const test1 = regincl.test(word); - const test2 = exactMatchOnly ? false : regexcl.test(word); - if ( - ((test1 && !test2) || (test1 && filterout === "")) && - word.length <= max && - word.length >= min - ) { - filteredWords.push(word); - } + const result = filterWordList(value, languageWordList.words); + if ("error" in result) { + showNoticeNotification(result.error); + return; } - if (filteredWords.length === 0) { + if (result.words.length === 0) { showNoticeNotification("No words found"); return; } props.setChainedData({ - splitText: filteredWords, + splitText: result.words, set: submitAction === "set", }); hideModal("WordFilter"); @@ -186,6 +235,22 @@ export function WordFilterModal(props: { const isExactMatch = form.useStore((s) => s.values.exactMatch); + const [languageWords] = createResource(language, async (lang) => { + const { data } = await tryCatch(JSONData.getLanguage(lang as Language)); + return data?.words ?? null; + }); + + const formValues = form.useStore((s) => s.values); + + // debounce the preview so it doesn't refilter the whole word list on every keystroke + const debouncedValues = createDebouncedSignal(formValues, 250); + + const matchResult = createMemo(() => { + const words = languageWords(); + if (words === null || words === undefined) return null; + return filterWordList(debouncedValues(), words); + }); + const applyPreset = async () => { const presetToApply = presets[preset()]; if (presetToApply === undefined) { @@ -205,6 +270,7 @@ export function WordFilterModal(props: { if (presetToApply.exactMatch === true) { form.setFieldValue("exactMatch", true); form.setFieldValue("exclude", ""); + form.setFieldValue("regex", ""); } else { form.setFieldValue("exactMatch", false); if (presetToApply.getExcludeString !== undefined) { @@ -239,9 +305,9 @@ export function WordFilterModal(props: { />
- You can manually filter words by length, words or characters - (separated by spaces) on the left side. On the right side you can - generate filters based on a preset and selected layout. + You can manually filter words by length, regular expressions, words, + or characters (separated by spaces) on the left side. On the right + side you can generate filters based on a preset and selected layout.
@@ -258,6 +324,15 @@ export function WordFilterModal(props: {
+ + + + {(field) => ( + + )} + + + {(field) => } @@ -268,6 +343,7 @@ export function WordFilterModal(props: { onChange: ({ value }) => { if (value) { form.setFieldValue("exclude", ""); + form.setFieldValue("regex", ""); } return undefined; }, @@ -321,7 +397,14 @@ export function WordFilterModal(props: { /> - +
+ {(() => { + const result = matchResult(); + if (result === null) return "loading words..."; + if ("error" in result) return result.error; + return `${result.words.length} words found`; + })()} +
{ '"Set" replaces the current custom word list with the filter result, "Add" appends the filter result to the current custom word list.' diff --git a/frontend/src/ts/hooks/createDebouncedSignal.ts b/frontend/src/ts/hooks/createDebouncedSignal.ts new file mode 100644 index 000000000000..bf3cb7154cc7 --- /dev/null +++ b/frontend/src/ts/hooks/createDebouncedSignal.ts @@ -0,0 +1,20 @@ +import { Accessor, createEffect, createSignal, onCleanup } from "solid-js"; +import { debounce } from "throttle-debounce"; + +/** + * Derives a debounced accessor from a reactive source. The returned accessor + * updates at most once per `delay` ms after the source stops changing. + */ +export function createDebouncedSignal( + source: Accessor, + delay: number, +): Accessor { + const [value, setValue] = createSignal(source()); + + const update = debounce(delay, (next: T) => setValue(() => next)); + onCleanup(() => update.cancel()); + + createEffect(() => update(source())); + + return value; +}