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

View File

@ -5,15 +5,18 @@ alwaysApply: true
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
@ -23,12 +26,14 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
## Rules
### Accessibility (a11y)
- Always include a `title` element for icons unless there's text beside the icon.
- Always include a `type` attribute for button elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
### Code Complexity and Quality
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Use for...of statements instead of Array.forEach.
@ -36,6 +41,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Use .flatMap() instead of map().flat() when possible.
### React and JSX Best Practices
- Don't import `React` itself.
- Don't define React components inside other components.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
@ -43,11 +49,13 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
### Function Parameters and Props
- Always use destructured props objects instead of individual parameters in functions.
- Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`.
- This applies to all functions, not just React components.
### Correctness and Safety
- Don't assign a value to itself.
- Avoid unused imports and variables.
- Don't use await inside loops.
@ -55,13 +63,16 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Don't use the TypeScript directive @ts-ignore.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Don't use the `delete` operator.
- Don't use `require()` in TypeScript/ES modules - use proper `import` statements.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Use either `T[]` or `Array<T>` consistently.
- Don't use the `any` type.
### Style and Consistency
- Don't use global `eval()`.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use `else` blocks when the `if` block breaks early.
@ -70,17 +81,19 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error('API call failed:', error);
console.error("API call failed:", error);
return { success: false, error: error.message };
}

View File

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

View File

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

View File

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

View File

@ -13,8 +13,8 @@ export function useEditorActions() {
selectedElements,
clearSelectedElements,
setSelectedElements,
removeElementFromTrack,
splitElement,
deleteSelected,
splitSelected,
addElementToTrack,
snappingEnabled,
toggleSnapping,
@ -135,7 +135,7 @@ export function useEditorActions() {
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitElement(trackId, elementId, currentTime);
splitSelected(currentTime, trackId, elementId);
} else {
toast.error("Playhead must be within selected element");
}
@ -150,12 +150,7 @@ export function useEditorActions() {
if (selectedElements.length === 0) {
return;
}
selectedElements.forEach(
({ trackId, elementId }: { trackId: string; elementId: string }) => {
removeElementFromTrack(trackId, elementId);
}
);
clearSelectedElements();
deleteSelected();
},
undefined
);

View File

@ -9,7 +9,7 @@ export const usePlaybackControls = () => {
const {
selectedElements,
tracks,
splitElement,
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
@ -37,8 +37,8 @@ export const usePlaybackControls = () => {
return;
}
splitElement(trackId, elementId, currentTime);
}, [selectedElements, tracks, currentTime, splitElement]);
splitSelected(currentTime, trackId, elementId);
}, [selectedElements, tracks, currentTime, splitSelected]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedElements.length !== 1) {

View File

@ -203,6 +203,12 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// If there are elements to remove, push history once before batch removal
if (elementsToRemove.length > 0) {
const {
removeElementFromTrack,
removeElementFromTrackWithRipple,
rippleEditingEnabled,
pushHistory,
} = useTimelineStore.getState();
pushHistory();
// Remove all elements without pushing additional history entries

View File

@ -6,12 +6,13 @@ import {
TimelineTrack,
TextElement,
DragData,
MediaElement,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
import { MediaFile } from "@/types/media";
import { MediaFile, MediaType } from "@/types/media";
import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
@ -20,6 +21,7 @@ import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { usePlaybackStore } from "./playback-store";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@ -99,11 +101,7 @@ interface TimelineStore {
removeTrack: (trackId: string) => void;
removeTrackWithRipple: (trackId: string) => void;
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
removeElementFromTrack: (
trackId: string,
elementId: string,
pushHistory?: boolean
) => void;
moveElementToTrack: (
fromTrackId: string,
toTrackId: string,
@ -129,15 +127,6 @@ interface TimelineStore {
pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
toggleElementHidden: (trackId: string, elementId: string) => void;
toggleElementMuted: (trackId: string, elementId: string) => void;
// Split operations for elements
splitElement: (
trackId: string,
elementId: string,
splitTime: number
) => string | null;
splitAndKeepLeft: (
trackId: string,
elementId: string,
@ -198,6 +187,33 @@ interface TimelineStore {
// Clipboard actions
copySelected: () => void;
pasteAtTime: (time: number) => void;
// Unified selection-aware actions
deleteSelected: (trackId?: string, elementId?: string) => void;
splitSelected: (
splitTime: number,
trackId?: string,
elementId?: string
) => void;
toggleSelectedHidden: (trackId?: string, elementId?: string) => void;
toggleSelectedMuted: (trackId?: string, elementId?: string) => void;
duplicateElement: (trackId: string, elementId: string) => void;
revealElementInMedia: (elementId: string) => void;
replaceElementWithFile: (
trackId: string,
elementId: string,
file: File
) => Promise<void>;
getContextMenuState: (
trackId: string,
elementId: string
) => {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
hasAudioElements: boolean;
canSplitSelected: boolean;
currentTime: number;
};
updateTextElement: (
trackId: string,
elementId: string,
@ -575,16 +591,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().selectElement(trackId, newElement.id);
},
removeElementFromTrack: (trackId, elementId, pushHistory = true) => {
const { rippleEditingEnabled } = get();
removeElementFromTrackWithRipple: (
trackId,
elementId,
pushHistory = true
) => {
const { _tracks, rippleEditingEnabled } = get();
if (rippleEditingEnabled) {
get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory);
} else {
if (!rippleEditingEnabled) {
// Inline non-ripple removal logic
if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()
._tracks.map((track) =>
_tracks
.map((track) =>
track.id === trackId
? {
...track,
@ -596,18 +615,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
)
.filter((track) => track.elements.length > 0)
);
}
},
removeElementFromTrackWithRipple: (
trackId,
elementId,
pushHistory = true
) => {
const { _tracks, rippleEditingEnabled } = get();
if (!rippleEditingEnabled) {
get().removeElementFromTrack(trackId, elementId, pushHistory);
return;
}
@ -864,42 +871,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
toggleElementHidden: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId
? { ...element, hidden: !element.hidden }
: element
),
}
: track
)
);
},
toggleElementMuted: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId && element.type === "media"
? { ...element, muted: !element.muted }
: element
),
}
: track
)
);
},
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
@ -918,60 +889,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
splitElement: (trackId, elementId, splitTime) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) return null;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
get().pushHistory();
const relativeTime = splitTime - element.startTime;
const firstDuration = relativeTime;
const secondDuration =
element.duration - element.trimStart - element.trimEnd - relativeTime;
const secondElementId = generateUUID();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.flatMap((c) =>
c.id === elementId
? [
{
...c,
trimEnd: c.trimEnd + secondDuration,
name: getElementNameWithSuffix(c.name, "left"),
},
{
...c,
id: secondElementId,
startTime: splitTime,
trimStart: c.trimStart + firstDuration,
name: getElementNameWithSuffix(c.name, "right"),
},
]
: [c]
),
}
: track
)
);
return secondElementId;
},
// Split element and keep only the left portion
splitAndKeepLeft: (trackId, elementId, splitTime) => {
const { _tracks } = get();
@ -1150,9 +1067,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
}
const mediaData: any = {
const mediaData: Omit<MediaFile, "id"> = {
name: newFile.name,
type: fileType,
type: fileType as MediaType,
file: newFile,
url: URL.createObjectURL(newFile),
};
@ -1606,6 +1523,332 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
}
},
deleteSelected: (trackId?: string, elementId?: string) => {
const { selectedElements, rippleEditingEnabled } = get();
const elementsToDelete =
trackId && elementId
? [{ trackId, elementId }]
: selectedElements.length > 0
? selectedElements
: [];
if (elementsToDelete.length === 0) return;
get().pushHistory();
if (rippleEditingEnabled) {
for (const { trackId: tId, elementId: eId } of elementsToDelete) {
get().removeElementFromTrackWithRipple(tId, eId, false);
}
} else {
updateTracksAndSave(
get()
._tracks.map((track) => ({
...track,
elements: track.elements.filter(
(element) =>
!elementsToDelete.some(
({ trackId: tId, elementId: eId }) =>
track.id === tId && element.id === eId
)
),
}))
.filter((track) => track.elements.length > 0)
);
}
get().clearSelectedElements();
},
splitSelected: (splitTime, trackId?: string, elementId?: string) => {
const { selectedElements, _tracks } = get();
const elementsToProcess =
trackId && elementId
? [{ trackId, elementId }]
: selectedElements.length > 0
? selectedElements
: [];
if (elementsToProcess.length === 0) return;
const elementsToSplit: Array<{
trackId: string;
elementId: string;
element: TimelineElement;
}> = [];
for (const { trackId: tId, elementId: eId } of elementsToProcess) {
const track = _tracks.find((t) => t.id === tId);
const element = track?.elements.find((e) => e.id === eId);
if (!track || !element) continue;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
elementsToSplit.push({ trackId: tId, elementId: eId, element });
}
}
if (elementsToSplit.length === 0) {
const { toast } = require("sonner");
const isMultiple = elementsToProcess.length > 1;
toast.error(
isMultiple
? "Playhead must be within all selected elements to split"
: "Playhead must be within element to split"
);
return;
}
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) => {
const elementsToSplitInTrack = elementsToSplit.filter(
({ trackId: tId }) => tId === track.id
);
if (elementsToSplitInTrack.length === 0) return track;
return {
...track,
elements: track.elements.flatMap((c) => {
const elementToSplit = elementsToSplitInTrack.find(
({ elementId: eId }) => eId === c.id
);
if (!elementToSplit) return [c];
const relativeTime = splitTime - elementToSplit.element.startTime;
const firstDuration = relativeTime;
const secondDuration =
elementToSplit.element.duration -
elementToSplit.element.trimStart -
elementToSplit.element.trimEnd -
relativeTime;
const secondElementId = generateUUID();
return [
{
...c,
trimEnd: c.trimEnd + secondDuration,
name: getElementNameWithSuffix(c.name, "left"),
},
{
...c,
id: secondElementId,
startTime: splitTime,
trimStart: c.trimStart + firstDuration,
name: getElementNameWithSuffix(c.name, "right"),
},
];
}),
};
})
);
},
toggleSelectedHidden: (trackId?: string, elementId?: string) => {
const { selectedElements, _tracks } = get();
const elementsToProcess =
trackId && elementId
? [{ trackId, elementId }]
: selectedElements.length > 0
? selectedElements
: [];
if (elementsToProcess.length === 0) return;
get().pushHistory();
const shouldHide = elementsToProcess.some(
({ trackId: tId, elementId: eId }) => {
const track = _tracks.find((t) => t.id === tId);
const element = track?.elements.find((e) => e.id === eId);
return element && !element.hidden;
}
);
updateTracksAndSave(
_tracks.map((track) => ({
...track,
elements: track.elements.map((element) => {
const shouldUpdate = elementsToProcess.some(
({ trackId: tId, elementId: eId }) =>
track.id === tId && element.id === eId
);
return shouldUpdate && element.hidden !== shouldHide
? { ...element, hidden: shouldHide }
: element;
}),
}))
);
},
toggleSelectedMuted: (trackId?: string, elementId?: string) => {
const { selectedElements, _tracks } = get();
const elementsToProcess =
trackId && elementId
? [{ trackId, elementId }]
: selectedElements.length > 0
? selectedElements
: [];
if (elementsToProcess.length === 0) return;
get().pushHistory();
const audioElements = elementsToProcess.filter(
({ trackId: tId, elementId: eId }) => {
const track = _tracks.find((t) => t.id === tId);
const element = track?.elements.find((e) => e.id === eId);
return element?.type === "media";
}
);
if (audioElements.length === 0) return;
const shouldMute = audioElements.some(
({ trackId: tId, elementId: eId }) => {
const track = _tracks.find((t) => t.id === tId);
const element = track?.elements.find((e) => e.id === eId);
return element?.type === "media" && !element.muted;
}
);
updateTracksAndSave(
_tracks.map((track) => ({
...track,
elements: track.elements.map((element) => {
const shouldUpdate = audioElements.some(
({ trackId: tId, elementId: eId }) =>
track.id === tId && element.id === eId
);
return shouldUpdate &&
element.type === "media" &&
element.muted !== shouldMute
? { ...element, muted: shouldMute }
: element;
}),
}))
);
},
duplicateElement: (trackId, elementId) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!track || !element) return;
const { id, ...elementWithoutId } = element;
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
get().addElementToTrack(trackId, {
...elementWithoutId,
name: `${element.name} (copy)`,
startTime: element.startTime + effectiveDuration + 0.1,
} as CreateTimelineElement);
},
revealElementInMedia: (elementId) => {
const {
useMediaPanelStore,
} = require("../components/editor/media-panel/store");
const { requestRevealMedia } = useMediaPanelStore.getState();
const { _tracks } = get();
const element = _tracks
.flatMap((track) => track.elements)
.find((el) => el.id === elementId);
if (element?.type === "media") {
requestRevealMedia(element.mediaId);
}
},
replaceElementWithFile: async (trackId, elementId, file) => {
try {
const result = await get().replaceElementMedia(
trackId,
elementId,
file
);
if (result.success) {
const { toast } = await import("sonner");
toast.success("Clip replaced successfully");
} else {
const { toast } = await import("sonner");
toast.error(result.error || "Failed to replace clip");
}
} catch (error) {
console.error("Unexpected error replacing clip:", error);
const { toast } = await import("sonner");
toast.error(
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
},
getContextMenuState: (trackId, elementId) => {
const { selectedElements, _tracks } = get();
const { currentTime } = usePlaybackStore.getState();
const { mediaFiles } = useMediaStore.getState();
const isMultipleSelected = selectedElements.length > 1;
const isCurrentElementSelected = selectedElements.some(
(sel) => sel.trackId === trackId && sel.elementId === elementId
);
const hasAudioElements = selectedElements.some(
({ trackId: tId, elementId: eId }) => {
const selectedTrack = _tracks.find((t) => t.id === tId);
const selectedElement = selectedTrack?.elements.find(
(e) => e.id === eId
);
if (selectedElement?.type !== "media") return false;
const mediaElement = selectedElement as MediaElement;
const mediaItem = mediaFiles.find(
(file: MediaFile) => file.id === mediaElement.mediaId
);
return mediaItem?.type === "audio" || mediaItem?.type === "video";
}
);
const canSplitSelected = selectedElements.every(
({ trackId: tId, elementId: eId }) => {
const selectedTrack = _tracks.find((t) => t.id === tId);
const selectedElement = selectedTrack?.elements.find(
(e) => e.id === eId
);
if (!selectedElement) return false;
const effectiveStart = selectedElement.startTime;
const effectiveEnd =
selectedElement.startTime +
(selectedElement.duration -
selectedElement.trimStart -
selectedElement.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
}
);
return {
isMultipleSelected,
isCurrentElementSelected,
hasAudioElements,
canSplitSelected,
currentTime,
};
},
};
});