Files
NoteAI/src/components/search-command.tsx
T

145 lines
6.6 KiB
TypeScript

"use client";
import { useSearchStore } from "@/lib/search-store";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useEditorStore } from "@/lib/store";
import { Command } from "cmdk";
import { Search, FileText, Folder } from "lucide-react";
import { filterPagesByQuery, parseSearchQuery } from "@/lib/search-query";
const RECENT_PAGE_IDS_KEY = "noteai-recent-page-ids";
const MAX_RECENT_PAGES = 30;
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function highlightText(text: string, terms: string[]) {
if (!text || terms.length === 0) return text;
const normalized = Array.from(new Set(terms.filter(Boolean).map((t) => t.toLowerCase())));
if (normalized.length === 0) return text;
const pattern = normalized.map(escapeRegExp).join("|");
const regex = new RegExp(`(${pattern})`, "ig");
const parts = text.split(regex);
return parts.map((part, idx) => {
const matched = normalized.includes(part.toLowerCase());
return matched ? (
<mark key={`${part}-${idx}`} className="rounded bg-yellow-200/70 px-0.5 text-foreground dark:bg-yellow-500/30">
{part}
</mark>
) : (
<Fragment key={`${part}-${idx}`}>{part}</Fragment>
);
});
}
export function SearchCommand() {
const { isOpen, setOpen, query, setQuery } = useSearchStore();
const { pages, setActivePageId } = useEditorStore();
const [recentPageIds, setRecentPageIds] = useState<string[]>(() => {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(RECENT_PAGE_IDS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
});
const parsedQuery = useMemo(() => parseSearchQuery(query), [query]);
const filteredPages = useMemo(() => {
const items = filterPagesByQuery(pages, query);
const recentRank = new Map(recentPageIds.map((id, idx) => [id, idx]));
return [...items].sort((a, b) => {
const ra = recentRank.has(a.id) ? (recentRank.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
const rb = recentRank.has(b.id) ? (recentRank.get(b.id) as number) : Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return tb - ta;
});
}, [pages, query, recentPageIds]);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(!isOpen);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [isOpen, setOpen]);
const runCommand = (command: () => void) => {
setOpen(false);
command();
};
const rememberVisitedPage = (pageId: string) => {
setRecentPageIds((prev) => {
const next = [pageId, ...prev.filter((id) => id !== pageId)].slice(0, MAX_RECENT_PAGES);
try {
localStorage.setItem(RECENT_PAGE_IDS_KEY, JSON.stringify(next));
} catch {
// ignore storage failures
}
return next;
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm animate-in fade-in duration-200">
<div className="fixed inset-0" onClick={() => setOpen(false)} />
<div className="ui-card relative w-full max-w-lg overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<Command className="w-full" shouldFilter={false}>
<div className="flex items-center border-b border-border/70 px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
placeholder="搜索... 支持 tag:项目 type:file updated:7d"
value={query}
onValueChange={setQuery}
className="flex h-12 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="border-b border-border/50 px-3 py-2 text-[11px] text-muted-foreground">
语法:`tag:标签` `type:file|folder` `updated:7d`
</div>
<Command.List className="max-h-[320px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">未找到匹配结果</Command.Empty>
<Command.Group heading="文档">
{filteredPages.map((page) => (
<Command.Item
key={page.id}
value={`${page.title} ${(page.tags || []).join(" ")}`}
onSelect={() =>
runCommand(() => {
setActivePageId(page.id);
rememberVisitedPage(page.id);
})
}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
>
{page.type === "folder" ? <Folder size={14} className="text-blue-500" /> : <FileText size={14} />}
<span>{highlightText(page.title || "无标题", parsedQuery.textTerms)}</span>
{page.tags && page.tags.length > 0 && (
<span className="ml-auto truncate text-[10px] text-muted-foreground">{page.tags.slice(0, 2).join(" · ")}</span>
)}
</Command.Item>
))}
</Command.Group>
</Command.List>
</Command>
</div>
</div>
);
}