feat(translation-hub): open the Translation Hub with a keyboard shortcut (#2050)

The hub is only reachable from the popup icon and the options sidebar, so
people who use it as their free-form translation input have to dig for it
every time.

Adds a configurable hotkey (default Alt+Shift+H) bound in the host content
script alongside the existing translation shortcuts, which asks the
background to open the hub through the same `openPage` route the popup
button and sidebar link already use.

- New `translationHub.shortcut` config key, validated by the shared
  shortcut schema; migration v093 -> v094 backfills it and falls back to
  Alt+Shift+U when the primary key is already bound elsewhere.
- New row on the Shortcuts settings page, registered in the command
  palette's search items and translated into all 9 locales.
- The popup hub button's tooltip now shows the key hint, matching the
  translate button.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ananaBMaster
2026-08-07 15:40:53 -07:00
committed by GitHub
parent 19df7c2389
commit c9d98221bd
24 changed files with 1920 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
feat(translation-hub): open the Translation Hub with a keyboard shortcut (Alt+Shift+H)
@@ -0,0 +1,46 @@
import type { Hotkey } from "@tanstack/hotkeys"
import { HotkeyManager } from "@tanstack/hotkeys"
import { browser } from "#imports"
import { getLocalConfig } from "@/utils/config/storage"
import { TRANSLATION_HUB_PAGE_PATH } from "@/utils/constants/translation-hub"
import { sendMessage } from "@/utils/message"
import {
isPageTranslationShortcutEmpty,
isValidConfiguredPageTranslationShortcut,
} from "@/utils/page-translation-shortcut"
/**
* Opens the Translation Hub from any page. A content script cannot create a
* tab itself, so the background does it — the same `openPage` route the popup
* button and the options sidebar link already use.
*/
export async function bindTranslationHubShortcutKey() {
const config = await getLocalConfig()
if (!config || isPageTranslationShortcutEmpty(config.translationHub.shortcut)) {
return () => {}
}
const shortcut = config.translationHub.shortcut
if (!isValidConfiguredPageTranslationShortcut(shortcut)) {
return () => {}
}
const registration = HotkeyManager.getInstance().register(
shortcut as Hotkey,
() => {
void sendMessage("openPage", {
url: browser.runtime.getURL(TRANSLATION_HUB_PAGE_PATH),
active: true,
})
},
{
ignoreInputs: true,
preventDefault: true,
stopPropagation: true,
},
)
return () => {
registration.unregister()
}
}
+4
View File
@@ -7,6 +7,7 @@ import { logger } from "@/utils/logger"
import { onMessage, sendMessage } from "@/utils/message"
import { clearEffectiveSiteControlUrl } from "@/utils/site-control"
import { areSamePageTranslationOrigin } from "@/utils/url"
import { bindTranslationHubShortcutKey } from "./bind-translation-hub-shortcut"
import { setupUrlChangeListener } from "./listen"
import { mountHostToast } from "./mount-host-toast"
import { bindTranslationModeShortcutKey } from "./translation-control/bind-translation-mode-shortcut"
@@ -40,6 +41,8 @@ export async function bootstrapHostContent(
const cleanupTranslationModeShortcut = await bindTranslationModeShortcutKey()
const cleanupTranslationHubShortcut = await bindTranslationHubShortcutKey()
const detectAndReportPageLanguage = async (url: string) => {
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
void sendMessage("reportDetectedPageLanguage", { url, detectedCodeOrUnd })
@@ -127,6 +130,7 @@ export async function bootstrapHostContent(
cleanupPageTranslationTriggers()
cleanupTranslationShortcut()
cleanupTranslationModeShortcut()
cleanupTranslationHubShortcut()
cleanupTranslationStateListener()
cleanupFrameTranslationStateListener()
cleanupDetectedLanguageRefreshListener()
@@ -17,6 +17,7 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/base-ui/sidebar"
import { TRANSLATION_HUB_PAGE_PATH } from "@/utils/constants/translation-hub"
import { i18n } from "@/utils/i18n"
const OVERLAY_TOOLS_PATHS = ["/floating-button", "/selection-toolbar", "/context-menu"] as const
@@ -137,7 +138,7 @@ export function FeaturesNav() {
<SidebarMenuButton
render={
<a
href={browser.runtime.getURL("/translation-hub.html")}
href={browser.runtime.getURL(TRANSLATION_HUB_PAGE_PATH)}
target="_blank"
rel="noopener noreferrer"
/>
@@ -135,6 +135,13 @@ export const SEARCH_ITEMS: SearchItem[] = [
descriptionKey: "options.shortcuts.nodeTranslation.description",
pageKey: "options.shortcuts.title",
},
{
sectionId: "translation-hub-shortcut",
route: "/shortcuts",
titleKey: "options.shortcuts.translationHub.title",
descriptionKey: "options.shortcuts.translationHub.description",
pageKey: "options.shortcuts.title",
},
// API Providers page
{
@@ -33,6 +33,10 @@ vi.mock("../node-translation-hotkey", () => ({
NodeTranslationHotkey: () => <section data-section="node-translation-hotkey" />,
}))
vi.mock("../translation-hub-shortcut", () => ({
TranslationHubShortcut: () => <section data-section="translation-hub-shortcut" />,
}))
describe("shortcuts page", () => {
it("lists every shortcut, widest scope first", () => {
const { container } = render(<ShortcutsPage />)
@@ -47,6 +51,7 @@ describe("shortcuts page", () => {
"selection-translation-shortcut",
"subtitles-toggle-shortcut",
"node-translation-hotkey",
"translation-hub-shortcut",
])
})
})
@@ -4,11 +4,14 @@ import { NodeTranslationHotkey } from "./node-translation-hotkey"
import { PageTranslationShortcut } from "./page-translation-shortcut"
import { SelectionTranslationShortcut } from "./selection-translation-shortcut"
import { SubtitlesToggleShortcut } from "./subtitles-toggle-shortcut"
import { TranslationHubShortcut } from "./translation-hub-shortcut"
import { TranslationModeShortcut } from "./translation-mode-shortcut"
/**
* Every shortcut in one flat list. The page is short enough that sections would only add
* headings between four rows.
* headings between four rows. Reading shortcuts come first, narrowing scope as the list
* goes down; the Translation Hub row is last because it opens a page instead of acting on
* what is already on screen.
*/
export function ShortcutsPage() {
return (
@@ -22,6 +25,7 @@ export function ShortcutsPage() {
<SelectionTranslationShortcut />
<SubtitlesToggleShortcut />
<NodeTranslationHotkey />
<TranslationHubShortcut />
</PageLayout>
)
}
@@ -0,0 +1,20 @@
import { useAtom } from "jotai"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { i18n } from "@/utils/i18n"
import { ShortcutConfigItem } from "./shortcut-config-item"
export function TranslationHubShortcut() {
const [translationHub, setTranslationHub] = useAtom(configFieldsAtomMap.translationHub)
return (
<ShortcutConfigItem
id="translation-hub-shortcut"
title={i18n.t("options.shortcuts.translationHub.title")}
description={i18n.t("options.shortcuts.translationHub.description")}
shortcut={translationHub.shortcut}
onChange={(nextShortcut) => {
void setTranslationHub({ shortcut: nextShortcut })
}}
/>
)
}
@@ -1,16 +1,30 @@
import { Icon } from "@iconify/react"
import { useAtomValue } from "jotai"
import { browser } from "#imports"
import { Button } from "@/components/ui/base-ui/button"
import { Kbd, KbdGroup } from "@/components/ui/base-ui/kbd"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/base-ui/tooltip"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { TRANSLATION_HUB_PAGE_PATH } from "@/utils/constants/translation-hub"
import { i18n } from "@/utils/i18n"
import { formatHotkeyParts } from "@/utils/os"
import { isPageTranslationShortcutEmpty } from "@/utils/page-translation-shortcut"
export function TranslationHubButton() {
const translationHub = useAtomValue(configFieldsAtomMap.translationHub)
const handleClick = async () => {
await browser.tabs.create({
url: browser.runtime.getURL("/translation-hub.html"),
url: browser.runtime.getURL(TRANSLATION_HUB_PAGE_PATH),
})
}
// The hub is buried enough that people ask where it lives; the tooltip is the
// one place that can teach the shortcut at the moment they reach for it.
const shortcutParts = isPageTranslationShortcutEmpty(translationHub.shortcut)
? []
: formatHotkeyParts(translationHub.shortcut)
return (
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon" onClick={handleClick} />}>
@@ -18,6 +32,13 @@ export function TranslationHubButton() {
</TooltipTrigger>
<TooltipContent className="max-w-[200px] text-wrap">
{i18n.t("popup.hub.tooltip")}
{shortcutParts.length > 0 && (
<KbdGroup className="mt-1.5 flex">
{shortcutParts.map((part) => (
<Kbd key={part}>{part}</Kbd>
))}
</KbdGroup>
)}
</TooltipContent>
</Tooltip>
)
+3
View File
@@ -458,6 +458,9 @@ options:
nodeTranslation:
title: Paragraph translation
description: Hover or long-press a paragraph to translate just that one.
translationHub:
title: Translation Hub
description: Open the Translation Hub in a new tab.
overlayTools:
title: Overlay Tools
inputTranslation:
+3
View File
@@ -458,6 +458,9 @@ options:
nodeTranslation:
title: Traducción de párrafo
description: Pasa el cursor o mantén pulsado un párrafo para traducir solo ese.
translationHub:
title: Centro de traducción
description: Abre el Centro de traducción en una pestaña nueva.
overlayTools:
title: Herramientas superpuestas
inputTranslation:
+3
View File
@@ -457,6 +457,9 @@ options:
nodeTranslation:
title: 段落翻訳
description: 段落にホバーまたは長押しすると、その段落だけ翻訳します。
translationHub:
title: 翻訳ハブ
description: 翻訳ハブを新しいタブで開きます。
overlayTools:
title: オーバーレイツール
inputTranslation:
+3
View File
@@ -457,6 +457,9 @@ options:
nodeTranslation:
title: 단락 번역
description: 단락에 마우스를 올리거나 길게 누르면 그 단락만 번역합니다.
translationHub:
title: 번역 허브
description: 번역 허브를 새 탭에서 엽니다.
overlayTools:
title: 오버레이 도구
inputTranslation:
+3
View File
@@ -457,6 +457,9 @@ options:
nodeTranslation:
title: Перевод абзаца
description: Наведите курсор на абзац или нажмите и удерживайте его, чтобы перевести только его.
translationHub:
title: Центр переводов
description: Открывает Центр переводов в новой вкладке.
overlayTools:
title: Инструменты наложения
inputTranslation:
+3
View File
@@ -457,6 +457,9 @@ options:
nodeTranslation:
title: Paragraf çevirisi
description: Bir paragrafın üzerine gelin veya uzun basın; yalnızca o paragraf çevrilir.
translationHub:
title: Çeviri Merkezi
description: Çeviri Merkezini yeni bir sekmede açar.
overlayTools:
title: Kaplama Araçları
inputTranslation:
+3
View File
@@ -457,6 +457,9 @@ options:
nodeTranslation:
title: Dịch đoạn văn
description: Di chuột hoặc nhấn giữ một đoạn để chỉ dịch đoạn đó.
translationHub:
title: Trung tâm Dịch thuật
description: Mở Trung tâm Dịch thuật trong tab mới.
overlayTools:
title: Công cụ nổi
inputTranslation:
+3
View File
@@ -458,6 +458,9 @@ options:
nodeTranslation:
title: 段落翻译
description: 悬停或长按段落,只翻译这一段。
translationHub:
title: 翻译中心
description: 在新标签页中打开翻译中心。
overlayTools:
title: 悬浮工具
inputTranslation:
+3
View File
@@ -458,6 +458,9 @@ options:
nodeTranslation:
title: 段落翻譯
description: 滑鼠停留或長按段落,只翻譯這一段。
translationHub:
title: 翻譯中心
description: 在新分頁中開啟翻譯中心。
overlayTools:
title: 懸浮工具
inputTranslation:
+12
View File
@@ -6,6 +6,7 @@ import {
MIN_SELECTION_OVERLAY_OPACITY,
} from "@/utils/constants/selection"
import { MIN_SIDE_CONTENT_WIDTH } from "@/utils/constants/side"
import { DEFAULT_TRANSLATION_HUB_SHORTCUT_KEY } from "@/utils/constants/translation-hub"
import {
doesProviderSupportsCapability,
getProviderIdsForCapability,
@@ -75,6 +76,16 @@ const sideContentSchema = z.object({
width: z.number().min(MIN_SIDE_CONTENT_WIDTH),
})
// Translation Hub schema. `.default()` mirrors `uiLanguageSchema`: it lets a
// config stored before this field existed still parse in UI contexts that load
// ahead of the background migration, instead of falling back to DEFAULT_CONFIG
// and writing that over the user's settings.
const translationHubSchema = z
.object({
shortcut: pageTranslationShortcutSchema,
})
.default({ shortcut: DEFAULT_TRANSLATION_HUB_SHORTCUT_KEY })
// beta experience schema
const betaExperienceSchema = z.object({
enabled: z.boolean(),
@@ -141,6 +152,7 @@ export const configSchema = z
siteControl: siteControlSchema,
siteRules: siteRulesConfigSchema,
uiLanguage: uiLanguageSchema,
translationHub: translationHubSchema,
})
.superRefine((data, ctx) => {
for (const featureKey of FEATURE_KEYS) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest"
import { migrate } from "../../migration-scripts/v093-to-v094"
function configWith({
pageShortcut = "Alt+E",
modeShortcut = "Alt+Shift+M",
selectionShortcut = "Alt+T",
subtitlesShortcut = "Alt+C",
}: {
pageShortcut?: string
modeShortcut?: string
selectionShortcut?: string
subtitlesShortcut?: string
} = {}) {
return {
translate: {
modeShortcut,
page: { shortcut: pageShortcut },
},
selectionToolbar: {
features: {
translate: { shortcut: selectionShortcut },
},
},
videoSubtitles: { enabled: true, toggleShortcut: subtitlesShortcut },
}
}
describe("v093 to v094 migration", () => {
it("adds the primary hub shortcut when nothing else claims it", () => {
const result = migrate(configWith())
expect(result.translationHub).toEqual({ shortcut: "Alt+Shift+H" })
})
it("leaves the rest of the config untouched", () => {
const oldConfig = configWith()
const snapshot = structuredClone(oldConfig)
const result = migrate(oldConfig)
expect(result).toEqual({ ...snapshot, translationHub: { shortcut: "Alt+Shift+H" } })
expect(oldConfig).toEqual(snapshot)
})
it.each([
["page translation", { pageShortcut: "Alt+Shift+H" }],
["translation mode", { modeShortcut: "Alt+Shift+H" }],
["selection translation", { selectionShortcut: "Alt+Shift+H" }],
["subtitles toggle", { subtitlesShortcut: "Alt+Shift+H" }],
])("falls back when %s already uses the primary key", (_label, overrides) => {
const result = migrate(configWith(overrides))
expect(result.translationHub.shortcut).toBe("Alt+Shift+U")
})
it("ignores case and surrounding whitespace when detecting a collision", () => {
const result = migrate(configWith({ modeShortcut: " alt+shift+h " }))
expect(result.translationHub.shortcut).toBe("Alt+Shift+U")
})
it("leaves the shortcut unbound when both candidates are taken", () => {
const result = migrate(configWith({ pageShortcut: "Alt+Shift+H", modeShortcut: "Alt+Shift+U" }))
expect(result.translationHub.shortcut).toBe("")
})
it("is idempotent", () => {
const once = migrate(configWith({ pageShortcut: "Alt+Shift+H" }))
const twice = migrate(once)
expect(twice).toEqual(once)
})
it("does not overwrite an existing translationHub config", () => {
const oldConfig = { ...configWith(), translationHub: { shortcut: "" } }
expect(migrate(oldConfig)).toBe(oldConfig)
})
it.each([
["null", null],
["a non-object", "config"],
["an array", []],
])("returns %s configs unchanged", (_label, oldConfig) => {
expect(migrate(oldConfig)).toBe(oldConfig)
})
it("still adds the shortcut when the sibling shortcut fields are missing", () => {
const result = migrate({ language: { targetCode: "cmn" } })
expect(result.translationHub).toEqual({ shortcut: "Alt+Shift+H" })
})
})
@@ -0,0 +1,66 @@
/**
* Migration script from v093 to v094
* - Adds the `translationHub` config with a `shortcut` that opens the
* Translation Hub page, defaulting to "Alt+Shift+H".
* - Falls back to "Alt+Shift+U" when the user already bound "Alt+Shift+H" to
* another shortcut, and leaves it unbound when both candidates are taken.
* The fallback key deliberately exists nowhere else in the codebase: it only
* ever applies to configs that predate this field.
*
* IMPORTANT: All values are hardcoded inline. Migration scripts are frozen
* snapshots - never import constants or helpers that may change.
*/
const PRIMARY_SHORTCUT = "Alt+Shift+H"
const FALLBACK_SHORTCUT = "Alt+Shift+U"
/**
* Shortcuts are stored already normalized (fixed modifier order, canonical key
* name), so comparing the lowercased strings is enough to spot a collision.
*/
function collectBoundShortcuts(oldConfig: any): Set<string> {
const bound = new Set<string>()
const candidates = [
oldConfig?.translate?.page?.shortcut,
oldConfig?.translate?.modeShortcut,
oldConfig?.selectionToolbar?.features?.translate?.shortcut,
oldConfig?.videoSubtitles?.toggleShortcut,
]
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue
}
const trimmed = candidate.trim()
if (trimmed) {
bound.add(trimmed.toLowerCase())
}
}
return bound
}
export function migrate(oldConfig: any): any {
if (!oldConfig || typeof oldConfig !== "object" || Array.isArray(oldConfig)) {
return oldConfig
}
if ("translationHub" in oldConfig) {
return oldConfig
}
const boundShortcuts = collectBoundShortcuts(oldConfig)
const shortcut = !boundShortcuts.has(PRIMARY_SHORTCUT.toLowerCase())
? PRIMARY_SHORTCUT
: !boundShortcuts.has(FALLBACK_SHORTCUT.toLowerCase())
? FALLBACK_SHORTCUT
: ""
return {
...oldConfig,
translationHub: {
shortcut,
},
}
}
+5 -1
View File
@@ -39,6 +39,7 @@ import {
DEFAULT_SELECTION_TRANSLATION_SHORTCUT_KEY,
DEFAULT_TRANSLATION_MODE_SHORTCUT_KEY,
} from "./translate"
import { DEFAULT_TRANSLATION_HUB_SHORTCUT_KEY } from "./translation-hub"
import { TRANSLATION_NODE_STYLE_ON_INSTALLED } from "./translation-node-style"
import { DEFAULT_TTS_CONFIG } from "./tts"
@@ -48,7 +49,7 @@ export const GOOGLE_DRIVE_TOKEN_STORAGE_KEY = "__googleDriveToken"
export const THEME_STORAGE_KEY = "theme"
export const DEFAULT_DETECTED_CODE = "eng" as const
export const CONFIG_SCHEMA_VERSION = 93
export const CONFIG_SCHEMA_VERSION = 94
export const DEFAULT_FLOATING_BUTTON_POSITION = 0.66
export const DEFAULT_FLOATING_BUTTON_SIDE: FloatingButtonSide = "right"
@@ -223,6 +224,9 @@ export const DEFAULT_CONFIG: Config = {
disabledBuiltInRules: [],
},
uiLanguage: "auto",
translationHub: {
shortcut: DEFAULT_TRANSLATION_HUB_SHORTCUT_KEY,
},
}
/**
+8
View File
@@ -0,0 +1,8 @@
// The Translation Hub lives at its own extension page. Every entry point
// (popup button, options sidebar, keyboard shortcut) opens this path.
export const TRANSLATION_HUB_PAGE_PATH = "/translation-hub.html"
// Three keys on purpose: the hub is opened far less often than the reading
// shortcuts, so it takes the roomier `Alt+Shift+` prefix rather than a bare
// `Alt+<letter>` that users would rather keep for something else.
export const DEFAULT_TRANSLATION_HUB_SHORTCUT_KEY = "Alt+Shift+H"