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

feat(translate): add custom shortcut combo support for hover paragraph translation, allowing users to set arbitrary modifier+key combos like Alt+T instead of single modifier keys
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import type { Config } from "@/types/config/config"
import { afterEach, describe, expect, it, vi } from "vitest"
import { registerNodeTranslationTriggerListeners } from "../node-translation-trigger"

function createConfig(hotkey: Config["translate"]["node"]["hotkey"]): Config {
function createConfig(
hotkey: Config["translate"]["node"]["hotkey"],
customShortcut = "",
): Config {
return {
translate: {
node: {
enabled: true,
hotkey,
customShortcut,
},
},
} as Config
Expand Down Expand Up @@ -259,4 +263,130 @@ describe("registerNodeTranslationTriggerListeners", () => {

expect(onTrigger).not.toHaveBeenCalled()
})

it("triggers custom combo (Alt+T) at the latest mouseover position", async () => {
const onTrigger = vi.fn()

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "Alt+T")),
onTrigger,
})

dispatchMouseEvent("mouseover", { clientX: 70, clientY: 80 })
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: true, bubbles: true }))

await vi.waitFor(() => {
expect(onTrigger).toHaveBeenCalledWith(
{ x: 70, y: 80 },
expect.objectContaining({
translate: expect.objectContaining({
node: expect.objectContaining({ hotkey: "custom", customShortcut: "Alt+T" }),
}),
}),
)
})
})

it("does not trigger custom combo twice on auto-repeat", async () => {
const onTrigger = vi.fn()

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "Alt+T")),
onTrigger,
})

dispatchMouseEvent("mouseover", { clientX: 70, clientY: 80 })
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: true, bubbles: true }))
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: true, bubbles: true, repeat: true }))

await vi.waitFor(() => {
expect(onTrigger).toHaveBeenCalledTimes(1)
})
})

it("does not trigger custom combo when modifier is missing", async () => {
const onTrigger = vi.fn()

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "Alt+T")),
onTrigger,
})

dispatchMouseEvent("mouseover", { clientX: 70, clientY: 80 })
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: false, bubbles: true }))

await Promise.resolve()
expect(onTrigger).not.toHaveBeenCalled()
})

it("triggers custom combo with multiple modifiers (Ctrl+Alt+T)", async () => {
const onTrigger = vi.fn()

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "Ctrl+Alt+T")),
onTrigger,
})

dispatchMouseEvent("mouseover", { clientX: 100, clientY: 200 })
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", ctrlKey: true, altKey: true, bubbles: true }))

await vi.waitFor(() => {
expect(onTrigger).toHaveBeenCalledWith(
{ x: 100, y: 200 },
expect.objectContaining({
translate: expect.objectContaining({
node: expect.objectContaining({ hotkey: "custom", customShortcut: "Ctrl+Alt+T" }),
}),
}),
)
})
})

it("does not trigger custom combo on editable targets", async () => {
const onTrigger = vi.fn()
const input = document.createElement("input")
document.body.append(input)

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "Alt+T")),
onTrigger,
})

dispatchMouseEvent("mouseover", { clientX: 70, clientY: 80 })
input.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: true, bubbles: true }))

await Promise.resolve()
expect(onTrigger).not.toHaveBeenCalled()
})

it("does not trigger custom combo when shouldIgnoreEvent returns true", async () => {
const onTrigger = vi.fn()

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "Alt+T")),
onTrigger,
shouldIgnoreEvent: () => true,
})

dispatchMouseEvent("mouseover", { clientX: 70, clientY: 80 })
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: true, bubbles: true }))

await Promise.resolve()
expect(onTrigger).not.toHaveBeenCalled()
})

