import type { PageMeta } from "@silverbulletmd/silverbullet/type/index"; import type { ComponentChildren } from "preact"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { BookOpen, Box, Calendar, CheckCircle, CheckSquare, ChevronDown, ChevronRight, Circle, Clock, Cloud, Command, Feather, FilePlus, FileText, Folder, GitBranch, Hash, Home, Menu, Moon, Plus, Search, Settings, Sliders, Star, Tag, X, Zap, } from "preact-feather"; import { parsePageMetaLastModified } from "../lib/page_meta.ts"; export type PageTreeFile = { kind: "page"; name: string; label: string; meta: PageMeta; }; export type PageTreeFolder = { kind: "folder"; name: string; path: string; children: PageTreeNode[]; }; export type PageTreeNode = PageTreeFile | PageTreeFolder; export type PlainleafSearchScope = "all" | "title" | "content"; export type PlainleafSearchResult = { page: string; title: string; path: string; excerpt: string; updated?: string; }; export type PlainleafTask = { ref: string; page: string; name: string; state: string; done: boolean; deadline?: string; }; export function plainleafTaskReference(task: PlainleafTask): string { const ref = task.ref.trim(); if (ref.includes("@") || ref.includes("#") || ref.includes("$")) return ref; return `${task.page}$${ref}`; } export type PlainleafAdvancedTool = | "commands" | "graph" | "libraries" | "configuration" | "system-pages"; type PlainleafView = | "notes" | "search" | "tasks" | "tags" | "settings" | "more"; type MutableFolder = { name: string; path: string; folders: Map; pages: PageTreeFile[]; }; const systemPageRoots = ["library", "repositories", "std"]; const systemTags = ["meta", "template", "library", "repository"]; const showSystemContentStorageKey = "plainleaf.showSystemContent"; function readShowSystemContent(): boolean { try { return ( globalThis.localStorage?.getItem(showSystemContentStorageKey) === "true" ); } catch { return false; } } function storeShowSystemContent(show: boolean): void { try { globalThis.localStorage?.setItem(showSystemContentStorageKey, String(show)); } catch { // Storage can be unavailable in private browsing; the in-memory switch // still works for the current session. } } export function isPlainleafSystemPage(page: PageMeta): boolean { const name = (page.name || page.ref || "").toLocaleLowerCase(); const root = name.split("/")[0]; if (systemPageRoots.includes(root) || name === "config") return true; return (page.tags ?? []).some((tag) => { const normalized = tag.toLocaleLowerCase(); return ( systemTags.includes(normalized) || normalized.startsWith("meta/") || normalized.startsWith("library/") || normalized.startsWith("repository/") ); }); } export function visiblePlainleafPages( allPages: PageMeta[], showSystemContent = false, ): PageMeta[] { return allPages.filter( (page) => !!page.name && !page._isAspiring && page.pageDecoration?.hide !== true && (showSystemContent || !isPlainleafSystemPage(page)), ); } export function plainleafPageLabel(name: string, indexPage = "index"): string { if (name === indexPage) return "首页"; const segment = name.split("/").at(-1) || name; const systemLabels: Record = { CONFIG: "系统配置", Library: "扩展库", Repositories: "扩展仓库", Std: "内置", }; return systemLabels[segment] ?? segment; } export function collectPlainleafTags( allPages: PageMeta[], showSystemContent = false, ): Array<{ name: string; count: number }> { const counts = new Map(); for (const page of visiblePlainleafPages(allPages, showSystemContent)) { for (const tag of page.tags ?? []) { const normalized = tag.toLocaleLowerCase(); if ( !showSystemContent && (systemTags.includes(normalized) || normalized.startsWith("meta/") || normalized.startsWith("library/") || normalized.startsWith("repository/")) ) { continue; } counts.set(tag, (counts.get(tag) ?? 0) + 1); } } return [...counts.entries()] .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "zh-CN")); } function compareTreeNodes(a: PageTreeNode, b: PageTreeNode): number { if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1; const aLabel = a.kind === "page" ? a.label : a.name; const bLabel = b.kind === "page" ? b.label : b.name; return aLabel.localeCompare(bLabel, "zh-CN"); } function parentFolderPaths(page?: string): string[] { const parts = page?.split("/").slice(0, -1) ?? []; return parts.map((_, index) => parts.slice(0, index + 1).join("/")); } function finalizeFolder(folder: MutableFolder): PageTreeNode[] { return [ ...Array.from(folder.folders.values()).map( (child): PageTreeFolder => ({ kind: "folder", name: child.name, path: child.path, children: finalizeFolder(child), }), ), ...folder.pages, ].sort(compareTreeNodes); } /** Derives a display-only folder tree from user-owned SilverBullet pages. */ export function buildPageTree( allPages: PageMeta[], indexPage = "index", showSystemContent = false, ): PageTreeNode[] { const root: MutableFolder = { name: "", path: "", folders: new Map(), pages: [], }; for (const page of visiblePlainleafPages(allPages, showSystemContent)) { const parts = page.name.split("/").filter(Boolean); if (parts.length === 0) continue; let parent = root; for (const part of parts.slice(0, -1)) { let folder = parent.folders.get(part); if (!folder) { const path = parent.path ? `${parent.path}/${part}` : part; folder = { name: plainleafPageLabel(part, indexPage), path, folders: new Map(), pages: [], }; parent.folders.set(part, folder); } parent = folder; } parent.pages.push({ kind: "page", name: page.name, label: plainleafPageLabel(page.name, indexPage), meta: page, }); } return finalizeFolder(root); } function countPages(nodes: PageTreeNode[]): number { return nodes.reduce( (total, node) => total + (node.kind === "page" ? 1 : countPages(node.children)), 0, ); } function pageTimestamp(page: PageMeta): number { return page.lastOpened ?? parsePageMetaLastModified(page.lastModified) ?? 0; } function formatUpdated(page: PageMeta): string { const timestamp = pageTimestamp(page); if (!timestamp) return ""; const date = new Date(timestamp); const now = new Date(); const sameDay = date.toDateString() === now.toDateString(); if (sameDay) { return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", }); } return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); } function PageTree({ nodes, currentPage, expanded, onToggleFolder, onOpenPage, depth = 0, }: { nodes: PageTreeNode[]; currentPage?: string; expanded: Set; onToggleFolder: (path: string) => void; onOpenPage: (page: string) => void; depth?: number; }) { return (
{nodes.map((node) => { if (node.kind === "page") { return ( ); } const isExpanded = expanded.has(node.path); return (
{isExpanded && ( )}
); })}
); } function NavButton({ view, activeView, label, count, children, onClick, }: { view: PlainleafView; activeView: PlainleafView; label: string; count?: number; children: ComponentChildren; onClick: () => void; }) { return ( ); } function SearchResultRow({ result, onOpen, }: { result: PlainleafSearchResult; onOpen: () => void; }) { return ( ); } function AdvancedTools({ onOpen, }: { onOpen: (tool: PlainleafAdvancedTool) => void; }) { const tools: Array<{ id: PlainleafAdvancedTool; label: string; description: string; icon: ComponentChildren; }> = [ { id: "commands", label: "命令面板", description: "运行自动化和不常用操作", icon: , }, { id: "graph", label: "关系图", description: "查看笔记之间的链接", icon: , }, { id: "libraries", label: "扩展库", description: "管理兼容扩展", icon: , }, { id: "configuration", label: "配置", description: "调整高级行为", icon: , }, { id: "system-pages", label: "系统页面", description: "查看默认隐藏的内部页面", icon: , }, ]; return (
{tools.map((tool) => ( ))}
); } export function PlainleafWorkspace({ allPages, currentPage, indexPage, isOnline, isReadOnly, darkMode, onOpenPage, onCreatePage, onOpenDaily, onSearchPages, onLoadTasks, onToggleTask, onOpenTask, onOpenAdvancedTool, onToggleTheme, children, }: { allPages: PageMeta[]; currentPage?: string; indexPage: string; isOnline: boolean; isReadOnly: boolean; darkMode: boolean; onOpenPage: (page: string) => void; onCreatePage: (page: string) => Promise; onOpenDaily: () => void; onSearchPages: ( query: string, scope: PlainleafSearchScope, showSystemContent: boolean, ) => Promise; onLoadTasks: () => Promise; onToggleTask: (task: PlainleafTask) => Promise; onOpenTask: (task: PlainleafTask) => void; onOpenAdvancedTool: (tool: PlainleafAdvancedTool) => void; onToggleTheme: () => void; children: ComponentChildren; }) { const [showSystemContent, setShowSystemContent] = useState( readShowSystemContent, ); const pages = useMemo( () => visiblePlainleafPages(allPages, showSystemContent), [allPages, showSystemContent], ); const tree = useMemo( () => buildPageTree( pages.filter((page) => page.name !== indexPage), indexPage, showSystemContent, ), [pages, indexPage, showSystemContent], ); const recentPages = useMemo( () => [...pages] .filter((page) => page.name !== indexPage) .sort((a, b) => pageTimestamp(b) - pageTimestamp(a)) .slice(0, 6), [pages, indexPage], ); const favoritePages = useMemo( () => pages.filter((page) => page.tags?.some((tag) => ["favorite", "收藏"].includes(tag.toLocaleLowerCase()), ), ), [pages], ); const tags = useMemo( () => collectPlainleafTags(pages, showSystemContent), [pages, showSystemContent], ); const [view, setView] = useState("notes"); const [expanded, setExpanded] = useState>( () => new Set(parentFolderPaths(currentPage)), ); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchScope, setSearchScope] = useState("all"); const [searchResults, setSearchResults] = useState( [], ); const [searchLoading, setSearchLoading] = useState(false); const [selectedTag, setSelectedTag] = useState(""); const [tasks, setTasks] = useState([]); const [tasksLoading, setTasksLoading] = useState(true); const [newNoteOpen, setNewNoteOpen] = useState(false); const [newNoteTitle, setNewNoteTitle] = useState(""); const [newNoteFolder, setNewNoteFolder] = useState("收件箱"); const [createError, setCreateError] = useState(""); const [creating, setCreating] = useState(false); const searchRun = useRef(0); useEffect(() => { const parentPaths = parentFolderPaths(currentPage); if (parentPaths.length === 0) return; setExpanded((previous) => { const next = new Set(previous); parentPaths.forEach((path) => next.add(path)); return next; }); }, [currentPage]); const refreshTasks = async () => { setTasksLoading(true); try { const visibleNames = new Set(pages.map((page) => page.name)); setTasks( (await onLoadTasks()).filter( (task) => !task.done && visibleNames.has(task.page), ), ); } catch (error) { console.error("Failed to load Plainleaf tasks", error); setTasks([]); } finally { setTasksLoading(false); } }; const completeTask = async (task: PlainleafTask) => { try { await onToggleTask(task); setTasks((previous) => previous.filter((candidate) => candidate.ref !== task.ref), ); } catch (error) { console.error("Failed to complete Plainleaf task", error); } }; useEffect(() => { void refreshTasks(); }, [allPages, showSystemContent]); useEffect(() => { if (view !== "search") return; const query = searchQuery.trim(); const run = ++searchRun.current; if (!query) { setSearchResults( recentPages.map((page) => ({ page: page.name, title: plainleafPageLabel(page.name, indexPage), path: page.name, excerpt: page.description ?? "", updated: formatUpdated(page), })), ); setSearchLoading(false); return; } setSearchLoading(true); const timeout = globalThis.setTimeout(() => { onSearchPages(query, searchScope, showSystemContent) .then((results) => { if (run === searchRun.current) setSearchResults(results); }) .catch((error) => { console.error("Failed to search Plainleaf pages", error); if (run === searchRun.current) setSearchResults([]); }) .finally(() => { if (run === searchRun.current) setSearchLoading(false); }); }, 180); return () => globalThis.clearTimeout(timeout); }, [view, searchQuery, searchScope, recentPages, showSystemContent]); const toggleSystemContent = () => { setShowSystemContent((current) => { const next = !current; storeShowSystemContent(next); return next; }); }; const rootFolders = useMemo(() => { const roots = new Set(["收件箱"]); for (const page of pages) { const root = page.name.split("/")[0]; if (page.name.includes("/") && root) roots.add(root); } return [...roots].sort((a, b) => a.localeCompare(b, "zh-CN")); }, [pages]); const toggleFolder = (path: string) => { setExpanded((previous) => { const next = new Set(previous); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; const openPage = (page: string) => { onOpenPage(page); setMobileOpen(false); }; const openView = (next: PlainleafView) => { setView(next); setMobileOpen(false); }; const openMobileView = (next: PlainleafView) => { setView(next); setMobileOpen(true); }; const openCreate = (mode: "blank" | "capture" = "blank") => { setCreateError(""); setNewNoteFolder("收件箱"); const now = new Date(); const captureTime = [ String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0"), String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), ]; setNewNoteTitle( mode === "capture" ? `随手记 ${captureTime[0]}-${captureTime[1]} ${captureTime[2]}-${captureTime[3]}` : "", ); setNewNoteOpen(true); }; const createPage = async () => { const title = newNoteTitle.trim(); if (!title) { setCreateError("请输入笔记标题"); return; } const pageName = title.includes("/") ? title : `${newNoteFolder}/${title}`; setCreating(true); setCreateError(""); try { await onCreatePage(pageName); setNewNoteOpen(false); setMobileOpen(false); } catch (error) { setCreateError(error instanceof Error ? error.message : "创建笔记失败"); } finally { setCreating(false); } }; const tagPages = selectedTag ? pages.filter((page) => page.tags?.includes(selectedTag)) : []; const showingCustomSurface = currentPage === indexPage || view === "settings"; const renderSidebarContent = () => { if (view === "search") { return ( <>
搜索笔记 {searchLoading ? "正在搜索" : `${searchResults.length} 条`}
setSearchQuery(event.currentTarget.value)} /> {searchQuery && ( )}
{(["all", "title", "content"] as const).map((scope) => ( ))}
{!searchLoading && searchResults.map((result) => ( openPage(result.page)} /> ))} {!searchLoading && searchResults.length === 0 && (
没有找到“{searchQuery}” 可以换个关键词,或创建一篇同名笔记。
)}
); } if (view === "tasks") { return ( <>
待办 {tasks.length} 项
{tasksLoading && (
正在整理待办...
)} {!tasksLoading && tasks.map((task) => (
))} {!tasksLoading && tasks.length === 0 && (
待办都完成了 Markdown 中新的未完成项会出现在这里。
)}
); } if (view === "tags") { return ( <>
标签 {tags.length} 个
{tags.map((tag, index) => ( ))}
{selectedTag && (

#{selectedTag}

{tagPages.map((page) => ( ))}
)} {tags.length === 0 && (
还没有标签 在 Markdown 中输入 #标签 即可整理笔记。
)} ); } if (view === "settings" || view === "more") { return ( <>
{view === "settings" ? "设置" : "更多"}
{view === "more" && ( )}

常用设置

文件同步 {isOnline ? "当前空间已连接" : "离线工作中"}

高级工具

); } return ( <>
你的笔记
文件夹
{tree.length ? ( ) : (
还没有 Markdown 文件
)}
); }; return (
{mobileOpen && (
{children}
{currentPage === indexPage && view !== "settings" && (

{new Date().toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric", weekday: "long", })}

今天想记点什么?

从一条想法开始,剩下的可以晚点再整理。

最近编辑

{recentPages.slice(0, 5).map((page) => ( ))}

今天待办

{tasks.slice(0, 4).map((task) => (
))} {!tasksLoading && tasks.length === 0 && (

今天没有未完成待办

)}
)} {view === "settings" && (

设置

日常写作选项放在前面,高级能力需要时再打开。

写作体验

界面主题 当前为{darkMode ? "深色" : "明亮"}界面
系统内容 扩展库、仓库、内置组件与系统配置

文件与同步

当前空间 {isOnline ? "Markdown 文件已同步" : "离线修改将保存在本机"}

高级工具

)} {newNoteOpen && (
{ if (event.currentTarget === event.target) setNewNoteOpen(false); }} >
)}
); }