Equicord/src/equicordplugins/commandPalette/CommandPicker.tsx
Jake fccee12ad9
Add Command Palette & KeyboardNavigation (#450)
Congrats you did it, you learned patching slightly.
2025-10-15 23:31:53 -04:00

417 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { classNameFactory } from "@api/Styles";
import { ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common";
import { settings } from "./index";
import {
type CommandEntry,
getCategoryGroupLabel,
getCategoryPath,
getCategoryWeight,
getCommandSearchText,
getRecentRank,
getRegistryVersion,
listCommands,
subscribeRegistry
} from "./registry";
type PickerItem =
| { type: "section"; label: string; }
| { type: "command"; command: CommandEntry; };
const isCommandItem = (item: PickerItem | undefined): item is { type: "command"; command: CommandEntry; } =>
item?.type === "command";
const getFirstSelectableIndex = (items: PickerItem[]) =>
items.findIndex(isCommandItem);
const getNextSelectableIndex = (start: number, direction: 1 | -1, items: PickerItem[]) => {
if (items.length === 0) return -1;
let index = start;
for (let i = 0; i < items.length; i += 1) {
index = (index + direction + items.length) % items.length;
if (isCommandItem(items[index])) return index;
}
return getFirstSelectableIndex(items);
};
interface CommandPickerProps {
allowMultiple?: boolean;
initialQuery?: string;
initialSelectedIds?: string[];
filter?(command: CommandEntry): boolean;
onSelect?(command: CommandEntry): void;
onComplete?(commands: CommandEntry[], commandIds: string[]): void;
onClose?(): void;
}
export function openCommandPicker(props: CommandPickerProps) {
return openModal(modalProps => (
<CommandPickerModal modalProps={modalProps} {...props} />
));
}
function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery = "", initialSelectedIds = [], filter: filterPredicate, onSelect, onComplete, onClose }: CommandPickerProps & { modalProps: any; }) {
const cl = classNameFactory("vc-command-palette-");
const [query, setQuery] = useState(initialQuery);
const [registryVersion, setRegistryVersion] = useState(() => getRegistryVersion());
const [selectedIndex, setSelectedIndex] = useState(-1);
const [visible, setVisible] = useState(false);
const showTags = settings.store.showTags ?? true;
const { visualStyle = "classic" } = settings.use(["visualStyle"]);
const styleClass = visualStyle === "polished" ? "style-polished" : "style-classic";
const normalizedInitialSelected = useMemo(() => Array.from(new Set(initialSelectedIds)), [initialSelectedIds]);
const [pickedIds, setPickedIds] = useState<string[]>(() => normalizedInitialSelected);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const pickedIdsRef = useRef<string[]>([]);
useEffect(() => subscribeRegistry(setRegistryVersion), []);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}, []);
useEffect(() => {
pickedIdsRef.current = pickedIds;
}, [pickedIds]);
const commands = useMemo(() => listCommands(), [registryVersion]);
const commandMap = useMemo(() => new Map(commands.map(entry => [entry.id, entry])), [commands]);
const availableCommands = useMemo(() => {
if (!filterPredicate) return commands;
return commands.filter(filterPredicate);
}, [commands, filterPredicate]);
const trimmedQuery = query.trim();
const lowerQuery = trimmedQuery.toLowerCase();
const pickedSet = useMemo(() => new Set(pickedIds), [pickedIds]);
const { items, hasCommands } = useMemo(() => {
const sortByWeight = (a: CommandEntry, b: CommandEntry) => {
const weightDiff = getCategoryWeight(b.categoryId) - getCategoryWeight(a.categoryId);
if (weightDiff !== 0) return weightDiff;
return a.label.localeCompare(b.label);
};
let entries: CommandEntry[];
if (lowerQuery) {
const scoreCommand = (entry: CommandEntry) => {
let score = 0;
const labelLower = entry.label.toLowerCase();
if (labelLower === lowerQuery) score += 60;
else if (labelLower.startsWith(lowerQuery)) score += 45;
else if (labelLower.includes(lowerQuery)) score += 30;
const labelTokens = labelLower.split(/[^a-z0-9]+/);
if (labelTokens.includes(lowerQuery)) score += 20;
if (entry.keywords && entry.keywords.length) {
const keywordMatches = entry.keywords
.map(keyword => keyword.toLowerCase())
.filter(Boolean);
if (keywordMatches.includes(lowerQuery)) score += 35;
else if (keywordMatches.some(keyword => keyword.includes(lowerQuery))) score += 15;
}
const descriptionLower = entry.description?.toLowerCase() ?? "";
if (descriptionLower.includes(lowerQuery)) score += 10;
if (entry.tags && entry.tags.length) {
const tagMatches = entry.tags.map(tag => tag.toLowerCase());
if (tagMatches.includes(lowerQuery)) score += 25;
else if (tagMatches.some(tag => tag.includes(lowerQuery))) score += 10;
}
if (score === 0) score = 5;
score += getCategoryWeight(entry.categoryId);
const recentRank = getRecentRank(entry.id);
if (recentRank >= 0) {
score += Math.max(0, 30 - recentRank * 4);
}
return score;
};
const bestByGroup = new Map<string, { entry: CommandEntry; score: number; order: number; }>();
let orderCounter = 0;
for (const entry of availableCommands) {
if (entry.hiddenInSearch) continue;
const searchText = getCommandSearchText(entry.id);
if (!searchText.includes(lowerQuery)) continue;
const score = scoreCommand(entry);
const groupKey = entry.searchGroup ?? entry.id;
const existing = bestByGroup.get(groupKey);
if (!existing || score > existing.score || (score === existing.score && orderCounter < existing.order)) {
bestByGroup.set(groupKey, { entry, score, order: orderCounter });
}
orderCounter += 1;
}
entries = Array.from(bestByGroup.values())
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.order - b.order;
})
.map(item => item.entry);
} else {
entries = [...availableCommands].sort(sortByWeight);
}
const grouped = new Map<string, CommandEntry[]>();
const groupOrder: string[] = [];
for (const entry of entries) {
const label = getCategoryGroupLabel(entry.categoryId);
if (!grouped.has(label)) {
grouped.set(label, []);
groupOrder.push(label);
}
grouped.get(label)!.push(entry);
}
const built: PickerItem[] = [];
for (const label of groupOrder) {
const groupEntries = grouped.get(label);
if (!groupEntries || groupEntries.length === 0) continue;
built.push({ type: "section", label });
for (const entry of groupEntries) {
built.push({ type: "command", command: entry });
}
}
return { items: built, hasCommands: entries.length > 0 };
}, [availableCommands, lowerQuery]);
useEffect(() => {
if (!hasCommands) {
setSelectedIndex(-1);
return;
}
setSelectedIndex(prev => {
if (prev >= 0 && prev < items.length && isCommandItem(items[prev])) return prev;
return getFirstSelectableIndex(items);
});
}, [items, hasCommands]);
useEffect(() => {
if (selectedIndex < 0) return;
const node = itemRefs.current[selectedIndex];
node?.scrollIntoView({ block: "nearest" });
}, [selectedIndex, items]);
itemRefs.current = itemRefs.current.slice(0, items.length);
const togglePicked = (command: CommandEntry) => {
setPickedIds(prev => {
const exists = prev.includes(command.id);
if (exists) return prev.filter(id => id !== command.id);
return [...prev, command.id];
});
};
const handleSelect = (command: CommandEntry) => {
if (allowMultiple) {
togglePicked(command);
return;
}
onSelect?.(command);
modalProps.onClose?.();
};
const moveSelection = (direction: 1 | -1) => {
if (!hasCommands) return;
setSelectedIndex(prev => {
const start = prev >= 0 && prev < items.length && isCommandItem(items[prev])
? prev
: getFirstSelectableIndex(items);
if (start < 0) return -1;
return getNextSelectableIndex(start, direction, items);
});
};
const flushSelection = useCallback(() => {
if (!allowMultiple) return;
const uniqueIds = Array.from(new Set(pickedIdsRef.current));
const selectedCommands = uniqueIds
.map(id => commandMap.get(id))
.filter((entry): entry is CommandEntry => Boolean(entry));
if (onComplete) onComplete(selectedCommands, uniqueIds);
else selectedCommands.forEach(entry => onSelect?.(entry));
}, [allowMultiple, commandMap, onComplete, onSelect]);
useEffect(() => () => {
if (allowMultiple) flushSelection();
onClose?.();
}, [allowMultiple, flushSelection, onClose]);
const isEditableTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA";
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!hasCommands) {
if (event.key === "Escape") {
event.preventDefault();
modalProps.onClose?.();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveSelection(-1);
return;
}
if (event.key === "Enter") {
const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
if (allowMultiple) {
if (isCommandItem(selectedItem)) {
event.preventDefault();
togglePicked(selectedItem.command);
}
return;
}
const fallback = items.find(isCommandItem);
const target = isCommandItem(selectedItem) ? selectedItem.command : fallback?.command;
if (target) {
event.preventDefault();
handleSelect(target);
}
return;
}
if (
allowMultiple &&
event.key === " " &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!isEditableTarget(event.target)
) {
const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
if (isCommandItem(selectedItem)) {
event.preventDefault();
togglePicked(selectedItem.command);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
modalProps.onClose?.();
}
};
const headerText = allowMultiple ? "Add Commands" : "Choose Command";
return (
<ModalRoot
{...modalProps}
size={ModalSize.SMALL}
className={cl("root", styleClass, visible && "root-visible")}
>
<div className={cl("container")} onKeyDown={handleKeyDown}>
<div className={cl("header")}>{headerText}</div>
<div
className={cl("search")}
onMouseDown={event => {
event.preventDefault();
event.currentTarget.querySelector("input")?.focus({ preventScroll: true });
}}
>
<span className={cl("prompt")}>›</span>
<TextInput
autoFocus
value={query}
onChange={setQuery}
placeholder="Search commands"
/>
</div>
<div className={cl("list")}>
{!hasCommands && (
<div className={cl("empty")}>No commands found</div>
)}
{items.map((item, index) => {
if (item.type === "section") {
itemRefs.current[index] = null;
return (
<div key={`section-${index}`} className={cl("section")}>
{item.label}
</div>
);
}
const { command } = item;
const path = getCategoryPath(command.categoryId);
const categoryLabel = path
.filter(cat => cat.label && cat.id !== "quick-actions")
.map(cat => cat.label)
.join(" / ");
return (
<button
key={command.id}
type="button"
className={cl("item", {
selected: index === selectedIndex,
chosen: allowMultiple && pickedSet.has(command.id),
danger: command.danger
})}
onClick={() => handleSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
ref={element => {
itemRefs.current[index] = element;
}}
>
<div className={cl("item-content")}>
<div className={cl("text")}>
<div className={cl("label")}>{command.label}</div>
{command.description && (
<div className={cl("description")}>{command.description}</div>
)}
{categoryLabel && (
<div className={cl("meta")}>{categoryLabel}</div>
)}
{showTags && command.tags && command.tags.length > 0 && (
<div className={cl("tags")}>
{command.tags.map(tag => (
<span key={tag} className={cl("tag-chip")}>{tag}</span>
))}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
</ModalRoot>
);
}