171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|