TextReplace: allow namin g & reordering rules (#4578)

This commit is contained in:
thororen 2026-09-07 13:11:29 -04:00 • committed by GitHub
parent 7764f2512a
commit 0fda4dd82d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 183 additions and 48 deletions

View file

@ -34,7 +34,13 @@ import { React, TextInput, useState } from "@webpack/common";
const cl = classNameFactory("vc-textReplace-"); 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 { interface TextReplaceProps {
title: string; title: string;
@ -43,12 +49,19 @@ interface TextReplaceProps {
isRegex?: boolean; isRegex?: boolean;
} }
interface RuleWithIndex {
rule: Rule;
index: number;
}
const makeEmptyRule: () => Rule = () => ({ const makeEmptyRule: () => Rule = () => ({
name: "",
find: "", find: "",
replace: "", replace: "",
onlyIfIncludes: "", onlyIfIncludes: "",
id: crypto.randomUUID() id: crypto.randomUUID()
}); });
const makeEmptyRuleArray = () => [makeEmptyRule()]; const makeEmptyRuleArray = () => [makeEmptyRule()];
const settings = definePluginSettings({ const settings = definePluginSettings({
@ -78,10 +91,12 @@ const settings = definePluginSettings({
stringRules: { stringRules: {
type: OptionType.CUSTOM, type: OptionType.CUSTOM,
default: makeEmptyRuleArray(), default: makeEmptyRuleArray(),
description: "Rules for replacing text using string matching."
}, },
regexRules: { regexRules: {
type: OptionType.CUSTOM, type: OptionType.CUSTOM,
default: makeEmptyRuleArray(), 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; 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) { function TextReplace({ title, description, rulesArray, isRegex = false }: TextReplaceProps) {
const [searchQuery, setSearchQuery] = useState("");
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null);
function onClickRemove(index: number) { function onClickRemove(index: number) {
rulesArray.splice(index, 1); rulesArray.splice(index, 1);
} }
@ -155,64 +189,149 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe
rulesArray[index][key] = e; rulesArray[index][key] = e;
// If a rule is empty after editing and is not the last rule, remove it // 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); 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 ( return (
<> <>
<div> <div>
<HeadingSecondary>{title}</HeadingSecondary> <HeadingSecondary>{title}</HeadingSecondary>
<Paragraph>{description}</Paragraph> <Paragraph>{description}</Paragraph>
<div className={cl("search-input")}>
<TextInput
placeholder="Search for a rule..."
value={searchQuery}
onChange={setSearchQuery}
/>
</div>
</div> </div>
<Flex flexDirection="column" style={{ gap: "0.5em" }}> <Flex flexDirection="column" style={{ gap: "0.5em", paddingBottom: "1.25em" }}>
{rulesArray.map((rule, index) => {!filteredRules.length && searchQuery && (
<ExpandableSection <Paragraph>No rules match your search criteria.</Paragraph>
)}
{filteredRules.map(({ rule, index }) =>
<div
key={rule.id} key={rule.id}
renderContent={() => ( onDragOver={handleDragOver(index)}
<> onDrop={handleDrop}
<div className={cl("input-grid")}> style={{ opacity: draggedIndex === index ? 0.5 : 1, position: "relative" }}
<TextRow
label="Find"
description={isRegex ? "The regex pattern" : "The text to replace"}
value={rule.find}
onChange={e => onChange(e, index, "find")}
/>
<TextRow
label="Replace"
description="The text to replace the found text with"
value={rule.replace}
onChange={e => onChange(e, index, "replace")}
/>
<TextRow
label="Only if includes"
description="This rule will only be applied if the message includes this text. This is optional"
value={rule.onlyIfIncludes}
onChange={e => onChange(e, index, "onlyIfIncludes")}
/>
</div>
{isRegex && renderFindError(rule.find)}
<Button
className={cl("delete-button")}
variant="dangerPrimary"
onClick={() => onClickRemove(index)}
>
Delete Rule
</Button>
</>
)}
> >
<Paragraph weight="medium" size="md"> {dropIndicator?.index === index && dropIndicator.position === "before" && (
{isEmptyRule(rule) <div className={cl("drop-indicator")} style={{ top: 0 }} />
? `Empty Rule ${index + 1}` )}
: `Rule ${index + 1} - ${rule.find}` {dropIndicator?.index === index && dropIndicator.position === "after" && (
} <div className={cl("drop-indicator")} style={{ bottom: 0 }} />
</Paragraph> )}
</ExpandableSection> <ExpandableSection
renderContent={() => (
<>
<div className={cl("input-grid")}>
<TextRow
label="Name"
description="An optional name to help you identify this rule."
value={rule.name ?? ""}
onChange={e => onChange(e, index, "name")}
/>
<TextRow
label="Find"
description={isRegex ? "The regex pattern" : "The text to replace"}
value={rule.find}
onChange={e => onChange(e, index, "find")}
/>
<TextRow
label="Replace"
description="The text to replace the found text with"
value={rule.replace}
onChange={e => onChange(e, index, "replace")}
/>
<TextRow
label="Only if includes"
description="Optionally, only apply this rule if the message includes this text."
value={rule.onlyIfIncludes}
onChange={e => onChange(e, index, "onlyIfIncludes")}
/>
</div>
{isRegex && renderFindError(rule.find)}
<Button
className={cl("delete-button")}
variant="dangerPrimary"
onClick={() => onClickRemove(index)}
>
Delete Rule
</Button>
</>
)}
>
<div
draggable={!searchQuery}
onDragStart={e => {
setDraggedIndex(index);
e.dataTransfer.setData("text/plain", index.toString());
}}
onDragEnd={() => {
setDraggedIndex(null);
setDropIndicator(null);
}}
style={{ cursor: searchQuery ? "default" : "grab", flex: 1 }}
>
<Paragraph weight="medium" size="md">
{rule.name
? rule.name
: isEmptyRule(rule)
? `Empty Rule ${index + 1}`
: `Rule ${index + 1} - ${rule.find}`
}
</Paragraph>
</div>
</ExpandableSection>
</div>
)} )}
<Button <Button
onClick={() => rulesArray.push(makeEmptyRule())} onClick={() => {
setSearchQuery("");
rulesArray.push(makeEmptyRule());
}}
disabled={rulesArray.length > 0 && isEmptyRule(rulesArray[rulesArray.length - 1])} disabled={rulesArray.length > 0 && isEmptyRule(rulesArray[rulesArray.length - 1])}
> >
Add Rule Add Rule
@ -265,7 +384,6 @@ function applyRules(content: string): string {
} }
const TEXT_REPLACE_RULES_CHANNEL_ID = "1102784112584040479"; const TEXT_REPLACE_RULES_CHANNEL_ID = "1102784112584040479";
export default definePlugin({ export default definePlugin({
name: "TextReplace", name: "TextReplace",
description: "Replace text in your messages. You can find pre-made rules in the #textreplace-rules channel in Vencord's Server", description: "Replace text in your messages. You can find pre-made rules in the #textreplace-rules channel in Vencord's Server",
@ -275,8 +393,10 @@ export default definePlugin({
settings, settings,
start() { start() {
settings.store.regexRules.forEach(rule => rule.id ??= crypto.randomUUID()); const { stringRules, regexRules } = settings.store;
settings.store.stringRules.forEach(rule => rule.id ??= crypto.randomUUID());
stringRules.forEach(normalizeRule);
regexRules.forEach(normalizeRule);
}, },
onBeforeMessageSend(channelId, msg) { onBeforeMessageSend(channelId, msg) {

View file

@ -8,4 +8,19 @@
.vc-textReplace-delete-button { .vc-textReplace-delete-button {
width: 100%; width: 100%;
margin-block: 0.75em 0.5em margin-block: 0.75em 0.5em
}
.vc-textReplace-drop-indicator {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--brand-experiment, #5865f2);
border-radius: 1px;
pointer-events: none;
z-index: 1;
}
.vc-textReplace-search-input {
margin-top: 8px;
} }