diff --git a/frontend/app/routes.tsx b/frontend/app/routes.tsx index 1451023..2fbd81d 100644 --- a/frontend/app/routes.tsx +++ b/frontend/app/routes.tsx @@ -1,6 +1,6 @@ import ErrorPage from "hooks/Error"; import layout from "themes/echoes/layout"; -import article from "themes/echoes/article"; +import article from "themes/echoes/posts"; import about from "themes/echoes/about"; import { useLocation } from "react-router-dom"; import post from "themes/echoes/post"; diff --git a/frontend/package.json b/frontend/package.json index c90c2ff..138a7a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.6.1", "rehype-raw": "^7.0.0", + "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "three": "^0.171.0" diff --git a/frontend/themes/echoes/post.tsx b/frontend/themes/echoes/post.tsx index 2e5ee98..110b3bc 100644 --- a/frontend/themes/echoes/post.tsx +++ b/frontend/themes/echoes/post.tsx @@ -19,8 +19,9 @@ import { getColorScheme } from "themes/echoes/utils/colorScheme"; import MarkdownIt from 'markdown-it'; import { ComponentPropsWithoutRef } from 'react'; import remarkGfm from 'remark-gfm'; -import type { Components } from "react-markdown"; import { toast } from "hooks/Notification"; +import rehypeRaw from 'rehype-raw'; +import remarkEmoji from 'remark-emoji'; // 示例文章数据 const mockPost: PostDisplay = { @@ -29,12 +30,19 @@ const mockPost: PostDisplay = { content: ` # Markdown 完全指南:从基础到高级排版 -这篇指南将介绍 Markdown 的基础语法和高级排版技巧。 +这篇指南介绍 Markdown 的基础语法和高级排版技巧。 ## 1. 基础语法 ### 1.1 文本格式化 +
+**这是粗体文本** +*这是斜体文本* +***这是粗斜体文本*** +~~这是删除线文本~~ ++ 普通文本不需要任何特殊标记。 **这是粗体文本** @@ -44,6 +52,27 @@ const mockPost: PostDisplay = { ### 1.2 列表 +
+#### 无序列表: +- 第一项 + - 子项 1 + - 子项 2 +- 第二项 +- 第三项 + +#### 有序列表: +1. 第一步 + 1. 子步骤 1 + 2. 子步骤 2 +2. 第二步 +3. 第三步 + +#### 任务列表: +- [x] 已完成任务 +- [ ] 未完成任务 +- [x] 又一个已完成任务 ++ #### 无序列表: - 第一项 - 子项 1 @@ -65,8 +94,28 @@ const mockPost: PostDisplay = { ### 1.3 代码展示 +
行内代码:\`const greeting = "Hello World";\` ++ +行内代码:\`const greeting = "Hello World";\` + +
+代码块: +\`\`\`typescript +interface User { + id: number; + name: string; + email: string; +} + +function greet(user: User): string { + return \`Hello, \${user.name}!\`; +} +\`\`\` ++ 代码块: \`\`\`typescript interface User { @@ -82,6 +131,14 @@ function greet(user: User): string { ### 1.4 表格 +
+| 功能 | 基础版 | 高级版 | +|:-----|:------:|-------:| +| 文本编辑 | ✓ | ✓ | +| 实时预览 | ✗ | ✓ | +| 导出格式 | 2种 | 5种 | ++ | 功能 | 基础版 | 高级版 | |:-----|:------:|-------:| | 文本编辑 | ✓ | ✓ | @@ -92,7 +149,17 @@ function greet(user: User): string { ### 2.1 图文混排布局 -#### 左图右文 +
++ ++++高效写作工具
+使用合适的写作工具可以极大提升写作效率。推荐使用支持即时预览的编辑器,这样可以实时查看排版效果。
+
好的版面设计应该让内容清晰易读,层次分明。合理使用留白和分隔符可以让文章更有结构感。
-+++++ 🎯 如何选择合适的写作工具? +
+ +选择写作工具时需要考虑以下几点: + +1. **跨平台支持** - 确保在不同设备上都能访问 +2. **实时预览** - Markdown 实时渲染很重要 +3. **版本控制** - 最好能支持文章的版本管理 +4. **导出功能** - 支持导出为多种格式 +
++++++🚀 快速上手
+通过简单的标记语法,快速创建格式化的文档,无需复杂的排版工具。
+++⚡ 高效输出
+专注于内容创作,让工具自动处理排版,提高写作效率。
+
++++💡 小贴士
+在写作时,可以先列出文章大纲,再逐步充实内容。这样可以保证文章结构清晰,内容完整。
+
在写作时,可以先列出文章大纲,再逐步充实内容。这样可以保证文章结构清晰,内容完整。
写作时要注意文章的受众,使用他们能理解的语言和例子。
-+++++ ++ +1. 确定主题+根据目标受众和写作目的,确定文章主题。
++ ++2. 收集资料+广泛搜集相关资料,为写作做充实准备。
+
广泛搜集相关资料,为写作做充分准备。
-按照大纲逐步展开写作。
+广泛搜集相关资料,为写作做充实准备。
+> 📌 **最佳实践** +> +> 好的文章需要有清晰的结构和流畅的表达。以下是一些建议: +> +> 1. 开门见山,直入主题 +> 2. 层次分明,逻辑清晰 +> 3. 语言简洁,表达准确 +> +> *— 写作指南* ++ > 📌 **最佳实践** > > 好的文章需要有清晰的结构和流畅的表达。以下是一些建议: @@ -192,25 +299,25 @@ function greet(user: User): string { ## 3. 特殊语法 -### 3.1 数学公式 +### 3.1 脚注 -行内公式:$E = mc^2$ +
+这里有一个脚注[^1]。 -块级公式: - -$$ -\\frac{n!}{k!(n-k)!} = \\binom{n}{k} -$$ - -### 3.2 脚注 +[^1]: 这是脚注的内容。 +这里有一个脚注[^1]。 [^1]: 这是脚注的内容。 -### 3.3 表情符号 +### 3.2 表情符号 -:smile: :heart: :thumbsup: :star: :rocket: +
+:smile: :heart: :star: :rocket: ++ +:smile: :heart: :star: :rocket: ## 4. 总结 @@ -220,7 +327,7 @@ $$ 2. 高级排版:图文混排、折叠面板、卡片布局等 3. 特殊语法:数学公式、脚注、表情符号等 -> 💡 **提示**:部分高级排版功能可能需要特定的 Markdown 编辑器或渲染器支持。使用前请确认你的工具是否支持这些特性。 +> 💡 **提示**:部分高级排版功能可能需要特定的 Markdown 编辑器或渲染器支持,请确认是否支持这些功能。 `, authorName: "Markdown 专家", publishedAt: new Date("2024-03-15"), @@ -315,19 +422,21 @@ const generateSequentialId = (() => { } const counter = idMap.get(postId)!; - const id = `heading-${postId}-${counter}`; + const id = `heading-${counter}`; idMap.set(postId, counter + 1); return id; }; })(); export default new Template({}, ({ http, args }) => { + + const [toc, setToc] = useState
- {children} -
- ), - ul: ({ children, ...props }: ComponentPropsWithoutRef<'ul'>) => ( -- {children} -- ), - code: ({ inline, className, children, ...props }: ComponentPropsWithoutRef<'code'> & { inline?: boolean }) => { - const match = /language-(\w+)/.exec(className || ""); - const lang = match ? match[1].toLowerCase() : ""; + useEffect(() => { + if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) { + headingIds.current = [...headingIdsArrays[mockPost.id]]; + } + }, [headingIdsArrays, mockPost.id]); - return inline ? ( -
+ const components = useMemo(() => {
+ return {
+ h1: ({ children, node, ...props }: ComponentPropsWithoutRef<'h1'> & { node?: any }) => {
+ const headingId = headingIds.current.shift();
+ return (
+
+ {children}
+
+ );
+ },
+ h2: ({ children, node, ...props }: ComponentPropsWithoutRef<'h2'> & { node?: any }) => {
+ const headingId = headingIds.current.shift();
+ return (
+
+ {children}
+
+ );
+ },
+ h3: ({ children, node, ...props }: ComponentPropsWithoutRef<'h3'> & { node?: any }) => {
+ const headingId = headingIds.current.shift();
+ return (
+
+ {children}
+
+ );
+ },
+ p: ({ children, node, ...props }: ComponentPropsWithoutRef<'p'> & { node?: any }) => (
+
{children}
-
- ) : (
- + {children} ++ ), + code: ({ inline, className, children, node, ...props }: ComponentPropsWithoutRef<'code'> & { + inline?: boolean, + node?: any + }) => { + // 使用多个条件来确保服务端和客户端渲染一致 + const isInPre = Boolean( + className?.includes('language-') + ); + + // 如果是行内代码(不在 pre 标签内),使用行内样式 + if (!isInPre) { + return ( +
+ {children}
+
+ );
+ }
+
+ // 以下是代码块的处理逻辑
+ const match = /language-(\w+)/.exec(className || "");
+ const lang = match ? match[1].toLowerCase() : "";
+
+ return (
+ + {/* 直接输出原始内容,不经过 markdown 解析 */} + {typeof content === 'string' ? content : children} ++ ); + }, + }; + }, []); // 修改滚动监听逻辑 useEffect(() => { if (typeof window === 'undefined') return; + let scrollTimeout: NodeJS.Timeout; + const observer = new IntersectionObserver( (entries) => { - if (!isMounted) return; - - const container = document.querySelector("#main-content"); - const contentBox = document.querySelector(".prose"); - - if (!container || !contentBox) return; + // 如果是点击触发的滚动,不处理高亮更新 + if (!isMounted || isClickScrolling.current) return; - // 找出所有进入可视区域的标题 - const intersectingEntries = entries.filter(entry => entry.isIntersecting); - - if (intersectingEntries.length > 0) { - // 获取所有可见标题的位置信息 - const visibleHeadings = intersectingEntries.map(entry => ({ - id: entry.target.id, - top: entry.boundingClientRect.top - })); + // 清除之前的定时器 + clearTimeout(scrollTimeout); + + // 添加防抖,等待滚动结束后再更新高亮 + scrollTimeout = setTimeout(() => { + const visibleEntries = entries.filter(entry => entry.isIntersecting); - // 选择靠近视口顶部的标题 - const closestHeading = visibleHeadings.reduce((prev, current) => { - return Math.abs(current.top) < Math.abs(prev.top) ? current : prev; - }); - - setActiveId(closestHeading.id); - } + if (visibleEntries.length > 0) { + const visibleHeadings = visibleEntries + .map(entry => ({ + id: entry.target.id, + top: entry.boundingClientRect.top, + y: entry.intersectionRatio + })) + .sort((a, b) => { + if (Math.abs(a.y - b.y) < 0.1) { + return a.top - b.top; + } + return b.y - a.y; + }); + + const mostVisible = visibleHeadings[0]; + + setActiveId(currentActiveId => { + if (mostVisible.id !== currentActiveId) { + return mostVisible.id; + } + return currentActiveId; + }); + } + }, 100); // 100ms 的防抖延迟 }, { root: document.querySelector("#main-content"), - rootMargin: '-20px 0px -80% 0px', - threshold: [0, 1] + rootMargin: '-10% 0px -70% 0px', + threshold: [0, 0.25, 0.5, 0.75, 1] } ); @@ -596,6 +784,7 @@ export default new Template({}, ({ http, args }) => { } return () => { + clearTimeout(scrollTimeout); if (isMounted) { tocItems.forEach((item) => { const element = document.getElementById(item.id); @@ -610,36 +799,38 @@ export default new Template({}, ({ http, args }) => { // 修改点击处理函数 const handleTocClick = useCallback((e: React.MouseEvent, itemId: string) => { e.preventDefault(); + const element = document.getElementById(itemId); const container = document.querySelector("#main-content"); - const contentBox = document.querySelector(".prose"); // 获取实际内容容器 + const contentBox = document.querySelector(".prose"); if (element && container && contentBox) { + // 设置点击滚动标志 isClickScrolling.current = true; - // 计算元素相对于内容容器的偏移量 + // 立即更新高亮,不等待滚动 + setActiveId(itemId); + + // 计算滚动位置 const elementRect = element.getBoundingClientRect(); const contentBoxRect = contentBox.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); - // 计算元素相对于内容容器的偏移量 const relativeTop = elementRect.top - contentBoxRect.top; - - // 计算内容容器相对于滚动容器的偏移量 const contentOffset = contentBoxRect.top - containerRect.top; - - // 计算最终滚动距离 const scrollDistance = container.scrollTop + relativeTop + contentOffset; + // 执行滚动 container.scrollTo({ top: scrollDistance, behavior: "smooth", }); - // 滚动完成后重置标记 + // 延迟重置 isClickScrolling 标志 + // 增加延迟时间,确保滚动完全结束 const resetTimeout = setTimeout(() => { isClickScrolling.current = false; - }, 100); + }, 1500); // 增加到 1.5 秒 return () => clearTimeout(resetTimeout); } @@ -733,6 +924,37 @@ export default new Template({}, ({ http, args }) => { > ); + // 在组件顶部添加 useMemo 包裹静态内容 + const PostContent = useMemo(() => { + // 在渲染内容前重置 headingIds + if (headingIdsArrays[mockPost.id]) { + headingIds.current = [...headingIdsArrays[mockPost.id]]; + } + + return ( +