Files
ttrpg-tools/src/components/Sidebar.tsx
T
hypercross 5097aba842 refactor: implement scroll container context and flex layout
Switch from fixed positioning to a flexbox-based layout for the
main application structure. This introduces a `ScrollContainerCtx` to
provide access to the main scrollable element, allowing child
components like `FileTree` and `RevealManager` to perform accurate
scrolling and positioning relative to the container instead of the
window.

refactor: set JournalPanel height to full
2026-07-08 15:03:31 +08:00

197 lines
6.0 KiB
TypeScript

import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
import { generateToc, type FileNode, type TocNode } from "../data-loader";
import { useLocation } from "@solidjs/router";
import { FileTreeNode, HeadingNode } from "./FileTree";
import { isPathRevealed } from "./stores/reveal";
export interface SidebarProps {
isOpen: boolean;
onClose: () => void;
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
}
interface SidebarContentProps {
fileTree: FileNode[];
pathHeadings: Record<string, TocNode[]>;
currentPath: string;
onClose: () => void;
isDesktop?: boolean;
onDataSourceOpen?: () => void;
}
/**
* 侧边栏内容组件
*/
const SidebarContent: Component<SidebarContentProps> = (props) => {
const location = useLocation();
// 响应式获取当前文件的标题列表
const currentFileHeadings = createMemo(() => {
const pathname = decodeURIComponent(location.pathname);
return (
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
);
});
const isFileHidden = (node: FileNode): boolean => {
if (isPathRevealed(node.path)) return false;
if (node.children?.some((child) => !isFileHidden(child))) return false;
return true;
};
const isHeadingHidden = (node: TocNode): boolean => {
const pathname = decodeURIComponent(location.pathname);
if (isPathRevealed(pathname, node.id ?? node.title)) return false;
if (node.children?.some((child) => !isHeadingHidden(child))) return false;
return true;
};
return (
<div class="flex flex-col h-full">
<div class="p-4 border-b border-b-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900">目录</h2>
<div class="flex items-center gap-1">
<button
onClick={props.onDataSourceOpen}
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
title="Content Source"
>
📂
</button>
<Show when={!props.isDesktop}>
<button
onClick={props.onClose}
class="text-gray-500 hover:text-gray-700"
title="关闭"
>
</button>
</Show>
</div>
</div>
</div>
{/* 文件树滚动区域 */}
<div class="flex-1 overflow-y-auto p-4 min-h-0">
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
文件
</h3>
{props.fileTree.map((node) => (
<FileTreeNode
node={node}
currentPath={props.currentPath}
pathHeadings={props.pathHeadings}
depth={0}
onClose={props.onClose}
isHidden={isFileHidden}
/>
))}
</div>
{/* 当前文件标题滚动区域 */}
<Show when={currentFileHeadings().length > 0}>
<div class="flex-1 border-t-gray-200 border-t overflow-y-auto p-4 min-h-0">
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
本页
</h3>
{currentFileHeadings().map((node) => (
<HeadingNode
node={node}
basePath={location.pathname}
depth={0}
isHidden={isHeadingHidden}
/>
))}
</div>
</Show>
</div>
);
};
/**
* 侧边栏组件(移动端抽屉)
*/
export const MobileSidebar: Component<SidebarProps> = (props) => {
const location = useLocation();
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const fileTree = () => props.fileTree ?? selfFileTree();
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
// 加载目录数据 (only if props not provided)
onMount(async () => {
if (props.fileTree && props.pathHeadings) return;
const toc = await generateToc();
setSelfFileTree(toc.fileTree);
setSelfPathHeadings(toc.pathHeadings);
});
return (
<>
{/* 遮罩层 */}
<div
class={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ease-in-out md:hidden ${
props.isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={props.onClose}
/>
{/* 侧边栏 */}
<aside
class={`fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-50 transform transition-transform duration-300 ease-in-out md:hidden ${
props.isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<SidebarContent
fileTree={fileTree()}
pathHeadings={pathHeadings()}
currentPath={location.pathname}
onClose={props.onClose}
onDataSourceOpen={props.onDataSourceOpen}
/>
</aside>
</>
);
};
/**
* 桌面端侧边栏 — flex child, no fixed positioning
*/
export const DesktopSidebar: Component<{
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
}> = (props) => {
const location = useLocation();
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const fileTree = () => props.fileTree ?? selfFileTree();
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
onMount(async () => {
if (props.fileTree && props.pathHeadings) return;
const toc = await generateToc();
setSelfFileTree(toc.fileTree);
setSelfPathHeadings(toc.pathHeadings);
});
return (
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
<SidebarContent
fileTree={fileTree()}
pathHeadings={pathHeadings()}
currentPath={location.pathname}
onClose={() => {}}
isDesktop
onDataSourceOpen={props.onDataSourceOpen}
/>
</aside>
);
};