Files
CLICD/frontend/src/components/AutoTranslate.tsx
T

183 lines
6.3 KiB
TypeScript

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<Text>()
const textOriginals = new WeakMap<Text, string>()
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<Node>()
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<HTMLElement>(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<HTMLElement>('[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<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
el.removeAttribute(translatedTitleAttr)
})
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
el.removeAttribute(translatedPlaceholderAttr)
})
root.querySelectorAll<HTMLElement>(`[${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')
}