mirror of
https://github.com/LiuYuYang01/ThriveX-Blog.git
synced 2026-06-09 08:42:35 +08:00
feat(article): 优化文章内容和目录组件
- 更新文章代码块样式为mac样式 - 增加代码块语言显示 - 增加代码块收起折叠以及总行数显示 - 优化目录组件,支持 h1~h6 标题自动生成目录 - 调整目录样式,增加响应式布局支持 - 添加prettier配置,确保格式化代码统一规范
This commit is contained in:
15
.prettierrc.js
Normal file
15
.prettierrc.js
Normal 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文件被正确解析
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user