diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 4e315ede..6a9b0d12 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -300,7 +300,7 @@ "deleteRecording": "delete recording", "deleteRecordingConfirmation": "Are you sure to delete this recording?", "myRecordings": "recordings", - "noRecordingForThisSegmentYet": "No recordings for this segment yet. Press R to start recording.", + "noRecordingForThisSegmentYet": "No recordings for this segment yet. Press {{key}} to start recording.", "lastYear": "last year", "less": "less", "more": "more", @@ -522,6 +522,11 @@ "AiDictionary": "AI dictionary", "AiTranslate": "AI translate", "cambridgeDictionary": "Cambridge dictionary", + "customizeShortcuts": "Customize shortcuts", + "customizeShortcutsTip":"Press any sequence of keys to set a shortcut", + "customizeShortcutsInvalidToast": "Your shortcut should only have one modifier (Ctrl, Alt, Shift, or Meta) and one key, like 'Ctrl+C'.", + "customizeShortcutsConflictToast": "{{input}} conflicts with the existing {{otherHotkeyName}} shortcut.", + "customizeShortcutsUpdated": "Changes saved", "following": "following", "followers": "followers", "allUsers": "all users", diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 45d5c7f7..22a1c115 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -521,6 +521,11 @@ "AiDictionary": "智能词典", "AiTranslate": "智能翻译", "cambridgeDictionary": "剑桥词典", + "customizeShortcuts": "自定义快捷键", + "customizeShortcutsTip":"按任意键序列设置快捷键", + "customizeShortcutsInvalidToast":"快捷键应最多含一个修饰键(Ctrl、Alt、Shift 或 Meta)和一个键,如 'Ctrl+C'", + "customizeShortcutsConflictToast": "{{input}}和已有{{otherHotkeyName}}的键位冲突了", + "customizeShortcutsUpdated": "设置成功", "following": "关注中", "followers": "被关注", "allUsers": "所有用户", diff --git a/enjoy/src/main/settings.ts b/enjoy/src/main/settings.ts index 3f9c72a4..5b3cb677 100644 --- a/enjoy/src/main/settings.ts +++ b/enjoy/src/main/settings.ts @@ -34,7 +34,7 @@ const libraryPath = () => { settings.setSync( "library", process.env.LIBRARY_PATH || - path.join(app.getPath("documents"), LIBRARY_PATH_SUFFIX) + path.join(app.getPath("documents"), LIBRARY_PATH_SUFFIX) ); } else if (path.parse(_library).base !== LIBRARY_PATH_SUFFIX) { settings.setSync("library", path.join(_library, LIBRARY_PATH_SUFFIX)); @@ -92,6 +92,7 @@ const userDataPath = () => { return userData; }; + export default { registerIpcHandlers: () => { ipcMain.handle("settings-get-library", (_event) => { @@ -152,6 +153,14 @@ export default { ipcMain.handle("settings-set-default-engine", (_event, engine) => { return settings.setSync("defaultEngine", engine); }); + + ipcMain.handle("settings-get-default-hotkeys", (_event) => { + return settings.getSync("defaultHotkeys"); + }); + + ipcMain.handle("settings-set-default-hotkeys", (_event, records) => { + return settings.setSync("defaultHotkeys", records); + }); }, cachePath, libraryPath, diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index c4662c8f..72df13bc 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -172,6 +172,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { switchLanguage: (language: string) => { return ipcRenderer.invoke("settings-switch-language", language); }, + getDefaultHotkeys: () => { + return ipcRenderer.invoke("settings-get-default-hotkeys"); + }, + setDefaultHotkeys: (records: Record) => { + return ipcRenderer.invoke("settings-set-default-hotkeys", records); + }, }, path: { join: (...paths: string[]) => { diff --git a/enjoy/src/renderer/app.tsx b/enjoy/src/renderer/app.tsx index 5c919c40..a9ed6980 100644 --- a/enjoy/src/renderer/app.tsx +++ b/enjoy/src/renderer/app.tsx @@ -3,12 +3,12 @@ import { AISettingsProvider, AppSettingsProvider, DbProvider, + HotKeysSettingsProvider, } from "@renderer/context"; import router from "./router"; import { RouterProvider } from "react-router-dom"; import { Toaster, toast } from "@renderer/components/ui"; import { Tooltip } from "react-tooltip"; -import { useHotkeys } from "react-hotkeys-hook"; function App() { window.__ENJOY_APP__.onNotification((_event, notification) => { @@ -31,32 +31,18 @@ function App() { } }); - const ControlOrCommand = navigator.platform.includes("Mac") - ? "Meta" - : "Control"; - - useHotkeys(`${ControlOrCommand}+Comma`, () => { - document.getElementById("preferences-button")?.click(); - }); - - useHotkeys(`${ControlOrCommand}+Q`, () => { - window.__ENJOY_APP__.app.quit(); - }); - - useHotkeys(`${ControlOrCommand}+Shift+I`, () => { - window.__ENJOY_APP__.app.openDevTools(); - }); - return ( - - - - - - - + + + + + + + + + ); diff --git a/enjoy/src/renderer/components/change-hotkey-dialog.tsx b/enjoy/src/renderer/components/change-hotkey-dialog.tsx new file mode 100644 index 00000000..d016f357 --- /dev/null +++ b/enjoy/src/renderer/components/change-hotkey-dialog.tsx @@ -0,0 +1,103 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@renderer/components/ui"; +import { toast } from "@renderer/components/ui"; +import { useContext, useMemo } from "react"; +import { HotKeysSettingsProviderContext } from "../context"; +import { CheckIcon, KeyboardIcon, XIcon } from "lucide-react"; +import { t } from "i18next"; + +export const ChangeHotkeyDialog = ({ + open, + name, + keyName, + onOpenChange, +}: { + open: boolean; + name: string; + keyName: string; + onOpenChange: (open: boolean) => void; +}) => { + const { + changeHotkey, + currentHotkeys, + recordingHotkeys, + resetRecordingHotkeys, + } = useContext(HotKeysSettingsProviderContext); + + const joinedKeys = useMemo(() => [...recordingHotkeys].join("+"), [ + recordingHotkeys, + ]); + + const changeKeyMap = async () => { + const ret = ((await changeHotkey( + keyName, + recordingHotkeys + )) as unknown) as { + error: "conflict" | "invalid"; + data: string | string[]; + input: string; + }; + const { error, data, input } = ret ?? {}; + if (error === "conflict") { + toast.error( + t("customizeShortcutsConflictToast", { + input, + otherHotkeyName: (data as string[]).join(","), + }) + ); + } else if (error === "invalid") { + toast.error(t("customizeShortcutsInvalidToast")); + } else { + toast.success(t("customizeShortcutsUpdated")); + } + }; + + const clear = () => { + resetRecordingHotkeys(); + }; + + return ( + + + + {t("customizeShortcuts")} + + + {name} + + + {currentHotkeys[keyName]} + + {joinedKeys.length > 0 ? ( + + + {joinedKeys} + + + + + + + + + ) : ( + + {t("customizeShortcutsTip")} + + + )} + + + + Close + + + + ); +}; diff --git a/enjoy/src/renderer/components/medias/media-current-recording.tsx b/enjoy/src/renderer/components/medias/media-current-recording.tsx index 250e905a..c96d9577 100644 --- a/enjoy/src/renderer/components/medias/media-current-recording.tsx +++ b/enjoy/src/renderer/components/medias/media-current-recording.tsx @@ -1,6 +1,7 @@ import { useEffect, useContext, useRef, useState } from "react"; import { AppSettingsProviderContext, + HotKeysSettingsProviderContext, MediaPlayerProviderContext, } from "@renderer/context"; import { MediaRecorder, RecordingDetail } from "@renderer/components"; @@ -60,6 +61,9 @@ export const MediaCurrentRecording = () => { currentTime: mediaCurrentTime, } = useContext(MediaPlayerProviderContext); const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext); + const { enabled, currentHotkeys } = useContext( + HotKeysSettingsProviderContext + ); const [player, setPlayer] = useState(null); const [regions, setRegions] = useState(null); const [currentTime, setCurrentTime] = useState(0); @@ -328,7 +332,7 @@ export const MediaCurrentRecording = () => { } const subscriptions = [ - regions.on("region-created", () => { }), + regions.on("region-created", () => {}), regions.on("region-clicked", (region, e) => { e.stopPropagation(); @@ -399,7 +403,7 @@ export const MediaCurrentRecording = () => { }, [currentRecording, isRecording, layout?.width]); useHotkeys( - ["Ctrl+R", "Meta+R"], + currentHotkeys.PlayOrPauseRecording, (keyboardEvent, hotkeyEvent) => { if (!player) return; keyboardEvent.preventDefault(); @@ -411,6 +415,7 @@ export const MediaCurrentRecording = () => { document.getElementById("recording-play-or-pause-button").click(); } }, + { enabled }, [player] ); @@ -422,7 +427,9 @@ export const MediaCurrentRecording = () => { @@ -551,16 +558,17 @@ export const MediaCurrentRecording = () => { > = 80 - ? "text-green-500" - : currentRecording.pronunciationAssessment - .pronunciationScore >= 60 - ? "text-yellow-600" - : "text-red-500" - : "" - } + ${ + currentRecording.pronunciationAssessment + ? currentRecording.pronunciationAssessment + .pronunciationScore >= 80 + ? "text-green-500" + : currentRecording.pronunciationAssessment + .pronunciationScore >= 60 + ? "text-yellow-600" + : "text-red-500" + : "" + } `} /> {t("pronunciationAssessment")} diff --git a/enjoy/src/renderer/components/medias/media-player-controls.tsx b/enjoy/src/renderer/components/medias/media-player-controls.tsx index 42fd89e2..c0e39901 100644 --- a/enjoy/src/renderer/components/medias/media-player-controls.tsx +++ b/enjoy/src/renderer/components/medias/media-player-controls.tsx @@ -13,6 +13,7 @@ import { import { MediaPlayerProviderContext, AppSettingsProviderContext, + HotKeysSettingsProviderContext, } from "@renderer/context"; import { ScissorsIcon, @@ -56,6 +57,7 @@ export const MediaPlayerControls = () => { setTranscriptionDraft, } = useContext(MediaPlayerProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { currentHotkeys, enabled } = useContext(HotKeysSettingsProviderContext) const [playMode, setPlayMode] = useState<"loop" | "single" | "all">("single"); const [playbackRate, setPlaybackRate] = useState(1); const [grouping, setGrouping] = useState(false); @@ -372,30 +374,32 @@ export const MediaPlayerControls = () => { }, [wavesurfer, decoded, playMode, activeRegion, currentTime]); useHotkeys( - ["Space", "p", "n", "r", "c"], + [currentHotkeys.PlayOrPause, currentHotkeys.PlayPreviousSegment, currentHotkeys.PlayNextSegment, currentHotkeys.StartOrStopRecording, currentHotkeys.Compare], (keyboardEvent, hotkeyEvent) => { if (!wavesurfer) return; keyboardEvent.preventDefault(); switch (hotkeyEvent.keys.join("")) { - case "space": + case currentHotkeys.PlayOrPause.toLowerCase(): document.getElementById("media-play-or-pause-button").click(); break; - case "p": + case currentHotkeys.PlayPreviousSegment.toLowerCase(): document.getElementById("media-play-previous-button").click(); break; - case "n": + case currentHotkeys.PlayNextSegment.toLowerCase(): document.getElementById("media-play-next-button").click(); break; - case "r": + case currentHotkeys.StartOrStopRecording.toLowerCase(): document.getElementById("media-record-button").click(); break; - case "c": + case currentHotkeys.Compare.toLowerCase(): document.getElementById("media-compare-button").click(); break; } + },{ + enabled }, - [wavesurfer] + [wavesurfer, currentHotkeys] ); /* diff --git a/enjoy/src/renderer/components/medias/media-recordings.tsx b/enjoy/src/renderer/components/medias/media-recordings.tsx index cf3be319..a1050fed 100644 --- a/enjoy/src/renderer/components/medias/media-recordings.tsx +++ b/enjoy/src/renderer/components/medias/media-recordings.tsx @@ -17,6 +17,7 @@ import { } from "@renderer/components/ui"; import { AppSettingsProviderContext, + HotKeysSettingsProviderContext, MediaPlayerProviderContext, } from "@renderer/context"; import { @@ -29,6 +30,7 @@ import { t } from "i18next"; import { formatDateTime, formatDuration } from "@renderer/lib/utils"; export const MediaRecordings = () => { + const { currentHotkeys } = useContext(HotKeysSettingsProviderContext); const containerRef = useRef(); const { recordings = [], @@ -59,7 +61,9 @@ export const MediaRecordings = () => { )} diff --git a/enjoy/src/renderer/components/preferences/hotkeys.tsx b/enjoy/src/renderer/components/preferences/hotkeys.tsx index 2e9df16c..604d872a 100644 --- a/enjoy/src/renderer/components/preferences/hotkeys.tsx +++ b/enjoy/src/renderer/components/preferences/hotkeys.tsx @@ -1,19 +1,45 @@ import { t } from "i18next"; import { Separator } from "@renderer/components/ui"; +import { HotKeysSettingsProviderContext, Hotkey } from "@/renderer/context"; +import { useContext, useState } from "react"; +import { ChangeHotkeyDialog } from "../change-hotkey-dialog"; export const Hotkeys = () => { + const [open, setOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState<{ + name: string; + keyName: string; + } | null>(null); + const { + currentHotkeys, + startRecordingHotkeys, + stopRecordingHotkeys, + } = useContext(HotKeysSettingsProviderContext); + const commandOrCtrl = navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"; + const handleItemSelected = (item: { name: string; keyName: Hotkey }) => { + setOpen(true); + startRecordingHotkeys(); + setSelectedItem(item); + }; + + const handleOpenChange = (open: boolean) => { + setOpen(open); + if (!open) { + stopRecordingHotkeys(); + } + }; + return ( <> {t("hotkeys")} - {t("system")} {t("quitApp")} - + {commandOrCtrl} + Q @@ -24,8 +50,16 @@ export const Hotkeys = () => { {t("openPreferences")} - - {commandOrCtrl} + , + + handleItemSelected({ + name: "Open preferences", + keyName: "OpenPreferences", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.OpenPreferences} @@ -36,8 +70,16 @@ export const Hotkeys = () => { {t("playOrPause")} - - Space + + handleItemSelected({ + name: t("playOrPause"), + keyName: "PlayOrPause", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.PlayOrPause} @@ -47,8 +89,16 @@ export const Hotkeys = () => { {t("startOrStopRecording")} - - r + + handleItemSelected({ + name: t("startOrStopRecording"), + keyName: "StartOrStopRecording", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.StartOrStopRecording} @@ -58,8 +108,16 @@ export const Hotkeys = () => { {t("playOrPauseRecording")} - - {commandOrCtrl} + r + + handleItemSelected({ + name: t("playOrPauseRecording"), + keyName: "PlayOrPauseRecording", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.PlayOrPauseRecording} @@ -69,8 +127,16 @@ export const Hotkeys = () => { {t("playPreviousSegment")} - - p + + handleItemSelected({ + name: t("playPreviousSegment"), + keyName: "PlayPreviousSegment", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.PlayPreviousSegment} @@ -80,8 +146,16 @@ export const Hotkeys = () => { {t("playNextSegment")} - - n + + handleItemSelected({ + name: t("playNextSegment"), + keyName: "PlayNextSegment", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.PlayNextSegment} @@ -91,14 +165,28 @@ export const Hotkeys = () => { {t("compare")} - - c + + handleItemSelected({ + name: t("compare"), + keyName: "Compare", + }) + } + className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer" + > + {currentHotkeys.Compare} - + + > ); }; diff --git a/enjoy/src/renderer/context/hotkeys-settings-provider.tsx b/enjoy/src/renderer/context/hotkeys-settings-provider.tsx new file mode 100644 index 00000000..943b1687 --- /dev/null +++ b/enjoy/src/renderer/context/hotkeys-settings-provider.tsx @@ -0,0 +1,236 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { useHotkeys, useRecordHotkeys } from "react-hotkeys-hook"; +import { AppSettingsProviderContext } from "./app-settings-provider"; +import _ from "lodash"; + +function isShortcutValid(shortcut: string) { + const modifiers = ["ctrl", "alt", "shift", "meta"]; + const keys = shortcut.toLowerCase().split("+"); + const modifierCount = keys.filter((key) => modifiers.includes(key)).length; + const normalKeyCount = keys.length - modifierCount; + // Validation rule: At most one modifier key, and at most one regular key + return modifierCount <= 1 && normalKeyCount === 1; +} + +function mergeWithPreference( + a: Record, // electron settings's cached value + b: Record // current version's default value +): Record { + const c: Record = {}; + + for (const key in b) { + c[key] = b[key]; + } + + for (const key in a) { + if (key in b) { + c[key] = a[key]; + } + } + + return c; +} + +const ControlOrCommand = navigator.platform.includes("Mac") + ? "Meta" + : "Control"; + +const defaultKeyMap = { + // system + QuitApp: `${ControlOrCommand}+Q`, + OpenPreferences: `${ControlOrCommand}+Comma`, + // player + PlayOrPause: "Space", + StartOrStopRecording: "r", + PlayOrPauseRecording: `${ControlOrCommand}+r`, + PlayPreviousSegment: "p", + PlayNextSegment: "n", + Compare: "c", + // dev tools + OpenDevTools: `${ControlOrCommand}+Shift+I`, +}; + +export type Hotkey = keyof typeof defaultKeyMap; + +function checkKeyAndValue( + key: Hotkey, + value: string, + shortcuts: typeof defaultKeyMap +) { + const inputValue = value.toLowerCase(); + + const conflictKeys = Object.keys(shortcuts).filter( + (k: Hotkey) => shortcuts[k].toLowerCase() === inputValue && k !== key + ); + + return conflictKeys; +} + +type HotkeysSettingsProviderState = { + currentHotkeys: Record; + recordingHotkeys?: any; + enabled: boolean; + startRecordingHotkeys?: () => void; + stopRecordingHotkeys?: () => void; + resetRecordingHotkeys?: () => void; + changeHotkey?: (key: string, recordedHotkeys: Set) => void; +}; + +const initialState: HotkeysSettingsProviderState = { + currentHotkeys: {}, + enabled: true, +}; + +export const HotKeysSettingsProviderContext = createContext< + HotkeysSettingsProviderState +>(initialState); + +const HotKeysSettingsSystemSettings = ({ + currentHotkeys, + enabled, + children, +}: { + currentHotkeys: Record; + enabled: boolean; + children: React.ReactNode; +}) => { + useHotkeys( + currentHotkeys.OpenPreferences, + () => { + document.getElementById("preferences-button")?.click(); + }, + { + enabled, + } + ); + + useHotkeys( + currentHotkeys.QuitApp, + () => { + window.__ENJOY_APP__.app.quit(); + }, + { + enabled, + } + ); + + useHotkeys( + currentHotkeys.OpenDevTools, + () => { + window.__ENJOY_APP__.app.openDevTools(); + }, + { + enabled, + } + ); + return children; +}; + +export const HotKeysSettingsProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [currentHotkeys, setCurrentHotkeys] = useState( + initialState.currentHotkeys + ); + const [keys, { start, stop, resetKeys, isRecording }] = useRecordHotkeys(); + + const { EnjoyApp } = useContext(AppSettingsProviderContext); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + const _hotkeys = await EnjoyApp.settings.getDefaultHotkeys(); + // During version iterations, there may be added or removed keys. + const merged = mergeWithPreference(_hotkeys ?? {}, defaultKeyMap); + await EnjoyApp.settings.setDefaultHotkeys(merged).then(() => { + setCurrentHotkeys(merged); + }); + }; + + const changeHotkey = useCallback( + async ( + keyName: Hotkey, + recordedHotkeys: Set + ): Promise<{ + error: "conflict" | "invalid"; + data: string | string[]; + input: string; + } | void> => { + const str = [...recordedHotkeys].join("+"); + const newMap = { + ...currentHotkeys, + [keyName]: str, + }; + + // validate + const conflictKeys = checkKeyAndValue(keyName, str, currentHotkeys); + if (conflictKeys.length > 0) { + resetKeys(); + return { + error: "conflict", + data: conflictKeys, + input: str, + }; + } + const isValid = isShortcutValid(str); + if (!isValid) { + resetKeys(); + return { + error: "invalid", + data: str, + input: str, + }; + } + + await EnjoyApp.settings.setDefaultHotkeys(newMap).then(() => { + setCurrentHotkeys(newMap); + }); + resetKeys(); + }, + [currentHotkeys] + ); + + const startRecordingHotkeys = () => { + start(); + }; + + const stopRecordingHotkeys = () => { + stop(); + resetKeys(); + }; + + return ( + + {_.isEmpty(currentHotkeys) ? null : ( + + {children} + + )} + + ); +}; diff --git a/enjoy/src/renderer/context/index.ts b/enjoy/src/renderer/context/index.ts index 413c0495..15e5ad8e 100644 --- a/enjoy/src/renderer/context/index.ts +++ b/enjoy/src/renderer/context/index.ts @@ -4,3 +4,4 @@ export * from "./db-provider"; export * from "./theme-provider"; export * from "./wavesurfer-provider"; export * from "./media-player-provider"; +export * from './hotkeys-settings-provider' diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index c28f2d5f..2160577d 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -104,6 +104,8 @@ type EnjoyAppType = { ) => Promise; getLanguage: () => Promise; switchLanguage: (language: string) => Promise; + getDefaultHotkeys: () => Promise | undefined>; + setDefaultHotkeys: (records: Record) => Promise; }; fs: { ensureDir: (path: string) => Promise;
{name}
+ {currentHotkeys[keyName]} +
+ {joinedKeys} +