notes/src/components/CommandPalette.tsx

187 lines
7.3 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import type { NoteEntry } from "../lib/commands";
interface Command {
id: string;
label: string;
icon: string;
action: () => void;
section: "notes" | "commands";
}
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { notes, vaultPath, refreshNotes } = useVault();
// Flatten notes for search
const allNotes = useMemo(() => flattenEntries(notes), [notes]);
// Build command list
const commands: Command[] = useMemo(() => {
const cmds: Command[] = [
{ id: "cmd-new", label: "New Note", icon: "✏️", section: "commands", action: () => {
onClose();
const name = prompt("Note name:");
if (name?.trim()) {
import("../lib/commands").then(({ writeNote }) => {
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
});
}
}},
{ id: "cmd-daily", label: "Open Daily Note", icon: "📅", section: "commands", action: () => { onClose(); navigate("/daily"); }},
{ id: "cmd-graph", label: "Open Graph View", icon: "🔮", section: "commands", action: () => { onClose(); navigate("/graph"); }},
];
// Add notes as commands
for (const note of allNotes) {
cmds.push({
id: `note-${note.path}`,
label: note.name,
icon: "📄",
section: "notes",
action: () => { onClose(); navigate(`/note/${encodeURIComponent(note.path)}`); },
});
}
return cmds;
}, [allNotes, navigate, onClose, vaultPath, refreshNotes]);
// Filter by query
const filtered = useMemo(() => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
return commands.filter((c) => c.label.toLowerCase().includes(q));
}, [commands, query]);
// Reset selection on filter change
useEffect(() => { setSelectedIndex(0); }, [filtered]);
// Focus input on open
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const item = listRef.current.children[selectedIndex] as HTMLElement;
item?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
filtered[selectedIndex]?.action();
} else if (e.key === "Escape") {
onClose();
}
};
if (!open) return null;
// Group by section
const noteResults = filtered.filter((c) => c.section === "notes");
const cmdResults = filtered.filter((c) => c.section === "commands");
const ordered = [...cmdResults, ...noteResults];
return (
<div className="palette-overlay" onClick={onClose}>
<div className="palette" onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
<div className="palette-input-wrap">
<svg className="palette-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
ref={inputRef}
type="text"
className="palette-input"
placeholder="Search notes & commands..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<kbd className="palette-kbd">esc</kbd>
</div>
<div className="palette-list" ref={listRef}>
{ordered.length === 0 && (
<div className="palette-empty">No results found</div>
)}
{cmdResults.length > 0 && (
<div className="palette-section-label">Commands</div>
)}
{cmdResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
{noteResults.length > 0 && (
<div className="palette-section-label">Notes</div>
)}
{noteResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
</div>
<div className="palette-footer">
<span> navigate</span>
<span> open</span>
<span>esc close</span>
</div>
</div>
</div>
);
}
function flattenEntries(entries: NoteEntry[]): { name: string; path: string }[] {
const result: { name: string; path: string }[] = [];
for (const e of entries) {
if (e.is_dir && e.children) {
result.push(...flattenEntries(e.children));
} else if (!e.is_dir) {
result.push({ name: e.name, path: e.path });
}
}
return result;
}