From 5d262144c96066bb58507c45d6abe52d0895c6fa Mon Sep 17 00:00:00 2001 From: Ben Huang Date: Tue, 23 Jun 2026 11:56:20 -0700 Subject: [PATCH 1/3] wip regex word filter --- frontend/src/ts/components/modals/WordFilterModal.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx index 01a041682c14..d5c342e85c14 100644 --- a/frontend/src/ts/components/modals/WordFilterModal.tsx +++ b/frontend/src/ts/components/modals/WordFilterModal.tsx @@ -239,9 +239,10 @@ 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, words, characters + (separated by spaces), or regular expressions on the left side. On + the right side you can generate filters based on a preset and + selected layout.
From 3a081fd8728901e45417ecda6eab14233e5877bf Mon Sep 17 00:00:00 2001 From: Ben Huang Date: Tue, 23 Jun 2026 15:34:45 -0700 Subject: [PATCH 2/3] added regex filter and implementation --- .../ts/components/modals/WordFilterModal.tsx | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx index d5c342e85c14..0916a1678b2c 100644 --- a/frontend/src/ts/components/modals/WordFilterModal.tsx +++ b/frontend/src/ts/components/modals/WordFilterModal.tsx @@ -120,6 +120,7 @@ export function WordFilterModal(props: { exclude: "", minLength: "", maxLength: "", + regex: "", exactMatch: false, }, onSubmit: async ({ value }) => { @@ -127,6 +128,30 @@ export function WordFilterModal(props: { showLoaderBar(); try { 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 { + let flags = value.regex.replace(/.*\/([gimy]*)$/, "$1"); + let pattern = value.regex.replace( + new RegExp(`^/(.*?)/${flags}$`), + "$1", + ); + reglit = new RegExp(pattern, flags); + } catch (error) { + if (error instanceof SyntaxError) { + showNoticeNotification("Invaid Regex Expression"); + return; + } else { + showNoticeNotification(String(error)); + return; + } + } + let filterin = Misc.escapeRegExp(value.include.trim()); filterin = filterin.replace(/\s+/gi, "|"); @@ -134,7 +159,6 @@ export function WordFilterModal(props: { showNoticeNotification("Include field is required for exact match"); return; } - const regincl = exactMatchOnly ? new RegExp(`^[${filterin}]+$`, "i") : new RegExp(filterin, "i"); @@ -157,10 +181,14 @@ export function WordFilterModal(props: { const filteredWords: string[] = []; for (const word of languageWordList.words) { - const test1 = regincl.test(word); - const test2 = exactMatchOnly ? false : regexcl.test(word); + const testincl = regincl.test(word); + const testexcl = + exactMatchOnly || filterout === "" ? false : regexcl.test(word); + const testlit = exactMatchOnly ? true : reglit.test(word); if ( - ((test1 && !test2) || (test1 && filterout === "")) && + testincl && + !testexcl && + testlit && word.length <= max && word.length >= min ) { @@ -205,6 +233,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,10 +268,9 @@ export function WordFilterModal(props: { />
- You can manually filter words by length, words, characters - (separated by spaces), or regular expressions 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.
@@ -259,6 +287,15 @@ export function WordFilterModal(props: {
+ + + + {(field) => ( + + )} + + + {(field) => } @@ -269,6 +306,7 @@ export function WordFilterModal(props: { onChange: ({ value }) => { if (value) { form.setFieldValue("exclude", ""); + form.setFieldValue("regex", ""); } return undefined; }, From 3d5135b744c8a396d588ee640cfb313ee95c5222 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 5 Jul 2026 16:39:20 +0200 Subject: [PATCH 3/3] implement word filtering logic with regex support and debounce functionality --- .../ts/components/modals/WordFilterModal.tsx | 168 +++++++++++------- .../src/ts/hooks/createDebouncedSignal.ts | 20 +++ 2 files changed, 126 insertions(+), 62 deletions(-) create mode 100644 frontend/src/ts/hooks/createDebouncedSignal.ts diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx index 0916a1678b2c..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 { @@ -127,46 +202,6 @@ export function WordFilterModal(props: { setLoading(true); showLoaderBar(); try { - 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 { - let flags = value.regex.replace(/.*\/([gimy]*)$/, "$1"); - let pattern = value.regex.replace( - new RegExp(`^/(.*?)/${flags}$`), - "$1", - ); - reglit = new RegExp(pattern, flags); - } catch (error) { - if (error instanceof SyntaxError) { - showNoticeNotification("Invaid Regex Expression"); - return; - } else { - showNoticeNotification(String(error)); - return; - } - } - - 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), ); @@ -176,32 +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 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); - } + 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"); @@ -214,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) { @@ -360,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; +}