Files
plainleaf/docs/design-v2/app.js
T
2026-08-12 16:52:01 +08:00

797 lines
34 KiB
JavaScript

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();