187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
|
|
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;
|
||
|
|
}
|