it("does not trigger custom combo when customShortcut is empty", async () => {
const onTrigger = vi.fn()

teardown = registerNodeTranslationTriggerListeners({
getConfig: () => Promise.resolve(createConfig("custom", "")),
onTrigger,
})

dispatchMouseEvent("mouseover", { clientX: 70, clientY: 80 })
document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", altKey: true, bubbles: true }))

await Promise.resolve()
expect(onTrigger).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Config } from "@/types/config/config"
import type { Point } from "@/types/dom"
import { HOTKEY_EVENT_KEYS } from "@/utils/constants/hotkeys"
import { matchesNodeCustomShortcut } from "@/utils/node-translation-shortcut"

const NODE_TRANSLATION_HOLD_TRIGGER_MS = 500
const CLICK_AND_HOLD_MOVE_TOLERANCE = 6
Expand Down Expand Up @@ -248,6 +249,13 @@ export function registerNodeTranslationTriggerListeners({
return
}

if (config.translate.node.hotkey === "custom") {
if (!event.repeat && matchesNodeCustomShortcut(event, config.translate.node.customShortcut)) {
triggerNodeTranslation(resolveTriggerPoint(), config)
Comment on lines +292 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Consume matched custom shortcut events

When a user configures a custom node shortcut that is also handled by the page/browser or another Read Frog hotkey, such as Ctrl+F or the selection-toolbar default Alt+T, this branch triggers node translation but then returns without consuming the keydown. The default action and other listeners can still run, so one key press can both translate the hovered node and open/run the other shortcut; consume the event after a successful match or route this through the shared hotkey manager.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed the event-consumption part by preventing matched custom shortcut events from continuing to page/browser handlers.

I kept the broader hotkey conflict behavior out of scope here. Existing page translation and selection toolbar shortcuts can already conflict with each other in the same way, so resolving cross-feature shortcut ownership should be handled in a separate PR, likely through the shared hotkey manager.

}
return
}

const hotkeyEventKey = HOTKEY_EVENT_KEYS[config.translate.node.hotkey]

if (event.key === hotkeyEventKey) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { deepmerge } from "deepmerge-ts"
import { useAtom } from "jotai"
import { i18n } from "#imports"
import { ShortcutKeyRecorder } from "@/components/shortcut-key-recorder"
import { Field, FieldContent, FieldLabel } from "@/components/ui/base-ui/field"
import {
Select,
Expand Down Expand Up @@ -73,6 +74,18 @@ export function NodeTranslationHotkey() {
</SelectGroup>
</SelectContent>
</Select>
{translateConfig.node.hotkey === "custom" && (
<div className="mt-4">
<ShortcutKeyRecorder
shortcutKey={translateConfig.node.customShortcut ?? ""}
onChange={(shortcut) => {
void setTranslateConfig(
deepmerge(translateConfig, { node: { customShortcut: shortcut } }),
)
}}
/>
</div>
)}
</div>
</ConfigCard>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ import {
import { Switch } from "@/components/ui/base-ui/switch"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { HOTKEY_ICONS, HOTKEYS } from "@/utils/constants/hotkeys"
import { formatPageTranslationShortcut } from "@/utils/page-translation-shortcut"

function HotkeyDisplay({ hotkey }: { hotkey: typeof HOTKEYS[number] }) {
function HotkeyDisplay({
hotkey,
customShortcut,
}: {
hotkey: typeof HOTKEYS[number]
customShortcut: string
}) {
const icon = HOTKEY_ICONS[hotkey]
const label = i18n.t(`hotkey.${hotkey}`)

Expand All @@ -28,6 +35,23 @@ function HotkeyDisplay({ hotkey }: { hotkey: typeof HOTKEYS[number] }) {
)
}

if (hotkey === "custom") {
const comboText = customShortcut
? formatPageTranslationShortcut(customShortcut)
: `<${i18n.t("popup.customShortcutEmpty")}>`
return (
<>
{i18n.t("popup.hover")}
{" "}
+
{" "}
{comboText}
{" "}
{i18n.t("popup.translateParagraph")}
</>
)
}

return (
<>
{i18n.t("popup.hover")}
Expand Down Expand Up @@ -66,14 +90,14 @@ export default function NodeTranslationHotkeySelector() {
className="pt-3.5 -mt-3.5 pb-4 -mb-4 px-2 -ml-2 h-5! ring-none cursor-pointer truncate border-none text-[13px] font-medium shadow-none focus-visible:border-none focus-visible:ring-0 bg-transparent! rounded-md"
>
<div className="truncate">
<HotkeyDisplay hotkey={translateConfig.node.hotkey} />
<HotkeyDisplay hotkey={translateConfig.node.hotkey} customShortcut={translateConfig.node.customShortcut} />
</div>
</SelectTrigger>
<SelectContent className="min-w-fit">
<SelectGroup>
{HOTKEYS.map(item => (
<SelectItem key={item} value={item}>
<HotkeyDisplay hotkey={item} />
<HotkeyDisplay hotkey={item} customShortcut={translateConfig.node.customShortcut} />
</SelectItem>
))}
</SelectGroup>
Expand Down
4 changes: 3 additions & 1 deletion src/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ popup:
options: Options
hover: Hover
translateParagraph: to translate paragraph
customShortcutEmpty: set in Options
translationModeToggle:
tooltip:
bilingual:
Expand Down Expand Up @@ -723,7 +724,7 @@ options:
error: Value must be between $1 and $2
nodeTranslationHotkey:
title: Hover or Long-Press Translation Shortcut
description: Customize the hover modifier key or use long press to translate paragraphs
description: Customize the hover modifier key, use long press, or set a custom shortcut combo to translate paragraphs
enable: Enable
llmProviderConfigured: LLM provider selected for $1
llmProviderNotConfigured: No LLM provider selected for $1
Expand Down Expand Up @@ -1094,6 +1095,7 @@ hotkey:
shift: Shift
backtick: Backtick
clickAndHold: Click and hold
custom: Custom
translationHub:
searchLanguages: Search languages...
noLanguagesFound: No languages found
Expand Down
4 changes: 3 additions & 1 deletion src/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ popup:
options: Opciones
hover: Pasar el cursor
translateParagraph: para traducir el párrafo
customShortcutEmpty: configurar en Opciones
translationModeToggle:
tooltip:
bilingual:
Expand Down Expand Up @@ -723,7 +724,7 @@ options:
error: El valor debe estar entre $1 y $2
nodeTranslationHotkey:
title: Atajo de traducción al pasar el cursor o mantener pulsado
description: Personaliza la tecla modificadora al pasar el cursor o usa pulsación larga para traducir párrafos
description: Personaliza la tecla modificadora al pasar el cursor, usa pulsación larga, o establece una combinación de teclas personalizada para traducir párrafos
enable: Activar
llmProviderConfigured: Proveedor LLM seleccionado para $1
llmProviderNotConfigured: No se seleccionó ningún proveedor LLM para $1
Expand Down Expand Up @@ -1094,6 +1095,7 @@ hotkey:
shift: Mayús
backtick: Acento grave
clickAndHold: Hacer clic y mantener
custom: Personalizado
translationHub:
searchLanguages: Buscar idiomas...
noLanguagesFound: No se encontraron idiomas
Expand Down
4 changes: 3 additions & 1 deletion src/locales/ja.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ popup:
options: オプション
hover: ホバー
translateParagraph: 段落を翻訳
customShortcutEmpty: オプションで設定
translationModeToggle:
tooltip:
bilingual:
Expand Down Expand Up @@ -608,7 +609,7 @@ options:
error: 値は $1 から $2 の間である必要があります
nodeTranslationHotkey:
title: ホバーまたは長押し翻訳ショートカットキー
description: ホバー翻訳の修飾キーをカスタマイズするか、長押しで段落を翻訳します
description: ホバー翻訳の修飾キーをカスタマイズするか、長押し、またはカスタムショートカットで段落を翻訳します
enable: 有効化
llmProviderConfigured: $1の LLM プロバイダー選択済み
llmProviderNotConfigured: $1の LLM プロバイダー未選択
Expand Down Expand Up @@ -979,6 +980,7 @@ hotkey:
shift: Shift
backtick: バッククォート
clickAndHold: クリックして長押し
custom: カスタム
translationHub:
searchLanguages: 言語を検索...
noLanguagesFound: 言語が見つかりません
Expand Down
4 changes: 3 additions & 1 deletion src/locales/ko.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ popup:
options: 옵션
hover: 마우스 오버
translateParagraph: 단락 번역
customShortcutEmpty: 옵션에서 설정
translationModeToggle:
tooltip:
bilingual:
Expand Down Expand Up @@ -608,7 +609,7 @@ options:
error: 값은 $1에서 $2 사이여야 합니다
nodeTranslationHotkey:
title: 호버 또는 길게 누르기 번역 단축키
description: 호버 번역 수정 키를 사용자 지정하거나 마우스 길게 누르기로 단락을 번역합니다
description: 호버 번역 수정 키를 사용자 지정하거나, 마우스 길게 누르기, 또는 사용자 지정 단축키 조합으로 단락을 번역합니다
enable: 활성화
llmProviderConfigured: $1에 LLM 제공업체 선택됨
llmProviderNotConfigured: $1에 LLM 제공업체 미선택
Expand Down Expand Up @@ -979,6 +980,7 @@ hotkey:
shift: Shift
backtick: 백틱
clickAndHold: 클릭하고 길게 누르기
custom: 사용자 지정
translationHub:
searchLanguages: 언어 검색...
noLanguagesFound: 언어를 찾을 수 없습니다
Expand Down
4 changes: 3 additions & 1 deletion src/locales/ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ popup:
options: Настройки
hover: Наведение
translateParagraph: для перевода абзаца
customShortcutEmpty: настройте в Параметрах
translationModeToggle:
tooltip:
bilingual:
Expand Down Expand Up @@ -608,7 +609,7 @@ options:
error: Значение должно быть от $1 до $2
nodeTranslationHotkey:
title: Горячая клавиша перевода при наведении или долгом нажатии
description: Настройте клавишу-модификатор для перевода при наведении или используйте долгое нажатие для перевода абзацев
description: Настройте клавишу-модификатор для перевода при наведении, используйте долгое нажатие или задайте своё сочетание клавиш для перевода абзацев
enable: Включить
llmProviderConfigured: LLM-провайдер выбран для $1
llmProviderNotConfigured: LLM-провайдер не выбран для $1
Expand Down Expand Up @@ -979,6 +980,7 @@ hotkey:
shift: Shift
backtick: Обратный апостроф
clickAndHold: Нажмите и удерживайте
custom: Своё сочетание клавиш
translationHub:
searchLanguages: Поиск языков...
noLanguagesFound: Языки не найдены
Expand Down
Loading