feat: add support for deleting/splitting multiple elements instead of single

This commit is contained in:
Maze Winther
2025-09-02 23:07:11 +02:00
parent c5c32a1e34
commit 167e2b83dc
8 changed files with 496 additions and 279 deletions
@@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { toast } from "sonner";
import { TimelineElementProps } from "@/types/timeline";
import { TimelineElementProps, MediaElement } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
@@ -42,16 +42,17 @@ export function TimelineElement({
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const {
removeElementFromTrack,
removeElementFromTrackWithRipple,
dragState,
splitElement,
addElementToTrack,
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
toggleElementMuted,
copySelected,
selectedElements,
deleteSelected,
splitSelected,
toggleSelectedHidden,
toggleSelectedMuted,
duplicateElement,
revealElementInMedia,
replaceElementWithFile,
getContextMenuState,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -68,7 +69,12 @@ export function TimelineElement({
zoomLevel,
});
const { requestRevealMedia } = useMediaPanelStore.getState();
const {
isMultipleSelected,
isCurrentElementSelected,
hasAudioElements,
canSplitSelected,
} = getContextMenuState(track.id, element.id);
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
@@ -77,44 +83,26 @@ export function TimelineElement({
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
// Element should always be positioned at startTime - trimStart only affects content, not position
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = (e: React.MouseEvent) => {
e.stopPropagation();
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
} else {
toast.error("Playhead must be within element to split");
}
splitSelected(
currentTime,
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
);
};
const handleElementDuplicateContext = (e: React.MouseEvent) => {
e.stopPropagation();
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
duplicateElement(track.id, element.id);
};
const handleElementCopyContext = (e: React.MouseEvent) => {
@@ -124,49 +112,38 @@ export function TimelineElement({
const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation();
if (rippleEditingEnabled) {
removeElementFromTrackWithRipple(track.id, element.id);
} else {
removeElementFromTrack(track.id, element.id);
}
deleteSelected(
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
);
};
const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
if (hasAudio && element.type === "media") {
toggleElementMuted(track.id, element.id);
return;
toggleSelectedMuted(
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
);
} else {
toggleSelectedHidden(
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
);
}
toggleElementHidden(track.id, element.id);
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
toast.error("Replace is only available for media clips");
return;
}
// Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const result = await replaceElementMedia(track.id, element.id, file);
if (result.success) {
toast.success("Clip replaced successfully");
} else {
toast.error(result.error || "Failed to replace clip");
}
} catch (error) {
console.error("Unexpected error replacing clip:", error);
toast.error(
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
);
if (file) {
await replaceElementWithFile(track.id, element.id, file);
}
};
input.click();
@@ -174,12 +151,7 @@ export function TimelineElement({
const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
toast.error("Reveal is only available for media clips");
return;
}
requestRevealMedia(element.mediaId);
revealElementInMedia(element.id);
};
const renderElementContent = () => {
@@ -191,7 +163,6 @@ export function TimelineElement({
);
}
// Render media element ->
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) {
return (
@@ -201,20 +172,15 @@ export function TimelineElement({
);
}
const TILE_ASPECT_RATIO = 16 / 9;
if (
mediaItem.type === "image" ||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
) {
// Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight;
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
const tileWidth = trackHeight * (16 / 9);
const imageUrl =
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
const isImage = mediaItem.type === "image";
return (
<div className="w-full h-full flex items-center justify-center">
@@ -228,18 +194,17 @@ export function TimelineElement({
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
/>
</div>
</div>
);
}
// Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
@@ -267,7 +232,7 @@ export function TimelineElement({
}
};
const isMuted = element.type === "media" ? element.muted === true : false;
const isMuted = element.type === "media" && element.muted;
return (
<ContextMenu>
@@ -332,16 +297,33 @@ export function TimelineElement({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200">
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
{(!isMultipleSelected ||
(isMultipleSelected &&
isCurrentElementSelected &&
canSplitSelected)) && (
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
{isMultipleSelected && isCurrentElementSelected
? `Split ${selectedElements.length} elements at playhead`
: "Split at playhead"}
</ContextMenuItem>
)}
<ContextMenuItem onClick={handleElementCopyContext}>
<Copy className="h-4 w-4 mr-2" />
Copy element
{isMultipleSelected && isCurrentElementSelected
? `Copy ${selectedElements.length} elements`
: "Copy element"}
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementContext}>
{hasAudio ? (
{isMultipleSelected && isCurrentElementSelected ? (
hasAudioElements ? (
<VolumeX className="h-4 w-4 mr-2" />
) : (
<EyeOff className="h-4 w-4 mr-2" />
)
) : hasAudio ? (
isMuted ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
@@ -353,21 +335,29 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
{hasAudio
? isMuted
? "Unmute"
: "Mute"
: element.hidden
? "Show"
: "Hide"}{" "}
{element.type === "text" ? "text" : "clip"}
{isMultipleSelected && isCurrentElementSelected
? hasAudioElements
? `Toggle mute ${selectedElements.length} elements`
: `Toggle visibility ${selectedElements.length} elements`
: hasAudio
? isMuted
? "Unmute"
: "Mute"
: element.hidden
? "Show"
: "Hide"}{" "}
{!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
</span>
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
{!isMultipleSelected && (
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
)}
{!isMultipleSelected && element.type === "media" && (
<>
<ContextMenuItem onClick={handleRevealInMedia}>
<Search className="h-4 w-4 mr-2" />
@@ -379,13 +369,17 @@ export function TimelineElement({
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete {element.type === "text" ? "text" : "clip"}
{isMultipleSelected && isCurrentElementSelected
? `Delete ${selectedElements.length} elements`
: `Delete ${element.type === "text" ? "text" : "clip"}`}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -52,11 +52,10 @@ export function TimelineToolbar({
tracks,
addTrack,
addElementToTrack,
removeElementFromTrack,
removeElementFromTrackWithRipple,
selectedElements,
clearSelectedElements,
splitElement,
deleteSelected,
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
@@ -70,25 +69,7 @@ export function TimelineToolbar({
const { scenes, currentScene } = useSceneStore();
const handleSplitSelected = () => {
if (selectedElements.length === 0) return;
let splitCount = 0;
selectedElements.forEach(({ trackId, elementId }) => {
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (element && track) {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
const newElementId = splitElement(trackId, elementId, currentTime);
if (newElementId) splitCount++;
}
}
});
if (splitCount === 0) {
toast.error("Playhead must be within selected elements to split");
}
splitSelected(currentTime);
};
const handleDuplicateSelected = () => {
@@ -173,15 +154,7 @@ export function TimelineToolbar({
};
const handleDeleteSelected = () => {
if (selectedElements.length === 0) return;
selectedElements.forEach(({ trackId, elementId }) => {
if (rippleEditingEnabled) {
removeElementFromTrackWithRipple(trackId, elementId);
} else {
removeElementFromTrack(trackId, elementId);
}
});
clearSelectedElements();
deleteSelected();
};
const handleZoomIn = () => {
@@ -1147,7 +1147,7 @@ export function TimelineTrackContent({
const handleElementSplit = () => {
const { currentTime } = usePlaybackStore();
const { splitElement } = useTimelineStore();
const { splitSelected } = useTimelineStore();
const splitTime = currentTime;
const effectiveStart = element.startTime;
const effectiveEnd =
@@ -1155,14 +1155,7 @@ export function TimelineTrackContent({
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
const secondElementId = splitElement(
track.id,
element.id,
splitTime
);
if (!secondElementId) {
toast.error("Failed to split element");
}
splitSelected(splitTime, track.id, element.id);
} else {
toast.error("Playhead must be within element to split");
}
@@ -1182,8 +1175,8 @@ export function TimelineTrackContent({
};
const handleElementDelete = () => {
const { removeElementFromTrack } = useTimelineStore.getState();
removeElementFromTrack(track.id, element.id);
const { deleteSelected } = useTimelineStore.getState();
deleteSelected(track.id, element.id);
};
return (