From dc1a33261edaebf8f90bb950edf734349403488f Mon Sep 17 00:00:00 2001 From: scarletborder Date: Mon, 29 Dec 2025 23:03:51 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20twitter=E5=92=8Cyoutube=E5=86=85?= =?UTF-8?q?=E5=B5=8C=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + doc/manual.md | 0 package.json | 3 + pnpm-lock.yaml | 177 ++++++++++++++++++ src/components/post/ContentView.tsx | 139 +++----------- src/components/post/comments/List.tsx | 96 +++++----- src/components/post/externalRes/processor.tsx | 67 +++++++ src/components/post/useBlogHighlighter.tsx | 43 +++++ src/services/BloggerComment.ts | 4 + 9 files changed, 371 insertions(+), 161 deletions(-) create mode 100644 doc/manual.md create mode 100644 src/components/post/externalRes/processor.tsx create mode 100644 src/components/post/useBlogHighlighter.tsx 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..923ffd0 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.0", "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..1fe0b23 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.0 + version: 3.3.0(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.0: + resolution: {integrity: sha512-gSIG2169ZK7UH6rBzuU+j1xnQbH3IlOTLEkuGrRiJJTMgETik+h+26yHyyVKrLkzwrOaYPk4K3OtEKycqKgNLw==} + 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.0(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..0d7edfc 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: 1, + 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; @@ -51,8 +48,11 @@ export default function CommentList({ try { const result = await GetPostLegacyComments({ postId: latestPostId.current, + startIndex: (page - 1) * size + 1, // Blogger API startIndex 从1开始 + maxResults: size, }); - setAllComments(result.items); + setComments(result.items); + setTotal(result.total); setCtx(prev => ({ ...prev, totalComments: result.total, @@ -60,7 +60,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 +69,8 @@ export default function CommentList({ }; useEffect(() => { - fetchAllComments(); - }, [postId]); + fetchComments(startIndex + 1, pageSize); // startIndex 从0开始,页码从1开始 + }, [postId, startIndex, pageSize]); const scrollAreaStyles: React.CSSProperties = { // 桌面端样式保持不变,移动端样式将不再直接使用在这个容器上 @@ -86,6 +87,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 +98,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..3b2906f --- /dev/null +++ b/src/components/post/externalRes/processor.tsx @@ -0,0 +1,67 @@ +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 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 ( +
+ +
+ ); + } + } + }, +}); + +export { getProcessorOptions }; diff --git a/src/components/post/useBlogHighlighter.tsx b/src/components/post/useBlogHighlighter.tsx new file mode 100644 index 0000000..14e9550 --- /dev/null +++ b/src/components/post/useBlogHighlighter.tsx @@ -0,0 +1,43 @@ +// hooks/useBlogHighlighter.ts +import { useEffect } from 'react'; +import DOMPurify from 'dompurify'; + +// 导入拆分后的模块 +import { cleanCodeMirrorContent } from './codeBlocks/codeMirrorCleaner'; +import { + performCompleteHighlight, + highlightOnMount, +} from './codeBlocks/prismHighlighter'; +import { + removeCodeBlockEnhancements, + createCodeBlockObserver, +} from './codeBlocks/codeBlockEnhancer'; + + +export const useBlogHighlighter = (content: string, cssLoaded: boolean, theme: string) => { + useEffect(() => { + if (!cssLoaded) return; + + const initHighlight = async () => { + // 1. 清理增强效果 + removeCodeBlockEnhancements(); + + // 2. 执行高亮 (这里直接传入清理过后的内容或直接操作 DOM) + const cleaned = cleanCodeMirrorContent(content); + const sanitized = DOMPurify.sanitize(cleaned); + + await performCompleteHighlight(sanitized); + await highlightOnMount(sanitized); + + // 3. 启动观察器 + const observer = createCodeBlockObserver(); + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }; + + const cleanupPromise = initHighlight(); + return () => { + cleanupPromise.then(cleanup => cleanup?.()); + }; + }, [content, cssLoaded, theme]); +}; diff --git a/src/services/BloggerComment.ts b/src/services/BloggerComment.ts index a16e9e9..7cd5381 100644 --- a/src/services/BloggerComment.ts +++ b/src/services/BloggerComment.ts @@ -6,6 +6,8 @@ import type { CommentItem } from '../models/CommentItem'; export type LegacyCommentRequest = { postId: string; + startIndex?: number; + maxResults?: number; }; export type LegacyCommentResponse = { @@ -38,6 +40,8 @@ export async function GetPostLegacyComments(req: LegacyCommentRequest): Promise< // 1. 获取主评论列表 const commentsData = await feed.comments.list({ postId: req.postId, + startIndex: req.startIndex, + maxResults: req.maxResults, }); // 2. 转换评论数据结构,并使用 lodash.keyBy 创建一个以评论ID为键的高效查找映射 From e87839986e5a174975b8d274a5d7af8fe2b184b9 Mon Sep 17 00:00:00 2001 From: scarletborder Date: Mon, 29 Dec 2025 23:22:06 +0800 Subject: [PATCH 2/4] fix: comment component --- src/components/post/comments/List.tsx | 8 +++--- src/services/BloggerComment.ts | 38 ++++++++++++++++----------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/components/post/comments/List.tsx b/src/components/post/comments/List.tsx index 0d7edfc..9cbb4aa 100644 --- a/src/components/post/comments/List.tsx +++ b/src/components/post/comments/List.tsx @@ -25,7 +25,7 @@ export default function CommentList({ const latestPostId = useLatest(postId); const { startIndex, pageSize, updateUrl } = usePaginationUrl({ - defaultStartIndex: 1, + defaultStartIndex: 0, defaultPageSize: 10, }); @@ -46,9 +46,11 @@ export default function CommentList({ setCtx(prev => ({ ...prev, loading: true })); try { + const apiStartIndex = (page - 1) * size + 1; + const result = await GetPostLegacyComments({ postId: latestPostId.current, - startIndex: (page - 1) * size + 1, // Blogger API startIndex 从1开始 + startIndex: apiStartIndex, maxResults: size, }); setComments(result.items); @@ -69,7 +71,7 @@ export default function CommentList({ }; useEffect(() => { - fetchComments(startIndex + 1, pageSize); // startIndex 从0开始,页码从1开始 + fetchComments(startIndex + 1, pageSize); }, [postId, startIndex, pageSize]); const scrollAreaStyles: React.CSSProperties = { diff --git a/src/services/BloggerComment.ts b/src/services/BloggerComment.ts index 7cd5381..cb8dc65 100644 --- a/src/services/BloggerComment.ts +++ b/src/services/BloggerComment.ts @@ -32,6 +32,7 @@ function transformComment(comments: any): CommentItem { }; } + export async function GetPostLegacyComments(req: LegacyCommentRequest): Promise { const feed = new BloggerFeed(API_URL_PREFIX, { jsonp: true, @@ -44,41 +45,45 @@ export async function GetPostLegacyComments(req: LegacyCommentRequest): Promise< maxResults: req.maxResults, }); - // 2. 转换评论数据结构,并使用 lodash.keyBy 创建一个以评论ID为键的高效查找映射 - const initialComments = Object.values(commentsData).map(transformComment); + // ==================== 修复点 1: 正确提取 items 数组 ==================== + // 原代码使用 Object.values(commentsData) 会错误地包含 totalResults 等元数据 + // Blogger API 返回结构通常是 { items: [...], totalResults: 100, ... } + const initialComments = commentsData.map(transformComment); const idToCommentItem = keyBy(initialComments, 'meta.id'); // 3. 识别出所有在当前列表中不存在的父评论ID const parentCommentIdsToFetch = new Set(); forEach(initialComments, (commentItem) => { const replyToId = commentItem.meta?.replyToId; - // 如果一个评论是回复,并且它的父评论不在当前列表中,则记录其ID以便后续获取 if (replyToId && !idToCommentItem[replyToId]) { parentCommentIdsToFetch.add(replyToId); } }); - // 4. 使用 Promise.all 并行获取所有缺失的父评论,以提高效率 + // 4. 并行获取缺失的父评论 if (parentCommentIdsToFetch.size > 0) { - const parentCommentPromises = Array.from(parentCommentIdsToFetch).map(id => feed.comments.get(req.postId, id).then(transformComment)); - const fetchedParentComments = await Promise.all(parentCommentPromises); + try { + const parentCommentPromises = Array.from(parentCommentIdsToFetch).map(id => feed.comments.get(req.postId, id) + .then(transformComment) + .catch(() => null)); + const fetchedParentComments = await Promise.all(parentCommentPromises); - // 5. 将新获取的父评论也加入到查找映射中,以便后续引用 - forEach(fetchedParentComments, (parentComment) => { - if (parentComment.meta?.id) { - idToCommentItem[parentComment.meta.id] = parentComment; - } - }); + forEach(fetchedParentComments, (parentComment) => { + if (parentComment?.meta?.id) { + idToCommentItem[parentComment.meta.id] = parentComment; + } + }); + } catch (e) { + console.warn('Failed to fetch parent comments', e); + } } - // 6. 最后一次遍历,为每个子评论添加对父评论的引用 - // 此时 idToCommentItem 包含了所有需要的评论(无论是初始加载的还是补充获取的) + // 6. 关联父子评论 forEach(initialComments, (commentItem) => { const replyToId = commentItem.meta?.replyToId; if (replyToId) { const parentComment = idToCommentItem[replyToId]; if (parentComment) { - // 在子评论 (commentItem) 上添加 inReplyTo 字段,引用父评论的信息 commentItem.inReplyTo = { author: { name: parentComment.author.name, @@ -90,12 +95,13 @@ export async function GetPostLegacyComments(req: LegacyCommentRequest): Promise< }); return { + // 从交叉类型中直接读取 totalResults total: commentsData.totalResults ?? 0, - // 返回最初获取和转换后的评论列表,现在它们已经包含了正确的 inReplyTo 信息 items: initialComments, }; } + /** * 获取比指定时间戳更新的评论。 * @param postId - 文章ID。 From 0b99bf07e0b7fd85b186024ed2b35a57287c4510 Mon Sep 17 00:00:00 2001 From: scarletborder Date: Tue, 30 Dec 2025 00:54:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?style:=20tweet=E7=BB=84=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E6=9A=97=E8=89=B2=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/post/externalRes/processor.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/post/externalRes/processor.tsx b/src/components/post/externalRes/processor.tsx index 3b2906f..e738e9f 100644 --- a/src/components/post/externalRes/processor.tsx +++ b/src/components/post/externalRes/processor.tsx @@ -4,6 +4,10 @@ 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; @@ -56,7 +60,38 @@ const getProcessorOptions = () => ({ overflow: 'hidden', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', }}> - + +
+ ); + } + + // 3. 新增:Bilibili 逻辑 + if (type === 'bilibili' && id) { + return ( +
+