mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-16 23:40:32 +08:00
feat(proxy): unify filter search box and sync filter options (#5819)
* feat(proxy): unify filter search box and sync filter options * docs: fix JSDoc placement * refactor(search): unify string matcher and stabilize regex filtering * fix(search): treat invalid regex as matching nothing * docs: update changelog to include advanced filter search feature for proxy page --------- Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
parent
aa72fa9a42
commit
b4e25951b4
@ -7,6 +7,8 @@
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||||
|
|
||||||
|
- 允许代理页面允许高级过滤搜索
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
|
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
|
||||||
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
|
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
|
||||||
import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react";
|
import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react";
|
||||||
|
import { buildRegex, compileStringMatcher } from "@/utils/search-matcher";
|
||||||
|
|
||||||
export type SearchState = {
|
export type SearchState = {
|
||||||
text: string;
|
text: string;
|
||||||
@ -21,11 +22,17 @@ export type SearchState = {
|
|||||||
useRegularExpression: boolean;
|
useRegularExpression: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SearchOptionState = Omit<SearchState, "text">;
|
||||||
|
|
||||||
type SearchProps = {
|
type SearchProps = {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
matchCase?: boolean;
|
matchCase?: boolean;
|
||||||
matchWholeWord?: boolean;
|
matchWholeWord?: boolean;
|
||||||
useRegularExpression?: boolean;
|
useRegularExpression?: boolean;
|
||||||
|
searchState?: Partial<SearchOptionState>;
|
||||||
onSearch: (match: (content: string) => boolean, state: SearchState) => void;
|
onSearch: (match: (content: string) => boolean, state: SearchState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -42,36 +49,61 @@ const StyledTextField = styled(TextField)(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const useControllableState = <T,>(options: {
|
||||||
|
controlled: T | undefined;
|
||||||
|
defaultValue: T;
|
||||||
|
}) => {
|
||||||
|
const { controlled, defaultValue } = options;
|
||||||
|
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
||||||
|
const isControlled = controlled !== undefined;
|
||||||
|
|
||||||
|
const value = isControlled ? controlled : uncontrolled;
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(next: T) => {
|
||||||
|
if (!isControlled) setUncontrolled(next);
|
||||||
|
},
|
||||||
|
[isControlled],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
};
|
||||||
|
|
||||||
export const BaseSearchBox = ({
|
export const BaseSearchBox = ({
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
autoFocus,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
searchState,
|
||||||
matchCase: defaultMatchCase = false,
|
matchCase: defaultMatchCase = false,
|
||||||
matchWholeWord: defaultMatchWholeWord = false,
|
matchWholeWord: defaultMatchWholeWord = false,
|
||||||
useRegularExpression: defaultUseRegularExpression = false,
|
useRegularExpression: defaultUseRegularExpression = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
}: SearchProps) => {
|
}: SearchProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const escapeRegex = useCallback((value: string) => {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const buildRegex = useCallback((pattern: string, flags = "") => {
|
|
||||||
try {
|
|
||||||
return new RegExp(pattern, flags);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[BaseSearchBox] buildRegex error:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const onSearchRef = useRef(onSearch);
|
const onSearchRef = useRef(onSearch);
|
||||||
const [matchCase, setMatchCase] = useState(defaultMatchCase);
|
const lastSearchStateRef = useRef<SearchState | null>(null);
|
||||||
const [matchWholeWord, setMatchWholeWord] = useState(defaultMatchWholeWord);
|
|
||||||
const [useRegularExpression, setUseRegularExpression] = useState(
|
const [text, setText] = useControllableState<string>({
|
||||||
defaultUseRegularExpression,
|
controlled: value,
|
||||||
);
|
defaultValue: defaultValue ?? "",
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
});
|
||||||
|
|
||||||
|
const [matchCase, setMatchCase] = useControllableState<boolean>({
|
||||||
|
controlled: searchState?.matchCase,
|
||||||
|
defaultValue: defaultMatchCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [matchWholeWord, setMatchWholeWord] = useControllableState<boolean>({
|
||||||
|
controlled: searchState?.matchWholeWord,
|
||||||
|
defaultValue: defaultMatchWholeWord,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [useRegularExpression, setUseRegularExpression] =
|
||||||
|
useControllableState<boolean>({
|
||||||
|
controlled: searchState?.useRegularExpression,
|
||||||
|
defaultValue: defaultUseRegularExpression,
|
||||||
|
});
|
||||||
|
|
||||||
const iconStyle = {
|
const iconStyle = {
|
||||||
style: {
|
style: {
|
||||||
@ -82,97 +114,43 @@ export const BaseSearchBox = ({
|
|||||||
inheritViewBox: true,
|
inheritViewBox: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper that verifies whether a pattern is a valid regular expression
|
|
||||||
const validateRegex = useCallback(
|
|
||||||
(pattern: string, flags = "") => {
|
|
||||||
if (!pattern) return true;
|
|
||||||
return !!buildRegex(pattern, flags);
|
|
||||||
},
|
|
||||||
[buildRegex],
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMatcher = useMemo(() => {
|
|
||||||
return (searchText: string) => {
|
|
||||||
if (!searchText) {
|
|
||||||
return () => true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const flags = matchCase ? "" : "i";
|
|
||||||
|
|
||||||
if (useRegularExpression) {
|
|
||||||
const regex = buildRegex(searchText, flags);
|
|
||||||
if (!regex) return () => false;
|
|
||||||
|
|
||||||
return (content: string) => {
|
|
||||||
try {
|
|
||||||
return regex.test(content);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[BaseSearchBox] regex match error:", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchWholeWord) {
|
|
||||||
const regex = buildRegex(`\\b${escapeRegex(searchText)}\\b`, flags);
|
|
||||||
if (!regex) return () => false;
|
|
||||||
|
|
||||||
return (content: string) => {
|
|
||||||
try {
|
|
||||||
return regex.test(content);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[BaseSearchBox] whole word match error:", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return (content: string) => {
|
|
||||||
const item = matchCase ? content : content.toLowerCase();
|
|
||||||
const target = matchCase ? searchText : searchText.toLowerCase();
|
|
||||||
return item.includes(target);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
buildRegex,
|
|
||||||
escapeRegex,
|
|
||||||
matchCase,
|
|
||||||
matchWholeWord,
|
|
||||||
useRegularExpression,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSearchRef.current = onSearch;
|
onSearchRef.current = onSearch;
|
||||||
}, [onSearch]);
|
}, [onSearch]);
|
||||||
|
|
||||||
|
const emitSearch = useCallback((nextState: SearchState) => {
|
||||||
|
const prevState = lastSearchStateRef.current;
|
||||||
|
const isSameState =
|
||||||
|
!!prevState &&
|
||||||
|
prevState.text === nextState.text &&
|
||||||
|
prevState.matchCase === nextState.matchCase &&
|
||||||
|
prevState.matchWholeWord === nextState.matchWholeWord &&
|
||||||
|
prevState.useRegularExpression === nextState.useRegularExpression;
|
||||||
|
if (isSameState) return;
|
||||||
|
|
||||||
|
const compiled = compileStringMatcher(nextState.text, nextState);
|
||||||
|
onSearchRef.current(compiled.matcher, nextState);
|
||||||
|
|
||||||
|
lastSearchStateRef.current = nextState;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inputRef.current) return;
|
emitSearch({ text, matchCase, matchWholeWord, useRegularExpression });
|
||||||
const value = inputRef.current.value;
|
}, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]);
|
||||||
const matcher = createMatcher(value);
|
|
||||||
onSearchRef.current(matcher, {
|
|
||||||
text: value,
|
|
||||||
matchCase,
|
|
||||||
matchWholeWord,
|
|
||||||
useRegularExpression,
|
|
||||||
});
|
|
||||||
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
|
|
||||||
|
|
||||||
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const effectiveErrorMessage = useMemo(() => {
|
||||||
const value = e.target?.value ?? "";
|
if (!useRegularExpression || !text) return "";
|
||||||
setErrorMessage("");
|
|
||||||
const flags = matchCase ? "" : "i";
|
const flags = matchCase ? "" : "i";
|
||||||
|
return buildRegex(text, flags) ? "" : t("shared.validation.invalidRegex");
|
||||||
|
}, [matchCase, t, text, useRegularExpression]);
|
||||||
|
|
||||||
// Validate regex input eagerly
|
const handleChangeText = (
|
||||||
if (useRegularExpression && value) {
|
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
const isValid = validateRegex(value, flags);
|
) => {
|
||||||
if (!isValid) {
|
const nextText = e.target?.value ?? "";
|
||||||
setErrorMessage(t("shared.validation.invalidRegex"));
|
setText(nextText);
|
||||||
}
|
emitSearch({
|
||||||
}
|
text: nextText,
|
||||||
|
|
||||||
const matcher = createMatcher(value);
|
|
||||||
onSearchRef.current(matcher, {
|
|
||||||
text: value,
|
|
||||||
matchCase,
|
matchCase,
|
||||||
matchWholeWord,
|
matchWholeWord,
|
||||||
useRegularExpression,
|
useRegularExpression,
|
||||||
@ -180,35 +158,43 @@ export const BaseSearchBox = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleUseRegularExpression = () => {
|
const handleToggleUseRegularExpression = () => {
|
||||||
setUseRegularExpression((prev) => {
|
const next = !useRegularExpression;
|
||||||
const next = !prev;
|
setUseRegularExpression(next);
|
||||||
if (!next) {
|
emitSearch({
|
||||||
setErrorMessage("");
|
text,
|
||||||
} else {
|
matchCase,
|
||||||
const value = inputRef.current?.value ?? "";
|
matchWholeWord,
|
||||||
const flags = matchCase ? "" : "i";
|
useRegularExpression: next,
|
||||||
if (value && !validateRegex(value, flags)) {
|
|
||||||
setErrorMessage(t("shared.validation.invalidRegex"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleMatchCase = () => {
|
||||||
|
const next = !matchCase;
|
||||||
|
setMatchCase(next);
|
||||||
|
emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMatchWholeWord = () => {
|
||||||
|
const next = !matchWholeWord;
|
||||||
|
setMatchWholeWord(next);
|
||||||
|
emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={errorMessage || ""} placement="bottom-start">
|
<Tooltip title={effectiveErrorMessage || ""} placement="bottom-start">
|
||||||
<StyledTextField
|
<StyledTextField
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
inputRef={inputRef}
|
|
||||||
hiddenLabel
|
hiddenLabel
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
autoFocus={autoFocus}
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
placeholder={placeholder ?? t("shared.placeholders.filter")}
|
placeholder={placeholder ?? t("shared.placeholders.filter")}
|
||||||
sx={{ input: { py: 0.65, px: 1.25 } }}
|
sx={{ input: { py: 0.65, px: 1.25 } }}
|
||||||
onChange={onChange}
|
value={text}
|
||||||
error={!!errorMessage}
|
onChange={handleChangeText}
|
||||||
|
error={!!effectiveErrorMessage}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
sx: { pr: 1 },
|
sx: { pr: 1 },
|
||||||
@ -220,7 +206,7 @@ export const BaseSearchBox = ({
|
|||||||
component={matchCaseIcon}
|
component={matchCaseIcon}
|
||||||
{...iconStyle}
|
{...iconStyle}
|
||||||
aria-label={matchCase ? "active" : "inactive"}
|
aria-label={matchCase ? "active" : "inactive"}
|
||||||
onClick={() => setMatchCase((prev) => !prev)}
|
onClick={handleToggleMatchCase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -230,7 +216,7 @@ export const BaseSearchBox = ({
|
|||||||
component={matchWholeWordIcon}
|
component={matchWholeWordIcon}
|
||||||
{...iconStyle}
|
{...iconStyle}
|
||||||
aria-label={matchWholeWord ? "active" : "inactive"}
|
aria-label={matchWholeWord ? "active" : "inactive"}
|
||||||
onClick={() => setMatchWholeWord((prev) => !prev)}
|
onClick={handleToggleMatchWholeWord}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { Box, IconButton, TextField, SxProps } from "@mui/material";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { BaseSearchBox } from "@/components/base/base-search-box";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import delayManager from "@/services/delay";
|
import delayManager from "@/services/delay";
|
||||||
import { debugLog } from "@/utils/debug";
|
import { debugLog } from "@/utils/debug";
|
||||||
@ -43,7 +44,16 @@ export const ProxyHead = ({
|
|||||||
onLocation,
|
onLocation,
|
||||||
onCheckDelay,
|
onCheckDelay,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showType, sortType, filterText, textState, testUrl } = headState;
|
const {
|
||||||
|
showType,
|
||||||
|
sortType,
|
||||||
|
filterText,
|
||||||
|
textState,
|
||||||
|
testUrl,
|
||||||
|
filterMatchCase,
|
||||||
|
filterMatchWholeWord,
|
||||||
|
filterUseRegularExpression,
|
||||||
|
} = headState;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [autoFocus, setAutoFocus] = useState(false);
|
const [autoFocus, setAutoFocus] = useState(false);
|
||||||
@ -154,17 +164,25 @@ export const ProxyHead = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{textState === "filter" && (
|
{textState === "filter" && (
|
||||||
<TextField
|
<Box sx={{ ml: 0.5, flex: "1 1 auto" }}>
|
||||||
autoComplete="new-password"
|
<BaseSearchBox
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
hiddenLabel
|
value={filterText}
|
||||||
value={filterText}
|
searchState={{
|
||||||
size="small"
|
matchCase: filterMatchCase,
|
||||||
variant="outlined"
|
matchWholeWord: filterMatchWholeWord,
|
||||||
placeholder={t("shared.placeholders.filter")}
|
useRegularExpression: filterUseRegularExpression,
|
||||||
onChange={(e) => onHeadState({ filterText: e.target.value })}
|
}}
|
||||||
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }}
|
onSearch={(_, state) =>
|
||||||
/>
|
onHeadState({
|
||||||
|
filterText: state.text,
|
||||||
|
filterMatchCase: state.matchCase,
|
||||||
|
filterMatchWholeWord: state.matchWholeWord,
|
||||||
|
filterUseRegularExpression: state.useRegularExpression,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{textState === "url" && (
|
{textState === "url" && (
|
||||||
|
|||||||
@ -2,15 +2,23 @@ import { useEffect, useMemo, useReducer } from "react";
|
|||||||
|
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import delayManager from "@/services/delay";
|
import delayManager from "@/services/delay";
|
||||||
|
import { compileStringMatcher } from "@/utils/search-matcher";
|
||||||
|
|
||||||
// default | delay | alphabet
|
// default | delay | alphabet
|
||||||
export type ProxySortType = 0 | 1 | 2;
|
export type ProxySortType = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export type ProxySearchState = {
|
||||||
|
matchCase?: boolean;
|
||||||
|
matchWholeWord?: boolean;
|
||||||
|
useRegularExpression?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function useFilterSort(
|
export default function useFilterSort(
|
||||||
proxies: IProxyItem[],
|
proxies: IProxyItem[],
|
||||||
groupName: string,
|
groupName: string,
|
||||||
filterText: string,
|
filterText: string,
|
||||||
sortType: ProxySortType,
|
sortType: ProxySortType,
|
||||||
|
searchState?: ProxySearchState,
|
||||||
) {
|
) {
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
|
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
|
||||||
@ -33,7 +41,7 @@ export default function useFilterSort(
|
|||||||
}, [groupName]);
|
}, [groupName]);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const fp = filterProxies(proxies, groupName, filterText);
|
const fp = filterProxies(proxies, groupName, filterText, searchState);
|
||||||
const sp = sortProxies(
|
const sp = sortProxies(
|
||||||
fp,
|
fp,
|
||||||
groupName,
|
groupName,
|
||||||
@ -46,6 +54,7 @@ export default function useFilterSort(
|
|||||||
groupName,
|
groupName,
|
||||||
filterText,
|
filterText,
|
||||||
sortType,
|
sortType,
|
||||||
|
searchState,
|
||||||
verge?.default_latency_timeout,
|
verge?.default_latency_timeout,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -56,8 +65,9 @@ export function filterSort(
|
|||||||
filterText: string,
|
filterText: string,
|
||||||
sortType: ProxySortType,
|
sortType: ProxySortType,
|
||||||
latencyTimeout?: number,
|
latencyTimeout?: number,
|
||||||
|
searchState?: ProxySearchState,
|
||||||
) {
|
) {
|
||||||
const fp = filterProxies(proxies, groupName, filterText);
|
const fp = filterProxies(proxies, groupName, filterText, searchState);
|
||||||
const sp = sortProxies(fp, groupName, sortType, latencyTimeout);
|
const sp = sortProxies(fp, groupName, sortType, latencyTimeout);
|
||||||
return sp;
|
return sp;
|
||||||
}
|
}
|
||||||
@ -76,10 +86,12 @@ function filterProxies(
|
|||||||
proxies: IProxyItem[],
|
proxies: IProxyItem[],
|
||||||
groupName: string,
|
groupName: string,
|
||||||
filterText: string,
|
filterText: string,
|
||||||
|
searchState?: ProxySearchState,
|
||||||
) {
|
) {
|
||||||
if (!filterText) return proxies;
|
const query = filterText.trim();
|
||||||
|
if (!query) return proxies;
|
||||||
|
|
||||||
const res1 = regex1.exec(filterText);
|
const res1 = regex1.exec(query);
|
||||||
if (res1) {
|
if (res1) {
|
||||||
const symbol = res1[1];
|
const symbol = res1[1];
|
||||||
const symbol2 = res1[2].toLowerCase();
|
const symbol2 = res1[2].toLowerCase();
|
||||||
@ -100,13 +112,25 @@ function filterProxies(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const res2 = regex2.exec(filterText);
|
const res2 = regex2.exec(query);
|
||||||
if (res2) {
|
if (res2) {
|
||||||
const type = res2[1].toLowerCase();
|
const type = res2[1].toLowerCase();
|
||||||
return proxies.filter((p) => p.type.toLowerCase().includes(type));
|
return proxies.filter((p) => p.type.toLowerCase().includes(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
return proxies.filter((p) => p.name.includes(filterText.trim()));
|
const {
|
||||||
|
matchCase = false,
|
||||||
|
matchWholeWord = false,
|
||||||
|
useRegularExpression = false,
|
||||||
|
} = searchState ?? {};
|
||||||
|
const compiled = compileStringMatcher(query, {
|
||||||
|
matchCase,
|
||||||
|
matchWholeWord,
|
||||||
|
useRegularExpression,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!compiled.isValid) return [];
|
||||||
|
return proxies.filter((p) => compiled.matcher(p.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,6 +9,9 @@ export interface HeadState {
|
|||||||
showType: boolean;
|
showType: boolean;
|
||||||
sortType: ProxySortType;
|
sortType: ProxySortType;
|
||||||
filterText: string;
|
filterText: string;
|
||||||
|
filterMatchCase?: boolean;
|
||||||
|
filterMatchWholeWord?: boolean;
|
||||||
|
filterUseRegularExpression?: boolean;
|
||||||
textState: "url" | "filter" | null;
|
textState: "url" | "filter" | null;
|
||||||
testUrl: string;
|
testUrl: string;
|
||||||
}
|
}
|
||||||
@ -21,6 +24,9 @@ export const DEFAULT_STATE: HeadState = {
|
|||||||
showType: true,
|
showType: true,
|
||||||
sortType: 0,
|
sortType: 0,
|
||||||
filterText: "",
|
filterText: "",
|
||||||
|
filterMatchCase: false,
|
||||||
|
filterMatchWholeWord: false,
|
||||||
|
filterUseRegularExpression: false,
|
||||||
textState: null,
|
textState: null,
|
||||||
testUrl: "",
|
testUrl: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -390,6 +390,11 @@ export const useRenderList = (
|
|||||||
headState.filterText,
|
headState.filterText,
|
||||||
headState.sortType,
|
headState.sortType,
|
||||||
latencyTimeout,
|
latencyTimeout,
|
||||||
|
{
|
||||||
|
matchCase: headState.filterMatchCase,
|
||||||
|
matchWholeWord: headState.filterMatchWholeWord,
|
||||||
|
useRegularExpression: headState.filterUseRegularExpression,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ret.push({
|
ret.push({
|
||||||
|
|||||||
59
src/utils/search-matcher.ts
Normal file
59
src/utils/search-matcher.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export type SearchMatcherOptions = {
|
||||||
|
matchCase?: boolean;
|
||||||
|
matchWholeWord?: boolean;
|
||||||
|
useRegularExpression?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompileStringMatcherResult = {
|
||||||
|
matcher: (content: string) => boolean;
|
||||||
|
isValid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const escapeRegex = (value: string) => {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRegex = (pattern: string, flags = "") => {
|
||||||
|
try {
|
||||||
|
return new RegExp(pattern, flags);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileStringMatcher = (
|
||||||
|
query: string,
|
||||||
|
options: SearchMatcherOptions = {},
|
||||||
|
): CompileStringMatcherResult => {
|
||||||
|
if (!query) return { matcher: () => true, isValid: true };
|
||||||
|
|
||||||
|
const matchCase = options.matchCase ?? false;
|
||||||
|
const matchWholeWord = options.matchWholeWord ?? false;
|
||||||
|
const useRegularExpression = options.useRegularExpression ?? false;
|
||||||
|
const flags = matchCase ? "" : "i";
|
||||||
|
|
||||||
|
if (useRegularExpression) {
|
||||||
|
const regex = buildRegex(query, flags);
|
||||||
|
if (!regex) return { matcher: () => false, isValid: false };
|
||||||
|
return { matcher: (content: string) => regex.test(content), isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchWholeWord) {
|
||||||
|
const regex = buildRegex(`\\b${escapeRegex(query)}\\b`, flags);
|
||||||
|
if (!regex) return { matcher: () => false, isValid: false };
|
||||||
|
return { matcher: (content: string) => regex.test(content), isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchCase) {
|
||||||
|
return {
|
||||||
|
matcher: (content: string) => content.includes(query),
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = query.toLowerCase();
|
||||||
|
return {
|
||||||
|
matcher: (content: string) => content.toLowerCase().includes(target),
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user