diff --git a/app/read/components/menu.tsx b/app/read/components/menu.tsx index 9f65727..045850d 100644 --- a/app/read/components/menu.tsx +++ b/app/read/components/menu.tsx @@ -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 : ( +
1 ? 0.85 : 1 + }} + className="truncate" + > + {title} +
+ ), + title: title + } + }) return ( 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, @@ -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
} + + 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 ( +
setLineRef(el, index)} + > +
+
{ + e.stopPropagation() + setPreviewVisible(true) + }} + title="查看大图" + > + +
+
+
+
+ {imageInfo.alt} setPreviewVisible(val), + mask:
查看大图
+ }} + /> + {imageInfo.alt && ( +
{imageInfo.alt}
+ )} +
+
+ ) + } + + // Markdown (表格/代码块等) + if (sentence.startsWith('![MD]')) { + const mdContent = sentence.slice(5) // 去掉 '![MD]' + return ( +
setLineRef(el, index)} + > +
handleLineClick(index)} + > + +
+
+
+ +
+
+ ) + } + + // 普通文本行 return (
({ title: chapter.title, - index + index, + level: chapter.level || 1 })), metadata: formattedBook.metadata } diff --git a/services/DB.ts b/services/DB.ts index 0ca338e..c894b60 100644 --- a/services/DB.ts +++ b/services/DB.ts @@ -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[] = [] diff --git a/services/MD.ts b/services/MD.ts index 8b1cfe4..871c7cc 100644 --- a/services/MD.ts +++ b/services/MD.ts @@ -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(`

${content}

`) - }) - 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('') } @@ -59,7 +71,8 @@ export function initMDBook(buffer: Buffer, name: string): FormattedBook { chapterList.push({ title: chapterTitle, - paragraphs + paragraphs, + level }) }) } @@ -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() }) return paragraphs diff --git a/test_md2.js b/test_md2.js new file mode 100644 index 0000000..5ad9c3d --- /dev/null +++ b/test_md2.js @@ -0,0 +1,34 @@ +const cheerio = require('cheerio'); +const html = ` +

Title

+

P1

+

Subtitle 1

+

P2

+

Subsubtitle

+

P3

+

Subtitle 2

+

P4

+

Quote Heading

P5

+`; +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); +}); diff --git a/types/book.ts b/types/book.ts index a0ea932..9696d9d 100644 --- a/types/book.ts +++ b/types/book.ts @@ -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 {