diff --git a/README.md b/README.md index 562696d..3dfba16 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Blogger 也能如此美丽 - [x] Discuss(Comments) - [x] Pages - [ ] More Docs +- [ ] Extra component + - [x] Twitter + - [x] Youtube ## 说明 diff --git a/doc/manual.md b/doc/manual.md new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 0cb64d3..a81d674 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,16 @@ "codemirror": "^6.0.2", "dexie": "^4.2.1", "dompurify": "^3.3.0", + "html-react-parser": "^5.2.11", "katex": "^0.16.25", "lodash": "^4.17.21", "prismjs": "^1.30.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", "react-dom": "^19.2.0", + "react-lite-youtube-embed": "^3.3.3", "react-router-dom": "^7.9.5", + "react-tweet": "^3.3.1", "swiper": "^12.0.3", "tdesign-icons-react": "^0.6.1", "tdesign-mobile-react": "^0.20.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a694ed7..14cf605 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: dompurify: specifier: ^3.3.0 version: 3.3.0 + html-react-parser: + specifier: ^5.2.11 + version: 5.2.11(@types/react@19.2.4)(react@19.2.0) katex: specifier: ^0.16.25 version: 0.16.25 @@ -71,9 +74,15 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) + react-lite-youtube-embed: + specifier: ^3.3.3 + version: 3.3.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router-dom: specifier: ^7.9.5 version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-tweet: + specifier: ^3.3.1 + version: 3.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) swiper: specifier: ^12.0.3 version: 12.0.3 @@ -715,6 +724,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1111,6 +1123,10 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} @@ -1203,6 +1219,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1221,9 +1241,22 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dompurify@3.3.0: resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1231,6 +1264,14 @@ packages: electron-to-chromium@1.5.250: resolution: {integrity: sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -1600,6 +1641,21 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-dom-parser@5.1.2: + resolution: {integrity: sha512-9nD3Rj3/FuQt83AgIa1Y3ruzspwFFA54AJbQnohXN+K6fL1/bhcDQJJY5Ne4L4A163ADQFVESd/0TLyNoV0mfg==} + + html-react-parser@5.2.11: + resolution: {integrity: sha512-WnSQVn/D1UTj64nSz5y8MriL+MrbsZH80Ytr1oqKqs8DGZnphWY1R1pl3t7TY3rpqTSu+FHA21P80lrsmrdNBA==} + peerDependencies: + '@types/react': 0.14 || 15 || 16 || 17 || 18 || 19 + react: 0.14 || 15 || 16 || 17 || 18 || 19 + peerDependenciesMeta: + '@types/react': + optional: true + + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -1630,6 +1686,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2118,6 +2177,15 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-lite-youtube-embed@3.3.3: + resolution: {integrity: sha512-RGrRpVu4cUKnIlS0/mZoWdS4rwdL0xAWpinbnzW+RsVrzh28URzTLl5zdOazlDbm6C0UM4ssnChbgcmEflr35Q==} + peerDependencies: + react: '>=18.2.0' + react-dom: '>=18.2.0' + + react-property@2.0.2: + resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2145,6 +2213,12 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-tweet@3.3.1: + resolution: {integrity: sha512-0Gj1YgBTe1K85NBMoWBlUc17Rdc67gIJLlacrVgj+3jWIoXpFfxPdZ1U5OnWkkF9zxhphqs2pY1i//JBfP3Qog==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -2377,6 +2451,12 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2389,6 +2469,11 @@ packages: resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==} engines: {node: '>= 4.7.0'} + swr@2.3.8: + resolution: {integrity: sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + tdesign-icons-react@0.6.1: resolution: {integrity: sha512-O+hfqf2HEDhZC3e7Rr+pkflnYVW1eMtRqcShhmegwWvj097rP910nGzWBGFNbuQEfoueRlCZ0iSacqYF71JeNQ==} peerDependencies: @@ -2519,6 +2604,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3143,6 +3233,10 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3654,6 +3748,8 @@ snapshots: classnames@2.5.1: {} + clsx@2.1.1: {} + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.19.1 @@ -3740,6 +3836,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} dexie@4.2.1: {} @@ -3757,10 +3855,28 @@ snapshots: '@babel/runtime': 7.28.4 csstype: 3.1.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + dompurify@3.3.0: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3769,6 +3885,10 @@ snapshots: electron-to-chromium@1.5.250: {} + entities@4.5.0: {} + + entities@6.0.1: {} + errno@0.1.8: dependencies: prr: 1.0.1 @@ -4301,6 +4421,28 @@ snapshots: dependencies: react-is: 16.13.1 + html-dom-parser@5.1.2: + dependencies: + domhandler: 5.0.3 + htmlparser2: 10.0.0 + + html-react-parser@5.2.11(@types/react@19.2.4)(react@19.2.0): + dependencies: + domhandler: 5.0.3 + html-dom-parser: 5.1.2 + react: 19.2.0 + react-property: 2.0.2 + style-to-js: 1.1.21 + optionalDependencies: + '@types/react': 19.2.4 + + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.1 + husky@9.1.7: {} iconv-lite@0.6.3: @@ -4322,6 +4464,8 @@ snapshots: imurmurhash@0.1.4: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -4790,6 +4934,13 @@ snapshots: react-is@18.3.1: {} + react-lite-youtube-embed@3.3.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + react-property@2.0.2: {} + react-refresh@0.18.0: {} react-router-dom@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -4815,6 +4966,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + react-tweet@3.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@swc/helpers': 0.5.18 + clsx: 2.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + swr: 2.3.8(react@19.2.0) + react@19.2.0: {} reflect.getprototypeof@1.0.10: @@ -5068,6 +5227,14 @@ snapshots: style-mod@4.1.3: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5076,6 +5243,12 @@ snapshots: swiper@12.0.3: {} + swr@2.3.8(react@19.2.0): + dependencies: + dequal: 2.0.3 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + tdesign-icons-react@0.6.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@babel/runtime': 7.28.4 @@ -5246,6 +5419,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + util-deprecate@1.0.2: {} validator@13.15.23: {} diff --git a/src/components/post/ContentView.tsx b/src/components/post/ContentView.tsx index 4c92659..0c569a9 100644 --- a/src/components/post/ContentView.tsx +++ b/src/components/post/ContentView.tsx @@ -1,141 +1,56 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import DOMPurify from 'dompurify'; +import parse from 'html-react-parser'; import useTheme from '../../hooks/useTheme'; -// 导入拆分后的模块 +// 导入原有工具 import { loadThemeStyles } from './codeBlocks/cssLoader'; import { generateContentStyles, generateInlineStyles } from './contentStyles'; import { cleanCodeMirrorContent } from './codeBlocks/codeMirrorCleaner'; -import { - performCompleteHighlight, - highlightAfterCSSLoad, - highlightOnMount, -} from './codeBlocks/prismHighlighter'; -import { - removeCodeBlockEnhancements, - createCodeBlockObserver, -} from './codeBlocks/codeBlockEnhancer'; +import { getProcessorOptions } from './externalRes/processor'; +import { useBlogHighlighter } from './useBlogHighlighter'; interface ContentViewProps { content: string; } -/** - * 内容渲染组件 - 安全地渲染包含各种格式的 HTML 字符串 - * 支持 Prism.js 语法高亮、KaTeX 数学公式、CodeMirror 代码块 - */ const ContentView: React.FC = ({ content }) => { const { theme } = useTheme(); const [cssLoaded, setCssLoaded] = useState(false); - // 主题样式加载 Effect + // 1. 处理主题 CSS 加载 useEffect(() => { - const loadStyles = async () => { - try { - setCssLoaded(false); - - // 加载主题相关的 CSS 文件 - await loadThemeStyles(theme); - - // 标记 CSS 已加载完成 - setCssLoaded(true); - - // CSS 加载完成后,重新高亮所有代码块 - setTimeout(async () => { - const cleanedContent = cleanCodeMirrorContent(content); - const sanitizedContent = DOMPurify.sanitize(cleanedContent); - await highlightAfterCSSLoad(sanitizedContent); - }, 50); - } catch (error) { - console.warn('Failed to load some CSS files:', error); - setCssLoaded(true); // 即使失败也标记为已完成,避免卡住 - } - }; - - loadStyles(); - }, [theme, content]); - - // 内容变化时的高亮 Effect - useEffect(() => { - // 只有在 CSS 加载完成后才执行高亮 - if (!cssLoaded) return; - - // 清除之前的代码块增强 - removeCodeBlockEnhancements(); - - // 动态加载所需语言包并高亮代码 - const highlightCode = async () => { - // 使用处理后的内容进行语言提取,而不是原始内容 - const cleanedContent = cleanCodeMirrorContent(content); - const sanitizedContent = DOMPurify.sanitize(cleanedContent); - await performCompleteHighlight(sanitizedContent); - }; - - highlightCode(); - }, [content, cssLoaded]); - - // 组件挂载时的高亮 Effect - useEffect(() => { - if (!cssLoaded) return; - - const setupHighlightOnMount = async () => { - const cleanedContent = cleanCodeMirrorContent(content); - const sanitizedContent = DOMPurify.sanitize(cleanedContent); - const cleanup = await highlightOnMount(sanitizedContent); - return cleanup; - }; - - let cleanup: (() => void) | undefined; - - setupHighlightOnMount().then((cleanupFn) => { - cleanup = cleanupFn; + let isMounted = true; + loadThemeStyles(theme).then(() => { + if (isMounted) setCssLoaded(true); }); - - return () => { - if (cleanup) { - cleanup(); - } - }; - }, [cssLoaded, content]); - - // 代码块观察器 Effect - useEffect(() => { - if (!cssLoaded) return; - - // 创建观察器来监听动态添加的代码块 - const observer = createCodeBlockObserver(); - - // 开始观察 DOM 变化 - observer.observe(document.body, { - childList: true, - subtree: true, - }); - return () => { - observer.disconnect(); + isMounted = false; }; - }, [cssLoaded]); + }, [theme]); - // 清理和简化 CodeMirror 生成的复杂 HTML 结构 - const cleanedContent = cleanCodeMirrorContent(content); + // 2. 抽离的复杂高亮逻辑副作用 + useBlogHighlighter(content, cssLoaded, theme); - // 使用 DOMPurify 清理传入的 HTML 字符串,防止 XSS 攻击 - const sanitizedContent = DOMPurify.sanitize(cleanedContent); + // 3. 内容预处理(Memo化防止重复计算) + const processedContent = useMemo(() => { + const cleaned = cleanCodeMirrorContent(content); + return DOMPurify.sanitize(cleaned); + }, [content]); - // 生成样式 - const contentStyles = generateContentStyles(theme); - const inlineStyles = generateInlineStyles(); + // 4. 解析配置 (识别 twitter/youtube) + const parseOptions = useMemo(() => getProcessorOptions(), []); return ( <> - +
+ className={`blog-content ${theme === 'dark' ? 'cm-s-yonce' : 'cm-s-duotone-light'}`} + style={generateInlineStyles()} + > + {/* 不再使用 dangerouslySetInnerHTML,而是解析后的 React 组件树 */} + {parse(processedContent, parseOptions)} +
); }; diff --git a/src/components/post/comments/List.tsx b/src/components/post/comments/List.tsx index 3094443..9cbb4aa 100644 --- a/src/components/post/comments/List.tsx +++ b/src/components/post/comments/List.tsx @@ -1,12 +1,13 @@ -import React, { useRef, useState, useEffect } from 'react'; -import { useVirtualList, useLatest } from 'ahooks'; -import { isMobile } from 'react-device-detect'; +import React, { useState, useEffect } from 'react'; +import { useLatest } from 'ahooks'; import { GetPostLegacyComments } from '../../../services/BloggerComment'; import { getCurrentTheme } from '../../../constants/colors'; import { CommentItemComponent } from './Item'; import type { CommentsState } from './types'; import type { SetState } from 'ahooks/lib/useSetState'; import type { CommentItem } from '../../../models/CommentItem'; +import { usePaginationUrl } from '../../../hooks/usePaginationUrl'; +import { Pagination } from 'tdesign-react'; type CommentListProps = { Ctx: CommentsState; @@ -23,23 +24,19 @@ export default function CommentList({ const { postId } = Ctx; const latestPostId = useLatest(postId); - const containerRef = useRef(null); - const wrapperRef = useRef(null); + const { startIndex, pageSize, updateUrl } = usePaginationUrl({ + defaultStartIndex: 0, + defaultPageSize: 10, + }); - const [allComments, setAllComments] = useState([]); + const [comments, setComments] = useState([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); - // useVirtualList Hook 仅在桌面端有意义,但我们可以在这里保留其定义 - const [virtualList] = useVirtualList(allComments, { - containerTarget: containerRef, - wrapperTarget: wrapperRef, - itemHeight: 110, - overscan: 10, - }); - - const fetchAllComments = async () => { + const fetchComments = async (page: number, size: number) => { if (!latestPostId.current) { - setAllComments([]); + setComments([]); + setTotal(0); setCtx({ totalComments: 0, loading: false }); setLoading(false); return; @@ -49,10 +46,15 @@ export default function CommentList({ setCtx(prev => ({ ...prev, loading: true })); try { + const apiStartIndex = (page - 1) * size + 1; + const result = await GetPostLegacyComments({ postId: latestPostId.current, + startIndex: apiStartIndex, + maxResults: size, }); - setAllComments(result.items); + setComments(result.items); + setTotal(result.total); setCtx(prev => ({ ...prev, totalComments: result.total, @@ -60,7 +62,8 @@ export default function CommentList({ })); } catch (error) { console.error('Failed to fetch comments:', error); - setAllComments([]); + setComments([]); + setTotal(0); setCtx(prev => ({ ...prev, totalComments: 0, loading: false })); } finally { setLoading(false); @@ -68,8 +71,8 @@ export default function CommentList({ }; useEffect(() => { - fetchAllComments(); - }, [postId]); + fetchComments(startIndex + 1, pageSize); + }, [postId, startIndex, pageSize]); const scrollAreaStyles: React.CSSProperties = { // 桌面端样式保持不变,移动端样式将不再直接使用在这个容器上 @@ -86,6 +89,10 @@ export default function CommentList({ fontSize: '16px', }; + const handlePageChange = (pageInfo: { current: number; pageSize: number }) => { + updateUrl(pageInfo.current - 1, pageInfo.pageSize); // startIndex 从0开始 + }; + if (loading) { return
正在加载评论...
; } @@ -93,38 +100,31 @@ export default function CommentList({ // ==================== 修改开始 (JSX 渲染逻辑) ==================== return ( <> - {allComments.length === 0 ? ( + {comments.length === 0 ? (
暂无评论,快来抢沙发吧!
- ) // 根据是否为移动端进行条件渲染 - : isMobile ? ( - // --- 移动端:渲染完整列表 --- - // 不需要 ref,也不需要固定的高度和滚动样式 -
- {allComments.map(comment => ( - - ))} -
- ) : ( - // --- 桌面端:使用虚拟列表 --- -
-
- {virtualList.map(ele => ( -
- -
+ ) : ( + <> +
+ {comments.map(comment => ( + ))}
-
- )} + + + )} ); // ==================== 修改结束 (JSX 渲染逻辑) ==================== diff --git a/src/components/post/externalRes/processor.tsx b/src/components/post/externalRes/processor.tsx new file mode 100644 index 0000000..e738e9f --- /dev/null +++ b/src/components/post/externalRes/processor.tsx @@ -0,0 +1,102 @@ +import { Tweet } from 'react-tweet'; +import LiteYouTubeEmbed from 'react-lite-youtube-embed'; +import 'react-lite-youtube-embed/dist/LiteYouTubeEmbed.css'; + +// 定义全局注入的 CSS +const tweetFixStyle = ` + .tweet-container-fix .react-tweet-theme { + background-color: transparent !important; + } + + .tweet-container-fix div[class*="_avatarOverflow"] img { + object-position: center top !important; + transform: translateY(-16px) !important; + } + + /* --- 2. 新增:自定义推文正文 (

标签) --- */ + /* 使用 article > p[lang] 来精准定位那段文字 */ + .tweet-container-fix article > p[lang] { + margin: 0 0; + } + + /* 可选:如果你想修改里面的链接颜色 */ + .tweet-container-fix article > p[lang] a { + color: #1d9bf0 !important; + text-decoration: none !important; + } +`; + +const getProcessorOptions = () => ({ + replace: (domNode: any) => { + if (!domNode.attribs) return; + + const className = domNode.attribs.class || ''; + + if (className.includes('external-res-')) { + const parts = className.split('-'); + const type = parts[2]; + const id = parts[3]; + + if (type === 'twitter' && id) { + return ( + // 给外层加一个特定 class: tweet-container-fix,用于 CSS 作用域限定 +

+ {/* 注入 Style */} + + + {/* 限制最大宽度,模拟官方推特卡片感 */} +
+ +
+
+ ); + } + + if (type === 'youtube' && id) { + return ( +
+ +
+ ); + } + + // 3. 新增:Bilibili 逻辑 + if (type === 'bilibili' && id) { + return ( +
+