TextReplace: allow namin g & reordering rules (#4578)
This commit is contained in:
parent
7764f2512a
commit
0fda4dd82d
2 changed files with 183 additions and 48 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue