180 lines
5.7 KiB
TypeScript
180 lines
5.7 KiB
TypeScript
import { Component, createMemo, createSignal, Show } from "solid-js";
|
|
import { useLocation } from "@solidjs/router";
|
|
import { type FileNode, type TocNode } from "../data-loader";
|
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
|
import { useScrollContainer } from "../App";
|
|
|
|
/**
|
|
* 检查当前文件路径是否在文件夹内
|
|
*/
|
|
function isPathInDir(currentPath: string, dirPath: string): boolean {
|
|
// 确保 dirPath 以 / 结尾,用于前缀匹配
|
|
const dirPathPrefix = dirPath.endsWith("/") ? dirPath : dirPath + "/";
|
|
return currentPath.startsWith(dirPathPrefix);
|
|
}
|
|
|
|
/**
|
|
* 文件树节点组件
|
|
*/
|
|
export const FileTreeNode: Component<{
|
|
node: FileNode;
|
|
currentPath: string;
|
|
pathHeadings: Record<string, TocNode[]>;
|
|
depth: number;
|
|
onClose: () => void;
|
|
isHidden?: (node: FileNode) => boolean;
|
|
}> = (props) => {
|
|
const navigate = useNavigateWithParams();
|
|
const location = useLocation();
|
|
const isDir = !!props.node.children;
|
|
const isActive = createMemo(() => props.currentPath === props.node.path);
|
|
// 默认收起,除非当前文件在该文件夹内
|
|
const [isExpanded, setIsExpanded] = createSignal(
|
|
isDir && isPathInDir(props.currentPath, props.node.path),
|
|
);
|
|
|
|
const href = () => props.node.path + location.search;
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (isDir) {
|
|
e.preventDefault();
|
|
setIsExpanded(!isExpanded());
|
|
} else if (e.button === 0) {
|
|
// Left-click: use SPA navigation
|
|
e.preventDefault();
|
|
navigate(props.node.path);
|
|
props.onClose();
|
|
}
|
|
// Middle-click / right-click: let the browser handle the <a> natively
|
|
};
|
|
|
|
const indent = props.depth * 12;
|
|
|
|
return (
|
|
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
|
<a
|
|
href={href()}
|
|
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
|
|
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
|
}`}
|
|
style={{ "padding-left": `${indent + 8}px` }}
|
|
onClick={handleClick}
|
|
>
|
|
<Show when={isDir}>
|
|
<span class="mr-1 text-gray-400">{isExpanded() ? "📂" : "📁"}</span>
|
|
</Show>
|
|
<Show when={!isDir}>
|
|
<span class="mr-1 text-gray-400">📄</span>
|
|
</Show>
|
|
<span class="text-sm truncate">{props.node.name}</span>
|
|
</a>
|
|
<Show when={isDir && isExpanded() && props.node.children}>
|
|
<div>
|
|
{props.node.children!.map((child) => (
|
|
<FileTreeNode
|
|
node={child}
|
|
currentPath={props.currentPath}
|
|
pathHeadings={props.pathHeadings}
|
|
depth={props.depth + 1}
|
|
onClose={props.onClose}
|
|
isHidden={props.isHidden}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 标题节点组件
|
|
*/
|
|
export const HeadingNode: Component<{
|
|
node: TocNode;
|
|
basePath: string;
|
|
depth: number;
|
|
isHidden?: (node: TocNode) => boolean;
|
|
}> = (props) => {
|
|
const navigate = useNavigateWithParams();
|
|
const location = useLocation();
|
|
const anchor = props.node.id || "";
|
|
const href = () => `${props.basePath}${location.search}#${anchor}`;
|
|
const hasChildren = !!props.node.children;
|
|
// 默认收起,除非当前锚点在该节点内
|
|
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
|
|
|
|
const handleExpand = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
if (hasChildren) {
|
|
setIsExpanded(!isExpanded());
|
|
}
|
|
};
|
|
const scrollContainer = useScrollContainer();
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (e.button === 0) {
|
|
// Left-click: use SPA navigation
|
|
e.preventDefault();
|
|
navigate(`${props.basePath}#${anchor}`);
|
|
}
|
|
// Middle-click / right-click: let the browser handle the <a> natively
|
|
// 滚动到目标元素,考虑导航栏高度偏移
|
|
requestAnimationFrame(() => {
|
|
const element = document.getElementById(anchor);
|
|
if (element) {
|
|
const scroller = scrollContainer();
|
|
const navBarHeight = 80;
|
|
const elementPosition = element.getBoundingClientRect().top;
|
|
const containerTop = scroller?.getBoundingClientRect().top ?? 0;
|
|
const relativePosition = elementPosition - containerTop;
|
|
const currentScroll = scroller?.scrollTop ?? window.scrollY;
|
|
const offsetPosition = currentScroll + relativePosition - navBarHeight;
|
|
if (scroller) {
|
|
scroller.scrollTo({ top: offsetPosition, behavior: "instant" });
|
|
} else {
|
|
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const indent = props.depth * 12;
|
|
|
|
return (
|
|
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
|
<div class="flex flex-row">
|
|
<span
|
|
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
|
|
style={{ "margin-left": `${indent + 8}px` }}
|
|
onClick={handleExpand}
|
|
>
|
|
<span
|
|
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
|
|
>
|
|
▶
|
|
</span>
|
|
</span>
|
|
<a
|
|
href={href()}
|
|
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
|
onClick={handleClick}
|
|
>
|
|
{props.node.title}
|
|
</a>
|
|
</div>
|
|
<Show when={hasChildren && isExpanded()}>
|
|
<div>
|
|
{props.node.children!.map((child) => (
|
|
<HeadingNode
|
|
node={child}
|
|
basePath={props.basePath}
|
|
depth={props.depth + 1}
|
|
isHidden={props.isHidden}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|