Files
mtgc/src/CommandPalette.tsx

171 lines
5.3 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from "react";
import { api, CardRef, Deck } from "./api";
import { DialogFrame, useClickPreview } from "./components";
type Tab = "add" | "library" | "decks";
interface PaletteItem {
key: string;
label: string;
hint?: string;
run: () => void;
}
/** Simple, dependency-free case-insensitive subsequence match — e.g. "gtd"
* matches "Go to Decks". Good enough for the small static command list and
* the deck list; card search still defers to the server-side fuzzy matcher
* via `api.searchCards`. */
function subsequenceMatch(query: string, text: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const t = text.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) qi++;
}
return qi === q.length;
}
/**
* Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and
* cards. Selecting a deck switches to the Decks tab with it pre-selected;
* selecting a card opens the existing click-to-zoom preview modal without
* changing tabs.
*/
export function CommandPalette({
onNavigate,
onUpdateCardData,
onOpenLibrary,
onNewLibrary,
}: {
onNavigate: (tab: Tab) => void;
onUpdateCardData: () => void;
onOpenLibrary: () => void;
onNewLibrary: () => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [decks, setDecks] = useState<Deck[]>([]);
const [cardResults, setCardResults] = useState<CardRef[]>([]);
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const { open: openCardPreview } = useClickPreview();
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.altKey && e.key === "/") {
e.preventDefault();
setOpen((o) => !o);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (!open) return;
setQuery("");
setCardResults([]);
api.listDecks().then(setDecks);
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return;
const q = query.trim();
if (!q) return setCardResults([]);
const t = setTimeout(() => api.searchCards(q, 8).then(setCardResults), 80);
return () => clearTimeout(t);
}, [query, open]);
function close() {
setOpen(false);
}
const staticCommands: PaletteItem[] = [
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
{ key: "new-library", label: "Create new library file…", run: onNewLibrary },
];
const items: PaletteItem[] = [
...staticCommands.filter((c) => subsequenceMatch(query, c.label)),
...decks
.filter((d) => subsequenceMatch(query, d.name))
.map((d) => ({
key: `deck-${d.id}`,
label: d.name,
hint: "Deck",
run: () => {
// DeckView reads its initial selection from this same
// localStorage key on mount (usePersistedState("deck-selected"),
// src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on
// tab switch, so writing here before navigating is enough for it
// to pick up the selection on its next mount.
localStorage.setItem("deck-selected", JSON.stringify(d.id));
onNavigate("decks");
},
})),
...cardResults.map((c) => ({
key: `card-${c.oracle_id}`,
label: c.name,
hint: c.type_line,
run: () => openCardPreview(c),
})),
].slice(0, 20);
useEffect(() => setSel(0), [items.length, query]);
function pick(item: PaletteItem) {
item.run();
close();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && items[sel]) {
e.preventDefault();
pick(items[sel]);
}
}
if (!open) return null;
return (
<DialogFrame title="Command palette" onCancel={close} wide>
<input
ref={inputRef}
autoFocus
style={{ width: "100%", marginBottom: 8 }}
placeholder="Type a command, deck, or card name…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{items.length > 0 && (
<ul className="result-list" style={{ marginBottom: 0 }}>
{items.map((item, i) => (
<li
key={item.key}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => pick(item)}
>
<span className="card-name">{item.label}</span>
{item.hint && <span className="card-type">{item.hint}</span>}
</li>
))}
</ul>
)}
</DialogFrame>
);
}