增加了双向链接和全局搜索
This commit is contained in:
@@ -1,14 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchStore } from "@/lib/search-store";
|
||||
import { useEffect } from "react";
|
||||
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) => {
|
||||
@@ -27,34 +82,57 @@ export function SearchCommand() {
|
||||
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">
|
||||
<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="搜索文档或标签..."
|
||||
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="文档">
|
||||
{pages.map((page) => (
|
||||
{filteredPages.map((page) => (
|
||||
<Command.Item
|
||||
key={page.id}
|
||||
value={`${page.title} ${(page.tags || []).join(" ")}`}
|
||||
onSelect={() => runCommand(() => setActivePageId(page.id))}
|
||||
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>{page.title || "无标题"}</span>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user