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
485 lines
13 KiB
TypeScript
485 lines
13 KiB
TypeScript
import type { ComponentChildren } from "preact";
|
|
import {
|
|
BookOpen,
|
|
CheckSquare,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Command,
|
|
Feather,
|
|
FilePlus,
|
|
FileText,
|
|
Folder,
|
|
GitBranch,
|
|
Hash,
|
|
Menu,
|
|
Moon,
|
|
Search,
|
|
Sidebar,
|
|
X,
|
|
} from "preact-feather";
|
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
|
|
|
|
export type PageTreeFile = {
|
|
kind: "page";
|
|
name: string;
|
|
label: string;
|
|
meta: PageMeta;
|
|
};
|
|
|
|
export type PageTreeFolder = {
|
|
kind: "folder";
|
|
name: string;
|
|
path: string;
|
|
children: PageTreeNode[];
|
|
};
|
|
|
|
export type PageTreeNode = PageTreeFile | PageTreeFolder;
|
|
|
|
type MutableFolder = {
|
|
name: string;
|
|
path: string;
|
|
folders: Map<string, MutableFolder>;
|
|
pages: PageTreeFile[];
|
|
};
|
|
|
|
function compareTreeNodes(a: PageTreeNode, b: PageTreeNode): number {
|
|
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
|
const aLabel = a.kind === "page" ? a.label : a.name;
|
|
const bLabel = b.kind === "page" ? b.label : b.name;
|
|
return aLabel.localeCompare(bLabel);
|
|
}
|
|
|
|
function parentFolderPaths(page?: string): string[] {
|
|
const parts = page?.split("/").slice(0, -1) ?? [];
|
|
return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
|
|
}
|
|
|
|
function finalizeFolder(folder: MutableFolder): PageTreeNode[] {
|
|
return [
|
|
...Array.from(folder.folders.values()).map(
|
|
(child): PageTreeFolder => ({
|
|
kind: "folder",
|
|
name: child.name,
|
|
path: child.path,
|
|
children: finalizeFolder(child),
|
|
}),
|
|
),
|
|
...folder.pages,
|
|
].sort(compareTreeNodes);
|
|
}
|
|
|
|
/** Derives a display-only folder tree from SilverBullet page names. */
|
|
export function buildPageTree(allPages: PageMeta[]): PageTreeNode[] {
|
|
const root: MutableFolder = {
|
|
name: "",
|
|
path: "",
|
|
folders: new Map(),
|
|
pages: [],
|
|
};
|
|
|
|
for (const page of allPages) {
|
|
if (!page.name || page._isAspiring || page.pageDecoration?.hide === true) {
|
|
continue;
|
|
}
|
|
|
|
const parts = page.name.split("/").filter(Boolean);
|
|
if (parts.length === 0) continue;
|
|
|
|
let parent = root;
|
|
for (const part of parts.slice(0, -1)) {
|
|
let folder = parent.folders.get(part);
|
|
if (!folder) {
|
|
const path = parent.path ? `${parent.path}/${part}` : part;
|
|
folder = { name: part, path, folders: new Map(), pages: [] };
|
|
parent.folders.set(part, folder);
|
|
}
|
|
parent = folder;
|
|
}
|
|
|
|
parent.pages.push({
|
|
kind: "page",
|
|
name: page.name,
|
|
label: parts.at(-1)!,
|
|
meta: page,
|
|
});
|
|
}
|
|
|
|
return finalizeFolder(root);
|
|
}
|
|
|
|
function countPages(nodes: PageTreeNode[]): number {
|
|
return nodes.reduce(
|
|
(total, node) =>
|
|
total + (node.kind === "page" ? 1 : countPages(node.children)),
|
|
0,
|
|
);
|
|
}
|
|
|
|
function PageTree({
|
|
nodes,
|
|
currentPage,
|
|
expanded,
|
|
onToggleFolder,
|
|
onOpenPage,
|
|
depth = 0,
|
|
}: {
|
|
nodes: PageTreeNode[];
|
|
currentPage?: string;
|
|
expanded: Set<string>;
|
|
onToggleFolder: (path: string) => void;
|
|
onOpenPage: (page: string) => void;
|
|
depth?: number;
|
|
}) {
|
|
return (
|
|
<div class="pl-page-tree">
|
|
{nodes.map((node) => {
|
|
if (node.kind === "page") {
|
|
return (
|
|
<button
|
|
key={`page:${node.name}`}
|
|
type="button"
|
|
class={`pl-tree-row pl-tree-page ${currentPage === node.name ? "is-active" : ""}`}
|
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
title={node.name}
|
|
onClick={() => onOpenPage(node.name)}
|
|
>
|
|
<span class="pl-tree-disclosure" />
|
|
<FileText size={15} />
|
|
<span class="pl-tree-label">{node.label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const isExpanded = expanded.has(node.path);
|
|
return (
|
|
<div class="pl-tree-folder" key={`folder:${node.path}`}>
|
|
<button
|
|
type="button"
|
|
class="pl-tree-row"
|
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
title={node.path}
|
|
aria-expanded={isExpanded}
|
|
onClick={() => onToggleFolder(node.path)}
|
|
>
|
|
<span class="pl-tree-disclosure">
|
|
{isExpanded ? (
|
|
<ChevronDown size={14} />
|
|
) : (
|
|
<ChevronRight size={14} />
|
|
)}
|
|
</span>
|
|
<Folder size={15} />
|
|
<span class="pl-tree-label">{node.name}</span>
|
|
<span class="pl-tree-count">{countPages(node.children)}</span>
|
|
</button>
|
|
{isExpanded && (
|
|
<PageTree
|
|
nodes={node.children}
|
|
currentPage={currentPage}
|
|
expanded={expanded}
|
|
onToggleFolder={onToggleFolder}
|
|
onOpenPage={onOpenPage}
|
|
depth={depth + 1}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RailButton({
|
|
label,
|
|
active = false,
|
|
children,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
active?: boolean;
|
|
children: ComponentChildren;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
class={`pl-rail-button ${active ? "is-active" : ""}`}
|
|
title={label}
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function PlainleafWorkspace({
|
|
allPages,
|
|
currentPage,
|
|
isOnline,
|
|
onOpenPage,
|
|
onOpenSearch,
|
|
onCreatePage,
|
|
onOpenTags,
|
|
onOpenGraph,
|
|
onOpenCommands,
|
|
onToggleTheme,
|
|
children,
|
|
}: {
|
|
allPages: PageMeta[];
|
|
currentPage?: string;
|
|
isOnline: boolean;
|
|
onOpenPage: (page: string) => void;
|
|
onOpenSearch: () => void;
|
|
onCreatePage: () => void;
|
|
onOpenTags: () => void;
|
|
onOpenGraph: () => void;
|
|
onOpenCommands: () => void;
|
|
onToggleTheme: () => void;
|
|
children: ComponentChildren;
|
|
}) {
|
|
const tree = useMemo(() => buildPageTree(allPages), [allPages]);
|
|
const [expanded, setExpanded] = useState<Set<string>>(
|
|
() => new Set(parentFolderPaths(currentPage)),
|
|
);
|
|
const [navigationOpen, setNavigationOpen] = useState(true);
|
|
const [mobileFilesOpen, setMobileFilesOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const parentPaths = parentFolderPaths(currentPage);
|
|
if (parentPaths.length === 0) return;
|
|
|
|
setExpanded((previous) => {
|
|
const next = new Set(previous);
|
|
parentPaths.forEach((path) => next.add(path));
|
|
return next;
|
|
});
|
|
}, [currentPage]);
|
|
|
|
const toggleFolder = (path: string) => {
|
|
setExpanded((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(path)) next.delete(path);
|
|
else next.add(path);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const openPage = (page: string) => {
|
|
onOpenPage(page);
|
|
setMobileFilesOpen(false);
|
|
};
|
|
|
|
const recentPages = useMemo(
|
|
() =>
|
|
allPages
|
|
.filter(
|
|
(page) =>
|
|
page.name &&
|
|
!page._isAspiring &&
|
|
page.pageDecoration?.hide !== true &&
|
|
page.lastOpened,
|
|
)
|
|
.sort((a, b) => (b.lastOpened ?? 0) - (a.lastOpened ?? 0))
|
|
.slice(0, 4),
|
|
[allPages],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
id="pl-workspace-shell"
|
|
class={navigationOpen ? "" : "is-navigation-collapsed"}
|
|
>
|
|
<aside class="pl-activity-rail" aria-label="Plainleaf 工具栏">
|
|
<div class="pl-brand-mark" title="Plainleaf" aria-label="Plainleaf">
|
|
<Feather size={19} />
|
|
</div>
|
|
<div class="pl-rail-main">
|
|
<RailButton
|
|
label="文件"
|
|
active={navigationOpen}
|
|
onClick={() => setNavigationOpen((open) => !open)}
|
|
>
|
|
<BookOpen size={18} />
|
|
</RailButton>
|
|
<RailButton label="搜索" onClick={onOpenSearch}>
|
|
<Search size={18} />
|
|
</RailButton>
|
|
<RailButton label="标签" onClick={onOpenTags}>
|
|
<Hash size={18} />
|
|
</RailButton>
|
|
<RailButton label="任务" onClick={onOpenCommands}>
|
|
<CheckSquare size={18} />
|
|
</RailButton>
|
|
<RailButton label="关系图" onClick={onOpenGraph}>
|
|
<GitBranch size={18} />
|
|
</RailButton>
|
|
</div>
|
|
<div class="pl-rail-footer">
|
|
<RailButton label="切换主题" onClick={onToggleTheme}>
|
|
<Moon size={18} />
|
|
</RailButton>
|
|
</div>
|
|
</aside>
|
|
|
|
{navigationOpen && (
|
|
<aside class="pl-navigation-pane" aria-label="文件">
|
|
<header class="pl-navigation-header">
|
|
<div>
|
|
<span>我的空间</span>
|
|
<strong>文件</strong>
|
|
</div>
|
|
<div class="pl-header-actions">
|
|
<button
|
|
type="button"
|
|
title="新建笔记"
|
|
aria-label="新建笔记"
|
|
onClick={onCreatePage}
|
|
>
|
|
<FilePlus size={17} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title="收起文件栏"
|
|
aria-label="收起文件栏"
|
|
onClick={() => setNavigationOpen(false)}
|
|
>
|
|
<Sidebar size={17} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<button type="button" class="pl-search-button" onClick={onOpenSearch}>
|
|
<Search size={15} />
|
|
<span>搜索笔记</span>
|
|
<kbd>⌘ K</kbd>
|
|
</button>
|
|
<div class="pl-navigation-scroll">
|
|
{recentPages.length > 0 && (
|
|
<section class="pl-nav-section">
|
|
<h2>最近使用</h2>
|
|
{recentPages.map((page) => (
|
|
<button
|
|
key={`recent:${page.name}`}
|
|
type="button"
|
|
class="pl-recent-row"
|
|
title={page.name}
|
|
onClick={() => openPage(page.name)}
|
|
>
|
|
<FileText size={15} />
|
|
<span>{page.name.split("/").at(-1)}</span>
|
|
</button>
|
|
))}
|
|
</section>
|
|
)}
|
|
<section class="pl-nav-section pl-files-section">
|
|
<h2>全部文件</h2>
|
|
{tree.length > 0 ? (
|
|
<PageTree
|
|
nodes={tree}
|
|
currentPage={currentPage}
|
|
expanded={expanded}
|
|
onToggleFolder={toggleFolder}
|
|
onOpenPage={openPage}
|
|
/>
|
|
) : (
|
|
<div class="pl-empty-tree">
|
|
<FileText size={22} />
|
|
<strong>还没有 Markdown 文件</strong>
|
|
<button type="button" onClick={onCreatePage}>
|
|
新建第一篇笔记
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
<footer class="pl-navigation-footer">
|
|
<span class={`pl-connection-dot ${isOnline ? "is-online" : ""}`} />
|
|
<span>{isOnline ? "文件已同步" : "离线工作中"}</span>
|
|
</footer>
|
|
</aside>
|
|
)}
|
|
|
|
<main class="pl-editor-workspace">
|
|
{!navigationOpen && (
|
|
<button
|
|
type="button"
|
|
class="pl-show-navigation"
|
|
title="显示文件栏"
|
|
aria-label="显示文件栏"
|
|
onClick={() => setNavigationOpen(true)}
|
|
>
|
|
<Menu size={18} />
|
|
</button>
|
|
)}
|
|
{children}
|
|
</main>
|
|
|
|
<nav class="pl-mobile-navigation" aria-label="手机导航">
|
|
<button type="button" onClick={() => setMobileFilesOpen(true)}>
|
|
<BookOpen size={19} />
|
|
<span>文件</span>
|
|
</button>
|
|
<button type="button" onClick={onOpenSearch}>
|
|
<Search size={19} />
|
|
<span>搜索</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-mobile-create"
|
|
title="新建笔记"
|
|
aria-label="新建笔记"
|
|
onClick={onCreatePage}
|
|
>
|
|
<FilePlus size={21} />
|
|
</button>
|
|
<button type="button" onClick={onOpenTags}>
|
|
<Hash size={19} />
|
|
<span>标签</span>
|
|
</button>
|
|
<button type="button" onClick={onOpenCommands}>
|
|
<Command size={19} />
|
|
<span>更多</span>
|
|
</button>
|
|
</nav>
|
|
|
|
{mobileFilesOpen && (
|
|
<div
|
|
class="pl-mobile-sheet-backdrop"
|
|
onClick={(event) => {
|
|
if (event.currentTarget === event.target) setMobileFilesOpen(false);
|
|
}}
|
|
>
|
|
<section
|
|
class="pl-mobile-sheet"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="文件"
|
|
>
|
|
<header>
|
|
<strong>文件</strong>
|
|
<button
|
|
type="button"
|
|
title="关闭文件列表"
|
|
aria-label="关闭文件列表"
|
|
onClick={() => setMobileFilesOpen(false)}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</header>
|
|
<div class="pl-mobile-tree">
|
|
<PageTree
|
|
nodes={tree}
|
|
currentPage={currentPage}
|
|
expanded={expanded}
|
|
onToggleFolder={toggleFolder}
|
|
onOpenPage={openPage}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|