增加了双向链接和全局搜索

This commit is contained in:
2026-02-25 09:20:38 +08:00
parent 317b9c7c26
commit ee3de1968c
12 changed files with 490 additions and 84 deletions
-2
View File
@@ -1,2 +0,0 @@
⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this.
Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins
-72
View File
@@ -1,72 +0,0 @@
> note-ai@1.0.0105 dev
> next dev -p 3001
▲ Next.js 16.1.1 (Turbopack)
- Local: http://localhost:3001
- Network: http://198.18.0.1:3001
- Environments: .env
✓ Starting...
✓ Ready in 1339ms
GET / 200 in 2.5s (compile: 1896ms, proxy.ts: 145ms, render: 479ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 164ms (compile: 104ms, proxy.ts: 9ms, render: 51ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 32ms (compile: 3ms, proxy.ts: 6ms, render: 22ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 4ms, render: 21ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 5ms, render: 21ms)
prisma:query SELECT `main`.`Page`.`id` FROM `main`.`Page` WHERE (`main`.`Page`.`id` = ? AND 1=1) LIMIT ? OFFSET ?
prisma:query UPDATE `main`.`Page` SET `content` = ?, `updatedAt` = ? WHERE (`main`.`Page`.`id` = ? AND 1=1) RETURNING `id` AS `id`, `title` AS `title`, `content` AS `content`, `tags` AS `tags`, `icon` AS `icon`, `type` AS `type`, `parentId` AS `parentId`, `createdAt` AS `createdAt`, `updatedAt` AS `updatedAt`, `order` AS `order`, `isLocked` AS `isLocked`
PUT /api/pages/d039b54a-ab1c-4341-a14a-70b1b1786118 200 in 898ms (compile: 865ms, proxy.ts: 8ms, render: 25ms)
GET /settings 200 in 208ms (compile: 182ms, proxy.ts: 5ms, render: 21ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 42ms (compile: 7ms, proxy.ts: 14ms, render: 21ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 31ms (compile: 4ms, proxy.ts: 10ms, render: 17ms)
✓ Compiled in 180ms
GET /settings 200 in 116ms (compile: 64ms, proxy.ts: 5ms, render: 47ms)
GET /settings 200 in 38ms (compile: 4ms, proxy.ts: 5ms, render: 29ms)
✓ Compiled in 135ms
GET /settings 200 in 200ms (compile: 61ms, proxy.ts: 4ms, render: 135ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 38ms (compile: 4ms, proxy.ts: 8ms, render: 25ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 23ms (compile: 3ms, proxy.ts: 9ms, render: 11ms)
✓ Compiled in 100ms
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 20ms (compile: 3ms, proxy.ts: 5ms, render: 12ms)
GET /settings 200 in 92ms (compile: 69ms, proxy.ts: 6ms, render: 17ms)
GET / 200 in 129ms (compile: 101ms, proxy.ts: 6ms, render: 22ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 36ms (compile: 10ms, proxy.ts: 13ms, render: 14ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 28ms (compile: 7ms, proxy.ts: 11ms, render: 10ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 25ms (compile: 5ms, proxy.ts: 9ms, render: 11ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 18ms (compile: 3ms, proxy.ts: 7ms, render: 8ms)
✓ Compiled in 455ms
✓ Compiled in 121ms
✓ Compiled in 87ms
GET /settings 200 in 76ms (compile: 57ms, proxy.ts: 5ms, render: 15ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 40ms (compile: 4ms, proxy.ts: 11ms, render: 26ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 38ms (compile: 5ms, proxy.ts: 8ms, render: 25ms)
GET / 200 in 93ms (compile: 73ms, proxy.ts: 4ms, render: 17ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 22ms (compile: 3ms, proxy.ts: 7ms, render: 12ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 20ms (compile: 6ms, proxy.ts: 6ms, render: 8ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 29ms (compile: 1969µs, proxy.ts: 4ms, render: 22ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 28ms (compile: 1741µs, proxy.ts: 4ms, render: 22ms)
+4
View File
@@ -29,6 +29,10 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
*.log
*.err.log
.dev*.log
.dev*.err.log
# env files (can opt-in for committing if needed)
.env*
BIN
View File
Binary file not shown.
+146 -2
View File
@@ -3,7 +3,7 @@
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
import { Editor } from "@/components/editor";
import { useEditorStore } from "@/lib/store";
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen } from "lucide-react";
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
import { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useMemo, useState } from "react";
@@ -12,6 +12,8 @@ import { useSearchStore } from "@/lib/search-store";
import { type Editor as TiptapEditor } from "@tiptap/react";
import { ImportProvider } from "@/components/import-context";
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
import { getBacklinks, getOutgoingLinks, getUnresolvedLinkTitles } from "@/lib/wiki-links";
import { getPageHistory } from "@/lib/page-history";
const ICONS = [
"📄", "📝", "📒", "📘", "📙", "📕", "📗", "📚",
@@ -22,7 +24,7 @@ const ICONS = [
];
export default function Home() {
const { activePageId, pages, updatePage } = useEditorStore();
const { activePageId, pages, updatePage, setActivePageId, addPage } = useEditorStore();
const { openSearchWithTag } = useSearchStore();
const { timezone } = useSettingsStore();
@@ -47,6 +49,26 @@ export default function Home() {
return result;
}, [activePage, pages]);
const outgoingLinks = useMemo(() => {
if (!activePage || activePage.type !== "file") return [];
return getOutgoingLinks(activePage, pages);
}, [activePage, pages]);
const backlinks = useMemo(() => {
if (!activePage || activePage.type !== "file") return [];
return getBacklinks(activePage, pages);
}, [activePage, pages]);
const unresolvedLinks = useMemo(() => {
if (!activePage || activePage.type !== "file") return [];
return getUnresolvedLinkTitles(activePage, pages);
}, [activePage, pages]);
const localHistory = useMemo(() => {
if (!activePage || activePage.type !== "file") return [];
return getPageHistory(activePage.id);
}, [activePage]);
const addTag = () => {
if (!activePage) return;
const next = tagInput.trim();
@@ -57,6 +79,17 @@ export default function Home() {
setIsAddingTag(false);
};
const handleOpenWikiLink = async (title: string) => {
const target = pages.find((p) => p.type === "file" && (p.title || "").trim() === title);
if (target) {
setActivePageId(target.id);
return;
}
const shouldCreate = window.confirm(`未找到页面“${title}”。是否立即创建?`);
if (!shouldCreate) return;
await addPage(null, "file", { title, content: "" });
};
return (
<ImportProvider>
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
@@ -250,8 +283,119 @@ export default function Home() {
onEditorReady={setEditor}
onToggleAI={() => setIsChatOpen(!isChatOpen)}
onExport={() => exportPageAsMarkdown(activePage)}
onOpenWikiLink={handleOpenWikiLink}
editable={!activePage.isLocked}
/>
<section className="ui-enter mt-8 space-y-4">
<div className="ui-card p-4">
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="mt-1 text-xs text-muted-foreground">使 `[[页面名]]` `Ctrl/Cmd + 点击` </p>
<div className="mt-3 flex flex-wrap gap-2">
{outgoingLinks.length > 0 ? (
outgoingLinks.map((item) => (
<button
key={item.id}
onClick={() => setActivePageId(item.id)}
className="rounded-md border border-border/70 bg-muted/40 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-accent"
>
{item.icon ? `${item.icon} ` : ""}
{item.title}
</button>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
<div className="ui-card p-4">
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="mt-1 text-xs text-muted-foreground"></p>
<div className="mt-3 flex flex-wrap gap-2">
{backlinks.length > 0 ? (
backlinks.map((item) => (
<button
key={item.id}
onClick={() => setActivePageId(item.id)}
className="rounded-md border border-border/70 bg-muted/40 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-accent"
>
{item.icon ? `${item.icon} ` : ""}
{item.title}
</button>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
<div className="ui-card p-4">
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="mt-1 text-xs text-muted-foreground"> `[[页面名]]` </p>
<div className="mt-3 flex flex-wrap gap-2">
{unresolvedLinks.length > 0 ? (
unresolvedLinks.map((title) => (
<button
key={title}
onClick={async () => {
await addPage(null, "file", { title, content: "" });
}}
className="rounded-md border border-dashed border-border bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-accent"
title={`创建页面:${title}`}
>
+ {title}
</button>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
<div className="ui-card p-4">
<div className="flex items-center gap-2">
<History size={14} className="text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="mt-1 text-xs text-muted-foreground"></p>
<div className="mt-3 space-y-2">
{localHistory.length > 0 ? (
localHistory.slice(0, 8).map((snapshot, index) => (
<div key={`${snapshot.timestamp}-${index}`} className="flex items-center justify-between rounded-md border border-border/60 bg-muted/20 px-2.5 py-2">
<div className="min-w-0">
<div className="truncate text-xs text-foreground">
{snapshot.title || "无标题"}
</div>
<div className="text-[11px] text-muted-foreground">
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
timeZone: timezone || "Asia/Shanghai",
hour12: false,
})}
</div>
</div>
<button
onClick={async () => {
const ok = window.confirm("恢复该历史版本后将覆盖当前内容,是否继续?");
if (!ok || !activePage) return;
await updatePage(activePage.id, {
title: snapshot.title,
content: snapshot.content,
});
}}
className="ui-btn-secondary px-2 py-1 text-xs"
>
<RotateCcw size={12} />
</button>
</div>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
</section>
</div>
)}
</div>
+34 -1
View File
@@ -37,10 +37,31 @@ interface EditorProps {
onEditorReady?: (editor: TiptapEditor) => void;
onToggleAI?: () => void;
onExport?: () => void;
onOpenWikiLink?: (title: string) => void;
editable?: boolean;
}
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editable = true }: EditorProps) {
function resolveWikiLinkAtPos(doc: TiptapEditor["state"]["doc"], pos: number): string | null {
const $pos = doc.resolve(pos);
const start = $pos.start($pos.depth);
const end = $pos.end($pos.depth);
const text = doc.textBetween(start, end, "\n");
const offset = pos - start;
const re = /\[\[([^[\]]+)\]\]/g;
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
const from = match.index;
const to = from + match[0].length;
if (offset >= from && offset <= to) {
const title = (match[1] || "").trim();
return title || null;
}
}
return null;
}
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, onOpenWikiLink, editable = true }: EditorProps) {
const [showAI, setShowAI] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -130,6 +151,18 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${lineHeight}; --table-line-height: ${tableLineHeight};`,
spellcheck: "false",
},
handleClick: (view, pos, event) => {
if (!onOpenWikiLink) return false;
const pointerEvent = event as MouseEvent;
if (!pointerEvent.metaKey && !pointerEvent.ctrlKey) return false;
const title = resolveWikiLinkAtPos(view.state.doc, pos);
if (!title) return false;
pointerEvent.preventDefault();
onOpenWikiLink(title);
return true;
},
},
editable,
immediatelyRender: false,
+84 -6
View File
@@ -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>
+71
View File
@@ -0,0 +1,71 @@
import type { Page } from "@/lib/store";
const MAX_SNAPSHOTS_PER_PAGE = 30;
const MIN_SNAPSHOT_INTERVAL_MS = 15_000;
export type PageSnapshot = {
title: string;
content: string;
timestamp: string;
};
function getKey(pageId: string): string {
return `noteai:history:${pageId}`;
}
function canUseStorage(): boolean {
return typeof window !== "undefined" && typeof localStorage !== "undefined";
}
export function getPageHistory(pageId: string): PageSnapshot[] {
if (!canUseStorage()) return [];
try {
const raw = localStorage.getItem(getKey(pageId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is PageSnapshot =>
item &&
typeof item === "object" &&
typeof item.title === "string" &&
typeof item.content === "string" &&
typeof item.timestamp === "string"
);
} catch {
return [];
}
}
export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void {
if (!canUseStorage()) return;
const history = getPageHistory(page.id);
const latest = history[0];
if (latest && latest.title === page.title && latest.content === page.content) {
return;
}
if (latest) {
const gap = Date.now() - new Date(latest.timestamp).getTime();
if (!Number.isNaN(gap) && gap < MIN_SNAPSHOT_INTERVAL_MS) {
return;
}
}
const next: PageSnapshot[] = [
{
title: page.title || "无标题",
content: page.content || "",
timestamp: new Date().toISOString(),
},
...history,
].slice(0, MAX_SNAPSHOTS_PER_PAGE);
try {
localStorage.setItem(getKey(page.id), JSON.stringify(next));
} catch {
// Ignore storage errors.
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { Page } from "@/lib/store";
type ParsedQuery = {
textTerms: string[];
tagTerms: string[];
typeTerm: "file" | "folder" | null;
updatedDays: number | null;
};
function stripHtml(input: string): string {
return (input || "").replace(/<[^>]+>/g, " ");
}
export function parseSearchQuery(rawQuery: string): ParsedQuery {
const tokens = rawQuery
.trim()
.split(/\s+/)
.filter(Boolean);
const parsed: ParsedQuery = {
textTerms: [],
tagTerms: [],
typeTerm: null,
updatedDays: null,
};
for (const token of tokens) {
const lower = token.toLowerCase();
if (lower.startsWith("tag:")) {
const value = token.slice(4).trim();
if (value) parsed.tagTerms.push(value.toLowerCase());
continue;
}
if (lower.startsWith("type:")) {
const value = token.slice(5).trim().toLowerCase();
if (value === "file" || value === "folder") parsed.typeTerm = value;
continue;
}
if (lower.startsWith("updated:")) {
const value = token.slice(8).trim().toLowerCase();
const match = /^(\d+)d$/.exec(value);
if (match) {
parsed.updatedDays = Number.parseInt(match[1], 10);
}
continue;
}
parsed.textTerms.push(lower);
}
return parsed;
}
export function filterPagesByQuery(pages: Page[], rawQuery: string): Page[] {
const q = parseSearchQuery(rawQuery);
const now = Date.now();
return pages.filter((page) => {
if (q.typeTerm && page.type !== q.typeTerm) return false;
if (q.tagTerms.length > 0) {
const tagSet = new Set((page.tags || []).map((t) => t.toLowerCase()));
const allTagsMatched = q.tagTerms.every((t) => tagSet.has(t));
if (!allTagsMatched) return false;
}
if (q.updatedDays !== null) {
if (!page.updatedAt) return false;
const diffMs = now - new Date(page.updatedAt).getTime();
const maxMs = q.updatedDays * 24 * 60 * 60 * 1000;
if (Number.isNaN(diffMs) || diffMs > maxMs) return false;
}
if (q.textTerms.length > 0) {
const haystack = [
page.title || "",
stripHtml(page.content || ""),
...(page.tags || []),
]
.join(" ")
.toLowerCase();
const allTermsMatched = q.textTerms.every((term) => haystack.includes(term));
if (!allTermsMatched) return false;
}
return true;
});
}
+1 -1
View File
@@ -13,5 +13,5 @@ export const useSearchStore = create<SearchState>((set) => ({
query: "",
setOpen: (isOpen) => set({ isOpen }),
setQuery: (query) => set({ query }),
openSearchWithTag: (tag) => set({ isOpen: true, query: tag }),
openSearchWithTag: (tag) => set({ isOpen: true, query: `tag:${tag}` }),
}));
+10
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { capturePageSnapshot } from "@/lib/page-history";
export interface Page {
id: string;
@@ -87,6 +88,15 @@ export const useEditorStore = create<EditorState>()(
updatePage: async (id, data) => {
const prevPages = get().pages;
const current = prevPages.find((p) => p.id === id);
if (current && (data.content !== undefined || data.title !== undefined)) {
capturePageSnapshot({
id: current.id,
title: current.title,
content: current.content,
});
}
set((state) => ({
pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)),
+52
View File
@@ -0,0 +1,52 @@
import type { Page } from "@/lib/store";
const WIKI_LINK_RE = /\[\[([^[\]]+)\]\]/g;
function stripHtml(input: string): string {
return input.replace(/<[^>]+>/g, " ");
}
export function extractWikiLinkTitles(content: string): string[] {
const text = stripHtml(content || "");
const result: string[] = [];
const seen = new Set<string>();
let match: RegExpExecArray | null;
while ((match = WIKI_LINK_RE.exec(text)) !== null) {
const title = (match[1] || "").trim();
if (!title || seen.has(title)) continue;
seen.add(title);
result.push(title);
}
return result;
}
export function getOutgoingLinks(page: Page, pages: Page[]): Page[] {
const titles = extractWikiLinkTitles(page.content || "");
if (titles.length === 0) return [];
const titleSet = new Set(titles);
return pages.filter((p) => p.id !== page.id && p.type === "file" && titleSet.has((p.title || "").trim()));
}
export function getUnresolvedLinkTitles(page: Page, pages: Page[]): string[] {
const titles = extractWikiLinkTitles(page.content || "");
if (titles.length === 0) return [];
const existing = new Set(
pages
.filter((p) => p.type === "file")
.map((p) => (p.title || "").trim())
.filter(Boolean)
);
return titles.filter((title) => !existing.has(title));
}
export function getBacklinks(target: Page, pages: Page[]): Page[] {
const targetTitle = (target.title || "").trim();
if (!targetTitle) return [];
return pages.filter((p) => {
if (p.id === target.id || p.type !== "file") return false;
const titles = extractWikiLinkTitles(p.content || "");
return titles.includes(targetTitle);
});
}