From 2511045425a9f59bfba944d5a9adefccaf7a6cd7 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Tue, 4 Nov 2025 23:41:02 +0800 Subject: [PATCH] shift click/drag segment to move/resize (configurable modifier key) closes #517 #2595 #483 --- src/main/configStore.ts | 1 + src/renderer/src/App.tsx | 3 +- src/renderer/src/NoFileLoaded.tsx | 8 +-- src/renderer/src/Timeline.tsx | 57 ++++++++++++++++--- .../src/components/KeyboardShortcuts.tsx | 13 ++++- src/renderer/src/components/Settings.tsx | 11 ++-- src/renderer/src/hooks/useSegments.tsx | 20 +++++-- src/renderer/src/hooks/useUserSettingsRoot.ts | 4 ++ types.ts | 1 + 9 files changed, 94 insertions(+), 24 deletions(-) diff --git a/src/main/configStore.ts b/src/main/configStore.ts index 7f305d4a..884d1e33 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -145,6 +145,7 @@ const defaults: Config = { mouseWheelZoomModifierKey: 'ctrl', mouseWheelFrameSeekModifierKey: 'alt', mouseWheelKeyframeSeekModifierKey: 'shift', + segmentMouseModifierKey: 'shift', captureFrameMethod: 'videotag', // we don't default to ffmpeg because ffmpeg might choose a frame slightly off captureFrameQuality: 0.95, captureFrameFileNameFormat: 'timestamp', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 24f769ed..fbfc916d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2624,6 +2624,7 @@ function App() { cutSegments={cutSegments} setCurrentSegIndex={setCurrentSegIndex} currentSegIndexSafe={currentSegIndexSafe} + currentCutSeg={currentCutSeg} inverseCutSegments={inverseCutSegments} formatTimecode={formatTimecode} formatTimeAndFrames={formatTimeAndFrames} @@ -2636,6 +2637,7 @@ function App() { onWheel={onTimelineWheel} goToTimecode={goToTimecode} darkMode={darkMode} + setCutTime={setCutTime} /> diff --git a/src/renderer/src/NoFileLoaded.tsx b/src/renderer/src/NoFileLoaded.tsx index 822c106d..171c1024 100644 --- a/src/renderer/src/NoFileLoaded.tsx +++ b/src/renderer/src/NoFileLoaded.tsx @@ -1,6 +1,6 @@ import { Fragment, memo, useMemo, useState } from 'react'; import { motion, MotionStyle } from 'framer-motion'; - +import { FaMouse } from 'react-icons/fa'; import { useTranslation, Trans } from 'react-i18next'; import SetCutpointButton from './components/SetCutpointButton'; @@ -48,7 +48,7 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy keyBindingByAction: Record, }) { const { t } = useTranslation(); - const { simpleMode } = useUserSettings(); + const { simpleMode, segmentMouseModifierKey } = useUserSettings(); const [dragging, setDragging] = useState(false); const currentCutSegOrDefault = useMemo(() => currentCutSeg ?? { segColorIndex: 0 }, [currentCutSeg]); @@ -63,14 +63,14 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy role="button" onClick={onClick} > -
{t('DROP FILE(S)')}
+
{t('DROP FILE(S)')}
See Help menu for help
- or to set cutpoints + , or {segmentMouseModifierKey}+ to set cutpoints
e.stopPropagation()}> diff --git a/src/renderer/src/Timeline.tsx b/src/renderer/src/Timeline.tsx index 542a6fa9..51622742 100644 --- a/src/renderer/src/Timeline.tsx +++ b/src/renderer/src/Timeline.tsx @@ -17,6 +17,8 @@ import { timelineBackground, darkModeTransition } from './colors'; import { Frame } from './ffmpeg'; import { FormatTimecode, InverseCutSegment, OverviewWaveform, RenderableWaveform, WaveformSlice, StateSegment, Thumbnail } from './types'; import Button from './components/Button'; +import { UseSegments } from './hooks/useSegments'; +import { keyMap } from './hooks/useTimelineScroll'; type CalculateTimelinePercent = (time: number) => string | undefined; @@ -95,6 +97,7 @@ function Timeline({ cutSegments, setCurrentSegIndex, currentSegIndexSafe, + currentCutSeg, inverseCutSegments, formatTimecode, formatTimeAndFrames, @@ -116,6 +119,7 @@ function Timeline({ commandedTimeRef, goToTimecode, darkMode, + setCutTime, } : { fileDurationNonZero: number, startTimeOffset: number, @@ -128,6 +132,7 @@ function Timeline({ cutSegments: StateSegment[], setCurrentSegIndex: (a: number) => void, currentSegIndexSafe: number, + currentCutSeg: StateSegment | undefined, inverseCutSegments: InverseCutSegment[], formatTimecode: FormatTimecode, formatTimeAndFrames: (a: number) => string, @@ -149,10 +154,11 @@ function Timeline({ commandedTimeRef: MutableRefObject, goToTimecode: () => void, darkMode: boolean, + setCutTime: UseSegments['setCutTime']; }) { const { t } = useTranslation(); - const { invertCutSegments, springAnimation } = useUserSettings(); + const { invertCutSegments, springAnimation, segmentMouseModifierKey } = useUserSettings(); const timelineScrollerRef = useRef(null); const timelineScrollerSkipEventRef = useRef(false); @@ -282,26 +288,63 @@ function Timeline({ const mouseDownRef = useRef(); - const handleScrub = useCallback((e: MouseEvent) => seekAbs((getMouseTimelinePos(e))), [seekAbs, getMouseTimelinePos]); - useEffect(() => { setHoveringTime(undefined); }, [relevantTime]); + // for performance + const currentCutSegRef = useRef(currentCutSeg); + useEffect(() => { + currentCutSegRef.current = currentCutSeg; + }, [currentCutSeg]); + + const resizingSegmentRef = useRef<{ operation: 'start' | 'end' | 'move', offset?: number } | undefined>(); + const onMouseDown = useCallback>((e) => { if (e.nativeEvent.buttons !== 1) return; // not primary button - handleScrub(e.nativeEvent); + const mouseTimelinePos = getMouseTimelinePos(e.nativeEvent); + seekAbs(mouseTimelinePos); + + // eslint-disable-next-line no-shadow + const currentCutSeg = currentCutSegRef.current; + + // start/end handles 1.5% of visible timeline + const threshold = ((0.01 / 2) * fileDurationNonZero) / zoom; + + if (currentCutSeg != null && currentCutSeg.selected && e[keyMap[segmentMouseModifierKey]]) { + if (Math.abs(mouseTimelinePos - currentCutSeg.start) < threshold) { + resizingSegmentRef.current = { operation: currentCutSeg.end == null ? 'move' : 'start' }; // move marker or resize segment + } else if (currentCutSeg.end != null && Math.abs(mouseTimelinePos - currentCutSeg.end) < threshold) { + resizingSegmentRef.current = { operation: 'end' }; + } else if (currentCutSeg.end != null && mouseTimelinePos >= currentCutSeg.start && mouseTimelinePos <= currentCutSeg.end) { + resizingSegmentRef.current = { operation: 'move', offset: mouseTimelinePos - currentCutSeg.start }; + } + } mouseDownRef.current = e.target; function onMouseMove(e2: MouseEvent) { if (mouseDownRef.current == null) return; - seekAbs(getMouseTimelinePos(e2)); + const mouseDragTimelinePos = getMouseTimelinePos(e2); + seekAbs(mouseDragTimelinePos); + try { + // eslint-disable-next-line unicorn/prefer-switch + if (resizingSegmentRef.current?.operation === 'start') { + setCutTime('start', mouseDragTimelinePos); + } else if (resizingSegmentRef?.current?.operation === 'end') { + setCutTime('end', mouseDragTimelinePos); + } else if (resizingSegmentRef?.current?.operation === 'move') { + setCutTime('move', mouseDragTimelinePos - (resizingSegmentRef.current.offset ?? 0)); + } + } catch (err) { + console.warn('Error while resizing segment:', err instanceof Error ? err.message : err); + } } function onMouseUp() { mouseDownRef.current = undefined; + resizingSegmentRef.current = undefined; window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('mousemove', onMouseMove); } @@ -311,7 +354,7 @@ function Timeline({ // https://stackoverflow.com/questions/6073505/what-is-the-difference-between-screenx-y-clientx-y-and-pagex-y window.addEventListener('mouseup', onMouseUp, { once: true }); window.addEventListener('mousemove', onMouseMove); - }, [getMouseTimelinePos, handleScrub, seekAbs]); + }, [fileDurationNonZero, getMouseTimelinePos, seekAbs, segmentMouseModifierKey, setCutTime, zoom]); const timeRef = useRef(null); const timeFadeTimeoutRef = useRef(); @@ -323,7 +366,7 @@ function Timeline({ const isInBounds = rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; const showHide = (show: boolean) => timeRef.current?.style.setProperty('opacity', show ? '0.2' : '1'); if (isInBounds != null) showHide(isInBounds); - console.log('isInBounds', isInBounds); + // console.log('isInBounds', isInBounds); // https://github.com/mifi/lossless-cut/issues/2592#issuecomment-3476211496 if (timeFadeTimeoutRef.current) clearTimeout(timeFadeTimeoutRef.current); diff --git a/src/renderer/src/components/KeyboardShortcuts.tsx b/src/renderer/src/components/KeyboardShortcuts.tsx index a1d4b210..2e9ec310 100644 --- a/src/renderer/src/components/KeyboardShortcuts.tsx +++ b/src/renderer/src/components/KeyboardShortcuts.tsx @@ -169,7 +169,7 @@ const KeyboardShortcuts = memo(({ }) => { const { t } = useTranslation(); - const { mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey } = useUserSettings(); + const { mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, segmentMouseModifierKey } = useUserSettings(); const { actionsMap, extraLinesPerCategory } = useMemo(() => { const playbackCategory = t('Playback'); @@ -749,13 +749,22 @@ const KeyboardShortcuts = memo(({ , ], + [segmentsAndCutpointsCategory]: [ +
+ {t('Manipulate segments on timeline')} +
+ {segmentMouseModifierKey} + + {t('Mouse click and drag')} +
, + ], }; return { extraLinesPerCategory, actionsMap, }; - }, [currentCutSeg, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, mouseWheelZoomModifierKey, t]); + }, [currentCutSeg, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, mouseWheelZoomModifierKey, segmentMouseModifierKey, t]); useEffect(() => { // cleanup invalid bindings, to prevent renamed actions from blocking user to rebind diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index ab2bb726..bfed4568 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -17,6 +17,7 @@ import ButtonRaw, { ButtonProps } from './Button'; import { getModifierKeyNames } from '../hooks/useTimelineScroll'; import { TunerType } from '../types'; import Truncated from './Truncated'; +import { primaryColor } from '../colors'; // eslint-disable-next-line react/jsx-props-no-spreading const Button = ({ style, ...props }: ButtonProps) => ; @@ -56,20 +57,19 @@ function Settings({ onKeyboardShortcutsDialogRequested, askForCleanupChoices, toggleStoreProjectInWorkingDir, - simpleMode, clearOutDir, }: { onTunerRequested: (type: TunerType) => void, onKeyboardShortcutsDialogRequested: () => void, askForCleanupChoices: () => Promise, toggleStoreProjectInWorkingDir: () => Promise, - simpleMode: boolean, clearOutDir: () => Promise, }) { const { t } = useTranslation(); - const [showAdvanced, setShowAdvanced] = useState(!simpleMode); - const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion } = useUserSettings(); + const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, segmentMouseModifierKey, setSegmentMouseModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion, simpleMode } = useUserSettings(); + + const [showAdvanced, setShowAdvanced] = useState(!simpleMode); const onLangChange = useCallback>((e) => { const { value } = e.target; @@ -227,7 +227,7 @@ function Settings({ @@ -380,6 +380,7 @@ function Settings({ + diff --git a/src/renderer/src/hooks/useSegments.tsx b/src/renderer/src/hooks/useSegments.tsx index a4fdfdfd..48914bf0 100644 --- a/src/renderer/src/hooks/useSegments.tsx +++ b/src/renderer/src/hooks/useSegments.tsx @@ -441,21 +441,31 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea safeSetCutSegments(cutSegmentsNew, fileDuration); }, [cutSegments, safeSetCutSegments, fileDuration]); - const setCutTime = useCallback((type: 'start' | 'end', time: number | undefined) => { + const setCutTime = useCallback((type: 'start' | 'end' | 'move', time: number | undefined) => { if (!isDurationValid(fileDuration) || currentCutSeg == null) return; + const clampStart = (start: number) => Math.min(Math.max(start, 0), fileDuration); + const clampEnd = (end?: number | undefined) => (end != null ? Math.min(Math.max(end, 0), fileDuration) : undefined); + if (type === 'start') { invariant(time != null); if (currentCutSeg.end != null && time >= currentCutSeg.end) { - throw new Error('Start time must precede end time'); + throw new UserFacingError(i18n.t('Segment start time must precede end time')); } - updateSegAtIndex(currentSegIndexSafe, { start: Math.min(Math.max(time, 0), fileDuration) }); + updateSegAtIndex(currentSegIndexSafe, { start: clampStart(time) }); } if (type === 'end') { if (time != null && time <= currentCutSeg.start) { - throw new Error('Start time must precede end time'); + throw new UserFacingError(i18n.t('Segment start time must precede end time')); } - updateSegAtIndex(currentSegIndexSafe, { end: time != null ? Math.min(Math.max(time, 0), fileDuration) : undefined }); + updateSegAtIndex(currentSegIndexSafe, { end: clampEnd(time) }); + } + if (type === 'move') { + invariant(time != null); + updateSegAtIndex(currentSegIndexSafe, { + start: clampStart(time), + ...(currentCutSeg.end != null && { end: clampEnd(time + (currentCutSeg.end - currentCutSeg.start)) }), + }); } }, [currentSegIndexSafe, currentCutSeg, fileDuration, updateSegAtIndex]); diff --git a/src/renderer/src/hooks/useUserSettingsRoot.ts b/src/renderer/src/hooks/useUserSettingsRoot.ts index 8501fb75..bc98ea9f 100644 --- a/src/renderer/src/hooks/useUserSettingsRoot.ts +++ b/src/renderer/src/hooks/useUserSettingsRoot.ts @@ -153,6 +153,8 @@ export default function useUserSettingsRoot() { useEffect(() => safeSetConfig({ mouseWheelFrameSeekModifierKey }), [mouseWheelFrameSeekModifierKey]); const [mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey] = useState(safeGetConfigInitial('mouseWheelKeyframeSeekModifierKey')); useEffect(() => safeSetConfig({ mouseWheelKeyframeSeekModifierKey }), [mouseWheelKeyframeSeekModifierKey]); + const [segmentMouseModifierKey, setSegmentMouseModifierKey] = useState(safeGetConfigInitial('segmentMouseModifierKey')); + useEffect(() => safeSetConfig({ segmentMouseModifierKey }), [segmentMouseModifierKey]); const [captureFrameMethod, setCaptureFrameMethod] = useState(safeGetConfigInitial('captureFrameMethod')); useEffect(() => safeSetConfig({ captureFrameMethod }), [captureFrameMethod]); const [captureFrameQuality, setCaptureFrameQuality] = useState(safeGetConfigInitial('captureFrameQuality')); @@ -269,6 +271,7 @@ export default function useUserSettingsRoot() { mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, + segmentMouseModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, @@ -343,6 +346,7 @@ export default function useUserSettingsRoot() { setMouseWheelZoomModifierKey, setMouseWheelFrameSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, + setSegmentMouseModifierKey, setCaptureFrameMethod, setCaptureFrameQuality, setCaptureFrameFileNameFormat, diff --git a/types.ts b/types.ts index 55040726..a8af856c 100644 --- a/types.ts +++ b/types.ts @@ -106,6 +106,7 @@ export interface Config { mouseWheelZoomModifierKey: ModifierKey, mouseWheelFrameSeekModifierKey: ModifierKey, mouseWheelKeyframeSeekModifierKey: ModifierKey, + segmentMouseModifierKey: ModifierKey, captureFrameMethod: 'videotag' | 'ffmpeg', captureFrameQuality: number, captureFrameFileNameFormat: 'timestamp' | 'index',