feat: build quiet Plainleaf notebook workspace

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-12 16:52:01 +08:00
co-authored by Codex
parent 81799e714a
commit b297fa797a
13 changed files with 6010 additions and 575 deletions
+64 -1
View File
@@ -1,6 +1,12 @@
import { expect, test } from "vitest";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { buildPageTree } from "./plainleaf_workspace.tsx";
import {
buildPageTree,
collectPlainleafTags,
plainleafPageLabel,
plainleafTaskReference,
visiblePlainleafPages,
} from "./plainleaf_workspace.tsx";
function page(name: string, extra: Partial<PageMeta> = {}): PageMeta {
return {
@@ -69,3 +75,60 @@ test("omits aspiring and decorated hidden pages", () => {
expect(tree).toMatchObject([{ kind: "page", name: "Visible" }]);
});
test("hides SilverBullet system roots and meta-tagged pages", () => {
const pages = [
page("Notes/Visible"),
page("Library/Std"),
page("Repositories/Std"),
page("CONFIG"),
page("Internal", { tags: ["meta/library"] }),
];
expect(visiblePlainleafPages(pages).map((item) => item.name)).toEqual([
"Notes/Visible",
]);
});
test("uses a friendly home label without changing the stored page name", () => {
expect(plainleafPageLabel("index", "index")).toBe("首页");
expect(plainleafPageLabel("项目/计划", "index")).toBe("计划");
const tree = buildPageTree([page("index")], "index");
expect(tree).toMatchObject([{ kind: "page", name: "index", label: "首页" }]);
});
test("counts user tags and excludes internal tags", () => {
const tags = collectPlainleafTags([
page("A", { tags: ["产品"] }),
page("B", { tags: ["产品", "灵感"] }),
page("C", { tags: ["template"] }),
page("D", { tags: ["产品", "meta/library"] }),
]);
expect(tags).toEqual([
{ name: "产品", count: 2 },
{ name: "灵感", count: 1 },
]);
});
test("qualifies anchor-backed task refs with their source page", () => {
expect(
plainleafTaskReference({
ref: "follow-up",
page: "项目/计划",
name: "跟进",
state: " ",
done: false,
}),
).toBe("项目/计划$follow-up");
expect(
plainleafTaskReference({
ref: "项目/计划@42",
page: "项目/计划",
name: "跟进",
state: " ",
done: false,
}),
).toBe("项目/计划@42");
});
File diff suppressed because it is too large Load Diff
+203 -13
View File
@@ -3,7 +3,16 @@ import { CommandPalette, keyboardHint } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { AnythingPicker } from "./components/anything_picker.tsx";
import { TopBar } from "./components/top_bar.tsx";
import { PlainleafWorkspace } from "./components/plainleaf_workspace.tsx";
import {
PlainleafWorkspace,
plainleafPageLabel,
plainleafTaskReference,
visiblePlainleafPages,
type PlainleafAdvancedTool,
type PlainleafSearchResult,
type PlainleafSearchScope,
type PlainleafTask,
} from "./components/plainleaf_workspace.tsx";
import reducer from "./reducer.ts";
import {
type Action,
@@ -34,9 +43,14 @@ import {
type Path,
} from "@silverbulletmd/silverbullet/lib/ref";
import { actionDescriptionZhCN, pageTitleZhCN } from "./lib/zh_cn.ts";
import { parsePageMetaLastModified } from "./lib/page_meta.ts";
export class MainUI {
viewState: AppViewState = initialViewState;
private plainleafPageTextCache = new Map<
string,
{ lastModified: string; text: string }
>();
constructor(private client: Client) {
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
@@ -329,6 +343,14 @@ export class MainUI {
"actionButtons",
[],
);
const plainleafActionButtons = actionButtons.filter(
(button) =>
![
"Navigate: Home",
"Navigate: Page Picker",
"Open Command Palette",
].includes(button.command ?? ""),
);
return (
<>
{viewState.showPageNavigator && (
@@ -489,21 +511,143 @@ export class MainUI {
? getNameFromPath(viewState.current.path)
: undefined
}
indexPage={client.bootConfig.indexPage}
isOnline={viewState.isOnline}
isReadOnly={
viewState.uiOptions.forcedROMode || client.bootConfig.readOnly
}
onOpenPage={(name) => {
const ref = parseToRef(name);
if (ref) void client.open(ref);
}}
onOpenSearch={() => client.startPageNavigate("page")}
onCreatePage={() => client.startPageNavigate("page")}
onOpenTags={() => {
safeRun(() => client.runCommandByName("Navigate: Tag Picker"));
onCreatePage={async (name) => {
if (!isValidName(name)) {
throw new Error("标题中包含不能用于笔记路径的字符");
}
const existing = viewState.allPages.find(
(page) => page.name === name && !page._isAspiring,
);
if (existing) {
const ref = parseToRef(existing.name);
if (ref) await client.open(ref);
return;
}
await client.space.writePage(name, "");
await client.updatePageListCache();
const ref = parseToRef(name);
if (ref) await client.open(ref);
}}
onOpenGraph={() => {
safeRun(() => client.runCommandByName("Graph: Global Page Map"));
onOpenDaily={() => {
safeRun(() => client.runCommandByName("Journal: Today"));
}}
onOpenCommands={() => {
void client.startCommandPalette();
onSearchPages={async (
query: string,
scope: PlainleafSearchScope,
): Promise<PlainleafSearchResult[]> => {
const normalized = query.toLocaleLowerCase("zh-CN");
const visiblePages = visiblePlainleafPages(viewState.allPages);
const results = new Map<string, PlainleafSearchResult>();
if (scope !== "content") {
for (const page of visiblePages) {
const haystack = [
page.name,
page.displayName,
page.description,
...(page.aliases ?? []),
...(page.tags ?? []),
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase("zh-CN");
if (!haystack.includes(normalized)) continue;
results.set(page.name, {
page: page.name,
title: plainleafPageLabel(
page.name,
client.bootConfig.indexPage,
),
path: page.name,
excerpt: page.description ?? "",
updated: formatPlainleafUpdated(page),
});
}
}
if (scope !== "title") {
const candidates = visiblePages.filter(
(page) => !results.has(page.name),
);
let nextIndex = 0;
const workerCount = Math.min(6, candidates.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < candidates.length) {
const page = candidates[nextIndex++];
const cached = this.plainleafPageTextCache.get(page.name);
const text =
cached?.lastModified === page.lastModified
? cached.text
: (await client.space.readPage(page.name)).text;
if (cached?.lastModified !== page.lastModified) {
this.plainleafPageTextCache.set(page.name, {
lastModified: page.lastModified,
text,
});
}
const matchIndex = text
.toLocaleLowerCase("zh-CN")
.indexOf(normalized);
if (matchIndex === -1) continue;
results.set(page.name, {
page: page.name,
title: plainleafPageLabel(
page.name,
client.bootConfig.indexPage,
),
path: page.name,
excerpt: plainleafSearchExcerpt(text, matchIndex),
updated: formatPlainleafUpdated(page),
});
}
}),
);
}
return [...results.values()].slice(0, 60);
}}
onLoadTasks={() => client.queryLuaObjects<PlainleafTask>("task", {})}
onToggleTask={async (task) => {
await client.clientSystem.system.invokeFunction(
"index.updateTaskState",
[plainleafTaskReference(task), task.state, "x"],
);
}}
onOpenTask={(task) => {
const ref =
parseToRef(plainleafTaskReference(task)) ?? parseToRef(task.page);
if (ref) void client.open(ref);
}}
onOpenAdvancedTool={(tool: PlainleafAdvancedTool) => {
switch (tool) {
case "commands":
void client.startCommandPalette();
break;
case "graph":
safeRun(() =>
client.runCommandByName("Graph: Global Page Map"),
);
break;
case "libraries":
safeRun(() => client.runCommandByName("Libraries: Manager"));
break;
case "configuration":
safeRun(() => client.runCommandByName("Configuration: Open"));
break;
case "system-pages":
client.startPageNavigate("meta");
break;
}
}}
onToggleTheme={() => {
safeRun(() => client.runCommandByName("Editor: Toggle Dark Mode"));
@@ -511,7 +655,14 @@ export class MainUI {
>
<TopBar
pageName={
!viewState.current ? "" : getNameFromPath(viewState.current.path)
!viewState.current
? ""
: isMarkdownPath(viewState.current.path)
? plainleafPageLabel(
getNameFromPath(viewState.current.path),
client.bootConfig.indexPage,
)
: getNameFromPath(viewState.current.path)
}
notifications={viewState.notifications}
onDismissNotification={(id) => {
@@ -541,9 +692,15 @@ export class MainUI {
return;
}
console.log("Now renaming page to...", newName);
const currentName = getNameFromPath(client.currentPath());
const currentFolder = currentName.split("/").slice(0, -1);
const targetName =
newName.includes("/") || currentFolder.length === 0
? newName
: [...currentFolder, newName].join("/");
await client.clientSystem.system.invokeFunction(
"index.renamePageCommand",
[{ page: newName }],
[{ page: targetName }],
);
client.focus();
}
@@ -569,7 +726,7 @@ export class MainUI {
]
: []),
// Custom action buttons
...actionButtons
...plainleafActionButtons
.filter(
(
// Filter out buttons without icons (invalid) and mobile buttons when not in mobile mode
@@ -584,7 +741,8 @@ export class MainUI {
// Then ensure all buttons have a priority set (by default based on array index)
.map((button, index) => ({
...button,
priority: button.priority ?? actionButtons.length - index,
priority:
button.priority ?? plainleafActionButtons.length - index,
}))
.sort((a, b) => b.priority - a.priority)
.map((button) => {
@@ -757,6 +915,38 @@ type ActionButton = {
run?: () => void;
};
function formatPlainleafUpdated(page: {
lastOpened?: number;
lastModified: string;
}): string {
const timestamp =
page.lastOpened ?? parsePageMetaLastModified(page.lastModified);
if (!timestamp) return "";
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
});
}
function plainleafSearchExcerpt(text: string, matchIndex: number): string {
const start = Math.max(0, matchIndex - 55);
const end = Math.min(text.length, matchIndex + 105);
const excerpt = text
.slice(start, end)
.replaceAll(/[`*_#>[\]{}]/g, "")
.replaceAll(/\s+/g, " ")
.trim();
return `${start > 0 ? "..." : ""}${excerpt}${end < text.length ? "..." : ""}`;
}
function kebabToCamel(str: string) {
return str
.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
File diff suppressed because it is too large Load Diff
+796
View File
@@ -0,0 +1,796 @@
const notes = [
{
id: "plainleaf-plan",
title: "Plainleaf 改造计划",
path: "项目/Plainleaf/Plainleaf 改造计划",
updated: "今天 10:42",
favorite: true,
tags: ["产品", "设计", "笔记"],
excerpt: "保留普通 Markdown 文件,同时把搜索、整理和移动记录体验做得更自然。",
content: `
<p>Plainleaf 会保留 SilverBullet 最重要的基础:每一篇笔记仍然是 NAS 中可以直接打开的 Markdown 文件。</p>
<blockquote>目标不是复制 Obsidian,而是让记录、查找和整理变得自然,同时保留查询与自动化能力。</blockquote>
<h2>第一阶段目标</h2>
<ul>
<li>把文件、搜索、待办和标签放进同一个稳定侧栏。</li>
<li>保留现有 <code>CodeMirror 6</code> 编辑器和 Markdown 能力。</li>
<li>让普通用户不需要理解命令、元页面和内部标签。</li>
<li>为手机设计专用的随手记和查找流程。</li>
</ul>
<h2>数据原则</h2>
<p>界面只是工作层,文件才是长期资产。即使以后停止使用 Plainleaf,也可以通过 <span class="wiki-link">Obsidian</span>、VS Code 或普通文本编辑器继续使用全部内容。</p>
<h2>下一步</h2>
<p>交互 Demo 确认后,再将新工作区接到现有页面索引和文件操作上。</p>
`,
},
{
id: "zspace",
title: "极空间部署",
path: "知识库/Docker/极空间部署",
updated: "昨天 22:16",
favorite: true,
tags: ["Docker", "NAS", "部署"],
excerpt: "极空间负责运行正式环境,Mac 用于开发和构建镜像。",
content: `
<p>极空间负责运行正式环境,Mac 用于开发和构建镜像。笔记目录通过卷挂载到容器的 <code>/space</code>。</p>
<h2>部署结构</h2>
<ul><li>固定镜像版本,避免使用 latest。</li><li>笔记目录和应用配置分别备份。</li><li>公网访问统一经过 HTTPS 与反向代理。</li></ul>
<h2>更新流程</h2>
<p>在本地完成构建和验证后,推送自定义镜像,再由极空间 Compose 切换版本。</p>
`,
},
{
id: "daily",
title: "2026-08-12",
displayTitle: "今天的日记",
path: "日记/2026/08/2026-08-12",
updated: "今天 08:30",
favorite: false,
tags: ["日记"],
excerpt: "今天继续整理个人笔记系统,希望数据保持开放,同时让 Web 端变得更易用。",
content: `
<p>今天继续整理个人笔记系统,希望数据保持开放,同时让 Web 端变得更易用。</p>
<h2>今日事项</h2>
<ul><li>确认 Plainleaf 的工作区方向。</li><li>完成桌面与手机交互 Demo。</li><li>整理正式开发任务。</li></ul>
<h2>记录</h2>
<p>文件树应该承担定位,而不是让用户记住所有命令。高级能力可以继续通过高级工具进入。</p>
`,
},
{
id: "docker-network",
title: "Docker 容器网络",
path: "知识库/Docker/Docker 容器网络",
updated: "8 月 10 日",
favorite: false,
tags: ["Docker", "网络"],
excerpt: "同一个 Compose 项目中的服务应通过服务名通信。",
content: `
<p>同一个 Compose 项目中的服务应通过服务名通信,而不是依赖宿主机端口或孤立容器名称。</p>
<h2>服务发现</h2>
<p>容器加入同一网络后,可以使用 <code>service:port</code> 作为内部地址。</p>
<h2>排查顺序</h2>
<ul><li>确认两个服务属于同一 Docker 网络。</li><li>检查服务名能否解析。</li><li>检查目标端口是否监听在 0.0.0.0。</li></ul>
`,
},
{
id: "tool-review",
title: "笔记工具对比",
path: "收件箱/笔记工具对比",
updated: "8 月 11 日",
favorite: false,
tags: ["调研", "笔记"],
excerpt: "对比 Blossom、Blinko、Notesnook、SilverBullet 和其他文件原生笔记工具。",
content: `
<p>对比了 Blossom、Blinko、Notesnook、SilverBullet 和其他文件原生笔记工具。</p>
<h2>核心结论</h2>
<p>数据库优先的产品通常拥有更现代的成品体验,但文件原生模型更符合长期迁移与多工具使用的要求。</p>
<h2>选择</h2>
<p>以 SilverBullet 为文件与能力内核,在其上构建 Plainleaf 的现代工作区。</p>
`,
},
{
id: "ideas",
title: "产品灵感收件箱",
path: "收件箱/产品灵感收件箱",
updated: "8 月 9 日",
favorite: false,
tags: ["灵感", "产品"],
excerpt: "临时想法先进入收件箱,稍后再归档到项目、知识库或日记。",
content: `
<p>临时想法先进入收件箱,稍后再归档到项目、知识库或日记。</p>
<h2>待整理</h2>
<ul><li>浏览器剪藏入口。</li><li>手机语音快速记录。</li><li>Markdown 文件历史对比。</li></ul>
`,
},
];
const tree = [
{ type: "folder", name: "收件箱", children: ["tool-review", "ideas"] },
{ type: "folder", name: "项目", children: [{ type: "folder", name: "Plainleaf", children: ["plainleaf-plan"] }] },
{ type: "folder", name: "知识库", children: [{ type: "folder", name: "Docker", children: ["zspace", "docker-network"] }] },
{ type: "folder", name: "日记", children: [{ type: "folder", name: "2026", children: [{ type: "folder", name: "08", children: ["daily"] }] }] },
];
const tasks = [
{ id: "task-demo", title: "完成桌面与手机交互 Demo", source: "Plainleaf 改造计划", noteId: "plainleaf-plan", group: "today" },
{ id: "task-backup", title: "确认笔记目录的备份策略", source: "极空间部署", noteId: "zspace", group: "today" },
{ id: "task-mobile", title: "整理手机端随手记流程", source: "2026-08-12", noteId: "daily", group: "today" },
{ id: "task-network", title: "记录反向代理网络结构", source: "Docker 容器网络", noteId: "docker-network", group: "overdue" },
];
const state = {
view: "notes",
currentNoteId: null,
settings: false,
searchQuery: "Plainleaf",
searchScope: "all",
selectedTag: "产品",
readMode: false,
offline: false,
expandedFolders: new Set(["收件箱", "项目", "项目/Plainleaf", "知识库", "知识库/Docker", "日记"]),
completedTasks: new Set(),
};
const byId = (id) => document.getElementById(id);
const currentNote = () => notes.find((note) => note.id === state.currentNoteId);
function icon(name, className = "") {
return `<i data-lucide="${name}"${className ? ` class="${className}"` : ""}></i>`;
}
function refreshIcons() {
if (window.lucide) window.lucide.createIcons();
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function noteName(note) {
return note.displayTitle || note.title;
}
function renderTreeNode(node, depth = 0, parentPath = "") {
if (typeof node === "string") {
const note = notes.find((item) => item.id === node);
return `
<button class="tree-row ${state.currentNoteId === note.id ? "is-active" : ""}" type="button" data-open-note="${note.id}" style="padding-left:${8 + depth * 14}px">
${icon("chevron-right", "disclosure placeholder")}
${icon("file-text")}
<span class="row-label">${escapeHtml(noteName(note))}</span>
</button>`;
}
const path = parentPath ? `${parentPath}/${node.name}` : node.name;
const expanded = state.expandedFolders.has(path);
return `
<button class="tree-row" type="button" data-folder="${escapeHtml(path)}" style="padding-left:${8 + depth * 14}px">
${icon(expanded ? "chevron-down" : "chevron-right", "disclosure")}
${icon(expanded ? "folder-open" : "folder")}
<span class="row-label">${escapeHtml(node.name)}</span>
</button>
<div class="tree-children ${expanded ? "" : "is-collapsed"}">
${node.children.map((child) => renderTreeNode(child, depth + 1, path)).join("")}
</div>`;
}
function notesSidebar() {
return `
<section class="side-section">
<div class="sidebar-heading"><strong>你的笔记</strong><button type="button" data-sidebar-action="collapse" title="折叠全部" aria-label="折叠全部">${icon("list-collapse")}</button></div>
<button class="side-row ${state.currentNoteId === null ? "is-active" : ""}" type="button" data-open-home>
${icon("house")}<span class="row-label">首页</span>
</button>
<button class="side-row" type="button" data-sidebar-action="recent">
${icon("clock-3")}<span class="row-label">最近编辑</span><span class="row-meta">6</span>
</button>
<button class="side-row" type="button" data-sidebar-action="favorites">
${icon("star")}<span class="row-label">收藏</span><span class="row-meta">2</span>
</button>
</section>
<section class="side-section">
<div class="sidebar-heading"><span>文件夹</span><button type="button" data-new-folder title="新建文件夹" aria-label="新建文件夹">${icon("folder-plus")}</button></div>
${tree.map((node) => renderTreeNode(node)).join("")}
</section>`;
}
function searchableText(note, scope) {
if (scope === "title") return note.title;
if (scope === "content") return `${note.excerpt} ${note.content.replace(/<[^>]+>/g, " ")}`;
return `${note.title} ${note.path} ${note.tags.join(" ")} ${note.excerpt} ${note.content.replace(/<[^>]+>/g, " ")}`;
}
function highlight(value, query) {
const safe = escapeHtml(value);
const trimmed = query.trim();
if (!trimmed) return safe;
const escapedQuery = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return safe.replace(new RegExp(`(${escapedQuery})`, "ig"), "<mark>$1</mark>");
}
function filteredNotes() {
const query = state.searchQuery.trim().toLocaleLowerCase("zh-CN");
if (!query) return [...notes];
return notes.filter((note) => searchableText(note, state.searchScope).toLocaleLowerCase("zh-CN").includes(query));
}
function searchResult(note) {
return `
<button class="search-result-row ${state.currentNoteId === note.id ? "is-active" : ""}" type="button" data-open-note="${note.id}">
<span class="search-result-title"><strong>${highlight(noteName(note), state.searchQuery)}</strong><time>${escapeHtml(note.updated)}</time></span>
<p>${highlight(note.excerpt, state.searchQuery)}</p>
<span class="result-path">${escapeHtml(note.path)}</span>
</button>`;
}
function searchSidebar() {
const results = filteredNotes();
const resultContent = results.length
? results.map(searchResult).join("")
: `<div class="empty-results">
<span class="empty-symbol">${icon("file-search")}</span>
<strong>没有找到“${escapeHtml(state.searchQuery)}”</strong>
<p>可以换个关键词,或直接创建一篇同名笔记。</p>
<button class="small-primary-button" type="button" data-create-from-search>${icon("plus")}新建笔记</button>
</div>`;
return `
<div class="sidebar-heading"><strong>搜索笔记</strong><span class="row-meta">${results.length} 条</span></div>
<div class="search-box">
${icon("search")}
<input id="sidebarSearchInput" type="search" value="${escapeHtml(state.searchQuery)}" placeholder="输入标题或正文" autocomplete="off" />
${state.searchQuery ? `<button class="clear-search" type="button" data-clear-search aria-label="清空搜索">${icon("x")}</button>` : ""}
</div>
<div class="scope-control" aria-label="搜索范围">
<button class="${state.searchScope === "all" ? "is-active" : ""}" type="button" data-search-scope="all">全部</button>
<button class="${state.searchScope === "title" ? "is-active" : ""}" type="button" data-search-scope="title">标题</button>
<button class="${state.searchScope === "content" ? "is-active" : ""}" type="button" data-search-scope="content">正文</button>
</div>
<div id="searchResultList">${resultContent}</div>`;
}
function taskItem(task) {
if (state.completedTasks.has(task.id)) return "";
return `
<div class="task-item" data-task-row="${task.id}">
<button class="task-check" type="button" data-complete-task="${task.id}" aria-label="完成待办">${icon("circle")}</button>
<div class="task-copy">
<span class="task-title">${escapeHtml(task.title)}</span>
<button class="task-source" type="button" data-open-note="${task.noteId}">${escapeHtml(task.source)}</button>
</div>
</div>`;
}
function tasksSidebar() {
const active = tasks.filter((task) => !state.completedTasks.has(task.id));
const today = active.filter((task) => task.group === "today");
const overdue = active.filter((task) => task.group === "overdue");
return `
<div class="sidebar-heading"><strong>待办</strong><span class="row-meta">${active.length} 项</span></div>
<section class="task-section">
<div class="task-group-title"><span>今天</span><span>${today.length}</span></div>
${today.length ? today.map(taskItem).join("") : '<div class="empty-results"><strong>今天都完成了</strong><p>新的待办会自动出现在这里。</p></div>'}
</section>
${overdue.length ? `<section class="task-section"><div class="task-group-title is-overdue"><span>较早</span><span>${overdue.length}</span></div>${overdue.map(taskItem).join("")}</section>` : ""}
<section class="task-section">
<div class="task-group-title"><span>无日期</span><span>0</span></div>
</section>`;
}
function tagMap() {
const counts = new Map();
for (const note of notes) {
for (const tag of note.tags) counts.set(tag, (counts.get(tag) || 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh-CN"));
}
function tagColor(index) {
return ["", "coral", "blue", "yellow"][index % 4];
}
function tagsSidebar() {
const tags = tagMap();
const matches = notes.filter((note) => note.tags.includes(state.selectedTag));
return `
<div class="sidebar-heading"><strong>标签</strong><span class="row-meta">${tags.length} 个</span></div>
<section class="side-section">
${tags.map(([tag, count], index) => `
<button class="tag-list-row ${state.selectedTag === tag ? "is-active" : ""}" type="button" data-select-tag="${escapeHtml(tag)}">
<span class="tag-dot ${tagColor(index)}"></span><span class="row-label">${escapeHtml(tag)}</span><span class="row-meta">${count}</span>
</button>`).join("")}
</section>
<section class="side-section">
<div class="sidebar-heading"><span>#${escapeHtml(state.selectedTag)}</span><span>${matches.length} 篇</span></div>
${matches.map(searchResult).join("")}
</section>`;
}
function settingsSidebar() {
return `
<div class="sidebar-heading"><strong>设置</strong></div>
<p class="settings-intro">调整写作体验和文件行为。高级能力集中放在底部,不影响日常记事。</p>
<section class="side-section">
<div class="sidebar-heading"><span>常用设置</span></div>
<div class="setting-row"><div class="setting-copy"><strong>界面主题</strong><span>明亮</span></div><select class="mini-select"><option>明亮</option><option>跟随系统</option></select></div>
<div class="setting-row"><div class="setting-copy"><strong>自动保存</strong><span>修改后自动写入文件</span></div><button class="switch is-on" type="button" data-toggle-switch aria-label="切换自动保存"></button></div>
<div class="setting-row"><div class="setting-copy"><strong>默认位置</strong><span>新笔记保存到收件箱</span></div>${icon("chevron-right")}</div>
</section>
<section class="side-section">
<div class="sidebar-heading"><span>高级工具</span></div>
${advancedRows()}
</section>`;
}
function advancedRows() {
const tools = [
["terminal-square", "命令面板", "运行自动化和不常用操作"],
["network", "关系图", "查看笔记之间的链接"],
["blocks", "扩展库", "管理兼容扩展"],
["file-cog", "配置", "调整高级行为"],
["files", "系统页面", "查看默认隐藏的内部页面"],
];
return tools.map(([toolIcon, title, subtitle]) => `
<button class="advanced-row" type="button" data-advanced-tool="${escapeHtml(title)}">
${icon(toolIcon)}<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(subtitle)}</span></div>${icon("chevron-right")}
</button>`).join("");
}
function moreSidebar() {
return `
<div class="sidebar-heading"><strong>更多</strong></div>
<section class="side-section">
<button class="side-row" type="button" data-more-target="tags">${icon("tags")}<span class="row-label">标签</span></button>
<button class="side-row" type="button" data-more-target="settings">${icon("settings")}<span class="row-label">设置</span></button>
</section>
<section class="side-section">
<div class="sidebar-heading"><span>状态</span></div>
<button class="side-row" type="button" data-toggle-offline>${icon(state.offline ? "wifi-off" : "cloud")}<span class="row-label">${state.offline ? "恢复在线" : "模拟离线"}</span></button>
</section>
<section class="side-section">
<div class="sidebar-heading"><span>高级工具</span></div>
${advancedRows()}
</section>`;
}
function renderSidebar() {
const content = byId("sidebarContent");
if (state.settings) content.innerHTML = settingsSidebar();
else if (state.view === "search") content.innerHTML = searchSidebar();
else if (state.view === "tasks") content.innerHTML = tasksSidebar();
else if (state.view === "tags") content.innerHTML = tagsSidebar();
else if (state.view === "more") content.innerHTML = moreSidebar();
else content.innerHTML = notesSidebar();
document.querySelectorAll(".primary-nav-item").forEach((button) => {
button.classList.toggle("is-active", !state.settings && button.dataset.view === state.view);
});
byId("settingsButton").classList.toggle("is-active", state.settings);
byId("taskCount").textContent = tasks.filter((task) => !state.completedTasks.has(task.id)).length;
refreshIcons();
if (state.view === "search" && !state.settings) {
const input = byId("sidebarSearchInput");
if (input && document.activeElement?.id !== "sidebarSearchInput") {
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
});
}
}
}
function renderBreadcrumb(parts) {
byId("breadcrumb").innerHTML = parts.map((part, index) => `${index ? icon("chevron-right") : ""}<span>${escapeHtml(part)}</span>`).join("");
}
function homePage() {
const activeTasks = tasks.filter((task) => !state.completedTasks.has(task.id)).slice(0, 4);
return `
<section class="home-page" data-page="home">
<p class="home-date">2026 年 8 月 12 日 · 星期三</p>
<h1 class="home-title">今天想记点什么?</h1>
<p class="home-subtitle">从一条想法开始,剩下的可以晚点再整理。</p>
<div class="home-actions">
<button class="home-action green" type="button" data-home-action="capture"><span>${icon("zap")}</span><span><strong>随手记</strong><small>快速记下一条想法</small></span></button>
<button class="home-action coral" type="button" data-open-note="daily"><span>${icon("calendar-days")}</span><span><strong>今天的日记</strong><small>继续今天的记录</small></span></button>
<button class="home-action blue" type="button" data-home-action="new"><span>${icon("file-plus-2")}</span><span><strong>新建笔记</strong><small>选择标题和保存位置</small></span></button>
</div>
<div class="home-grid">
<section>
<header class="home-section-title"><h2>最近编辑</h2><button type="button" data-switch-view="notes">查看全部</button></header>
${notes.slice(0, 5).map((note) => `
<button class="recent-note-row" type="button" data-open-note="${note.id}">
<strong>${escapeHtml(noteName(note))}</strong><time>${escapeHtml(note.updated)}</time><span>${escapeHtml(note.path)}</span>
</button>`).join("")}
</section>
<section>
<header class="home-section-title"><h2>今天待办</h2><button type="button" data-switch-view="tasks">查看全部</button></header>
${activeTasks.map((task) => `
<div class="home-task-row" data-home-task="${task.id}">
<button type="button" data-complete-task="${task.id}" aria-label="完成待办">${icon("circle")}</button>
<div><strong>${escapeHtml(task.title)}</strong><small>${escapeHtml(task.source)}</small></div>
</div>`).join("")}
</section>
</div>
</section>`;
}
function documentPage(note) {
return `
<article class="document-page ${state.readMode ? "read-mode" : ""}" data-page="document">
<input class="page-title" id="pageTitleInput" aria-label="笔记标题" value="${escapeHtml(note.title)}" />
<h1 class="reading-title">${escapeHtml(note.title)}</h1>
<div class="page-meta"><span>${escapeHtml(note.updated)} 更新</span><span>·</span><span>${escapeHtml(note.path.split("/").slice(0, -1).join(" / "))}</span></div>
<div class="editor-body" id="editorBody" contenteditable="true" spellcheck="false" aria-label="笔记正文">${note.content}</div>
<div class="reading-body">${note.content}</div>
</article>`;
}
function settingsPage() {
const tools = [
["terminal-square", "命令面板", "运行自动化和不常用操作"],
["network", "关系图", "查看笔记之间的链接"],
["blocks", "扩展库", "管理兼容扩展"],
["file-cog", "配置", "编辑 Plainleaf 的高级行为"],
["files", "系统页面", "查看默认隐藏的内部页面"],
];
return `
<section class="settings-page" data-page="settings">
<h1>设置</h1>
<p>日常写作选项放在前面,高级能力需要时再打开。</p>
<section class="settings-group">
<h2>写作体验</h2>
<div class="settings-main-row"><div><strong>正文宽度</strong><span>适合长时间阅读的 760px</span></div><select class="mini-select"><option>舒适</option><option>宽屏</option></select></div>
<div class="settings-main-row"><div><strong>自动保存</strong><span>修改后自动写回 Markdown 文件</span></div><button class="switch is-on" type="button" data-toggle-switch aria-label="切换自动保存"></button></div>
<div class="settings-main-row"><div><strong>显示系统内容</strong><span>Library、Std 和 meta 页面默认隐藏</span></div><button class="switch" type="button" data-toggle-switch aria-label="显示系统内容"></button></div>
</section>
<section class="settings-group">
<h2>文件与同步</h2>
<div class="settings-main-row"><div><strong>默认新建位置</strong><span>收件箱</span></div><button class="secondary-button" type="button">更改</button></div>
<div class="settings-main-row"><div><strong>当前空间</strong><span>Markdown 文件已同步</span></div><span class="sync-dot"></span></div>
</section>
<section class="settings-group">
<h2>高级工具</h2>
<div class="advanced-tools-grid">
${tools.map(([toolIcon, title, subtitle]) => `
<button class="advanced-tool-card" type="button" data-advanced-tool="${escapeHtml(title)}">
<span class="advanced-tool-icon">${icon(toolIcon)}</span><span><strong>${escapeHtml(title)}</strong><small>${escapeHtml(subtitle)}</small></span>${icon("chevron-right")}
</button>`).join("")}
</div>
</section>
</section>`;
}
function renderWorkspace() {
const content = byId("workspaceContent");
if (state.settings) {
content.innerHTML = settingsPage();
renderBreadcrumb(["设置"]);
byId("readModeButton").disabled = true;
} else if (!state.currentNoteId) {
content.innerHTML = homePage();
renderBreadcrumb(["首页"]);
byId("readModeButton").disabled = true;
} else {
const note = currentNote();
content.innerHTML = documentPage(note);
renderBreadcrumb(note.path.split("/"));
byId("readModeButton").disabled = false;
}
byId("readModeButton").classList.toggle("is-active", state.readMode);
byId("wordCount").textContent = state.currentNoteId ? "426 字 · Markdown" : "Markdown";
refreshIcons();
}
function renderAll() {
renderSidebar();
renderWorkspace();
}
function openNote(noteId, closeMobile = true) {
state.currentNoteId = noteId;
state.settings = false;
state.readMode = false;
renderAll();
if (closeMobile) closeSidebar();
}
function openHome() {
state.currentNoteId = null;
state.settings = false;
state.view = "notes";
state.readMode = false;
renderAll();
closeSidebar();
}
function switchView(view, openMobile = false) {
state.view = view;
state.settings = false;
renderSidebar();
if (openMobile) openSidebar();
}
function openSettings(openMobile = false) {
state.settings = true;
state.view = "settings";
renderAll();
if (openMobile) openSidebar();
}
function openSidebar() {
byId("app").classList.add("sidebar-open");
}
function closeSidebar() {
byId("app").classList.remove("sidebar-open");
}
function openNewNote(preset = "blank") {
byId("newNoteModal").classList.add("is-visible");
byId("newNoteModal").setAttribute("aria-hidden", "false");
selectQuickCreate(preset);
requestAnimationFrame(() => byId("newNoteTitle").focus());
}
function closeNewNote() {
byId("newNoteModal").classList.remove("is-visible");
byId("newNoteModal").setAttribute("aria-hidden", "true");
}
function selectQuickCreate(preset) {
document.querySelectorAll("[data-quick-create]").forEach((button) => button.classList.toggle("is-selected", button.dataset.quickCreate === preset));
const titleInput = byId("newNoteTitle");
const folder = byId("newNoteFolder");
if (preset === "capture") {
titleInput.value = "随手记 11:20";
folder.value = "收件箱";
} else if (preset === "daily") {
titleInput.value = "2026-08-12";
folder.value = "日记/2026/08";
} else {
titleInput.value = "";
folder.value = "收件箱";
}
titleInput.focus();
titleInput.select();
}
let toastTimer;
function showToast(message) {
clearTimeout(toastTimer);
byId("toastText").textContent = message;
byId("toast").classList.add("is-visible");
toastTimer = setTimeout(() => byId("toast").classList.remove("is-visible"), 2200);
}
function completeTask(taskId) {
const row = document.querySelector(`[data-task-row="${taskId}"], [data-home-task="${taskId}"]`);
if (row) row.classList.add("is-complete");
setTimeout(() => {
state.completedTasks.add(taskId);
renderAll();
showToast("待办已完成,Markdown 已更新");
}, 170);
}
function toggleOffline() {
state.offline = !state.offline;
byId("offlineBanner").classList.toggle("is-visible", state.offline);
byId("syncText").textContent = state.offline ? "修改已保存在本机" : "文件已同步";
if (state.view === "more") renderSidebar();
showToast(state.offline ? "已切换到离线演示" : "连接已恢复");
}
function setSaving() {
const saveState = byId("saveState");
saveState.classList.add("is-saving");
saveState.innerHTML = `${icon("circle-dot-dashed")}<span>保存中</span>`;
refreshIcons();
clearTimeout(setSaving.timer);
setSaving.timer = setTimeout(() => {
saveState.classList.remove("is-saving");
saveState.innerHTML = `${icon("check")}<span>已保存</span>`;
refreshIcons();
}, 700);
}
document.querySelectorAll(".primary-nav-item").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
byId("homeButton").addEventListener("click", openHome);
byId("newNoteButton").addEventListener("click", () => openNewNote("blank"));
byId("mobileNewButton").addEventListener("click", () => openNewNote("capture"));
byId("settingsButton").addEventListener("click", () => openSettings(false));
byId("openSidebarButton").addEventListener("click", openSidebar);
byId("closeSidebarButton").addEventListener("click", closeSidebar);
byId("mobileScrim").addEventListener("click", closeSidebar);
document.querySelectorAll("[data-mobile-view]").forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.mobileView;
state.settings = false;
switchView(view, true);
});
});
byId("sidebarContent").addEventListener("click", (event) => {
const noteButton = event.target.closest("[data-open-note]");
if (noteButton) return openNote(noteButton.dataset.openNote);
const homeButton = event.target.closest("[data-open-home]");
if (homeButton) return openHome();
const folderButton = event.target.closest("[data-folder]");
if (folderButton) {
const folder = folderButton.dataset.folder;
if (state.expandedFolders.has(folder)) state.expandedFolders.delete(folder);
else state.expandedFolders.add(folder);
return renderSidebar();
}
const scopeButton = event.target.closest("[data-search-scope]");
if (scopeButton) {
state.searchScope = scopeButton.dataset.searchScope;
return renderSidebar();
}
if (event.target.closest("[data-clear-search]")) {
state.searchQuery = "";
return renderSidebar();
}
if (event.target.closest("[data-create-from-search]")) return openNewNote("blank");
const taskButton = event.target.closest("[data-complete-task]");
if (taskButton) return completeTask(taskButton.dataset.completeTask);
const tagButton = event.target.closest("[data-select-tag]");
if (tagButton) {
state.selectedTag = tagButton.dataset.selectTag;
return renderSidebar();
}
const moreTarget = event.target.closest("[data-more-target]");
if (moreTarget) {
if (moreTarget.dataset.moreTarget === "settings") openSettings(true);
else switchView(moreTarget.dataset.moreTarget, true);
return;
}
const toolButton = event.target.closest("[data-advanced-tool]");
if (toolButton) return showToast(`${toolButton.dataset.advancedTool}已放在高级工具中`);
const sidebarAction = event.target.closest("[data-sidebar-action]");
if (sidebarAction?.dataset.sidebarAction === "collapse") {
state.expandedFolders.clear();
return renderSidebar();
}
if (sidebarAction?.dataset.sidebarAction === "recent") {
showToast("最近编辑按时间排列在首页");
return;
}
if (sidebarAction?.dataset.sidebarAction === "favorites") {
showToast("已显示 2 篇收藏笔记");
return;
}
if (event.target.closest("[data-new-folder]")) return showToast("新建文件夹入口");
if (event.target.closest("[data-toggle-offline]")) return toggleOffline();
const switchButton = event.target.closest("[data-toggle-switch]");
if (switchButton) switchButton.classList.toggle("is-on");
});
byId("sidebarContent").addEventListener("input", (event) => {
if (event.target.id !== "sidebarSearchInput") return;
state.searchQuery = event.target.value;
const selectionStart = event.target.selectionStart;
renderSidebar();
const input = byId("sidebarSearchInput");
input.focus();
input.setSelectionRange(selectionStart, selectionStart);
});
byId("workspaceContent").addEventListener("click", (event) => {
const noteButton = event.target.closest("[data-open-note]");
if (noteButton) return openNote(noteButton.dataset.openNote, false);
const taskButton = event.target.closest("[data-complete-task]");
if (taskButton) return completeTask(taskButton.dataset.completeTask);
const homeAction = event.target.closest("[data-home-action]");
if (homeAction) return openNewNote(homeAction.dataset.homeAction === "capture" ? "capture" : "blank");
const viewButton = event.target.closest("[data-switch-view]");
if (viewButton) return switchView(viewButton.dataset.switchView, window.innerWidth <= 720);
const advancedButton = event.target.closest("[data-advanced-tool]");
if (advancedButton) return showToast(`${advancedButton.dataset.advancedTool}会打开现有高级能力`);
const switchButton = event.target.closest("[data-toggle-switch]");
if (switchButton) switchButton.classList.toggle("is-on");
});
byId("workspaceContent").addEventListener("input", (event) => {
const note = currentNote();
if (!note) return;
if (event.target.id === "pageTitleInput") note.title = event.target.value;
if (event.target.id === "editorBody") note.content = event.target.innerHTML;
setSaving();
});
byId("readModeButton").addEventListener("click", () => {
if (!state.currentNoteId) return;
state.readMode = !state.readMode;
renderWorkspace();
});
byId("pageSearchButton").addEventListener("click", () => showToast("页内搜索会在正文顶部打开"));
byId("moreButton").addEventListener("click", (event) => {
event.stopPropagation();
const popover = byId("morePopover");
const visible = popover.classList.toggle("is-visible");
popover.setAttribute("aria-hidden", visible ? "false" : "true");
});
document.addEventListener("click", (event) => {
if (!event.target.closest("#morePopover") && !event.target.closest("#moreButton")) {
byId("morePopover").classList.remove("is-visible");
byId("morePopover").setAttribute("aria-hidden", "true");
}
});
byId("morePopover").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
showToast(`${button.textContent.trim()}入口已确认`);
byId("morePopover").classList.remove("is-visible");
});
document.querySelectorAll("[data-close-modal]").forEach((button) => button.addEventListener("click", closeNewNote));
document.querySelectorAll("[data-quick-create]").forEach((button) => button.addEventListener("click", () => selectQuickCreate(button.dataset.quickCreate)));
byId("newNoteForm").addEventListener("submit", (event) => {
event.preventDefault();
const title = byId("newNoteTitle").value.trim() || "未命名笔记";
const folder = byId("newNoteFolder").value;
const id = `demo-${Date.now()}`;
notes.unshift({
id,
title,
path: `${folder}/${title}`,
updated: "刚刚",
favorite: false,
tags: [],
excerpt: "新创建的 Markdown 笔记。",
content: "<p><br></p>",
});
closeNewNote();
openNote(id, false);
showToast(`已在${folder}创建笔记`);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeNewNote();
closeSidebar();
byId("morePopover").classList.remove("is-visible");
}
if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k") {
event.preventDefault();
switchView("search", window.innerWidth <= 720);
}
});
renderAll();
+150
View File
@@ -0,0 +1,150 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f0f2ef" />
<title>Plainleaf 安静笔记工作台 Demo</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app" id="app">
<aside class="sidebar" id="sidebar" aria-label="笔记导航">
<header class="brand-row">
<button class="brand" id="homeButton" type="button" aria-label="打开首页">
<span class="brand-mark"><i data-lucide="leaf"></i></span>
<span>Plainleaf</span>
</button>
<button class="icon-button mobile-close" id="closeSidebarButton" type="button" aria-label="关闭导航" title="关闭导航">
<i data-lucide="x"></i>
</button>
</header>
<button class="create-button" id="newNoteButton" type="button">
<i data-lucide="plus"></i>
<span>新建笔记</span>
</button>
<nav class="primary-nav" aria-label="主要功能">
<button class="primary-nav-item is-active" type="button" data-view="notes">
<i data-lucide="notebook-tabs"></i><span>笔记</span>
</button>
<button class="primary-nav-item" type="button" data-view="search">
<i data-lucide="search"></i><span>搜索</span>
</button>
<button class="primary-nav-item" type="button" data-view="tasks">
<i data-lucide="circle-check-big"></i><span>待办</span><span class="nav-count" id="taskCount">4</span>
</button>
<button class="primary-nav-item" type="button" data-view="tags">
<i data-lucide="tags"></i><span>标签</span>
</button>
</nav>
<div class="sidebar-divider"></div>
<section class="sidebar-content" id="sidebarContent" aria-live="polite"></section>
<footer class="sidebar-footer">
<button class="settings-button" id="settingsButton" type="button">
<i data-lucide="settings"></i>
<span>设置</span>
</button>
</footer>
</aside>
<button class="mobile-scrim" id="mobileScrim" type="button" aria-label="关闭导航"></button>
<main class="workspace">
<header class="topbar">
<div class="topbar-leading">
<button class="icon-button mobile-menu" id="openSidebarButton" type="button" aria-label="打开导航" title="打开导航">
<i data-lucide="menu"></i>
</button>
<div class="breadcrumb" id="breadcrumb"></div>
</div>
<div class="topbar-actions">
<div class="save-state" id="saveState"><i data-lucide="check"></i><span>已保存</span></div>
<button class="icon-button desktop-only" id="readModeButton" type="button" aria-label="阅读模式" title="阅读模式">
<i data-lucide="book-open"></i>
</button>
<button class="icon-button desktop-only" id="pageSearchButton" type="button" aria-label="页内搜索" title="页内搜索">
<i data-lucide="text-search"></i>
</button>
<button class="icon-button" id="moreButton" type="button" aria-label="更多笔记操作" title="更多笔记操作">
<i data-lucide="ellipsis"></i>
</button>
</div>
</header>
<div class="offline-banner" id="offlineBanner">
<i data-lucide="wifi-off"></i>
<span>当前离线,修改已保存在本机</span>
</div>
<div class="workspace-scroll" id="workspaceContent"></div>
<footer class="statusbar">
<div><span class="sync-dot"></span><span id="syncText">文件已同步</span></div>
<div><span id="wordCount">Markdown</span><span class="desktop-only">UTF-8</span></div>
</footer>
</main>
<nav class="mobile-bottom-nav" aria-label="手机导航">
<button type="button" data-mobile-view="notes"><i data-lucide="notebook-tabs"></i><span>笔记</span></button>
<button type="button" data-mobile-view="search"><i data-lucide="search"></i><span>搜索</span></button>
<button class="mobile-new" id="mobileNewButton" type="button" aria-label="新建笔记"><i data-lucide="plus"></i></button>
<button type="button" data-mobile-view="tasks"><i data-lucide="circle-check-big"></i><span>待办</span></button>
<button type="button" data-mobile-view="more"><i data-lucide="ellipsis"></i><span>更多</span></button>
</nav>
</div>
<div class="modal-layer" id="newNoteModal" aria-hidden="true">
<button class="modal-scrim" type="button" data-close-modal aria-label="关闭"></button>
<section class="new-note-dialog" role="dialog" aria-modal="true" aria-labelledby="newNoteHeading">
<header class="dialog-header">
<h2 id="newNoteHeading">新建笔记</h2>
<button class="icon-button" type="button" data-close-modal aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</header>
<div class="quick-create-grid">
<button type="button" data-quick-create="capture"><span class="quick-icon green"><i data-lucide="zap"></i></span><span>随手记</span></button>
<button type="button" data-quick-create="daily"><span class="quick-icon coral"><i data-lucide="calendar-days"></i></span><span>今天的日记</span></button>
<button class="is-selected" type="button" data-quick-create="blank"><span class="quick-icon blue"><i data-lucide="file-text"></i></span><span>空白笔记</span></button>
</div>
<form id="newNoteForm">
<label class="field-label" for="newNoteTitle">标题</label>
<input class="text-field" id="newNoteTitle" type="text" placeholder="输入笔记标题" autocomplete="off" />
<label class="field-label" for="newNoteFolder">保存到</label>
<div class="select-wrap">
<i data-lucide="folder"></i>
<select id="newNoteFolder">
<option value="收件箱">收件箱</option>
<option value="项目/Plainleaf">项目 / Plainleaf</option>
<option value="知识库">知识库</option>
<option value="日记/2026/08">日记 / 2026 / 08</option>
</select>
<i data-lucide="chevron-down"></i>
</div>
<div class="dialog-actions">
<button class="secondary-button" type="button" data-close-modal>取消</button>
<button class="primary-button" type="submit"><i data-lucide="file-plus-2"></i><span>创建笔记</span></button>
</div>
</form>
</section>
</div>
<div class="popover" id="morePopover" aria-hidden="true">
<button type="button"><i data-lucide="pencil-line"></i><span>重命名</span></button>
<button type="button"><i data-lucide="folder-input"></i><span>移动到...</span></button>
<button type="button"><i data-lucide="copy"></i><span>创建副本</span></button>
<button type="button"><i data-lucide="history"></i><span>版本历史</span></button>
<div></div>
<button class="danger-item" type="button"><i data-lucide="trash-2"></i><span>移到废纸篓</span></button>
</div>
<div class="toast" id="toast" role="status" aria-live="polite">
<i data-lucide="check-circle-2"></i><span id="toastText">已完成</span>
</div>
<script src="./lucide.min.js"></script>
<script src="./app.js"></script>
</body>
</html>
+12
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const baseUrl = process.env.PLAINLEAF_DEMO_URL || "http://127.0.0.1:4174/";
const outputDir = "/tmp/plainleaf-design-v2";
await fs.mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const viewports = [
{ name: "desktop-1440", width: 1440, height: 900 },
{ name: "desktop-1024", width: 1024, height: 768 },
{ name: "mobile-390", width: 390, height: 844 },
{ name: "mobile-430", width: 430, height: 932 },
];
const failures = [];
const results = [];
for (const viewport of viewports) {
const page = await browser.newPage({ viewport });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.goto(baseUrl, { waitUntil: "networkidle" });
await page.waitForSelector('[data-page="home"]');
const initialMetrics = await page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const overflow = [...document.querySelectorAll("button, .topbar, .mobile-bottom-nav, .home-action, .quick-create-grid")]
.filter(visible)
.filter((element) => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1)
.map((element) => ({
selector: element.id || element.className || element.tagName,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
return {
overflow,
viewportOverflow: document.documentElement.scrollWidth > innerWidth + 1 || document.documentElement.scrollHeight > innerHeight + 1,
bodyText: document.body.innerText,
};
});
if (initialMetrics.bodyText.includes("SilverBullet")) failures.push(`${viewport.name}: SilverBullet is visible in primary UI`);
if (initialMetrics.bodyText.includes("meta/library")) failures.push(`${viewport.name}: system tag is visible`);
if (viewport.width > 720) {
await page.locator('[data-view="search"]').click();
await page.locator("#sidebarSearchInput").fill("Docker");
const resultCount = await page.locator(".search-result-row").count();
if (resultCount < 2) failures.push(`${viewport.name}: expected Docker search results`);
await page.locator('.search-result-row[data-open-note="zspace"]').click();
if ((await page.locator("#pageTitleInput").inputValue()) !== "极空间部署") failures.push(`${viewport.name}: desktop note open failed`);
await page.locator('[data-view="tasks"]').click();
await page.locator('[data-complete-task="task-demo"]').first().click();
await page.waitForTimeout(220);
if (await page.locator('[data-task-row="task-demo"]').count()) failures.push(`${viewport.name}: task completion failed`);
await page.locator("#newNoteButton").click();
await page.waitForSelector("#newNoteModal.is-visible");
await page.locator("#newNoteTitle").fill("会议记录");
await page.locator("#newNoteForm .primary-button").click();
if ((await page.locator("#pageTitleInput").inputValue()) !== "会议记录") failures.push(`${viewport.name}: create note failed`);
await page.locator("#settingsButton").click();
await page.waitForSelector('[data-page="settings"]');
const settingsText = await page.locator("#workspaceContent").innerText();
if (!settingsText.includes("高级工具") || !settingsText.includes("关系图")) failures.push(`${viewport.name}: advanced settings missing`);
} else {
await page.locator('[data-mobile-view="search"]').click();
await page.waitForSelector("#app.sidebar-open");
await page.locator("#sidebarSearchInput").fill("Docker");
await page.locator('.search-result-row[data-open-note="zspace"]').click();
if ((await page.locator("#pageTitleInput").inputValue()) !== "极空间部署") failures.push(`${viewport.name}: mobile note open failed`);
if (await page.locator("#app.sidebar-open").count()) failures.push(`${viewport.name}: sidebar did not close after note open`);
await page.locator("#mobileNewButton").click();
await page.waitForSelector("#newNoteModal.is-visible");
const presetTitle = await page.locator("#newNoteTitle").inputValue();
if (!presetTitle.startsWith("随手记")) failures.push(`${viewport.name}: mobile quick capture preset failed`);
await page.locator("[data-close-modal]").last().click();
await page.locator('[data-mobile-view="more"]').click();
await page.waitForSelector("#app.sidebar-open");
await page.waitForFunction(() => document.querySelector("#sidebarContent")?.textContent?.includes("高级工具"));
await page.waitForFunction(() => getComputedStyle(document.querySelector("#sidebar")).visibility === "visible");
const moreText = await page.locator("#sidebarContent").innerText();
if (!moreText.includes("标签") || !moreText.includes("高级工具")) failures.push(`${viewport.name}: mobile more view incomplete`);
}
const finalMetrics = await page.evaluate(() => ({
viewportOverflow: document.documentElement.scrollWidth > innerWidth + 1 || document.documentElement.scrollHeight > innerHeight + 1,
}));
if (initialMetrics.viewportOverflow || finalMetrics.viewportOverflow) failures.push(`${viewport.name}: viewport overflow`);
if (initialMetrics.overflow.length) failures.push(`${viewport.name}: element overflow ${JSON.stringify(initialMetrics.overflow)}`);
if (errors.length) failures.push(`${viewport.name}: ${errors.join(" | ")}`);
await page.goto(baseUrl, { waitUntil: "networkidle" });
await page.screenshot({ path: `${outputDir}/${viewport.name}.png`, fullPage: true });
results.push({ viewport: viewport.name, initialMetrics: { ...initialMetrics, bodyText: undefined }, errors });
await page.close();
}
await browser.close();
console.log(JSON.stringify({ baseUrl, outputDir, results, failures }, null, 2));
if (failures.length) process.exit(1);
+45
View File
@@ -0,0 +1,45 @@
# Plainleaf V2 关键决策
状态:已确认,可进入交互 Demo
## 产品定位
Plainleaf 是一个中文优先、文件原生的个人笔记工作台。日常体验服务于记录、查找和
整理,高级能力按需出现;NAS 中的普通 Markdown 文件始终是内容真源。
## 已确认决策
1. 采用“安静笔记工作台”方向:白色编辑区配单层浅色侧栏,不保留深色图标轨道。
2. 主导航固定为“笔记、搜索、待办、标签”,四个入口都切换同一个侧栏,不弹命令框。
3. 图谱、命令面板、扩展库、配置和系统页面移入“设置 > 高级工具”。
4. 默认隐藏 `Library`、`Repositories`、`Std`、`CONFIG`、`meta/*` 及系统标签;只在
显示层过滤,不删除、不移动底层文件。
5. 首页由“随手记、最近编辑、今天待办、今天日记”组成,`index.md` 在界面显示为
“首页”,真实文件名不改变。
6. 新建笔记使用标题和位置明确的创建面板;“随手记”和“今天的日记”是可见快捷项。
7. 搜索结果显示标题、路径、正文摘要和更新时间;没有结果时直接提供“新建同名笔记”。
8. 普通流程不显示命令 ID,不要求用户理解 `Enter`/`Shift-Enter` 差异。
9. 继续复用 SilverBullet 的 CodeMirror、页面索引、查询和文件操作,不建立第二套数据层。
10. 本轮同时保留 Service Worker 更新修复,待全部需求合并后再统一部署。
## 本轮范围
- 桌面与手机导航外壳。
- 首页、文件、新建、搜索、标签、待办六条主要路径。
- 设置与高级工具分层。
- 系统内容过滤与友好显示名。
- 弹层、通知、按钮、空状态和编辑器周边视觉统一。
## 暂不处理
- 多标签页、团队协作、AI 写作和插件市场。
- 数据库存储或 Markdown 文件格式调整。
- `.txt`、`.html` 的专用编辑体验。
- 暗色主题。本轮先把浅色日常体验做稳定,技术上保留后续主题能力。
## 图像与装饰原则
- 真实工作区不使用大幅图片、背景插画或装饰卡片。
- 品牌由小型叶片标记、字体和颜色完成。
- AI 生成图片只用于空空间、无搜索结果等低频空状态,规格为透明 PNG、纸张/叶片/
清单主题,不含文字;没有明确空状态需求时不生成。
+57
View File
@@ -0,0 +1,57 @@
# Plainleaf V2 规格审查
状态:审查完成,问题已回写规格
## 设计视角
### 必须修
- **避免重新变成单一绿色界面。** 已加入珊瑚、蓝色和暖黄的语义分工,并限制绿色
填充面积。编辑画布保持中性。
- **首页不能卡片化。** 已规定首页使用分隔线和行列表,仅快捷操作使用浅色块,不做
卡片套卡片。
- **手机入口不能照搬桌面。** 已将标签放入“更多”,底栏稳定为五项。
### 建议修
- 空状态插画可能增加亲和力,但不应先于核心流程。已设为可选资产,本轮 Demo 使用
CSS 品牌标记,不生成无明确用途的图片。
- 阅读模式保留为图标入口,不在顶栏放三段模式切换,降低工具栏密度。
## 产品视角
### 必须修
- **“待办”必须是真实视图。** 已明确从 Markdown 聚合、直接勾选并写回原文。
- **新建不能再借用页面选择器。** 已定义独立标题、位置和快捷记录流程。
- **搜索不能丢失上下文。** 桌面结果在侧栏持续保留,正文切换不关闭搜索。
- **高级能力不能被删除。** 已集中在设置下并为每项提供普通语言说明。
### 建议修
- 首页的“今天待办”限制为四项,避免把首页做成任务管理器。
- `.txt`/`.html` 对用户原始诉求有价值,但涉及编辑器和安全预览边界,留在下一阶段。
## 架构视角
### 必须修
- **不能复制一套索引和文件状态。** 已要求视图复用 `allPages` 与对象索引。
- **系统内容只能过滤,不能迁移或删除。** 已写入决策和技术映射。
- **任务勾选必须更新原 Markdown。** 已明确写回来源位置,避免 UI 与文件不一致。
- **Demo 不得污染当前生产修改。** 使用独立 `docs/design-v2/`,不修改客户端组件。
### 建议修
- 搜索、标签和待办先抽象为共享结果行,减少正式实现的视觉与逻辑分叉。
- Service Worker 修复与 V2 实现统一验证、统一发布,避免连续触发客户端升级提示。
## 最终结论
规格通过,可制作交互 Demo。正式开发前仍需用户对以下体验做目测确认:
1. 单侧栏是否比当前两层导航更直观。
2. 首页的信息量是否适合每日打开。
3. 搜索常驻侧栏是否满足快速查找。
4. 高级工具的收纳深度是否合适。
5. 手机底栏和抽屉是否符合随手记习惯。
+216
View File
@@ -0,0 +1,216 @@
# Plainleaf 安静笔记工作台设计规格 V2
状态:HTML Demo 实施基线
## 1. 体验原则
1. **先写再整理。** 首页和移动端都把随手记、新建与继续编辑放在最短路径。
2. **同层入口,同种反馈。** 笔记、搜索、待办、标签只替换侧栏内容,当前正文保持可见。
3. **文件可理解。** 路径、文件夹和 Markdown 身份可见,但系统页和实现术语默认隐藏。
4. **能力渐进出现。** 普通用户不接触命令 ID;高级工具有明确入口并说明用途。
5. **安静但不单色。** 叶绿只表达主要动作,珊瑚色表达待办/提醒,蓝色表达链接/同步。
6. **几何稳定。** 导航、工具栏、编辑宽度和移动底栏都有稳定尺寸,动态内容不推动布局。
## 2. 信息架构
```text
Plainleaf
├── 首页
├── 笔记
│ ├── 最近编辑
│ ├── 收藏
│ └── 文件夹与笔记
├── 搜索
│ ├── 全部
│ ├── 标题
│ └── 正文
├── 待办
│ ├── 今天
│ ├── 较早
│ └── 无日期
├── 标签
│ └── 用户标签
└── 设置
├── 外观与编辑
├── 文件与同步
└── 高级工具
├── 命令面板
├── 关系图
├── 扩展库
├── 配置
└── 系统页面
```
## 3. 设计系统
### 色彩
| Token | 色值 | 用途 |
| --- | --- | --- |
| `canvas` | `#F6F7F5` | 应用底色、次级控件 |
| `surface` | `#FFFFFF` | 编辑器与弹层 |
| `sidebar` | `#F0F2EF` | 左侧导航 |
| `text` | `#242824` | 标题与正文 |
| `muted` | `#687168` | 路径、时间、说明 |
| `faint` | `#929A92` | 次要计数、占位 |
| `border` | `#DDE2DC` | 区域分隔 |
| `green` | `#3F7658` | 主要按钮、焦点、当前项 |
| `green-soft` | `#DDEBE2` | 选中背景 |
| `coral` | `#C96C5C` | 逾期、提醒、关键待办 |
| `coral-soft` | `#F6E4E0` | 待办分组背景 |
| `blue` | `#4C7591` | 链接与同步信息 |
| `yellow` | `#E7C96F` | 收藏星标、小面积暖色点缀 |
| `danger` | `#B94A4A` | 删除与冲突 |
任何单屏中绿色填充面积不超过约 15%。编辑区保持中性白色,不使用渐变、光球或毛玻璃。
### 字体与密度
- UI:`-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif`。
- 正文:同 UI 字体;代码块使用 `JetBrains Mono`。
- 导航 13px/20px,控件 13px/18px,正文 16px/29px。
- 页面标题 30px/39px、650;侧栏标题 18px/25px、650。
- 字距始终为 `0`。
### 尺寸
- 桌面侧栏:288px,可缩放范围 248-360px。
- 顶部工具栏:48px;底部状态栏:26px。
- 编辑正文最大宽度:760px,桌面最小左右留白 36px。
- 导航行:34px;图标按钮:32x32px;主要按钮高度:34px。
- 控件圆角 6px,弹层 8px,首页快捷项 8px;不使用大胶囊和嵌套卡片。
### 动效和反馈
- hover/focus:120ms;侧栏切换和抽屉:180ms ease-out。
- 保存状态在固定 68px 宽区域内切换“保存中/已保存”,不推动工具栏。
- 搜索结果、待办勾选和标签选择立即响应;失败时在原位置显示可恢复状态。
## 4. 桌面工作区
### 常驻侧栏
顶部依次是品牌行、绿色“新建”按钮和搜索输入。品牌文字必须是 `Plainleaf`,不出现
SilverBullet。主导航以图标加文字呈现:笔记、搜索、待办、标签。
侧栏中段随当前入口变化:
- **笔记:** 首页、最近编辑、收藏、文件夹树。
- **搜索:** 搜索框、范围分段控件、带摘要的结果列表。
- **待办:** 今天/较早/无日期分组,可直接勾选,来源笔记可跳转。
- **标签:** 用户标签和数量,选择后显示对应笔记;系统标签默认过滤。
底部固定“设置”。同步状态放在编辑区状态栏,不与主导航争夺空间。
### 编辑区
- 顶栏左侧:当前路径;右侧:保存状态、阅读模式、页内搜索、更多。
- 正文首行为可编辑标题;下方显示友好路径与更新时间。
- 编辑器保持一整块无边框纸面,不包在卡片内。
- “更多”只含当前笔记操作:重命名、移动、复制、历史、删除。
- 大纲、反向链接和属性放入右侧可开合的检查器,不默认常驻。
## 5. 首页
首页不是数据仪表盘,仍位于编辑主画布。结构如下:
1. 日期与问候,字号不超过 30px。
2. 横向快捷操作:随手记、今天的日记、新建笔记。
3. 最近编辑列表,按行展示标题、路径与时间。
4. 今天待办,最多展示 4 项,可直接完成。
桌面允许最近编辑与待办形成 2:1 的并列区域,但不使用“卡片套卡片”。手机端全部单列。
## 6. 搜索
- 搜索始终在侧栏内完成,不出现命令面板式居中大弹窗。
- 搜索范围使用“全部/标题/正文”分段控件。
- 结果显示标题、路径、最多两行摘要、更新时间,命中词使用柔和黄色标记。
- 空结果显示当前关键词和“新建笔记”按钮;不展示快捷键教学文案。
- 桌面点击结果保留查询并打开正文;手机点击后关闭搜索抽屉。
## 7. 新建笔记
- 桌面使用 440px 宽小弹层,手机使用底部 Sheet。
- 顶部三个快捷方式:随手记、今天的日记、空白笔记。
- 空白笔记输入标题并选择保存位置,默认“收件箱”。
- 主要按钮明确写“创建笔记”,不可仅用图标。
- 重名时在输入框下方提示并提供“打开已有笔记”。
## 8. 待办与标签
### 待办
- 解析 Markdown 未完成项,按今天、较早、无日期分组。
- 行高至少 42px;复选框、内容、来源和日期有固定位置。
- 逾期使用珊瑚色文字,不将整行染红。
- 勾选后原 Markdown 同步变为 `- [x]`,并提供短时撤销提示。
### 标签
- 标签呈普通列表而不是彩色胶囊墙,颜色只用于左侧 8px 标记点。
- 用户标签数量固定右对齐。
- 标签结果采用与搜索一致的笔记结果行。
- `meta`、`library`、`repository`、`template`、`task` 等系统标签默认不显示。
## 9. 设置与高级工具
设置以侧栏视图呈现,内容区显示分组设置。普通设置包括主题、字体、正文宽度、默认
新建位置与同步状态。
“高级工具”使用独立分组,并在每项下提供一句用户语言说明:
- 命令面板:运行自动化和不常用操作。
- 关系图:查看笔记之间的链接。
- 扩展库:管理 SilverBullet 兼容扩展。
- 配置:编辑高级行为。
- 系统页面:查看被隐藏的内部页面。
内部命令 ID 默认隐藏,只有打开“显示技术信息”后才出现。
## 10. 手机端
- 顶栏:打开导航、当前标题、保存状态、更多。
- 底栏:笔记、搜索、居中“新建”、待办、更多。
- 笔记/搜索/待办/更多打开全高抽屉,抽屉占宽 100%,不与编辑器并排。
- 标签放入“更多”首屏,避免底栏超过五项。
- 新建使用底部 Sheet;随手记为第一快捷项。
- 所有触控目标至少 42px,底栏包含安全区。
## 11. 状态
- **首次空空间:** 一句话和“新建第一篇笔记”,可选小型透明叶片/纸张插画。
- **无搜索结果:** 显示关键词、创建动作;不显示命令帮助。
- **离线:** 顶栏下方 34px 非阻塞提示,编辑继续可用。
- **保存中:** 琥珀色圆点与“保存中”;完成后为灰绿色勾选。
- **冲突:** 禁止静默覆盖,提供比较与保留两份。
- **只读:** 操作保留位置但禁用,并显示具体原因。
## 12. 技术映射
| 体验 | 现有能力 |
| --- | --- |
| 文件树/最近编辑 | `AppViewState.allPages` + 页面元数据 |
| 打开页面 | `Client.navigate(parseToRef(name))` |
| 编辑正文 | 现有 CodeMirror 6 挂载点 |
| 标题重命名 | 现有 `index.renamePageCommand` |
| 搜索 | 对象索引与页面导航器查询 |
| 标签/待办 | 对象索引查询,写回原 Markdown 位置 |
| 系统内容隐藏 | 展示层路径与标签过滤器 |
| 高级工具 | 包装现有命令/元页面入口 |
| 响应式 | 现有 `isMobile` + 新工作区媒体查询 |
## 13. Demo 验收
Demo 必须可点击演示:
1. 首页与普通笔记切换。
2. 四个桌面主导航入口切换。
3. 搜索输入、范围选择、结果和空结果新建。
4. 新建笔记快捷项和标题/位置表单。
5. 待办勾选与来源跳转。
6. 标签筛选与结果打开。
7. 设置和高级工具分层。
8. 手机底栏、抽屉与新建 Sheet。
不依赖远程字体、远程脚本或网络资源,断网打开仍应完整呈现。
@@ -0,0 +1,220 @@
# Plainleaf 个人笔记体验重设计评审
状态:Phase 1 诊断完成,方案 A 已确认;后续决策见 `decisions-v2.md`
## 1. 这次要解决的问题
Plainleaf 当前已经有文件树、中文外壳和 SilverBullet 的编辑能力,但体验仍然像
“开发者工具套了一层笔记界面”。这次重设计的目标不是继续换颜色,而是重新安排
功能层级:普通用户不需要理解命令、元页面、插件、索引和 Lua,也能完成日常记事。
成功标准:
1. 第一次打开时,用户能直接理解如何新建、查找和整理笔记。
2. 浏览、搜索、标签和待办使用同一种侧栏交互,不再随机弹出命令窗口。
3. 默认界面不出现内部命令 ID、系统标签或 SilverBullet 工程概念。
4. 高级能力继续保留,但放入明确的“高级工具”入口。
5. 所有内容仍是原生 Markdown 文件,不改变存储和迁移能力。
## 2. 当前体验诊断
### 严重问题
1. **主导航行为不一致。** 文件按钮控制侧栏,搜索、标签、任务和图谱却直接弹窗或
运行命令。相同层级的按钮产生不同结果,用户无法形成稳定预期。
2. **快速打开仍是命令选择器。** 巨大的输入框、说明文字、蓝色整行选中、
`Enter`/`Shift-Enter` 操作规则都更像 IDE,而不是笔记搜索。
3. **命令面板占据普通用户路径。** 中文命令下方继续显示英文内部 ID,暴露了
SilverBullet 的实现结构。普通记事不应依赖命令面板。
4. **系统内容污染个人空间。** `Library`、`Repositories`、`Std`、`meta/*` 与用户
笔记混在文件、最近使用和标签列表里,无法分辨哪些内容属于自己。
5. **外壳与正文视觉割裂。** 左侧工作区使用 Plainleaf 样式,弹窗、通知、按钮和
Markdown 小组件仍是 SilverBullet 默认样式,字体、颜色、圆角和密度不统一。
### 高优先级问题
1. `index` 是底层文件名,界面应显示“首页”,但重命名和保存仍操作 `index.md`。
2. 新建按钮仍打开页面选择器,没有自然的“输入标题后创建”流程。
3. 任务按钮实际打开命令面板,并没有任务视图,图标含义与结果不符。
4. 标签列表包含系统标签,且弹窗仍有英文标题和帮助语。
5. 图谱是低频高级能力,却与文件、搜索处于同一主导航层级。
6. 顶部默认动作按钮含“主页、页面选择器、终端”等工程化入口,意义不清晰。
7. 首页的动态查询按钮和列表仍像未样式化的网页控件。
## 3. 五方评审
### 产品负责人
- Plainleaf 的核心价值不是“拥有很多命令”,而是“文件真正属于用户,同时比
SilverBullet 更容易用”。
- 首屏必须先服务于日常记事,不能用高级能力证明产品很强。
- 与 Obsidian 的差异应是更安静、更少配置、中文开箱即用,而不是功能更少的仿品。
### 产品经理
日常路径应收敛到五个:
1. 继续编辑最近笔记。
2. 新建普通笔记或随手记。
3. 按标题和正文搜索。
4. 在文件夹、标签和待办中整理内容。
5. 重命名、移动、复制和删除当前笔记。
命令面板、图谱、元页面、扩展库和配置页都不是日常路径。
### 产品设计师
- 删除深色图标轨道,改成一个浅色、带文字的导航侧栏,减少视觉割裂和猜图标。
- 主编辑区保持白色纸面,但不使用卡片边框;侧栏通过底色和细分隔线区分。
- 弹层控制在 680-760px,使用浅绿色选中态,不再使用高饱和蓝色整行高亮。
- 导航字体紧凑,正文舒适;控件和正文不使用等宽字体,代码块除外。
- 搜索结果展示标题、路径、正文摘要和修改时间,不展示内部对象结构。
### 技术架构师
- 继续复用 `allPages`、对象索引、现有命令和 CodeMirror,不新建第二套数据层。
- 文件、标签和待办视图都在新侧栏内渲染;高级功能才调用原命令面板。
- 通过显示层过滤系统页和系统标签,底层内容不删除、不改名。
- 新建、重命名、删除调用既有能力,保证插件事件和外部 Markdown 变更仍兼容。
- Service Worker 更新修复与界面改造一起保留,最后统一发布。
### 目标用户
**普通个人记录者:**
> 我只想看到自己的笔记。左边每个按钮最好带文字,我不想猜一个分叉图标是什么。
**Markdown 用户:**
> 可以保留命令和脚本,但不要让我每次找笔记都像在用代码编辑器。
**手机记录者:**
> 我最常用的是搜索、随手记和今天的日记,图谱和配置可以放到更多里面。
## 4. 推荐信息架构
### 桌面端
```text
+--------------------------+----------------------------------------------+
| Plainleaf [新建] | 路径 / 保存状态 [更多] |
| [搜索全部笔记……] | |
| | 页面标题 |
| 笔记 | |
| 搜索 | Markdown 编辑器 |
| 待办 | |
| 标签 | |
| ------------------------ | |
| 最近使用 | |
| 文件夹与笔记 | |
| | |
| 设置 / 高级工具 | 字数 · Markdown · 已同步 |
+--------------------------+----------------------------------------------+
```
不再使用独立的深色活动轨道。点击“笔记、搜索、待办、标签”只切换左侧栏内容,
编辑区始终保留当前笔记。
### 手机端
```text
+-----------------------------------+
| 文件/返回 当前笔记 更多 |
|-----------------------------------|
| |
| Markdown 编辑器 |
| |
|-----------------------------------|
| 笔记 搜索 新建 待办 更多 |
+-----------------------------------+
```
文件、搜索、待办作为全高抽屉;“新建”直接创建,不先进入命令面板。
## 5. 核心交互重做
### 搜索笔记
- 标题为“搜索笔记”,单一大输入框。
- 可选范围:全部、标题、正文。
- 默认按最近使用排序;输入后展示匹配摘要。
- 结果行展示:笔记标题、文件夹路径、命中摘要、更新时间。
- 没有结果时出现“新建笔记:当前输入”,无需记住 Shift-Enter。
- `index` 显示为“首页”,真实路径作为次要文字显示。
### 新建笔记
- 左侧“新建”按钮直接打开小型创建面板。
- 输入标题,选择位置:当前文件夹、收件箱或其他文件夹。
- 主要按钮为“创建笔记”。
- “随手记”和“今天的日记”作为可见快捷项,不暴露命令名称。
### 标签
- 侧栏直接列出用户标签和笔记数量。
- 默认过滤 `meta`、`template`、`library`、`repository` 等系统标签。
- 点击标签后,侧栏显示属于该标签的笔记列表,不弹出 Picker。
- “显示系统标签”只放在高级设置中。
### 待办
- 聚合 Markdown 中未完成的 `- [ ]` 项目。
- 按今天、较早、无日期分组,展示来源笔记。
- 点击待办跳到原文位置,勾选直接更新 Markdown。
- 不再把任务按钮连接到命令面板。
### 高级工具
- 放在侧栏底部“设置”里的独立区域。
- 包含:命令面板、关系图、扩展库、配置、系统页面。
- 默认命令列表只显示中文名称;“显示内部名称”是高级开关。
- 图谱从主导航移除,因为它不是普通记事的高频操作。
## 6. 两套视觉方向
### A. 安静笔记工作台(推荐)
- 单浅色侧栏,无深色图标轨道。
- 白色编辑区、浅灰绿导航、深灰文字、叶绿交互强调。
- 类似 Craft、Bear 与 Obsidian 的折中:清晰但不过度装饰。
- 优点:普通用户最容易理解,现有代码改造边界清楚,手机也容易统一。
- 代价:与当前活动轨道布局差异较大,需要重做侧栏组件。
### B. 专业知识工作台
- 保留活动轨道,但图标旁增加可展开文字,并为每项建立真实侧栏视图。
- 支持图谱、属性、反向链接和高级命令的更高信息密度。
- 优点:更接近 Obsidian,技术用户切换功能更快。
- 代价:第一眼仍偏专业,普通用户学习成本更高,移动端需另一套导航。
## 7. 推荐 V2 范围
本轮做:
- 方案 A 的桌面与手机导航。
- 搜索、新建、文件、标签、待办五条普通用户路径。
- 系统页/系统标签过滤。
- 统一弹层、通知、按钮和 Markdown 组件视觉。
- 首页、最近使用和日记的友好显示名。
- 设置与高级工具分层。
- Service Worker 自动更新修复。
本轮不做:
- 多标签页编辑。
- 数据库或存储格式改造。
- AI 功能。
- 插件市场。
- 完整团队协作。
- `.txt`/`.html` 编辑器;先留在后续文件类型阶段。
## 8. 已确认的决定
1. 采用视觉方向 A。
2. 主导航采用“笔记、搜索、待办、标签”四项,图谱进入高级工具。
3. 默认隐藏 `Library`、`Repositories`、`Std`、`CONFIG` 与所有系统标签,但不删除文件。
4. 首页改为随手记、最近编辑、今天待办和今天日记。
5. 命令面板默认只在“设置 > 高级工具”出现,同时保留快捷键。
V2 规格与可点击 Demo 已据此制作;不改正式运行代码,不部署。