Files
plainleaf/client/components/plainleaf_workspace.tsx
T
wushenghuaandCodex 645c04ed01 feat: 优化移动端笔记与编辑体验
手机端改用抽屉侧栏并移除底部导航,补充 Markdown 编辑工具栏,收拢开发者选项并改善社区扩展下载错误反馈。

Co-Authored-By: Codex <noreply@anthropic.com>
2026-08-13 06:42:18 +08:00

3013 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import type { ComponentChildren } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import {
BookOpen,
Bell,
Bold,
Box,
Calendar,
CheckCircle,
CheckSquare,
ChevronDown,
ChevronRight,
Circle,
Clipboard,
Clock,
Cloud,
Code,
Command,
CornerUpLeft,
CornerUpRight,
Download,
Feather,
FilePlus,
FileText,
Folder,
GitBranch,
Grid,
Hash,
Home,
Image as ImageIcon,
Italic,
Link,
List,
Menu,
Moon,
Plus,
Repeat,
Search,
Settings,
Sliders,
Star,
Tag,
User,
X,
Zap,
} from "preact-feather";
import { parsePageMetaLastModified } from "../lib/page_meta.ts";
import type { PlainleafEditorAction } from "./plainleaf_markdown_toolbar.ts";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
import {
calendarMonth,
dueReminderTasks,
groupScheduledTasks,
groupLogbookTasks,
groupTasksByProject,
isOverdueTask,
localDateKey,
logbookTasks,
nextRepeatedTask,
parseNaturalTaskDate,
plainleafTaskInbox,
projectForTask,
projectSummaries,
sortTodayTasks,
staleProjects,
subtaskProgress,
taskDate,
taskDepth,
taskHealthIssues,
taskMatchesFilters,
tasksForCalendarDate,
tasksForAnytime,
tasksForInbox,
type PlainleafNewTask,
type PlainleafDeletedTaskBlock,
type PlainleafTask,
type PlainleafTaskList,
type PlainleafTaskPatch,
type PlainleafRepeatRule,
repeatRuleLabels,
} from "./plainleaf_tasks.ts";
export type {
PlainleafDeletedTaskBlock,
PlainleafNewTask,
PlainleafRepeatRule,
PlainleafTask,
PlainleafTaskPatch,
} from "./plainleaf_tasks.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 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";
export type { PlainleafEditorAction } from "./plainleaf_markdown_toolbar.ts";
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";
const developerToolsStorageKey = "plainleaf.developerTools";
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.
}
}
function readDeveloperTools(): boolean {
try {
return (
globalThis.localStorage?.getItem(developerToolsStorageKey) === "true"
);
} catch {
return false;
}
}
function storeDeveloperTools(show: boolean): void {
try {
globalThis.localStorage?.setItem(developerToolsStorageKey, String(show));
} catch {
// Keep the setting for this session when persistent storage is unavailable.
}
}
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: "从 GitHub 安装第三方能力",
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>
);
}
function MarkdownToolbar({
readOnly,
onAction,
}: {
readOnly: boolean;
onAction: (action: PlainleafEditorAction) => void;
}) {
const button = (
action: PlainleafEditorAction,
label: string,
icon: ComponentChildren,
) => (
<button
type="button"
aria-label={label}
title={label}
disabled={readOnly && action !== "undo" && action !== "redo"}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onAction(action)}
>
{icon}
</button>
);
return (
<div
class="pl-markdown-toolbar"
role="toolbar"
aria-label="Markdown 编辑工具"
>
<div class="pl-toolbar-group">
{button("undo", "撤销", <CornerUpLeft size={16} />)}
{button("redo", "重做", <CornerUpRight size={16} />)}
</div>
<span class="pl-toolbar-divider" />
<div class="pl-toolbar-group">
{button("heading-1", "一级标题", <strong>H1</strong>)}
{button("heading-2", "二级标题", <strong>H2</strong>)}
{button("bold", "粗体", <Bold size={16} />)}
{button("italic", "斜体", <Italic size={16} />)}
</div>
<span class="pl-toolbar-divider" />
<div class="pl-toolbar-group">
{button("bullet-list", "项目列表", <List size={16} />)}
{button("numbered-list", "编号列表", <strong>1.</strong>)}
{button("task-list", "待办事项", <CheckSquare size={16} />)}
{button("quote", "引用", <strong>&gt;</strong>)}
{button("code", "代码", <Code size={16} />)}
</div>
<span class="pl-toolbar-divider" />
<div class="pl-toolbar-group">
{button("link", "插入链接", <Link size={16} />)}
{button("image", "上传图片或文件", <ImageIcon size={16} />)}
</div>
</div>
);
}
const taskListLabels: Record<PlainleafTaskList, string> = {
inbox: "收件箱",
today: "今天",
planned: "计划",
anytime: "随时",
projects: "项目",
logbook: "日志簿",
};
function taskListIcon(list: PlainleafTaskList) {
const props = { size: 16 };
switch (list) {
case "inbox":
return <Clipboard {...props} />;
case "today":
return <Star {...props} />;
case "planned":
return <Calendar {...props} />;
case "anytime":
return <Circle {...props} />;
case "projects":
return <Folder {...props} />;
case "logbook":
return <CheckCircle {...props} />;
}
}
function displayTaskDate(value?: string): string {
const date = taskDate(value);
if (!date) return "";
const today = localDateKey();
if (date === today) return "今天";
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
if (date === localDateKey(tomorrow)) return "明天";
return new Date(`${date}T00:00:00`).toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
weekday: "short",
});
}
export function plainleafTaskTitle(task: PlainleafTask): string {
return task.name.replace(/\s*\[\[[^\]]+\]\]/g, "").trim() || task.name;
}
function TaskRow({
task,
depth,
selected,
selectionMode,
checked,
readOnly,
progress,
onSelect,
onToggleSelection,
onComplete,
}: {
task: PlainleafTask;
depth: number;
selected: boolean;
selectionMode: boolean;
checked: boolean;
readOnly: boolean;
progress?: { completed: number; total: number };
onSelect: () => void;
onToggleSelection: () => void;
onComplete: () => void;
}) {
const date = taskDate(task.deadline) ?? taskDate(task.scheduled);
return (
<div
class={`pl-task-center-row ${selected ? "is-selected" : ""} ${checked ? "is-checked" : ""} ${task.done ? "is-complete" : ""}`}
style={{ "--pl-task-depth": Math.min(depth, 3) }}
>
<button
type="button"
class="pl-task-center-check"
disabled={readOnly}
aria-label={
selectionMode
? checked
? "取消选择"
: "选择待办"
: task.done
? "恢复待办"
: "完成待办"
}
onClick={selectionMode ? onToggleSelection : onComplete}
>
{task.done || checked ? (
<CheckCircle size={19} />
) : (
<Circle size={19} />
)}
</button>
<button type="button" class="pl-task-center-copy" onClick={onSelect}>
<strong>{plainleafTaskTitle(task)}</strong>
<span>
<small>{projectForTask(task) ?? task.page}</small>
{progress && (
<small class="pl-task-progress">
{progress.completed}/{progress.total} 子任务
</small>
)}
{date && (
<time class={isOverdueTask(task) ? "is-overdue" : ""}>
{displayTaskDate(date)}
</time>
)}
{task.repeat && (
<Repeat size={12} aria-label={repeatRuleLabels[task.repeat]} />
)}
{task.reminder && <Bell size={12} aria-label="已设置提醒" />}
</span>
</button>
</div>
);
}
function TaskEditor({
task,
readOnly,
onClose,
onSave,
onAddSubtask,
onDelete,
onOpenSource,
}: {
task: PlainleafTask;
readOnly: boolean;
onClose: () => void;
onSave: (patch: PlainleafTaskPatch) => Promise<void>;
onAddSubtask: (input: PlainleafNewTask) => Promise<void>;
onDelete: () => Promise<void>;
onOpenSource: () => void;
}) {
const [name, setName] = useState(plainleafTaskTitle(task));
const [scheduled, setScheduled] = useState(taskDate(task.scheduled) ?? "");
const [deadline, setDeadline] = useState(taskDate(task.deadline) ?? "");
const [project, setProject] = useState(task.links?.[0] ?? "");
const [tags, setTags] = useState((task.tags ?? []).join(" "));
const [repeat, setRepeat] = useState<PlainleafRepeatRule | "">(
task.repeat ?? "",
);
const [reminder, setReminder] = useState(task.reminder ?? "");
const [subtask, setSubtask] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
setName(plainleafTaskTitle(task));
setScheduled(taskDate(task.scheduled) ?? "");
setDeadline(taskDate(task.deadline) ?? "");
setProject(task.links?.[0] ?? "");
setTags((task.tags ?? []).join(" "));
setRepeat(task.repeat ?? "");
setReminder(task.reminder ?? "");
}, [
task.ref,
task.name,
task.scheduled,
task.deadline,
task.links,
task.repeat,
task.reminder,
]);
const save = async () => {
if (!name.trim()) return;
setSaving(true);
try {
await onSave({
name: name.trim(),
scheduled: scheduled || null,
deadline: deadline || null,
repeat: repeat || null,
reminder: reminder || null,
project: project || null,
tags: tags.split(/[\s,,]+/).filter(Boolean),
});
} finally {
setSaving(false);
}
};
return (
<aside class="pl-task-inspector" aria-label="待办详情">
<header>
<strong>待办详情</strong>
<button type="button" aria-label="关闭详情" onClick={onClose}>
<X size={18} />
</button>
</header>
<label>
标题
<textarea
value={name}
disabled={readOnly}
rows={3}
onInput={(event) => setName(event.currentTarget.value)}
/>
</label>
<div class="pl-task-date-fields">
<label>
计划日期
<input
type="date"
value={scheduled}
disabled={readOnly}
onInput={(event) => setScheduled(event.currentTarget.value)}
/>
</label>
<label>
截止日期
<input
type="date"
value={deadline}
disabled={readOnly}
onInput={(event) => setDeadline(event.currentTarget.value)}
/>
</label>
</div>
<label>
项目
<input
value={project}
disabled={readOnly}
placeholder="例如:项目/Plainleaf"
onInput={(event) => setProject(event.currentTarget.value)}
/>
</label>
<label>
标签
<input
value={tags}
disabled={readOnly}
placeholder="用空格分隔"
onInput={(event) => setTags(event.currentTarget.value)}
/>
</label>
<div class="pl-task-date-fields">
<label>
重复
<select
value={repeat}
disabled={readOnly}
onChange={(event) =>
setRepeat(event.currentTarget.value as typeof repeat)
}
>
<option value="">不重复</option>
{Object.entries(repeatRuleLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label>
提醒时间
<input
type="datetime-local"
value={reminder}
disabled={readOnly}
onInput={(event) => setReminder(event.currentTarget.value)}
/>
</label>
</div>
<label class="pl-subtask-field">
新增子任务
<span>
<input
value={subtask}
disabled={readOnly}
placeholder="输入子任务标题"
onInput={(event) => setSubtask(event.currentTarget.value)}
/>
<button
type="button"
disabled={readOnly || !subtask.trim()}
onClick={() => {
void onAddSubtask({ name: subtask.trim() }).then(() =>
setSubtask(""),
);
}}
>
<Plus size={15} />
</button>
</span>
</label>
<div class="pl-task-source">
<span>来源</span>
<strong>{task.page}</strong>
</div>
<footer>
<button
type="button"
class="is-danger"
disabled={readOnly}
onClick={() => {
if (
confirm(
`确定删除“${plainleafTaskTitle(task)}”吗?删除后可以立即撤销。`,
)
) {
void onDelete();
}
}}
>
删除待办
</button>
<button type="button" onClick={onOpenSource}>
在原文中打开
</button>
<button
type="button"
class="is-primary"
disabled={readOnly || saving || !name.trim()}
onClick={() => void save()}
>
{saving ? "正在保存" : "保存修改"}
</button>
</footer>
</aside>
);
}
function NewTaskDialog({
readOnly,
onClose,
onCreate,
}: {
readOnly: boolean;
onClose: () => void;
onCreate: (input: PlainleafNewTask) => Promise<void>;
}) {
const [name, setName] = useState("");
const [scheduled, setScheduled] = useState("");
const [naturalDate, setNaturalDate] = useState("");
const [project, setProject] = useState("");
const [repeat, setRepeat] = useState<PlainleafNewTask["repeat"]>(null);
const [advanced, setAdvanced] = useState(false);
const [creating, setCreating] = useState(false);
const create = async () => {
if (!name.trim()) return;
setCreating(true);
try {
await onCreate({
name: name.trim(),
scheduled: scheduled || null,
project: project || null,
repeat,
});
onClose();
} finally {
setCreating(false);
}
};
return (
<div
class="pl-task-dialog-backdrop"
onClick={(event) => event.currentTarget === event.target && onClose()}
>
<section
class="pl-task-dialog"
role="dialog"
aria-modal="true"
aria-label="添加待办"
>
<header>
<h2>添加待办</h2>
<button type="button" aria-label="关闭" onClick={onClose}>
<X size={18} />
</button>
</header>
<textarea
autoFocus
rows={3}
value={name}
disabled={readOnly}
placeholder="要做什么?"
onInput={(event) => setName(event.currentTarget.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter")
void create();
}}
/>
<div class="pl-task-quick-dates">
<button
type="button"
class={scheduled === localDateKey() ? "is-active" : ""}
onClick={() => setScheduled(localDateKey())}
>
<Star size={15} /> 今天
</button>
<label>
<Calendar size={15} />
<span>{scheduled ? displayTaskDate(scheduled) : "选择日期"}</span>
<input
type="date"
value={scheduled}
onInput={(event) => setScheduled(event.currentTarget.value)}
/>
</label>
</div>
<label class="pl-task-dialog-project">
用中文安排日期
<input
value={naturalDate}
placeholder="例如:明天、下周一、8月20日"
onInput={(event) => {
const value = event.currentTarget.value;
setNaturalDate(value);
const parsed = parseNaturalTaskDate(value);
if (parsed) setScheduled(parsed);
}}
/>
{naturalDate && !parseNaturalTaskDate(naturalDate) && (
<small>暂时无法识别这个日期</small>
)}
</label>
<label class="pl-task-dialog-project">
项目(可选)
<input
value={project}
placeholder="输入项目页面名称"
onInput={(event) => setProject(event.currentTarget.value)}
/>
</label>
<button
type="button"
class="pl-task-advanced-toggle"
aria-expanded={advanced}
onClick={() => setAdvanced((value) => !value)}
>
<Sliders size={14} />
{advanced ? "收起高级选项" : "高级选项"}
</button>
{advanced && (
<label class="pl-task-dialog-project">
重复
<select
value={repeat ?? ""}
onChange={(event) =>
setRepeat(
(event.currentTarget.value ||
null) as PlainleafNewTask["repeat"],
)
}
>
<option value="">不重复</option>
{Object.entries(repeatRuleLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
)}
<p>保存到 {plainleafTaskInbox}.md</p>
<footer>
<button type="button" onClick={onClose}>
取消
</button>
<button
type="button"
class="is-primary"
disabled={creating || !name.trim()}
onClick={() => void create()}
>
{creating ? "正在添加" : "添加待办"}
</button>
</footer>
</section>
</div>
);
}
export function PlainleafWorkspace({
allPages,
currentPage,
indexPage,
isOnline,
isReadOnly,
darkMode,
onOpenPage,
onCreatePage,
onOpenDaily,
onSearchPages,
onLoadTasks,
onToggleTask,
onUpdateTask,
onBatchUpdateTasks,
onCreateTask,
onCreateSubtask,
onExportMarkdown,
onImportMarkdown,
onDeleteTask,
onRestoreTask,
accountManaged,
onOpenTask,
onOpenAdvancedTool,
onEditorAction,
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,
patch: PlainleafTaskPatch,
) => Promise<void>;
onUpdateTask: (
task: PlainleafTask,
patch: PlainleafTaskPatch,
) => Promise<void>;
onBatchUpdateTasks: (
tasks: PlainleafTask[],
patch: PlainleafTaskPatch,
) => Promise<void>;
onCreateTask: (input: PlainleafNewTask, page?: string) => Promise<string>;
onCreateSubtask: (
task: PlainleafTask,
input: PlainleafNewTask,
) => Promise<string>;
onExportMarkdown: () => Promise<number>;
onImportMarkdown: (file: File) => Promise<number>;
onDeleteTask: (task: PlainleafTask) => Promise<PlainleafDeletedTaskBlock>;
onRestoreTask: (block: PlainleafDeletedTaskBlock) => Promise<void>;
accountManaged: boolean;
onOpenTask: (task: PlainleafTask) => void;
onOpenAdvancedTool: (tool: PlainleafAdvancedTool) => void;
onEditorAction: (action: PlainleafEditorAction) => void;
onToggleTheme: () => void;
children: ComponentChildren;
}) {
const [developerTools, setDeveloperTools] = useState(readDeveloperTools);
const [showSystemContent, setShowSystemContent] = useState(
() => readDeveloperTools() && 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 [taskList, setTaskList] = useState<PlainleafTaskList>("today");
const [selectedTaskRef, setSelectedTaskRef] = useState<string>();
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [taskError, setTaskError] = useState("");
const [taskNotice, setTaskNotice] = useState("");
const [taskQuery, setTaskQuery] = useState("");
const [taskTagFilter, setTaskTagFilter] = useState("");
const [taskProjectFilter, setTaskProjectFilter] = useState("");
const [taskCalendar, setTaskCalendar] = useState(false);
const [calendarDate, setCalendarDate] = useState(localDateKey());
const [selectedTaskRefs, setSelectedTaskRefs] = useState<Set<string>>(
new Set(),
);
const [taskSelectionMode, setTaskSelectionMode] = useState(false);
const [taskToolsOpen, setTaskToolsOpen] = useState(false);
const [logbookPeriod, setLogbookPeriod] = useState<"week" | "month">("week");
const [undoTask, setUndoTask] = useState<{
task: PlainleafTask;
repeatedTask?: PlainleafTask;
}>();
const [deletedTask, setDeletedTask] = useState<{
tasks: PlainleafTask[];
block: PlainleafDeletedTaskBlock;
}>();
const [installPrompt, setInstallPrompt] =
useState<BeforeInstallPromptEvent>();
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) => visibleNames.has(task.page)),
);
} catch (error) {
console.error("Failed to load Plainleaf tasks", error);
setTasks([]);
} finally {
setTasksLoading(false);
}
};
const completeTask = async (task: PlainleafTask) => {
setTaskError("");
let repeatedTask: PlainleafTask | undefined;
try {
const done = !task.done;
const completed = done ? localDateKey() : undefined;
const repeated = done ? nextRepeatedTask(task) : undefined;
if (repeated) {
const ref = await onCreateTask(repeated, task.page);
repeatedTask = {
ref,
page: task.page,
name: repeated.name,
state: " ",
done: false,
scheduled: repeated.scheduled ?? undefined,
deadline: repeated.deadline ?? undefined,
repeat: repeated.repeat ?? undefined,
reminder: repeated.reminder ?? undefined,
links: repeated.project ? [repeated.project] : undefined,
tags: repeated.tags,
};
}
await onToggleTask(task, {
state: done ? "x" : " ",
completed: completed ?? null,
});
setTasks((previous) =>
previous.map((candidate) =>
candidate.ref === task.ref
? {
...candidate,
state: done ? "x" : " ",
done,
completed,
}
: candidate,
),
);
if (repeatedTask) {
setTasks((previous) => [...previous, repeatedTask!]);
}
setUndoTask(done ? { task, repeatedTask } : undefined);
} catch (error) {
console.error("Failed to complete Plainleaf task", error);
let cleanupFailed = false;
if (repeatedTask) {
try {
await onDeleteTask(repeatedTask);
} catch (cleanupError) {
cleanupFailed = true;
console.error("Failed to roll back repeated task", cleanupError);
}
}
await refreshTasks();
setTaskError(
cleanupFailed
? "完成失败,自动清理未完全成功,请核对重复任务"
: error instanceof Error
? error.message
: "更新待办失败",
);
}
};
useEffect(() => {
if (!undoTask) return;
const timeout = globalThis.setTimeout(() => setUndoTask(undefined), 5000);
return () => globalThis.clearTimeout(timeout);
}, [undoTask]);
useEffect(() => {
if (!deletedTask) return;
const timeout = globalThis.setTimeout(
() => setDeletedTask(undefined),
8000,
);
return () => globalThis.clearTimeout(timeout);
}, [deletedTask]);
useEffect(() => {
if (!taskNotice) return;
const timeout = globalThis.setTimeout(() => setTaskNotice(""), 5000);
return () => globalThis.clearTimeout(timeout);
}, [taskNotice]);
useEffect(() => {
const onInstallPrompt = (event: Event) => {
event.preventDefault();
setInstallPrompt(event as BeforeInstallPromptEvent);
};
const onInstalled = () => setInstallPrompt(undefined);
addEventListener("beforeinstallprompt", onInstallPrompt);
addEventListener("appinstalled", onInstalled);
return () => {
removeEventListener("beforeinstallprompt", onInstallPrompt);
removeEventListener("appinstalled", onInstalled);
};
}, []);
const updateTask = async (task: PlainleafTask, patch: PlainleafTaskPatch) => {
setTaskError("");
try {
await onUpdateTask(task, patch);
setTasks((previous) =>
previous.map((candidate) => {
if (candidate.ref !== task.ref) return candidate;
const next = { ...candidate, ...patch } as PlainleafTask;
if (patch.project !== undefined) {
next.links = patch.project ? [patch.project] : [];
}
return next;
}),
);
} catch (error) {
setTaskError(error instanceof Error ? error.message : "保存待办失败");
throw error;
}
};
const createTask = async (input: PlainleafNewTask) => {
setTaskError("");
try {
const ref = await onCreateTask(input);
setTasks((previous) => [
...previous,
{
ref,
page: plainleafTaskInbox,
name: input.name,
state: " ",
done: false,
scheduled: input.scheduled ?? undefined,
deadline: input.deadline ?? undefined,
repeat: input.repeat ?? undefined,
reminder: input.reminder ?? undefined,
links: input.project ? [input.project] : undefined,
tags: input.tags,
},
]);
setTaskList(input.scheduled === localDateKey() ? "today" : "inbox");
} catch (error) {
setTaskError(error instanceof Error ? error.message : "添加待办失败");
throw error;
}
};
const createSubtask = async (
task: PlainleafTask,
input: PlainleafNewTask,
) => {
const ref = await onCreateSubtask(task, input);
setTasks((previous) => [
...previous,
{
ref,
page: task.page,
parent: task.ref,
name: input.name,
state: " ",
done: false,
scheduled: input.scheduled ?? undefined,
},
]);
};
const deleteTask = async (task: PlainleafTask) => {
try {
const block = await onDeleteTask(task);
const descendants = new Set([task.ref]);
let changed = true;
while (changed) {
changed = false;
for (const candidate of tasks) {
if (
candidate.parent &&
descendants.has(candidate.parent) &&
!descendants.has(candidate.ref)
) {
descendants.add(candidate.ref);
changed = true;
}
}
}
const removed = tasks.filter((candidate) =>
descendants.has(candidate.ref),
);
setTasks((previous) =>
previous.filter((candidate) => !descendants.has(candidate.ref)),
);
setSelectedTaskRef(undefined);
setDeletedTask({ tasks: removed, block });
} catch (error) {
setTaskError(error instanceof Error ? error.message : "删除待办失败");
}
};
const batchUpdate = async (patch: PlainleafTaskPatch) => {
const selected = tasks.filter((task) => selectedTaskRefs.has(task.ref));
if (!selected.length) return;
const repeatedTasks: PlainleafTask[] = [];
try {
if (patch.state === "x") {
for (const task of selected.filter((task) => !task.done)) {
const repeated = nextRepeatedTask(task);
if (!repeated) continue;
const ref = await onCreateTask(repeated, task.page);
repeatedTasks.push({
ref,
page: task.page,
name: repeated.name,
state: " ",
done: false,
scheduled: repeated.scheduled ?? undefined,
deadline: repeated.deadline ?? undefined,
repeat: repeated.repeat ?? undefined,
reminder: repeated.reminder ?? undefined,
links: repeated.project ? [repeated.project] : undefined,
tags: repeated.tags,
});
}
}
await onBatchUpdateTasks(selected, patch);
setTasks((previous) => [
...previous.map((task) =>
selectedTaskRefs.has(task.ref)
? ({
...task,
...patch,
done:
patch.state === "x"
? true
: patch.state === " "
? false
: task.done,
} as PlainleafTask)
: task,
),
...repeatedTasks,
]);
setSelectedTaskRefs(new Set());
setTaskSelectionMode(false);
} catch (error) {
let cleanupFailed = false;
for (const repeatedTask of [...repeatedTasks].reverse()) {
try {
await onDeleteTask(repeatedTask);
} catch (cleanupError) {
cleanupFailed = true;
console.error("Failed to roll back repeated task", cleanupError);
}
}
await refreshTasks();
setTaskError(
cleanupFailed
? "批量更新失败,自动清理未完全成功,请核对重复任务"
: error instanceof Error
? error.message
: "批量更新失败",
);
}
};
useEffect(() => {
if (
!("Notification" in globalThis) ||
Notification.permission !== "granted"
) {
return;
}
const check = () => {
let notified = new Set<string>();
try {
notified = new Set(
JSON.parse(localStorage.getItem("plainleaf.taskReminders") ?? "[]"),
);
} catch {
// A malformed local record should not stop reminder checks.
}
const due = dueReminderTasks(tasks, new Date(), notified);
for (const task of due) {
const key = `${task.ref}:${task.reminder}`;
new Notification(plainleafTaskTitle(task), {
body: `${displayTaskDate(task.scheduled ?? task.deadline) || "待办"} · ${projectForTask(task) ?? task.page}`,
icon: ".client/favicon-96x96.png",
tag: key,
});
notified.add(key);
}
if (due.length) {
localStorage.setItem(
"plainleaf.taskReminders",
JSON.stringify([...notified].slice(-500)),
);
}
};
check();
const timer = globalThis.setInterval(check, 60_000);
return () => globalThis.clearInterval(timer);
}, [tasks]);
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 toggleDeveloperTools = () => {
setDeveloperTools((previous) => {
const next = !previous;
storeDeveloperTools(next);
if (!next && showSystemContent) {
setShowSystemContent(false);
storeShowSystemContent(false);
}
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 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 todayTasks = sortTodayTasks(tasks);
const inboxTasks = tasksForInbox(tasks);
const plannedGroups = groupScheduledTasks(tasks);
const plannedTasks = plannedGroups.flatMap((group) => group.tasks);
const anytimeTasks = tasksForAnytime(tasks);
const projectGroups = groupTasksByProject(tasks);
const projectProgress = projectSummaries(tasks);
const projectTasks = projectGroups.flatMap((group) => group.tasks);
const completedTasks = logbookTasks(tasks);
const taskTags = [...new Set(tasks.flatMap((task) => task.tags ?? []))].sort(
(a, b) => a.localeCompare(b, "zh-CN"),
);
const taskProjects = projectGroups.map((group) => group.project);
const staleProjectNames = staleProjects(tasks);
const healthIssues = taskHealthIssues(tasks);
const taskListCounts: Record<PlainleafTaskList, number> = {
inbox: inboxTasks.length,
today: todayTasks.length,
planned: plannedTasks.length,
anytime: anytimeTasks.length,
projects: projectTasks.length,
logbook: completedTasks.length,
};
const selectedTask = tasks.find((task) => task.ref === selectedTaskRef);
const showingCustomSurface =
currentPage === indexPage || view === "settings" || view === "tasks";
const baseTaskGroups: Array<{
key: string;
label?: string;
tasks: PlainleafTask[];
}> =
taskList === "planned"
? plannedGroups.map((group) => ({
key: group.date,
label: displayTaskDate(group.date),
tasks: group.tasks,
}))
: taskList === "projects"
? projectGroups.map((group) => ({
key: group.project,
label: `${group.project}${staleProjectNames.has(group.project) ? " · 久未更新" : ""}${(() => {
const summary = projectProgress.find(
(item) => item.project === group.project,
);
return summary ? ` · ${summary.completed}/${summary.total}` : "";
})()}`,
tasks: group.tasks,
}))
: taskList === "logbook"
? groupLogbookTasks(tasks, logbookPeriod).map((group) => ({
key: group.period,
label: group.period,
tasks: group.tasks,
}))
: [
{
key: taskList,
tasks:
taskList === "inbox"
? inboxTasks
: taskList === "today"
? todayTasks
: taskList === "anytime"
? anytimeTasks
: completedTasks,
},
];
const taskGroups = baseTaskGroups
.map((group) => ({
...group,
tasks: group.tasks.filter((task) =>
taskMatchesFilters(task, {
query: taskQuery,
tag: taskTagFilter,
project: taskProjectFilter,
}),
),
}))
.filter((group) => group.tasks.length > 0);
const visibleTaskCount = taskGroups.reduce(
(total, group) => total + group.tasks.length,
0,
);
const calendarBase = new Date(`${calendarDate}T12:00:00`);
const calendarDays = calendarMonth(
calendarBase.getFullYear(),
calendarBase.getMonth(),
);
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.filter((task) => !task.done).length} 项</span>
</header>
<div class="pl-task-sidebar-lists">
{(Object.keys(taskListLabels) as PlainleafTaskList[]).map(
(list) => (
<button
key={list}
type="button"
class={taskList === list ? "is-active" : ""}
onClick={() => {
setTaskList(list);
setSelectedTaskRef(undefined);
setMobileOpen(false);
}}
>
{taskListIcon(list)}
<span>{taskListLabels[list]}</span>
<small>{taskListCounts[list]}</small>
</button>
),
)}
</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={() => openView("settings")}>
<Settings size={16} />
<span>设置</span>
</button>
{accountManaged && (
<a href="/.spaces/account">
<User size={16} />
<span>我的账号</span>
</a>
)}
</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>
<div class="pl-setting-row">
<span>
<strong>文件同步</strong>
<small>{isOnline ? "当前空间已连接" : "离线工作中"}</small>
</span>
<Cloud size={15} />
</div>
</section>
{developerTools && (
<section class="pl-settings-section">
<h2>开发者选项已开启</h2>
<div class="pl-setting-row">
<span>
<strong>系统内容</strong>
<small>{showSystemContent ? "当前显示" : "当前隐藏"}</small>
</span>
<Sliders size={15} />
</div>
</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}
{currentPage && currentPage !== indexPage && (
<MarkdownToolbar readOnly={isReadOnly} onAction={onEditorAction} />
)}
</div>
{currentPage === indexPage &&
view !== "settings" &&
view !== "tasks" && (
<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={() => openView("tasks")}>
查看全部
</button>
</header>
{todayTasks.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 && todayTasks.length === 0 && (
<p class="pl-home-empty">今天没有未完成待办</p>
)}
</section>
</div>
</div>
</section>
)}
{view === "tasks" && (
<section class="pl-task-surface">
<div class="pl-task-content">
<header class="pl-task-content-header">
<button
type="button"
class="pl-task-mobile-lists"
aria-label="切换待办清单"
onClick={() => setMobileOpen(true)}
>
<Menu size={19} />
</button>
<span class={`pl-task-title-icon is-${taskList}`}>
{taskListIcon(taskList)}
</span>
<div>
<h1>{taskListLabels[taskList]}</h1>
<p>{taskListCounts[taskList]} 项</p>
</div>
<button
type="button"
class="pl-task-add-button"
disabled={isReadOnly}
onClick={() => setNewTaskOpen(true)}
>
<Plus size={17} />
<span>添加待办</span>
</button>
</header>
<div class="pl-task-toolbar">
<label class="pl-task-toolbar-search">
<Search size={14} />
<input
value={taskQuery}
placeholder="搜索待办"
onInput={(event) => setTaskQuery(event.currentTarget.value)}
/>
{taskQuery && (
<button
type="button"
aria-label="清空待办搜索"
onClick={() => setTaskQuery("")}
>
<X size={13} />
</button>
)}
</label>
<select
aria-label="按标签筛选"
value={taskTagFilter}
onChange={(event) =>
setTaskTagFilter(event.currentTarget.value)
}
>
<option value="">全部标签</option>
{taskTags.map((tag) => (
<option key={tag} value={tag}>
#{tag}
</option>
))}
</select>
<select
aria-label="按项目筛选"
value={taskProjectFilter}
onChange={(event) =>
setTaskProjectFilter(event.currentTarget.value)
}
>
<option value="">全部项目</option>
{taskProjects.map((project) => (
<option key={project} value={project}>
{project}
</option>
))}
</select>
{taskList === "planned" && (
<button
type="button"
class={taskCalendar ? "is-active" : ""}
aria-label={taskCalendar ? "切换到列表" : "切换到月历"}
onClick={() => setTaskCalendar((value) => !value)}
>
<Grid size={15} />
<span>{taskCalendar ? "列表" : "月历"}</span>
</button>
)}
{taskList === "logbook" && (
<button
type="button"
onClick={() =>
setLogbookPeriod((value) =>
value === "week" ? "month" : "week",
)
}
>
<Calendar size={15} />
<span>{logbookPeriod === "week" ? "按月" : "按周"}</span>
</button>
)}
<button
type="button"
class={taskSelectionMode ? "is-active" : ""}
onClick={() => {
setTaskSelectionMode((value) => !value);
setSelectedTaskRefs(new Set());
}}
>
<CheckSquare size={15} />
<span>{taskSelectionMode ? "取消选择" : "选择"}</span>
</button>
<button
type="button"
onClick={() => setTaskToolsOpen((value) => !value)}
>
<Sliders size={15} />
<span>工具</span>
</button>
</div>
{taskSelectionMode && (
<div class="pl-task-batch-bar">
<strong>{selectedTaskRefs.size} 项已选</strong>
<button
type="button"
disabled={!selectedTaskRefs.size}
onClick={() =>
void batchUpdate({ scheduled: localDateKey() })
}
>
安排到今天
</button>
<button
type="button"
disabled={!selectedTaskRefs.size}
onClick={() =>
void batchUpdate({ scheduled: null, deadline: null })
}
>
清除日期
</button>
<button
type="button"
disabled={!selectedTaskRefs.size}
onClick={() =>
void batchUpdate({
state: "x",
completed: localDateKey(),
})
}
>
标记完成
</button>
</div>
)}
{taskToolsOpen && (
<section class="pl-task-tools-panel">
<button
type="button"
onClick={() => {
if (!("Notification" in globalThis)) {
setTaskError("当前浏览器不支持通知");
return;
}
void Notification.requestPermission().then(
(permission) => {
if (permission !== "granted")
setTaskError("通知权限未开启");
},
);
}}
>
<Bell size={16} />
<span>
<strong>开启提醒</strong>
<small>应用打开或已安装时检查到期提醒</small>
</span>
</button>
<button
type="button"
onClick={() => {
setTaskError("");
void onExportMarkdown()
.then((count) =>
setTaskNotice(`已导出 ${count} 个 Markdown 文件`),
)
.catch((error) =>
setTaskError(
error instanceof Error ? error.message : "备份失败",
),
);
}}
>
<Download size={16} />
<span>
<strong>备份 Markdown</strong>
<small>下载全部笔记文件的 JSON 备份包</small>
</span>
</button>
<label class="pl-task-import-tool">
<Download size={16} />
<span>
<strong>恢复 Markdown</strong>
<small>从 Plainleaf 备份包写回页面,不删除其他文件</small>
</span>
<input
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (!file) return;
void onImportMarkdown(file)
.then(async (count) => {
if (!count) return;
await refreshTasks();
setTaskNotice(`已恢复 ${count} 个 Markdown 文件`);
})
.catch((error) =>
setTaskError(
error instanceof Error
? error.message
: "恢复失败",
),
);
event.currentTarget.value = "";
}}
/>
</label>
<details class="pl-task-health">
<summary>
<CheckCircle size={16} />
<span>
<strong>数据检查</strong>
<small>
{healthIssues.length
? `发现 ${healthIssues.length} 个问题`
: "任务数据结构正常"}
</small>
</span>
{healthIssues.length > 0 && <ChevronDown size={14} />}
</summary>
{healthIssues.length > 0 && (
<div>
{healthIssues.map((issue, index) => {
const task = tasks.find(
(candidate) => candidate.ref === issue.ref,
);
return (
<button
type="button"
key={`${issue.ref}:${issue.type}:${index}`}
disabled={!task}
onClick={() => task && onOpenTask(task)}
>
<strong>
{task ? plainleafTaskTitle(task) : issue.ref}
</strong>
<small>{issue.message}</small>
</button>
);
})}
</div>
)}
</details>
{accountManaged ? (
<a href="/.spaces/account">
<User size={16} />
<span>
<strong>我的账号</strong>
<small>修改密码与管理 API 令牌</small>
</span>
</a>
) : (
<div aria-disabled="true">
<User size={16} />
<span>
<strong>账号管理</strong>
<small>部署切换到多用户模式后启用</small>
</span>
</div>
)}
</section>
)}
<div class="pl-task-scroll">
{tasksLoading && (
<div class="pl-task-loading">正在整理待办...</div>
)}
{!tasksLoading && taskList === "planned" && taskCalendar && (
<section class="pl-task-calendar">
<header>
<button
type="button"
aria-label="上个月"
onClick={() => {
const date = new Date(`${calendarDate}T12:00:00`);
date.setMonth(date.getMonth() - 1, 1);
setCalendarDate(localDateKey(date));
}}
>
<ChevronDown size={16} />
</button>
<strong>
{calendarBase.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
})}
</strong>
<button
type="button"
aria-label="下个月"
onClick={() => {
const date = new Date(`${calendarDate}T12:00:00`);
date.setMonth(date.getMonth() + 1, 1);
setCalendarDate(localDateKey(date));
}}
>
<ChevronDown size={16} />
</button>
</header>
<div class="pl-task-calendar-weekdays">
{["日", "一", "二", "三", "四", "五", "六"].map((day) => (
<span key={day}>{day}</span>
))}
</div>
<div class="pl-task-calendar-grid">
{calendarDays.map((day) => {
const dayTasks = tasksForCalendarDate(tasks, day.date);
return (
<button
type="button"
key={day.date}
class={`${day.inMonth ? "" : "is-outside"} ${day.date === calendarDate ? "is-selected" : ""}`}
onClick={() => setCalendarDate(day.date)}
>
<span>{Number(day.date.slice(-2))}</span>
{dayTasks.length > 0 && (
<small>{dayTasks.length}</small>
)}
</button>
);
})}
</div>
<section class="pl-task-calendar-day">
<h2>{displayTaskDate(calendarDate)}</h2>
{tasksForCalendarDate(tasks, calendarDate).map((task) => (
<TaskRow
key={task.ref}
task={task}
depth={taskDepth(task, tasks)}
selected={selectedTaskRef === task.ref}
selectionMode={taskSelectionMode}
checked={selectedTaskRefs.has(task.ref)}
readOnly={isReadOnly}
progress={subtaskProgress(task, tasks)}
onSelect={() => setSelectedTaskRef(task.ref)}
onToggleSelection={() =>
setSelectedTaskRefs((previous) => {
const next = new Set(previous);
if (next.has(task.ref)) next.delete(task.ref);
else next.add(task.ref);
return next;
})
}
onComplete={() => void completeTask(task)}
/>
))}
</section>
</section>
)}
{!tasksLoading &&
!(taskList === "planned" && taskCalendar) &&
taskGroups.map((group) => (
<section class="pl-task-group" key={group.key}>
{group.label && (
<header>
<h2>{group.label}</h2>
<span>{group.tasks.length}</span>
</header>
)}
{group.tasks.map((task) => (
<TaskRow
key={task.ref}
task={task}
depth={taskDepth(task, tasks)}
selected={selectedTaskRef === task.ref}
selectionMode={taskSelectionMode}
checked={selectedTaskRefs.has(task.ref)}
readOnly={isReadOnly}
progress={subtaskProgress(task, tasks)}
onSelect={() => setSelectedTaskRef(task.ref)}
onToggleSelection={() =>
setSelectedTaskRefs((previous) => {
const next = new Set(previous);
if (next.has(task.ref)) next.delete(task.ref);
else next.add(task.ref);
return next;
})
}
onComplete={() => void completeTask(task)}
/>
))}
</section>
))}
{!tasksLoading && visibleTaskCount === 0 && (
<div class="pl-task-empty">
<span>{taskListIcon(taskList)}</span>
<strong>
{taskList === "today"
? "今天没有待办"
: taskList === "logbook"
? "还没有完成记录"
: "这里还是空的"}
</strong>
<p>
{taskList === "inbox"
? "先把想到的事情记下来,稍后再安排。"
: "新的 Markdown 待办会自动整理到合适的清单。"}
</p>
</div>
)}
</div>
<button
type="button"
class="pl-task-mobile-add"
disabled={isReadOnly}
aria-label="添加待办"
onClick={() => setNewTaskOpen(true)}
>
<Plus size={22} />
</button>
</div>
{selectedTask && (
<TaskEditor
key={selectedTask.ref}
task={selectedTask}
readOnly={isReadOnly}
onClose={() => setSelectedTaskRef(undefined)}
onSave={(patch) => updateTask(selectedTask, patch)}
onAddSubtask={(input) => createSubtask(selectedTask, input)}
onDelete={() => deleteTask(selectedTask)}
onOpenSource={() => {
onOpenTask(selectedTask);
setSelectedTaskRef(undefined);
}}
/>
)}
</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 pl-settings-writing-note">
<span>
<strong>Markdown 编辑器</strong>
<small>
已启用格式工具栏、实时渲染、双向链接和自动保存
</small>
</span>
<span class="pl-status-label">已启用</span>
</div>
<div class="pl-settings-main-row">
<span>
<strong>界面主题</strong>
<small>当前为{darkMode ? "深色" : "明亮"}界面</small>
</span>
<button type="button" onClick={onToggleTheme}>
切换为{darkMode ? "明亮" : "深色"}
</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>
{installPrompt && (
<div class="pl-settings-main-row">
<span>
<strong>安装到设备</strong>
<small>作为独立应用打开,手机端使用更接近原生 App</small>
</span>
<button
type="button"
onClick={() => {
void installPrompt
.prompt()
.then(() => installPrompt.userChoice)
.then(() => setInstallPrompt(undefined));
}}
>
安装
</button>
</div>
)}
</section>
{accountManaged && (
<section>
<h2>账号与空间</h2>
<div class="pl-settings-main-row">
<span>
<strong>我的账号</strong>
<small>修改密码与管理 API 令牌</small>
</span>
<a class="pl-settings-link" href="/.spaces/account">
打开
</a>
</div>
</section>
)}
<section>
<h2>开发者选项</h2>
<div class="pl-settings-main-row">
<span>
<strong>启用开发者选项</strong>
<small>命令、系统页面和社区扩展仅适合高级用户</small>
</span>
<button
type="button"
class="pl-switch-button"
role="switch"
aria-checked={developerTools}
onClick={toggleDeveloperTools}
>
<span>{developerTools ? "开启" : "关闭"}</span>
<span
class={`pl-switch ${developerTools ? "is-on" : ""}`}
aria-hidden="true"
>
<span />
</span>
</button>
</div>
{developerTools && (
<>
<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>
<div class="pl-developer-warning">
社区扩展从 GitHub 下载,不属于 Plainleaf
核心功能。安装前请确认来源可信。
</div>
<AdvancedTools onOpen={onOpenAdvancedTool} />
</>
)}
</section>
</div>
</section>
)}
</main>
{undoTask && (
<div class="pl-task-undo" role="status">
<span>已完成“{undoTask.task.name}”</span>
<button
type="button"
onClick={() => {
const undo = undoTask;
setUndoTask(undefined);
void (async () => {
let repeatedBlock: PlainleafDeletedTaskBlock | undefined;
try {
if (undo.repeatedTask) {
repeatedBlock = await onDeleteTask(undo.repeatedTask);
}
await onToggleTask(undo.task, {
state: " ",
completed: null,
});
setTasks((previous) =>
previous
.filter(
(candidate) => candidate.ref !== undo.repeatedTask?.ref,
)
.map((candidate) =>
candidate.ref === undo.task.ref
? {
...candidate,
state: " ",
done: false,
completed: undefined,
}
: candidate,
),
);
} catch (error) {
if (repeatedBlock) {
try {
await onRestoreTask(repeatedBlock);
} catch (restoreError) {
console.error(
"Failed to restore repeated task after undo failure",
restoreError,
);
}
}
await refreshTasks();
setTaskError(
error instanceof Error ? error.message : "撤销完成失败",
);
}
})();
}}
>
撤销
</button>
</div>
)}
{deletedTask && (
<div class="pl-task-undo" role="status">
<span>已删除“{plainleafTaskTitle(deletedTask.tasks[0])}”</span>
<button
type="button"
onClick={() => {
void onRestoreTask(deletedTask.block)
.then(() => {
setTasks((previous) => [...previous, ...deletedTask.tasks]);
setDeletedTask(undefined);
})
.catch((error) =>
setTaskError(
error instanceof Error ? error.message : "恢复待办失败",
),
);
}}
>
撤销
</button>
</div>
)}
{taskNotice && (
<div class="pl-task-undo pl-task-notice" role="status">
<span>{taskNotice}</span>
</div>
)}
{taskError && (
<div class="pl-task-error" role="alert">
<span>{taskError}</span>
<button
type="button"
aria-label="关闭错误"
onClick={() => setTaskError("")}
>
<X size={15} />
</button>
</div>
)}
{newTaskOpen && (
<NewTaskDialog
readOnly={isReadOnly}
onClose={() => setNewTaskOpen(false)}
onCreate={createTask}
/>
)}
{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>
);
}