import { useEffect } from 'react' import { useLocation } from 'react-router-dom' import { useLanguage } from '../contexts/LanguageContext' import { shouldTranslateText, translateText } from '../utils/i18n' const translatedTitleAttr = 'data-i18n-title-original' const translatedPlaceholderAttr = 'data-i18n-placeholder-original' const translatedAriaLabelAttr = 'data-i18n-aria-label-original' const attributeNames = ['title', 'placeholder', 'aria-label'] as const const translatedTextNodes = new Set() const textOriginals = new WeakMap() const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small' export default function AutoTranslate() { const { language } = useLanguage() const location = useLocation() useEffect(() => { if (language === 'zh') { restoreTranslatedNodes(document.body) return } translateNode(document.body) const pending = new Set() let scheduled = false const flush = () => { scheduled = false const nodes = Array.from(pending) pending.clear() for (const node of nodes) { if (node.isConnected) translateNode(node) } } const schedule = (node: Node) => { pending.add(node) if (scheduled) return scheduled = true window.requestAnimationFrame(flush) } const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(schedule) } else { schedule(mutation.target) } } }) observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [...attributeNames], }) return () => observer.disconnect() }, [language, location.pathname, location.search]) return null } function translateNode(root: Node) { if (root.nodeType === Node.TEXT_NODE) { translateTextNode(root as Text) return } if (!(root instanceof Element)) return if (shouldSkipElement(root)) return translateWholeTextElement(root) root.querySelectorAll(wholeTextSelector).forEach(translateWholeTextElement) translateElementAttributes(root) const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT const parent = node.parentElement if (!parent || shouldSkipElement(parent)) { return NodeFilter.FILTER_REJECT } return NodeFilter.FILTER_ACCEPT }, }) const nodes: Text[] = [] while (walker.nextNode()) nodes.push(walker.currentNode as Text) for (const node of nodes) translateTextNode(node) root.querySelectorAll('[title], [placeholder], [aria-label]').forEach(translateElementAttributes) } function translateTextNode(node: Text) { const original = node.textContent || '' if (!shouldTranslateText(original)) return const parent = node.parentElement if (!parent || shouldSkipElement(parent)) return const translated = translateText(original) if (translated === original) return textOriginals.set(node, original) translatedTextNodes.add(node) node.textContent = translated } function translateWholeTextElement(el: Element) { if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return const original = el.textContent || '' if (!shouldTranslateText(original)) return const translated = translateText(original) if (translated === original) return const textNodes = directTextNodes(el) if (textNodes.length === 0) return textNodes.forEach((node, index) => { textOriginals.set(node, node.textContent || '') translatedTextNodes.add(node) node.textContent = index === 0 ? translated : '' }) } function directTextNodes(el: HTMLElement) { return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE) } function translateElementAttributes(el: Element) { if (!(el instanceof HTMLElement)) return translateAttribute(el, 'title', translatedTitleAttr) translateAttribute(el, 'placeholder', translatedPlaceholderAttr) translateAttribute(el, 'aria-label', translatedAriaLabelAttr) } function restoreTranslatedNodes(root: ParentNode) { for (const node of Array.from(translatedTextNodes)) { if (!node.isConnected) { translatedTextNodes.delete(node) continue } if (root instanceof Document || root.contains(node)) { node.textContent = textOriginals.get(node) || node.textContent translatedTextNodes.delete(node) } } root.querySelectorAll(`[${translatedTitleAttr}]`).forEach((el) => { el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '') el.removeAttribute(translatedTitleAttr) }) root.querySelectorAll(`[${translatedPlaceholderAttr}]`).forEach((el) => { el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '') el.removeAttribute(translatedPlaceholderAttr) }) root.querySelectorAll(`[${translatedAriaLabelAttr}]`).forEach((el) => { el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '') el.removeAttribute(translatedAriaLabelAttr) }) } function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) { const storedOriginal = el.getAttribute(originalAttr) const original = storedOriginal || el.getAttribute(attr) || '' if (!shouldTranslateText(original)) return const translated = translateText(original) if (translated === original) return if (!storedOriginal) { el.setAttribute(originalAttr, original) } if (el.getAttribute(attr) !== translated) { el.setAttribute(attr, translated) } } function shouldSkipElement(el: Element) { return !!el.closest('script, style, code, pre, textarea, [data-no-translate]') } function isSimpleTextElement(el: HTMLElement) { if (!el.matches(wholeTextSelector)) return false if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false const textNodes = directTextNodes(el) if (textNodes.length === 0) return false return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg') }