` 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 `
` elements in Next.js projects.
- Don't use `` 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 };
}
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index 677b9c61..ca47d3ca 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -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 (
@@ -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}`}
/>
);
}
- // Render audio element ->
if (mediaItem.type === "audio") {
return (
@@ -267,7 +232,7 @@ export function TimelineElement({
}
};
- const isMuted = element.type === "media" ? element.muted === true : false;
+ const isMuted = element.type === "media" && element.muted;
return (
@@ -332,16 +297,33 @@ export function TimelineElement({
-
-
- Split at playhead
-
+ {(!isMultipleSelected ||
+ (isMultipleSelected &&
+ isCurrentElementSelected &&
+ canSplitSelected)) && (
+
+
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Split ${selectedElements.length} elements at playhead`
+ : "Split at playhead"}
+
+ )}
+
- Copy element
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Copy ${selectedElements.length} elements`
+ : "Copy element"}
+
- {hasAudio ? (
+ {isMultipleSelected && isCurrentElementSelected ? (
+ hasAudioElements ? (
+
+ ) : (
+
+ )
+ ) : hasAudio ? (
isMuted ? (
) : (
@@ -353,21 +335,29 @@ export function TimelineElement({
)}
- {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")}
-
-
- Duplicate {element.type === "text" ? "text" : "clip"}
-
- {element.type === "media" && (
+
+ {!isMultipleSelected && (
+
+
+ Duplicate {element.type === "text" ? "text" : "clip"}
+
+ )}
+
+ {!isMultipleSelected && element.type === "media" && (
<>
@@ -379,13 +369,17 @@ export function TimelineElement({
>
)}
+
+
- Delete {element.type === "text" ? "text" : "clip"}
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedElements.length} elements`
+ : `Delete ${element.type === "text" ? "text" : "clip"}`}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index bec4458c..80c6ce08 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -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 = () => {
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 42c78aa4..7cfde931 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -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 (
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
index 974f9db5..9287d2e0 100644
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -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
);
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
index ecbd1232..c27911d2 100644
--- a/apps/web/src/hooks/use-playback-controls.ts
+++ b/apps/web/src/hooks/use-playback-controls.ts
@@ -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) {
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index e00cffd5..61c771db 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -203,6 +203,12 @@ export const useMediaStore = create((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
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 24b6eca3..aeeb65c5 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -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;
+ 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((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((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((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((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((set, get) => {
};
}
- const mediaData: any = {
+ const mediaData: Omit = {
name: newFile.name,
- type: fileType,
+ type: fileType as MediaType,
file: newFile,
url: URL.createObjectURL(newFile),
};
@@ -1606,6 +1523,332 @@ export const useTimelineStore = create((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,
+ };
+ },
};
});