Skip to content

Commit a7d90f7

Browse files
committed
feat: copy/pasting for elements
1 parent 70a9bfe commit a7d90f7

File tree

6 files changed

+136
-7
lines changed

6 files changed

+136
-7
lines changed

apps/web/src/components/editor/timeline/timeline-element.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function TimelineElement({
5151
rippleEditingEnabled,
5252
toggleElementHidden,
5353
toggleElementMuted,
54+
copySelected,
5455
} = useTimelineStore();
5556
const { currentTime } = usePlaybackStore();
5657

@@ -116,6 +117,11 @@ export function TimelineElement({
116117
});
117118
};
118119

120+
const handleElementCopyContext = (e: React.MouseEvent) => {
121+
e.stopPropagation();
122+
copySelected();
123+
};
124+
119125
const handleElementDeleteContext = (e: React.MouseEvent) => {
120126
e.stopPropagation();
121127
if (rippleEditingEnabled) {
@@ -330,6 +336,10 @@ export function TimelineElement({
330336
<Scissors className="h-4 w-4 mr-2" />
331337
Split at playhead
332338
</ContextMenuItem>
339+
<ContextMenuItem onClick={handleElementCopyContext}>
340+
<Copy className="h-4 w-4 mr-2" />
341+
Copy element
342+
</ContextMenuItem>
333343
<ContextMenuItem onClick={handleToggleElementContext}>
334344
{hasAudio ? (
335345
isMuted ? (

apps/web/src/constants/actions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ export type Action =
4545
| "duplicate-selected" // Duplicate selected element
4646
| "toggle-snapping" // Toggle snapping
4747
| "undo" // Undo last action
48-
| "redo"; // Redo last undone action
48+
| "redo" // Redo last undone action
49+
| "copy-selected" // Copy selected elements to clipboard
50+
| "paste-selected"; // Paste elements from clipboard at playhead
4951

5052
/**
5153
* Defines the arguments, if present for a given type that is required to be passed on

apps/web/src/hooks/use-editor-actions.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,23 @@ export function useEditorActions() {
202202
undefined
203203
);
204204

205+
useActionHandler(
206+
"copy-selected",
207+
() => {
208+
if (selectedElements.length === 0) return;
209+
useTimelineStore.getState().copySelected();
210+
},
211+
undefined
212+
);
213+
214+
useActionHandler(
215+
"paste-selected",
216+
() => {
217+
useTimelineStore.getState().pasteAtTime(currentTime);
218+
},
219+
undefined
220+
);
221+
205222
useActionHandler(
206223
"toggle-snapping",
207224
() => {

apps/web/src/hooks/use-keyboard-shortcuts-help.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ const actionDescriptions: Record<
6363
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
6464
undo: { description: "Undo", category: "History" },
6565
redo: { description: "Redo", category: "History" },
66+
"copy-selected": {
67+
description: "Copy selected elements",
68+
category: "Editing",
69+
},
70+
"paste-selected": {
71+
description: "Paste elements at playhead",
72+
category: "Editing",
73+
},
6674
};
6775

6876
// Convert key binding format to display format

apps/web/src/stores/keybindings-store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const defaultKeybindings: KeybindingConfig = {
2323
n: "toggle-snapping",
2424
"ctrl+a": "select-all",
2525
"ctrl+d": "duplicate-selected",
26+
"ctrl+c": "copy-selected",
27+
"ctrl+v": "paste-selected",
2628
"ctrl+z": "undo",
2729
"ctrl+shift+z": "redo",
2830
"ctrl+y": "redo",
@@ -162,7 +164,7 @@ export const useKeybindingsStore = create<KeybindingsState>()(
162164
}),
163165
{
164166
name: "opencut-keybindings",
165-
version: 1,
167+
version: 2,
166168
}
167169
)
168170
);

apps/web/src/stores/timeline-store.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ interface TimelineStore {
4141
history: TimelineTrack[][];
4242
redoStack: TimelineTrack[][];
4343

44+
// Clipboard buffer
45+
clipboard: {
46+
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
47+
} | null;
48+
4449
// Always returns properly ordered tracks with main track ensured
4550
tracks: TimelineTrack[];
4651

@@ -176,6 +181,10 @@ interface TimelineStore {
176181
loadProjectTimeline: (projectId: string) => Promise<void>;
177182
saveProjectTimeline: (projectId: string) => Promise<void>;
178183
clearTimeline: () => void;
184+
185+
// Clipboard actions
186+
copySelected: () => void;
187+
pasteAtTime: (time: number) => void;
179188
updateTextElement: (
180189
trackId: string,
181190
elementId: string,
@@ -253,6 +262,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
253262
redoStack: [],
254263
selectedElements: [],
255264
rippleEditingEnabled: false,
265+
clipboard: null,
256266

257267
// Snapping settings defaults
258268
snappingEnabled: true,
@@ -493,10 +503,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
493503
const newElement: TimelineElement = {
494504
...elementData,
495505
id: generateUUID(),
496-
startTime: elementData.startTime || 0,
497-
trimStart: 0,
498-
trimEnd: 0,
499-
...(elementData.type === "media" ? { muted: false } : {}),
506+
startTime: elementData.startTime,
507+
trimStart: elementData.trimStart ?? 0,
508+
trimEnd: elementData.trimEnd ?? 0,
509+
...(elementData.type === "media"
510+
? { muted: elementData.muted ?? false }
511+
: {}),
500512
} as TimelineElement;
501513

502514
if (isFirstElement && newElement.type === "media") {
@@ -1347,7 +1359,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
13471359
clearTimeline: () => {
13481360
const defaultTracks = ensureMainTrack([]);
13491361
updateTracks(defaultTracks);
1350-
set({ history: [], redoStack: [], selectedElements: [] });
1362+
set({
1363+
history: [],
1364+
redoStack: [],
1365+
selectedElements: [],
1366+
clipboard: null,
1367+
});
13511368
},
13521369

13531370
// Snapping actions
@@ -1451,6 +1468,79 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
14511468
});
14521469
return true;
14531470
},
1471+
1472+
copySelected: () => {
1473+
const { selectedElements, _tracks } = get();
1474+
if (selectedElements.length === 0) return;
1475+
1476+
const items: Array<{
1477+
trackType: TrackType;
1478+
element: CreateTimelineElement;
1479+
}> = [];
1480+
1481+
for (const { trackId, elementId } of selectedElements) {
1482+
const track = _tracks.find((t) => t.id === trackId);
1483+
const element = track?.elements.find((e) => e.id === elementId);
1484+
if (!track || !element) continue;
1485+
1486+
// Prepare a creation-friendly copy without id
1487+
const { id: _id, ...rest } = element as TimelineElement;
1488+
items.push({
1489+
trackType: track.type,
1490+
element: rest as CreateTimelineElement,
1491+
});
1492+
}
1493+
1494+
set({ clipboard: { items } });
1495+
},
1496+
1497+
pasteAtTime: (time) => {
1498+
const { clipboard } = get();
1499+
if (!clipboard || clipboard.items.length === 0) return;
1500+
1501+
// Determine reference start time offset based on earliest element in clipboard
1502+
const minStart = Math.min(
1503+
...clipboard.items.map((x) => x.element.startTime)
1504+
);
1505+
1506+
get().pushHistory();
1507+
1508+
for (const item of clipboard.items) {
1509+
const targetTrackId = get().findOrCreateTrack(item.trackType);
1510+
const relativeOffset = item.element.startTime - minStart;
1511+
const startTime = Math.max(0, time + relativeOffset);
1512+
1513+
// Ensure no overlap on target track
1514+
const duration =
1515+
item.element.duration - item.element.trimStart - item.element.trimEnd;
1516+
const hasOverlap = get().checkElementOverlap(
1517+
targetTrackId,
1518+
startTime,
1519+
duration
1520+
);
1521+
if (hasOverlap) {
1522+
// If overlap, nudge forward slightly until free (simple resolve)
1523+
let candidate = startTime;
1524+
let safety = 0;
1525+
while (
1526+
get().checkElementOverlap(targetTrackId, candidate, duration) &&
1527+
safety < 1000
1528+
) {
1529+
candidate += 0.01;
1530+
safety += 1;
1531+
}
1532+
get().addElementToTrack(targetTrackId, {
1533+
...item.element,
1534+
startTime: candidate,
1535+
});
1536+
} else {
1537+
get().addElementToTrack(targetTrackId, {
1538+
...item.element,
1539+
startTime,
1540+
});
1541+
}
1542+
}
1543+
},
14541544
};
14551545
});
14561546

0 commit comments

Comments
 (0)