Files
plainleaf/client/components/plainleaf_workspace.tsx
T
wushenghua 94d310d712
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
feat: make Plainleaf Chinese-first
2026-08-12 11:33:21 +08:00

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