Skip to content

feat: 优化 Markdown 渲染(树状多级目录、保留首段引言、图片全屏预览) - #13

Open
EricLeeK wants to merge 3 commits into
WindChimeEcho:mainfrom
EricLeeK:main
Open

feat: 优化 Markdown 渲染(树状多级目录、保留首段引言、图片全屏预览)#13
EricLeeK wants to merge 3 commits into
WindChimeEcho:mainfrom
EricLeeK:main

Conversation

@EricLeeK

Copy link
Copy Markdown

你好!我非常喜欢 ReadBridge,在使用过程中发现了一些关于 Markdown 导入与渲染的痛点,所以对 services/MD.ts 和阅读区做了重构:

主要改动:

  1. 支持真正的全层级树状大纲

    • 之前只识别 <h2> 并截断,导致 h2 之前的内容(如前言/引言)全部丢失,且不支持多级目录。
    • 修复:现在 h1h6 所有标题均会被正确分割为独立章节,并在侧边栏 TOC 中根据 level 自动缩进,呈现类似 Obsidian 的树状目录结构。
    • 保留了首个标题前的所有引言内容。
  2. 重写了图片与文本提取的遍历算法 (DOM Traversal)

    • 之前遇到 <p>文字<img...></p> 等混合结构时,提取顺序错乱,甚至包含特殊字符的图片链接会被 NLP 分句库误删。
    • 修复:使用递归遍历保证了文字与图片的精确相对顺序,不会再出现图片不显示或丢失的情况。
  3. 优雅的大图全屏预览功能

    • 新增:在图片行的左侧,将普通的选中单选框(Radio)替换为放大镜图标(ZoomInOutlined)。
    • 新增:引入了 antd 的 Image 组件,点击图片或放大镜图标,可以直接全屏预览大图(支持缩放拖拽、ESC 退出)。
  4. 支持 Markdown 表格和代码块渲染

    • 之前的解析会把表格结构强行提取为纯文本碎片并分句。
    • 修复:遇到 <table><pre> 等块级元素时,保留完整的 HTML,并通过内部的 ChatMarkdownWrapper 在阅读区渲染出原生 Markdown 表格与代码样式。

测试运行一切正常,希望这个 PR 能帮到其他像我一样依赖 Markdown 导入阅读的用户!

Copilot AI review requested due to automatic review settings March 29, 2026 17:55
@vercel

vercel Bot commented Mar 29, 2026

Copy link
Copy Markdown

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 heading level for 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 via ChatMarkdownWrapper; update TOC menu indentation by level.

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.

Comment on lines +189 to +227
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>

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

图片行目前没有任何地方会调用 handleLineClick(index),导致无法选中该行、不会触发 SEND_LINE_INDEX,也不会在点击时更新 currentLocation(只有滚动才会保存)。建议让图片行的容器或左侧区域响应点击并调用 handleLineClick(index),同时保留放大镜按钮的 stopPropagation 以避免误触。

Copilot uses AI. Check for mistakes.
Comment thread services/MD.ts
Comment on lines +96 to +139
$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()

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractParagraphs 现在对每个 body 顶层子节点做递归拼接文本;当遇到 ul/ol 等列表容器时,会把多个 li 的文本合并到同一个 paragraph(换行在渲染时会被折叠),从而把列表项粘在一起,影响阅读与后续分句。建议对 ul/ol 做特殊处理:按 li 逐条 push(必要时加上项目符号/序号),或将列表块整体作为 ![MD] 交给 Markdown 渲染。

Copilot uses AI. Check for mistakes.
Comment thread test_md2.js
Comment on lines +2 to +34
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);
});

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增的 test_md2.js 看起来是本地调试脚本(未被 npm scripts/测试框架引用),直接放在仓库根目录容易造成噪音或被误认为项目的一部分。建议移除该文件,或移到 scripts/ 并在 package.json 中加对应脚本/说明(或转为正式的单元测试)。

Suggested change
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,
};

Copilot uses AI. Check for mistakes.
@WindChimeEcho

Copy link
Copy Markdown
Owner

感谢pr,不过我最近比较忙,等我有时间看一下。

@WindChimeEcho

Copy link
Copy Markdown
Owner

还有就是,先将test文件删除一下,同时我认为一次性修改文件可能有些多,看看能不能拆分一下。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants