Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
9 changes: 9 additions & 0 deletions .changeset/context-menu-translate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@read-frog/extension": minor
---

feat: add context menu translate option

Add right-click context menu option for translating pages directly from the browser context menu.

**Important**: This feature requires a new `contextMenus` permission. When upgrading, your browser may prompt you to approve this new permission and temporarily disable the extension until approved. This is normal browser behavior for permission changes.
211 changes: 211 additions & 0 deletions src/entrypoints/background/context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import type { Browser } from '#imports'
import type { Config } from '@/types/config/config'
import { browser, i18n, storage } from '#imports'
import { isAPIProviderConfig } from '@/types/config/provider'
import { getProviderConfigById } from '@/utils/config/helpers'
import { CONFIG_STORAGE_KEY } from '@/utils/constants/config'
import { getTranslationStateKey, TRANSLATION_STATE_KEY_PREFIX } from '@/utils/constants/storage-keys'
import { logger } from '@/utils/logger'
import { sendMessage } from '@/utils/message'

const MENU_ID_TRANSLATE = 'read-frog-translate'

let currentConfig: Config | null = null

/**
* Initialize context menu based on config
*/
export async function setupContextMenu() {
const config = await storage.getItem<Config>(`local:${CONFIG_STORAGE_KEY}`)
if (!config) {
return
}

currentConfig = config
await updateContextMenuItems(config)

// Listen for config changes
storage.watch<Config>(`local:${CONFIG_STORAGE_KEY}`, async (newConfig) => {
if (newConfig) {
currentConfig = newConfig
await updateContextMenuItems(newConfig)
}
})

// Listen for tab activation to update menu title
browser.tabs.onActivated.addListener(async (activeInfo) => {
await updateTranslateMenuTitle(activeInfo.tabId)
})

// Listen for tab updates (e.g., navigation)
browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
await updateTranslateMenuTitle(tabId)
}
})

// Listen for translation state changes in storage
// This ensures menu updates when translation is toggled from any UI
// (floating button, auto-translate, etc.) without interfering with
// the translation logic in translation-signal.ts
browser.storage.session.onChanged.addListener(async (changes) => {
for (const [key, change] of Object.entries(changes)) {
// Check if this is a translation state change
if (key.startsWith(TRANSLATION_STATE_KEY_PREFIX.replace('session:', ''))) {
// Extract tabId from key (format: "translationState.{tabId}")
const parts = key.split('.')
const tabId = Number.parseInt(parts[1])

if (!Number.isNaN(tabId)) {
// Only update menu if this is the active tab
const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true })
if (activeTab?.id === tabId) {
const newValue = change.newValue as { enabled: boolean } | undefined
await updateTranslateMenuTitle(tabId, newValue?.enabled)
}
}
}
}
})

// Handle menu item clicks
browser.contextMenus.onClicked.addListener(handleContextMenuClick)
}

/**
* Update context menu items based on config
*/
async function updateContextMenuItems(config: Config) {
// Remove all existing menu items first
await browser.contextMenus.removeAll()

const { translateEnabled } = config.contextMenu

if (translateEnabled) {
browser.contextMenus.create({
id: MENU_ID_TRANSLATE,
title: i18n.t('contextMenu.translate'),
contexts: ['page'],
})
}

// Update translate menu title for current tab
const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true })
if (activeTab?.id) {
await updateTranslateMenuTitle(activeTab.id)
}
}

/**
* Update translate menu title based on current translation state
* @param tabId - The tab ID to check translation state for
* @param enabled - Optional: if provided, use this value instead of reading from storage
*/
async function updateTranslateMenuTitle(tabId: number, enabled?: boolean) {
if (!currentConfig?.contextMenu.translateEnabled) {
return
}

try {
let isTranslated: boolean
if (enabled !== undefined) {
isTranslated = enabled
}
else {
const state = await storage.getItem<{ enabled: boolean }>(
getTranslationStateKey(tabId),
)
isTranslated = state?.enabled ?? false
}

await browser.contextMenus.update(MENU_ID_TRANSLATE, {
title: isTranslated
? i18n.t('contextMenu.showOriginal')
: i18n.t('contextMenu.translate'),
})
}
catch {
// Menu item might not exist if translateEnabled is false
}
}

/**
* Handle context menu item click
*/
async function handleContextMenuClick(
info: Browser.contextMenus.OnClickData,
tab?: Browser.tabs.Tab,
) {
if (!tab?.id) {
return
}

if (info.menuItemId === MENU_ID_TRANSLATE) {
await handleTranslateClick(tab.id)
}
}

/**
* Validate translation configuration in background context
* This is a simplified version of validateTranslationConfig without toast notifications
*/
function validateTranslationConfigInBackground(config: Config): boolean {
const { providersConfig, translate: translateConfig, language: languageConfig } = config
const providerConfig = getProviderConfigById(providersConfig, translateConfig.providerId)

// Check if provider exists
if (!providerConfig) {
logger.warn('[ContextMenu] Translation provider not found')
return false
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-mode same-language detection now blocks translation in the context menu, unlike the existing validation which only warns, so right-click translations get incorrectly rejected whenever the detected language equals the target language.

Prompt for AI agents
Address the following comment on src/entrypoints/background/context-menu.ts at line 159:

<comment>Auto-mode same-language detection now blocks translation in the context menu, unlike the existing validation which only warns, so right-click translations get incorrectly rejected whenever the detected language equals the target language.</comment>

<file context>
@@ -142,6 +145,41 @@ async function handleContextMenuClick(
+  // Check if provider exists
+  if (!providerConfig) {
+    logger.warn(&#39;[ContextMenu] Translation provider not found&#39;)
+    return false
+  }
+
</file context>

✅ Addressed in bca207f

}

// Check if source and target languages are the same
if (languageConfig.sourceCode === languageConfig.targetCode) {
logger.warn('[ContextMenu] Source and target languages are the same')
return false
}

// Check if detected language and target language are the same in auto mode
if (languageConfig.sourceCode === 'auto' && languageConfig.detectedCode === languageConfig.targetCode) {
logger.warn('[ContextMenu] Detected language matches target language in auto mode')
return false
}

// Check if API key is configured for providers that require it
if (isAPIProviderConfig(providerConfig) && !providerConfig.apiKey?.trim() && !['deeplx', 'ollama'].includes(providerConfig.provider)) {
logger.warn('[ContextMenu] API key not configured for provider')
return false
}

return true
}

/**
* Handle translate menu click - toggle page translation
*/
async function handleTranslateClick(tabId: number) {
const state = await storage.getItem<{ enabled: boolean }>(
getTranslationStateKey(tabId),
)
const isCurrentlyTranslated = state?.enabled ?? false
const newState = !isCurrentlyTranslated

// If enabling translation, validate configuration first
if (newState && currentConfig) {
if (!validateTranslationConfigInBackground(currentConfig)) {
logger.error('[ContextMenu] Translation config validation failed')
// Send a message to content script to show error notification
void sendMessage('showTranslationConfigError', undefined, tabId)
return
}
}

// Update storage directly (instead of sending message to self)
await storage.setItem(getTranslationStateKey(tabId), { enabled: newState })

// Notify content script in that specific tab
void sendMessage('translationStateChanged', { enabled: newState }, tabId)

// Update menu title immediately
await updateTranslateMenuTitle(tabId)
}
7 changes: 6 additions & 1 deletion src/entrypoints/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { onMessage, sendMessage } from '@/utils/message'
import { SessionCacheGroupRegistry } from '@/utils/session-cache/session-cache-group-registry'
import { ensureInitializedConfig } from './config'
import { setUpConfigBackup } from './config-backup'
import { setupContextMenu } from './context-menu'
import { cleanupAllCache, setUpDatabaseCleanup } from './db-cleanup'
import { handleAnalyzeSelectionPort, handleTranslateStreamPort, runAnalyzeSelectionStream } from './firefox-stream'
import { initMockData } from './mock-data'
Expand All @@ -16,9 +17,12 @@ import { setupUninstallSurvey } from './uninstall-survey'

export default defineBackground({
type: 'module',
main: () => {
main: async () => {
logger.info('Hello background!', { id: browser.runtime.id })

// Ensure config is initialized before setting up features that depend on it
await ensureInitializedConfig()

browser.runtime.onInstalled.addListener(async (details) => {
await ensureInitializedConfig()

Expand Down Expand Up @@ -78,6 +82,7 @@ export default defineBackground({

newUserGuide()
translationMessage()
void setupContextMenu()
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupContextMenu() is invoked before the config is initialized, so on fresh installs it returns early and never creates the context menu. Await the config initialization (e.g., call ensureInitializedConfig() first) before running setupContextMenu() to guarantee the menu is registered.

Prompt for AI agents
Address the following comment on src/entrypoints/background/index.ts at line 82:

<comment>`setupContextMenu()` is invoked before the config is initialized, so on fresh installs it returns early and never creates the context menu. Await the config initialization (e.g., call `ensureInitializedConfig()` first) before running `setupContextMenu()` to guarantee the menu is registered.</comment>

<file context>
@@ -78,6 +79,7 @@ export default defineBackground({
 
     newUserGuide()
     translationMessage()
+    void setupContextMenu()
 
     void setUpRequestQueue()
</file context>
Suggested change
void setupContextMenu()
void ensureInitializedConfig().then(() => setupContextMenu())

✅ Addressed in b224925


void setUpRequestQueue()
void setUpDatabaseCleanup()
Expand Down
15 changes: 15 additions & 0 deletions src/entrypoints/host.content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ export default defineContentScript({
enabled ? void manager.start() : manager.stop()
})

// Listen for translation config error from context menu
onMessage('showTranslationConfigError', async () => {
const config = await getConfigFromStorage()
if (!config)
return

// Dynamically import validateTranslationConfig to show proper error messages with toast
const { validateTranslationConfig } = await import('@/utils/host/translate/translate-text')
validateTranslationConfig({
providersConfig: config.providersConfig,
translate: config.translate,
language: config.language,
})
})

const config = await getConfigFromStorage()
if (config) {
const { detectedCode } = getDocumentInfo()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { i18n } from '#imports'
import { useAtom } from 'jotai'
import { Switch } from '@/components/shadcn/switch'
import { configFieldsAtomMap } from '@/utils/atoms/config'
import { ConfigCard } from '../../components/config-card'

export function ContextMenuTranslateToggle() {
const [contextMenu, setContextMenu] = useAtom(
configFieldsAtomMap.contextMenu,
)

return (
<ConfigCard
title={i18n.t('options.floatingButtonAndToolbar.contextMenu.translate.title')}
description={i18n.t('options.floatingButtonAndToolbar.contextMenu.translate.description')}
>
<div className="w-full flex justify-end">
<Switch
checked={contextMenu.translateEnabled}
onCheckedChange={(checked) => {
void setContextMenu({ ...contextMenu, translateEnabled: checked })
}}
/>
</div>
</ConfigCard>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import floatingButtonDemoImage from '@/assets/demo/floating-button.png'
import selectionToolbarDemoImage from '@/assets/demo/selection-toolbar.png'
import { GradientBackground } from '@/components/gradient-background'
import { PageLayout } from '../../components/page-layout'
import { ContextMenuTranslateToggle } from './context-menu-translate-toggle'
import { FloatingButtonDisabledSites } from './floating-button-disabled-sites'
import { FloatingButtonGlobalToggle } from './floating-button-global-toggle'
import { SelectionToolbarDisabledSites } from './selection-toolbar-disabled-sites'
Expand All @@ -29,6 +30,10 @@ export function FloatingButtonAndToolbarPage() {
</GradientBackground>
<SelectionToolbarGlobalToggle />
<SelectionToolbarDisabledSites />
<h2 className="text-lg font-semibold mt-6 mb-2">
{i18n.t('options.floatingButtonAndToolbar.contextMenu.title')}
</h2>
<ContextMenuTranslateToggle />
</PageLayout>
)
}
8 changes: 8 additions & 0 deletions src/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: Read Frog
extName: Read Frog - Open Source Immersive Translate
extDescription: Read Frog is an open source browser extension designed to help you learn languages deeply from any website.
uninstallSurveyUrl: https://tally.so/r/nPK6Ob
contextMenu:
translate: Translate
showOriginal: Show Original
popup:
autoLang: Auto
sourceLang: Source
Expand Down Expand Up @@ -176,6 +179,11 @@ options:
description: Configure websites where the selection toolbar should be disabled
enterUrlPattern: Enter URL pattern
urlPattern: URL Pattern
contextMenu:
title: Context Menu
translate:
title: Enable Context Menu Translate
description: Add a translate option to the browser right-click menu for quick page translation
translation:
title: Translation
requestQueueConfig:
Expand Down
8 changes: 8 additions & 0 deletions src/locales/ja.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: 読書カエル
extName: 読書カエル - オープンソースインビジブル翻訳
extDescription: 読書カエルは、あらゆるウェブサイトから言語を深く学ぶために設計されたオープンソースのブラウザ拡張機能です。
contextMenu:
translate: 翻訳
showOriginal: 原文を表示
popup:
autoLang: 自動検出
sourceLang: ソース言語
Expand Down Expand Up @@ -174,6 +177,11 @@ options:
description: 選択ツールバーを無効化するウェブサイトを設定します
enterUrlPattern: URL パターンを入力
urlPattern: URL パターン
contextMenu:
title: コンテキストメニュー
translate:
title: 右クリック翻訳を有効化
description: ブラウザの右クリックメニューに翻訳オプションを追加し、ページを素早く翻訳
translation:
title: 翻訳
requestQueueConfig:
Expand Down
8 changes: 8 additions & 0 deletions src/locales/ko.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: Read Frog
extName: Read Frog - 오픈 소스 몰입형 번역
extDescription: Read Frog는 모든 웹사이트에서 언어를 깊이 있게 학습할 수 있도록 도와주는 오픈 소스 브라우저 확장 프로그램입니다.
contextMenu:
translate: 번역
showOriginal: 원문 보기
popup:
autoLang: 자동
sourceLang: 원본
Expand Down Expand Up @@ -175,6 +178,11 @@ options:
description: 선택 툴바를 비활성화할 웹사이트를 설정합니다
enterUrlPattern: URL 패턴 입력
urlPattern: URL 패턴
contextMenu:
title: 컨텍스트 메뉴
translate:
title: 우클릭 번역 활성화
description: 브라우저 우클릭 메뉴에 번역 옵션을 추가하여 빠른 페이지 번역
translation:
title: 번역
requestQueueConfig:
Expand Down
Loading