diff --git a/src/globalKeys.ts b/src/globalKeys.ts index 661b909cc..720245df5 100644 --- a/src/globalKeys.ts +++ b/src/globalKeys.ts @@ -8,6 +8,7 @@ * If you add a new keyMap, be sure to add it to the getAllHotkeys function */ import { match } from "@opencast/appkit"; +import { isString } from "lodash"; import { ParseKeys } from "i18next"; import { isMacOs } from "react-device-detect"; @@ -20,13 +21,10 @@ const groupSubtitleList = "keyboardControls.groupSubtitleList"; /** * Helper function that rewrites keys based on the OS */ -export const rewriteKeys = (key: string) => { - let newKey = key; - if (isMacOs) { - newKey = newKey.replace("Alt", "Option"); - } +export const rewriteKeys = (key: string | IKey) => { + const newKey = isString(key) ? key : key.key.replaceAll(key.combinationKey, "+"); - return newKey; + return isMacOs ? newKey.replace("Alt", "Option") : newKey; }; export const getGroupName = (groupName: string): ParseKeys => { @@ -49,6 +47,7 @@ export interface IKeyGroup { export interface IKey { name: string; key: string; + combinationKey?: string; } export const KEYMAP: IKeyMap = { @@ -87,6 +86,15 @@ export const KEYMAP: IKeyMap = { name: "cuttingActions.mergeRight-button", key: "Shift+Alt+m", }, + zoomIn: { + name: "cuttingActions.zoomIn", + key: "Shift;Alt;z, +", + combinationKey: ";", + }, + zoomOut: { + name: "cuttingActions.zoomOut", + key: "Shift+Alt+t, -", + }, }, timeline: { left: { diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index b775856ac..c74eeb1d0 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -26,7 +26,12 @@ "mergeLeft-tooltip-aria": "Merge Left. Combine the currently active segment with the segment to its left. Hotkey: {{hotkeyName}}.", "mergeRight-button": "Merge Right", "mergeRight-tooltip": "Combine the currently active segment with the segment to its right. Hotkey: {{hotkeyName}}", - "mergeRight-tooltip-aria": "Merge Right. Combine the currently active segment with the segment to its right. Hotkey: {{hotkeyName}}." + "mergeRight-tooltip-aria": "Merge Right. Combine the currently active segment with the segment to its right. Hotkey: {{hotkeyName}}.", + "zoom": "Zoom", + "zoomSlider-aria": "Zoom. Zoom in or out of the timeline. Hotkey for Zoom in: {{hotkeyNameIn}}. Hotkey for Zoom out: {{hotkeyNameOut}}.", + "zoomSlider-tooltip": "Zoom in or out of the timeline. Hotkey for Zoom in: {{hotkeyNameIn}}. Hotkey for Zoom out: {{hotkeyNameOut}}.", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out" }, "video": { diff --git a/src/main/CuttingActions.tsx b/src/main/CuttingActions.tsx index 95e7922fe..b2612d70b 100644 --- a/src/main/CuttingActions.tsx +++ b/src/main/CuttingActions.tsx @@ -10,14 +10,25 @@ import { css } from "@emotion/react"; import { useAppDispatch, useAppSelector } from "../redux/store"; import { - cut, markAsDeletedOrAlive, selectIsCurrentSegmentAlive, mergeLeft, mergeRight, mergeAll, + cut, + markAsDeletedOrAlive, + mergeAll, + mergeLeft, + mergeRight, + selectIsCurrentSegmentAlive, + selectTimelineZoom, + selectTimelineZoomMax, + setTimelineZoom, + timelineZoomIn, + timelineZoomOut, } from "../redux/videoSlice"; import { KEYMAP, rewriteKeys } from "../globalKeys"; -import { ActionCreatorWithoutPayload } from "@reduxjs/toolkit"; +import { ActionCreatorWithoutPayload, ActionCreatorWithPayload } from "@reduxjs/toolkit"; import { useTranslation } from "react-i18next"; import { useTheme } from "../themes"; import { ThemedTooltip } from "./Tooltip"; +import { Slider } from "@mui/material"; import { useHotkeys } from "react-hotkeys-hook"; /** @@ -36,8 +47,19 @@ const CuttingActions: React.FC = () => { * @param action redux event to dispatch * @param ref Pass a reference if the clicked element should lose focus */ - const dispatchAction = (action: ActionCreatorWithoutPayload, ref?: React.RefObject) => { - dispatch(action()); + const dispatchAction = ( + action: ActionCreatorWithoutPayload | undefined, + actionWithPayload?: ActionCreatorWithPayload | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload?: any, + ref?: React.RefObject + ) => { + if (action) { + dispatch(action()); + } + if (actionWithPayload) { + dispatch(actionWithPayload(payload)); + } // Lose focus if clicked by mouse if (ref) { @@ -70,6 +92,18 @@ const CuttingActions: React.FC = () => { { preventDefault: true }, [mergeRight] ); + useHotkeys( + KEYMAP.cutting.zoomIn.key, + () => dispatchAction(timelineZoomIn), + { preventDefault: true, combinationKey: KEYMAP.cutting.zoomIn.combinationKey }, + [timelineZoomIn] + ); + useHotkeys( + KEYMAP.cutting.zoomOut.key, + () => dispatchAction(timelineZoomOut, undefined), + { preventDefault: true }, + [timelineZoomOut] + ); const cuttingStyle = css({ display: "flex", @@ -89,6 +123,8 @@ const CuttingActions: React.FC = () => { actionName={t("cuttingActions.cut-button")} actionHandler={dispatchAction} 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) })} /> @@ -101,6 +137,8 @@ const CuttingActions: React.FC = () => { actionName={t("cuttingActions.mergeLeft-button")} actionHandler={dispatchAction} action={mergeLeft} + actionWithPayload={undefined} + payload={undefined} tooltip={t("cuttingActions.mergeLeft-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key) })} ariaLabelText={ t("cuttingActions.mergeLeft-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key) }) @@ -111,6 +149,8 @@ const CuttingActions: React.FC = () => { actionName={t("cuttingActions.mergeRight-button")} actionHandler={dispatchAction} action={mergeRight} + actionWithPayload={undefined} + payload={undefined} tooltip={t("cuttingActions.mergeRight-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key) })} ariaLabelText={ t("cuttingActions.mergeRight-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key) }) @@ -121,9 +161,22 @@ const CuttingActions: React.FC = () => { actionName={t("cuttingActions.merge-all-button")} actionHandler={dispatchAction} action={mergeAll} + actionWithPayload={undefined} + payload={undefined} tooltip={t("cuttingActions.merge-all-tooltip")} ariaLabelText={t("cuttingActions.merge-all-tooltip-aria")} /> +
+ {/* , ref?: React.RefObject) => void, + actionHandler: ( + action: ActionCreatorWithoutPayload, + actionWithPayload: ActionCreatorWithPayload | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + ref?: React.RefObject, + ) => void, action: ActionCreatorWithoutPayload, + actionWithPayload: ActionCreatorWithPayload | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, tooltip: string, ariaLabelText: string, } @@ -163,6 +225,8 @@ const CuttingActionsButton: React.FC = ({ actionName, actionHandler, action, + actionWithPayload, + payload, tooltip, ariaLabelText, }) => { @@ -174,10 +238,10 @@ const CuttingActionsButton: React.FC = ({
actionHandler(action, ref)} + onClick={() => actionHandler(action, actionWithPayload, payload, ref)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key === " " || event.key === "Enter") { - actionHandler(action); + actionHandler(action, actionWithPayload, payload); } }} > @@ -189,7 +253,13 @@ const CuttingActionsButton: React.FC = ({ }; interface markAsDeleteButtonInterface { - actionHandler: (action: ActionCreatorWithoutPayload, ref?: React.RefObject) => void, + actionHandler: ( + action: ActionCreatorWithoutPayload | undefined, + actionWithPayload: ActionCreatorWithPayload | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + ref?: React.RefObject + ) => void, action: ActionCreatorWithoutPayload, hotKeyName: string, } @@ -214,10 +284,10 @@ const MarkAsDeletedButton: React.FC = ({ ref={ref} role="button" tabIndex={0} aria-label={t("cuttingActions.delete-restore-tooltip-aria", { hotkeyName: hotKeyName })} - onClick={() => actionHandler(action, ref)} + onClick={() => actionHandler(action, undefined, undefined, ref)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key === " " || event.key === "Enter") { - actionHandler(action); + actionHandler(action, undefined, undefined); } }} > @@ -228,4 +298,79 @@ const MarkAsDeletedButton: React.FC = ({ ); }; +interface ZoomSliderInterface { + actionHandler: ( + action: ActionCreatorWithoutPayload | undefined, + actionWithPayload: ActionCreatorWithPayload | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + ref?: React.RefObject, + ) => void, + tooltip: string, + ariaLabelText: string, +} + +const ZoomSlider : React.FC = ({ + actionHandler, + tooltip, + ariaLabelText, +}) => { + + const { t } = useTranslation(); + const theme = useTheme(); + const timelineZoom = useAppSelector(selectTimelineZoom); + const timelineZoomMax = useAppSelector(selectTimelineZoomMax); + + // Callback for the zoom slider + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const zoomSliderOnChange = (event: Event, newValue: number | number[]) => { + actionHandler(undefined, setTimelineZoom, newValue, undefined); + }; + + const zoomStyle = css({ + display: "flex", + flexDirection: "row", + paddingLeft: "16px", + paddingRight: "16px", + gap: "15px", + justifyContent: "center", + alignItems: "center", + }); + + + const sliderStyle = css({ + width: "150px", + "& .MuiSlider-thumb": { + color: `${theme.slider_thumb_color}`, + "&:hover, &.Mui-focusVisible, &.Mui-active": { + boxShadow: `${theme.slider_thumb_shadow}`, + }, + }, + "& .MuiSlider-rail": { + color: `${theme.slider_track_color}`, + }, + "& .MuiSlider-track": { + color: `${theme.slider_track_color}`, + }, + }); + + return ( + +
+ {t("cuttingActions.zoom")} + +
+
+ ); +}; + export default CuttingActions; diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index a807ef90d..1857ebdc9 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -131,11 +131,12 @@ const KeyboardControls: React.FC = () => { const entries: { [groupName: string]: string[][]; } = {}; Object.entries(group).forEach(([, action]) => { const sequences = action.key.split(",").map(item => item.trim()); + const sequenceCombinationkey = action.combinationKey ? action.combinationKey : "+"; entries[action.name] = Object.entries(sequences).map(([, sequence]) => { - return sequence.split("+").map(item => rewriteKeys(item.trim())); + return sequence.split(sequenceCombinationkey).map(item => rewriteKeys(item.trim())); }); }); - groups.push(); + groups.push(); }); return ( diff --git a/src/main/SubtitleTimeline.tsx b/src/main/SubtitleTimeline.tsx index c7846a3f5..c3e3e352c 100644 --- a/src/main/SubtitleTimeline.tsx +++ b/src/main/SubtitleTimeline.tsx @@ -125,7 +125,7 @@ const SubtitleTimeline: React.FC = () => { horizontal={true} onEndScroll={onEndScroll} // dom elements with this id in the container will not trigger scrolling when dragged - ignoreElements={"#no-scrolling"} + ignoreElements={".prevent-drag-scroll"} > {/* Container. Overflows. Width based on parent times zoom level*/}
@@ -413,7 +413,7 @@ const TimelineSubtitleSegment: React.FC<{ // Fix most likely requires changes in one of those modules resizeHandles={["w"]} > -
+
{props.cue.text}
diff --git a/src/main/Timeline.tsx b/src/main/Timeline.tsx index 5e22f01e1..5061e2925 100644 --- a/src/main/Timeline.tsx +++ b/src/main/Timeline.tsx @@ -1,3 +1,4 @@ +import { debounce } from "lodash"; import React, { useState, useRef, useEffect } from "react"; import Draggable, { DraggableEventHandler } from "react-draggable"; @@ -7,7 +8,13 @@ import { css } from "@emotion/react"; import { useAppDispatch, useAppSelector } from "../redux/store"; import { Segment, httpRequestState } from "../types"; import { - selectSegments, selectActiveSegmentIndex, selectDuration, selectVideoURL, selectWaveformImages, setWaveformImages, + selectSegments, + selectActiveSegmentIndex, + selectDuration, + selectVideoURL, + selectWaveformImages, + setWaveformImages, + selectTimelineZoom, moveCut, } from "../redux/videoSlice"; @@ -24,6 +31,7 @@ import { ActionCreatorWithPayload } from "@reduxjs/toolkit"; import { RootState } from "../redux/store"; import { useTheme } from "../themes"; import { ThemedTooltip } from "./Tooltip"; +import ScrollContainer from "react-indiana-drag-scroll"; import CuttingActionsContextMenu from "./CuttingActionsContextMenu"; import { useHotkeys } from "react-hotkeys-hook"; import { spinningStyle } from "../cssStyles"; @@ -53,15 +61,50 @@ const Timeline: React.FC<{ }) => { // Init redux variables + const currentlyAt = useAppSelector(selectCurrentlyAt); const dispatch = useAppDispatch(); const duration = useAppSelector(selectDuration); + const timelineZoom = useAppSelector(selectTimelineZoom); const { ref, width = 1 } = useResizeObserver(); + const scrollContainerRef = useRef(null); + const topOffset = 20; + + const currentlyScrolling = useRef(false); + const zoomCenter = useRef(0); + + const updateScroll = () => { + if (currentlyScrolling.current) { + currentlyScrolling.current = false; + return; + } + const scrollLeft = scrollContainerRef.current?.scrollLeft ?? 0; + const clientWidth = scrollContainerRef.current?.clientWidth ?? 0; + const centerPosition = scrollLeft + 0.5 * clientWidth; + const scrubberPosition = duration ? (currentlyAt / duration) * width : 0; + const scrubberVisible = scrollLeft <= scrubberPosition && scrubberPosition <= scrollLeft + clientWidth; + + zoomCenter.current = (scrubberVisible ? scrubberPosition : centerPosition) / width; + }; + + useEffect(updateScroll, [currentlyAt, timelineZoom, width]); + + useEffect(() => { + if (!scrollContainerRef.current) { + return; + } + const clientWidth = scrollContainerRef.current.clientWidth ?? 0; + const left = zoomCenter.current * timelineZoom * clientWidth - 0.5 * clientWidth; + + currentlyScrolling.current = true; + scrollContainerRef.current.scrollLeft = left; + }, [timelineZoom]); const timelineStyle = css({ position: "relative", // Need to set position for Draggable bounds to work height: timelineHeight + "px", - width: "100%", + width: (timelineZoom) * 100 + "%", // Width modified by zoom + top: `${topOffset}px`, }); // Update the current time based on the position clicked on the timeline @@ -73,27 +116,38 @@ const Timeline: React.FC<{ }; return ( - -
setCurrentlyAtToClick(e)}> - -
- - + +
setCurrentlyAtToClick(e)}> + +
+ + +
-
- + + ); }; @@ -147,17 +201,17 @@ export const Scrubber: React.FC<{ // Reposition scrubber when the timeline width changes useEffect(() => { if (currentlyAt && duration) { - setControlledPosition({ x: (currentlyAt / duration) * (timelineWidth), y: 0 }); + updateXPos(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [timelineWidth]); // Callback for when the scrubber gets dragged by the user - const onControlledDrag: DraggableEventHandler = (_e, position) => { + const onControlledDrag: DraggableEventHandler = debounce((_e, position) => { // Update position const { x } = position; dispatch(setCurrentlyAt((x / timelineWidth) * (duration))); - }; + }, 200); // Callback for when the position changes by something other than dragging const updateXPos = () => { @@ -275,7 +329,7 @@ export const Scrubber: React.FC<{ position={controlledPosition} nodeRef={nodeRef} > -
+
) => { state.hasChanges = action.payload; }, + setTimelineZoom: (state, action: PayloadAction) => { + state.timelineZoom = clamp(action.payload, 1, timelineZoomMax(state)); + }, setWaveformImages: (state, action: PayloadAction) => { state.waveformImages = action.payload; }, @@ -282,6 +288,12 @@ const videoSlice = createSlice({ mergeSegments(state, state.activeSegmentIndex, state.segments.length - 1); state.hasChanges = true; }, + timelineZoomIn: state => { + state.timelineZoom = clamp(state.timelineZoom + 1, 1, timelineZoomMax(state)); + }, + timelineZoomOut: state => { + state.timelineZoom = clamp(state.timelineZoom - 1, 1, timelineZoomMax(state)); + }, }, // For Async Requests extraReducers: builder => { @@ -351,6 +363,8 @@ const videoSlice = createSlice({ selectIsCurrentSegmentAlive: state => !state.segments[state.activeSegmentIndex].deleted, selectSelectedWorkflowId: state => state.selectedWorkflowId, selectHasChanges: state => state.hasChanges, + selectTimelineZoom: state => state.timelineZoom, + selectTimelineZoomMax: timelineZoomMax, selectWaveformImages: state => state.waveformImages, selectOriginalThumbnails: state => state.originalThumbnails, // Selectors mainly pertaining to the information fetched from Opencast @@ -489,6 +503,14 @@ const setThumbnailHelper = (state: video, id: Track["id"], uri: Track["thumbnail } }; +const ZOOM_SECONDS_VISIBLE = 20 * 1000; + +function timelineZoomMax(state) { + const maxZoom = state.duration / ZOOM_SECONDS_VISIBLE; + + return Math.max(2, Math.ceil(maxZoom)); +} + export const { setTrackEnabled, setIsPlaying, @@ -514,6 +536,9 @@ export const { mergeAll, setPreviewTriggered, setClickTriggered, + setTimelineZoom, + timelineZoomIn, + timelineZoomOut, setJumpTriggered, jumpToPreviousSegment, jumpToNextSegment, @@ -540,6 +565,8 @@ export const { selectIsCurrentSegmentAlive, selectSelectedWorkflowId, selectHasChanges, + selectTimelineZoom, + selectTimelineZoomMax, selectWaveformImages, selectOriginalThumbnails, selectVideoURL,