summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx190
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx35
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx15
3 files changed, 100 insertions, 140 deletions
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 (
<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>
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 (