From 7f175010f85b388711904959972e8f765db86399 Mon Sep 17 00:00:00 2001 From: ianujvarshney Date: Sun, 5 Jul 2026 12:40:47 +0530 Subject: [PATCH] feat(ui): mount tabbed floating UI in Shadow DOM and wire background worker --- src/background.ts | 200 +++-------- src/content/main.tsx | 70 +++- src/content/ui/ShadowWrapper.tsx | 63 ++++ src/content/views/Popup.tsx | 587 +++++++++++++++---------------- 4 files changed, 456 insertions(+), 464 deletions(-) create mode 100644 src/content/ui/ShadowWrapper.tsx diff --git a/src/background.ts b/src/background.ts index 6099915..b13587d 100644 --- a/src/background.ts +++ b/src/background.ts @@ -15,100 +15,66 @@ function normalizeDetectedLang(detected: string | undefined | null): string { return baseMatch ? baseMatch.code : "en"; } -const GOOGLE_GENAI_API_URL = - "https://generativelanguage.googleapis.com/v1beta/models"; +import { LLMFactory } from "./services/llm.factory"; +import { CacheService } from "./services/cache/CacheService"; -async function getApiKey(): Promise { +const DEFAULT_PROVIDER = 'gemini'; + +async function getApiKey(): Promise { return new Promise((resolve) => { chrome.storage.local.get("genai_api_key", (result) => { - const key = result.genai_api_key; - resolve(typeof key === 'string' ? key : null); + resolve(typeof result.genai_api_key === 'string' ? result.genai_api_key : ''); }); }); } -// Utility to list available models -async function listModels(apiKey: string): Promise { - console.log("Listing available models with API key:", apiKey); - const res = await fetch(`${GOOGLE_GENAI_API_URL}?key=${apiKey}`); - const json = await res.json(); - console.log("Available models response:", json); - return json; -} - -// Always use 'gemini-1.5-flash' if available for translation -async function translateText( - text: string, - sourceLang: string, - targetLang: string -): Promise { - const apiKey = await getApiKey(); - if (!apiKey) { - console.error("API key not found"); - throw new Error("API key not found"); - } - - // List models and check for 'gemini-1.5-flash' - const models = await listModels(apiKey); - const flashModel = models?.models?.find( - (m: any) => m.name === "models/gemini-2.5-flash-preview-05-20" - ); - if (!flashModel) throw new Error("models/gemini-2.5-flash-preview-05-20 model not available"); - - // Get language names for prompt - const sourceLangObj = languages.find((l) => l.code === sourceLang); - const targetLangObj = languages.find((l) => l.code === targetLang); - const sourceLangName = sourceLangObj ? sourceLangObj.name : sourceLang; - const targetLangName = targetLangObj ? targetLangObj.name : targetLang; - - const prompt = `For the following text: "${text}" - -Please provide the translation from ${sourceLangName} to ${targetLangName} in a JSON format like this, without any markdown formatting: - -{ - "meaning": "The direct translation of the selected word/phrase", - "synonyms": ["Synonym 1", "Synonym 2"], - "examples": { - "source": "An example sentence in ${sourceLangName}", - "target": "The translation of the example sentence in ${targetLangName}" - } -} -`; - console.log("Translation request prompt:", prompt); - const url = `https://generativelanguage.googleapis.com/v1beta/${flashModel.name}:generateContent?key=${apiKey}`; - console.log("Translation request URL:", url); - const body = { - contents: [{ parts: [{ text: prompt }] }], - }; - console.log("Translation request body:", body); - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), +// 🗝️ Initialize API key placeholder (for dev/testing, though users should set it in options) +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.get("genai_api_key", (result) => { + if (!result.genai_api_key) { + chrome.storage.local.set({ genai_api_key: "" }); + } }); +}); + +async function handleTranslation(msg: any, sourceLang: string, sendResponse: (res: any) => void) { + try { + const apiKey = await getApiKey(); + const provider = LLMFactory.createProvider(DEFAULT_PROVIDER, apiKey); + + const cacheKey = CacheService.generateKey(msg.text, msg.mode === 'refine' ? (msg.refinementMode || 'improve') : msg.mode, msg.targetLang); + const cached = await CacheService.get(cacheKey); + + if (cached) { + return sendResponse({ translatedText: cached }); + } - const data = await res.json(); - console.log("Translation API response:", data); - // Extract JSON from model response text - let result = ""; - if (Array.isArray(data?.candidates)) { - const textBlock = data.candidates[0]?.content?.parts?.[0]?.text || ""; - console.log("Model response text block:", textBlock); - // Try to extract JSON from response - const match = textBlock.match(/\{[\s\S]*\}/); - result = match ? match[0] : textBlock; - console.log("Extracted translation result:", result); - } else { - console.log("No candidates found in response."); + if (msg.mode === 'dictionary') { + const res = await provider.translateDictionary({ + text: msg.text, sourceLang, targetLang: msg.targetLang + }); + const resString = JSON.stringify(res); + await CacheService.set(cacheKey, resString); + sendResponse({ translatedText: resString }); + } else if (msg.mode === 'refine') { + const res = await provider.refineText( + msg.text, { targetLang: msg.targetLang, mode: msg.refinementMode || 'improve' } + ); + await CacheService.set(cacheKey, res); + sendResponse({ translatedText: res }); + } else { + const res = await provider.translateRaw({ + text: msg.text, sourceLang, targetLang: msg.targetLang + }); + await CacheService.set(cacheKey, res); + sendResponse({ translatedText: res }); + } + } catch (err: any) { + console.error("Translation error:", err); + sendResponse({ translatedText: "", error: err.message }); } - return result; } -// 🗝️ Set API key placeholder once on install -chrome.runtime.onInstalled.addListener(() => { - chrome.storage.local.set({ genai_api_key: "AIzaSyA5yumOrzhK1T7a0OiPbSSPYRATFmgjy70" }); -}); - // 📩 Listen for translation requests and text-to-speech chrome.runtime.onMessage.addListener((msg: any, sender, sendResponse) => { console.log("Received message:", msg); @@ -118,28 +84,11 @@ chrome.runtime.onMessage.addListener((msg: any, sender, sendResponse) => { chrome.i18n.detectLanguage(msg.text, (result) => { const raw = result.languages[0]?.language || "en"; const detectedLang = normalizeDetectedLang(raw); - chrome.storage.sync.set({ sourceLang: detectedLang }, () => { - translateText(msg.text, detectedLang, msg.targetLang) - .then((translatedText) => { - console.log("Sending translated text response:", translatedText); - sendResponse({ translatedText }); - }) - .catch((err) => { - console.error("Translation error:", err); - sendResponse({ translatedText: "", error: err.message }); - }); - }); + chrome.storage.sync.set({ sourceLang: detectedLang }); + handleTranslation(msg, detectedLang, sendResponse); }); } else { - translateText(msg.text, msg.sourceLang, msg.targetLang) - .then((translatedText) => { - console.log("Sending translated text response:", translatedText); - sendResponse({ translatedText }); - }) - .catch((err) => { - console.error("Translation error:", err); - sendResponse({ translatedText: "", error: err.message }); - }); + handleTranslation(msg, msg.sourceLang, sendResponse); } return true; // async response } else if (msg.action === "openDashboard") { @@ -320,56 +269,9 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { } }); -// Listen for content script requests to show the popup -chrome.runtime.onMessage.addListener((msg, sender) => { - if (msg.action === "showFullPagePopup") { - chrome.storage.sync.get( - [ - "fullPageTranslate", - "fullPageTargetLang", - "showFullPagePopup", - "excludedSites", - "excludedLanguages", - "autoTranslateLangs", - ], - (settings) => { - const tabId = sender.tab?.id; - if (!tabId) return; - chrome.storage.local.get([`pageLang_${tabId}`], (result) => { - const pageLang = result[`pageLang_${tabId}`]; - const pageUrl = sender.tab?.url; - if ( - settings.fullPageTranslate && - settings.showFullPagePopup && - pageUrl && - !(settings as any).excludedSites.includes(new URL(pageUrl).hostname) && - !(settings as any).excludedLanguages.includes(pageLang) - ) { - // Show popup - chrome.tabs.sendMessage(tabId, { action: "createPopup" }); - } - }); - } - ); - } -}); -// Listen for requests to reset full-page translation settings -chrome.runtime.onMessage.addListener((msg) => { - if (msg.action === "resetFullPageSettings") { - chrome.storage.sync.set({ - fullPageTranslate: true, - fullPageTargetLang: "en", - showFullPagePopup: true, - autoCloseSidePanel: false, - excludedSites: [], - excludedLanguages: [], - autoTranslateLangs: [], - }); - } -}); /* ---------------- 📑 Context Menu (toggle support) ---------------- */ function createContextMenu() { diff --git a/src/content/main.tsx b/src/content/main.tsx index e532f5e..ecbc54d 100644 --- a/src/content/main.tsx +++ b/src/content/main.tsx @@ -1,5 +1,4 @@ // import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' import App from './views/App.tsx' import { LexiFlowSettingsProvider } from "../context/LexiFlowSettingsContext"; import { languages } from "../utils/languages"; @@ -7,6 +6,8 @@ import FullPageTranslationPopup from './components/FullPageTranslationPopup'; console.log('[CRXJS] Hello world from content script!') +import { ShadowDOMManager } from './ui/ShadowWrapper'; + // Listen for messages from the background script for text-to-speech chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg.action === "speakText") { @@ -176,22 +177,71 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; // Indicate async response } else if (msg.action === "createPopup") { - const popupContainer = document.createElement('div'); - popupContainer.id = 'lexiflow-full-page-popup-container'; - document.body.appendChild(popupContainer); - createRoot(popupContainer).render(); + const root = ShadowDOMManager.createShadowRoot('lexiflow-full-page-popup-container'); + root.render(); sendResponse({ status: "popup created" }); } }); -const container = document.createElement('div') -container.id = 'crxjs-app' -document.body.appendChild(container) -createRoot(container).render( +import { FieldDetectionEngine } from './core/fieldDetection'; +import Popup from './views/Popup'; +import { Root } from 'react-dom/client'; + +// Mount the persistent App context via Shadow DOM +const appRoot = ShadowDOMManager.createShadowRoot('lexiflow-app-container'); +appRoot.render( -) +); + +let dynamicRoot: Root | null = null; + +// Initialize Field Detection Engine for AI Writing popup +FieldDetectionEngine.start((element, adapter, rect) => { + const containerId = 'lexiflow-dynamic-floating-ui'; + + if (element && adapter && rect) { + console.log(`[Lexiflow] Detected active field via ${adapter.name}`, rect); + + // Clean up previous if exists + if (dynamicRoot) { + dynamicRoot.unmount(); + dynamicRoot = null; + } + document.getElementById(containerId)?.remove(); + + // Determine a position (just below the element) + const position = { + x: rect.left + window.scrollX, + y: rect.bottom + window.scrollY + 10 + }; + + dynamicRoot = ShadowDOMManager.createShadowRoot(containerId); + dynamicRoot.render( + + { + if (dynamicRoot) { + dynamicRoot.unmount(); + dynamicRoot = null; + } + document.getElementById(containerId)?.remove(); + }} + initialPosition={position} + /> + + ); + } else { + console.log(`[Lexiflow] Field focus lost, unmounting UI`); + if (dynamicRoot) { + dynamicRoot.unmount(); + dynamicRoot = null; + } + document.getElementById(containerId)?.remove(); + } +}); // Inform the background script that the content script is ready chrome.runtime.sendMessage({ action: "showFullPagePopup" }); diff --git a/src/content/ui/ShadowWrapper.tsx b/src/content/ui/ShadowWrapper.tsx new file mode 100644 index 0000000..9caa601 --- /dev/null +++ b/src/content/ui/ShadowWrapper.tsx @@ -0,0 +1,63 @@ +import { createRoot, Root } from 'react-dom/client'; + +export class ShadowDOMManager { + /** + * Creates a Shadow DOM container and returns a React Root mounted inside it. + * Clones any extension-injected styles into the Shadow Root for complete isolation. + */ + static createShadowRoot(id: string = 'lexiflow-shadow-host'): Root { + const container = document.createElement('div'); + container.id = id; + + // Ensure the host container doesn't interfere with page layout + container.style.position = 'absolute'; + container.style.top = '0'; + container.style.left = '0'; + container.style.width = '100%'; + container.style.zIndex = '2147483647'; + container.style.pointerEvents = 'none'; // Let clicks pass through empty areas + + document.body.appendChild(container); + + const shadowRoot = container.attachShadow({ mode: 'open' }); + + // Clone styles from document head to shadow root + // In dev, Vite uses