notes/src/components/CommandPalette.tsx

315 lines
12 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { searchVault, listTemplates, createFromTemplate, exportNoteHtml, type SearchResult, type TemplateInfo } from "../lib/commands";
interface CommandItem {
id: string;
icon: string;
label: string;
hint?: string;
action: () => void;
}
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath, notes, navigateToNote, currentNote } = useVault();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Load templates when opened
useEffect(() => {
if (open && vaultPath) {
listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([]));
}
}, [open, vaultPath]);
// Focus input when opened
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
setSearchResults([]);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Debounced search
useEffect(() => {
if (!query.trim() || query.length < 2) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
const results = await searchVault(vaultPath, query);
setSearchResults(results.slice(0, 10));
} catch {
setSearchResults([]);
}
}, 200);
return () => clearTimeout(timer);
}, [query, vaultPath]);
// Build command list
const commands: CommandItem[] = [];
if (!query.trim()) {
commands.push(
{ id: "daily", icon: "📅", label: "Open Daily Note", hint: "⌘D", action: () => { navigate("/daily"); onClose(); } },
{ id: "graph", icon: "🔮", label: "Graph View", hint: "⌘G", action: () => { navigate("/graph"); onClose(); } },
{ id: "new", icon: "✏️", label: "New Note", hint: "⌘N", action: () => { const name = prompt("Note name:"); if (name?.trim()) { navigateToNote(name.trim()); onClose(); } } },
);
// Template commands
if (templates.length > 0) {
for (const t of templates) {
commands.push({
id: `template-${t.path}`,
icon: "📋",
label: `New from template: ${t.name}`,
hint: "_templates",
action: async () => {
const name = prompt(`Note name (from "${t.name}" template):`);
if (name?.trim()) {
try {
const path = await createFromTemplate(vaultPath, t.path, name.trim());
navigate(`/note/${encodeURIComponent(path)}`);
onClose();
} catch (e) {
console.error("Template creation failed:", e);
}
}
},
});
}
}
// v0.4 commands
commands.push(
{ id: "calendar", icon: "📆", label: "Calendar View", action: () => { navigate("/calendar"); onClose(); } },
{ id: "theme", icon: "🎨", label: "Change Theme", hint: "⌘T", action: () => { onClose(); } },
);
if (currentNote) {
commands.push({
id: "export-html",
icon: "📤",
label: "Export as HTML",
action: async () => {
try {
const html = await exportNoteHtml(vaultPath, currentNote);
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = currentNote.replace(/\.md$/, ".html");
a.click();
URL.revokeObjectURL(url);
onClose();
} catch (e) {
console.error("Export failed:", e);
}
},
});
}
// v0.5 commands
commands.push(
{ id: "kanban", icon: "📋", label: "Kanban Board", action: () => { navigate("/kanban"); onClose(); } },
{ id: "focus", icon: "🧘", label: "Focus Mode", hint: "⌘⇧F", action: () => { onClose(); } },
{ id: "search-replace", icon: "🔍", label: "Search & Replace", hint: "⌘H", action: () => { onClose(); } },
);
if (currentNote) {
commands.push({
id: "export-pdf",
icon: "📄",
label: "Export as PDF",
action: () => {
onClose();
setTimeout(() => window.print(), 200);
},
});
}
// v0.6 commands
commands.push(
{ id: "flashcards", icon: "🎴", label: "Flashcards", action: () => { navigate("/flashcards"); onClose(); } },
{ id: "custom-css", icon: "🎨", label: "Custom CSS", action: () => { onClose(); } },
{
id: "save-workspace", icon: "💾", label: "Save Workspace", action: () => {
const name = prompt("Workspace name:");
if (name?.trim()) { onClose(); }
}
},
);
// v0.7 commands
commands.push(
{ id: "database", icon: "📊", label: "Database View", action: () => { navigate("/database"); onClose(); } },
{
id: "whiteboard", icon: "🎨", label: "New Whiteboard", action: () => {
const name = prompt("Canvas name:");
if (name?.trim()) { navigate(`/whiteboard/${encodeURIComponent(name.trim())}`); onClose(); }
}
},
{ id: "git-sync", icon: "🔀", label: "Git Sync", action: () => { onClose(); } },
);
// v0.8 commands
commands.push(
{ id: "timeline", icon: "📅", label: "Timeline", action: () => { navigate("/timeline"); onClose(); } },
{
id: "random-note", icon: "🎲", label: "Random Note", action: async () => {
try {
const { randomNote } = await import("../lib/commands");
const name = await randomNote(vaultPath);
navigate(`/note/${encodeURIComponent(name)}`);
} catch { }
onClose();
}
},
);
// v0.9 commands
commands.push(
{ id: "analytics", icon: "📊", label: "Graph Analytics", action: () => { navigate("/analytics"); onClose(); } },
{ id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } },
{ id: "shortcuts", icon: "⌨️", label: "Keyboard Shortcuts", action: () => { onClose(); } },
);
}
// Note matches
const flatNotes = flattenNoteNames(notes);
const queryLower = query.toLowerCase();
if (query.trim()) {
const nameMatches = flatNotes
.filter(n => n.name.toLowerCase().includes(queryLower))
.slice(0, 5);
for (const note of nameMatches) {
commands.push({
id: `note-${note.path}`,
icon: "📄",
label: note.name,
hint: note.path,
action: () => { navigate(`/note/${encodeURIComponent(note.path)}`); onClose(); },
});
}
// Content search results
for (const result of searchResults) {
// Don't duplicate filename matches
if (commands.some(c => c.id === `note-${result.path}`)) continue;
commands.push({
id: `search-${result.path}`,
icon: "🔍",
label: result.name,
hint: result.context.substring(0, 80),
action: () => { navigate(`/note/${encodeURIComponent(result.path)}`); onClose(); },
});
}
}
// Clamp selection
const maxIndex = Math.max(0, commands.length - 1);
const safeIndex = Math.min(selectedIndex, maxIndex);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, maxIndex));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
commands[safeIndex]?.action();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}, [commands, safeIndex, maxIndex, onClose]);
// Scroll selected item into view
useEffect(() => {
const list = listRef.current;
if (!list) return;
const item = list.children[safeIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: "nearest" });
}, [safeIndex]);
if (!open) return null;
return (
<div className="cmd-backdrop" onClick={onClose}>
<div className="cmd-palette" onClick={e => e.stopPropagation()} onKeyDown={handleKeyDown}>
{/* Search input */}
<div className="cmd-input-wrap">
<svg className="cmd-input-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}
className="cmd-input"
type="text"
placeholder="Search notes, commands..."
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
/>
<kbd className="cmd-kbd">ESC</kbd>
</div>
{/* Results */}
<div className="cmd-list" ref={listRef}>
{commands.length === 0 && query.trim() && (
<div className="cmd-empty">No results for "{query}"</div>
)}
{commands.map((cmd, i) => (
<button
key={cmd.id}
className={`cmd-item ${i === safeIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="cmd-item-icon">{cmd.icon}</span>
<span className="cmd-item-label">{cmd.label}</span>
{cmd.hint && <span className="cmd-item-hint">{cmd.hint}</span>}
</button>
))}
</div>
{/* Footer */}
<div className="cmd-footer">
<span> navigate</span>
<span> select</span>
<span>esc close</span>
</div>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
function flattenNoteNames(
entries: { name: string; path: string; is_dir: boolean; children?: any[] }[]
): { name: string; path: string }[] {
const result: { name: string; path: string }[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
result.push(...flattenNoteNames(entry.children));
} else if (!entry.is_dir) {
result.push({ name: entry.name, path: entry.path });
}
}
return result;
}