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..d0db1949f 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -350,7 +350,23 @@ "scrubberLeft": "Move left", "scrubberRight": "Move right", "scrubberIncrease": "Move faster", - "scrubberDecrease": "Move slower" + "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. Maximum are 3 keys.", + "recordedKeys": "Recorded keys: ", + "save": "Save", + "discard": "Discard" + }, + "deleteModal": { + "title": "Delete hotkey: {{name}}", + "info": "Unsets the hotkey, rendering it unusable.", + "confirm": "Confirm", + "cancel": "Cancel" + } }, "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..1b72efbda 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -1,21 +1,29 @@ +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, IKeyMap, rewriteKeys } from "../globalKeys"; +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 { resetHotkey, selectKeymap, setHotkey } from "../redux/hotkeySlice"; +import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit"; +import { LuPen, LuRotateCcw, LuTrash } 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 + openDeleteModal: (group: string, action: string, actionTitle: string) => void +}> = ({ id, entries, openEditModal, openDeleteModal }) => { const { t } = useTranslation(); const theme = useTheme(); const groupStyle = css({ display: "flex", - flexDirection: "column" as const, + flexDirection: "column", width: "460px", maxWidth: "50vw", @@ -32,18 +40,44 @@ 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 + openDeleteModal: (group: string, action: string, actionTitle: string) => void +}> = ({ id, entry, groupId, openEditModal, openDeleteModal }) => { const { t } = useTranslation(); const theme = useTheme(); + const dispatch = useAppDispatch(); + + 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", @@ -62,6 +96,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 +132,118 @@ const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, seque fontWeight: "bold", }); + const editButtonStyle = css({ + padding: "12px", + }); + return (
-
{name || t("keyboardControls.missingLabel")}
- {sequences.map((sequence, index, arr) => ( -
- {sequence.map((singleKey, index) => ( -
-
- {singleKey} +
{entry.name || t("keyboardControls.missingLabel")}
+
+ {entry.key === "" ? +
{t("keyboardControls.keyUndefined")}
+ : +
+ {formatEntry(entry).map((sequence, index, arr) => ( +
+ {sequence.map((singleKey, index) => ( +
+
+ {singleKey} +
+ {sequence.length - 1 !== index && +
+
+ } +
+ ))} +
+ {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} +
- {sequence.length - 1 !== index && -
+
- } -
- ))} -
- {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} -
+ ))} +
+ } +
+ openEditModal(groupId, id, t(entry.name as ParseKeys))} + > + + + openDeleteModal(groupId, id, t(entry.name as ParseKeys))} + > + + + dispatch(resetHotkey({ group: groupId, action: id }))} + > + +
- ))} +
); }; 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 modalRefDelete = 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 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" 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 +259,7 @@ const KeyboardControls: React.FC = () => { const keyboardControlsStyle = css({ display: "flex", - flexDirection: "column" as const, + flexDirection: "column", alignItems: "center", width: "100%", }); @@ -163,9 +270,243 @@ 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 keymap = useAppSelector(selectKeymap); + + // Hard limit maximum amount of keys + if (keys.size >= 3) { + stop(); + } + + 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}`, + }); + + const containsPlus = keys.has("+"); + const keyIsAlreadyPresent = isKeyInKeymap(keymap, Array.from(keys)); + const disabled = keys.size === 0 || keyIsAlreadyPresent || containsPlus; + + return ( + +
+

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

+

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

+

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

+ {keyIsAlreadyPresent ?

{t("keyboardControls.alreadyInUse")}

: null} + {containsPlus ?

{t("keyboardControls.containsPlus")}

: null} +
+
+ + {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.deleteModal.cancel")} + + + {t("keyboardControls.deleteModal.confirm")} + +
+
+
+ ); +}; + +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; 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;