feat: 优化 Markdown 渲染(树状多级目录、保留首段引言、图片全屏预览) - #13
Conversation
|
Someone is attempting to deploy a commit to the windchime-echo's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
This PR refactors Markdown import/rendering to preserve full heading hierarchy for chapter splitting + TOC indentation, improves mixed text/image extraction order, and adds richer rendering for images and block content (tables/code) in the reading area.
Changes:
- Split Markdown into chapters using top-level
h1~h6, preserving pre-heading intro content and recording headinglevelfor TOC indentation. - Rework HTML extraction to keep text/image order, emitting special markers for images (
![IMG]...) and block HTML (![MD]...), and prevent sentence-splitting for those markers. - Update reading UI to support image fullscreen preview (antd
Image+ zoom icon) and render![MD]blocks viaChatMarkdownWrapper; update TOC menu indentation bylevel.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| types/book.ts | Add optional level to chapters/TOC items to support hierarchical TOC. |
| services/MD.ts | New chapter-splitting logic (h1~h6), intro retention, and recursive DOM traversal emitting ![IMG]/![MD] markers. |
| services/DB.ts | Treat ![IMG]/![MD] paragraphs as atomic lines (no sentence splitting). |
| services/BookService.ts | Populate TOC level from chapter level. |
| app/read/components/readArea/index.tsx | Image fullscreen preview rendering; render ![MD] blocks with markdown wrapper. |
| app/read/components/menu.tsx | Indent TOC items based on level. |
| test_md2.js | Add an ad-hoc script to test heading splitting behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (imageInfo) { | ||
| return ( | ||
| <div | ||
| className={`flex mb-4 group rounded-lg ${isSelected ? 'bg-[var(--ant-color-bg-text-hover)]' : ''} hover:bg-[var(--ant-color-bg-text-hover)]`} | ||
| ref={(el) => setLineRef(el, index)} | ||
| > | ||
| <div | ||
| className={`${radioSizeClasses[size]} flex justify-center items-start mt-2`} | ||
| > | ||
| <div | ||
| className="cursor-pointer text-gray-400 hover:text-blue-500 transition-colors hidden group-hover:block" | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| setPreviewVisible(true) | ||
| }} | ||
| title="查看大图" | ||
| > | ||
| <ZoomInOutlined style={{ fontSize: '18px' }} /> | ||
| </div> | ||
| </div> | ||
| <div className={`mx-1`} /> | ||
| <div className="flex-1 py-2"> | ||
| <Image | ||
| src={imageInfo.src} | ||
| alt={imageInfo.alt} | ||
| className="max-w-full h-auto rounded-md shadow-sm cursor-zoom-in" | ||
| style={{ maxHeight: '500px', objectFit: 'contain' }} | ||
| loading="lazy" | ||
| preview={{ | ||
| visible: previewVisible, | ||
| onVisibleChange: (val) => setPreviewVisible(val), | ||
| mask: <div className="flex items-center gap-2"><ZoomInOutlined /> 查看大图</div> | ||
| }} | ||
| /> | ||
| {imageInfo.alt && ( | ||
| <div className="text-xs text-gray-400 mt-2 text-center">{imageInfo.alt}</div> | ||
| )} | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
图片行目前没有任何地方会调用 handleLineClick(index),导致无法选中该行、不会触发 SEND_LINE_INDEX,也不会在点击时更新 currentLocation(只有滚动才会保存)。建议让图片行的容器或左侧区域响应点击并调用 handleLineClick(index),同时保留放大镜按钮的 stopPropagation 以避免误触。
| $content('body').children().each((_, elem) => { | ||
| const tagName = elem.tagName | ||
|
|
||
| if (tagName === 'h1' || tagName === 'h2') { | ||
| return // 跳过(章节分割用) | ||
| } | ||
| }) | ||
|
|
||
| // 处理其他可能的内容元素(如列表、引用等) | ||
| $content('li, blockquote').each((_, elem) => { | ||
| const text = $content(elem).text().trim() | ||
| if (text) { | ||
| paragraphs.push(text) | ||
| // 保留原始HTML供MarkdownRenderer渲染,比如表格和代码块 | ||
| if (tagName === 'table' || tagName === 'pre') { | ||
| const html = $content(elem).prop('outerHTML') || $content.html(elem) | ||
| if (html) { | ||
| paragraphs.push(`![MD]${html}`) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| let currentText = '' | ||
| const flushText = () => { | ||
| const t = currentText.trim() | ||
| if (t) paragraphs.push(t) | ||
| currentText = '' | ||
| } | ||
|
|
||
| const traverse = (node: any) => { | ||
| if (node.type === 'text') { | ||
| currentText += node.data | ||
| } else if (node.type === 'tag' && node.tagName === 'br') { | ||
| currentText += '\n' | ||
| } else if (node.type === 'tag' && node.tagName === 'img') { | ||
| flushText() | ||
| const src = node.attribs?.src | ||
| const alt = node.attribs?.alt || '' | ||
| if (src) { | ||
| paragraphs.push(`![IMG]${alt ? alt + '|' : ''}${src}`) | ||
| } | ||
| } else if (node.type === 'tag') { | ||
| if (node.children) { | ||
| node.children.forEach(traverse) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| traverse(elem) | ||
| flushText() |
There was a problem hiding this comment.
extractParagraphs 现在对每个 body 顶层子节点做递归拼接文本;当遇到 ul/ol 等列表容器时,会把多个 li 的文本合并到同一个 paragraph(换行在渲染时会被折叠),从而把列表项粘在一起,影响阅读与后续分句。建议对 ul/ol 做特殊处理:按 li 逐条 push(必要时加上项目符号/序号),或将列表块整体作为 ![MD] 交给 Markdown 渲染。
| const html = `<body> | ||
| <h1>Title</h1> | ||
| <p>P1</p> | ||
| <h2>Subtitle 1</h2> | ||
| <p>P2</p> | ||
| <h3>Subsubtitle</h3> | ||
| <p>P3</p> | ||
| <h2>Subtitle 2</h2> | ||
| <p>P4</p> | ||
| <blockquote><h2>Quote Heading</h2><p>P5</p></blockquote> | ||
| </body>`; | ||
| const $ = cheerio.load(html); | ||
|
|
||
| const headingElements = $('body > h1, body > h2, body > h3, body > h4, body > h5, body > h6'); | ||
| console.log("Found headings:", headingElements.length); | ||
| headingElements.each((_, elem) => { | ||
| const chapterTitle = $(elem).text(); | ||
| const level = parseInt(elem.tagName.replace('h', ''), 10); | ||
| console.log("Heading:", chapterTitle, "Level:", level); | ||
|
|
||
| const $elem = $(elem); | ||
| let $nextAll = $elem.nextAll(); | ||
| let $nextHeading = $nextAll.filter('h1, h2, h3, h4, h5, h6').first(); | ||
|
|
||
| let content = ''; | ||
| if ($nextHeading.length > 0) { | ||
| let $contents = $nextAll.slice(0, $nextAll.index($nextHeading)); | ||
| content = $contents.map((_, el) => $.html(el)).get().join('').trim(); | ||
| } else { | ||
| content = $nextAll.map((_, el) => $.html(el)).get().join('').trim(); | ||
| } | ||
| console.log(" Content:", content); | ||
| }); |
There was a problem hiding this comment.
新增的 test_md2.js 看起来是本地调试脚本(未被 npm scripts/测试框架引用),直接放在仓库根目录容易造成噪音或被误认为项目的一部分。建议移除该文件,或移到 scripts/ 并在 package.json 中加对应脚本/说明(或转为正式的单元测试)。
| const html = `<body> | |
| <h1>Title</h1> | |
| <p>P1</p> | |
| <h2>Subtitle 1</h2> | |
| <p>P2</p> | |
| <h3>Subsubtitle</h3> | |
| <p>P3</p> | |
| <h2>Subtitle 2</h2> | |
| <p>P4</p> | |
| <blockquote><h2>Quote Heading</h2><p>P5</p></blockquote> | |
| </body>`; | |
| const $ = cheerio.load(html); | |
| const headingElements = $('body > h1, body > h2, body > h3, body > h4, body > h5, body > h6'); | |
| console.log("Found headings:", headingElements.length); | |
| headingElements.each((_, elem) => { | |
| const chapterTitle = $(elem).text(); | |
| const level = parseInt(elem.tagName.replace('h', ''), 10); | |
| console.log("Heading:", chapterTitle, "Level:", level); | |
| const $elem = $(elem); | |
| let $nextAll = $elem.nextAll(); | |
| let $nextHeading = $nextAll.filter('h1, h2, h3, h4, h5, h6').first(); | |
| let content = ''; | |
| if ($nextHeading.length > 0) { | |
| let $contents = $nextAll.slice(0, $nextAll.index($nextHeading)); | |
| content = $contents.map((_, el) => $.html(el)).get().join('').trim(); | |
| } else { | |
| content = $nextAll.map((_, el) => $.html(el)).get().join('').trim(); | |
| } | |
| console.log(" Content:", content); | |
| }); | |
| /** | |
| * Extract headings and their associated HTML content from the given HTML string. | |
| * | |
| * Only direct child heading elements of <body> are considered (h1–h6). For each | |
| * heading, all following siblings up to (but not including) the next heading | |
| * are collected as that heading's content. | |
| * | |
| * @param {string} html - The HTML string to parse. | |
| * @returns {Array<{ title: string, level: number, content: string }>} | |
| */ | |
| function extractHeadings(html) { | |
| const $ = cheerio.load(html); | |
| const headingElements = $('body > h1, body > h2, body > h3, body > h4, body > h5, body > h6'); | |
| return headingElements | |
| .map((_, elem) => { | |
| const chapterTitle = $(elem).text(); | |
| const level = parseInt(elem.tagName.replace('h', ''), 10); | |
| const $elem = $(elem); | |
| const $nextAll = $elem.nextAll(); | |
| const $nextHeading = $nextAll.filter('h1, h2, h3, h4, h5, h6').first(); | |
| let content = ''; | |
| if ($nextHeading.length > 0) { | |
| const $contents = $nextAll.slice(0, $nextAll.index($nextHeading)); | |
| content = $contents.map((_, el) => $.html(el)).get().join('').trim(); | |
| } else { | |
| content = $nextAll.map((_, el) => $.html(el)).get().join('').trim(); | |
| } | |
| return { | |
| title: chapterTitle, | |
| level, | |
| content, | |
| }; | |
| }) | |
| .get(); | |
| } | |
| module.exports = { | |
| extractHeadings, | |
| }; |
|
感谢pr,不过我最近比较忙,等我有时间看一下。 |
|
还有就是,先将test文件删除一下,同时我认为一次性修改文件可能有些多,看看能不能拆分一下。 |
你好!我非常喜欢 ReadBridge,在使用过程中发现了一些关于 Markdown 导入与渲染的痛点,所以对
services/MD.ts和阅读区做了重构:主要改动:
支持真正的全层级树状大纲
<h2>并截断,导致h2之前的内容(如前言/引言)全部丢失,且不支持多级目录。h1到h6所有标题均会被正确分割为独立章节,并在侧边栏 TOC 中根据level自动缩进,呈现类似 Obsidian 的树状目录结构。重写了图片与文本提取的遍历算法 (DOM Traversal)
<p>文字<img...></p>等混合结构时,提取顺序错乱,甚至包含特殊字符的图片链接会被 NLP 分句库误删。优雅的大图全屏预览功能
ZoomInOutlined)。Image组件,点击图片或放大镜图标,可以直接全屏预览大图(支持缩放拖拽、ESC 退出)。支持 Markdown 表格和代码块渲染
<table>或<pre>等块级元素时,保留完整的 HTML,并通过内部的ChatMarkdownWrapper在阅读区渲染出原生 Markdown 表格与代码样式。测试运行一切正常,希望这个 PR 能帮到其他像我一样依赖 Markdown 导入阅读的用户!