diff --git a/README.md b/README.md
index fca4668..ddb1409 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,9 @@
#### v0.29.1-1补丁
1. 置顶内容新增自动折叠;置顶效果及图标改为全局显示
-2. 归档页面新增(取消)置顶、编辑按钮
-3. “私有”图标改为全局显示
+2. 归档页面新增(取消)置顶、编辑、复制按钮
+3. 权限图标改为全局显示
+4. 修复中文目录锚点无效;优化目录显示效果
#### v0.29.1-1
1. 修改默认配置
diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx
index b84e21f..e1f7200 100644
--- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx
+++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx
@@ -87,25 +87,23 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
>
)}
- {/* Copy submenu (non-archived) */}
- {!isArchived && (
-
-
-
- {t("common.copy")}
-
-
-
-
- {t("memo.copy-link")}
-
-
-
- {t("memo.copy-content")}
-
-
-
- )}
+ {/* Copy submenu */}
+
+
+
+ {t("common.copy")}
+
+
+
+
+ {t("memo.copy-link")}
+
+
+
+ {t("memo.copy-content")}
+
+
+
{/* Task submenu (writable task memos) */}
{canMutateTasks && (
diff --git a/web/src/components/MemoCommentSection.tsx b/web/src/components/MemoCommentSection.tsx
index 76a70d8..4d17d58 100644
--- a/web/src/components/MemoCommentSection.tsx
+++ b/web/src/components/MemoCommentSection.tsx
@@ -68,7 +68,7 @@ const MemoCommentSection = ({ memo, comments, parentPage }: Props) => {
)}
{comments.map((comment) => (
))}
diff --git a/web/src/components/MemoDetailSidebar/MemoOutline.tsx b/web/src/components/MemoDetailSidebar/MemoOutline.tsx
index 5ca0a1c..37ae935 100644
--- a/web/src/components/MemoDetailSidebar/MemoOutline.tsx
+++ b/web/src/components/MemoDetailSidebar/MemoOutline.tsx
@@ -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 = {
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(null);
+ const itemRefs = useRef(new Map());
+ const [activeSlugs, setActiveSlugs] = useState([]);
+
+ 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, slug: string) => {
e.preventDefault();
const el = document.getElementById(slug);
@@ -24,27 +112,50 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => {
};
return (
-