From f2e5111ee5cab476d1463f01308a0588c685c0d4 Mon Sep 17 00:00:00 2001 From: Arnei Date: Wed, 4 Feb 2026 12:51:47 +0100 Subject: [PATCH 1/8] Add configurable hotkeys Allows users to configure their own hotkeys. This should empower users to choose the hotkeys that work for them, instead of having to use the cumbersome "Shift + Alt + something" combinations. Configured hotkeys are persisted in local storage. To that end, this patch introduces the redux-persist package and all the necessary boilerplate that it requires. This also makes more changes to KeyboardControls.tsx than strictly necessary in the hopes of making it a little less complicated. --- package-lock.json | 10 + package.json | 1 + src/globalKeys.ts | 3 +- src/i18n/locales/en-US.json | 9 +- src/index.tsx | 12 +- src/main/CuttingActions.tsx | 59 +++--- src/main/CuttingActionsContextMenu.tsx | 20 +- src/main/KeyboardControls.tsx | 250 +++++++++++++++++++++---- src/main/SubtitleListEditor.tsx | 24 +-- src/main/SubtitleTimeline.tsx | 23 +-- src/main/Timeline.tsx | 28 +-- src/main/VideoControls.tsx | 35 ++-- src/redux/hotkeySlice.ts | 43 +++++ src/redux/store.ts | 47 +++-- 14 files changed, 418 insertions(+), 146 deletions(-) create mode 100644 src/redux/hotkeySlice.ts diff --git a/package-lock.json b/package-lock.json index 3c75cbb9c..3f95eae1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^1.8.11", "redux": "^5.0.1", + "redux-persist": "^6.0.0", "smol-toml": "^1.4.0", "standardized-audio-context": "^25.3.77", "typescript": "^5.8.3", @@ -6490,6 +6491,15 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "license": "MIT", + "peerDependencies": { + "redux": ">4.0.0" + } + }, "node_modules/redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", diff --git a/package.json b/package.json index 4be5afa0a..e36805340 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^1.8.11", "redux": "^5.0.1", + "redux-persist": "^6.0.0", "smol-toml": "^1.4.0", "standardized-audio-context": "^25.3.77", "typescript": "^5.8.3", diff --git a/src/globalKeys.ts b/src/globalKeys.ts index a4e1769e8..75570ba12 100644 --- a/src/globalKeys.ts +++ b/src/globalKeys.ts @@ -64,7 +64,8 @@ export const subtitleListHotkeysDefaultOptions: object = { const cuttingZoomInSplitKey = ";"; -export const KEYMAP: IKeyMap = { + +export const DEFAULT_KEYMAP: IKeyMap = { videoPlayer: { play: { name: "keyboardControls.videoPlayButton", diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 32ed51f01..163f0f852 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -350,7 +350,14 @@ "scrubberLeft": "Move left", "scrubberRight": "Move right", "scrubberIncrease": "Move faster", - "scrubberDecrease": "Move slower" + "scrubberDecrease": "Move slower", + "changeModal": { + "title": "Change hotkey: {{name}}", + "info": "Record a new hotkey sequence by pressing the keys you want.", + "recordedKeys": "Recorded keys: ", + "save": "Save", + "discard": "Discard" + } }, "theme": { diff --git a/src/index.tsx b/src/index.tsx index dbb9f7311..657bd713c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,8 @@ import "./i18n/config"; import "@opencast/appkit/dist/colors.css"; import { ColorSchemeProvider } from "@opencast/appkit"; +import { PersistGate } from "redux-persist/lib/integration/react"; +import { persistStore } from "redux-persist"; const container = document.getElementById("root"); if (!container) { @@ -21,6 +23,8 @@ if (!container) { } const root = ReactDOMClient.createRoot(container); +// Commenting persistent stuff out can help with debugging +const persistor = persistStore(store); // Load config here // Load the rest of the application and try to fetch the settings file from the @@ -36,9 +40,11 @@ initialize.then( root.render( - - - + loading...} persistor={persistor}> + + + + , ); diff --git a/src/main/CuttingActions.tsx b/src/main/CuttingActions.tsx index 86c0e6572..f20d16ada 100644 --- a/src/main/CuttingActions.tsx +++ b/src/main/CuttingActions.tsx @@ -26,7 +26,7 @@ import { timelineZoomIn, timelineZoomOut, } from "../redux/videoSlice"; -import { KEYMAP, rewriteKeys } from "../globalKeys"; +import { rewriteKeys } from "../globalKeys"; import { ActionCreatorWithoutPayload, ActionCreatorWithPayload, PayloadActionCreator } from "@reduxjs/toolkit"; import { useTranslation } from "react-i18next"; @@ -36,6 +36,7 @@ import { Slider } from "@mui/material"; import { useHotkeys } from "react-hotkeys-hook"; import { ProtoButton } from "@opencast/appkit"; import Select, { components, SingleValue } from "react-select"; +import { selectKeymap } from "../redux/hotkeySlice"; /** * Defines the different actions a user can perform while in cutting mode @@ -65,6 +66,8 @@ const CuttingActions: React.FC<{ // Init redux variables const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); + /** * General action callback for cutting actions * @param event event triggered by click or button press @@ -92,39 +95,39 @@ const CuttingActions: React.FC<{ // Maps functions to hotkeys useHotkeys( - KEYMAP.cutting.cut.key, + keymap.cutting.cut.key, () => dispatchAction(cut), - KEYMAP.cutting.cut.options, + keymap.cutting.cut.options, [cut], ); useHotkeys( - KEYMAP.cutting.delete.key, + keymap.cutting.delete.key, () => dispatchAction(markAsDeletedOrAlive), - KEYMAP.cutting.delete.options, + keymap.cutting.delete.options, [markAsDeletedOrAlive], ); useHotkeys( - KEYMAP.cutting.mergeLeft.key, + keymap.cutting.mergeLeft.key, () => dispatchAction(mergeLeft), - KEYMAP.cutting.mergeLeft.options, + keymap.cutting.mergeLeft.options, [mergeLeft], ); useHotkeys( - KEYMAP.cutting.mergeRight.key, + keymap.cutting.mergeRight.key, () => dispatchAction(mergeRight), - KEYMAP.cutting.mergeRight.options, + keymap.cutting.mergeRight.options, [mergeRight], ); useHotkeys( - KEYMAP.cutting.zoomIn.key, + keymap.cutting.zoomIn.key, () => dispatchAction(timelineZoomIn), - KEYMAP.cutting.zoomIn.options, + keymap.cutting.zoomIn.options, [timelineZoomIn], ); useHotkeys( - KEYMAP.cutting.zoomOut.key, + keymap.cutting.zoomOut.key, () => dispatchAction(timelineZoomOut), - KEYMAP.cutting.zoomOut.options, + keymap.cutting.zoomOut.options, [timelineZoomOut], ); @@ -152,8 +155,8 @@ const CuttingActions: React.FC<{ action={cut} actionWithPayload={undefined} payload={undefined} - tooltip={t("cuttingActions.cut-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })} - ariaLabelText={t("cuttingActions.cut-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })} + tooltip={t("cuttingActions.cut-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })} + ariaLabelText={t("cuttingActions.cut-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })} />
@@ -166,8 +169,8 @@ const CuttingActions: React.FC<{ action={add} actionWithPayload={undefined} payload={undefined} - tooltip={t("cuttingActions.add-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })} - ariaLabelText={t("cuttingActions.add-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })} + tooltip={t("cuttingActions.add-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })} + ariaLabelText={t("cuttingActions.add-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })} />
@@ -175,7 +178,7 @@ const CuttingActions: React.FC<{ {!isDeleteButtonDisabled && <>
@@ -188,9 +191,9 @@ const CuttingActions: React.FC<{ action={deleteByMerge} actionWithPayload={undefined} payload={undefined} - tooltip={t("cuttingActions.deleteByMerge-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key) })} + tooltip={t("cuttingActions.deleteByMerge-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.delete.key) })} ariaLabelText={t("cuttingActions.deleteByMerge-tooltip-aria", - { hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key) })} + { hotkeyName: rewriteKeys(keymap.cutting.delete.key) })} />
@@ -203,9 +206,9 @@ const CuttingActions: React.FC<{ action={mergeLeft} actionWithPayload={undefined} payload={undefined} - tooltip={t("cuttingActions.mergeLeft-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key) })} + tooltip={t("cuttingActions.mergeLeft-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.mergeLeft.key) })} ariaLabelText={ - t("cuttingActions.mergeLeft-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key) }) + t("cuttingActions.mergeLeft-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.mergeLeft.key) }) } />
@@ -219,9 +222,9 @@ const CuttingActions: React.FC<{ action={mergeRight} actionWithPayload={undefined} payload={undefined} - tooltip={t("cuttingActions.mergeRight-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key) })} + tooltip={t("cuttingActions.mergeRight-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.mergeRight.key) })} ariaLabelText={ - t("cuttingActions.mergeRight-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key) }) + t("cuttingActions.mergeRight-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.mergeRight.key) }) } />
@@ -257,12 +260,12 @@ const CuttingActions: React.FC<{ } diff --git a/src/main/CuttingActionsContextMenu.tsx b/src/main/CuttingActionsContextMenu.tsx index f5a0138ec..92892aae7 100644 --- a/src/main/CuttingActionsContextMenu.tsx +++ b/src/main/CuttingActionsContextMenu.tsx @@ -5,9 +5,10 @@ import { LuChevronLeft, LuChevronRight, LuScissors, LuTrash } from "react-icons/ import TrashRestore from "../img/trash-restore.svg?react"; import { ContextMenuItem, ThemedContextMenu } from "./ContextMenu"; -import { KEYMAP, rewriteKeys } from "../globalKeys"; +import { rewriteKeys } from "../globalKeys"; import { useAppDispatch, useAppSelector } from "../redux/store"; import { cut, markAsDeletedOrAlive, mergeLeft, mergeRight, selectIsCurrentSegmentAlive } from "../redux/videoSlice"; +import { selectKeymap } from "../redux/hotkeySlice"; const CuttingActionsContextMenu: React.FC<{ children: React.ReactNode, @@ -19,6 +20,7 @@ const CuttingActionsContextMenu: React.FC<{ // Init redux variables const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); const isCurrentSegmentAlive = useAppSelector(selectIsCurrentSegmentAlive); const cuttingContextMenuItems: ContextMenuItem[] = [ @@ -26,36 +28,36 @@ const CuttingActionsContextMenu: React.FC<{ name: t("cuttingActions.cut-button"), action: () => dispatch(cut()), icon: LuScissors, - hotKey: KEYMAP.cutting.cut.key, + hotKey: keymap.cutting.cut.key, ariaLabel: t("cuttingActions.cut-tooltip-aria", { - hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key), + hotkeyName: rewriteKeys(keymap.cutting.cut.key), }), }, { name: isCurrentSegmentAlive ? t("cuttingActions.delete-button") : t("cuttingActions.restore-button"), action: () => dispatch(markAsDeletedOrAlive()), icon: isCurrentSegmentAlive ? LuTrash : TrashRestore, - hotKey: KEYMAP.cutting.delete.key, + hotKey: keymap.cutting.delete.key, ariaLabel: t("cuttingActions.delete-restore-tooltip-aria", { - hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key), + hotkeyName: rewriteKeys(keymap.cutting.delete.key), }), }, { name: t("cuttingActions.mergeLeft-button"), action: () => dispatch(mergeLeft()), icon: LuChevronLeft, - hotKey: KEYMAP.cutting.mergeLeft.key, + hotKey: keymap.cutting.mergeLeft.key, ariaLabel: t("cuttingActions.mergeLeft-tooltip-aria", { - hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key), + hotkeyName: rewriteKeys(keymap.cutting.mergeLeft.key), }), }, { name: t("cuttingActions.mergeRight-button"), action: () => dispatch(mergeRight()), icon: LuChevronRight, - hotKey: KEYMAP.cutting.mergeRight.key, + hotKey: keymap.cutting.mergeRight.key, ariaLabel: t("cuttingActions.mergeRight-tooltip-aria", { - hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key), + hotkeyName: rewriteKeys(keymap.cutting.mergeRight.key), }), }, ]; diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index bb27f0f66..284a4af5d 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -1,21 +1,28 @@ +import React from "react"; import { css } from "@emotion/react"; import { ParseKeys } from "i18next"; - -import React from "react"; - import { useTranslation, Trans } from "react-i18next"; -import { getGroupName, KEYMAP, rewriteKeys } from "../globalKeys"; -import { useTheme } from "../themes"; -import { titleStyle, titleStyleBold } from "../cssStyles"; +import { getGroupName, IKey, IKeyGroup, rewriteKeys } from "../globalKeys"; +import { Theme, useTheme } from "../themes"; +import { basicButtonStyle, titleStyle, titleStyleBold } from "../cssStyles"; +import { useRecordHotkeys } from "react-hotkeys-hook"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { selectKeymap, setHotkey } from "../redux/hotkeySlice"; +import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit"; +import { LuPen } from "react-icons/lu"; -const Group: React.FC<{ name: ParseKeys, entries: { [key: string]: string[][]; }; }> = ({ name, entries }) => { +const Group: React.FC<{ + id: string + entries: IKeyGroup + openEditModal: (group: string, action: string, actionTitle: string) => void +}> = ({ id, entries, openEditModal }) => { const { t } = useTranslation(); const theme = useTheme(); const groupStyle = css({ display: "flex", - flexDirection: "column" as const, + flexDirection: "column", width: "460px", maxWidth: "50vw", @@ -32,19 +39,42 @@ const Group: React.FC<{ name: ParseKeys, entries: { [key: string]: string[][]; } return (
-

{t(name)}

- {Object.entries(entries).map(([key, value], index) => - , +

{t(getGroupName(id))}

+ {Object.entries(entries).map(([entryId, value]) => + , )}
); }; -const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, sequences }) => { +const Entry: React.FC<{ + id: string + entry: IKey + groupId: string + openEditModal: (group: string, action: string, actionTitle: string) => void +}> = ({ id, entry, groupId, openEditModal }) => { const { t } = useTranslation(); const theme = useTheme(); + const formatEntry = (entry: IKey) => { + let formattedSequences: string[][] = []; + + const sequences = entry.key.split(",").map(item => item.trim()); + const sequenceSplitKey = entry.splitKey ?? "+"; + formattedSequences = Object.entries(sequences).map(([, sequence]) => { + return sequence.split(sequenceSplitKey).map(item => rewriteKeys(item.trim())); + }); + + return formattedSequences; + }; + const entryStyle = css({ display: "flex", flexFlow: "column nowrap", @@ -62,6 +92,19 @@ const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, seque color: `${theme.text}`, }); + const entryContentStyle = css({ + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }); + + const sequencesStyle = css({ + display: "flex", + flexFlow: "column", + gap: "10px", + }); + const sequenceStyle = css({ display: "flex", flexDirection: "row", @@ -85,58 +128,88 @@ const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, seque fontWeight: "bold", }); + const editButtonStyle = css({ + padding: "16px", + }); + return (
-
{name || t("keyboardControls.missingLabel")}
- {sequences.map((sequence, index, arr) => ( -
- {sequence.map((singleKey, index) => ( -
-
- {singleKey} -
- {sequence.length - 1 !== index && -
+
- } +
{entry.name || t("keyboardControls.missingLabel")}
+
+
+ {formatEntry(entry).map((sequence, index, arr) => ( +
+ {sequence.map((singleKey, index) => ( +
+
+ {singleKey} +
+ {sequence.length - 1 !== index && +
+
+ } +
+ ))} +
+ {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} +
))} -
- {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} -
- ))} + openEditModal(groupId, id, t(entry.name as ParseKeys))} + > + + +
); }; const KeyboardControls: React.FC = () => { - const { t } = useTranslation(); const theme = useTheme(); + const keymap = useAppSelector(selectKeymap); + + const [keys, { start, stop, resetKeys }] = useRecordHotkeys(); + const modalRef = React.useRef(null); + const [editGroup, setEditGroup] = React.useState(""); + const [editAction, setEditAction] = React.useState(""); + const [editActionTitle, setEditActionTitle] = React.useState(""); + + const openEditModal = (group: string, action: string, actionTitle: string) => { + setEditGroup(group); + setEditAction(action); + setEditActionTitle(actionTitle); + + resetKeys(); + start(); + + if (modalRef.current) { + modalRef.current?.open(); + } + }; const groupsStyle = css({ display: "flex", - flexDirection: "row" as const, + flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: "30px", }); const render = () => { - if (KEYMAP && Object.keys(KEYMAP).length > 0) { + if (keymap && Object.keys(keymap).length > 0) { const groups: JSX.Element[] = []; - Object.entries(KEYMAP).forEach(([groupName, group], index) => { - const entries: { [groupName: string]: string[][]; } = {}; - Object.entries(group).forEach(([, action]) => { - const sequences = action.key.split(",").map(item => item.trim()); - const sequenceSplitKey = action.splitKey ? action.splitKey : "+"; - entries[action.name] = Object.entries(sequences).map(([, sequence]) => { - return sequence.split(sequenceSplitKey).map(item => rewriteKeys(item.trim())); - }); - }); - groups.push(); + Object.entries(keymap).forEach(([groupId, group]) => { + groups.push(); }); return ( @@ -152,7 +225,7 @@ const KeyboardControls: React.FC = () => { const keyboardControlsStyle = css({ display: "flex", - flexDirection: "column" as const, + flexDirection: "column", alignItems: "center", width: "100%", }); @@ -163,9 +236,104 @@ const KeyboardControls: React.FC = () => { {t("keyboardControls.header")}
+ + {render()}
); }; +const ChangeHotkeyModal: React.FC<{ + modalRef: React.RefObject, + keys: Set, + stop: () => void + group: string, + action: string, + actionTitle: string, +}> = ({ + modalRef, + keys, + stop, + group, + action, + actionTitle, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const theme = useTheme(); + + const setNewKeys = () => { + stop(); + + dispatch(setHotkey({ + group: group, + action: action, + key: Array.from(keys).join(" + "), + })); + + if (modalRef.current?.close) { + modalRef.current.close(); + } + }; + + const modalContentStyle = css({ + display: "flex", + flexDirection: "column", + alignItems: "center", + width: "100%", + }); + + const buttonsStyle = css({ + display: "flex", + flexDirection: "row", + width: "100%", + justifyContent: "space-around", + }); + + const buttonStyle = (theme: Theme) => css({ + fontSize: "16px", + padding: "12px 16px", + justifyContent: "space-around", + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + }); + + return ( + +
+

{t("keyboardControls.changeModal.info")}

+

{t("keyboardControls.changeModal.recordedKeys")}

+

{Array.from(keys).join(" + ")}

+
+
+ + {t("keyboardControls.changeModal.save")} + + + {t("keyboardControls.changeModal.discard")} + +
+
+
+ ); + +}; + export default KeyboardControls; diff --git a/src/main/SubtitleListEditor.tsx b/src/main/SubtitleListEditor.tsx index 68929f06d..d0cdf1e90 100644 --- a/src/main/SubtitleListEditor.tsx +++ b/src/main/SubtitleListEditor.tsx @@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"; import { shallowEqual } from "react-redux"; import { RootState, useAppDispatch, useAppSelector } from "../redux/store"; import { basicButtonStyle } from "../cssStyles"; -import { KEYMAP, subtitleListHotkeysDefaultOptions } from "../globalKeys"; +import { subtitleListHotkeysDefaultOptions } from "../globalKeys"; import { SubtitleCue, SubtitlesInEditor } from "../types"; import { convertMsToReadableString } from "../util/utilityFunctions"; import { ListChildComponentProps, VariableSizeList } from "react-window"; @@ -21,6 +21,7 @@ import { ProtoButton, useColorScheme } from "@opencast/appkit"; import { ActionCreatorWithPayload } from "@reduxjs/toolkit"; import { setCues } from "../redux/chapterSlice"; import { selectDuration } from "../redux/videoSlice"; +import { selectKeymap } from "../redux/hotkeySlice"; /** * Displays everything needed to edit subtitles @@ -305,6 +306,7 @@ const SubtitleListSegment : React.FC<{ const theme = useTheme(); const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); const duration = useAppSelector(selectDuration); const currentlyAt = useAppSelector(selectCurrentlyAt); // Unfortunately, the focus selectors will cause every element to rerender, @@ -438,28 +440,28 @@ const SubtitleListSegment : React.FC<{ // Maps functions to hotkeys const hotkeyRef = useHotkeys([ - KEYMAP.subtitleList.addAbove.key, - KEYMAP.subtitleList.addBelow.key, - KEYMAP.subtitleList.jumpAbove.key, - KEYMAP.subtitleList.jumpBelow.key, - KEYMAP.subtitleList.delete.key, + keymap.subtitleList.addAbove.key, + keymap.subtitleList.addBelow.key, + keymap.subtitleList.jumpAbove.key, + keymap.subtitleList.jumpBelow.key, + keymap.subtitleList.delete.key, ], (_, handler) => { switch (handler.keys?.join("")) { - case KEYMAP.subtitleList.addAbove.key.split("+").pop(): + case keymap.subtitleList.addAbove.key.split("+").pop(): addCueAbove(); break; - case KEYMAP.subtitleList.addBelow.key.split("+").pop(): + case keymap.subtitleList.addBelow.key.split("+").pop(): addCueBelow(); break; - case KEYMAP.subtitleList.jumpAbove.key.split("+").pop(): + case keymap.subtitleList.jumpAbove.key.split("+").pop(): dispatch(setFocusSegmentTriggered(true)); dispatch(setFocusToSegmentAboveId({ identifier: identifier, segmentId: cue.idInternal })); break; - case KEYMAP.subtitleList.jumpBelow.key.split("+").pop(): + case keymap.subtitleList.jumpBelow.key.split("+").pop(): dispatch(setFocusSegmentTriggered(true)); dispatch(setFocusToSegmentBelowId({ identifier: identifier, segmentId: cue.idInternal })); break; - case KEYMAP.subtitleList.delete.key.split("+").pop(): + case keymap.subtitleList.delete.key.split("+").pop(): dispatch(setFocusSegmentTriggered(true)); dispatch(setFocusToSegmentAboveId({ identifier: identifier, segmentId: cue.idInternal })); deleteCue(); diff --git a/src/main/SubtitleTimeline.tsx b/src/main/SubtitleTimeline.tsx index bf7064787..49937184a 100644 --- a/src/main/SubtitleTimeline.tsx +++ b/src/main/SubtitleTimeline.tsx @@ -25,9 +25,9 @@ import { useTheme } from "../themes"; import { ThemedTooltip } from "./Tooltip"; import { useTranslation } from "react-i18next"; import { useHotkeys } from "react-hotkeys-hook"; -import { KEYMAP } from "../globalKeys"; import { shallowEqual } from "react-redux"; import TimelineStamps from "./TimelineStamps"; +import { selectKeymap } from "../redux/hotkeySlice"; /** * Copy-paste of the timeline in Video.tsx, so that we can make some small adjustments, @@ -40,6 +40,7 @@ const SubtitleTimeline: React.FC = () => { // Init redux variables const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); const duration = useAppSelector(selectDuration); const currentlyAt = useAppSelector(selectCurrentlyAt); const subtitleId = useAppSelector(selectSelectedSubtitleId, shallowEqual); @@ -101,33 +102,33 @@ const SubtitleTimeline: React.FC = () => { // TODO: Better increases and decreases than ten intervals // TODO: Additional helpful controls (e.g. jump to start/end of segment/next segment) useHotkeys( - KEYMAP.timeline.left.key, + keymap.timeline.left.key, () => dispatch(setCurrentlyAt(Math.max(currentlyAt - keyboardJumpDelta, 0))), - KEYMAP.timeline.left.options, + keymap.timeline.left.options, [currentlyAt, keyboardJumpDelta], ); useHotkeys( - KEYMAP.timeline.right.key, + keymap.timeline.right.key, () => dispatch(setCurrentlyAt(Math.min(currentlyAt + keyboardJumpDelta, duration))), - KEYMAP.timeline.right.options, + keymap.timeline.right.options, [currentlyAt, keyboardJumpDelta, duration], ); useHotkeys( - KEYMAP.timeline.increase.key, + keymap.timeline.increase.key, () => setKeyboardJumpDelta(keyboardJumpDelta => Math.min(keyboardJumpDelta * 10, 1000000)), - KEYMAP.timeline.increase.options, + keymap.timeline.increase.options, [keyboardJumpDelta], ); useHotkeys( - KEYMAP.timeline.decrease.key, + keymap.timeline.decrease.key, () => setKeyboardJumpDelta(keyboardJumpDelta => Math.max(keyboardJumpDelta / 10, 1)), - KEYMAP.timeline.decrease.options, + keymap.timeline.decrease.options, [keyboardJumpDelta], ); useHotkeys( - KEYMAP.subtitleList.addCue.key, + keymap.subtitleList.addCue.key, () => addCue(currentlyAt), - KEYMAP.subtitleList.addCue.options, + keymap.subtitleList.addCue.options, [currentlyAt], ); diff --git a/src/main/Timeline.tsx b/src/main/Timeline.tsx index 4b1488af4..13fc8c2ac 100644 --- a/src/main/Timeline.tsx +++ b/src/main/Timeline.tsx @@ -26,7 +26,7 @@ import useResizeObserver from "use-resize-observer"; import { Waveform } from "../util/waveform"; import { convertMsToReadableString } from "../util/utilityFunctions"; -import { KEYMAP, rewriteKeys } from "../globalKeys"; +import { rewriteKeys } from "../globalKeys"; import { useTranslation } from "react-i18next"; import { ActionCreatorWithPayload } from "@reduxjs/toolkit"; @@ -43,6 +43,7 @@ import { moveCut as chapterMoveCut, } from "../redux/chapterSlice"; import TimelineStamps from "./TimelineStamps"; +import { selectKeymap } from "../redux/hotkeySlice"; /** * A container for visualizing the cutting of the video, as well as for controlling @@ -259,6 +260,7 @@ export const Scrubber = React.forwardRef((props, // Init redux variables const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); const isPlaying = useAppSelector(selectIsPlaying); const currentlyAt = useAppSelector(selectCurrentlyAt); const duration = useAppSelector(selectDuration); @@ -339,27 +341,27 @@ export const Scrubber = React.forwardRef((props, // TODO: Better increases and decreases than ten intervals // TODO: Additional helpful controls (e.g. jump to start/end of segment/next segment) useHotkeys( - KEYMAP.timeline.left.key, + keymap.timeline.left.key, () => dispatch(setCurrentlyAt(Math.max(currentlyAt - keyboardJumpDelta, 0))), - KEYMAP.timeline.left.options, + keymap.timeline.left.options, [currentlyAt, keyboardJumpDelta], ); useHotkeys( - KEYMAP.timeline.right.key, + keymap.timeline.right.key, () => dispatch(setCurrentlyAt(Math.min(currentlyAt + keyboardJumpDelta, duration))), - KEYMAP.timeline.right.options, + keymap.timeline.right.options, [currentlyAt, keyboardJumpDelta, duration], ); useHotkeys( - KEYMAP.timeline.increase.key, + keymap.timeline.increase.key, () => setKeyboardJumpDelta(keyboardJumpDelta => Math.min(keyboardJumpDelta * 10, 1000000)), - KEYMAP.timeline.increase.options, + keymap.timeline.increase.options, [keyboardJumpDelta], ); useHotkeys( - KEYMAP.timeline.decrease.key, + keymap.timeline.decrease.key, () => setKeyboardJumpDelta(keyboardJumpDelta => Math.max(keyboardJumpDelta / 10, 1)), - KEYMAP.timeline.decrease.options, + keymap.timeline.decrease.options, [keyboardJumpDelta], ); @@ -427,10 +429,10 @@ export const Scrubber = React.forwardRef((props, currentTime: convertMsToReadableString(currentlyAt), segment: activeSegmentIndex, segmentStatus: (activeSegment && activeSegment.deleted ? "Deleted" : "Alive"), - moveLeft: rewriteKeys(KEYMAP.timeline.left.key), - moveRight: rewriteKeys(KEYMAP.timeline.right.key), - increase: rewriteKeys(KEYMAP.timeline.increase.key), - decrease: rewriteKeys(KEYMAP.timeline.decrease.key), + moveLeft: rewriteKeys(keymap.timeline.left.key), + moveRight: rewriteKeys(keymap.timeline.right.key), + increase: rewriteKeys(keymap.timeline.increase.key), + decrease: rewriteKeys(keymap.timeline.decrease.key), })} tabIndex={0}> diff --git a/src/main/VideoControls.tsx b/src/main/VideoControls.tsx index 4b09b2f67..31f3298b2 100644 --- a/src/main/VideoControls.tsx +++ b/src/main/VideoControls.tsx @@ -13,7 +13,7 @@ import { import { convertMsToReadableString } from "../util/utilityFunctions"; import { BREAKPOINTS, basicButtonStyle, undisplay, undisplayContainer } from "../cssStyles"; -import { KEYMAP, rewriteKeys } from "../globalKeys"; +import { rewriteKeys } from "../globalKeys"; import { useTranslation } from "react-i18next"; import { RootState } from "../redux/store"; @@ -24,6 +24,7 @@ import { Theme, useTheme } from "../themes"; import { useHotkeys } from "react-hotkeys-hook"; import { Slider } from "@mui/material"; import { ProtoButton } from "@opencast/appkit"; +import { selectKeymap } from "../redux/hotkeySlice"; /** * Contains controls for manipulating multiple video players at once @@ -125,6 +126,7 @@ const PreviewMode: React.FC<{ // Init redux variables const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); const isPlayPreview = useAppSelector(selectIsPlayPreview); const theme = useTheme(); @@ -140,9 +142,9 @@ const PreviewMode: React.FC<{ // Maps functions to hotkeys useHotkeys( - KEYMAP.videoPlayer.preview.key, + keymap.videoPlayer.preview.key, () => switchPlayPreview(undefined), - KEYMAP.videoPlayer.preview.options, + keymap.videoPlayer.preview.options, [isPlayPreview], ); @@ -168,13 +170,13 @@ const PreviewMode: React.FC<{ return (
switchPlayPreview(ref)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key === " ") { @@ -207,6 +209,7 @@ const PlayButton: React.FC<{ // Init redux variables const dispatch = useAppDispatch(); + const keymap = useAppSelector(selectKeymap); const isPlaying = useAppSelector(selectIsPlaying); const theme = useTheme(); @@ -217,9 +220,9 @@ const PlayButton: React.FC<{ // Maps functions to hotkeys useHotkeys( - KEYMAP.videoPlayer.play.key, + keymap.videoPlayer.play.key, () => switchIsPlaying(), - KEYMAP.videoPlayer.play.options, + keymap.videoPlayer.play.options, [isPlaying], ); @@ -268,15 +271,16 @@ const PreviousButton: React.FC<{ const dispatch = useAppDispatch(); const theme = useTheme(); + const keymap = useAppSelector(selectKeymap); const jumpToPrevious = () => { dispatch(jumpToPreviousSegment()); }; useHotkeys( - KEYMAP.videoPlayer.previous.key, + keymap.videoPlayer.previous.key, () => jumpToPrevious(), - KEYMAP.videoPlayer.previous.options, + keymap.videoPlayer.previous.options, ); const previousIconStyle = css({ @@ -285,11 +289,11 @@ const PreviousButton: React.FC<{ return (
{ if (event.key === "Enter") { @@ -314,15 +318,16 @@ const NextButton: React.FC<{ const dispatch = useAppDispatch(); const theme = useTheme(); + const keymap = useAppSelector(selectKeymap); const jumpToNext = () => { dispatch(jumpToNextSegment()); }; useHotkeys( - KEYMAP.videoPlayer.next.key, + keymap.videoPlayer.next.key, () => jumpToNext(), - KEYMAP.videoPlayer.next.options, + keymap.videoPlayer.next.options, ); const nextIconStyle = css({ @@ -330,9 +335,9 @@ const NextButton: React.FC<{ }); return ( - + >; + +const hotkeysSlice = createSlice({ + name: "hotkeys", + initialState: {} as HotkeyOverrides, + + reducers: { + setHotkey: (state, action: PayloadAction<{ group: string; action: string; key: string }>) => { + const { group, action: actionName, key } = action.payload; + + state[group] ??= {}; + state[group][actionName] = key; + }, + resetHotkey: (state, action: PayloadAction<{ group: string; action: string }>) => { + delete state[action.payload.group]?.[action.payload.action]; + }, + resetAllHotkeys: () => ({}), + }, +}); + +export const selectKeymap = createSelector( + [(state: RootState) => state.hotkeyState], + (overrides): IKeyMap => { + const merged = structuredClone(DEFAULT_KEYMAP); + + for (const group in overrides) { + for (const action in overrides[group]) { + merged[group][action].key = overrides[group][action]; + } + } + + return merged; + }, +); + + +export const { setHotkey, resetHotkey, resetAllHotkeys } = hotkeysSlice.actions; + +export default hotkeysSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index cbaf9c987..d9b980eb9 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,4 +1,4 @@ -import { configureStore } from "@reduxjs/toolkit"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; import mainMenuStateReducer from "./mainMenuSlice"; import finishStateReducer from "./finishSlice"; import videoReducer from "./videoSlice"; @@ -8,22 +8,43 @@ import metadataReducer from "./metadataSlice"; import subtitleReducer from "./subtitleSlice"; import chapterReducer from "./chapterSlice"; import thumbnailReducer from "./thumbnailSlice"; +import hotkeyReducer from "./hotkeySlice"; import errorReducer from "./errorSlice"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; +import storage from "redux-persist/lib/storage"; +import { FLUSH, PAUSE, PERSIST, persistReducer, PURGE, REGISTER, REHYDRATE } from "redux-persist"; + +const reducers = combineReducers({ + mainMenuState: mainMenuStateReducer, + finishState: finishStateReducer, + videoState: videoReducer, + workflowPostState: workflowPostReducer, + endState: endReducer, + metadataState: metadataReducer, + subtitleState: subtitleReducer, + chapterState: chapterReducer, + thumbnailState: thumbnailReducer, + hotkeyState: hotkeyReducer, + errorState: errorReducer, +}); + +const persistConfig = { + key: "root", + storage, + whitelist: ["hotkeyState"], // persist hotkeys +}; + +const persistedReducer = persistReducer(persistConfig, reducers); export const store = configureStore({ - reducer: { - mainMenuState: mainMenuStateReducer, - finishState: finishStateReducer, - videoState: videoReducer, - workflowPostState: workflowPostReducer, - endState: endReducer, - metadataState: metadataReducer, - subtitleState: subtitleReducer, - chapterState: chapterReducer, - thumbnailState: thumbnailReducer, - errorState: errorReducer, - }, + reducer: persistedReducer, + + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), }); export type AppDispatch = typeof store.dispatch; From ae7afc5145d376d7fb5b16b6617e8edb86605220 Mon Sep 17 00:00:00 2001 From: Arnei Date: Wed, 15 Apr 2026 14:47:55 +0200 Subject: [PATCH 2/8] Fix key recorder assuming QWERTY keyboard With this changed the key recorder or hotkeys should listen for the produced key, better supporting alternative keyboards. --- src/main/KeyboardControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index 284a4af5d..930335dc1 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -172,7 +172,7 @@ const KeyboardControls: React.FC = () => { const theme = useTheme(); const keymap = useAppSelector(selectKeymap); - const [keys, { start, stop, resetKeys }] = useRecordHotkeys(); + const [keys, { start, stop, resetKeys }] = useRecordHotkeys(true); const modalRef = React.useRef(null); const [editGroup, setEditGroup] = React.useState(""); const [editAction, setEditAction] = React.useState(""); From f6243dafc78971aa080584ec5915a0630b27ca8a Mon Sep 17 00:00:00 2001 From: Arnei Date: Wed, 15 Apr 2026 16:13:36 +0200 Subject: [PATCH 3/8] Properly handle unsetting hotkeys Setting a hotkey to nothing was possibly before, but felt very unintentional as the user had to save an empty hotkey sequence. This prevents users from saving empty hotkeys sequences, and instead gives them an explicit button for unsetting. --- src/i18n/locales/en-US.json | 7 ++ src/main/KeyboardControls.tsx | 174 ++++++++++++++++++++++++++++------ 2 files changed, 151 insertions(+), 30 deletions(-) diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 163f0f852..073f5bf44 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -351,12 +351,19 @@ "scrubberRight": "Move right", "scrubberIncrease": "Move faster", "scrubberDecrease": "Move slower", + "keyUndefined": "Undefined", "changeModal": { "title": "Change hotkey: {{name}}", "info": "Record a new hotkey sequence by pressing the keys you want.", "recordedKeys": "Recorded keys: ", "save": "Save", "discard": "Discard" + }, + "deleteModal": { + "title": "Delete hotkey: {{name}}", + "info": "Unsets the hotkey, rendering it unusable.", + "confirm": "Confirm", + "cancel": "Cancel" } }, diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index 930335dc1..36725ed02 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -4,18 +4,19 @@ import { ParseKeys } from "i18next"; import { useTranslation, Trans } from "react-i18next"; import { getGroupName, IKey, IKeyGroup, rewriteKeys } from "../globalKeys"; import { Theme, useTheme } from "../themes"; -import { basicButtonStyle, titleStyle, titleStyleBold } from "../cssStyles"; +import { basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold } from "../cssStyles"; import { useRecordHotkeys } from "react-hotkeys-hook"; import { useAppDispatch, useAppSelector } from "../redux/store"; import { selectKeymap, setHotkey } from "../redux/hotkeySlice"; import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit"; -import { LuPen } from "react-icons/lu"; +import { LuPen, LuTrash } from "react-icons/lu"; const Group: React.FC<{ id: string entries: IKeyGroup openEditModal: (group: string, action: string, actionTitle: string) => void -}> = ({ id, entries, openEditModal }) => { + openDeleteModal: (group: string, action: string, actionTitle: string) => void +}> = ({ id, entries, openEditModal, openDeleteModal }) => { const { t } = useTranslation(); const theme = useTheme(); @@ -47,6 +48,7 @@ const Group: React.FC<{ entry={value} groupId={id} openEditModal={openEditModal} + openDeleteModal={openDeleteModal} />, )}
@@ -58,7 +60,8 @@ const Entry: React.FC<{ entry: IKey groupId: string openEditModal: (group: string, action: string, actionTitle: string) => void -}> = ({ id, entry, groupId, openEditModal }) => { + openDeleteModal: (group: string, action: string, actionTitle: string) => void +}> = ({ id, entry, groupId, openEditModal, openDeleteModal }) => { const { t } = useTranslation(); const theme = useTheme(); @@ -136,31 +139,43 @@ const Entry: React.FC<{
{entry.name || t("keyboardControls.missingLabel")}
-
- {formatEntry(entry).map((sequence, index, arr) => ( -
- {sequence.map((singleKey, index) => ( -
-
- {singleKey} + {entry.key === "" ? +
{t("keyboardControls.keyUndefined")}
+ : +
+ {formatEntry(entry).map((sequence, index, arr) => ( +
+ {sequence.map((singleKey, index) => ( +
+
+ {singleKey} +
+ {sequence.length - 1 !== index && +
+
+ }
- {sequence.length - 1 !== index && -
+
- } -
- ))} -
- {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} -
-
- ))} + ))} +
+ {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} +
+
+ ))} +
+ } +
+ openEditModal(groupId, id, t(entry.name as ParseKeys))} + > + + + openDeleteModal(groupId, id, t(entry.name as ParseKeys))} + > + +
- openEditModal(groupId, id, t(entry.name as ParseKeys))} - > - -
); @@ -174,6 +189,7 @@ const KeyboardControls: React.FC = () => { const [keys, { start, stop, resetKeys }] = useRecordHotkeys(true); const modalRef = React.useRef(null); + const modalRefDelete = React.useRef(null); const [editGroup, setEditGroup] = React.useState(""); const [editAction, setEditAction] = React.useState(""); const [editActionTitle, setEditActionTitle] = React.useState(""); @@ -191,6 +207,16 @@ const KeyboardControls: React.FC = () => { } }; + const openDeleteModal = (group: string, action: string, actionTitle: string) => { + setEditGroup(group); + setEditAction(action); + setEditActionTitle(actionTitle); + + if (modalRefDelete.current) { + modalRefDelete.current?.open(); + } + }; + const groupsStyle = css({ display: "flex", flexDirection: "row", @@ -209,6 +235,7 @@ const KeyboardControls: React.FC = () => { id={groupId} entries={group} openEditModal={openEditModal} + openDeleteModal={openDeleteModal} />); }); @@ -245,6 +272,13 @@ const KeyboardControls: React.FC = () => { actionTitle={editActionTitle} /> + + {render()}
); @@ -318,22 +352,102 @@ const ChangeHotkeyModal: React.FC<{
+ {t("keyboardControls.changeModal.discard")} + + {t("keyboardControls.changeModal.save")} +
+
+ + ); +}; + +const DeleteHotkeyModal: React.FC<{ + modalRef: React.RefObject, + group: string, + action: string, + actionTitle: string, +}> = ({ + modalRef, + group, + action, + actionTitle, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const theme = useTheme(); + + const unsetHotkey = () => { + dispatch(setHotkey({ + group: group, + action: action, + key: "", + })); + + if (modalRef.current?.close) { + modalRef.current.close(); + } + }; + + const modalContentStyle = css({ + display: "flex", + flexDirection: "column", + alignItems: "center", + width: "100%", + }); + + const buttonsStyle = css({ + display: "flex", + flexDirection: "row", + width: "100%", + justifyContent: "space-around", + }); + + const buttonStyle = (theme: Theme) => css({ + fontSize: "16px", + padding: "12px 16px", + justifyContent: "space-around", + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + }); + + return ( + +
+

{t("keyboardControls.deleteModal.info")}

+
+
- {t("keyboardControls.changeModal.discard")} + {t("keyboardControls.deleteModal.cancel")} + + + {t("keyboardControls.deleteModal.confirm")}
); - }; + export default KeyboardControls; From 6ea96000eece6a636168650661f8cdf507326300 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 16 Apr 2026 13:36:31 +0200 Subject: [PATCH 4/8] Prevent duplicate hotkey sequences Do not allow a user to set a hotkey if that hotkey already exists. Disables in the save button in such a case and shows a warning message. --- src/i18n/locales/en-US.json | 1 + src/main/KeyboardControls.tsx | 51 ++++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 073f5bf44..25100889b 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -352,6 +352,7 @@ "scrubberIncrease": "Move faster", "scrubberDecrease": "Move slower", "keyUndefined": "Undefined", + "alreadyInUse": "Key sequence is already in use. Please choose a different sequence.", "changeModal": { "title": "Change hotkey: {{name}}", "info": "Record a new hotkey sequence by pressing the keys you want.", diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index 36725ed02..9c53e6de6 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -2,7 +2,7 @@ import React from "react"; import { css } from "@emotion/react"; import { ParseKeys } from "i18next"; import { useTranslation, Trans } from "react-i18next"; -import { getGroupName, IKey, IKeyGroup, rewriteKeys } from "../globalKeys"; +import { getGroupName, IKey, IKeyGroup, IKeyMap, rewriteKeys } from "../globalKeys"; import { Theme, useTheme } from "../themes"; import { basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold } from "../cssStyles"; import { useRecordHotkeys } from "react-hotkeys-hook"; @@ -303,6 +303,8 @@ const ChangeHotkeyModal: React.FC<{ const { t } = useTranslation(); const theme = useTheme(); + const keymap = useAppSelector(selectKeymap); + const setNewKeys = () => { stop(); @@ -339,6 +341,9 @@ const ChangeHotkeyModal: React.FC<{ background: `${theme.element_bg}`, }); + const keyIsAlreadyPresent = isKeyInKeymap(keymap, Array.from(keys)); + const disabled = keys.size === 0 || keyIsAlreadyPresent; + return ( {t("keyboardControls.changeModal.info")}

{t("keyboardControls.changeModal.recordedKeys")}

{Array.from(keys).join(" + ")}

+ {keyIsAlreadyPresent ?

{t("keyboardControls.alreadyInUse")}

: null}
{t("keyboardControls.changeModal.save")} @@ -449,5 +455,44 @@ const DeleteHotkeyModal: React.FC<{ ); }; +function isKeyInKeymap( + keymap: IKeyMap, + targetKeys: string[], +): boolean { + if (!targetKeys || targetKeys.length === 0) { + return false; + } + + for (const group of Object.values(keymap)) { + for (const action of Object.values(group)) { + const sequenceSeparator = ","; + const sequences = action.key + .split(sequenceSeparator) + .map(k => k.trim()); + + const targetKeysTransformed = targetKeys + .map(v => v.toLowerCase()) + .sort(); + + for (const sequence of sequences) { + const keySeparator = action.splitKey ?? "+"; + + const keys = sequence + .split(keySeparator) + .map(k => k.trim()) + .map(k => k.toLowerCase()); + + const setKeys = new Set(keys); + const setTargetKeys = new Set(targetKeysTransformed); + + if (setKeys.symmetricDifference(setTargetKeys).size === 0) { + return true; + } + } + } + } + return false; +} + export default KeyboardControls; From 263aeaeee093c29848586a01ed1c0109f751620e Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 16 Apr 2026 13:54:34 +0200 Subject: [PATCH 5/8] Add a reset button To allow user to reset a hotkey to its default sequence(s). --- src/main/KeyboardControls.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index 9c53e6de6..8adf5afc9 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -7,9 +7,9 @@ import { Theme, useTheme } from "../themes"; import { basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold } from "../cssStyles"; import { useRecordHotkeys } from "react-hotkeys-hook"; import { useAppDispatch, useAppSelector } from "../redux/store"; -import { selectKeymap, setHotkey } from "../redux/hotkeySlice"; +import { resetHotkey, selectKeymap, setHotkey } from "../redux/hotkeySlice"; import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit"; -import { LuPen, LuTrash } from "react-icons/lu"; +import { LuPen, LuRotateCcw, LuTrash } from "react-icons/lu"; const Group: React.FC<{ id: string @@ -65,6 +65,7 @@ const Entry: React.FC<{ const { t } = useTranslation(); const theme = useTheme(); + const dispatch = useAppDispatch(); const formatEntry = (entry: IKey) => { let formattedSequences: string[][] = []; @@ -132,7 +133,7 @@ const Entry: React.FC<{ }); const editButtonStyle = css({ - padding: "16px", + padding: "12px", }); return ( @@ -175,6 +176,12 @@ const Entry: React.FC<{ > + dispatch(resetHotkey({ group: groupId, action: id }))} + > + +
From 151b1253cab8be11fad65ce26626adc58f95b582 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 16 Apr 2026 13:59:05 +0200 Subject: [PATCH 6/8] Prevent users from using "+" for hotkeys We use "+" for delimiting purposes, so it would be problematic if user included that in their hotkeys sequence. --- src/i18n/locales/en-US.json | 1 + src/main/KeyboardControls.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 25100889b..d5eb0f0c8 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -353,6 +353,7 @@ "scrubberDecrease": "Move slower", "keyUndefined": "Undefined", "alreadyInUse": "Key sequence is already in use. Please choose a different sequence.", + "containsPlus": "The '+' character is not allowed. Please choose a different sequence", "changeModal": { "title": "Change hotkey: {{name}}", "info": "Record a new hotkey sequence by pressing the keys you want.", diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index 8adf5afc9..e4292666d 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -348,8 +348,9 @@ const ChangeHotkeyModal: React.FC<{ background: `${theme.element_bg}`, }); + const containsPlus = keys.has("+"); const keyIsAlreadyPresent = isKeyInKeymap(keymap, Array.from(keys)); - const disabled = keys.size === 0 || keyIsAlreadyPresent; + const disabled = keys.size === 0 || keyIsAlreadyPresent || containsPlus; return ( {t("keyboardControls.changeModal.recordedKeys")}

{Array.from(keys).join(" + ")}

{keyIsAlreadyPresent ?

{t("keyboardControls.alreadyInUse")}

: null} + {containsPlus ?

{t("keyboardControls.containsPlus")}

: null}
Date: Mon, 20 Apr 2026 13:24:45 +0200 Subject: [PATCH 7/8] Revert ae7afc5145d376d7fb5b16b6617e8edb86605220 Because we need the actual key for proper rendering. --- src/main/KeyboardControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index e4292666d..eca607366 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -194,7 +194,7 @@ const KeyboardControls: React.FC = () => { const theme = useTheme(); const keymap = useAppSelector(selectKeymap); - const [keys, { start, stop, resetKeys }] = useRecordHotkeys(true); + const [keys, { start, stop, resetKeys }] = useRecordHotkeys(); const modalRef = React.useRef(null); const modalRefDelete = React.useRef(null); const [editGroup, setEditGroup] = React.useState(""); From a457babe08e1c0ddcd7b104f379e0a38f963979a Mon Sep 17 00:00:00 2001 From: Arnei Date: Mon, 20 Apr 2026 13:36:35 +0200 Subject: [PATCH 8/8] Limit max amount of keys for sequence to 3 --- src/i18n/locales/en-US.json | 2 +- src/main/KeyboardControls.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index d5eb0f0c8..d0db1949f 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -356,7 +356,7 @@ "containsPlus": "The '+' character is not allowed. Please choose a different sequence", "changeModal": { "title": "Change hotkey: {{name}}", - "info": "Record a new hotkey sequence by pressing the keys you want.", + "info": "Record a new hotkey sequence by pressing the keys you want. Maximum are 3 keys.", "recordedKeys": "Recorded keys: ", "save": "Save", "discard": "Discard" diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index eca607366..1b72efbda 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -312,6 +312,11 @@ const ChangeHotkeyModal: React.FC<{ const keymap = useAppSelector(selectKeymap); + // Hard limit maximum amount of keys + if (keys.size >= 3) { + stop(); + } + const setNewKeys = () => { stop();