diff --git a/package-lock.json b/package-lock.json index ba08856c..c46a2d2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "github-stars-manager", "version": "0.5.5", "dependencies": { + "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", "highlight.js": "^11.11.1", "lucide-react": "^0.344.0", + "query-string": "^9.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -1946,6 +1948,12 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/query-string": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.2.0.tgz", + "integrity": "sha512-dnYqKg7eZ+t7ZhCuBtwLxjqON8yXr27hiu3zXfPqxfJSbWUZNwwISE0BJUxghlcKsk4lZSp7bdFSJBJVNWBfmA==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -4666,6 +4674,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -5314,6 +5331,18 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8566,6 +8595,23 @@ "node": ">=6" } }, + "node_modules/query-string": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz", + "integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -9407,6 +9453,18 @@ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/split-on-first": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index e9311357..efe93c2f 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,11 @@ "build:all": "npm run build && npm run build:server" }, "dependencies": { + "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", "highlight.js": "^11.11.1", "lucide-react": "^0.344.0", + "query-string": "^9.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx new file mode 100644 index 00000000..e66caff2 --- /dev/null +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -0,0 +1,415 @@ +import { memo, useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'; +import MarkdownRenderer from './MarkdownRenderer'; +import { + scanDomForTranslation, + ATTR_ORIGINAL, + ATTR_TRANSLATION, + DomBlockSegment, + wrapTextNodesWithAttr, + unwrapSpans, +} from '../utils/domTextScanner'; +import { translateBatch, TranslateResult } from '../services/translateService'; +import { detectLanguage, getTranslateDirection, cleanTranslatedText } from '../utils/markdownSplitter'; +import { FileText, Languages, Eye, Loader2 } from 'lucide-react'; + +export type DisplayMode = 'original' | 'translated' | 'bilingual'; +export type TranslationStatus = 'idle' | 'scanning' | 'translating' | 'translated' | 'error'; + +export interface BilingualMarkdownRendererHandle { + translate: () => Promise; + revert: () => void; + getStatus: () => TranslationStatus; +} + +interface BilingualMarkdownRendererProps { + markdown: string; + baseUrl?: string; + headingIds?: Map; + fontSize?: 'small' | 'medium' | 'large'; + language?: 'zh' | 'en'; + defaultDisplayMode?: DisplayMode; + displayMode?: DisplayMode; + onDisplayModeChange?: (mode: DisplayMode) => void; + onStatusChange?: (status: TranslationStatus) => void; + onProgress?: (current: number, total: number) => void; + onHeadingsTranslated?: (headings: { id: string; text: string }[]) => void; + autoTranslate?: boolean; +} + +const BILINGUAL_MODE_CSS = ` +.bimd-mode-translated [${ATTR_ORIGINAL}] { display: none !important; } +.bimd-mode-original [${ATTR_TRANSLATION}] { display: none !important; } +[${ATTR_TRANSLATION}] code { + background: rgba(0,0,0,0.06); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.92em; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} +@media (prefers-color-scheme: dark) { + [${ATTR_TRANSLATION}] code { + background: rgba(255,255,255,0.1); + } +} +`; + +function decodeHtmlEntities(text: string): string { + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + return textarea.value; +} + +const BilingualMarkdownRenderer = forwardRef(({ + markdown, + baseUrl, + headingIds, + fontSize = 'medium', + language = 'zh', + defaultDisplayMode = 'bilingual', + displayMode: controlledDisplayMode, + onDisplayModeChange, + onStatusChange, + onProgress, + onHeadingsTranslated, + autoTranslate = false, +}, ref) => { + const containerRef = useRef(null); + const segmentsRef = useRef([]); + const translationElementsRef = useRef>(new Map()); + const originalSpansRef = useRef([]); + const abortRef = useRef(null); + const statusRef = useRef('idle'); + const onHeadingsTranslatedRef = useRef(onHeadingsTranslated); + onHeadingsTranslatedRef.current = onHeadingsTranslated; + + const [internalDisplayMode, setInternalDisplayMode] = useState(defaultDisplayMode); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [error, setError] = useState(null); + + const isControlled = controlledDisplayMode !== undefined; + const displayMode = isControlled ? controlledDisplayMode : internalDisplayMode; + const modeClass = `bimd-mode-${displayMode}`; + + const updateStatus = useCallback((newStatus: TranslationStatus) => { + statusRef.current = newStatus; + setStatus(newStatus); + onStatusChange?.(newStatus); + }, [onStatusChange]); + + const removeTranslations = useCallback(() => { + translationElementsRef.current.forEach(el => { + if (el.parentNode) el.parentNode.removeChild(el); + }); + translationElementsRef.current.clear(); + + unwrapSpans(originalSpansRef.current); + originalSpansRef.current = []; + + segmentsRef.current.forEach(seg => { + if (!seg.hasVisualContent) { + seg.element.removeAttribute(ATTR_ORIGINAL); + } + }); + segmentsRef.current = []; + }, []); + + const scan = useCallback((): DomBlockSegment[] => { + const container = containerRef.current; + if (!container) return []; + const segments = scanDomForTranslation(container); + segmentsRef.current = segments; + return segments; + }, []); + + const translate = useCallback(async () => { + const container = containerRef.current; + if (!container || statusRef.current === 'translating') return; + + removeTranslations(); + + const segments = scan(); + if (segments.length === 0) { + updateStatus('translated'); + return; + } + + const segmentTexts = segments.map(s => s.text).filter(Boolean); + const sampleText = segmentTexts.slice(0, 20).join(' '); + const detected = detectLanguage(sampleText); + const targetLang = language; + + if (detected === targetLang) { + setError(language === 'zh' ? '内容已是中文,无需翻译' : 'Content is already in English'); + updateStatus('error'); + return; + } + + const direction = getTranslateDirection(detected, targetLang); + + for (const segment of segments) { + if (segment.hasVisualContent) { + const spans = wrapTextNodesWithAttr(segment.element, ATTR_ORIGINAL, 'true'); + originalSpansRef.current.push(...spans); + } else { + segment.element.setAttribute(ATTR_ORIGINAL, 'true'); + } + } + + updateStatus('translating'); + setProgress({ current: 0, total: segments.length }); + setError(null); + + abortRef.current = new AbortController(); + const signal = abortRef.current.signal; + + try { + + const batchSize = 10; + let completedCount = 0; + const translatedTexts: string[] = new Array(segments.length).fill(''); + + for (let i = 0; i < segments.length; i += batchSize) { + if (signal.aborted) throw new DOMException('Aborted', 'AbortError'); + + const batchIndices: number[] = []; + const batchTexts: string[] = []; + + for (let j = i; j < Math.min(i + batchSize, segments.length); j++) { + if (segments[j].text.trim()) { + batchTexts.push(segments[j].text); + batchIndices.push(j); + } + } + + if (batchTexts.length === 0) continue; + + const htmlIndices: number[] = []; + const htmlTexts: string[] = []; + const plainIndices: number[] = []; + const plainTexts: string[] = []; + + for (let k = 0; k < batchIndices.length; k++) { + const j = batchIndices[k]; + if (segments[j].hasInlineCode) { + htmlIndices.push(j); + htmlTexts.push(segments[j].text); + } else { + plainIndices.push(j); + plainTexts.push(segments[j].text); + } + } + + const processResults = (indices: number[], results: TranslateResult[]) => { + indices.forEach((segIndex, resultIndex) => { + translatedTexts[segIndex] = cleanTranslatedText(results[resultIndex]?.translatedText || ''); + }); + }; + + if (htmlTexts.length > 0) { + const htmlResults = await translateBatch(htmlTexts, direction.to, direction.from, signal, 'html'); + processResults(htmlIndices, htmlResults); + completedCount += htmlIndices.length; + setProgress({ current: completedCount, total: segments.length }); + onProgress?.(completedCount, segments.length); + } + + if (plainTexts.length > 0) { + const plainResults = await translateBatch(plainTexts, direction.to, direction.from, signal, 'plain'); + processResults(plainIndices, plainResults); + completedCount += plainIndices.length; + setProgress({ current: completedCount, total: segments.length }); + onProgress?.(completedCount, segments.length); + } + } + + const inlineContainerTags = new Set(['LI', 'TD', 'TH', 'DT', 'DD']); + + for (let i = 0; i < segments.length; i++) { + if (!translatedTexts[i]) continue; + + const wrapper = document.createElement('div'); + wrapper.setAttribute(ATTR_TRANSLATION, 'true'); + wrapper.className = + 'mt-1 pl-3 border-l-2 border-blue-400 dark:border-blue-500 text-gray-600 dark:text-text-tertiary text-sm leading-relaxed'; + + if (segments[i].hasInlineCode) { + const codeRegex = /([\s\S]*?)<\/code>/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = codeRegex.exec(translatedTexts[i])) !== null) { + if (match.index > lastIndex) { + wrapper.appendChild(document.createTextNode(translatedTexts[i].slice(lastIndex, match.index))); + } + const codeEl = document.createElement('code'); + codeEl.textContent = decodeHtmlEntities(match[1]); + wrapper.appendChild(codeEl); + lastIndex = codeRegex.lastIndex; + } + if (lastIndex < translatedTexts[i].length) { + wrapper.appendChild(document.createTextNode(translatedTexts[i].slice(lastIndex))); + } + } else { + wrapper.textContent = translatedTexts[i]; + } + + if (segments[i].blockType === 'heading' && segments[i].element.id) { + wrapper.setAttribute('data-bi-heading-id', segments[i].element.id); + } + + if (inlineContainerTags.has(segments[i].element.tagName)) { + segments[i].element.appendChild(wrapper); + } else { + segments[i].element.after(wrapper); + } + translationElementsRef.current.set(segments[i].id, wrapper); + } + + if (onHeadingsTranslatedRef.current) { + const headingTranslations: { id: string; text: string }[] = []; + for (let i = 0; i < segments.length; i++) { + if (segments[i].blockType === 'heading' && segments[i].element.id && translatedTexts[i]) { + headingTranslations.push({ id: segments[i].element.id, text: translatedTexts[i] }); + } + } + if (headingTranslations.length > 0) { + onHeadingsTranslatedRef.current(headingTranslations); + } + } + + updateStatus('translated'); + setProgress({ current: segments.length, total: segments.length }); + onProgress?.(segments.length, segments.length); + } catch (err) { + if ((err as { name?: string })?.name === 'AbortError') { + updateStatus('idle'); + return; + } + setError(err instanceof Error ? err.message : 'Translation failed'); + updateStatus('error'); + } + }, [markdown, language, scan, updateStatus, removeTranslations, onProgress]); + + const revert = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + } + removeTranslations(); + updateStatus('idle'); + setError(null); + setProgress({ current: 0, total: 0 }); + }, [removeTranslations, updateStatus]); + + useImperativeHandle(ref, () => ({ + translate, + revert, + getStatus: () => statusRef.current, + }), [translate, revert]); + + useEffect(() => { + revert(); + const timer = setTimeout(() => { + if (containerRef.current) { + scan(); + if (autoTranslate) { + translate(); + } + } + }, 150); + return () => clearTimeout(timer); + }, [markdown]); + + useEffect(() => { + return () => { + if (abortRef.current) { + abortRef.current.abort(); + } + }; + }, []); + + const handleModeChange = (newMode: DisplayMode) => { + if (!isControlled) { + setInternalDisplayMode(newMode); + } + onDisplayModeChange?.(newMode); + }; + + const isTranslated = status === 'translated'; + + return ( +
+ + + {!isControlled && ( +
+ {[ + { + mode: 'original' as DisplayMode, + icon: FileText, + label: language === 'zh' ? '原文' : 'Original', + }, + { + mode: 'translated' as DisplayMode, + icon: Languages, + label: language === 'zh' ? '译文' : 'Translated', + }, + { + mode: 'bilingual' as DisplayMode, + icon: Eye, + label: language === 'zh' ? '双语' : 'Bilingual', + }, + ].map(({ mode, icon: Icon, label }) => { + const active = displayMode === mode; + const disabled = mode !== 'original' && !isTranslated; + return ( + + ); + })} +
+ )} + +
+ +
+ + {status === 'translating' && ( +
+ + + {language === 'zh' ? '翻译中...' : 'Translating...'} + {progress.total > 0 && ` ${progress.current}/${progress.total}`} + +
+ )} + + {error && ( +
{error}
+ )} +
+ ); +}); + +BilingualMarkdownRenderer.displayName = 'BilingualMarkdownRenderer'; + +export default memo(BilingualMarkdownRenderer); diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 4c287405..4ea5ff0a 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -738,13 +738,16 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }> }; const extractTextFromChildren = (children: React.ReactNode): string => { - if (typeof children === 'string') return children; - if (typeof children === 'number') return String(children); - if (Array.isArray(children)) return children.map(extractTextFromChildren).join(''); - if (React.isValidElement(children)) { - return extractTextFromChildren((children.props as { children?: React.ReactNode }).children); - } - return ''; + const inner = (children: React.ReactNode): string => { + if (typeof children === 'string') return children; + if (typeof children === 'number') return String(children); + if (Array.isArray(children)) return children.map(inner).join(''); + if (React.isValidElement(children)) { + return inner((children.props as { children?: React.ReactNode }).children); + } + return ''; + }; + return inner(children).replace(/\s+/g, ' ').trim(); }; const MarkdownRenderer: React.FC = memo(({ diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index ea20043a..4278708c 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp } from 'lucide-react'; -import MarkdownRenderer from './MarkdownRenderer'; +import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, Eye } from 'lucide-react'; +import BilingualMarkdownRenderer, { DisplayMode, BilingualMarkdownRendererHandle, TranslationStatus } from './BilingualMarkdownRenderer'; import { stripMarkdownFormatting } from '../utils/markdownUtils'; import { Repository } from '../types'; import { GitHubApiService } from '../services/githubApi'; @@ -43,11 +43,25 @@ export const ReadmeModal: React.FC = ({ const [scrollProgress, setScrollProgress] = useState(0); const [showBackToTop, setShowBackToTop] = useState(false); const [activeHeadingId, setActiveHeadingId] = useState(null); + const [displayMode, setDisplayMode] = useState('bilingual'); + const [errorExpanded, setErrorExpanded] = useState(false); + const [tocWidth, setTocWidth] = useState(224); + const [translatedHeadingMap, setTranslatedHeadingMap] = useState>(new Map()); const modalRef = useRef(null); const contentRef = useRef(null); const previousFocusRef = useRef(null); const abortControllerRef = useRef(null); + const isResizingRef = useRef(false); + const startXRef = useRef(0); + const startWidthRef = useRef(0); + + const bilingualRef = useRef(null); + const [translateStatus, setTranslateStatus] = useState('idle'); + const [translateProgress, setTranslateProgress] = useState({ current: 0, total: 0 }); + const [translateError, setTranslateError] = useState(null); + + const displayContent = readmeContent; const currentFontSize = FONT_SIZES[fontSizeIndex].value; @@ -93,6 +107,19 @@ export const ReadmeModal: React.FC = ({ if (!contentRef.current) return; const container = contentRef.current; + const translationWrapper = container.querySelector(`[data-bi-heading-id="${CSS.escape(id)}"]`) as HTMLElement | null; + if (translationWrapper && translationWrapper.offsetParent !== null) { + const elementRect = translationWrapper.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const scrollTop = container.scrollTop + elementRect.top - containerRect.top - 20; + try { + container.scrollTo({ top: scrollTop, behavior: 'smooth' }); + } catch { + container.scrollTop = scrollTop; + } + return; + } + let element = container.querySelector(`#${CSS.escape(id)}`) as HTMLElement | null; if (!element && fallbackText) { @@ -160,7 +187,8 @@ export const ReadmeModal: React.FC = ({ const topEntry = visibleEntries.reduce((a, b) => a.boundingClientRect.top < b.boundingClientRect.top ? a : b ); - setActiveHeadingId(topEntry.target.id); + const target = topEntry.target as HTMLElement; + setActiveHeadingId(target.dataset.biHeadingId ?? target.id); } }, { @@ -171,7 +199,10 @@ export const ReadmeModal: React.FC = ({ ); tocItems.forEach((item) => { - let el = container.querySelector(`#${CSS.escape(item.id)}`); + let el = container.querySelector(`[data-bi-heading-id="${CSS.escape(item.id)}"]`) as HTMLElement | null; + if (!el) { + el = container.querySelector(`#${CSS.escape(item.id)}`); + } if (!el && item.text) { const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (let i = 0; i < headings.length; i++) { @@ -190,7 +221,27 @@ export const ReadmeModal: React.FC = ({ clearTimeout(timer); if (observer) observer.disconnect(); }; - }, [tocItems, readmeContent]); + }, [tocItems, readmeContent, translateStatus, displayMode]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return; + const delta = e.clientX - startXRef.current; + setTocWidth(Math.max(150, Math.min(500, startWidthRef.current + delta))); + }; + const handleMouseUp = () => { + if (!isResizingRef.current) return; + isResizingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); const scrollToTop = useCallback(() => { if (contentRef.current) { @@ -206,8 +257,33 @@ export const ReadmeModal: React.FC = ({ setFontSizeIndex((prev) => (prev + 1) % FONT_SIZES.length); }, []); + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizingRef.current = true; + startXRef.current = e.clientX; + startWidthRef.current = tocWidth; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [tocWidth]); + const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + const handleTranslate = useCallback(async () => { + if (translateStatus === 'translating') return; + await bilingualRef.current?.translate(); + }, [translateStatus]); + + const handleRevertTranslation = useCallback(() => { + bilingualRef.current?.revert(); + setTranslatedHeadingMap(new Map()); + }, []); + + const handleHeadingsTranslated = useCallback((headings: { id: string; text: string }[]) => { + const map = new Map(); + headings.forEach(h => map.set(h.id, h.text)); + setTranslatedHeadingMap(map); + }, []); + const fetchReadme = useCallback(async () => { if (!repository) return; @@ -239,9 +315,6 @@ export const ReadmeModal: React.FC = ({ if (content.trim()) { setReadmeContent(content); - const { items, idMap } = extractToc(content); - setTocItems(items); - setHeadingIdMap(idMap); } else { setError(language === 'zh' ? '该仓库没有 README 文件' : 'This repository has no README file'); } @@ -254,7 +327,7 @@ export const ReadmeModal: React.FC = ({ setLoading(false); } } - }, [repository, githubToken, language, extractToc]); + }, [repository, githubToken, language]); useEffect(() => { if (isOpen && repository) { @@ -262,6 +335,15 @@ export const ReadmeModal: React.FC = ({ } }, [isOpen, repository, fetchReadme]); + useEffect(() => { + if (displayContent) { + const { items, idMap } = extractToc(displayContent); + setTocItems(items); + setHeadingIdMap(idMap); + setTranslatedHeadingMap(new Map()); + } + }, [displayContent, extractToc]); + useEffect(() => { setReadmeModalOpen(isOpen); return () => setReadmeModalOpen(false); @@ -281,6 +363,16 @@ export const ReadmeModal: React.FC = ({ setScrollProgress(0); setShowBackToTop(false); setActiveHeadingId(null); + setDisplayMode('bilingual'); + setErrorExpanded(false); + bilingualRef.current?.revert(); + setTranslateStatus('idle'); + setTranslateProgress({ current: 0, total: 0 }); + setTranslateError(null); + setTranslatedHeadingMap(new Map()); + isResizingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; } else { setShowToc(true); } @@ -346,6 +438,10 @@ export const ReadmeModal: React.FC = ({ return 'text-gray-500 dark:text-gray-500 text-xs'; }; + const isTranslating = translateStatus === 'translating'; + const isTranslated = translateStatus === 'translated'; + const isTranslateError = translateStatus === 'error'; + return (
= ({ style={{ maxWidth: '1130px' }} onClick={(e) => e.stopPropagation()} > - {/* Reading Progress Bar */} {readmeContent && !loading && (
= ({
)} - {/* Header */}
= ({
+ {readmeContent && !loading && ( + isTranslated ? ( + <> + + {([ + { mode: 'original' as DisplayMode, icon: FileText, label: t('原文', 'Original') }, + { mode: 'translated' as DisplayMode, icon: Languages, label: t('译文', 'Translated') }, + { mode: 'bilingual' as DisplayMode, icon: Eye, label: t('双语', 'Bilingual') }, + ]).map(({ mode, icon: Icon, label }) => ( + + ))} + + ) : isTranslateError ? ( + <> + + + + ) : ( + + ) + )} + {translateError && ( +
setErrorExpanded(!errorExpanded)} + title={!errorExpanded ? translateError : undefined} + > + {translateError} +
+ )} {tocItems.length > 0 && (
- {/* Main Content Area */}
- {/* TOC Sidebar */} {showToc && tocItems.length > 0 && ( -
-

- {t('目录', 'Contents')} -

- -
+ <> +
+

+ {t('目录', 'Contents')} +

+ +
+
+
+
+ )} - {/* Content */}
= ({
) : readmeContent ? ( - setTranslateProgress({ current, total })} + onHeadingsTranslated={handleHeadingsTranslated} /> ) : (
@@ -501,7 +699,6 @@ export const ReadmeModal: React.FC = ({ )}
- {/* Back to Top Button */} {showBackToTop && (