generated from dellevin/template
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
// Translation modes
|
|
export const TranslationMode = {
|
|
OFF: 'off',
|
|
TRANSLATION_ONLY: 'translation-only',
|
|
ORIGINAL_ONLY: 'original-only',
|
|
BILINGUAL: 'bilingual'
|
|
}
|
|
|
|
// Make TranslationMode globally available for debugging
|
|
if (typeof window !== 'undefined') {
|
|
window.TranslationMode = TranslationMode
|
|
}
|
|
|
|
// Translation function that calls Flutter's translation service
|
|
const translate = async (text) => {
|
|
try {
|
|
// Call Flutter's translation handler
|
|
const result = await window.flutter_inappwebview.callHandler('translateText', text)
|
|
return result || `Translation failed: ${text}`
|
|
} catch (error) {
|
|
console.error('Translation failed:', error)
|
|
return `Translation error: ${text}`
|
|
}
|
|
}
|
|
|
|
export class Translator {
|
|
#translationMode = TranslationMode.OFF
|
|
observedElements = new Set()
|
|
#translatedElements = new WeakMap()
|
|
#observer = null
|
|
|
|
constructor() {
|
|
this.#initializeObserver()
|
|
}
|
|
|
|
#initializeObserver() {
|
|
this.#observer = new IntersectionObserver(
|
|
(entries) => {
|
|
// console.log(`IntersectionObserver triggered with ${entries.length} entries`)
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
// console.log('Element intersecting, translating:', entry.target.tagName, entry.target.textContent?.substring(0, 30))
|
|
this.#translateElement(entry.target).catch(error =>
|
|
console.warn('Translation failed in observer:', error)
|
|
)
|
|
}
|
|
})
|
|
},
|
|
{
|
|
rootMargin: '1280px',
|
|
threshold: 0
|
|
}
|
|
)
|
|
}
|
|
|
|
async setTranslationMode(mode) {
|
|
if (!Object.values(TranslationMode).includes(mode)) {
|
|
console.warn(`Invalid translation mode: ${mode}`)
|
|
return
|
|
}
|
|
|
|
const oldMode = this.#translationMode
|
|
this.#translationMode = mode
|
|
|
|
if (oldMode !== mode) {
|
|
// console.log(`Translation mode changed from ${oldMode} to ${mode}`)
|
|
|
|
if (mode === TranslationMode.OFF) {
|
|
// Turn off translation
|
|
this.#updateTranslationDisplay()
|
|
} else if (oldMode === TranslationMode.OFF) {
|
|
// Turn on translation - force translate visible elements and wait for completion
|
|
await this.#forceTranslateVisibleElements()
|
|
} else {
|
|
// Just update display mode
|
|
this.#updateTranslationDisplay()
|
|
}
|
|
}
|
|
|
|
// Re-render annotations after translation mode change (and after translation completion)
|
|
if (window.reader && window.reader.annotationsByValue) {
|
|
const existingAnnotations = Array.from(window.reader.annotationsByValue.values())
|
|
if (existingAnnotations.length > 0) {
|
|
// console.log('Re-rendering annotations after translation mode change:', existingAnnotations.length)
|
|
window.renderAnnotations(existingAnnotations)
|
|
}
|
|
}
|
|
}
|
|
|
|
getTranslationMode() {
|
|
return this.#translationMode
|
|
}
|
|
|
|
observeDocument(doc) {
|
|
// console.log('Observing document for translation, doc:', doc)
|
|
if (!doc) {
|
|
console.warn('No document provided to observeDocument')
|
|
return
|
|
}
|
|
|
|
const textElements = this.#walkTextNodes(doc.body || doc.documentElement)
|
|
// console.log(`Found ${textElements.length} text elements to observe`)
|
|
|
|
textElements.forEach(element => {
|
|
if (!this.observedElements.has(element)) {
|
|
this.#observer.observe(element)
|
|
this.observedElements.add(element)
|
|
// console.log('Added element to observer:', element.tagName, element.textContent?.substring(0, 50))
|
|
}
|
|
})
|
|
|
|
// console.log(`Total observed elements: ${this.observedElements.size}`)
|
|
}
|
|
|
|
clearTranslations() {
|
|
// Remove all translation elements and restore original content
|
|
this.observedElements.forEach(element => {
|
|
const translationElements = element.querySelectorAll('.translated-text')
|
|
translationElements.forEach(trans => trans.remove())
|
|
|
|
// Restore original text if hidden
|
|
this.#restoreOriginalText(element)
|
|
})
|
|
|
|
// Clear observer
|
|
this.#observer.disconnect()
|
|
this.observedElements.clear()
|
|
this.#translatedElements = new WeakMap()
|
|
|
|
// Reinitialize observer
|
|
this.#initializeObserver()
|
|
}
|
|
|
|
#walkTextNodes(root, rejectTags = ['pre', 'code', 'math', 'style', 'script']) {
|
|
const elements = []
|
|
|
|
const walk = (node, depth = 0) => {
|
|
if (depth > 15) return
|
|
|
|
const children = Array.from(node.children || [])
|
|
for (const child of children) {
|
|
if (rejectTags.includes(child.tagName.toLowerCase())) {
|
|
continue
|
|
}
|
|
|
|
// Skip translation elements
|
|
if (child.classList.contains('translated-text')) {
|
|
continue
|
|
}
|
|
|
|
const hasDirectText = Array.from(child.childNodes).some(node => {
|
|
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
|
return true
|
|
}
|
|
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SPAN') {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
if (child.children.length === 0 && child.textContent?.trim()) {
|
|
elements.push(child)
|
|
} else if (hasDirectText) {
|
|
elements.push(child)
|
|
} else if (child.children.length > 0) {
|
|
walk(child, depth + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(root)
|
|
return elements
|
|
}
|
|
|
|
async #translateElement(element) {
|
|
if (this.#translationMode === TranslationMode.OFF) return
|
|
if (this.#translatedElements.has(element)) return
|
|
|
|
const text = element.innerText?.trim()
|
|
if (!text) return
|
|
|
|
try {
|
|
const translatedText = await translate(text)
|
|
|
|
// Mark as translated to prevent re-processing
|
|
this.#translatedElements.set(element, {
|
|
originalText: text,
|
|
translatedText: translatedText
|
|
})
|
|
|
|
this.#applyTranslation(element, translatedText)
|
|
} catch (error) {
|
|
console.warn('Translation failed:', error)
|
|
}
|
|
}
|
|
|
|
#applyTranslation(element, translatedText) {
|
|
// Remove existing translation if any
|
|
const existingTranslation = element.querySelector('.translated-text')
|
|
if (existingTranslation) {
|
|
existingTranslation.remove()
|
|
}
|
|
|
|
// Create translation wrapper
|
|
const wrapper = document.createElement('span')
|
|
wrapper.className = 'translated-text'
|
|
wrapper.setAttribute('data-translation-mark', '1')
|
|
wrapper.style.display = 'block'
|
|
// wrapper.style.fontSize = '0.9em'
|
|
// wrapper.style.color = '#666'
|
|
// wrapper.style.fontStyle = 'italic'
|
|
wrapper.style.marginTop = '0.2em'
|
|
wrapper.textContent = translatedText
|
|
|
|
// Apply based on current mode
|
|
this.#updateElementDisplay(element, wrapper)
|
|
|
|
element.appendChild(wrapper)
|
|
}
|
|
|
|
#updateElementDisplay(element, translationWrapper) {
|
|
const data = this.#translatedElements.get(element)
|
|
if (!data) return
|
|
|
|
switch (this.#translationMode) {
|
|
case TranslationMode.TRANSLATION_ONLY:
|
|
this.#hideOriginalText(element)
|
|
translationWrapper.style.display = 'block'
|
|
break
|
|
|
|
case TranslationMode.ORIGINAL_ONLY:
|
|
this.#restoreOriginalText(element)
|
|
translationWrapper.style.display = 'none'
|
|
break
|
|
|
|
case TranslationMode.BILINGUAL:
|
|
this.#restoreOriginalText(element)
|
|
translationWrapper.style.display = 'block'
|
|
break
|
|
|
|
case TranslationMode.OFF:
|
|
default:
|
|
this.#restoreOriginalText(element)
|
|
translationWrapper.style.display = 'none'
|
|
break
|
|
}
|
|
}
|
|
|
|
#hideOriginalText(element) {
|
|
// Use CSS to hide original content instead of removing DOM nodes
|
|
if (!element.hasAttribute('data-original-visibility')) {
|
|
element.setAttribute('data-original-visibility', 'hidden')
|
|
|
|
// Hide all child nodes except translation elements using CSS
|
|
Array.from(element.childNodes).forEach(node => {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const el = node
|
|
if (!el.classList || !el.classList.contains('translated-text')) {
|
|
// Store and hide using CSS
|
|
if (!el.hasAttribute('data-original-display')) {
|
|
el.setAttribute('data-original-display', el.style.display || 'initial')
|
|
el.style.display = 'none'
|
|
}
|
|
}
|
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
|
// For text nodes, store content and make invisible
|
|
if (!node.__originalContent) {
|
|
node.__originalContent = node.textContent
|
|
node.textContent = ''
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Mark element as having hidden text
|
|
element.classList.add('translation-source-hidden')
|
|
}
|
|
|
|
#restoreOriginalText(element) {
|
|
// Restore visibility by reversing the hide operations
|
|
if (element.hasAttribute('data-original-visibility')) {
|
|
// Restore all child nodes
|
|
Array.from(element.childNodes).forEach(node => {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const el = node
|
|
if (!el.classList || !el.classList.contains('translated-text')) {
|
|
// Restore original display
|
|
if (el.hasAttribute('data-original-display')) {
|
|
const originalDisplay = el.getAttribute('data-original-display')
|
|
el.style.display = originalDisplay === 'initial' ? '' : originalDisplay
|
|
el.removeAttribute('data-original-display')
|
|
}
|
|
}
|
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
|
// Restore text content
|
|
if (node.__originalContent !== undefined) {
|
|
node.textContent = node.__originalContent
|
|
delete node.__originalContent
|
|
}
|
|
}
|
|
})
|
|
|
|
element.removeAttribute('data-original-visibility')
|
|
}
|
|
|
|
element.classList.remove('translation-source-hidden')
|
|
}
|
|
|
|
async #forceTranslateVisibleElements() {
|
|
// console.log('Force translating visible elements')
|
|
|
|
const translationPromises = []
|
|
|
|
// Find elements in viewport and translate them immediately
|
|
this.observedElements.forEach(element => {
|
|
const rect = element.getBoundingClientRect()
|
|
const isVisible = rect.top < window.innerHeight && rect.bottom > 0
|
|
|
|
if (isVisible && !this.#translatedElements.has(element)) {
|
|
// console.log('Force translating visible element:', element)
|
|
const translationPromise = this.#translateElement(element).catch(error => {
|
|
console.warn('Force translation failed:', error)
|
|
})
|
|
translationPromises.push(translationPromise)
|
|
} else if (isVisible && this.#translatedElements.has(element)) {
|
|
// Element already translated, just update display
|
|
const translationWrapper = element.querySelector('.translated-text')
|
|
if (translationWrapper) {
|
|
this.#updateElementDisplay(element, translationWrapper)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Wait for all visible translations to complete
|
|
if (translationPromises.length > 0) {
|
|
// console.log(`Waiting for ${translationPromises.length} translations to complete`)
|
|
await Promise.allSettled(translationPromises)
|
|
// console.log('All visible translations completed')
|
|
}
|
|
}
|
|
|
|
#updateTranslationDisplay() {
|
|
// console.log('Updating translation display for mode:', this.#translationMode, 'Elements:', this.observedElements.size)
|
|
this.observedElements.forEach(element => {
|
|
const translationWrapper = element.querySelector('.translated-text')
|
|
if (translationWrapper) {
|
|
// console.log('Updating display for element with translation:', element)
|
|
this.#updateElementDisplay(element, translationWrapper)
|
|
} else {
|
|
// console.log('No translation wrapper found for element:', element)
|
|
}
|
|
})
|
|
}
|
|
|
|
destroy() {
|
|
this.clearTranslations()
|
|
this.#observer = null
|
|
}
|
|
} |