Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ import {
} from "./projectPersistence";
import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
cloneAnnotationRegion,
getPastedAnnotationPosition,
spansOverlap,
} from "./timelineClipboardUtils";
import {
type AnnotationRegion,
type BlurData,
Expand All @@ -76,6 +81,13 @@ 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 };
Comment thread
LorenzoLancia marked this conversation as resolved.

export default function VideoEditor() {
const {
state: editorState,
Expand Down Expand Up @@ -144,6 +156,7 @@ export default function VideoEditor() {
format: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [timelineClipboard, setTimelineClipboard] = useState<TimelineClipboardItem | null>(null);

const playerContainerRef = useRef<HTMLDivElement>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
Expand All @@ -155,6 +168,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();

Expand Down Expand Up @@ -1013,6 +1027,211 @@ 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const sourceDuration = Math.max(
1,
timelineClipboard.region.endMs - timelineClipboard.region.startMs,
);
const pastedDuration = Math.min(sourceDuration, totalMs);
const currentTimeMs = Math.max(0, Math.round(currentTime * 1000));
const targetStart = Math.min(currentTimeMs, Math.max(0, totalMs - pastedDuration));
const targetEnd = Math.min(totalMs, targetStart + pastedDuration);

function clearTimelineSelection() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
setSelectedSpeedId(null);
}

function pasteSpanRegion<T extends { id: string; startMs: number; endMs: number }>(config: {
existingRegions: T[];
createId: () => string;
createRegion: (id: string) => T;
pushRegion: (region: T) => void;
selectRegion: (id: string) => void;
errorTitle: string;
errorDescription: string;
}) {
const hasConflict = config.existingRegions.some((region) =>
spansOverlap(region.startMs, region.endMs, targetStart, targetEnd),
);
if (hasConflict) {
toast.error(config.errorTitle, {
description: config.errorDescription,
});
return true;
}

const id = config.createId();
config.pushRegion(config.createRegion(id));
clearTimelineSelection();
config.selectRegion(id);
return true;
}

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: getPastedAnnotationPosition(source.position, source.size),
};
Comment thread
LorenzoLancia marked this conversation as resolved.

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") {
pasteSpanRegion({
existingRegions: zoomRegions,
createId: () => `zoom-${nextZoomIdRef.current++}`,
createRegion: (id) => ({
...timelineClipboard.region,
id,
startMs: targetStart,
endMs: targetEnd,
focus: { ...timelineClipboard.region.focus },
}),
pushRegion: (region) => {
pushState((prev) => ({
zoomRegions: [...prev.zoomRegions, region],
}));
},
selectRegion: setSelectedZoomId,
errorTitle: tt("errors.cannotPlaceZoom"),
errorDescription: tt("errors.zoomExistsAtLocation"),
});
return;
}

if (timelineClipboard.kind === "trim") {
pasteSpanRegion({
existingRegions: trimRegions,
createId: () => `trim-${nextTrimIdRef.current++}`,
createRegion: (id) => ({
...timelineClipboard.region,
id,
startMs: targetStart,
endMs: targetEnd,
}),
pushRegion: (region) => {
pushState((prev) => ({
trimRegions: [...prev.trimRegions, region],
}));
},
selectRegion: setSelectedTrimId,
errorTitle: tt("errors.cannotPlaceTrim"),
errorDescription: tt("errors.trimExistsAtLocation"),
});
return;
}

pasteSpanRegion({
existingRegions: speedRegions,
createId: () => `speed-${nextSpeedIdRef.current++}`,
createRegion: (id) => ({
...timelineClipboard.region,
id,
startMs: targetStart,
endMs: targetEnd,
}),
pushRegion: (region) => {
pushState((prev) => ({
speedRegions: [...prev.speedRegions, region],
}));
},
selectRegion: setSelectedSpeedId,
errorTitle: tt("errors.cannotPlaceSpeed"),
errorDescription: tt("errors.speedExistsAtLocation"),
});
}, [
timelineClipboard,
duration,
currentTime,
pushState,
zoomRegions,
trimRegions,
speedRegions,
tt,
]);

const handleAnnotationDelete = useCallback(
(id: string) => {
pushState((prev) => ({
Expand Down Expand Up @@ -1921,6 +2140,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({
Expand Down
49 changes: 48 additions & 1 deletion src/components/video-editor/timeline/TimelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -806,6 +810,10 @@ export default function TimelineEditor({
onSpeedDelete,
selectedSpeedId,
onSelectSpeed,
canCopySelectedItem = false,
canPasteTimelineItem = false,
onCopySelectedItem,
onPasteTimelineItem,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1326,6 +1369,10 @@ export default function TimelineEditor({
onSelectAnnotation,
keyShortcuts,
isMac,
canCopySelectedItem,
canPasteTimelineItem,
onCopySelectedItem,
onPasteTimelineItem,
]);

const clampedRange = useMemo<Range>(() => {
Expand Down
Loading
Loading