feat: copy/pasting for elements

This commit is contained in:
Maze Winther 2025-08-28 22:37:51 +02:00
parent 70a9bfebb1
commit a7d90f7eac
6 changed files with 136 additions and 7 deletions

View File

@ -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({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleElementCopyContext}>
<Copy className="h-4 w-4 mr-2" />
Copy element
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementContext}>
{hasAudio ? (
isMuted ? (

View File

@ -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

View File

@ -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",
() => {

View File

@ -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

View File

@ -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<KeybindingsState>()(
}),
{
name: "opencut-keybindings",
version: 1,
version: 2,
}
)
);

View File

@ -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<void>;
saveProjectTimeline: (projectId: string) => Promise<void>;
clearTimeline: () => void;
// Clipboard actions
copySelected: () => void;
pasteAtTime: (time: number) => void;
updateTextElement: (
trackId: string,
elementId: string,
@ -253,6 +262,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
redoStack: [],
selectedElements: [],
rippleEditingEnabled: false,
clipboard: null,
// Snapping settings defaults
snappingEnabled: true,
@ -493,10 +503,12 @@ export const useTimelineStore = create<TimelineStore>((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<TimelineStore>((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<TimelineStore>((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,
});
}
}
},
};
});