优化目录效果
This commit is contained in:
@@ -4,8 +4,9 @@
|
||||
|
||||
#### v0.29.1-1补丁
|
||||
1. 置顶内容新增自动折叠;置顶效果及图标改为全局显示
|
||||
2. 归档页面新增(取消)置顶、编辑按钮
|
||||
3. “私有”图标改为全局显示
|
||||
2. 归档页面新增(取消)置顶、编辑、复制按钮
|
||||
3. 权限图标改为全局显示
|
||||
4. 修复中文目录锚点无效;优化目录显示效果
|
||||
|
||||
#### v0.29.1-1
|
||||
1. 修改默认配置
|
||||
|
||||
@@ -87,25 +87,23 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Copy submenu (non-archived) */}
|
||||
{!isArchived && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CopyIcon className="w-4 h-auto" />
|
||||
{t("common.copy")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
<LinkIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-link")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCopyContent}>
|
||||
<FileTextIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-content")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{/* Copy submenu */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CopyIcon className="w-4 h-auto" />
|
||||
{t("common.copy")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
<LinkIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-link")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCopyContent}>
|
||||
<FileTextIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-content")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Task submenu (writable task memos) */}
|
||||
{canMutateTasks && (
|
||||
|
||||
@@ -68,7 +68,7 @@ const MemoCommentSection = ({ memo, comments, parentPage }: Props) => {
|
||||
)}
|
||||
{comments.map((comment) => (
|
||||
<div className="w-full" key={`${comment.name}-${comment.updateTime}`} id={extractMemoIdFromName(comment.name)}>
|
||||
<MemoView memo={comment} parentPage={parentPage} showCreator compact />
|
||||
<MemoView memo={comment} parentPage={parentPage} showCreator showVisibility compact />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HeadingItem } from "@/utils/markdown-manipulation";
|
||||
|
||||
@@ -12,8 +13,95 @@ const levelIndent: Record<number, string> = {
|
||||
4: "ml-8",
|
||||
};
|
||||
|
||||
/** Outline navigation for memo headings (h1–h4). */
|
||||
const areStringArraysEqual = (a: string[], b: string[]) => {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item, index) => item === b[index]);
|
||||
};
|
||||
|
||||
/** Outline navigation for memo headings (h1-h4). */
|
||||
const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
||||
const outlineRef = useRef<HTMLElement>(null);
|
||||
const itemRefs = useRef(new Map<string, HTMLAnchorElement>());
|
||||
const [activeSlugs, setActiveSlugs] = useState<string[]>([]);
|
||||
|
||||
const orderedSlugs = useMemo(() => headings.map((heading) => heading.slug).filter(Boolean), [headings]);
|
||||
const activeSlugSet = useMemo(() => new Set(activeSlugs), [activeSlugs]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSlugs([]);
|
||||
|
||||
const elements = orderedSlugs
|
||||
.map((slug) => document.getElementById(slug))
|
||||
.filter((element): element is HTMLElement => Boolean(element));
|
||||
|
||||
if (elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let animationFrame = 0;
|
||||
|
||||
const updateActiveSlugs = () => {
|
||||
animationFrame = 0;
|
||||
const viewportTop = window.scrollY;
|
||||
const viewportBottom = viewportTop + (window.innerHeight || document.documentElement.clientHeight);
|
||||
const documentBottom = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
||||
const headingSections = elements.map((element, index) => {
|
||||
const sectionTop = element.getBoundingClientRect().top + window.scrollY;
|
||||
const nextElement = elements[index + 1];
|
||||
const sectionBottom = nextElement ? nextElement.getBoundingClientRect().top + window.scrollY : documentBottom;
|
||||
return { slug: element.id, top: sectionTop, bottom: sectionBottom };
|
||||
});
|
||||
const nextActiveSlugs = headingSections
|
||||
.filter((section) => section.bottom > viewportTop && section.top < viewportBottom)
|
||||
.map((section) => section.slug);
|
||||
|
||||
setActiveSlugs((previousSlugs) => (areStringArraysEqual(previousSlugs, nextActiveSlugs) ? previousSlugs : nextActiveSlugs));
|
||||
};
|
||||
|
||||
const scheduleUpdateActiveSlugs = () => {
|
||||
if (animationFrame) {
|
||||
return;
|
||||
}
|
||||
animationFrame = window.requestAnimationFrame(updateActiveSlugs);
|
||||
};
|
||||
|
||||
updateActiveSlugs();
|
||||
window.addEventListener("scroll", scheduleUpdateActiveSlugs, { passive: true });
|
||||
window.addEventListener("resize", scheduleUpdateActiveSlugs);
|
||||
|
||||
return () => {
|
||||
if (animationFrame) {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
window.removeEventListener("scroll", scheduleUpdateActiveSlugs);
|
||||
window.removeEventListener("resize", scheduleUpdateActiveSlugs);
|
||||
};
|
||||
}, [orderedSlugs]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstActiveSlug = activeSlugs[0];
|
||||
const outline = outlineRef.current;
|
||||
const activeItem = firstActiveSlug ? itemRefs.current.get(firstActiveSlug) : undefined;
|
||||
|
||||
if (!outline || !activeItem || outline.scrollHeight <= outline.clientHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemTop = activeItem.offsetTop;
|
||||
const itemBottom = itemTop + activeItem.offsetHeight;
|
||||
const comfortableTop = outline.scrollTop + 24;
|
||||
const comfortableBottom = outline.scrollTop + outline.clientHeight - 24;
|
||||
|
||||
if (itemTop >= comfortableTop && itemBottom <= comfortableBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
outline.scrollTo({
|
||||
top: Math.max(0, itemTop - outline.clientHeight / 2 + activeItem.offsetHeight / 2),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlugs]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(slug);
|
||||
@@ -24,27 +112,50 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="relative flex flex-col">
|
||||
{headings.map((heading, index) => (
|
||||
<a
|
||||
key={`${heading.slug}-${index}`}
|
||||
href={`#${heading.slug}`}
|
||||
onClick={(e) => handleClick(e, heading.slug)}
|
||||
className={cn(
|
||||
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
|
||||
"text-muted-foreground/60 hover:text-foreground/90",
|
||||
"transition-colors duration-200 ease-out",
|
||||
levelIndent[heading.level],
|
||||
heading.level === 1 && "font-medium text-muted-foreground/80",
|
||||
)}
|
||||
title={heading.text}
|
||||
>
|
||||
<span className="relative">
|
||||
{heading.text}
|
||||
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
<nav ref={outlineRef} className="relative flex max-h-[45vh] flex-col overflow-y-auto overscroll-contain pr-1">
|
||||
{headings.map((heading, index) => {
|
||||
const isActive = activeSlugSet.has(heading.slug);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={`${heading.slug}-${index}`}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
itemRefs.current.set(heading.slug, element);
|
||||
} else {
|
||||
itemRefs.current.delete(heading.slug);
|
||||
}
|
||||
}}
|
||||
href={`#${heading.slug}`}
|
||||
aria-current={activeSlugs[0] === heading.slug ? "location" : undefined}
|
||||
onClick={(e) => handleClick(e, heading.slug)}
|
||||
className={cn(
|
||||
"group relative block shrink-0 rounded-l-sm py-[5px] pr-1 pl-2 text-[13px] leading-snug truncate",
|
||||
"transition-colors duration-200 ease-out",
|
||||
isActive ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground/60 hover:text-foreground/90",
|
||||
levelIndent[heading.level],
|
||||
heading.level === 1 && !isActive && "font-medium text-muted-foreground/80",
|
||||
)}
|
||||
title={heading.text}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1.5 bottom-1.5 left-0 w-0.5 rounded-full bg-primary transition-opacity duration-200",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="relative">
|
||||
{heading.text}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-px left-0 h-px bg-foreground/30 transition-all duration-200",
|
||||
isActive ? "w-full bg-primary/50" : "w-0 group-hover:w-full",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,7 +118,7 @@ export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
@@ -143,6 +143,8 @@ export function extractHeadings(markdown: string): HeadingItem[] {
|
||||
if (!text) return;
|
||||
|
||||
let slug = slugify(text);
|
||||
if (!slug) return;
|
||||
|
||||
const count = slugCounts.get(slug) || 0;
|
||||
slugCounts.set(slug, count + 1);
|
||||
if (count > 0) slug = `${slug}-${count}`;
|
||||
|
||||
Reference in New Issue
Block a user