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
+14 -1
View File
@@ -5,15 +5,18 @@ alwaysApply: true
--- ---
# Project Context # 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. 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 ## Key Principles
- Zero configuration required - Zero configuration required
- Subsecond performance - Subsecond performance
- Maximum type safety - Maximum type safety
- AI-friendly code generation - AI-friendly code generation
## Before Writing Code ## Before Writing Code
1. Analyze existing patterns in the codebase 1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios 2. Consider edge cases and error scenarios
3. Follow the rules below strictly 3. Follow the rules below strictly
@@ -23,12 +26,14 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
## Rules ## Rules
### Accessibility (a11y) ### Accessibility (a11y)
- Always include a `title` element for icons unless there's text beside the icon. - Always include a `title` element for icons unless there's text beside the icon.
- Always include a `type` attribute for button elements. - Always include a `type` attribute for button elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
### Code Complexity and Quality ### Code Complexity and Quality
- Don't use primitive type aliases or misleading types. - Don't use primitive type aliases or misleading types.
- Don't use the comma operator. - Don't use the comma operator.
- Use for...of statements instead of Array.forEach. - 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. - Use .flatMap() instead of map().flat() when possible.
### React and JSX Best Practices ### React and JSX Best Practices
- Don't import `React` itself. - Don't import `React` itself.
- Don't define React components inside other components. - Don't define React components inside other components.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. - 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>`. - Use `<>...</>` instead of `<Fragment>...</Fragment>`.
### Function Parameters and Props ### Function Parameters and Props
- Always use destructured props objects instead of individual parameters in functions. - Always use destructured props objects instead of individual parameters in functions.
- Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`. - Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`.
- This applies to all functions, not just React components. - This applies to all functions, not just React components.
### Correctness and Safety ### Correctness and Safety
- Don't assign a value to itself. - Don't assign a value to itself.
- Avoid unused imports and variables. - Avoid unused imports and variables.
- Don't use await inside loops. - 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. - Don't use the TypeScript directive @ts-ignore.
- Make sure the `preconnect` attribute is used when using Google Fonts. - Make sure the `preconnect` attribute is used when using Google Fonts.
- Don't use the `delete` operator. - Don't use the `delete` operator.
- Don't use `require()` in TypeScript/ES modules - use proper `import` statements.
### TypeScript Best Practices ### TypeScript Best Practices
- Don't use TypeScript enums. - Don't use TypeScript enums.
- Use either `T[]` or `Array<T>` consistently. - Use either `T[]` or `Array<T>` consistently.
- Don't use the `any` type. - Don't use the `any` type.
### Style and Consistency ### Style and Consistency
- Don't use global `eval()`. - Don't use global `eval()`.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`. - Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use `else` blocks when the `if` block breaks early. - 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()`. - Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
### Next.js Specific Rules ### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects. - Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects. - Don't use `<head>` elements in Next.js projects.
## Example: Error Handling ## Example: Error Handling
```typescript ```typescript
// ✅ Good: Comprehensive error handling // ✅ Good: Comprehensive error handling
try { try {
const result = await fetchData(); const result = await fetchData();
return { success: true, data: result }; return { success: true, data: result };
} catch (error) { } catch (error) {
console.error('API call failed:', error); console.error("API call failed:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform"; import AudioWaveform from "../audio-waveform";
import { toast } from "sonner"; import { toast } from "sonner";
import { TimelineElementProps } from "@/types/timeline"; import { TimelineElementProps, MediaElement } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import { import {
getTrackElementClasses, getTrackElementClasses,
@@ -42,16 +42,17 @@ export function TimelineElement({
}: TimelineElementProps) { }: TimelineElementProps) {
const { mediaFiles } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { const {
removeElementFromTrack,
removeElementFromTrackWithRipple,
dragState, dragState,
splitElement,
addElementToTrack,
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
toggleElementMuted,
copySelected, copySelected,
selectedElements,
deleteSelected,
splitSelected,
toggleSelectedHidden,
toggleSelectedMuted,
duplicateElement,
revealElementInMedia,
replaceElementWithFile,
getContextMenuState,
} = useTimelineStore(); } = useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
@@ -68,7 +69,12 @@ export function TimelineElement({
zoomLevel, zoomLevel,
}); });
const { requestRevealMedia } = useMediaPanelStore.getState(); const {
isMultipleSelected,
isCurrentElementSelected,
hasAudioElements,
canSplitSelected,
} = getContextMenuState(track.id, element.id);
const effectiveDuration = const effectiveDuration =
element.duration - element.trimStart - element.trimEnd; element.duration - element.trimStart - element.trimEnd;
@@ -77,44 +83,26 @@ export function TimelineElement({
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
); );
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id; const isBeingDragged = dragState.elementId === element.id;
const elementStartTime = const elementStartTime =
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? dragState.currentTime ? dragState.currentTime
: element.startTime; : element.startTime;
// Element should always be positioned at startTime - trimStart only affects content, not position
const elementLeft = elementStartTime * 50 * zoomLevel; const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = (e: React.MouseEvent) => { const handleElementSplitContext = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const effectiveStart = element.startTime; splitSelected(
const effectiveEnd = currentTime,
element.startTime + isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
(element.duration - element.trimStart - element.trimEnd); isMultipleSelected && isCurrentElementSelected ? undefined : element.id
);
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");
}
}; };
const handleElementDuplicateContext = (e: React.MouseEvent) => { const handleElementDuplicateContext = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const { id, ...elementWithoutId } = element; duplicateElement(track.id, element.id);
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
}; };
const handleElementCopyContext = (e: React.MouseEvent) => { const handleElementCopyContext = (e: React.MouseEvent) => {
@@ -124,49 +112,38 @@ export function TimelineElement({
const handleElementDeleteContext = (e: React.MouseEvent) => { const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (rippleEditingEnabled) { deleteSelected(
removeElementFromTrackWithRipple(track.id, element.id); isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
} else { isMultipleSelected && isCurrentElementSelected ? undefined : element.id
removeElementFromTrack(track.id, element.id); );
}
}; };
const handleToggleElementContext = (e: React.MouseEvent) => { const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (hasAudio && element.type === "media") { if (hasAudio && element.type === "media") {
toggleElementMuted(track.id, element.id); toggleSelectedMuted(
return; 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) => { const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation(); 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"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "video/*,audio/*,image/*"; input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => { input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return; if (file) {
await replaceElementWithFile(track.id, element.id, file);
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"}`
);
} }
}; };
input.click(); input.click();
@@ -174,12 +151,7 @@ export function TimelineElement({
const handleRevealInMedia = (e: React.MouseEvent) => { const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (element.type !== "media") { revealElementInMedia(element.id);
toast.error("Reveal is only available for media clips");
return;
}
requestRevealMedia(element.mediaId);
}; };
const renderElementContent = () => { const renderElementContent = () => {
@@ -191,7 +163,6 @@ export function TimelineElement({
); );
} }
// Render media element ->
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) { if (!mediaItem) {
return ( return (
@@ -201,20 +172,15 @@ export function TimelineElement({
); );
} }
const TILE_ASPECT_RATIO = 16 / 9;
if ( if (
mediaItem.type === "image" || mediaItem.type === "image" ||
(mediaItem.type === "video" && mediaItem.thumbnailUrl) (mediaItem.type === "video" && mediaItem.thumbnailUrl)
) { ) {
// Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type); const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight; const tileWidth = trackHeight * (16 / 9);
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
const imageUrl = const imageUrl =
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
const isImage = mediaItem.type === "image";
return ( return (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
@@ -228,18 +194,17 @@ export function TimelineElement({
style={{ style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x", backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`, backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center", backgroundPosition: "left center",
pointerEvents: "none", pointerEvents: "none",
}} }}
aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
/> />
</div> </div>
</div> </div>
); );
} }
// Render audio element ->
if (mediaItem.type === "audio") { if (mediaItem.type === "audio") {
return ( return (
<div className="w-full h-full flex items-center gap-2"> <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 ( return (
<ContextMenu> <ContextMenu>
@@ -332,16 +297,33 @@ export function TimelineElement({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-200"> <ContextMenuContent className="z-200">
{(!isMultipleSelected ||
(isMultipleSelected &&
isCurrentElementSelected &&
canSplitSelected)) && (
<ContextMenuItem onClick={handleElementSplitContext}> <ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" /> <Scissors className="h-4 w-4 mr-2" />
Split at playhead {isMultipleSelected && isCurrentElementSelected
? `Split ${selectedElements.length} elements at playhead`
: "Split at playhead"}
</ContextMenuItem> </ContextMenuItem>
)}
<ContextMenuItem onClick={handleElementCopyContext}> <ContextMenuItem onClick={handleElementCopyContext}>
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
Copy element {isMultipleSelected && isCurrentElementSelected
? `Copy ${selectedElements.length} elements`
: "Copy element"}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementContext}> <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 ? ( isMuted ? (
<Volume2 className="h-4 w-4 mr-2" /> <Volume2 className="h-4 w-4 mr-2" />
) : ( ) : (
@@ -353,21 +335,29 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" /> <EyeOff className="h-4 w-4 mr-2" />
)} )}
<span> <span>
{hasAudio {isMultipleSelected && isCurrentElementSelected
? hasAudioElements
? `Toggle mute ${selectedElements.length} elements`
: `Toggle visibility ${selectedElements.length} elements`
: hasAudio
? isMuted ? isMuted
? "Unmute" ? "Unmute"
: "Mute" : "Mute"
: element.hidden : element.hidden
? "Show" ? "Show"
: "Hide"}{" "} : "Hide"}{" "}
{element.type === "text" ? "text" : "clip"} {!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
</span> </span>
</ContextMenuItem> </ContextMenuItem>
{!isMultipleSelected && (
<ContextMenuItem onClick={handleElementDuplicateContext}> <ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"} Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem> </ContextMenuItem>
{element.type === "media" && ( )}
{!isMultipleSelected && element.type === "media" && (
<> <>
<ContextMenuItem onClick={handleRevealInMedia}> <ContextMenuItem onClick={handleRevealInMedia}>
<Search className="h-4 w-4 mr-2" /> <Search className="h-4 w-4 mr-2" />
@@ -379,13 +369,17 @@ export function TimelineElement({
</ContextMenuItem> </ContextMenuItem>
</> </>
)} )}
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
onClick={handleElementDeleteContext} onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2 className="h-4 w-4 mr-2" /> <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> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -52,11 +52,10 @@ export function TimelineToolbar({
tracks, tracks,
addTrack, addTrack,
addElementToTrack, addElementToTrack,
removeElementFromTrack,
removeElementFromTrackWithRipple,
selectedElements, selectedElements,
clearSelectedElements, clearSelectedElements,
splitElement, deleteSelected,
splitSelected,
splitAndKeepLeft, splitAndKeepLeft,
splitAndKeepRight, splitAndKeepRight,
separateAudio, separateAudio,
@@ -70,25 +69,7 @@ export function TimelineToolbar({
const { scenes, currentScene } = useSceneStore(); const { scenes, currentScene } = useSceneStore();
const handleSplitSelected = () => { const handleSplitSelected = () => {
if (selectedElements.length === 0) return; splitSelected(currentTime);
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");
}
}; };
const handleDuplicateSelected = () => { const handleDuplicateSelected = () => {
@@ -173,15 +154,7 @@ export function TimelineToolbar({
}; };
const handleDeleteSelected = () => { const handleDeleteSelected = () => {
if (selectedElements.length === 0) return; deleteSelected();
selectedElements.forEach(({ trackId, elementId }) => {
if (rippleEditingEnabled) {
removeElementFromTrackWithRipple(trackId, elementId);
} else {
removeElementFromTrack(trackId, elementId);
}
});
clearSelectedElements();
}; };
const handleZoomIn = () => { const handleZoomIn = () => {
@@ -1147,7 +1147,7 @@ export function TimelineTrackContent({
const handleElementSplit = () => { const handleElementSplit = () => {
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
const { splitElement } = useTimelineStore(); const { splitSelected } = useTimelineStore();
const splitTime = currentTime; const splitTime = currentTime;
const effectiveStart = element.startTime; const effectiveStart = element.startTime;
const effectiveEnd = const effectiveEnd =
@@ -1155,14 +1155,7 @@ export function TimelineTrackContent({
(element.duration - element.trimStart - element.trimEnd); (element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) { if (splitTime > effectiveStart && splitTime < effectiveEnd) {
const secondElementId = splitElement( splitSelected(splitTime, track.id, element.id);
track.id,
element.id,
splitTime
);
if (!secondElementId) {
toast.error("Failed to split element");
}
} else { } else {
toast.error("Playhead must be within element to split"); toast.error("Playhead must be within element to split");
} }
@@ -1182,8 +1175,8 @@ export function TimelineTrackContent({
}; };
const handleElementDelete = () => { const handleElementDelete = () => {
const { removeElementFromTrack } = useTimelineStore.getState(); const { deleteSelected } = useTimelineStore.getState();
removeElementFromTrack(track.id, element.id); deleteSelected(track.id, element.id);
}; };
return ( return (
+4 -9
View File
@@ -13,8 +13,8 @@ export function useEditorActions() {
selectedElements, selectedElements,
clearSelectedElements, clearSelectedElements,
setSelectedElements, setSelectedElements,
removeElementFromTrack, deleteSelected,
splitElement, splitSelected,
addElementToTrack, addElementToTrack,
snappingEnabled, snappingEnabled,
toggleSnapping, toggleSnapping,
@@ -135,7 +135,7 @@ export function useEditorActions() {
(element.duration - element.trimStart - element.trimEnd); (element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) { if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitElement(trackId, elementId, currentTime); splitSelected(currentTime, trackId, elementId);
} else { } else {
toast.error("Playhead must be within selected element"); toast.error("Playhead must be within selected element");
} }
@@ -150,12 +150,7 @@ export function useEditorActions() {
if (selectedElements.length === 0) { if (selectedElements.length === 0) {
return; return;
} }
selectedElements.forEach( deleteSelected();
({ trackId, elementId }: { trackId: string; elementId: string }) => {
removeElementFromTrack(trackId, elementId);
}
);
clearSelectedElements();
}, },
undefined undefined
); );
+3 -3
View File
@@ -9,7 +9,7 @@ export const usePlaybackControls = () => {
const { const {
selectedElements, selectedElements,
tracks, tracks,
splitElement, splitSelected,
splitAndKeepLeft, splitAndKeepLeft,
splitAndKeepRight, splitAndKeepRight,
separateAudio, separateAudio,
@@ -37,8 +37,8 @@ export const usePlaybackControls = () => {
return; return;
} }
splitElement(trackId, elementId, currentTime); splitSelected(currentTime, trackId, elementId);
}, [selectedElements, tracks, currentTime, splitElement]); }, [selectedElements, tracks, currentTime, splitSelected]);
const handleSplitAndKeepLeftCallback = useCallback(() => { const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedElements.length !== 1) { if (selectedElements.length !== 1) {
+6
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 there are elements to remove, push history once before batch removal
if (elementsToRemove.length > 0) { if (elementsToRemove.length > 0) {
const {
removeElementFromTrack,
removeElementFromTrackWithRipple,
rippleEditingEnabled,
pushHistory,
} = useTimelineStore.getState();
pushHistory(); pushHistory();
// Remove all elements without pushing additional history entries // Remove all elements without pushing additional history entries
+369 -126
View File
@@ -6,12 +6,13 @@ import {
TimelineTrack, TimelineTrack,
TextElement, TextElement,
DragData, DragData,
MediaElement,
sortTracksByOrder, sortTracksByOrder,
ensureMainTrack, ensureMainTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
} from "@/types/timeline"; } from "@/types/timeline";
import { useMediaStore, getMediaAspectRatio } from "./media-store"; import { useMediaStore, getMediaAspectRatio } from "./media-store";
import { MediaFile } from "@/types/media"; import { MediaFile, MediaType } from "@/types/media";
import { findBestCanvasPreset } from "@/lib/editor-utils"; import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service"; import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store"; import { useProjectStore } from "./project-store";
@@ -20,6 +21,7 @@ import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { usePlaybackStore } from "./playback-store";
// Helper function to manage element naming with suffixes // Helper function to manage element naming with suffixes
const getElementNameWithSuffix = ( const getElementNameWithSuffix = (
@@ -99,11 +101,7 @@ interface TimelineStore {
removeTrack: (trackId: string) => void; removeTrack: (trackId: string) => void;
removeTrackWithRipple: (trackId: string) => void; removeTrackWithRipple: (trackId: string) => void;
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
removeElementFromTrack: (
trackId: string,
elementId: string,
pushHistory?: boolean
) => void;
moveElementToTrack: ( moveElementToTrack: (
fromTrackId: string, fromTrackId: string,
toTrackId: string, toTrackId: string,
@@ -129,15 +127,6 @@ interface TimelineStore {
pushHistory?: boolean pushHistory?: boolean
) => void; ) => void;
toggleTrackMute: (trackId: string) => 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: ( splitAndKeepLeft: (
trackId: string, trackId: string,
elementId: string, elementId: string,
@@ -198,6 +187,33 @@ interface TimelineStore {
// Clipboard actions // Clipboard actions
copySelected: () => void; copySelected: () => void;
pasteAtTime: (time: number) => 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: ( updateTextElement: (
trackId: string, trackId: string,
elementId: string, elementId: string,
@@ -575,16 +591,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().selectElement(trackId, newElement.id); get().selectElement(trackId, newElement.id);
}, },
removeElementFromTrack: (trackId, elementId, pushHistory = true) => { removeElementFromTrackWithRipple: (
const { rippleEditingEnabled } = get(); trackId,
elementId,
pushHistory = true
) => {
const { _tracks, rippleEditingEnabled } = get();
if (rippleEditingEnabled) { if (!rippleEditingEnabled) {
get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory); // Inline non-ripple removal logic
} else {
if (pushHistory) get().pushHistory(); if (pushHistory) get().pushHistory();
updateTracksAndSave( updateTracksAndSave(
get() _tracks
._tracks.map((track) => .map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@@ -596,18 +615,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
) )
.filter((track) => track.elements.length > 0) .filter((track) => track.elements.length > 0)
); );
}
},
removeElementFromTrackWithRipple: (
trackId,
elementId,
pushHistory = true
) => {
const { _tracks, rippleEditingEnabled } = get();
if (!rippleEditingEnabled) {
get().removeElementFromTrack(trackId, elementId, pushHistory);
return; 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) => { updateTextElement: (trackId, elementId, updates) => {
get().pushHistory(); get().pushHistory();
updateTracksAndSave( 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 // Split element and keep only the left portion
splitAndKeepLeft: (trackId, elementId, splitTime) => { splitAndKeepLeft: (trackId, elementId, splitTime) => {
const { _tracks } = get(); 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, name: newFile.name,
type: fileType, type: fileType as MediaType,
file: newFile, file: newFile,
url: URL.createObjectURL(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,
};
},
}; };
}); });