CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
Add a persistent system-content switch, translate the extension manager, and apply theme changes in place without reloading the browser UI. Co-Authored-By: Codex <noreply@anthropic.com>
1399 lines
41 KiB
TypeScript
1399 lines
41 KiB
TypeScript
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<string, MutableFolder>;
|
|
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<string, string> = {
|
|
CONFIG: "系统配置",
|
|
Library: "扩展库",
|
|
Repositories: "扩展仓库",
|
|
Std: "内置",
|
|
};
|
|
return systemLabels[segment] ?? segment;
|
|
}
|
|
|
|
export function collectPlainleafTags(
|
|
allPages: PageMeta[],
|
|
showSystemContent = false,
|
|
): Array<{ name: string; count: number }> {
|
|
const counts = new Map<string, number>();
|
|
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<string>;
|
|
onToggleFolder: (path: string) => void;
|
|
onOpenPage: (page: string) => void;
|
|
depth?: number;
|
|
}) {
|
|
return (
|
|
<div class="pl-page-tree">
|
|
{nodes.map((node) => {
|
|
if (node.kind === "page") {
|
|
return (
|
|
<button
|
|
key={`page:${node.name}`}
|
|
type="button"
|
|
class={`pl-tree-row pl-tree-page ${currentPage === node.name ? "is-active" : ""}`}
|
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
title={node.name}
|
|
onClick={() => onOpenPage(node.name)}
|
|
>
|
|
<span class="pl-tree-disclosure" />
|
|
<FileText size={15} />
|
|
<span class="pl-tree-label">{node.label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const isExpanded = expanded.has(node.path);
|
|
return (
|
|
<div class="pl-tree-folder" key={`folder:${node.path}`}>
|
|
<button
|
|
type="button"
|
|
class="pl-tree-row"
|
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
title={node.path}
|
|
aria-expanded={isExpanded}
|
|
onClick={() => onToggleFolder(node.path)}
|
|
>
|
|
<span class="pl-tree-disclosure">
|
|
{isExpanded ? (
|
|
<ChevronDown size={14} />
|
|
) : (
|
|
<ChevronRight size={14} />
|
|
)}
|
|
</span>
|
|
<Folder size={15} />
|
|
<span class="pl-tree-label">{node.name}</span>
|
|
<span class="pl-tree-count">{countPages(node.children)}</span>
|
|
</button>
|
|
{isExpanded && (
|
|
<PageTree
|
|
nodes={node.children}
|
|
currentPage={currentPage}
|
|
expanded={expanded}
|
|
onToggleFolder={onToggleFolder}
|
|
onOpenPage={onOpenPage}
|
|
depth={depth + 1}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavButton({
|
|
view,
|
|
activeView,
|
|
label,
|
|
count,
|
|
children,
|
|
onClick,
|
|
}: {
|
|
view: PlainleafView;
|
|
activeView: PlainleafView;
|
|
label: string;
|
|
count?: number;
|
|
children: ComponentChildren;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
class={`pl-primary-nav-item ${activeView === view ? "is-active" : ""}`}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
<span>{label}</span>
|
|
{count !== undefined && count > 0 && (
|
|
<span class="pl-nav-count">{count}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SearchResultRow({
|
|
result,
|
|
onOpen,
|
|
}: {
|
|
result: PlainleafSearchResult;
|
|
onOpen: () => void;
|
|
}) {
|
|
return (
|
|
<button type="button" class="pl-search-result" onClick={onOpen}>
|
|
<span class="pl-search-result-heading">
|
|
<strong>{result.title}</strong>
|
|
<time>{result.updated}</time>
|
|
</span>
|
|
{result.excerpt && (
|
|
<span class="pl-search-excerpt">{result.excerpt}</span>
|
|
)}
|
|
<span class="pl-search-path">{result.path}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function AdvancedTools({
|
|
onOpen,
|
|
}: {
|
|
onOpen: (tool: PlainleafAdvancedTool) => void;
|
|
}) {
|
|
const tools: Array<{
|
|
id: PlainleafAdvancedTool;
|
|
label: string;
|
|
description: string;
|
|
icon: ComponentChildren;
|
|
}> = [
|
|
{
|
|
id: "commands",
|
|
label: "命令面板",
|
|
description: "运行自动化和不常用操作",
|
|
icon: <Command size={16} />,
|
|
},
|
|
{
|
|
id: "graph",
|
|
label: "关系图",
|
|
description: "查看笔记之间的链接",
|
|
icon: <GitBranch size={16} />,
|
|
},
|
|
{
|
|
id: "libraries",
|
|
label: "扩展库",
|
|
description: "管理兼容扩展",
|
|
icon: <Box size={16} />,
|
|
},
|
|
{
|
|
id: "configuration",
|
|
label: "配置",
|
|
description: "调整高级行为",
|
|
icon: <Sliders size={16} />,
|
|
},
|
|
{
|
|
id: "system-pages",
|
|
label: "系统页面",
|
|
description: "查看默认隐藏的内部页面",
|
|
icon: <FileText size={16} />,
|
|
},
|
|
];
|
|
return (
|
|
<div class="pl-advanced-tools">
|
|
{tools.map((tool) => (
|
|
<button key={tool.id} type="button" onClick={() => onOpen(tool.id)}>
|
|
<span class="pl-advanced-icon">{tool.icon}</span>
|
|
<span>
|
|
<strong>{tool.label}</strong>
|
|
<small>{tool.description}</small>
|
|
</span>
|
|
<ChevronRight size={14} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<void>;
|
|
onOpenDaily: () => void;
|
|
onSearchPages: (
|
|
query: string,
|
|
scope: PlainleafSearchScope,
|
|
showSystemContent: boolean,
|
|
) => Promise<PlainleafSearchResult[]>;
|
|
onLoadTasks: () => Promise<PlainleafTask[]>;
|
|
onToggleTask: (task: PlainleafTask) => Promise<void>;
|
|
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<PlainleafView>("notes");
|
|
const [expanded, setExpanded] = useState<Set<string>>(
|
|
() => new Set(parentFolderPaths(currentPage)),
|
|
);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchScope, setSearchScope] = useState<PlainleafSearchScope>("all");
|
|
const [searchResults, setSearchResults] = useState<PlainleafSearchResult[]>(
|
|
[],
|
|
);
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
const [selectedTag, setSelectedTag] = useState<string>("");
|
|
const [tasks, setTasks] = useState<PlainleafTask[]>([]);
|
|
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<string>(["收件箱"]);
|
|
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 (
|
|
<>
|
|
<header class="pl-sidebar-heading">
|
|
<strong>搜索笔记</strong>
|
|
<span>
|
|
{searchLoading ? "正在搜索" : `${searchResults.length} 条`}
|
|
</span>
|
|
</header>
|
|
<div class="pl-search-field">
|
|
<Search size={15} />
|
|
<input
|
|
value={searchQuery}
|
|
placeholder="输入标题或正文"
|
|
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
aria-label="清空搜索"
|
|
onClick={() => setSearchQuery("")}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div class="pl-search-scopes">
|
|
{(["all", "title", "content"] as const).map((scope) => (
|
|
<button
|
|
key={scope}
|
|
type="button"
|
|
class={searchScope === scope ? "is-active" : ""}
|
|
onClick={() => setSearchScope(scope)}
|
|
>
|
|
{scope === "all" ? "全部" : scope === "title" ? "标题" : "正文"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div class="pl-result-list">
|
|
{!searchLoading &&
|
|
searchResults.map((result) => (
|
|
<SearchResultRow
|
|
key={result.page}
|
|
result={result}
|
|
onOpen={() => openPage(result.page)}
|
|
/>
|
|
))}
|
|
{!searchLoading && searchResults.length === 0 && (
|
|
<div class="pl-empty-state">
|
|
<Search size={22} />
|
|
<strong>没有找到“{searchQuery}”</strong>
|
|
<span>可以换个关键词,或创建一篇同名笔记。</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setNewNoteTitle(searchQuery);
|
|
setNewNoteOpen(true);
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
新建笔记
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (view === "tasks") {
|
|
return (
|
|
<>
|
|
<header class="pl-sidebar-heading">
|
|
<strong>待办</strong>
|
|
<span>{tasks.length} 项</span>
|
|
</header>
|
|
<div class="pl-task-list">
|
|
{tasksLoading && (
|
|
<div class="pl-loading-state">正在整理待办...</div>
|
|
)}
|
|
{!tasksLoading &&
|
|
tasks.map((task) => (
|
|
<div class="pl-task-row" key={task.ref}>
|
|
<button
|
|
type="button"
|
|
class="pl-task-check"
|
|
disabled={isReadOnly}
|
|
aria-label="完成待办"
|
|
onClick={() => void completeTask(task)}
|
|
>
|
|
<Circle size={17} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-task-copy"
|
|
onClick={() => {
|
|
onOpenTask(task);
|
|
setMobileOpen(false);
|
|
}}
|
|
>
|
|
<strong>{task.name}</strong>
|
|
<small>
|
|
{task.page}
|
|
{task.deadline ? ` · ${task.deadline}` : ""}
|
|
</small>
|
|
</button>
|
|
</div>
|
|
))}
|
|
{!tasksLoading && tasks.length === 0 && (
|
|
<div class="pl-empty-state">
|
|
<CheckCircle size={23} />
|
|
<strong>待办都完成了</strong>
|
|
<span>Markdown 中新的未完成项会出现在这里。</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (view === "tags") {
|
|
return (
|
|
<>
|
|
<header class="pl-sidebar-heading">
|
|
<strong>标签</strong>
|
|
<span>{tags.length} 个</span>
|
|
</header>
|
|
<div class="pl-tag-list">
|
|
{tags.map((tag, index) => (
|
|
<button
|
|
key={tag.name}
|
|
type="button"
|
|
class={selectedTag === tag.name ? "is-active" : ""}
|
|
onClick={() => setSelectedTag(tag.name)}
|
|
>
|
|
<span class={`pl-tag-dot color-${index % 4}`} />
|
|
<span>{tag.name}</span>
|
|
<small>{tag.count}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{selectedTag && (
|
|
<section class="pl-tag-results">
|
|
<h2>#{selectedTag}</h2>
|
|
{tagPages.map((page) => (
|
|
<button
|
|
type="button"
|
|
key={page.name}
|
|
onClick={() => openPage(page.name)}
|
|
>
|
|
<FileText size={14} />
|
|
<span>{plainleafPageLabel(page.name, indexPage)}</span>
|
|
<small>{formatUpdated(page)}</small>
|
|
</button>
|
|
))}
|
|
</section>
|
|
)}
|
|
{tags.length === 0 && (
|
|
<div class="pl-empty-state">
|
|
<Tag size={22} />
|
|
<strong>还没有标签</strong>
|
|
<span>在 Markdown 中输入 #标签 即可整理笔记。</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (view === "settings" || view === "more") {
|
|
return (
|
|
<>
|
|
<header class="pl-sidebar-heading">
|
|
<strong>{view === "settings" ? "设置" : "更多"}</strong>
|
|
</header>
|
|
{view === "more" && (
|
|
<div class="pl-more-links">
|
|
<button type="button" onClick={() => setView("tags")}>
|
|
<Hash size={16} />
|
|
<span>标签</span>
|
|
</button>
|
|
<button type="button" onClick={() => setView("settings")}>
|
|
<Settings size={16} />
|
|
<span>设置</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<section class="pl-settings-section">
|
|
<h2>常用设置</h2>
|
|
<button
|
|
type="button"
|
|
class="pl-setting-row"
|
|
onClick={onToggleTheme}
|
|
>
|
|
<span>
|
|
<strong>界面主题</strong>
|
|
<small>当前为{darkMode ? "深色" : "明亮"}界面</small>
|
|
</span>
|
|
<Moon size={15} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-setting-row"
|
|
role="switch"
|
|
aria-checked={showSystemContent}
|
|
onClick={toggleSystemContent}
|
|
>
|
|
<span>
|
|
<strong>显示系统内容</strong>
|
|
<small>扩展库、仓库与配置页面</small>
|
|
</span>
|
|
<span
|
|
class={`pl-switch ${showSystemContent ? "is-on" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
<span />
|
|
</span>
|
|
</button>
|
|
<div class="pl-setting-row">
|
|
<span>
|
|
<strong>文件同步</strong>
|
|
<small>{isOnline ? "当前空间已连接" : "离线工作中"}</small>
|
|
</span>
|
|
<Cloud size={15} />
|
|
</div>
|
|
</section>
|
|
<section class="pl-settings-section">
|
|
<h2>高级工具</h2>
|
|
<AdvancedTools onOpen={onOpenAdvancedTool} />
|
|
</section>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<header class="pl-sidebar-heading">
|
|
<strong>你的笔记</strong>
|
|
</header>
|
|
<div class="pl-note-shortcuts">
|
|
<button
|
|
type="button"
|
|
class={currentPage === indexPage ? "is-active" : ""}
|
|
onClick={() => openPage(indexPage)}
|
|
>
|
|
<Home size={15} />
|
|
<span>首页</span>
|
|
</button>
|
|
<button type="button">
|
|
<Clock size={15} />
|
|
<span>最近编辑</span>
|
|
<small>{recentPages.length}</small>
|
|
</button>
|
|
<button type="button">
|
|
<Star size={15} />
|
|
<span>收藏</span>
|
|
<small>{favoritePages.length}</small>
|
|
</button>
|
|
</div>
|
|
<section class="pl-files-section">
|
|
<header>
|
|
<span>文件夹</span>
|
|
</header>
|
|
{tree.length ? (
|
|
<PageTree
|
|
nodes={tree}
|
|
currentPage={currentPage}
|
|
expanded={expanded}
|
|
onToggleFolder={toggleFolder}
|
|
onOpenPage={openPage}
|
|
/>
|
|
) : (
|
|
<div class="pl-empty-state">
|
|
<FileText size={22} />
|
|
<strong>还没有 Markdown 文件</strong>
|
|
<button type="button" onClick={() => openCreate()}>
|
|
<Plus size={14} />
|
|
新建第一篇笔记
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div id="pl-workspace-shell">
|
|
<aside
|
|
class={`pl-sidebar ${mobileOpen ? "is-mobile-open" : ""}`}
|
|
aria-label="Plainleaf 导航"
|
|
>
|
|
<header class="pl-brand-row">
|
|
<button
|
|
type="button"
|
|
class="pl-brand"
|
|
onClick={() => openPage(indexPage)}
|
|
>
|
|
<span class="pl-brand-mark">
|
|
<Feather size={17} />
|
|
</span>
|
|
<span>Plainleaf</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-close-mobile"
|
|
aria-label="关闭导航"
|
|
onClick={() => setMobileOpen(false)}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</header>
|
|
<button
|
|
type="button"
|
|
class="pl-create-button"
|
|
disabled={isReadOnly}
|
|
onClick={() => openCreate()}
|
|
>
|
|
<Plus size={16} />
|
|
<span>新建笔记</span>
|
|
</button>
|
|
<nav class="pl-primary-nav">
|
|
<NavButton
|
|
view="notes"
|
|
activeView={view}
|
|
label="笔记"
|
|
onClick={() => openView("notes")}
|
|
>
|
|
<BookOpen size={17} />
|
|
</NavButton>
|
|
<NavButton
|
|
view="search"
|
|
activeView={view}
|
|
label="搜索"
|
|
onClick={() => openView("search")}
|
|
>
|
|
<Search size={17} />
|
|
</NavButton>
|
|
<NavButton
|
|
view="tasks"
|
|
activeView={view}
|
|
label="待办"
|
|
count={tasks.length}
|
|
onClick={() => openView("tasks")}
|
|
>
|
|
<CheckSquare size={17} />
|
|
</NavButton>
|
|
<NavButton
|
|
view="tags"
|
|
activeView={view}
|
|
label="标签"
|
|
onClick={() => openView("tags")}
|
|
>
|
|
<Hash size={17} />
|
|
</NavButton>
|
|
</nav>
|
|
<div class="pl-sidebar-divider" />
|
|
<div class="pl-sidebar-scroll">{renderSidebarContent()}</div>
|
|
<footer class="pl-sidebar-footer">
|
|
<button
|
|
type="button"
|
|
class={view === "settings" ? "is-active" : ""}
|
|
onClick={() => openView("settings")}
|
|
>
|
|
<Settings size={16} />
|
|
<span>设置</span>
|
|
</button>
|
|
</footer>
|
|
</aside>
|
|
|
|
{mobileOpen && (
|
|
<button
|
|
type="button"
|
|
class="pl-mobile-scrim"
|
|
aria-label="关闭导航"
|
|
onClick={() => setMobileOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<main class="pl-editor-workspace">
|
|
<button
|
|
type="button"
|
|
class="pl-mobile-menu-button"
|
|
aria-label="打开导航"
|
|
onClick={() => setMobileOpen(true)}
|
|
>
|
|
<Menu size={18} />
|
|
</button>
|
|
<div
|
|
class={`pl-editor-layer ${showingCustomSurface ? "is-hidden" : ""}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
{currentPage === indexPage && view !== "settings" && (
|
|
<section class="pl-home-surface">
|
|
<div class="pl-home-inner">
|
|
<p class="pl-home-date">
|
|
{new Date().toLocaleDateString("zh-CN", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
weekday: "long",
|
|
})}
|
|
</p>
|
|
<h1>今天想记点什么?</h1>
|
|
<p class="pl-home-subtitle">
|
|
从一条想法开始,剩下的可以晚点再整理。
|
|
</p>
|
|
<div class="pl-home-actions">
|
|
<button type="button" onClick={() => openCreate("capture")}>
|
|
<span class="is-green">
|
|
<Zap size={16} />
|
|
</span>
|
|
<span>
|
|
<strong>随手记</strong>
|
|
<small>快速记下一条想法</small>
|
|
</span>
|
|
</button>
|
|
<button type="button" onClick={onOpenDaily}>
|
|
<span class="is-coral">
|
|
<Calendar size={16} />
|
|
</span>
|
|
<span>
|
|
<strong>今天的日记</strong>
|
|
<small>继续今天的记录</small>
|
|
</span>
|
|
</button>
|
|
<button type="button" onClick={() => openCreate()}>
|
|
<span class="is-blue">
|
|
<FilePlus size={16} />
|
|
</span>
|
|
<span>
|
|
<strong>新建笔记</strong>
|
|
<small>选择标题和保存位置</small>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<div class="pl-home-grid">
|
|
<section>
|
|
<header>
|
|
<h2>最近编辑</h2>
|
|
<button type="button" onClick={() => setView("notes")}>
|
|
查看全部
|
|
</button>
|
|
</header>
|
|
{recentPages.slice(0, 5).map((page) => (
|
|
<button
|
|
type="button"
|
|
class="pl-home-recent"
|
|
key={page.name}
|
|
onClick={() => openPage(page.name)}
|
|
>
|
|
<strong>
|
|
{plainleafPageLabel(page.name, indexPage)}
|
|
</strong>
|
|
<time>{formatUpdated(page)}</time>
|
|
<span>{page.name}</span>
|
|
</button>
|
|
))}
|
|
</section>
|
|
<section>
|
|
<header>
|
|
<h2>今天待办</h2>
|
|
<button
|
|
type="button"
|
|
onClick={() => openMobileView("tasks")}
|
|
>
|
|
查看全部
|
|
</button>
|
|
</header>
|
|
{tasks.slice(0, 4).map((task) => (
|
|
<div class="pl-home-task" key={task.ref}>
|
|
<button
|
|
type="button"
|
|
disabled={isReadOnly}
|
|
aria-label="完成待办"
|
|
onClick={() => void completeTask(task)}
|
|
>
|
|
<Circle size={17} />
|
|
</button>
|
|
<button type="button" onClick={() => onOpenTask(task)}>
|
|
<strong>{task.name}</strong>
|
|
<small>{task.page}</small>
|
|
</button>
|
|
</div>
|
|
))}
|
|
{!tasksLoading && tasks.length === 0 && (
|
|
<p class="pl-home-empty">今天没有未完成待办</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
{view === "settings" && (
|
|
<section class="pl-settings-surface">
|
|
<div class="pl-settings-inner">
|
|
<h1>设置</h1>
|
|
<p>日常写作选项放在前面,高级能力需要时再打开。</p>
|
|
<section>
|
|
<h2>写作体验</h2>
|
|
<div class="pl-settings-main-row">
|
|
<span>
|
|
<strong>界面主题</strong>
|
|
<small>当前为{darkMode ? "深色" : "明亮"}界面</small>
|
|
</span>
|
|
<button type="button" onClick={onToggleTheme}>
|
|
切换为{darkMode ? "明亮" : "深色"}
|
|
</button>
|
|
</div>
|
|
<div class="pl-settings-main-row">
|
|
<span>
|
|
<strong>系统内容</strong>
|
|
<small>扩展库、仓库、内置组件与系统配置</small>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="pl-switch-button"
|
|
role="switch"
|
|
aria-checked={showSystemContent}
|
|
onClick={toggleSystemContent}
|
|
>
|
|
<span>{showSystemContent ? "显示" : "隐藏"}</span>
|
|
<span
|
|
class={`pl-switch ${showSystemContent ? "is-on" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
<span />
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<h2>文件与同步</h2>
|
|
<div class="pl-settings-main-row">
|
|
<span>
|
|
<strong>当前空间</strong>
|
|
<small>
|
|
{isOnline
|
|
? "Markdown 文件已同步"
|
|
: "离线修改将保存在本机"}
|
|
</small>
|
|
</span>
|
|
<span
|
|
class={`pl-online-dot ${isOnline ? "is-online" : ""}`}
|
|
/>
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<h2>高级工具</h2>
|
|
<AdvancedTools onOpen={onOpenAdvancedTool} />
|
|
</section>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
<nav class="pl-mobile-navigation" aria-label="手机导航">
|
|
<button type="button" onClick={() => openMobileView("notes")}>
|
|
<BookOpen size={19} />
|
|
<span>笔记</span>
|
|
</button>
|
|
<button type="button" onClick={() => openMobileView("search")}>
|
|
<Search size={19} />
|
|
<span>搜索</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-mobile-create"
|
|
disabled={isReadOnly}
|
|
aria-label="新建笔记"
|
|
onClick={() => openCreate("capture")}
|
|
>
|
|
<Plus size={21} />
|
|
</button>
|
|
<button type="button" onClick={() => openMobileView("tasks")}>
|
|
<CheckSquare size={19} />
|
|
<span>待办</span>
|
|
</button>
|
|
<button type="button" onClick={() => openMobileView("more")}>
|
|
<Menu size={19} />
|
|
<span>更多</span>
|
|
</button>
|
|
</nav>
|
|
|
|
{newNoteOpen && (
|
|
<div
|
|
class="pl-new-note-backdrop"
|
|
onClick={(event) => {
|
|
if (event.currentTarget === event.target) setNewNoteOpen(false);
|
|
}}
|
|
>
|
|
<section
|
|
class="pl-new-note-dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="新建笔记"
|
|
>
|
|
<header>
|
|
<h2>新建笔记</h2>
|
|
<button
|
|
type="button"
|
|
aria-label="关闭"
|
|
onClick={() => setNewNoteOpen(false)}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</header>
|
|
<div class="pl-quick-create">
|
|
<button type="button" onClick={() => openCreate("capture")}>
|
|
<Zap size={15} />
|
|
随手记
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setNewNoteOpen(false);
|
|
onOpenDaily();
|
|
}}
|
|
>
|
|
<Calendar size={15} />
|
|
今天的日记
|
|
</button>
|
|
<button type="button" class="is-active">
|
|
<FileText size={15} />
|
|
空白笔记
|
|
</button>
|
|
</div>
|
|
<label>
|
|
标题
|
|
<input
|
|
autoFocus
|
|
value={newNoteTitle}
|
|
placeholder="输入笔记标题"
|
|
onInput={(event) => setNewNoteTitle(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") void createPage();
|
|
}}
|
|
/>
|
|
</label>
|
|
<label>
|
|
保存到
|
|
<select
|
|
value={newNoteFolder}
|
|
onChange={(event) =>
|
|
setNewNoteFolder(event.currentTarget.value)
|
|
}
|
|
>
|
|
{rootFolders.map((folder) => (
|
|
<option key={folder} value={folder}>
|
|
{folder}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{createError && <p class="pl-create-error">{createError}</p>}
|
|
<footer>
|
|
<button type="button" onClick={() => setNewNoteOpen(false)}>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="is-primary"
|
|
disabled={creating}
|
|
onClick={() => void createPage()}
|
|
>
|
|
{creating ? "正在创建" : "创建笔记"}
|
|
</button>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|