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',