diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6d21d13c..8e502055 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -76,6 +76,33 @@ import { import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants"; +type TimelineClipboardItem = + | { kind: "zoom"; region: ZoomRegion } + | { kind: "trim"; region: TrimRegion } + | { kind: "speed"; region: SpeedRegion } + | { kind: "annotation"; region: AnnotationRegion } + | { kind: "blur"; region: AnnotationRegion }; + +function cloneAnnotationRegion(region: AnnotationRegion): AnnotationRegion { + return { + ...region, + position: { ...region.position }, + size: { ...region.size }, + style: { ...region.style }, + figureData: region.figureData ? { ...region.figureData } : undefined, + blurData: region.blurData + ? { + ...region.blurData, + freehandPoints: region.blurData.freehandPoints?.map((point) => ({ ...point })), + } + : undefined, + }; +} + +function spansOverlap(startA: number, endA: number, startB: number, endB: number): boolean { + return startA < endB && endA > startB; +} + export default function VideoEditor() { const { state: editorState, @@ -144,6 +171,7 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [timelineClipboard, setTimelineClipboard] = useState(null); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); @@ -155,6 +183,7 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const tt = useScopedT("timeline"); const availableLocales = getAvailableLocales(); const { locale, setLocale } = useI18n(); @@ -1013,6 +1042,209 @@ export default function VideoEditor() { [pushState], ); + const handleCopySelectedTimelineItem = useCallback(() => { + if (selectedAnnotationId) { + const region = annotationOnlyRegions.find((item) => item.id === selectedAnnotationId); + if (region) { + setTimelineClipboard({ kind: "annotation", region: cloneAnnotationRegion(region) }); + } + return; + } + + if (selectedBlurId) { + const region = blurRegions.find((item) => item.id === selectedBlurId); + if (region) { + setTimelineClipboard({ kind: "blur", region: cloneAnnotationRegion(region) }); + } + return; + } + + if (selectedZoomId) { + const region = zoomRegions.find((item) => item.id === selectedZoomId); + if (region) { + setTimelineClipboard({ kind: "zoom", region: { ...region, focus: { ...region.focus } } }); + } + return; + } + + if (selectedTrimId) { + const region = trimRegions.find((item) => item.id === selectedTrimId); + if (region) { + setTimelineClipboard({ kind: "trim", region: { ...region } }); + } + return; + } + + if (selectedSpeedId) { + const region = speedRegions.find((item) => item.id === selectedSpeedId); + if (region) { + setTimelineClipboard({ kind: "speed", region: { ...region } }); + } + } + }, [ + selectedAnnotationId, + selectedBlurId, + selectedZoomId, + selectedTrimId, + selectedSpeedId, + annotationOnlyRegions, + blurRegions, + zoomRegions, + trimRegions, + speedRegions, + ]); + + const handlePasteTimelineItem = useCallback(() => { + if (!timelineClipboard) return; + + const totalMs = Math.max(0, Math.round(duration * 1000)); + if (totalMs <= 0) return; + + const sourceDuration = Math.max( + 1, + timelineClipboard.region.endMs - timelineClipboard.region.startMs, + ); + const pastedDuration = Math.min(sourceDuration, totalMs); + const targetStart = Math.min( + Math.max(0, Math.round(currentTime * 1000)), + Math.max(0, totalMs - pastedDuration), + ); + const targetEnd = Math.min(totalMs, targetStart + pastedDuration); + + if (timelineClipboard.kind === "annotation" || timelineClipboard.kind === "blur") { + const id = `annotation-${nextAnnotationIdRef.current++}`; + const zIndex = nextAnnotationZIndexRef.current++; + const source = cloneAnnotationRegion(timelineClipboard.region); + const pasted: AnnotationRegion = { + ...source, + id, + startMs: targetStart, + endMs: targetEnd, + zIndex, + position: { + x: Math.min(96, source.position.x + 4), + y: Math.min(96, source.position.y + 4), + }, + }; + + pushState((prev) => ({ + annotationRegions: [...prev.annotationRegions, pasted], + })); + + if (timelineClipboard.kind === "blur") { + setSelectedBlurId(id); + setSelectedAnnotationId(null); + } else { + setSelectedAnnotationId(id); + setSelectedBlurId(null); + } + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + return; + } + + if (timelineClipboard.kind === "zoom") { + const hasConflict = zoomRegions.some((region) => + spansOverlap(region.startMs, region.endMs, targetStart, targetEnd), + ); + if (hasConflict) { + toast.error(tt("errors.cannotPlaceZoom"), { + description: tt("errors.zoomExistsAtLocation"), + }); + return; + } + + const id = `zoom-${nextZoomIdRef.current++}`; + pushState((prev) => ({ + zoomRegions: [ + ...prev.zoomRegions, + { + ...timelineClipboard.region, + id, + startMs: targetStart, + endMs: targetEnd, + focus: { ...timelineClipboard.region.focus }, + }, + ], + })); + setSelectedZoomId(id); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + setSelectedSpeedId(null); + return; + } + + if (timelineClipboard.kind === "trim") { + const hasConflict = trimRegions.some((region) => + spansOverlap(region.startMs, region.endMs, targetStart, targetEnd), + ); + if (hasConflict) { + toast.error(tt("errors.cannotPlaceTrim"), { + description: tt("errors.trimExistsAtLocation"), + }); + return; + } + + const id = `trim-${nextTrimIdRef.current++}`; + pushState((prev) => ({ + trimRegions: [ + ...prev.trimRegions, + { + ...timelineClipboard.region, + id, + startMs: targetStart, + endMs: targetEnd, + }, + ], + })); + setSelectedTrimId(id); + setSelectedZoomId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + setSelectedSpeedId(null); + return; + } + + const hasConflict = speedRegions.some((region) => + spansOverlap(region.startMs, region.endMs, targetStart, targetEnd), + ); + if (hasConflict) { + toast.error(tt("errors.cannotPlaceSpeed"), { + description: tt("errors.speedExistsAtLocation"), + }); + return; + } + + const id = `speed-${nextSpeedIdRef.current++}`; + pushState((prev) => ({ + speedRegions: [ + ...prev.speedRegions, + { + ...timelineClipboard.region, + id, + startMs: targetStart, + endMs: targetEnd, + }, + ], + })); + setSelectedSpeedId(id); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + }, [ + timelineClipboard, + duration, + currentTime, + pushState, + zoomRegions, + trimRegions, + speedRegions, + tt, + ]); + const handleAnnotationDelete = useCallback( (id: string) => { pushState((prev) => ({ @@ -1921,6 +2153,16 @@ export default function VideoEditor() { onBlurDelete={handleAnnotationDelete} selectedBlurId={selectedBlurId} onSelectBlur={handleSelectBlur} + canCopySelectedItem={ + !!selectedZoomId || + !!selectedTrimId || + !!selectedAnnotationId || + !!selectedBlurId || + !!selectedSpeedId + } + canPasteTimelineItem={!!timelineClipboard} + onCopySelectedItem={handleCopySelectedTimelineItem} + onPasteTimelineItem={handlePasteTimelineItem} aspectRatio={aspectRatio} onAspectRatioChange={(ar) => pushState({ diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 81e62182..b4e837b5 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -87,6 +87,10 @@ interface TimelineEditorProps { onSpeedDelete?: (id: string) => void; selectedSpeedId?: string | null; onSelectSpeed?: (id: string | null) => void; + canCopySelectedItem?: boolean; + canPasteTimelineItem?: boolean; + onCopySelectedItem?: () => void; + onPasteTimelineItem?: () => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; } @@ -806,6 +810,10 @@ export default function TimelineEditor({ onSpeedDelete, selectedSpeedId, onSelectSpeed, + canCopySelectedItem = false, + canPasteTimelineItem = false, + onCopySelectedItem, + onPasteTimelineItem, aspectRatio, onAspectRatioChange, }: TimelineEditorProps) { @@ -1234,7 +1242,42 @@ export default function TimelineEditor({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + const target = e.target; + const isEditableTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable); + if (isEditableTarget) { + return; + } + + const mod = isMac ? e.metaKey : e.ctrlKey; + const key = e.key.toLowerCase(); + + if ( + mod && + !e.shiftKey && + !e.altKey && + key === "c" && + canCopySelectedItem && + onCopySelectedItem + ) { + e.preventDefault(); + onCopySelectedItem(); + return; + } + + if ( + mod && + !e.shiftKey && + !e.altKey && + key === "v" && + canPasteTimelineItem && + onPasteTimelineItem + ) { + e.preventDefault(); + onPasteTimelineItem(); return; } @@ -1326,6 +1369,10 @@ export default function TimelineEditor({ onSelectAnnotation, keyShortcuts, isMac, + canCopySelectedItem, + canPasteTimelineItem, + onCopySelectedItem, + onPasteTimelineItem, ]); const clampedRange = useMemo(() => {