diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index 300c4c7b..677b9c61 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -51,6 +51,7 @@ export function TimelineElement({
rippleEditingEnabled,
toggleElementHidden,
toggleElementMuted,
+ copySelected,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -116,6 +117,11 @@ export function TimelineElement({
});
};
+ const handleElementCopyContext = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ copySelected();
+ };
+
const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation();
if (rippleEditingEnabled) {
@@ -330,6 +336,10 @@ export function TimelineElement({
Split at playhead
+
+
+ Copy element
+
{hasAudio ? (
isMuted ? (
diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts
index 4278ce7e..50e8ae5d 100644
--- a/apps/web/src/constants/actions.ts
+++ b/apps/web/src/constants/actions.ts
@@ -45,7 +45,9 @@ export type Action =
| "duplicate-selected" // Duplicate selected element
| "toggle-snapping" // Toggle snapping
| "undo" // Undo last action
- | "redo"; // Redo last undone action
+ | "redo" // Redo last undone action
+ | "copy-selected" // Copy selected elements to clipboard
+ | "paste-selected"; // Paste elements from clipboard at playhead
/**
* Defines the arguments, if present for a given type that is required to be passed on
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
index 81500416..974f9db5 100644
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -202,6 +202,23 @@ export function useEditorActions() {
undefined
);
+ useActionHandler(
+ "copy-selected",
+ () => {
+ if (selectedElements.length === 0) return;
+ useTimelineStore.getState().copySelected();
+ },
+ undefined
+ );
+
+ useActionHandler(
+ "paste-selected",
+ () => {
+ useTimelineStore.getState().pasteAtTime(currentTime);
+ },
+ undefined
+ );
+
useActionHandler(
"toggle-snapping",
() => {
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
index e2cde3b4..29e3c09b 100644
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -63,6 +63,14 @@ const actionDescriptions: Record<
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
undo: { description: "Undo", category: "History" },
redo: { description: "Redo", category: "History" },
+ "copy-selected": {
+ description: "Copy selected elements",
+ category: "Editing",
+ },
+ "paste-selected": {
+ description: "Paste elements at playhead",
+ category: "Editing",
+ },
};
// Convert key binding format to display format
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index 4c11cfde..7fa0da5e 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -23,6 +23,8 @@ export const defaultKeybindings: KeybindingConfig = {
n: "toggle-snapping",
"ctrl+a": "select-all",
"ctrl+d": "duplicate-selected",
+ "ctrl+c": "copy-selected",
+ "ctrl+v": "paste-selected",
"ctrl+z": "undo",
"ctrl+shift+z": "redo",
"ctrl+y": "redo",
@@ -162,7 +164,7 @@ export const useKeybindingsStore = create()(
}),
{
name: "opencut-keybindings",
- version: 1,
+ version: 2,
}
)
);
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 884db23e..f0bae00c 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -41,6 +41,11 @@ interface TimelineStore {
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
+ // Clipboard buffer
+ clipboard: {
+ items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
+ } | null;
+
// Always returns properly ordered tracks with main track ensured
tracks: TimelineTrack[];
@@ -176,6 +181,10 @@ interface TimelineStore {
loadProjectTimeline: (projectId: string) => Promise;
saveProjectTimeline: (projectId: string) => Promise;
clearTimeline: () => void;
+
+ // Clipboard actions
+ copySelected: () => void;
+ pasteAtTime: (time: number) => void;
updateTextElement: (
trackId: string,
elementId: string,
@@ -253,6 +262,7 @@ export const useTimelineStore = create((set, get) => {
redoStack: [],
selectedElements: [],
rippleEditingEnabled: false,
+ clipboard: null,
// Snapping settings defaults
snappingEnabled: true,
@@ -493,10 +503,12 @@ export const useTimelineStore = create((set, get) => {
const newElement: TimelineElement = {
...elementData,
id: generateUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- ...(elementData.type === "media" ? { muted: false } : {}),
+ startTime: elementData.startTime,
+ trimStart: elementData.trimStart ?? 0,
+ trimEnd: elementData.trimEnd ?? 0,
+ ...(elementData.type === "media"
+ ? { muted: elementData.muted ?? false }
+ : {}),
} as TimelineElement;
if (isFirstElement && newElement.type === "media") {
@@ -1347,7 +1359,12 @@ export const useTimelineStore = create((set, get) => {
clearTimeline: () => {
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
- set({ history: [], redoStack: [], selectedElements: [] });
+ set({
+ history: [],
+ redoStack: [],
+ selectedElements: [],
+ clipboard: null,
+ });
},
// Snapping actions
@@ -1451,6 +1468,79 @@ export const useTimelineStore = create((set, get) => {
});
return true;
},
+
+ copySelected: () => {
+ const { selectedElements, _tracks } = get();
+ if (selectedElements.length === 0) return;
+
+ const items: Array<{
+ trackType: TrackType;
+ element: CreateTimelineElement;
+ }> = [];
+
+ for (const { trackId, elementId } of selectedElements) {
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ if (!track || !element) continue;
+
+ // Prepare a creation-friendly copy without id
+ const { id: _id, ...rest } = element as TimelineElement;
+ items.push({
+ trackType: track.type,
+ element: rest as CreateTimelineElement,
+ });
+ }
+
+ set({ clipboard: { items } });
+ },
+
+ pasteAtTime: (time) => {
+ const { clipboard } = get();
+ if (!clipboard || clipboard.items.length === 0) return;
+
+ // Determine reference start time offset based on earliest element in clipboard
+ const minStart = Math.min(
+ ...clipboard.items.map((x) => x.element.startTime)
+ );
+
+ get().pushHistory();
+
+ for (const item of clipboard.items) {
+ const targetTrackId = get().findOrCreateTrack(item.trackType);
+ const relativeOffset = item.element.startTime - minStart;
+ const startTime = Math.max(0, time + relativeOffset);
+
+ // Ensure no overlap on target track
+ const duration =
+ item.element.duration - item.element.trimStart - item.element.trimEnd;
+ const hasOverlap = get().checkElementOverlap(
+ targetTrackId,
+ startTime,
+ duration
+ );
+ if (hasOverlap) {
+ // If overlap, nudge forward slightly until free (simple resolve)
+ let candidate = startTime;
+ let safety = 0;
+ while (
+ get().checkElementOverlap(targetTrackId, candidate, duration) &&
+ safety < 1000
+ ) {
+ candidate += 0.01;
+ safety += 1;
+ }
+ get().addElementToTrack(targetTrackId, {
+ ...item.element,
+ startTime: candidate,
+ });
+ } else {
+ get().addElementToTrack(targetTrackId, {
+ ...item.element,
+ startTime,
+ });
+ }
+ }
+ },
};
});