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