Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 51 additions & 149 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
const DEFAULT_PROVIDER = 'gemini';

async function getApiKey(): Promise<string> {
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<any> {
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<string> {
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);
Expand All @@ -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") {
Expand Down Expand Up @@ -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() {
Expand Down
70 changes: 60 additions & 10 deletions src/content/main.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// 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";
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") {
Expand Down Expand Up @@ -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(<FullPageTranslationPopup />);
const root = ShadowDOMManager.createShadowRoot('lexiflow-full-page-popup-container');
root.render(<FullPageTranslationPopup />);
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(
<LexiFlowSettingsProvider>
<App />
</LexiFlowSettingsProvider>
)
);

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(
<LexiFlowSettingsProvider>
<Popup
selectedText={adapter.getText(element)}
onClose={() => {
if (dynamicRoot) {
dynamicRoot.unmount();
dynamicRoot = null;
}
document.getElementById(containerId)?.remove();
}}
initialPosition={position}
/>
</LexiFlowSettingsProvider>
);
} 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" });
63 changes: 63 additions & 0 deletions src/content/ui/ShadowWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 <style type="text/css" data-vite-dev-id="...">
// In prod, crxjs injects <link rel="stylesheet" href="chrome-extension://...">
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
styles.forEach(node => {
if (node.tagName.toLowerCase() === 'link') {
const href = (node as HTMLLinkElement).href;
if (href && href.startsWith('chrome-extension://' + chrome.runtime.id)) {
shadowRoot.appendChild(node.cloneNode(true));
}
} else if (node.tagName.toLowerCase() === 'style') {
// Clone styles that belong to the extension (Tailwind, custom CSS)
// This is safe because CSS scoping inside shadow DOM won't leak out to the host page
const text = node.textContent || '';
if (node.hasAttribute('data-vite-dev-id') || text.includes('lexiflow') || text.includes('tailwind') || text.includes('--tw-')) {
shadowRoot.appendChild(node.cloneNode(true));
}
}
});

const mountPoint = document.createElement('div');
mountPoint.id = 'lexiflow-mount-point';
// Re-enable pointer events for our UI. Because the React children are position: absolute,
// the mount point itself will be 0x0 size and won't block the screen.
mountPoint.style.pointerEvents = 'auto';

// Stop event propagation so host page doesn't receive our UI interactions and trigger weird behaviors
const stopProp = (e: Event) => e.stopPropagation();
mountPoint.addEventListener('keydown', stopProp);
mountPoint.addEventListener('keyup', stopProp);
mountPoint.addEventListener('keypress', stopProp);
mountPoint.addEventListener('mousedown', stopProp);
mountPoint.addEventListener('mouseup', stopProp);
mountPoint.addEventListener('click', stopProp);

shadowRoot.appendChild(mountPoint);

return createRoot(mountPoint);
}
}
Loading