docs: add interactive Plainleaf workspace demo
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
const notes = [
|
||||
{
|
||||
id: "plainleaf-plan",
|
||||
title: "Plainleaf 改造计划",
|
||||
path: "项目/Plainleaf/Plainleaf 改造计划",
|
||||
updated: "今天 10:42",
|
||||
favorite: true,
|
||||
tags: ["产品", "设计", "SilverBullet"],
|
||||
content: `
|
||||
<p>Plainleaf 会保留 SilverBullet 最重要的基础:每一篇笔记仍然是 NAS 中可以直接打开的 Markdown 文件。</p>
|
||||
<blockquote>目标不是复制 Obsidian,而是让文件、搜索和上下文工具变得可见,同时保留 SilverBullet 的查询与自动化能力。</blockquote>
|
||||
<h2>第一阶段目标</h2>
|
||||
<ul>
|
||||
<li>建立常驻的文件树与快速搜索。</li>
|
||||
<li>保留现有 <code>CodeMirror 6</code> 编辑器和实时预览。</li>
|
||||
<li>增加大纲、反向链接、属性和页面信息。</li>
|
||||
<li>为手机设计专用的快速记录流程。</li>
|
||||
</ul>
|
||||
<h2>数据原则</h2>
|
||||
<p>界面只是工作层,文件才是长期资产。即使以后停止使用 Plainleaf,也可以通过 <a href="#">Obsidian</a>、VS Code 或普通文本编辑器继续使用全部内容。</p>
|
||||
<h2>下一步</h2>
|
||||
<p>交互 Demo 确认后,将工作区外壳接到 SilverBullet 的 <span class="wiki-link">[[页面索引]]</span> 与现有导航命令上。</p>
|
||||
`,
|
||||
outline: ["第一阶段目标", "数据原则", "下一步"],
|
||||
backlinks: [
|
||||
["产品路线图", "将 Plainleaf 改造计划作为第一阶段的体验基线。"],
|
||||
["极空间部署", "新版上线时继续挂载同一个 Markdown Space。"],
|
||||
["笔记工具对比", "最终选择基于 SilverBullet 构建 Plainleaf。"]
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zspace",
|
||||
title: "极空间部署",
|
||||
path: "知识库/Docker/极空间部署",
|
||||
updated: "昨天 22:16",
|
||||
favorite: true,
|
||||
tags: ["Docker", "NAS", "部署"],
|
||||
content: `
|
||||
<p>极空间负责运行正式环境,Mac 用于开发和构建镜像。笔记目录通过卷挂载到容器的 <code>/space</code>。</p>
|
||||
<h2>部署结构</h2>
|
||||
<ul><li>固定镜像版本,避免使用 latest。</li><li>笔记目录和应用配置分别备份。</li><li>公网访问统一经过 HTTPS 与反向代理。</li></ul>
|
||||
<h2>更新流程</h2>
|
||||
<p>在本地完成构建和验证后,推送自定义镜像,再由极空间 Compose 切换版本。</p>
|
||||
`,
|
||||
outline: ["部署结构", "更新流程"],
|
||||
backlinks: [["Plainleaf 改造计划", "正式版继续使用极空间现有的 Markdown 目录。"]]
|
||||
},
|
||||
{
|
||||
id: "daily",
|
||||
title: "2026-08-12",
|
||||
path: "日记/2026/08/2026-08-12",
|
||||
updated: "今天 08:30",
|
||||
favorite: false,
|
||||
tags: ["日记"],
|
||||
content: `
|
||||
<p>今天继续整理个人笔记系统,希望数据保持开放,同时让 Web 端变得更易用。</p>
|
||||
<h2>今日事项</h2>
|
||||
<ul><li>确认 Plainleaf 的工作区方向。</li><li>完成桌面与手机交互 Demo。</li><li>整理正式开发任务。</li></ul>
|
||||
<h2>记录</h2>
|
||||
<p>文件树应该承担定位,而不是让用户记住所有命令。高级能力可以继续通过命令面板进入。</p>
|
||||
`,
|
||||
outline: ["今日事项", "记录"],
|
||||
backlinks: []
|
||||
},
|
||||
{
|
||||
id: "docker-network",
|
||||
title: "Docker 容器网络",
|
||||
path: "知识库/Docker/Docker 容器网络",
|
||||
updated: "8 月 10 日",
|
||||
favorite: false,
|
||||
tags: ["Docker", "网络"],
|
||||
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>
|
||||
`,
|
||||
outline: ["服务发现", "排查顺序"],
|
||||
backlinks: [["极空间部署", "部署时先确认容器网络和服务发现。"]]
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
title: "笔记工具对比",
|
||||
path: "收件箱/笔记工具对比",
|
||||
updated: "8 月 11 日",
|
||||
favorite: false,
|
||||
tags: ["调研", "笔记"],
|
||||
content: `
|
||||
<p>对比了 Blossom、Blinko、Notesnook、SilverBullet 和 Flatnotes。</p>
|
||||
<h2>核心结论</h2>
|
||||
<p>数据库优先的产品通常拥有更现代的成品体验,但文件原生模型更符合长期迁移与多工具使用的要求。</p>
|
||||
<h2>选择</h2>
|
||||
<p>以 SilverBullet 为文件与能力内核,在其上构建 Plainleaf 的现代工作区。</p>
|
||||
`,
|
||||
outline: ["核心结论", "选择"],
|
||||
backlinks: [["Plainleaf 改造计划", "选型结论推动了 Plainleaf 项目。"]]
|
||||
},
|
||||
{
|
||||
id: "ideas",
|
||||
title: "产品灵感收件箱",
|
||||
path: "收件箱/产品灵感收件箱",
|
||||
updated: "8 月 9 日",
|
||||
favorite: false,
|
||||
tags: ["灵感"],
|
||||
content: `
|
||||
<p>临时想法先进入收件箱,稍后再归档到项目、知识库或日记。</p>
|
||||
<h2>待整理</h2>
|
||||
<ul><li>浏览器剪藏入口。</li><li>手机语音快速记录。</li><li>Markdown 文件历史对比。</li></ul>
|
||||
`,
|
||||
outline: ["待整理"],
|
||||
backlinks: []
|
||||
}
|
||||
];
|
||||
|
||||
const tree = [
|
||||
{ type: "folder", name: "收件箱", children: ["tools", "ideas"] },
|
||||
{
|
||||
type: "folder",
|
||||
name: "项目",
|
||||
children: [
|
||||
{ type: "folder", name: "Plainleaf", children: ["plainleaf-plan"] },
|
||||
{ type: "folder", name: "网站改版", children: [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "folder",
|
||||
name: "知识库",
|
||||
children: [
|
||||
{ type: "folder", name: "Docker", children: ["zspace", "docker-network"] },
|
||||
{ type: "folder", name: "AI", children: [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "folder",
|
||||
name: "日记",
|
||||
children: [{ type: "folder", name: "2026", children: [{ type: "folder", name: "08", children: ["daily"] }] }]
|
||||
}
|
||||
];
|
||||
|
||||
const state = {
|
||||
currentNoteId: "plainleaf-plan",
|
||||
tool: "files",
|
||||
context: "outline",
|
||||
mode: "edit",
|
||||
theme: "light",
|
||||
offline: false,
|
||||
searchIndex: 0,
|
||||
expandedFolders: new Set(["收件箱", "项目", "项目/Plainleaf", "知识库", "知识库/Docker", "日记", "日记/2026", "日记/2026/08"])
|
||||
};
|
||||
|
||||
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 renderTreeNode(node, depth = 0, parentPath = "") {
|
||||
if (typeof node === "string") {
|
||||
const note = notes.find((item) => item.id === node);
|
||||
return `
|
||||
<div class="tree-row ${note.id === state.currentNoteId ? "is-active" : ""}" data-note-id="${note.id}" style="padding-left:${7 + depth * 15}px">
|
||||
${icon("chevron-right", "disclosure is-placeholder")}
|
||||
${icon("file-text")}
|
||||
<span class="row-label">${note.title}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const path = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
const expanded = state.expandedFolders.has(path);
|
||||
const children = node.children.map((child) => renderTreeNode(child, depth + 1, path)).join("");
|
||||
return `
|
||||
<div class="tree-folder">
|
||||
<div class="tree-row" data-folder-path="${path}" style="padding-left:${7 + depth * 15}px">
|
||||
${icon(expanded ? "chevron-down" : "chevron-right", "disclosure")}
|
||||
${icon(expanded ? "folder-open" : "folder")}
|
||||
<span class="row-label">${node.name}</span>
|
||||
${node.children.length ? `<span class="row-meta">${node.children.length}</span>` : ""}
|
||||
</div>
|
||||
<div class="folder-children ${expanded ? "" : "is-collapsed"}">${children}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function filesViewMarkup() {
|
||||
const favoriteRows = notes.filter((note) => note.favorite).map((note) => `
|
||||
<div class="nav-row" data-note-id="${note.id}">
|
||||
${icon("star")}
|
||||
<span class="row-label">${note.title}</span>
|
||||
</div>`).join("");
|
||||
const recentRows = notes.slice(0, 3).map((note) => `
|
||||
<div class="nav-row" data-note-id="${note.id}">
|
||||
${icon("history")}
|
||||
<span class="row-label">${note.title}</span>
|
||||
<span class="row-meta">${note.updated.split(" ")[0]}</span>
|
||||
</div>`).join("");
|
||||
return `
|
||||
<section class="nav-section">
|
||||
<div class="section-heading"><span>收藏</span><button title="管理收藏" aria-label="管理收藏">${icon("ellipsis")}</button></div>
|
||||
${favoriteRows}
|
||||
</section>
|
||||
<section class="nav-section">
|
||||
<div class="section-heading"><span>最近</span><button title="清除最近记录" aria-label="清除最近记录">${icon("rotate-ccw")}</button></div>
|
||||
${recentRows}
|
||||
</section>
|
||||
<section class="nav-section">
|
||||
<div class="section-heading"><span>文件</span><button id="inlineNewNote" title="新建笔记" aria-label="新建笔记">${icon("plus")}</button></div>
|
||||
<div class="file-tree">${tree.map((node) => renderTreeNode(node)).join("")}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function searchViewMarkup() {
|
||||
return `
|
||||
<section class="nav-section">
|
||||
<div class="section-heading"><span>搜索建议</span></div>
|
||||
${["最近修改的笔记", "包含待办的页面", "没有标签的笔记", "本周创建的页面"].map((label, index) => `
|
||||
<div class="nav-row" data-open-search="${label}">
|
||||
${icon(index === 0 ? "clock-3" : index === 1 ? "list-checks" : index === 2 ? "tag" : "calendar-days")}
|
||||
<span class="row-label">${label}</span>
|
||||
</div>`).join("")}
|
||||
</section>
|
||||
<section class="nav-section">
|
||||
<div class="section-heading"><span>搜索历史</span></div>
|
||||
${["Docker", "Plainleaf", "部署"].map((label) => `<div class="nav-row" data-open-search="${label}">${icon("search")}<span class="row-label">${label}</span></div>`).join("")}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function tagsViewMarkup() {
|
||||
const tags = [["Docker", 3], ["产品", 2], ["设计", 2], ["NAS", 2], ["日记", 1], ["灵感", 1], ["部署", 1]];
|
||||
return `<section class="nav-section"><div class="section-heading"><span>所有标签</span><span>${tags.length}</span></div>${tags.map(([name, count]) => `<div class="tag-row">${icon("hash")}<span class="row-label">${name}</span><span class="row-meta">${count}</span></div>`).join("")}</section>`;
|
||||
}
|
||||
|
||||
function tasksViewMarkup() {
|
||||
const tasks = [["完成交互 Demo", false, "今天"], ["确认文件树布局", true, "今天"], ["整理开发任务", false, "明天"], ["构建极空间镜像", false, "稍后"]];
|
||||
return `<section class="nav-section"><div class="section-heading"><span>开放任务</span><span>3</span></div>${tasks.map(([name, done, date]) => `<div class="task-row">${icon(done ? "circle-check-big" : "circle")}<span class="row-label" style="${done ? "text-decoration:line-through;opacity:.65" : ""}">${name}</span><span class="row-meta">${date}</span></div>`).join("")}</section>`;
|
||||
}
|
||||
|
||||
function graphViewMarkup() {
|
||||
return `<div class="empty-state">${icon("network")}<h3>关系图保留为高级视图</h3><p>正式实现将复用 SilverBullet 现有对象索引和关系图能力。</p></div>`;
|
||||
}
|
||||
|
||||
function bindNavigationRows(root = byId("navigationContent"), mobileSheet = false) {
|
||||
root.querySelectorAll("[data-note-id]").forEach((row) => row.addEventListener("click", () => openNote(row.dataset.noteId)));
|
||||
root.querySelectorAll("[data-folder-path]").forEach((row) => row.addEventListener("click", () => {
|
||||
const path = row.dataset.folderPath;
|
||||
if (state.expandedFolders.has(path)) state.expandedFolders.delete(path);
|
||||
else state.expandedFolders.add(path);
|
||||
if (mobileSheet) renderMobileSheet("files");
|
||||
else renderNavigation();
|
||||
}));
|
||||
root.querySelectorAll("[data-open-search]").forEach((row) => row.addEventListener("click", () => openSearch(row.dataset.openSearch)));
|
||||
root.querySelector("#inlineNewNote")?.addEventListener("click", openNewNote);
|
||||
}
|
||||
|
||||
function renderNavigation() {
|
||||
const titles = { files: "文件", search: "搜索", tags: "标签", tasks: "任务", graph: "关系图" };
|
||||
byId("paneTitle").textContent = titles[state.tool];
|
||||
const views = { files: filesViewMarkup, search: searchViewMarkup, tags: tagsViewMarkup, tasks: tasksViewMarkup, graph: graphViewMarkup };
|
||||
byId("navigationContent").innerHTML = views[state.tool]();
|
||||
bindNavigationRows();
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function renderBreadcrumb(note) {
|
||||
const segments = note.path.split("/");
|
||||
byId("breadcrumb").innerHTML = segments.map((segment, index) => `${index ? icon("chevron-right") : ""}<span>${segment}</span>`).join("");
|
||||
}
|
||||
|
||||
function renderContext() {
|
||||
const note = currentNote();
|
||||
const heading = { outline: "大纲", backlinks: "反向链接", properties: "属性", info: "页面信息" }[state.context];
|
||||
let body = "";
|
||||
if (state.context === "outline") {
|
||||
body = `<div class="outline-list">${note.outline.length ? note.outline.map((item, index) => `<button class="outline-item ${index === 0 ? "is-active" : ""}" type="button">${item}</button>`).join("") : `<div class="empty-state">${icon("list-tree")}<h3>没有大纲</h3><p>添加二级或三级标题后会显示在这里。</p></div>`}</div>`;
|
||||
} else if (state.context === "backlinks") {
|
||||
body = `<div class="backlink-list">${note.backlinks.length ? note.backlinks.map(([source, excerpt]) => `<article class="backlink-item"><div class="backlink-source">${icon("link-2")}<span>${source}</span></div><p class="backlink-excerpt">${excerpt}</p></article>`).join("") : `<div class="empty-state">${icon("links")}<h3>还没有反向链接</h3><p>其他页面引用当前笔记后会显示在这里。</p></div>`}</div>`;
|
||||
} else if (state.context === "properties") {
|
||||
body = `<dl class="property-list"><div class="property-row"><dt>tags</dt><dd>${note.tags.map((tag) => `<span class="tag-chip">${tag}</span>`).join("")}</dd></div><div class="property-row"><dt>status</dt><dd>active</dd></div><div class="property-row"><dt>created</dt><dd>2026-08-12</dd></div><div class="property-row"><dt>aliases</dt><dd>暂无</dd></div></dl>`;
|
||||
} else {
|
||||
body = `<dl class="info-list"><div class="info-row"><dt>文件</dt><dd>${note.path}.md</dd></div><div class="info-row"><dt>修改</dt><dd>${note.updated}</dd></div><div class="info-row"><dt>编码</dt><dd>UTF-8</dd></div><div class="info-row"><dt>大小</dt><dd>3.4 KB</dd></div><div class="info-row"><dt>权限</dt><dd>读写</dd></div></dl>`;
|
||||
}
|
||||
byId("contextContent").innerHTML = `<h2 class="context-title">${heading}</h2>${body}`;
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function renderPreview(note) {
|
||||
byId("documentPreview").innerHTML = `<h1>${note.title}</h1>${note.content}`;
|
||||
}
|
||||
|
||||
function openNote(id) {
|
||||
const note = notes.find((item) => item.id === id);
|
||||
if (!note) return;
|
||||
state.currentNoteId = id;
|
||||
byId("pageTitleInput").value = note.title;
|
||||
byId("editorContent").innerHTML = note.content;
|
||||
byId("updatedTime").textContent = `${note.updated} 更新`;
|
||||
byId("pageLocation").textContent = note.path.split("/").slice(0, -1).join(" / ") || "根目录";
|
||||
renderBreadcrumb(note);
|
||||
renderPreview(note);
|
||||
renderContext();
|
||||
renderNavigation();
|
||||
closeAllOverlays();
|
||||
byId("mobileSheetOverlay").classList.remove("is-visible");
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
state.mode = mode;
|
||||
byId("documentStage").dataset.mode = mode;
|
||||
document.querySelectorAll(".segment").forEach((button) => button.classList.toggle("is-active", button.dataset.mode === mode));
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
state.theme = theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
const iconName = theme === "dark" ? "sun" : "moon";
|
||||
byId("themeButton").innerHTML = icon(iconName);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(state.theme === "light" ? "dark" : "light");
|
||||
}
|
||||
|
||||
function setOffline(offline) {
|
||||
state.offline = offline;
|
||||
byId("offlineBanner").classList.toggle("is-visible", offline);
|
||||
byId("connectionLabel").textContent = offline ? "离线" : "已连接";
|
||||
byId("connectionButton").innerHTML = `${icon(offline ? "cloud-off" : "cloud-check")}<span id="connectionLabel">${offline ? "离线" : "已连接"}</span>`;
|
||||
byId("navStatusDot").classList.toggle("is-online", !offline);
|
||||
byId("navStatusLabel").textContent = offline ? "等待连接后同步" : "本地文件已同步";
|
||||
byId("menuOfflineButton").querySelector("span").textContent = offline ? "恢复在线" : "模拟离线";
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
let saveTimer;
|
||||
function markChanged() {
|
||||
clearTimeout(saveTimer);
|
||||
byId("saveState").className = "save-state is-saving";
|
||||
byId("saveState").innerHTML = `${icon("loader-circle")}<span>保存中</span>`;
|
||||
refreshIcons();
|
||||
saveTimer = setTimeout(() => {
|
||||
byId("saveState").className = "save-state is-saved";
|
||||
byId("saveState").innerHTML = `${icon("check")}<span>已保存</span>`;
|
||||
refreshIcons();
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function filteredNotes(query) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return notes.slice(0, 5);
|
||||
return notes.filter((note) => `${note.title} ${note.path} ${note.tags.join(" ")} ${note.content.replace(/<[^>]+>/g, " ")}`.toLowerCase().includes(normalized));
|
||||
}
|
||||
|
||||
function renderSearchResults(query = "") {
|
||||
const results = filteredNotes(query);
|
||||
state.searchIndex = Math.min(state.searchIndex, Math.max(results.length - 1, 0));
|
||||
byId("searchResults").innerHTML = results.length ? results.map((note, index) => `
|
||||
<div class="search-result ${index === state.searchIndex ? "is-selected" : ""}" data-search-note="${note.id}">
|
||||
<div class="search-result-icon">${icon("file-text")}</div>
|
||||
<div class="search-result-copy"><div class="search-result-title">${note.title}</div><div class="search-result-path">${note.path}.md</div></div>
|
||||
<span class="row-meta">${note.updated}</span>
|
||||
</div>`).join("") : `<div class="empty-state">${icon("search-x")}<h3>没有找到笔记</h3><p>保留当前搜索词,可以直接创建同名 Markdown 文件。</p></div>`;
|
||||
byId("searchResults").querySelectorAll("[data-search-note]").forEach((row) => row.addEventListener("click", () => openNote(row.dataset.searchNote)));
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function openSearch(query = "") {
|
||||
state.searchIndex = 0;
|
||||
byId("searchOverlay").classList.add("is-visible");
|
||||
byId("searchOverlay").setAttribute("aria-hidden", "false");
|
||||
byId("globalSearchInput").value = query;
|
||||
renderSearchResults(query);
|
||||
requestAnimationFrame(() => byId("globalSearchInput").focus());
|
||||
}
|
||||
|
||||
function openNewNote() {
|
||||
byId("newNoteOverlay").classList.add("is-visible");
|
||||
byId("newNoteOverlay").setAttribute("aria-hidden", "false");
|
||||
byId("newNoteName").value = "";
|
||||
requestAnimationFrame(() => byId("newNoteName").focus());
|
||||
}
|
||||
|
||||
function closeAllOverlays() {
|
||||
document.querySelectorAll(".overlay").forEach((overlay) => {
|
||||
overlay.classList.remove("is-visible");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
|
||||
function createNote() {
|
||||
const title = byId("newNoteName").value.trim() || "未命名笔记";
|
||||
const folder = byId("newNoteFolder").value;
|
||||
const id = `new-${Date.now()}`;
|
||||
const note = {
|
||||
id,
|
||||
title,
|
||||
path: `${folder}/${title}`,
|
||||
updated: "刚刚",
|
||||
favorite: false,
|
||||
tags: [],
|
||||
content: `<p>开始记录...</p>`,
|
||||
outline: [],
|
||||
backlinks: []
|
||||
};
|
||||
notes.unshift(note);
|
||||
const targetFolder = findFolder(tree, folder.split("/"));
|
||||
if (targetFolder) targetFolder.children.unshift(id);
|
||||
state.expandedFolders.add(folder);
|
||||
openNote(id);
|
||||
requestAnimationFrame(() => byId("editorContent").focus());
|
||||
}
|
||||
|
||||
function findFolder(nodes, parts) {
|
||||
const [head, ...rest] = parts;
|
||||
const found = nodes.find((node) => typeof node !== "string" && node.name === head);
|
||||
if (!found) return null;
|
||||
if (!rest.length) return found;
|
||||
return findFolder(found.children.filter((child) => typeof child !== "string"), rest);
|
||||
}
|
||||
|
||||
function renderMobileSheet(kind) {
|
||||
const title = kind === "links" ? "反向链接" : "文件";
|
||||
byId("mobileSheetTitle").textContent = title;
|
||||
if (kind === "links") {
|
||||
const note = currentNote();
|
||||
byId("mobileSheetContent").innerHTML = note.backlinks.length ? `<div class="backlink-list">${note.backlinks.map(([source, excerpt]) => `<article class="backlink-item"><div class="backlink-source">${icon("link-2")}<span>${source}</span></div><p class="backlink-excerpt">${excerpt}</p></article>`).join("")}</div>` : `<div class="empty-state">${icon("links")}<h3>还没有反向链接</h3><p>其他页面引用当前笔记后会显示在这里。</p></div>`;
|
||||
} else {
|
||||
byId("mobileSheetContent").innerHTML = `<div class="file-tree">${tree.map((node) => renderTreeNode(node)).join("")}</div>`;
|
||||
bindNavigationRows(byId("mobileSheetContent"), true);
|
||||
}
|
||||
byId("mobileSheetOverlay").classList.add("is-visible");
|
||||
byId("mobileSheetOverlay").setAttribute("aria-hidden", "false");
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function setupResizer(resizer, side) {
|
||||
resizer.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
resizer.setPointerCapture(event.pointerId);
|
||||
resizer.classList.add("is-dragging");
|
||||
const onMove = (moveEvent) => {
|
||||
const value = side === "left" ? moveEvent.clientX - 48 : window.innerWidth - moveEvent.clientX;
|
||||
document.documentElement.style.setProperty(side === "left" ? "--nav-width" : "--context-width", `${Math.max(side === "left" ? 220 : 240, Math.min(side === "left" ? 380 : 380, value))}px`);
|
||||
};
|
||||
const onUp = () => {
|
||||
resizer.classList.remove("is-dragging");
|
||||
resizer.removeEventListener("pointermove", onMove);
|
||||
resizer.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
resizer.addEventListener("pointermove", onMove);
|
||||
resizer.addEventListener("pointerup", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
function initEvents() {
|
||||
document.querySelectorAll("[data-tool]").forEach((button) => button.addEventListener("click", () => {
|
||||
state.tool = button.dataset.tool;
|
||||
document.querySelectorAll("[data-tool]").forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
renderNavigation();
|
||||
}));
|
||||
document.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode)));
|
||||
document.querySelectorAll("[data-context]").forEach((button) => button.addEventListener("click", () => {
|
||||
state.context = button.dataset.context;
|
||||
document.querySelectorAll("[data-context]").forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
renderContext();
|
||||
}));
|
||||
document.querySelectorAll("[data-mobile-action]").forEach((button) => button.addEventListener("click", () => {
|
||||
const action = button.dataset.mobileAction;
|
||||
if (action === "files") renderMobileSheet("files");
|
||||
if (action === "search") openSearch();
|
||||
if (action === "new") openNewNote();
|
||||
if (action === "links") renderMobileSheet("links");
|
||||
if (action === "more") byId("moreMenu").classList.toggle("is-visible");
|
||||
}));
|
||||
byId("searchTrigger").addEventListener("click", () => openSearch());
|
||||
byId("inPageSearchButton").addEventListener("click", () => openSearch(currentNote().title));
|
||||
byId("newNoteButton").addEventListener("click", openNewNote);
|
||||
byId("createNoteButton").addEventListener("click", createNote);
|
||||
document.querySelectorAll(".close-overlay-button").forEach((button) => button.addEventListener("click", closeAllOverlays));
|
||||
byId("globalSearchInput").addEventListener("input", (event) => {
|
||||
state.searchIndex = 0;
|
||||
renderSearchResults(event.target.value);
|
||||
});
|
||||
byId("globalSearchInput").addEventListener("keydown", (event) => {
|
||||
const results = filteredNotes(event.currentTarget.value);
|
||||
if (event.key === "ArrowDown" && results.length) {
|
||||
event.preventDefault(); state.searchIndex = (state.searchIndex + 1) % results.length; renderSearchResults(event.currentTarget.value);
|
||||
}
|
||||
if (event.key === "ArrowUp" && results.length) {
|
||||
event.preventDefault(); state.searchIndex = (state.searchIndex - 1 + results.length) % results.length; renderSearchResults(event.currentTarget.value);
|
||||
}
|
||||
if (event.key === "Enter" && results[state.searchIndex]) openNote(results[state.searchIndex].id);
|
||||
});
|
||||
byId("pageTitleInput").addEventListener("input", (event) => {
|
||||
const note = currentNote();
|
||||
note.title = event.target.value;
|
||||
const pathParts = note.path.split("/");
|
||||
pathParts[pathParts.length - 1] = note.title;
|
||||
note.path = pathParts.join("/");
|
||||
renderBreadcrumb(note);
|
||||
renderPreview(note);
|
||||
renderNavigation();
|
||||
markChanged();
|
||||
});
|
||||
byId("editorContent").addEventListener("input", () => {
|
||||
currentNote().content = byId("editorContent").innerHTML;
|
||||
renderPreview(currentNote());
|
||||
const count = byId("editorContent").innerText.replace(/\s+/g, "").length;
|
||||
byId("wordCount").textContent = `${count} 字`;
|
||||
markChanged();
|
||||
});
|
||||
byId("themeButton").addEventListener("click", toggleTheme);
|
||||
byId("menuThemeButton").addEventListener("click", () => { toggleTheme(); byId("moreMenu").classList.remove("is-visible"); });
|
||||
byId("connectionButton").addEventListener("click", () => setOffline(!state.offline));
|
||||
byId("menuOfflineButton").addEventListener("click", () => { setOffline(!state.offline); byId("moreMenu").classList.remove("is-visible"); });
|
||||
byId("moreButton").addEventListener("click", (event) => { event.stopPropagation(); byId("moreMenu").classList.toggle("is-visible"); });
|
||||
byId("collapseNavButton").addEventListener("click", () => byId("appShell").classList.add("is-nav-collapsed"));
|
||||
byId("showNavButton").addEventListener("click", () => {
|
||||
if (window.innerWidth <= 760) renderMobileSheet("files");
|
||||
else byId("appShell").classList.remove("is-nav-collapsed");
|
||||
});
|
||||
byId("toggleContextButton").addEventListener("click", () => byId("appShell").classList.toggle("is-context-collapsed"));
|
||||
byId("closeContextButton").addEventListener("click", () => byId("appShell").classList.add("is-context-collapsed"));
|
||||
byId("closeMobileSheet").addEventListener("click", closeAllOverlays);
|
||||
document.querySelectorAll(".overlay").forEach((overlay) => overlay.addEventListener("pointerdown", (event) => { if (event.target === overlay) closeAllOverlays(); }));
|
||||
document.addEventListener("click", (event) => { if (!byId("moreMenu").contains(event.target) && event.target !== byId("moreButton")) byId("moreMenu").classList.remove("is-visible"); });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") { closeAllOverlays(); byId("moreMenu").classList.remove("is-visible"); }
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); openSearch(); }
|
||||
});
|
||||
setupResizer(byId("navResizer"), "left");
|
||||
setupResizer(byId("contextResizer"), "right");
|
||||
}
|
||||
|
||||
function init() {
|
||||
byId("documentStage").dataset.mode = state.mode;
|
||||
renderNavigation();
|
||||
openNote(state.currentNoteId);
|
||||
initEvents();
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f7f8f6" />
|
||||
<title>Plainleaf Workspace Demo</title>
|
||||
<link rel="preconnect" href="https://unpkg.com" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell" id="appShell">
|
||||
<aside class="activity-rail" aria-label="工作区工具">
|
||||
<button class="brand-mark" type="button" title="Plainleaf" aria-label="Plainleaf">
|
||||
<i data-lucide="leaf"></i>
|
||||
</button>
|
||||
|
||||
<nav class="rail-tools" aria-label="主要工具">
|
||||
<button class="rail-button is-active" type="button" data-tool="files" title="文件" aria-label="文件">
|
||||
<i data-lucide="files"></i>
|
||||
</button>
|
||||
<button class="rail-button" type="button" data-tool="search" title="搜索" aria-label="搜索">
|
||||
<i data-lucide="search"></i>
|
||||
</button>
|
||||
<button class="rail-button" type="button" data-tool="tags" title="标签" aria-label="标签">
|
||||
<i data-lucide="tags"></i>
|
||||
</button>
|
||||
<button class="rail-button" type="button" data-tool="tasks" title="任务" aria-label="任务">
|
||||
<i data-lucide="circle-check-big"></i>
|
||||
</button>
|
||||
<button class="rail-button" type="button" data-tool="graph" title="关系图" aria-label="关系图">
|
||||
<i data-lucide="network"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="rail-footer">
|
||||
<button class="rail-button" id="themeButton" type="button" title="切换主题" aria-label="切换主题">
|
||||
<i data-lucide="moon"></i>
|
||||
</button>
|
||||
<button class="rail-button" type="button" title="设置" aria-label="设置">
|
||||
<i data-lucide="settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="navigation-pane" id="navigationPane" aria-label="导航面板">
|
||||
<div class="pane-header">
|
||||
<div>
|
||||
<p class="workspace-kicker">MY SPACE</p>
|
||||
<h1 id="paneTitle">文件</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-button" id="newNoteButton" type="button" title="新建笔记" aria-label="新建笔记">
|
||||
<i data-lucide="file-plus-2"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" title="更多文件操作" aria-label="更多文件操作">
|
||||
<i data-lucide="ellipsis"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="search-trigger" id="searchTrigger" type="button">
|
||||
<i data-lucide="search"></i>
|
||||
<span>搜索笔记</span>
|
||||
<kbd>⌘ K</kbd>
|
||||
</button>
|
||||
|
||||
<div class="navigation-content" id="navigationContent"></div>
|
||||
|
||||
<div class="navigation-footer">
|
||||
<div class="sync-summary">
|
||||
<span class="status-dot is-online" id="navStatusDot"></span>
|
||||
<span id="navStatusLabel">本地文件已同步</span>
|
||||
</div>
|
||||
<button class="icon-button" id="collapseNavButton" type="button" title="收起文件栏" aria-label="收起文件栏">
|
||||
<i data-lucide="panel-left-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="pane-resizer nav-resizer" id="navResizer" role="separator" aria-orientation="vertical"></div>
|
||||
|
||||
<main class="workspace" id="workspace">
|
||||
<header class="workspace-toolbar">
|
||||
<div class="toolbar-leading">
|
||||
<button class="icon-button show-nav-button" id="showNavButton" type="button" title="显示文件栏" aria-label="显示文件栏">
|
||||
<i data-lucide="panel-left-open"></i>
|
||||
</button>
|
||||
<div class="breadcrumb" id="breadcrumb" aria-label="当前路径"></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<div class="save-state is-saved" id="saveState" aria-live="polite">
|
||||
<i data-lucide="check"></i>
|
||||
<span>已保存</span>
|
||||
</div>
|
||||
<div class="segmented-control" aria-label="文档模式">
|
||||
<button class="segment is-active" type="button" data-mode="edit" title="编辑模式">编辑</button>
|
||||
<button class="segment" type="button" data-mode="read" title="阅读模式">阅读</button>
|
||||
<button class="segment" type="button" data-mode="split" title="分屏模式">分屏</button>
|
||||
</div>
|
||||
<button class="icon-button" id="inPageSearchButton" type="button" title="页内搜索" aria-label="页内搜索">
|
||||
<i data-lucide="search"></i>
|
||||
</button>
|
||||
<button class="icon-button" id="toggleContextButton" type="button" title="切换上下文栏" aria-label="切换上下文栏">
|
||||
<i data-lucide="panel-right"></i>
|
||||
</button>
|
||||
<button class="icon-button" id="moreButton" type="button" title="更多" aria-label="更多">
|
||||
<i data-lucide="ellipsis"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="offline-banner" id="offlineBanner" role="status">
|
||||
<i data-lucide="wifi-off"></i>
|
||||
<span>当前离线,修改会保存在本机并在连接恢复后同步。</span>
|
||||
</div>
|
||||
|
||||
<section class="document-stage" id="documentStage">
|
||||
<article class="document-editor" id="documentEditor">
|
||||
<input class="page-title-input" id="pageTitleInput" aria-label="页面标题" />
|
||||
<div class="page-meta-line">
|
||||
<span id="updatedTime">今天 10:42 更新</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span id="pageLocation">项目 / Plainleaf</span>
|
||||
</div>
|
||||
<div class="editor-content" id="editorContent" contenteditable="true" spellcheck="false" aria-label="笔记正文"></div>
|
||||
</article>
|
||||
|
||||
<div class="split-divider" aria-hidden="true"></div>
|
||||
|
||||
<article class="document-preview" id="documentPreview" aria-label="文档预览"></article>
|
||||
</section>
|
||||
|
||||
<footer class="status-bar">
|
||||
<div class="status-group">
|
||||
<button class="status-button" id="connectionButton" type="button" title="切换离线演示状态">
|
||||
<i data-lucide="cloud-check"></i>
|
||||
<span id="connectionLabel">已连接</span>
|
||||
</button>
|
||||
<span id="wordCount">426 字</span>
|
||||
<span>12 个链接</span>
|
||||
</div>
|
||||
<div class="status-group">
|
||||
<span>第 18 行,第 6 列</span>
|
||||
<span>Markdown</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div class="pane-resizer context-resizer" id="contextResizer" role="separator" aria-orientation="vertical"></div>
|
||||
|
||||
<aside class="context-pane" id="contextPane" aria-label="页面上下文">
|
||||
<div class="context-header">
|
||||
<div class="context-tabs" role="tablist" aria-label="上下文视图">
|
||||
<button class="context-tab is-active" type="button" data-context="outline" title="大纲" aria-label="大纲">
|
||||
<i data-lucide="list-tree"></i>
|
||||
</button>
|
||||
<button class="context-tab" type="button" data-context="backlinks" title="反向链接" aria-label="反向链接">
|
||||
<i data-lucide="links"></i>
|
||||
</button>
|
||||
<button class="context-tab" type="button" data-context="properties" title="属性" aria-label="属性">
|
||||
<i data-lucide="list-filter"></i>
|
||||
</button>
|
||||
<button class="context-tab" type="button" data-context="info" title="页面信息" aria-label="页面信息">
|
||||
<i data-lucide="info"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="icon-button" id="closeContextButton" type="button" title="关闭上下文栏" aria-label="关闭上下文栏">
|
||||
<i data-lucide="panel-right-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="context-content" id="contextContent"></div>
|
||||
</aside>
|
||||
|
||||
<nav class="mobile-navigation" aria-label="手机导航">
|
||||
<button type="button" data-mobile-action="files"><i data-lucide="files"></i><span>文件</span></button>
|
||||
<button type="button" data-mobile-action="search"><i data-lucide="search"></i><span>搜索</span></button>
|
||||
<button class="mobile-create" type="button" data-mobile-action="new" aria-label="新建笔记"><i data-lucide="plus"></i></button>
|
||||
<button type="button" data-mobile-action="links"><i data-lucide="links"></i><span>链接</span></button>
|
||||
<button type="button" data-mobile-action="more"><i data-lucide="ellipsis"></i><span>更多</span></button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="searchOverlay" aria-hidden="true">
|
||||
<section class="command-dialog" role="dialog" aria-modal="true" aria-labelledby="searchDialogTitle">
|
||||
<div class="dialog-search-row">
|
||||
<i data-lucide="search"></i>
|
||||
<input id="globalSearchInput" type="search" placeholder="搜索标题、正文或标签" autocomplete="off" />
|
||||
<kbd>ESC</kbd>
|
||||
</div>
|
||||
<div class="dialog-section-label" id="searchDialogTitle">快速打开</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
<footer class="dialog-footer">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
|
||||
<span><kbd>↵</kbd> 打开</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="newNoteOverlay" aria-hidden="true">
|
||||
<section class="new-note-dialog" role="dialog" aria-modal="true" aria-labelledby="newNoteTitle">
|
||||
<header>
|
||||
<div>
|
||||
<p class="dialog-kicker">NEW MARKDOWN FILE</p>
|
||||
<h2 id="newNoteTitle">新建笔记</h2>
|
||||
</div>
|
||||
<button class="icon-button close-overlay-button" type="button" title="关闭" aria-label="关闭">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</header>
|
||||
<label>
|
||||
<span>文件名</span>
|
||||
<input id="newNoteName" type="text" placeholder="例如:产品思路" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>保存到</span>
|
||||
<select id="newNoteFolder">
|
||||
<option>收件箱</option>
|
||||
<option>项目/Plainleaf</option>
|
||||
<option>知识库/Docker</option>
|
||||
<option>日记</option>
|
||||
</select>
|
||||
</label>
|
||||
<footer>
|
||||
<button class="secondary-button close-overlay-button" type="button">取消</button>
|
||||
<button class="primary-button" id="createNoteButton" type="button">
|
||||
<i data-lucide="file-plus-2"></i>
|
||||
创建笔记
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="overlay mobile-sheet-overlay" id="mobileSheetOverlay" aria-hidden="true">
|
||||
<section class="mobile-sheet" role="dialog" aria-modal="true" aria-label="手机导航面板">
|
||||
<div class="sheet-handle" aria-hidden="true"></div>
|
||||
<header>
|
||||
<h2 id="mobileSheetTitle">文件</h2>
|
||||
<button class="icon-button" id="closeMobileSheet" type="button" title="关闭" aria-label="关闭">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div id="mobileSheetContent"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="popover-menu" id="moreMenu" aria-hidden="true">
|
||||
<button type="button" id="menuThemeButton"><i data-lucide="sun-moon"></i><span>切换主题</span></button>
|
||||
<button type="button" id="menuOfflineButton"><i data-lucide="wifi-off"></i><span>模拟离线</span></button>
|
||||
<button type="button"><i data-lucide="terminal"></i><span>命令面板</span><kbd>⌘ P</kbd></button>
|
||||
<button type="button"><i data-lucide="history"></i><span>页面历史</span></button>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
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:4173/";
|
||||
const outputDir = "/tmp/plainleaf-design-v1";
|
||||
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("#documentEditor");
|
||||
|
||||
const metrics = 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, .pane-header, .workspace-toolbar, .mobile-navigation, .page-title-input")]
|
||||
.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,
|
||||
}));
|
||||
const viewportOverflow = document.documentElement.scrollWidth > innerWidth + 1 || document.documentElement.scrollHeight > innerHeight + 1;
|
||||
return { overflow, viewportOverflow, title: document.querySelector("#pageTitleInput")?.value };
|
||||
});
|
||||
|
||||
if (viewport.width > 760) {
|
||||
await page.locator('[data-tool="search"]').click();
|
||||
await page.locator('[data-open-search="Plainleaf"]').click();
|
||||
await page.waitForSelector("#searchOverlay.is-visible");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator('[data-tool="files"]').click();
|
||||
await page.locator('[data-note-id="zspace"]').first().click();
|
||||
const title = await page.locator("#pageTitleInput").inputValue();
|
||||
if (title !== "极空间部署") failures.push(`${viewport.name}: note navigation failed`);
|
||||
await page.locator('[data-mode="split"]').click();
|
||||
await page.locator("#themeButton").click();
|
||||
} else {
|
||||
await page.locator('[data-mobile-action="files"]').click();
|
||||
await page.waitForSelector("#mobileSheetOverlay.is-visible");
|
||||
await page.locator('#mobileSheetContent [data-note-id="daily"]').click();
|
||||
const title = await page.locator("#pageTitleInput").inputValue();
|
||||
if (title !== "2026-08-12") failures.push(`${viewport.name}: mobile note navigation failed`);
|
||||
await page.locator('[data-mobile-action="search"]').click();
|
||||
await page.waitForSelector("#searchOverlay.is-visible");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await page.screenshot({ path: `${outputDir}/${viewport.name}.png`, fullPage: true });
|
||||
if (errors.length) failures.push(`${viewport.name}: ${errors.join(" | ")}`);
|
||||
if (metrics.viewportOverflow) failures.push(`${viewport.name}: viewport overflow`);
|
||||
if (metrics.overflow.length) failures.push(`${viewport.name}: element overflow ${JSON.stringify(metrics.overflow)}`);
|
||||
results.push({ viewport: viewport.name, ...metrics, errors });
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ baseUrl, outputDir, results, failures }, null, 2));
|
||||
if (failures.length) process.exit(1);
|
||||
@@ -0,0 +1,56 @@
|
||||
# Plainleaf V1 Product Decisions
|
||||
|
||||
Status: Accepted defaults for interactive prototype
|
||||
|
||||
## Product Position
|
||||
|
||||
Plainleaf is a file-native personal knowledge workspace. It keeps
|
||||
SilverBullet's Markdown, local-first, programmable foundation and presents it
|
||||
through a familiar, discoverable workspace.
|
||||
|
||||
The product is inspired by Obsidian's working model, but it is not an Obsidian
|
||||
skin. Plainleaf should be easier to approach, quieter during writing, and retain
|
||||
SilverBullet's scripting, query, and extension strengths.
|
||||
|
||||
## Accepted Decisions
|
||||
|
||||
1. **Compact workbench density.** Navigation is compact while document text
|
||||
remains comfortable to read.
|
||||
2. **Files-first entry.** The file tree is the default navigation surface.
|
||||
Recent notes, favorites, tags, and tasks remain one click away.
|
||||
3. **Single-document V1.** Tabs are deferred until the workspace shell and
|
||||
editor lifecycle are stable.
|
||||
4. **Plainleaf product identity.** The prototype uses the Plainleaf name and a
|
||||
restrained leaf-green accent, while upstream attribution remains in About
|
||||
and repository documentation.
|
||||
5. **Markdown-first V1.** Plain text and sandboxed HTML editing are phase-two
|
||||
capabilities.
|
||||
6. **Native desktop shell, purpose-built mobile shell.** Mobile uses sheets and
|
||||
bottom navigation instead of compressing desktop panes.
|
||||
7. **No storage changes.** Ordinary files in the SilverBullet Space remain the
|
||||
source of truth.
|
||||
|
||||
## V1 Scope
|
||||
|
||||
- Activity rail and persistent file navigation.
|
||||
- Slash-path folder tree.
|
||||
- Recent and favorite notes.
|
||||
- Search and quick switcher.
|
||||
- Existing CodeMirror editor in edit, reading, and split presentation modes.
|
||||
- Collapsible/resizable navigation and context panes.
|
||||
- Outline, backlinks, properties, and page information.
|
||||
- Create, rename, move, and delete entry points backed by existing commands.
|
||||
- Light and dark themes.
|
||||
- Mobile files/search sheets and quick create.
|
||||
- Existing command palette, plugs, Lua, queries, offline sync, and external
|
||||
file reconciliation remain available.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Multi-document tabs.
|
||||
- AI features.
|
||||
- Plugin marketplace.
|
||||
- `.txt` and `.html` editing.
|
||||
- Multi-window desktop behavior.
|
||||
- A new server, database, or sync model.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Plainleaf V1 Design Review
|
||||
|
||||
Status: Approved for interactive prototype
|
||||
|
||||
## Designer Review
|
||||
|
||||
- **Must fix before core implementation:** Validate long Chinese and English
|
||||
filenames, nested folder depth, small desktop widths, and mobile keyboard
|
||||
viewport behavior in the prototype.
|
||||
- **Must fix before core implementation:** Make active, hover, focus, modified,
|
||||
offline, and disabled states visually distinct without relying on color alone.
|
||||
- **Recommended:** Keep the editor title at 30px rather than adopting oversized
|
||||
document headings from marketing-oriented interfaces.
|
||||
- **Later:** User-selectable density and typography presets.
|
||||
|
||||
## Product Review
|
||||
|
||||
- **Must fix before core implementation:** New note, search, rename, and delete
|
||||
must be visible actions, not command-palette-only flows.
|
||||
- **Must fix before core implementation:** The default launch state must work
|
||||
with an empty Space and with thousands of pages.
|
||||
- **Recommended:** Defer tabs. Recent history plus quick switcher is sufficient
|
||||
to validate the workspace model.
|
||||
- **Later:** A configurable home/dashboard page.
|
||||
|
||||
## Architecture Review
|
||||
|
||||
- **Must fix before core implementation:** Do not duplicate `allPages` into a
|
||||
second persistent tree index. Derive a memoized tree view.
|
||||
- **Must fix before core implementation:** Keep a single CodeMirror mount in V1.
|
||||
Presentation modes must not create competing editor instances.
|
||||
- **Must fix before core implementation:** UI commands must invoke existing
|
||||
navigation and file operations so plugins and events continue to work.
|
||||
- **Recommended:** Build the shell from new isolated Preact components and one
|
||||
SCSS entry, minimizing edits to `editor_ui.tsx`.
|
||||
- **Later:** Define stable plug extension slots after the shell behavior is
|
||||
validated.
|
||||
|
||||
## Prototype Gate
|
||||
|
||||
Approved with these checks:
|
||||
|
||||
1. Desktop at 1440x900 and 1024x768.
|
||||
2. Mobile at 390x844 and 430x932.
|
||||
3. File, search, tag, and task rail switching.
|
||||
4. Note switching, folder expansion, pane collapse, resizing, and theme change.
|
||||
5. No overlapping controls or clipped labels at tested sizes.
|
||||
6. Keyboard focus and Escape behavior for overlays.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Plainleaf Workspace Design Specification V1
|
||||
|
||||
Status: Prototype specification
|
||||
|
||||
## 1. Experience Principles
|
||||
|
||||
1. **Files stay legible.** UI labels and actions should reinforce that the user
|
||||
is working with ordinary files and folders.
|
||||
2. **Writing owns the canvas.** Navigation supports the editor and never turns
|
||||
the product into a dashboard of decorative cards.
|
||||
3. **Power is progressive.** Basic actions are visible; Lua, queries, commands,
|
||||
and plug surfaces remain available without dominating first use.
|
||||
4. **Stable geometry.** Toolbars, panes, counters, and controls use stable
|
||||
dimensions so content changes do not shift the workspace.
|
||||
5. **Mobile is for capture, search, and reading first.** Editing remains fully
|
||||
available, but the layout prioritizes common narrow-screen workflows.
|
||||
|
||||
## 2. Design System
|
||||
|
||||
### Color
|
||||
|
||||
Light theme:
|
||||
|
||||
| Token | Value | Use |
|
||||
| --- | --- | --- |
|
||||
| Canvas | `#f7f8f6` | Application background |
|
||||
| Editor | `#ffffff` | Document surface |
|
||||
| Navigation | `#eef1ee` | Left navigation |
|
||||
| Rail | `#202621` | Activity rail |
|
||||
| Primary text | `#20241f` | Headings and body |
|
||||
| Secondary text | `#687169` | Metadata |
|
||||
| Border | `#dce2dc` | Pane and control separation |
|
||||
| Accent | `#397453` | Selection, links, primary action |
|
||||
| Accent soft | `#dcece2` | Selected navigation row |
|
||||
| Informational | `#376d8a` | Links and sync information |
|
||||
| Warning | `#a86718` | Unsaved or degraded state |
|
||||
| Danger | `#b64a4a` | Destructive actions |
|
||||
|
||||
Dark theme:
|
||||
|
||||
| Token | Value | Use |
|
||||
| --- | --- | --- |
|
||||
| Canvas | `#171a18` | Application background |
|
||||
| Editor | `#1e221f` | Document surface |
|
||||
| Navigation | `#202521` | Left navigation |
|
||||
| Rail | `#111411` | Activity rail |
|
||||
| Primary text | `#e8ece8` | Headings and body |
|
||||
| Secondary text | `#9ba69d` | Metadata |
|
||||
| Border | `#343b35` | Pane and control separation |
|
||||
| Accent | `#75b18d` | Selection, links, primary action |
|
||||
| Accent soft | `#283c30` | Selected navigation row |
|
||||
|
||||
Accent green is limited to active state and commands. Blue is reserved for
|
||||
links and sync, amber for pending work, and red for destructive or failed
|
||||
states. The interface should never read as a monochromatic green theme.
|
||||
|
||||
### Typography
|
||||
|
||||
- UI: system sans (`-apple-system`, `Segoe UI`, `Noto Sans SC`, sans-serif).
|
||||
- Editor: `Inter`, `Noto Sans SC`, system sans by default.
|
||||
- Code: `JetBrains Mono`, `SFMono-Regular`, monospace.
|
||||
- Navigation: 13px / 20px.
|
||||
- Controls: 13px / 18px.
|
||||
- Body: 16px / 28px.
|
||||
- Page title: 30px / 38px, weight 650.
|
||||
- Section heading: 21px / 30px, weight 650.
|
||||
- Letter spacing is always `0`.
|
||||
|
||||
### Spacing and Geometry
|
||||
|
||||
- Base spacing unit: 4px.
|
||||
- Activity rail: 48px.
|
||||
- Desktop top bar: 44px.
|
||||
- Navigation pane: default 272px; range 220-380px.
|
||||
- Context pane: default 296px; range 240-380px.
|
||||
- Editor readable width: 760px, with 40px minimum lateral padding.
|
||||
- Icon buttons: 32x32px; mobile primary create: 44x44px.
|
||||
- Control radius: 5px; modal radius: 8px.
|
||||
- Borders, not floating cards, define workspace regions.
|
||||
|
||||
### Interaction
|
||||
|
||||
- Hover: 120ms background/color transition.
|
||||
- Pane expansion: 180ms ease-out.
|
||||
- Sheet/modal entry: 180ms opacity and translate.
|
||||
- Save status changes without moving the title or toolbar.
|
||||
- Tooltips appear for icon-only controls after a short hover delay.
|
||||
- Focus rings remain visible and use the accent color.
|
||||
|
||||
## 3. Navigation Architecture
|
||||
|
||||
```text
|
||||
Plainleaf
|
||||
├── Files
|
||||
│ ├── Favorites
|
||||
│ ├── Recent
|
||||
│ └── Folder tree
|
||||
├── Search
|
||||
│ ├── Title and content search
|
||||
│ └── Saved filters (later)
|
||||
├── Tags
|
||||
├── Tasks
|
||||
├── Graph (existing capability, secondary)
|
||||
├── Command palette
|
||||
└── Settings
|
||||
```
|
||||
|
||||
The activity rail changes the navigation pane's content. It does not navigate
|
||||
away from the open document.
|
||||
|
||||
## 4. Desktop Workspace
|
||||
|
||||
### Activity Rail
|
||||
|
||||
- Plainleaf mark at the top.
|
||||
- Files, Search, Tags, Tasks, and Graph tools.
|
||||
- Settings, theme, and account/about actions at the bottom.
|
||||
- The active tool has a left indicator and a stronger icon color.
|
||||
|
||||
### Navigation Pane
|
||||
|
||||
- Header names the current tool and exposes contextual add/more actions.
|
||||
- Files view shows quick search, favorites, recent, then the tree.
|
||||
- Rows are 28px high. Folder disclosure, icon, label, and optional count have
|
||||
fixed positions.
|
||||
- Clicking a note opens it. Double-click behavior is not required in V1.
|
||||
- Context menus expose rename, move, duplicate, and delete through existing
|
||||
commands.
|
||||
|
||||
### Document Workspace
|
||||
|
||||
- Compact toolbar: breadcrumb, save state, presentation mode, search, more.
|
||||
- Title is a clear input backed by the existing rename command.
|
||||
- Existing CodeMirror is mounted without replacement.
|
||||
- Edit is the default. Reading and split are presentation choices, not separate
|
||||
copies of the note.
|
||||
- A narrow status row shows words, links, cursor position, Markdown, and sync.
|
||||
|
||||
### Context Pane
|
||||
|
||||
- Tabs: Outline, Backlinks, Properties, Info.
|
||||
- Outline follows document headings and highlights the current section.
|
||||
- Backlinks show source page and a short matched excerpt.
|
||||
- Properties edit frontmatter through existing editor operations.
|
||||
- The pane is optional and hidden at widths below 1100px.
|
||||
|
||||
## 5. Mobile Workspace
|
||||
|
||||
- Top bar contains back/files, current page title, sync state, and more.
|
||||
- Bottom navigation: Files, Search, New, Links, More.
|
||||
- Files and search open in full-height sheets above the editor.
|
||||
- New creates a note in the current folder when possible, then focuses the
|
||||
editor.
|
||||
- Outline, backlinks, properties, and commands live in sheets.
|
||||
- Toolbar items remain at least 40px tall and account for safe-area insets.
|
||||
|
||||
## 6. States
|
||||
|
||||
- **Loading:** stable skeleton rows and document lines; no centered spinner
|
||||
that changes layout.
|
||||
- **Empty Space:** tree remains present with a single clear new-note command.
|
||||
- **No Search Results:** preserves query and offers create-with-title.
|
||||
- **Modified:** amber dot and `Saving...`; resolves to a quiet checkmark.
|
||||
- **Offline:** persistent but nonblocking status; edits remain available.
|
||||
- **Conflict:** blocks silent overwrite and opens a comparison action.
|
||||
- **Read-only:** controls remain visible but disabled with a clear reason.
|
||||
|
||||
## 7. Prototype Screens
|
||||
|
||||
The interactive prototype must demonstrate:
|
||||
|
||||
1. Desktop editor with files and outline.
|
||||
2. Desktop dark theme with backlinks.
|
||||
3. Search/command overlay.
|
||||
4. Mobile editor.
|
||||
5. Mobile file sheet.
|
||||
6. Empty/offline/save states through interactive controls.
|
||||
|
||||
No generated visual assets are required. Product identity is represented by a
|
||||
small CSS mark and Lucide icons; the actual application experience is the
|
||||
primary visual.
|
||||
|
||||
## 8. Engineering Mapping
|
||||
|
||||
| Prototype element | SilverBullet integration |
|
||||
| --- | --- |
|
||||
| File tree | Derive from `AppViewState.allPages` |
|
||||
| Active page | `viewState.current` |
|
||||
| Open page | `Client.navigate(parseToRef(name))` |
|
||||
| Search | Page navigator and object index |
|
||||
| Rename | Existing `index.renamePageCommand` |
|
||||
| Delete | Existing Space/command flow |
|
||||
| Editor | Existing `#sb-editor` CodeMirror mount |
|
||||
| Outline/backlinks | Object index/query APIs |
|
||||
| Pane state | Existing client store/config |
|
||||
| Mobile state | Existing `isMobile` plus CSS media queries |
|
||||
|
||||
Reference in New Issue
Block a user