TextReplace: DND UX

This commit is contained in:
thororen 2026-09-05 21:23:06 -04:00
parent 3b617d4f02
commit 3b09e2cdac
No known key found for this signature in database
2 changed files with 65 additions and 13 deletions

View file

@ -178,9 +178,12 @@ function normalizeRule(rule: Rule) {
rule.id ??= crypto.randomUUID(); 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 [searchQuery, setSearchQuery] = useState("");
const [draggedIndex, setDraggedIndex] = useState<number | null>(null); 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);
@ -209,12 +212,35 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe
return acc; return acc;
}, []); }, []);
const handleDrop = (index: number) => (e: React.DragEvent) => { const handleDragOver = (index: number) => (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (draggedIndex === null || draggedIndex === index || searchQuery) return; if (draggedIndex === null || searchQuery) return;
const draggedRule = rulesArray.splice(draggedIndex, 1)[0];
rulesArray.splice(index, 0, draggedRule); 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); setDraggedIndex(null);
setDropIndicator(null);
}; };
return ( return (
@ -222,11 +248,13 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe
<div> <div>
<HeadingSecondary>{title}</HeadingSecondary> <HeadingSecondary>{title}</HeadingSecondary>
<Paragraph>{description}</Paragraph> <Paragraph>{description}</Paragraph>
<TextInput <div className={cl("search-input")}>
placeholder="Search for a rule..." <TextInput
value={searchQuery} placeholder="Search for a rule..."
onChange={setSearchQuery} value={searchQuery}
/> onChange={setSearchQuery}
/>
</div>
</div> </div>
<Flex flexDirection="column" style={{ gap: "0.5em", paddingBottom: "1.25em" }}> <Flex flexDirection="column" style={{ gap: "0.5em", paddingBottom: "1.25em" }}>
{!filteredRules.length && searchQuery && ( {!filteredRules.length && searchQuery && (
@ -235,10 +263,16 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe
{filteredRules.map(({ rule, index }) => {filteredRules.map(({ rule, index }) =>
<div <div
key={rule.id} key={rule.id}
onDragOver={e => e.preventDefault()} onDragOver={handleDragOver(index)}
onDrop={handleDrop(index)} onDrop={handleDrop}
style={{ opacity: draggedIndex === index ? 0.5 : 1 }} style={{ opacity: draggedIndex === index ? 0.5 : 1, position: "relative" }}
> >
{dropIndicator?.index === index && dropIndicator.position === "before" && (
<div className={cl("drop-indicator")} style={{ top: 0 }} />
)}
{dropIndicator?.index === index && dropIndicator.position === "after" && (
<div className={cl("drop-indicator")} style={{ bottom: 0 }} />
)}
<ExpandableSection <ExpandableSection
renderContent={() => ( renderContent={() => (
<> <>
@ -293,7 +327,10 @@ function TextReplace({ title, description, rulesArray, isRegex = false }: TextRe
setDraggedIndex(index); setDraggedIndex(index);
e.dataTransfer.setData("text/plain", index.toString()); e.dataTransfer.setData("text/plain", index.toString());
}} }}
onDragEnd={() => setDraggedIndex(null)} onDragEnd={() => {
setDraggedIndex(null);
setDropIndicator(null);
}}
style={{ cursor: searchQuery ? "default" : "grab", flex: 1 }} style={{ cursor: searchQuery ? "default" : "grab", flex: 1 }}
> >
<Paragraph weight="medium" size="md"> <Paragraph weight="medium" size="md">

View file

@ -9,3 +9,18 @@
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;
}