feat(article): 优化文章内容和目录组件

- 更新文章代码块样式为mac样式
- 增加代码块语言显示
- 增加代码块收起折叠以及总行数显示
- 优化目录组件,支持 h1~h6 标题自动生成目录
- 调整目录样式,增加响应式布局支持
- 添加prettier配置,确保格式化代码统一规范
This commit is contained in:
皓月归尘
2025-07-06 19:07:58 +08:00
parent 1a808e26f6
commit b30db0ffec
5 changed files with 620 additions and 488 deletions

15
.prettierrc.js Normal file
View File

@@ -0,0 +1,15 @@
// @ts-nocheck
/** @type {import("prettier").Config} */
export default {
bracketSpacing: true,
singleQuote: false, // 使用双引号
jsxSingleQuote: false, // JSX和TSX中也使用双引号
arrowParens: "avoid",
trailingComma: "none",
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
parser: "typescript", // 确保TypeScript/TSX文件被正确解析
};

View File

@@ -1,203 +1,269 @@
@use "@/styles/var" as *;
@use "@/styles/fun" as *;
@use '@/styles/var' as *;
@use '@/styles/fun' as *;
.ContentMdComponent {
// 自定义文章内容样式
.markdown-body {
// 继承父级字体font-family: inherit;
@apply font-[inherit] dark:bg-transparent dark:text-gray-400 !important;
.markdown-body {
@apply font-[inherit] dark:bg-transparent dark:text-gray-400 !important;
pre {
display: flex;
position: relative;
margin: 15px 0;
pre.mac-style {
position: relative;
margin: 1rem 0;
padding-top: 2.5rem;
background-color: #121212;
border-radius: 10px;
overflow: auto;
font-size: 1rem;
color: #dcdcdc;
font-family: inherit;
&:hover button {
@apply opacity-100;
}
&::before {
content: '';
position: absolute;
top: 0.75rem;
left: 1rem;
width: 0.75rem;
height: 0.75rem;
background: #ff5f56;
border-radius: 50%;
box-shadow: 1rem 0 0 #ffbd2e, 2rem 0 0 #27c93f;
z-index: 2;
}
// span.lineNumber {
// @apply absolute left-[16px] top-[16px] flex flex-col mt-[1.5px] text-base block;
// }
.language-label {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #ccc;
font-weight: bold;
text-transform: lowercase;
-webkit-user-select: none;
user-select: none;
z-index: 3;
}
code.hljs {
width: 100%;
// padding-left: 50px;
border-radius: 10px;
font-weight: 400;
padding: 1rem !important;
.copy-button {
position: absolute;
top: 0.75rem;
right: 1rem;
background: #2c2c2c;
color: #ccc;
border-radius: 4px;
padding: 4px 6px;
font-size: 0.875rem;
z-index: 4;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition: background-color 0.2s;
background: #1e1e1e !important;
color: #dcdcdc !important;
@include scrollbar-style(rgba(255, 255, 255, 0.3));
}
&:hover {
background: #444;
}
}
img {
// 图片模糊
@apply blur-[20px] rounded-xl hover:scale-105 cursor-pointer transition-all;
&.collapsed {
max-height: 320px;
overflow: hidden;
cursor: pointer;
&::after {
content: '点击展开代码';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
text-align: center;
background: linear-gradient(to top, #121212 60%, transparent);
color: #999;
padding: 12px 0;
font-size: 0.875rem;
z-index: 5;
-webkit-user-select: none;
user-select: none;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: bold;
scroll-margin-top: 100px !important;
@apply dark:text-white dark:border-black-b;
&.expanded {
max-height: none;
}
code.hljs {
display: block;
white-space: pre-wrap;
font-family: inherit !important;
background: transparent !important;
color: inherit !important;
padding: 1rem;
}
.toggle-btn {
position: absolute;
bottom: 4px;
right: 8px;
background: rgba(0, 0, 0, 0.5);
color: #ccc;
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 4px;
z-index: 6;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
}
}
}
h1 {
@apply text-2xl mt-10 pb-3 border-b;
img {
@apply blur-[20px] rounded-xl hover:scale-105 cursor-pointer transition-all;
}
&:first-child {
@apply my-6;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: bold;
scroll-margin-top: 100px !important;
@apply dark:text-white dark:border-black-b;
}
h2 {
@apply relative text-xl my-6;
}
h1 {
@apply text-2xl mt-10 pb-3 border-b;
&:first-child {
@apply my-6;
}
}
h3 {
@apply relative text-lg my-5;
}
h2 {
@apply relative text-xl my-6;
}
h4,
h5,
h6 {
@apply relative text-lg my-3;
}
h3 {
@apply relative text-lg my-5;
}
hr {
@apply my-5 border dark:border-[#4e5969];
}
h4,
h5,
h6 {
@apply relative text-lg my-3;
}
// 行内代码
p code,
ul code,
ol code {
@apply bg-[rgba(13,110,253,0.1)] dark:bg-[#334052] text-[#0d6efd] rounded-md py-1 px-2 text-sm;
}
hr {
@apply my-5 border dark:border-[#4e5969];
}
// 任务列表
li.task-list-item {
@apply my-2.5;
}
p code,
ul code,
ol code {
@apply bg-[rgba(13,110,253,0.1)] dark:bg-[#334052] text-[#0d6efd] rounded-md py-1 px-2 text-sm;
}
li:not(.task-list-item) {
@apply my-2.5 ml-6;
}
li.task-list-item {
@apply my-2.5;
}
// 无序列表
ul:not(.contains-task-list) {
@apply list-disc;
}
li:not(.task-list-item) {
@apply my-2.5 ml-6;
}
// 有序列表
ol:not([start]) {
counter-reset: counter;
}
ul:not(.contains-task-list) {
@apply list-disc;
}
ol>li:not([id^="user-content-fn-"]) {
ol:not([start]) {
counter-reset: counter;
}
&::before {
@apply absolute w-4 h-4 mt-1 leading-none left-0 rounded-full text-center text-sm border border-[#11181C] dark:border-gray-400;
counter-increment: counter;
content: counter(counter);
}
& ol>li::before {
@apply absolute left-7 border-0 text-base mt-0 leading-normal;
content: counter(counter) ".";
}
}
// 引用
blockquote {
@apply my-5 pl-4 bg-[rgba(246,248,250)] border-l-[4px] border-[#11181C] dark:border-gray-500 dark:bg-[rgba(246,248,250,0.1)];
}
// 脚注
.footnotes {
@apply mt-10 before:content-[''];
&>h2 {
@apply text-yellow-500;
}
&::before {
@apply block w-full h-[1px] bg-gray-300;
}
& ol {
@apply list-decimal;
}
}
// 高亮标记
mark {
@apply bg-[#dbfdad] dark:bg-[#9db47e];
}
// 提示块
.callout-content {
@apply pt-1;
&>p {
@apply mb-0;
}
}
// 数学公式
.katex {
@apply text-base;
}
a {
@apply text-primary
}
p {
@apply leading-9 mb-2;
}
strong {
@apply text-[15px];
}
table {
@apply w-full;
th {
@apply bg-[#f1f7fd] dark:bg-[#334052];
}
tr,
th,
td {
@apply border p-[10px_20px];
}
}
input[type="checkbox"] {
width: 16px;
height: 16px;
border-radius: 4px;
position: relative;
cursor: not-allowed;
}
ol > li:not([id^='user-content-fn-']) {
&::before {
@apply absolute w-4 h-4 mt-1 leading-none left-0 rounded-full text-center text-sm border border-[#11181C] dark:border-gray-400;
counter-increment: counter;
content: counter(counter);
}
.douyin {
min-width: 320px;
min-height: 720px;
border-radius: 10px;
margin: 10px 0;
& ol > li::before {
@apply absolute left-7 border-0 text-base mt-0 leading-normal;
content: counter(counter) '.';
}
}
}
blockquote {
@apply my-5 pl-4 bg-[rgba(246,248,250)] border-l-[4px] border-[#11181C] dark:border-gray-500 dark:bg-[rgba(246,248,250,0.1)];
}
.footnotes {
@apply mt-10 before:content-[''];
& > h2 {
@apply text-yellow-500;
}
&::before {
@apply block w-full h-[1px] bg-gray-300;
}
& ol {
@apply list-decimal;
}
}
mark {
@apply bg-[#dbfdad] dark:bg-[#9db47e];
}
.callout-content {
@apply pt-1;
& > p {
@apply mb-0;
}
}
.katex {
@apply text-base;
}
a {
@apply text-primary;
}
p {
@apply leading-9 mb-2;
}
strong {
@apply text-[15px];
}
table {
@apply w-full;
th {
@apply bg-[#f1f7fd] dark:bg-[#334052];
}
tr,
th,
td {
@apply border p-[10px_20px];
}
}
input[type='checkbox'] {
width: 16px;
height: 16px;
border-radius: 4px;
position: relative;
cursor: not-allowed;
}
.douyin {
min-width: 320px;
min-height: 720px;
border-radius: 10px;
margin: 10px 0;
}
}

View File

@@ -1,210 +1,259 @@
"use client"
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { useConfigStore } from "@/stores";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { ToastContainer, toast } from 'react-toastify'
import { ToastContainer, toast } from "react-toastify";
import "react-photo-view/dist/react-photo-view.css";
import 'react-toastify/dist/ReactToastify.css';
import 'highlight.js/styles/vs2015.css';
import "react-toastify/dist/ReactToastify.css";
import "katex/dist/katex.min.css";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { remarkMark } from 'remark-mark-highlight';
import rehypeHighlight from "rehype-highlight";
import { remarkMark } from "remark-mark-highlight";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSemanticBlockquotes from "rehype-semantic-blockquotes";
import rehypeCallouts from "rehype-callouts";
import 'rehype-callouts/theme/obsidian';
import rehypeRaw from 'rehype-raw';
import "rehype-callouts/theme/obsidian";
import Skeleton from "@/components/Skeleton";
import { BiCopy } from "react-icons/bi";
import "./index.scss";
import hljs from "highlight.js";
// 主题样式,换成你喜欢的
import "highlight.js/styles/atom-one-dark.css";
interface Props {
data: string;
data: string;
}
const ContentMD = ({ data }: Props) => {
const { isDark } = useConfigStore();
const [isClient, setIsClient] = useState(false);
const { isDark } = useConfigStore();
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
useEffect(() => {
setIsClient(true);
document.body.style.backgroundColor = '#fff';
let color = isDark ? "36, 41, 48" : "255, 255, 255";
document.body.style.backgroundColor = isDark ? "#0f0f0f" : "#fff";
const waves = document.querySelectorAll<SVGUseElement>(".waves use");
if (waves.length) {
waves[0].style.fill = `rgba(${color}, 0.7)`;
waves[1].style.fill = `rgba(${color}, 0.5)`;
waves[2].style.fill = `rgba(${color}, 0.3)`;
waves[3].style.fill = `rgba(${color})`;
}
return () => {
document.body.style.backgroundColor = '#f9f9f9';
if (waves) {
waves[0].style.fill = "rgba(249, 249, 249, 0.7)";
waves[1].style.fill = "rgba(249, 249, 249, 0.5)";
waves[2].style.fill = "rgba(249, 249, 249, 0.3)";
waves[3].style.fill = "rgba(249, 249, 249)";
}
};
}, [isDark]);
if (!isClient) {
return (
<div className="ContentMdComponent">
<div className="content markdown-body space-y-6 p-4">
{/* 标题骨架屏 */}
<Skeleton className="h-10 w-3/4" />
{/* 段落骨架屏 */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
{/* 图片骨架屏 */}
<Skeleton className="h-[200px] w-3/6 my-4" />
{/* 更多段落骨架屏 */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-9/12" />
</div>
{/* 代码块骨架屏 */}
<Skeleton className="h-[120px] w-full" />
</div>
</div>
);
// 处理波浪色假设页面有波浪SVG
let color = isDark ? "36, 41, 48" : "255, 255, 255";
const waves = document.querySelectorAll<SVGUseElement>(".waves use");
if (waves.length) {
waves[0].style.fill = `rgba(${color}, 0.7)`;
waves[1].style.fill = `rgba(${color}, 0.5)`;
waves[2].style.fill = `rgba(${color}, 0.3)`;
waves[3].style.fill = `rgba(${color})`;
}
const renderers = {
img: ({ alt, src }: { alt?: string; src?: string }) => {
const imgRef = useRef<HTMLImageElement>(null);
return () => {
document.body.style.backgroundColor = "#f9f9f9";
if (waves) {
waves[0].style.fill = "rgba(249, 249, 249, 0.7)";
waves[1].style.fill = "rgba(249, 249, 249, 0.5)";
waves[2].style.fill = "rgba(249, 249, 249, 0.3)";
waves[3].style.fill = "rgba(249, 249, 249)";
}
};
}, [isDark]);
useEffect(() => {
const img = imgRef.current;
if (!img) return;
if (!isClient) {
return (
<div className="ContentMdComponent">
<div className="content markdown-body space-y-6 p-4">
<Skeleton className="h-10 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-[200px] w-3/6 my-4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-9/12" />
</div>
<Skeleton className="h-[120px] w-full" />
</div>
</div>
);
}
// 监听图片是否进入可视区
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setTimeout(() => {
img.style.filter = 'blur(0px)';
}, 400)
observer.unobserve(img); // 停止观察
}
});
},
{ threshold: 0.1 }
);
// 代码块组件,带行号、折叠、复制
const CodeBlock = ({
language,
value,
}: {
language: string;
value: string;
}) => {
const [expanded, setExpanded] = useState(false);
const isLong = value.split("\n").length > 10;
observer.observe(img);
return () => {
observer.unobserve(img);
};
}, []);
return (
<PhotoView src={src || ''}>
<span className="flex justify-center my-4 dark:brightness-90">
<img ref={imgRef} alt={alt} src={src} className="max-h-[500px]" />
</span>
</PhotoView>
);
},
a: ({ href, children }: { href?: string, children?: React.ReactNode }) => {
if (children === 'douyin-video' && href) {
// 从URL中提取视频ID
const videoId = href.split('/').pop();
return (
<div className="flex justify-center">
<iframe
src={`https://open.douyin.com/player/video?vid=${videoId}&autoplay=0`}
referrerPolicy="unsafe-url"
allowFullScreen
className="douyin"
/>
</div>
);
}
return <a href={href}>{children}</a>;
},
code: ({ node, inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const text = useMemo(() => {
const getTextFromChildren = (children: React.ReactNode): string => {
return React.Children.toArray(children).map(child => {
if (typeof child === 'string') {
return child;
} else if (React.isValidElement(child)) {
return getTextFromChildren(((child.props as { children: React.ReactNode })).children);
}
return '';
}).join('').trim();
};
return getTextFromChildren(children);
}, [children]);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
toast.success('代码已复制 🎉');
}).catch(err => {
toast.error('复制失败 😖');
});
};
return (
<>
{(!inline && match) && (
<button onClick={handleCopy} className="absolute top-3 right-3 bg-gray-300 text-gray-700 rounded p-1.5 lg:opacity-0 transition-opacity">
<BiCopy />
</button>
)}
<code className={className} {...props}>
{children}
</code>
</>
);
const highlighted = useMemo(() => {
try {
if (hljs.getLanguage(language)) {
return hljs.highlight(value, { language }).value;
}
} catch {}
return hljs.highlightAuto(value).value;
}, [value, language]);
const handleCopy = () => {
navigator.clipboard.writeText(value).then(
() => toast.success("代码已复制 🎉"),
() => toast.error("复制失败 😖")
);
};
return (
<div className="ContentMdComponent">
<ToastContainer
theme={isDark ? "dark" : "light"}
autoClose={1000}
hideProgressBar />
<PhotoProvider>
<div className="content markdown-body">
<ReactMarkdown
components={renderers}
remarkPlugins={[[remarkGfm, { singleTilde: false }], remarkMath, remarkMark]}
rehypePlugins={[rehypeRaw, rehypeHighlight, rehypeKatex, rehypeCallouts, rehypeSemanticBlockquotes]}
>{data}</ReactMarkdown>
</div>
</PhotoProvider>
</div>
<pre
className={`mac-style ${
isLong ? (expanded ? "expanded" : "collapsed") : ""
}`}
onClick={() => {
if (isLong && !expanded) setExpanded(true);
}}
>
<div className="language-label">{language?.toLowerCase()}</div>
<button
className="copy-button"
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
type="button"
aria-label="复制代码"
>
<BiCopy size={16} />
</button>
<code
className={`hljs language-${language}`}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
{isLong && (
<button
className="toggle-btn"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
type="button"
>
{expanded
? "收起代码"
: `展开代码 (${value.split("\n").length} 行)`}
</button>
)}
</pre>
);
};
// 图片渲染支持懒加载和点击大图预览
const renderers = {
img: ({ alt, src }: { alt?: string; src?: string }) => {
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const img = imgRef.current;
if (!img) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setTimeout(() => {
img.style.filter = "blur(0px)";
}, 400);
observer.unobserve(img);
}
});
},
{ threshold: 0.1 }
);
observer.observe(img);
return () => {
observer.unobserve(img);
};
}, []);
return (
<PhotoView src={src || ""}>
<span className="flex justify-center my-4 dark:brightness-90">
<img ref={imgRef} alt={alt} src={src} className="max-h-[500px]" />
</span>
</PhotoView>
);
},
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
if (children === "douyin-video" && href) {
const videoId = href.split("/").pop();
return (
<div className="flex justify-center">
<iframe
src={`https://open.douyin.com/player/video?vid=${videoId}&autoplay=0`}
referrerPolicy="unsafe-url"
allowFullScreen
className="douyin"
/>
</div>
);
}
return <a href={href}>{children}</a>;
},
code: ({ node, inline, className = "", children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
if (inline || !match) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const language = match[1].toLowerCase();
const codeString = node?.value ?? String(children);
return <CodeBlock language={language} value={codeString} />;
},
};
return (
<div className="ContentMdComponent">
<ToastContainer
theme={isDark ? "dark" : "light"}
autoClose={1000}
hideProgressBar
/>
<PhotoProvider>
<div className="content markdown-body">
<ReactMarkdown
components={renderers}
remarkPlugins={[
[remarkGfm, { singleTilde: false }],
remarkMath,
remarkMark,
]}
rehypePlugins={[
rehypeRaw,
rehypeKatex,
rehypeCallouts,
rehypeSemanticBlockquotes,
]}
>
{data}
</ReactMarkdown>
</div>
</PhotoProvider>
</div>
);
};
export default ContentMD;

View File

@@ -1,37 +1,28 @@
@use "@/styles/var" as *;
@use '@/styles/var' as *;
:target::before {
content: "";
display: block;
// height: 80px;
content: '';
display: block;
height: 80px; // 防止锚点被顶部遮挡
}
.ContentNavComponent {
.nav_item ::before {
@apply content-[''] -left-2.5 absolute top-1/2 -translate-y-1/2 w-1 h-[15px] rounded-[20px] bg-primary transition-transform;
}
.nav_item::before {
@apply content-[''] -left-2.5 absolute top-1/2 -translate-y-1/2 w-1 h-[15px] rounded-[20px] bg-primary transition-transform;
}
.h2 {
padding-left: 35px;
// 动态支持 h1~h6 样式缩进
@for $i from 1 through 6 {
.h#{$i} {
padding-left: #{15 + $i * 10}px;
&.active {
padding-left: 45px;
&.active {
padding-left: #{25 + $i * 10}px;
&::before {
left: 30px;
}
&::before {
left: #{10 + $i * 10}px;
}
}
}
.h3 {
padding-left: 55px;
&.active {
padding-left: 65px;
&::before {
left: 50px;
}
}
}
}
}
}

View File

@@ -1,140 +1,151 @@
"use client"
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { MdOutlineKeyboardDoubleArrowLeft } from "react-icons/md";
import directory from '@/assets/svg/other/directory.svg';
import directory from "@/assets/svg/other/directory.svg";
import "./index.scss";
interface NavItem {
name: string;
href: string;
start: number;
end?: number;
className: string;
name: string;
href: string;
start: number;
end?: number;
className: string;
}
// 定义距离视口顶部多少像素时高亮导航项
const OFFSET = 85;
const ContentNav = () => {
const [open, setOpen] = useState(false);
const [navs, setNavs] = useState<NavItem[]>([]);
const [active, setActive] = useState(0);
const [open, setOpen] = useState(true);
const [navs, setNavs] = useState<NavItem[]>([]);
const [active, setActive] = useState(0);
useEffect(() => {
setTimeout(() => {
const list = document.querySelectorAll(".content h1, .content h2, .content h3");
useEffect(() => {
setTimeout(() => {
const list = document.querySelectorAll<HTMLHeadingElement>(
".content h1, .content h2, .content h3, .content h4, .content h5, .content h6"
);
list?.forEach((nav,index) => {
nav.setAttribute("id", nav.textContent! + index);
list?.forEach((nav, index) => {
const tag = nav.tagName.toLowerCase(); // "h1"~"h6"
nav.setAttribute("id", nav.textContent! + index);
nav.setAttribute("class", tag);
});
switch (nav.tagName) {
case "H1":
nav.setAttribute("class", "h1");
break;
case "H2":
nav.setAttribute("class", "h2");
break;
case "H3":
nav.setAttribute("class", "h3");
break;
}
});
const titles = Array.from(list).map((t) => {
const top = t.getBoundingClientRect().top + window.scrollY;
return {
href: t.textContent!,
top,
className: t.className,
};
});
// 给每个标题设置一个视口顶部的距离
const titles = Array.from(list)?.map(t => {
const top = t.getBoundingClientRect().top + window.scrollY;
return { href: t.textContent!, top, className: t.className };
});
const titlesList: NavItem[] = titles.map((title, index) => ({
name: title.href,
href: title.href + index,
start: title.top - OFFSET,
end:
index < titles.length - 1 ? titles[index + 1].top - OFFSET : Infinity,
className: title.className,
}));
// 设置起始距离和结束距离
const titlesList: NavItem[] = titles?.map((title, index) => ({
name: title.href,
href: title.href + index,
start: title.top - OFFSET, // 减去偏移量
end: index < titles.length - 1 ? titles[index + 1].top - OFFSET : Infinity,
className: title.className
}));
setNavs(titlesList);
setNavs(titlesList);
// 页面滚动到指定位置高亮导航项
const onHandleScroll = () => {
const scrollPosition = window.scrollY;
const activeIndex = titlesList.findIndex(
(item) => scrollPosition >= item.start && scrollPosition < item.end!
);
if (activeIndex !== -1) {
setActive(activeIndex);
}
};
// 初始化时执行一次,设置初始高亮状态
onHandleScroll();
window.addEventListener("scroll", onHandleScroll);
return () => {
window.removeEventListener("scroll", onHandleScroll);
};
}, 0);
}, []);
// 添加点击处理函数
const onHandleToNavItem = (index: number, href: string) => {
const element = document.getElementById(href);
if (element) {
const elementPosition = element.getBoundingClientRect().top + window.scrollY - OFFSET;
window.scrollTo({
top: elementPosition,
behavior: 'instant' // 改为instant实现直接跳转不使用平滑效果
});
setActive(index);
const onScroll = () => {
const scrollPosition = window.scrollY;
const activeIndex = titlesList.findIndex(
(item) => scrollPosition >= item.start && scrollPosition < item.end!
);
if (activeIndex !== -1) {
setActive(activeIndex);
}
};
};
return (
<>
{open
? (
<div className="fixed bottom-5 right-5 sm:top-[80%] sm:left-[320px] z-50 cursor-pointer flex justify-center items-center w-12 h-12 rounded-xl bg-white dark:bg-black-b dark:border-[#4e5969] p-3 border" onClick={() => setOpen(false)}>
<MdOutlineKeyboardDoubleArrowLeft className="w-full text-4xl text-primary" />
</div>
)
: (
!!navs?.length &&
<div className="fixed top-[80%] left-[2%] z-50 cursor-pointer w-12 h-12 rounded-xl bg-white dark:bg-black-b dark:border-[#4e5969] p-3 border" onClick={() => setOpen(true)}>
<Image src={directory} alt="" width={23} height={23} className="text-5xl text-primary" />
</div >
)
}
onScroll();
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, 0);
}, []);
<div className={`ContentNavComponent overflow-auto fixed top-0 z-[60] max-w-0 h-screen bg-[rgba(255,255,255,0.9)] dark:bg-[rgba(30,36,46,0.9)] backdrop-blur-sm shadow-[16px_0px_15px_-3px_rgba(101,155,246,0.1)] ${open ? 'min-w-[300px] p-[20px_10px]' : 'min-w-0'} transition-[min-width] hide_sliding`}>
<div className="flex justify-center items-center mt-5">
<Image src={directory} alt="" width={23} height={23} className="mr-2" />
</div>
const onHandleToNavItem = (index: number, href: string) => {
const element = document.getElementById(href);
if (element) {
const top = element.getBoundingClientRect().top + window.scrollY - OFFSET;
window.scrollTo({
top,
behavior: "instant", // 可改为 "smooth" 实现平滑滚动
});
setActive(index);
}
};
<div className="text-[#4d4d4d] dark:text-[#8c9ab1] text-sm w-full mt-4">
{navs?.map((item, index) => (
<a
key={index}
href={`#${item.href}`}
onClick={(e) => {
e.preventDefault();
onHandleToNavItem(index, item.href);
}}
className={`nav_item overflow-hidden relative block p-1 pl-5 mb-[5px] hover:text-primary ${active === index ? 'text-primary pl-[30px] rounded-[10px] text-[15px] dark:bg-[#313d4e99] before:!left-4' : ''} ${item.className}`}
>
{item.name}
</a>
))}
</div>
</div>
</>
);
return (
<>
{open ? (
<div
className="fixed bottom-5 right-5 sm:top-[80%] sm:left-[320px] z-50 cursor-pointer flex justify-center items-center w-12 h-12 rounded-xl bg-white dark:bg-black-b dark:border-[#4e5969] p-3 border"
onClick={() => setOpen(false)}
>
<MdOutlineKeyboardDoubleArrowLeft className="w-full text-4xl text-primary" />
</div>
) : (
!!navs?.length && (
<div
className="fixed top-[80%] left-[2%] z-50 cursor-pointer w-12 h-12 rounded-xl bg-white dark:bg-black-b dark:border-[#4e5969] p-3 border"
onClick={() => setOpen(true)}
>
<Image
src={directory}
alt=""
width={23}
height={23}
className="text-5xl text-primary"
/>
</div>
)
)}
<div
className={`ContentNavComponent overflow-auto fixed top-16 z-[60] max-w-0 h-screen bg-[rgba(255,255,255,0.9)] dark:bg-[rgba(30,36,46,0.9)] backdrop-blur-sm shadow-[16px_0px_15px_-3px_rgba(101,155,246,0.1)] ${
open ? "min-w-[300px] p-[20px_10px]" : "min-w-0"
} transition-[min-width] hide_sliding`}
>
<div className="flex justify-center items-center mt-5">
<Image
src={directory}
alt=""
width={23}
height={23}
className="mr-2"
/>
</div>
<div className="text-[#4d4d4d] dark:text-[#8c9ab1] text-sm w-full mt-4">
{navs?.map((item, index) => (
<a
key={index}
href={`#${item.href}`}
onClick={(e) => {
e.preventDefault();
onHandleToNavItem(index, item.href);
}}
className={`nav_item overflow-hidden relative block p-1 pl-5 mb-[5px] hover:text-primary ${
active === index
? "text-primary pl-[30px] rounded-[10px] text-[15px] dark:bg-[#313d4e99] before:!left-4"
: ""
} ${item.className}`}
>
{item.name}
</a>
))}
</div>
</div>
</>
);
};
export default ContentNav;