diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 586e09d2a..6d67c5588 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -519,6 +519,7 @@ interface SettingsPanelProps { selectedZoomMode?: ZoomMode | null; onZoomModeChange?: (mode: ZoomMode) => void; onZoomDelete?: (id: string) => void; + onZoomDuplicate?: () => boolean | void; selectedClipId?: string | null; selectedClipSpeed?: number | null; selectedClipMuted?: boolean | null; @@ -538,6 +539,7 @@ interface SettingsPanelProps { onAudioVolumeChange?: (volume: number) => void; onAudioNormalizeChange?: (normalize: boolean) => void; onAudioDelete?: (id: string) => void; + onAudioDuplicate?: () => boolean | void; shadowIntensity?: number; onShadowChange?: (intensity: number) => void; backgroundBlur?: number; @@ -626,6 +628,7 @@ interface SettingsPanelProps { onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void; onAnnotationBlurColorChange?: (id: string, color: string) => void; onAnnotationDelete?: (id: string) => void; + onAnnotationDuplicate?: () => boolean | void; autoCaptions?: CaptionCue[]; autoCaptionSettings?: AutoCaptionSettings; whisperExecutablePath?: string | null; @@ -979,6 +982,7 @@ export function SettingsPanel({ selectedZoomMode, onZoomModeChange, onZoomDelete, + onZoomDuplicate, selectedClipId, selectedClipSpeed, selectedClipMuted, @@ -998,6 +1002,7 @@ export function SettingsPanel({ onAudioVolumeChange, onAudioNormalizeChange, onAudioDelete, + onAudioDuplicate, shadowIntensity = 0.67, onShadowChange, backgroundBlur = 0, @@ -1074,6 +1079,7 @@ export function SettingsPanel({ onAnnotationBlurIntensityChange, onAnnotationBlurColorChange, onAnnotationDelete, + onAnnotationDuplicate, autoCaptions = [], autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, whisperModelPath, @@ -3788,44 +3794,95 @@ export function SettingsPanel({ )} {activeEffectSection === "zoom" && selectedZoomId && ( - + <> + + + )} {activeEffectSection === "audio" && selectedAudioId && ( - + <> + + + )} {selectedAnnotationId && ( - + <> + + + )} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e3f98e1a1..2bb128be7 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -349,6 +349,7 @@ export default function VideoEditor() { handleShowCursorChange, currentTime, isPlaying, + timelineDurationMs: Math.round(projection.timelineDuration * 1000), aspectRatio, setAspectRatio, whisperExecutablePath, diff --git a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts index e6cd9942c..0c792154e 100644 --- a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts +++ b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts @@ -1,5 +1,6 @@ import type { Span } from "dnd-timeline"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import { placeSpanAfter } from "../timeline/hooks/utils/timelineDuplicateUtils"; import { type AnnotationRegion, DEFAULT_ANNOTATION_POSITION, @@ -86,6 +87,47 @@ export function useAnnotationRegionCommands({ [selectedAnnotationId, setAnnotationRegions, setSelectedAnnotationId], ); + const handleAnnotationDuplicate = useCallback( + (id: string, totalMs: number): boolean => { + let createdId: string | null = null; + setAnnotationRegions((current) => { + const source = current.find((region) => region.id === id); + if (!source) return current; + + const placed = placeSpanAfter(source, totalMs); + if (!placed) return current; + + createdId = `annotation-${nextAnnotationIdRef.current++}`; + return [ + ...current, + { + ...source, + id: createdId, + startMs: placed.startMs, + endMs: placed.endMs, + position: { ...source.position }, + size: { ...source.size }, + style: { ...source.style }, + figureData: source.figureData ? { ...source.figureData } : undefined, + zIndex: nextAnnotationZIndexRef.current++, + }, + ]; + }); + + if (!createdId) return false; + setSelectedAnnotationId(createdId); + setSelectedZoomId(null); + return true; + }, + [ + nextAnnotationIdRef, + nextAnnotationZIndexRef, + setAnnotationRegions, + setSelectedAnnotationId, + setSelectedZoomId, + ], + ); + const handleAnnotationContentChange = useCallback( (id: string, content: string) => { setAnnotationRegions((current) => @@ -167,6 +209,7 @@ export function useAnnotationRegionCommands({ handleAnnotationAdded, handleAnnotationSpanChange, handleAnnotationDelete, + handleAnnotationDuplicate, handleAnnotationContentChange, handleAnnotationTypeChange, handleAnnotationStyleChange, diff --git a/src/components/video-editor/hooks/useAudioRegionCommands.ts b/src/components/video-editor/hooks/useAudioRegionCommands.ts index c02a8f6d9..778eb0f97 100644 --- a/src/components/video-editor/hooks/useAudioRegionCommands.ts +++ b/src/components/video-editor/hooks/useAudioRegionCommands.ts @@ -1,5 +1,6 @@ import type { Span } from "dnd-timeline"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import { placeSpanAfter } from "../timeline/hooks/utils/timelineDuplicateUtils"; import type { AudioRegion, EditorEffectSection } from "../types"; interface UseAudioRegionCommandsParams { @@ -117,6 +118,47 @@ export function useAudioRegionCommands({ [selectedAudioId, setAudioRegions, setSelectedAudioId], ); + const handleAudioDuplicate = useCallback( + (id: string, totalMs: number): boolean => { + let createdId: string | null = null; + setAudioRegions((current) => { + const source = current.find((region) => region.id === id); + if (!source) return current; + + const placed = placeSpanAfter(source, totalMs); + if (!placed) return current; + + createdId = `audio-${nextAudioIdRef.current++}`; + return [ + ...current, + { + ...source, + id: createdId, + startMs: placed.startMs, + endMs: placed.endMs, + }, + ]; + }); + + if (!createdId) return false; + setSelectedAudioId(createdId); + setSelectedZoomId(null); + setSelectedAnnotationId(null); + setSelectedCaptionId(null); + setActiveEffectSection("audio"); + return true; + }, + [ + nextAudioIdRef, + setActiveEffectSection, + setAudioRegions, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedZoomId, + ], + ); + const handleAudioNormalizeChange = useCallback( (normalize: boolean) => { if (!selectedAudioId) return; @@ -135,6 +177,7 @@ export function useAudioRegionCommands({ handleAudioSpanChange, handleAudioVolumeChange, handleAudioDelete, + handleAudioDuplicate, handleAudioNormalizeChange, }; } diff --git a/src/components/video-editor/hooks/useZoomRegionCommands.ts b/src/components/video-editor/hooks/useZoomRegionCommands.ts index 0fadb5785..b93056064 100644 --- a/src/components/video-editor/hooks/useZoomRegionCommands.ts +++ b/src/components/video-editor/hooks/useZoomRegionCommands.ts @@ -1,5 +1,6 @@ import type { Span } from "dnd-timeline"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import { placeSpanAfter } from "../timeline/hooks/utils/timelineDuplicateUtils"; import { clampFocusToDepth, DEFAULT_AUTO_ZOOM_DEPTH, @@ -170,6 +171,48 @@ export function useZoomRegionCommands({ [selectedZoomId, setSelectedZoomId, setZoomRegions], ); + const handleZoomDuplicate = useCallback( + (id: string, totalMs: number): boolean => { + let createdId: string | null = null; + setZoomRegions((current) => { + const source = current.find((region) => region.id === id); + if (!source) return current; + + const placed = placeSpanAfter(source, totalMs); + if (!placed) return current; + + createdId = `zoom-${nextZoomIdRef.current++}`; + return [ + ...current, + { + ...source, + id: createdId, + startMs: placed.startMs, + endMs: placed.endMs, + focus: { ...source.focus }, + }, + ]; + }); + + if (!createdId) return false; + setSelectedZoomId(createdId); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + setSelectedCaptionId(null); + setActiveEffectSection("zoom"); + return true; + }, + [ + nextZoomIdRef, + setActiveEffectSection, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedZoomId, + setZoomRegions, + ], + ); + return { handleSelectZoom, handleZoomAdded, @@ -179,5 +222,6 @@ export function useZoomRegionCommands({ handleZoomDepthChange, handleZoomModeChange, handleZoomDelete, + handleZoomDuplicate, }; } diff --git a/src/components/video-editor/layout/EditorTimelinePanel.tsx b/src/components/video-editor/layout/EditorTimelinePanel.tsx index 78bfca53e..e7b84bd9a 100644 --- a/src/components/video-editor/layout/EditorTimelinePanel.tsx +++ b/src/components/video-editor/layout/EditorTimelinePanel.tsx @@ -75,6 +75,12 @@ export function EditorTimelinePanel(props: Props) { onZoomSuggested={zoomCommands.handleZoomSuggested} onZoomSpanChange={zoomCommands.handleZoomSpanChange} onZoomDelete={zoomCommands.handleZoomDelete} + onZoomDuplicate={(id) => + zoomCommands.handleZoomDuplicate( + id, + Math.round(projection.timelineDuration * 1000), + ) + } selectedZoomId={timeline.selectedZoomId} onSelectZoom={zoomCommands.handleSelectZoom} trimRegions={timeline.trimRegions} @@ -87,6 +93,12 @@ export function EditorTimelinePanel(props: Props) { onAudioAdded={audioCommands.handleAudioAdded} onAudioSpanChange={audioCommands.handleAudioSpanChange} onAudioDelete={audioCommands.handleAudioDelete} + onAudioDuplicate={(id) => + audioCommands.handleAudioDuplicate( + id, + Math.round(projection.timelineDuration * 1000), + ) + } selectedAudioId={timeline.selectedAudioId} onSelectAudio={audioCommands.handleSelectAudio} captionRegions={projection.effectiveCaptionRegions} @@ -106,6 +118,12 @@ export function EditorTimelinePanel(props: Props) { onAnnotationAdded={annotationCommands.handleAnnotationAdded} onAnnotationSpanChange={annotationCommands.handleAnnotationSpanChange} onAnnotationDelete={annotationCommands.handleAnnotationDelete} + onAnnotationDuplicate={(id) => + annotationCommands.handleAnnotationDuplicate( + id, + Math.round(projection.timelineDuration * 1000), + ) + } selectedAnnotationId={timeline.selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} showSourceAudioTrack={timeline.clipRegions.some((clip) => clip.showSourceAudio)} diff --git a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts index fc7427c26..c4465010c 100644 --- a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts +++ b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts @@ -27,6 +27,7 @@ type Input = { handleShowCursorChange: (show: boolean) => void; currentTime: number; isPlaying: boolean; + timelineDurationMs: number; aspectRatio: AspectRatio; setAspectRatio: Dispatch>; whisperExecutablePath: string | null; @@ -56,6 +57,7 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps timeline.selectedZoomId && zoomCommands.handleZoomModeChange(mode), onZoomDelete: zoomCommands.handleZoomDelete, + onZoomDuplicate: timeline.selectedZoomId + ? () => zoomCommands.handleZoomDuplicate(timeline.selectedZoomId!, timelineDurationMs) + : undefined, selectedClipId: timeline.selectedClipId, selectedClipSpeed: selectedClip?.speed ?? (timeline.selectedClipId ? 1 : null), selectedClipMuted: selectedClip?.muted ?? (timeline.selectedClipId ? false : null), @@ -112,6 +117,13 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps + audioCommands.handleAudioDuplicate( + timeline.selectedAudioId!, + timelineDurationMs, + ) + : undefined, shadowIntensity: appearance.shadowIntensity, onShadowChange: appearance.setShadowIntensity, backgroundBlur: appearance.backgroundBlur, @@ -224,5 +236,12 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps + annotationCommands.handleAnnotationDuplicate( + timeline.selectedAnnotationId!, + timelineDurationMs, + ) + : undefined, }; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 46a610f29..dc1ff817c 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -45,6 +45,7 @@ export interface TimelineEditorProps { onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; onZoomSpanChange: (id: string, span: Span) => void; onZoomDelete: (id: string) => void; + onZoomDuplicate?: (id: string) => boolean; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; trimRegions?: TrimRegion[]; @@ -59,6 +60,7 @@ export interface TimelineEditorProps { onAnnotationAdded?: (span: Span, trackIndex?: number) => void; onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onAnnotationDelete?: (id: string) => void; + onAnnotationDuplicate?: (id: string) => boolean; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; speedRegions?: SpeedRegion[]; @@ -67,6 +69,7 @@ export interface TimelineEditorProps { onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void; onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onAudioDelete?: (id: string) => void; + onAudioDuplicate?: (id: string) => boolean; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; captionRegions?: CaptionCue[]; @@ -128,6 +131,7 @@ const TimelineEditor = forwardRef( onZoomSuggested, onZoomSpanChange, onZoomDelete, + onZoomDuplicate, selectedZoomId, onSelectZoom, trimRegions = [], @@ -142,6 +146,7 @@ const TimelineEditor = forwardRef( onAnnotationAdded, onAnnotationSpanChange, onAnnotationDelete, + onAnnotationDuplicate, selectedAnnotationId, onSelectAnnotation, speedRegions = [], @@ -150,6 +155,7 @@ const TimelineEditor = forwardRef( onAudioAdded, onAudioSpanChange, onAudioDelete, + onAudioDuplicate, selectedAudioId, onSelectAudio, captionRegions = [], @@ -367,6 +373,7 @@ const TimelineEditor = forwardRef( onZoomSuggested, onZoomSpanChange, onZoomDelete, + onZoomDuplicate, selectedZoomId, onSelectZoom, trimRegions, @@ -381,6 +388,7 @@ const TimelineEditor = forwardRef( onAnnotationAdded, onAnnotationSpanChange, onAnnotationDelete, + onAnnotationDuplicate, selectedAnnotationId, onSelectAnnotation, speedRegions, @@ -389,6 +397,7 @@ const TimelineEditor = forwardRef( onAudioAdded, onAudioSpanChange, onAudioDelete, + onAudioDuplicate, selectedAudioId, onSelectAudio, captionCues: captionRegions, diff --git a/src/components/video-editor/timeline/core/timelineTypes.ts b/src/components/video-editor/timeline/core/timelineTypes.ts index 881ec39ca..ca8bc90d4 100644 --- a/src/components/video-editor/timeline/core/timelineTypes.ts +++ b/src/components/video-editor/timeline/core/timelineTypes.ts @@ -25,6 +25,7 @@ export interface TimelineShortcutBindings { splitClip: ShortcutBinding; addAnnotation: ShortcutBinding; deleteSelected: ShortcutBinding; + duplicateSelected: ShortcutBinding; } export interface TimelineRenderItem { diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts index 9f7092db0..b80e18da4 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -1,6 +1,7 @@ import type { Span } from "dnd-timeline"; import type { ForwardedRef, RefObject } from "react"; import { useCallback, useImperativeHandle } from "react"; +import { toast } from "sonner"; import type { AnnotationRegion, AudioRegion, @@ -21,6 +22,7 @@ import { useTimelineDndBindings } from "./useTimelineDndBindings"; import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; import { useTimelineNormalization } from "./useTimelineNormalization"; import { useTimelineSelection } from "./useTimelineSelection"; +import { resolveDuplicateSelectionTarget } from "./utils/timelineSelectionUtils"; interface UseTimelineEditorRuntimeParams { ref: ForwardedRef; @@ -37,6 +39,7 @@ interface UseTimelineEditorRuntimeParams { onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; onZoomSpanChange: (id: string, span: Span) => void; onZoomDelete: (id: string) => void; + onZoomDuplicate?: (id: string) => boolean; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; trimRegions: TrimRegion[]; @@ -51,6 +54,7 @@ interface UseTimelineEditorRuntimeParams { onAnnotationAdded?: (span: Span, trackIndex?: number) => void; onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onAnnotationDelete?: (id: string) => void; + onAnnotationDuplicate?: (id: string) => boolean; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; speedRegions: SpeedRegion[]; @@ -59,6 +63,7 @@ interface UseTimelineEditorRuntimeParams { onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void; onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onAudioDelete?: (id: string) => void; + onAudioDuplicate?: (id: string) => boolean; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; captionCues: CaptionCue[]; @@ -87,6 +92,7 @@ export function useTimelineEditorRuntime({ onZoomSuggested, onZoomSpanChange, onZoomDelete, + onZoomDuplicate, selectedZoomId, onSelectZoom, trimRegions, @@ -101,6 +107,7 @@ export function useTimelineEditorRuntime({ onAnnotationAdded, onAnnotationSpanChange, onAnnotationDelete, + onAnnotationDuplicate, selectedAnnotationId, onSelectAnnotation, speedRegions, @@ -109,6 +116,7 @@ export function useTimelineEditorRuntime({ onAudioAdded, onAudioSpanChange, onAudioDelete, + onAudioDuplicate, selectedAudioId, onSelectAudio, captionCues, @@ -260,6 +268,36 @@ export function useTimelineEditorRuntime({ [videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded], ); + const handleDuplicateSelected = useCallback(() => { + const target = resolveDuplicateSelectionTarget({ + selectedZoomId, + selectedAnnotationId, + selectedAudioId, + }); + + let ok = false; + if (target === "zoom" && selectedZoomId && onZoomDuplicate) { + ok = onZoomDuplicate(selectedZoomId); + } else if (target === "annotation" && selectedAnnotationId && onAnnotationDuplicate) { + ok = onAnnotationDuplicate(selectedAnnotationId); + } else if (target === "audio" && selectedAudioId && onAudioDuplicate) { + ok = onAudioDuplicate(selectedAudioId); + } else { + return; + } + + if (!ok) { + toast.error("Not enough space to duplicate after the selected item"); + } + }, [ + onAnnotationDuplicate, + onAudioDuplicate, + onZoomDuplicate, + selectedAnnotationId, + selectedAudioId, + selectedZoomId, + ]); + useTimelineKeyboardShortcuts({ isMac, keyShortcuts, @@ -278,6 +316,7 @@ export function useTimelineEditorRuntime({ handleAddZoom, handleSplitClip, handleAddAnnotation: () => handleAddAnnotation(), + handleDuplicateSelected, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedClip, diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts index 94cb39735..2a22c038c 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -21,6 +21,7 @@ interface UseTimelineKeyboardShortcutsParams { handleAddZoom: () => void; handleSplitClip: () => void; handleAddAnnotation: () => void; + handleDuplicateSelected: () => void; deleteSelectedKeyframe: () => void; deleteSelectedZoom: () => void; deleteSelectedClip: () => void; @@ -48,6 +49,7 @@ export function useTimelineKeyboardShortcuts({ handleAddZoom, handleSplitClip, handleAddAnnotation, + handleDuplicateSelected, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedClip, @@ -87,6 +89,11 @@ export function useTimelineKeyboardShortcuts({ if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } + if (matchesShortcut(e, keyShortcuts.duplicateSelected, isMac)) { + e.preventDefault(); + handleDuplicateSelected(); + return; + } if (e.key === "Tab" && annotationCount > 0) { if (cycleAnnotationsAtCurrentTime(e.shiftKey)) { @@ -142,6 +149,7 @@ export function useTimelineKeyboardShortcuts({ deleteSelectedZoom, handleAddAnnotation, handleAddZoom, + handleDuplicateSelected, handleSplitClip, hasAnyZoomBlocks, isMac, diff --git a/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.test.ts new file mode 100644 index 000000000..b2923079e --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { placeSpanAfter } from "./timelineDuplicateUtils"; + +describe("placeSpanAfter", () => { + it("places a copy immediately after the source span", () => { + expect(placeSpanAfter({ startMs: 1000, endMs: 2000 }, 10_000)).toEqual({ + startMs: 2000, + endMs: 3000, + }); + }); + + it("clamps to the timeline end when space is partial", () => { + expect(placeSpanAfter({ startMs: 8000, endMs: 9500 }, 10_000)).toEqual({ + startMs: 9500, + endMs: 10_000, + }); + }); + + it("returns null when there is no room after the source", () => { + expect(placeSpanAfter({ startMs: 9000, endMs: 10_000 }, 10_000)).toBeNull(); + expect(placeSpanAfter({ startMs: 0, endMs: 0 }, 10_000)).toBeNull(); + expect(placeSpanAfter({ startMs: 0, endMs: 1000 }, 0)).toBeNull(); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.ts new file mode 100644 index 000000000..41d10f204 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.ts @@ -0,0 +1,31 @@ +export type TimelineSpan = { + startMs: number; + endMs: number; +}; + +/** + * Place a copy of a span immediately after the original. + * Returns null when there is no remaining room on the timeline. + */ +export function placeSpanAfter( + span: TimelineSpan, + totalMs: number, +): { startMs: number; endMs: number } | null { + const duration = Math.max(0, Math.round(span.endMs) - Math.round(span.startMs)); + const timelineEnd = Math.max(0, Math.round(totalMs)); + if (duration <= 0 || timelineEnd <= 0) { + return null; + } + + const startMs = Math.round(span.endMs); + if (startMs >= timelineEnd) { + return null; + } + + const endMs = Math.min(startMs + duration, timelineEnd); + if (endMs - startMs < 1) { + return null; + } + + return { startMs, endMs }; +} diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts index c1e3289a5..89bb9006d 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveDeleteSelectionTarget } from "./timelineSelectionUtils"; +import { + resolveDeleteSelectionTarget, + resolveDuplicateSelectionTarget, +} from "./timelineSelectionUtils"; describe("timelineSelectionUtils", () => { it("treats zoom select-all as a zoom deletion target", () => { @@ -51,4 +54,35 @@ describe("timelineSelectionUtils", () => { }), ).toBe("none"); }); + + it("resolves duplicate targets for zoom, annotation, and audio", () => { + expect( + resolveDuplicateSelectionTarget({ + selectedZoomId: "z-1", + selectedAnnotationId: "a-1", + selectedAudioId: "au-1", + }), + ).toBe("zoom"); + expect( + resolveDuplicateSelectionTarget({ + selectedZoomId: null, + selectedAnnotationId: "a-1", + selectedAudioId: "au-1", + }), + ).toBe("annotation"); + expect( + resolveDuplicateSelectionTarget({ + selectedZoomId: null, + selectedAnnotationId: null, + selectedAudioId: "au-1", + }), + ).toBe("audio"); + expect( + resolveDuplicateSelectionTarget({ + selectedZoomId: null, + selectedAnnotationId: null, + selectedAudioId: null, + }), + ).toBe("none"); + }); }); diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts index 9e825635b..5502b0b58 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts @@ -7,6 +7,8 @@ export type DeleteSelectionTarget = | "caption" | "none"; +export type DuplicateSelectionTarget = "zoom" | "annotation" | "audio" | "none"; + interface ResolveDeleteSelectionTargetParams { selectAllBlocksActive: boolean; selectedKeyframeId: string | null; @@ -35,3 +37,19 @@ export function resolveDeleteSelectionTarget({ if (selectedCaptionId) return "caption"; return "none"; } + +/** Resolve which selected timeline item can be duplicated (MVP: zoom, annotation, audio). */ +export function resolveDuplicateSelectionTarget({ + selectedZoomId, + selectedAnnotationId, + selectedAudioId, +}: { + selectedZoomId: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; +}): DuplicateSelectionTarget { + if (selectedZoomId) return "zoom"; + if (selectedAnnotationId) return "annotation"; + if (selectedAudioId) return "audio"; + return "none"; +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 31953838b..692f70572 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -9,6 +9,7 @@ "level": "Zoom Level", "selectRegion": "Select a zoom region to adjust", "deleteZoom": "Delete Zoom", + "duplicateZoom": "Duplicate Zoom", "modeAuto": "Auto", "modeManual": "Manual", "modeManualDescription": "Set a fixed focus point for this zoom", @@ -260,6 +261,7 @@ "systemLabel": "Source System", "micLabel": "Source Mic", "mixedLabel": "Source", - "deleteRegion": "Delete Audio" + "deleteRegion": "Delete Audio", + "duplicateRegion": "Duplicate Audio" } } diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index dd789d984..b2c944b4f 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -6,6 +6,7 @@ "addAnnotation": "Add Annotation", "addKeyframe": "Add Keyframe", "deleteSelected": "Delete Selected", + "duplicateSelected": "Duplicate Selected", "playPause": "Play / Pause", "cycleForward": "Cycle Annotations Forward", "cycleBackward": "Cycle Annotations Backward", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d43846930..b27ce5024 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -4,6 +4,7 @@ export const SHORTCUT_ACTIONS = [ "addAnnotation", "addKeyframe", "deleteSelected", + "duplicateSelected", "playPause", ] as const; @@ -78,6 +79,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addAnnotation: { key: "a" }, addKeyframe: { key: "f" }, deleteSelected: { key: "d", ctrl: true }, + duplicateSelected: { key: "d", ctrl: true, shift: true }, playPause: { key: " " }, }; @@ -87,6 +89,7 @@ export const SHORTCUT_LABELS: Record = { addAnnotation: "Add Annotation", addKeyframe: "Add Keyframe", deleteSelected: "Delete Selected", + duplicateSelected: "Duplicate Selected", playPause: "Play / Pause", };