notes/src/components/CommandPalette.tsx

236 lines
9.7 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { listTemplates, readNote, writeNote, type NoteEntry } from "../lib/commands";
interface Command {
id: string;
label: string;
icon: string;
action: () => void;
section: "notes" | "commands" | "templates";
}
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();
const [templates, setTemplates] = useState<string[]>([]);
// 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)}`); },
});
}
// Add template commands
for (const tmpl of templates) {
cmds.push({
id: `tmpl-${tmpl}`,
label: `New from: ${tmpl}`,
icon: "📋",
section: "templates",
action: () => {
onClose();
const name = prompt(`Note name (from ${tmpl} template):`);
if (name?.trim() && vaultPath) {
readNote(vaultPath, `_templates/${tmpl}.md`).then((content) => {
const today = new Date().toISOString().split("T")[0];
const filled = content
.replace(/\{\{title\}\}/gi, name.trim())
.replace(/\{\{date\}\}/gi, today);
writeNote(vaultPath, `${name.trim()}.md`, filled).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
});
}
},
});
}
return cmds;
}, [allNotes, templates, 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);
// Load templates
if (vaultPath) {
listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([]));
}
}
}, [open, vaultPath]);
// 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 tmplResults = filtered.filter((c) => c.section === "templates");
const ordered = [...cmdResults, ...tmplResults, ...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>
)}
{tmplResults.length > 0 && (
<div className="palette-section-label">Templates</div>
)}
{tmplResults.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.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;
}