mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: copy/pasting for elements
This commit is contained in:
@@ -51,6 +51,7 @@ export function TimelineElement({
|
|||||||
rippleEditingEnabled,
|
rippleEditingEnabled,
|
||||||
toggleElementHidden,
|
toggleElementHidden,
|
||||||
toggleElementMuted,
|
toggleElementMuted,
|
||||||
|
copySelected,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
@@ -116,6 +117,11 @@ export function TimelineElement({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleElementCopyContext = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copySelected();
|
||||||
|
};
|
||||||
|
|
||||||
const handleElementDeleteContext = (e: React.MouseEvent) => {
|
const handleElementDeleteContext = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (rippleEditingEnabled) {
|
if (rippleEditingEnabled) {
|
||||||
@@ -330,6 +336,10 @@ export function TimelineElement({
|
|||||||
<Scissors className="h-4 w-4 mr-2" />
|
<Scissors className="h-4 w-4 mr-2" />
|
||||||
Split at playhead
|
Split at playhead
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={handleElementCopyContext}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Copy element
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={handleToggleElementContext}>
|
<ContextMenuItem onClick={handleToggleElementContext}>
|
||||||
{hasAudio ? (
|
{hasAudio ? (
|
||||||
isMuted ? (
|
isMuted ? (
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export type Action =
|
|||||||
| "duplicate-selected" // Duplicate selected element
|
| "duplicate-selected" // Duplicate selected element
|
||||||
| "toggle-snapping" // Toggle snapping
|
| "toggle-snapping" // Toggle snapping
|
||||||
| "undo" // Undo last action
|
| "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
|
* Defines the arguments, if present for a given type that is required to be passed on
|
||||||
|
|||||||
@@ -202,6 +202,23 @@ export function useEditorActions() {
|
|||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useActionHandler(
|
||||||
|
"copy-selected",
|
||||||
|
() => {
|
||||||
|
if (selectedElements.length === 0) return;
|
||||||
|
useTimelineStore.getState().copySelected();
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
useActionHandler(
|
||||||
|
"paste-selected",
|
||||||
|
() => {
|
||||||
|
useTimelineStore.getState().pasteAtTime(currentTime);
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"toggle-snapping",
|
"toggle-snapping",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ const actionDescriptions: Record<
|
|||||||
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
|
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
|
||||||
undo: { description: "Undo", category: "History" },
|
undo: { description: "Undo", category: "History" },
|
||||||
redo: { description: "Redo", 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
|
// Convert key binding format to display format
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const defaultKeybindings: KeybindingConfig = {
|
|||||||
n: "toggle-snapping",
|
n: "toggle-snapping",
|
||||||
"ctrl+a": "select-all",
|
"ctrl+a": "select-all",
|
||||||
"ctrl+d": "duplicate-selected",
|
"ctrl+d": "duplicate-selected",
|
||||||
|
"ctrl+c": "copy-selected",
|
||||||
|
"ctrl+v": "paste-selected",
|
||||||
"ctrl+z": "undo",
|
"ctrl+z": "undo",
|
||||||
"ctrl+shift+z": "redo",
|
"ctrl+shift+z": "redo",
|
||||||
"ctrl+y": "redo",
|
"ctrl+y": "redo",
|
||||||
@@ -162,7 +164,7 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "opencut-keybindings",
|
name: "opencut-keybindings",
|
||||||
version: 1,
|
version: 2,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ interface TimelineStore {
|
|||||||
history: TimelineTrack[][];
|
history: TimelineTrack[][];
|
||||||
redoStack: TimelineTrack[][];
|
redoStack: TimelineTrack[][];
|
||||||
|
|
||||||
|
// Clipboard buffer
|
||||||
|
clipboard: {
|
||||||
|
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
|
||||||
|
} | null;
|
||||||
|
|
||||||
// Always returns properly ordered tracks with main track ensured
|
// Always returns properly ordered tracks with main track ensured
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
|
|
||||||
@@ -176,6 +181,10 @@ interface TimelineStore {
|
|||||||
loadProjectTimeline: (projectId: string) => Promise<void>;
|
loadProjectTimeline: (projectId: string) => Promise<void>;
|
||||||
saveProjectTimeline: (projectId: string) => Promise<void>;
|
saveProjectTimeline: (projectId: string) => Promise<void>;
|
||||||
clearTimeline: () => void;
|
clearTimeline: () => void;
|
||||||
|
|
||||||
|
// Clipboard actions
|
||||||
|
copySelected: () => void;
|
||||||
|
pasteAtTime: (time: number) => void;
|
||||||
updateTextElement: (
|
updateTextElement: (
|
||||||
trackId: string,
|
trackId: string,
|
||||||
elementId: string,
|
elementId: string,
|
||||||
@@ -253,6 +262,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
redoStack: [],
|
redoStack: [],
|
||||||
selectedElements: [],
|
selectedElements: [],
|
||||||
rippleEditingEnabled: false,
|
rippleEditingEnabled: false,
|
||||||
|
clipboard: null,
|
||||||
|
|
||||||
// Snapping settings defaults
|
// Snapping settings defaults
|
||||||
snappingEnabled: true,
|
snappingEnabled: true,
|
||||||
@@ -493,10 +503,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
const newElement: TimelineElement = {
|
const newElement: TimelineElement = {
|
||||||
...elementData,
|
...elementData,
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
startTime: elementData.startTime || 0,
|
startTime: elementData.startTime,
|
||||||
trimStart: 0,
|
trimStart: elementData.trimStart ?? 0,
|
||||||
trimEnd: 0,
|
trimEnd: elementData.trimEnd ?? 0,
|
||||||
...(elementData.type === "media" ? { muted: false } : {}),
|
...(elementData.type === "media"
|
||||||
|
? { muted: elementData.muted ?? false }
|
||||||
|
: {}),
|
||||||
} as TimelineElement;
|
} as TimelineElement;
|
||||||
|
|
||||||
if (isFirstElement && newElement.type === "media") {
|
if (isFirstElement && newElement.type === "media") {
|
||||||
@@ -1347,7 +1359,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
clearTimeline: () => {
|
clearTimeline: () => {
|
||||||
const defaultTracks = ensureMainTrack([]);
|
const defaultTracks = ensureMainTrack([]);
|
||||||
updateTracks(defaultTracks);
|
updateTracks(defaultTracks);
|
||||||
set({ history: [], redoStack: [], selectedElements: [] });
|
set({
|
||||||
|
history: [],
|
||||||
|
redoStack: [],
|
||||||
|
selectedElements: [],
|
||||||
|
clipboard: null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Snapping actions
|
// Snapping actions
|
||||||
@@ -1451,6 +1468,79 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
});
|
});
|
||||||
return true;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user