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 图文混排布局 -#### 左图右文 +
+
+ 写作工具 +
+

高效写作工具

+

使用合适的写作工具可以极大提升写作效率。推荐使用支持即时预览的编辑器,这样可以实时查看排版效果。

+
+
+
-#### 右图左文 - -
-
-

版面设计原则

-

好的版面设计应该让内容清晰易读,层次分明。合理使用留白和分隔符可以让文章更有结构感。

-
- 设计工具 -
- ### 2.2 可折叠内容 +
+
+ + 🎯 如何选择合适的写作工具? + + +选择写作工具时需要考虑以下几点: + +1. **跨平台支持** - 确保在不同设备上都能访问 +2. **实时预览** - Markdown 实时渲染很重要 +3. **版本控制** - 最好能支持文章的版本管理 +4. **导出功能** - 支持导出为多种格式 +
+
+
🎯 如何选择合适的写作工具? @@ -133,6 +203,19 @@ function greet(user: User): string { ### 2.3 并排卡片 +
+
+
+

🚀 快速上手

+

通过简单的标记语法,快速创建格式化的文档,无需复杂的排版工具。

+
+
+

⚡ 高效输出

+

专注于内容创作,让工具自动处理排版,提高写作效率。

+
+
+
+

🚀 快速上手

@@ -146,18 +229,36 @@ function greet(user: User): string { ### 2.4 高亮提示框 +
+
+

💡 小贴士

+

在写作时,可以先列出文章大纲,再逐步充实内容。这样可以保证文章结构清晰,内容完整。

+
+
+

💡 小贴士

在写作时,可以先列出文章大纲,再逐步充实内容。这样可以保证文章结构清晰,内容完整。

-
-

⚠️ 注意事项

-

写作时要注意文章的受众,使用他们能理解的语言和例子。

-
- ### 2.5 时间线 +
+
+
+
+
1. 确定主题
+

根据目标受众和写作目的,确定文章主题。

+
+ +
+
+
2. 收集资料
+

广泛搜集相关资料,为写作做充实准备。

+
+
+
+
@@ -168,18 +269,24 @@ function greet(user: User): string {
2. 收集资料
-

广泛搜集相关资料,为写作做充分准备。

-
- -
-
-
3. 开始写作
-

按照大纲逐步展开写作。

+

广泛搜集相关资料,为写作做充实准备。

### 2.6 引用样式 +
+> 📌 **最佳实践**
+> 
+> 好的文章需要有清晰的结构和流畅的表达。以下是一些建议:
+> 
+> 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([]); const [tocItems, setTocItems] = useState([]); const [activeId, setActiveId] = useState(""); const contentRef = useRef(null); const [showToc, setShowToc] = useState(false); - const [isMounted, setIsMounted] = useState(true); + const [isMounted, setIsMounted] = useState(false); const [headingIdsArrays, setHeadingIdsArrays] = useState<{[key: string]: string[]}>({}); const headingIds = useRef([]); // 保持原有的 ref const containerRef = useRef(null); @@ -343,7 +452,7 @@ export default new Template({}, ({ http, args }) => { const md = new MarkdownIt(); const tocArray: TocItem[] = []; - // 重置计数器,传入文章ID + // 重计数器,传入文章ID generateSequentialId(mockPost.id.toString(), true); let isInCodeBlock = false; @@ -361,7 +470,6 @@ export default new Template({}, ({ http, args }) => { if (level <= 3 && !isInCodeBlock) { const content = tokens[idx + 1].content; - // 生成ID时传入文章ID const id = generateSequentialId(mockPost.id.toString()); token.attrSet('id', id); @@ -376,213 +484,293 @@ export default new Template({}, ({ http, args }) => { md.render(mockPost.content); + // 只在 ID 数组发生变化时更新 const newIds = tocArray.map(item => item.id); - headingIds.current = [...newIds]; - setHeadingIdsArrays(prev => ({ - ...prev, - [mockPost.id]: [...newIds] - })); + if (JSON.stringify(headingIds.current) !== JSON.stringify(newIds)) { + headingIds.current = [...newIds]; + setHeadingIdsArrays(prev => ({ + ...prev, + [mockPost.id]: [...newIds] + })); + } setToc(newIds); setTocItems(tocArray); - if (tocArray.length > 0) { + if (tocArray.length > 0 && !activeId) { setActiveId(tocArray[0].id); } - }, [mockPost.content, mockPost.id]); + }, [mockPost.content, mockPost.id, activeId]); - const components = useMemo(() => ({ - h1: ({ children, ...props }: ComponentPropsWithoutRef<'h1'> & { node?: any }) => { - if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) { - headingIds.current = [...headingIdsArrays[mockPost.id]]; - } - const headingId = headingIds.current.shift(); - return ( -

- {children} -

- ); - }, - h2: ({ children, ...props }: ComponentPropsWithoutRef<'h2'> & { node?: any }) => { - if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) { - headingIds.current = [...headingIdsArrays[mockPost.id]]; - } - const headingId = headingIds.current.shift(); - return ( -

- {children} -

- ); - }, - h3: ({ children, ...props }: ComponentPropsWithoutRef<'h3'> & { node?: any }) => { - if (headingIdsArrays[mockPost.id] && headingIds.current.length === 0) { - headingIds.current = [...headingIdsArrays[mockPost.id]]; - } - const headingId = headingIds.current.shift(); - return ( -

- {children} -

- ); - }, - p: ({ children, ...props }: ComponentPropsWithoutRef<'p'>) => ( -

- {children} -

- ), - ul: ({ children, ...props }: ComponentPropsWithoutRef<'ul'>) => ( -
    - {children} -
- ), - ol: ({ children, ...props }: ComponentPropsWithoutRef<'ol'>) => ( -
    - {children} -
- ), - li: ({ children, ...props }: ComponentPropsWithoutRef<'li'>) => ( -
  • - {children} -
  • - ), - blockquote: ({ children, ...props }: ComponentPropsWithoutRef<'blockquote'>) => ( -
    - {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} - - ) : ( -

    -
    -
    {lang || "text"}
    - +

    + ), + ul: ({ children, node, ...props }: ComponentPropsWithoutRef<'ul'> & { node?: any }) => ( +
      + {children} +
    + ), + ol: ({ children, node, ...props }: ComponentPropsWithoutRef<'ol'> & { node?: any }) => ( +
      + {children} +
    + ), + li: ({ children, node, ...props }: ComponentPropsWithoutRef<'li'> & { node?: any }) => ( +
  • + {children} +
  • + ), + blockquote: ({ children, node, ...props }: ComponentPropsWithoutRef<'blockquote'> & { node?: any }) => ( +
    + {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 ( +
    +
    +
    {lang || "text"}
    + +
    + +
    +
    +
    + + {String(children).replace(/\n$/, "")} + +
    +
    +
    - -
    -
    -
    - - {String(children).replace(/\n$/, "")} - + ); + }, + // 修改表格相关组件的响应式设计 + table: ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => ( +
    +
    +
    +
    + + {children} +
    - ); - }, - // 修改表格相关组件的响应式设计 - table: ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => ( -
    -
    -
    -
    - - {children} -
    -
    -
    -
    -
    - ), - - th: ({ children, ...props }: ComponentPropsWithoutRef<'th'>) => ( - - {children} - - ), - - td: ({ children, ...props }: ComponentPropsWithoutRef<'td'>) => ( - - {children} - - ), - }), [mockPost.id, headingIdsArrays]); + ), + + th: ({ children, ...props }: ComponentPropsWithoutRef<'th'>) => ( + + {children} + + ), + + td: ({ children, ...props }: ComponentPropsWithoutRef<'td'>) => ( + + {children} + + ), + // 修改 details 组件 + details: ({ children, ...props }: ComponentPropsWithoutRef<'details'>) => ( +
    + {children} +
    + ), + + // 修改 summary 组件 + summary: ({ children, ...props }: ComponentPropsWithoutRef<'summary'>) => ( + + {children} + + ), + pre: ({ children, ...props }: ComponentPropsWithoutRef<'pre'>) => { + // 添加调试日志 + console.log('Pre Component Props:', props); + console.log('Pre Component Children:', children); + + // 检查children的具体结构 + if (Array.isArray(children)) { + children.forEach((child, index) => { + console.log(`Child ${index}:`, child); + console.log(`Child ${index} props:`, (child as any)?.props); + }); + } + + const content = (children as any)?.[0]?.props?.children || ''; + console.log('Extracted content:', content); + + 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 ( + +
    + + {mockPost.content} + +
    +
    + ); + }, [mockPost.content, components, mockPost.id, headingIdsArrays]); // 添加必要的依赖 + return ( { className="relative flex-col lg:flex-row" gap={{initial: "4", lg: "8"}} > - {/* 文章主体 */} - + {/* 文章主体 - 调整宽度计算 */} + {/* 头部 */} @@ -849,28 +1071,13 @@ export default new Template({}, ({ http, args }) => { )} - {/* 内容区域 */} - -
    - - {mockPost.content} - -
    -
    + {/* 内容区域使用记忆化的组件 */} + {PostContent}
    - {/* 侧边目录 */} - + {/* 侧边目录 - 减小宽度 */} +