diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index 4ffea1fb1..f03f1890c 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -34,7 +34,13 @@ import { React, TextInput, useState } from "@webpack/common"; const cl = classNameFactory("vc-textReplace-"); -type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "id", string>; +interface Rule { + name?: string; + find: string; + replace: string; + onlyIfIncludes: string; + id: string; +} interface TextReplaceProps { title: string; @@ -43,12 +49,19 @@ interface TextReplaceProps { isRegex?: boolean; } +interface RuleWithIndex { + rule: Rule; + index: number; +} + const makeEmptyRule: () => Rule = () => ({ + name: "", find: "", replace: "", onlyIfIncludes: "", id: crypto.randomUUID() }); + const makeEmptyRuleArray = () => [makeEmptyRule()]; const settings = definePluginSettings({ @@ -78,10 +91,12 @@ const settings = definePluginSettings({ stringRules: { type: OptionType.CUSTOM, default: makeEmptyRuleArray(), + description: "Rules for replacing text using string matching." }, regexRules: { type: OptionType.CUSTOM, default: makeEmptyRuleArray(), + description: "Rules for replacing text using regular expressions." } }); @@ -146,7 +161,26 @@ function TextRow({ label, description, value, onChange }: { label: string; descr const isEmptyRule = (rule: Rule) => !rule.find; +function matchesRuleSearch(rule: Rule, query: string) { + if (!query) return true; + + const normalizedQuery = query.trim().toLowerCase(); + return [rule.name ?? "", rule.find, rule.replace, rule.onlyIfIncludes] + .some(value => value.toLowerCase().includes(normalizedQuery)); +} + +function normalizeRule(rule: Rule) { + rule.name ??= ""; + rule.id ??= crypto.randomUUID(); +} + +type DropIndicator = { index: number; position: "before" | "after"; }; + function TextReplace({ title, description, rulesArray, isRegex = false }: TextReplaceProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dropIndicator, setDropIndicator] = useState(null); + function onClickRemove(index: number) { rulesArray.splice(index, 1); } @@ -155,64 +189,149 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe rulesArray[index][key] = e; // If a rule is empty after editing and is not the last rule, remove it - if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) { + if (rulesArray[index].name === "" && rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) { rulesArray.splice(index, 1); } } + const filteredRules = rulesArray.reduce((acc: RuleWithIndex[], rule, index) => { + if (matchesRuleSearch(rule, searchQuery)) { + acc.push({ rule, index }); + } + + return acc; + }, []); + + const handleDragOver = (index: number) => (e: React.DragEvent) => { + e.preventDefault(); + if (draggedIndex === null || searchQuery) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const position = e.clientY - rect.top < rect.height / 2 ? "before" : "after"; + setDropIndicator(prev => + prev?.index === index && prev.position === position ? prev : { index, position } + ); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + if (draggedIndex === null || !dropIndicator || searchQuery) { + setDraggedIndex(null); + setDropIndicator(null); + return; + } + + let targetIndex = dropIndicator.index + (dropIndicator.position === "after" ? 1 : 0); + if (targetIndex > draggedIndex) targetIndex -= 1; + + if (targetIndex !== draggedIndex) { + const draggedRule = rulesArray.splice(draggedIndex, 1)[0]; + rulesArray.splice(targetIndex, 0, draggedRule); + } + + setDraggedIndex(null); + setDropIndicator(null); + }; + return ( <>
{title} {description} +
+ +
- - {rulesArray.map((rule, index) => - + {!filteredRules.length && searchQuery && ( + No rules match your search criteria. + )} + {filteredRules.map(({ rule, index }) => +
( - <> -
- onChange(e, index, "find")} - /> - onChange(e, index, "replace")} - /> - onChange(e, index, "onlyIfIncludes")} - /> -
- {isRegex && renderFindError(rule.find)} - - - )} + onDragOver={handleDragOver(index)} + onDrop={handleDrop} + style={{ opacity: draggedIndex === index ? 0.5 : 1, position: "relative" }} > - - {isEmptyRule(rule) - ? `Empty Rule ${index + 1}` - : `Rule ${index + 1} - ${rule.find}` - } - - + {dropIndicator?.index === index && dropIndicator.position === "before" && ( +
+ )} + {dropIndicator?.index === index && dropIndicator.position === "after" && ( +
+ )} + ( + <> +
+ onChange(e, index, "name")} + /> + onChange(e, index, "find")} + /> + onChange(e, index, "replace")} + /> + onChange(e, index, "onlyIfIncludes")} + /> +
+ {isRegex && renderFindError(rule.find)} + + + )} + > +
{ + setDraggedIndex(index); + e.dataTransfer.setData("text/plain", index.toString()); + }} + onDragEnd={() => { + setDraggedIndex(null); + setDropIndicator(null); + }} + style={{ cursor: searchQuery ? "default" : "grab", flex: 1 }} + > + + {rule.name + ? rule.name + : isEmptyRule(rule) + ? `Empty Rule ${index + 1}` + : `Rule ${index + 1} - ${rule.find}` + } + +
+
+
)}