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 ( -