Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions app/read/components/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,28 @@ const renderTocMenu = (
onChapterChange: (index: number, lineIndex: number) => void,
collapsed: boolean
) => {
const menuItems = toc.map(({ title, index }) => ({
key: index,
label: collapsed ? index + 1 : title,
title: title
}))
const menuItems = toc.map(({ title, index, level }) => {
const lvl = level || 1
const indent = collapsed ? 0 : (lvl - 1) * 12

return {
key: index,
label: collapsed ? index + 1 : (
<div
style={{
paddingLeft: `${indent}px`,
fontSize: lvl === 1 ? '14px' : '13px',
fontWeight: lvl === 1 ? 600 : 'normal',
opacity: lvl > 1 ? 0.85 : 1
}}
className="truncate"
>
{title}
</div>
),
title: title
}
})

return (
<Menu
Expand Down
90 changes: 88 additions & 2 deletions app/read/components/readArea/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { Book, ReadingProgress } from "@/types/book"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import db from "@/services/DB"
import { EVENT_NAMES, EventEmitter } from "@/services/EventService"
import { Radio } from "antd"
import { Radio, Image } from "antd"
import { ZoomInOutlined } from "@ant-design/icons"
import { useStyleStore, FontSize } from "@/store/useStyleStore"

import ChatMarkdownWrapper from "@/app/components/common/MarkdownRendererWrapper"

export default function ReadArea({ book, readingProgress }: { book: Book, readingProgress: ReadingProgress }) {
const { fontSize } = useStyleStore()
Expand Down Expand Up @@ -150,6 +151,17 @@ export default function ReadArea({ book, readingProgress }: { book: Book, readin
)
}

// 解析图片标记:![IMG]alt|src 或 ![IMG]src
function parseImageLine(sentence: string): { src: string; alt: string } | null {
if (!sentence.startsWith('![IMG]')) return null
const content = sentence.slice(6) // 去掉 '![IMG]'
const pipeIndex = content.indexOf('|')
if (pipeIndex > 0) {
return { alt: content.slice(0, pipeIndex), src: content.slice(pipeIndex + 1) }
}
return { alt: '', src: content }
}

// 单行组件,使用memo优化性能
const Line = React.memo(({ sentence, index, isSelected, handleLineClick, setLineRef, size }: {
sentence: string,
Expand All @@ -159,15 +171,89 @@ const Line = React.memo(({ sentence, index, isSelected, handleLineClick, setLine
setLineRef: (element: HTMLDivElement | null, index: number) => void,
size: FontSize
}) => {
const [previewVisible, setPreviewVisible] = useState(false)

if (!sentence) {
return <div className="h-4" />
}

const imageInfo = parseImageLine(sentence)

const radioSizeClasses = {
small: 'w-5 h-5 pt-0.5',
medium: 'w-6 h-6 pt-[4.5px]',
large: 'w-7 h-7 pt-[5px]'
}

// 图片行
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>
Comment on lines +189 to +227

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.
)
}

// Markdown (表格/代码块等)
if (sentence.startsWith('![MD]')) {
const mdContent = sentence.slice(5) // 去掉 '![MD]'
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`}
onClick={() => handleLineClick(index)}
>
<Radio
checked={isSelected}
className={`${isSelected ? "" : "hidden group-hover:block"}`}
/>
</div>
<div className={`mx-1`} />
<div className="flex-1 py-2 overflow-x-auto w-0">
<ChatMarkdownWrapper content={mdContent} />
</div>
</div>
)
}

// 普通文本行
return (
<div
className={`flex mb-1 group rounded-lg min-h-[1.5em] ${isSelected ? 'bg-[var(--ant-color-bg-text-hover)]' : ''} hover:bg-[var(--ant-color-bg-text-hover)]`}
Expand Down
3 changes: 2 additions & 1 deletion services/BookService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ function createBookModel(formattedBook: FormattedBook, hash: string): Book {
chapterList: formattedBook.chapterList,
toc: formattedBook.chapterList.map((chapter, index) => ({
title: chapter.title,
index
index,
level: chapter.level || 1
})),
metadata: formattedBook.metadata
}
Expand Down
6 changes: 6 additions & 0 deletions services/DB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,12 @@ function paragraphs2Lines(book: Book, chapterIndex: number): string[] {

const allSentences: string[] = []
paragraphs.forEach(paragraph => {
// 图片段落或Markdown不拆句,整段保留
if (paragraph.startsWith('![IMG]') || paragraph.startsWith('![MD]')) {
allSentences.push(paragraph, 'EOB')
return
}

// 判断是否主要为中文文本
const isChinese = /[\u4e00-\u9fa5]/.test(paragraph)
let sentences: string[] = []
Expand Down
107 changes: 75 additions & 32 deletions services/MD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,58 @@ export function initMDBook(buffer: Buffer, name: string): FormattedBook {
const title = $('h1').text() || name
const language = detectLanguage(mdString.slice(0, 500))

// 将h3和h4转换为普通段落
$('h3, h4, h5').each((_, elem) => {
const content = $(elem).html() || ''
$(elem).replaceWith(`<p>${content}</p>`)
})

const chapterList: PlainTextChapter[] = []

// 找到所有h2元素
const h2Elements = $('h2')
// 找到所有顶层标题元素 (h1~h6)
const headingElements = $('body > h1, body > h2, body > h3, body > h4, body > h5, body > h6')

if (h2Elements.length === 0) {
// 如果没有h2元素,将整个内容作为一个章节
if (headingElements.length === 0) {
// 如果没有标题元素,将整个内容作为一个章节
const content = $('body').html() || ''
// 把HTML内容转换为段落数组
const paragraphs = extractParagraphs($, content)

chapterList.push({
title: title,
paragraphs
paragraphs,
level: 1
})
} else {
// 根据h2元素分割内容
h2Elements.each((_, elem) => {
// 检查第一个标题之前的内容(保留文章开头的引言/前言)
const $bodyChildren = $('body').children()
const firstHeadingIndex = $bodyChildren.index(headingElements.first())
if (firstHeadingIndex > 0) {
const beforeHeading = $bodyChildren.slice(0, firstHeadingIndex)
const content = beforeHeading.map((_, el) => $.html(el)).get().join('')
const paragraphs = extractParagraphs($, content)
if (paragraphs.length > 0) {
chapterList.push({
title: title || '引言',
paragraphs,
level: 1
})
}
}

// 根据标题元素分割内容
headingElements.each((_, elem) => {
const chapterTitle = $(elem).text()
const level = parseInt(elem.tagName.replace(/h/i, ''), 10) || 1
let content = ''

// 获取当前h2元素
// 获取当前标题元素
const $elem = $(elem)

// 获取当前h2到下一个h2之间的内容
// 获取当前标题到下一个顶层标题之间的内容
let $nextAll = $elem.nextAll()
let $nextH2 = $nextAll.filter('h2').first()
let $nextHeading = $nextAll.filter('h1, h2, h3, h4, h5, h6').first()

if ($nextH2.length > 0) {
// 获取到下一个h2之前的所有元素
let $contents = $nextAll.slice(0, $nextAll.index($nextH2))
if ($nextHeading.length > 0) {
// 获取到下一个标题之前的所有元素
let $contents = $nextAll.slice(0, $nextAll.index($nextHeading))
content = $contents.map((_, el) => $.html(el)).get().join('')
} else {
// 如果没有下一个h2,获取当前h2后面的所有内容
// 如果没有下一个标题,获取当前标题后面的所有内容
content = $nextAll.map((_, el) => $.html(el)).get().join('')
}

Expand All @@ -59,7 +71,8 @@ export function initMDBook(buffer: Buffer, name: string): FormattedBook {

chapterList.push({
title: chapterTitle,
paragraphs
paragraphs,
level
})
})
}
Expand All @@ -80,20 +93,50 @@ function extractParagraphs($: cheerio.CheerioAPI, htmlContent: string): string[]
const $content = cheerio.load(htmlContent)
const paragraphs: string[] = []

// 提取所有段落元素
$content('p').each((_, elem) => {
const text = $content(elem).text().trim()
if (text) {
paragraphs.push(text)
$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()
Comment on lines +96 to +139

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.
})

return paragraphs
Expand Down
34 changes: 34 additions & 0 deletions test_md2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const cheerio = require('cheerio');
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);
});
Comment on lines +2 to +34

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.
2 changes: 2 additions & 0 deletions types/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ export interface FormattedBook {
export interface PlainTextChapter {
title: string;
paragraphs: string[];
level?: number;
}
interface TocItem {
title: string;
index: number;
level?: number;
}

export interface Book {
Expand Down
Loading