feat: main track and ordering tracks
This commit is contained in:
@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
|
import { TimelineTrack } from "@/types/timeline";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
useTimelineStore,
|
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||||
type TimelineTrack,
|
|
||||||
} from "@/stores/timeline-store";
|
|
||||||
import { TimelineElement } from "@/types/timeline";
|
|
||||||
import {
|
import {
|
||||||
useMediaStore,
|
useMediaStore,
|
||||||
type MediaItem,
|
type MediaItem,
|
||||||
@ -112,16 +109,19 @@ export function PreviewPanel() {
|
|||||||
track.elements.forEach((element) => {
|
track.elements.forEach((element) => {
|
||||||
const elementStart = element.startTime;
|
const elementStart = element.startTime;
|
||||||
const elementEnd =
|
const elementEnd =
|
||||||
element.startTime + (element.duration - element.trimStart - element.trimEnd);
|
element.startTime +
|
||||||
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
|
|
||||||
if (currentTime >= elementStart && currentTime < elementEnd) {
|
if (currentTime >= elementStart && currentTime < elementEnd) {
|
||||||
let mediaItem = null;
|
let mediaItem = null;
|
||||||
|
|
||||||
// Only get media item for media elements
|
// Only get media item for media elements
|
||||||
if (element.type === "media") {
|
if (element.type === "media") {
|
||||||
mediaItem = element.mediaId === "test"
|
mediaItem =
|
||||||
|
element.mediaId === "test"
|
||||||
? null // Test elements don't have a real media item
|
? null // Test elements don't have a real media item
|
||||||
: mediaItems.find((item) => item.id === element.mediaId) || null;
|
: mediaItems.find((item) => item.id === element.mediaId) ||
|
||||||
|
null;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeElements.push({ element, track, mediaItem });
|
activeElements.push({ element, track, mediaItem });
|
||||||
@ -165,9 +165,9 @@ export function PreviewPanel() {
|
|||||||
fontWeight: element.fontWeight,
|
fontWeight: element.fontWeight,
|
||||||
fontStyle: element.fontStyle,
|
fontStyle: element.fontStyle,
|
||||||
textDecoration: element.textDecoration,
|
textDecoration: element.textDecoration,
|
||||||
padding: '4px 8px',
|
padding: "4px 8px",
|
||||||
borderRadius: '2px',
|
borderRadius: "2px",
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: "pre-wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{element.content}
|
{element.content}
|
||||||
@ -262,7 +262,9 @@ export function PreviewPanel() {
|
|||||||
No elements at current time
|
No elements at current time
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activeElements.map((elementData, index) => renderElement(elementData, index))
|
activeElements.map((elementData, index) =>
|
||||||
|
renderElement(elementData, index)
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -305,7 +307,9 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
|||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.type === "media") {
|
if (element.type === "media") {
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
const mediaItem = mediaItems.find(
|
||||||
|
(item) => item.id === element.mediaId
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
mediaItem &&
|
mediaItem &&
|
||||||
(mediaItem.type === "video" || mediaItem.type === "image")
|
(mediaItem.type === "video" || mediaItem.type === "image")
|
||||||
|
@ -13,7 +13,12 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../ui/context-menu";
|
} from "../ui/context-menu";
|
||||||
import { TimelineTrack } from "@/stores/timeline-store";
|
import {
|
||||||
|
TimelineTrack,
|
||||||
|
sortTracksByOrder,
|
||||||
|
ensureMainTrack,
|
||||||
|
getMainTrack
|
||||||
|
} from "@/types/timeline";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import type {
|
import type {
|
||||||
TimelineElement as TimelineElementType,
|
TimelineElement as TimelineElementType,
|
||||||
@ -555,15 +560,23 @@ export function TimelineTrackContent({
|
|||||||
|
|
||||||
// Handle position-aware track creation for text
|
// Handle position-aware track creation for text
|
||||||
if (track.type !== "text" || dropPosition !== "on") {
|
if (track.type !== "text" || dropPosition !== "on") {
|
||||||
// Determine where to insert the new text track
|
// Text tracks should go above the main track
|
||||||
|
const mainTrack = getMainTrack(tracks);
|
||||||
let insertIndex: number;
|
let insertIndex: number;
|
||||||
|
|
||||||
if (dropPosition === "above") {
|
if (dropPosition === "above") {
|
||||||
insertIndex = currentTrackIndex;
|
insertIndex = currentTrackIndex;
|
||||||
} else if (dropPosition === "below") {
|
} else if (dropPosition === "below") {
|
||||||
insertIndex = currentTrackIndex + 1;
|
insertIndex = currentTrackIndex + 1;
|
||||||
} else {
|
} else {
|
||||||
// dropPosition === "on" but track is not text type
|
// dropPosition === "on" but track is not text type
|
||||||
insertIndex = currentTrackIndex + 1;
|
// Insert above main track if main track exists, otherwise at top
|
||||||
|
if (mainTrack) {
|
||||||
|
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
|
||||||
|
insertIndex = mainTrackIndex;
|
||||||
|
} else {
|
||||||
|
insertIndex = 0; // Top of timeline
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
targetTrackId = insertTrackAt("text", insertIndex);
|
targetTrackId = insertTrackAt("text", insertIndex);
|
||||||
@ -646,42 +659,72 @@ export function TimelineTrackContent({
|
|||||||
const needsNewTrack = !isCompatible || dropPosition !== "on";
|
const needsNewTrack = !isCompatible || dropPosition !== "on";
|
||||||
|
|
||||||
if (needsNewTrack) {
|
if (needsNewTrack) {
|
||||||
// Determine where to insert the new track
|
if (isVideoOrImage) {
|
||||||
|
// For video/image, check if we need a main track or additional media track
|
||||||
|
const mainTrack = getMainTrack(tracks);
|
||||||
|
|
||||||
|
if (!mainTrack) {
|
||||||
|
// No main track exists, create it
|
||||||
|
const updatedTracks = ensureMainTrack(tracks);
|
||||||
|
const newMainTrack = getMainTrack(updatedTracks);
|
||||||
|
if (newMainTrack && newMainTrack.elements.length === 0) {
|
||||||
|
targetTrackId = newMainTrack.id;
|
||||||
|
targetTrack = newMainTrack;
|
||||||
|
} else {
|
||||||
|
// Main track was created but somehow has elements, create new media track
|
||||||
|
const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id);
|
||||||
|
targetTrackId = insertTrackAt("media", mainTrackIndex);
|
||||||
|
const updatedTracksAfterInsert = useTimelineStore.getState().tracks;
|
||||||
|
const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId);
|
||||||
|
if (!newTargetTrack) return;
|
||||||
|
targetTrack = newTargetTrack;
|
||||||
|
}
|
||||||
|
} else if (mainTrack.elements.length === 0 && dropPosition === "on") {
|
||||||
|
// Main track exists and is empty, use it
|
||||||
|
targetTrackId = mainTrack.id;
|
||||||
|
targetTrack = mainTrack;
|
||||||
|
} else {
|
||||||
|
// Create new media track above main track
|
||||||
|
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
|
||||||
let insertIndex: number;
|
let insertIndex: number;
|
||||||
|
|
||||||
if (dropPosition === "above") {
|
if (dropPosition === "above") {
|
||||||
insertIndex = currentTrackIndex;
|
insertIndex = currentTrackIndex;
|
||||||
} else if (dropPosition === "below") {
|
} else if (dropPosition === "below") {
|
||||||
insertIndex = currentTrackIndex + 1;
|
insertIndex = currentTrackIndex + 1;
|
||||||
} else {
|
} else {
|
||||||
// dropPosition === "on" but track is incompatible
|
// Insert above main track
|
||||||
insertIndex = currentTrackIndex + 1;
|
insertIndex = mainTrackIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideoOrImage) {
|
|
||||||
// For video/image, check if main media track is empty and at the right position
|
|
||||||
const mainMediaTrack = tracks.find((t) => t.type === "media");
|
|
||||||
if (
|
|
||||||
mainMediaTrack &&
|
|
||||||
mainMediaTrack.elements.length === 0 &&
|
|
||||||
dropPosition === "on"
|
|
||||||
) {
|
|
||||||
targetTrackId = mainMediaTrack.id;
|
|
||||||
targetTrack = mainMediaTrack;
|
|
||||||
} else {
|
|
||||||
targetTrackId = insertTrackAt("media", insertIndex);
|
targetTrackId = insertTrackAt("media", insertIndex);
|
||||||
const updatedTracks = useTimelineStore.getState().tracks;
|
const updatedTracks = useTimelineStore.getState().tracks;
|
||||||
const newTargetTrack = updatedTracks.find(
|
const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
|
||||||
(t) => t.id === targetTrackId
|
|
||||||
);
|
|
||||||
if (!newTargetTrack) return;
|
if (!newTargetTrack) return;
|
||||||
targetTrack = newTargetTrack;
|
targetTrack = newTargetTrack;
|
||||||
}
|
}
|
||||||
} else if (isAudio) {
|
} else if (isAudio) {
|
||||||
|
// Audio tracks go at the bottom
|
||||||
|
const mainTrack = getMainTrack(tracks);
|
||||||
|
let insertIndex: number;
|
||||||
|
|
||||||
|
if (dropPosition === "above") {
|
||||||
|
insertIndex = currentTrackIndex;
|
||||||
|
} else if (dropPosition === "below") {
|
||||||
|
insertIndex = currentTrackIndex + 1;
|
||||||
|
} else {
|
||||||
|
// Insert after main track (bottom area)
|
||||||
|
if (mainTrack) {
|
||||||
|
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
|
||||||
|
insertIndex = mainTrackIndex + 1;
|
||||||
|
} else {
|
||||||
|
insertIndex = tracks.length; // Bottom of timeline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
targetTrackId = insertTrackAt("audio", insertIndex);
|
targetTrackId = insertTrackAt("audio", insertIndex);
|
||||||
const updatedTracks = useTimelineStore.getState().tracks;
|
const updatedTracks = useTimelineStore.getState().tracks;
|
||||||
const newTargetTrack = updatedTracks.find(
|
const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
|
||||||
(t) => t.id === targetTrackId
|
|
||||||
);
|
|
||||||
if (!newTargetTrack) return;
|
if (!newTargetTrack) return;
|
||||||
targetTrack = newTargetTrack;
|
targetTrack = newTargetTrack;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type {
|
import {
|
||||||
TrackType,
|
TrackType,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
CreateTimelineElement,
|
CreateTimelineElement,
|
||||||
|
TimelineTrack,
|
||||||
|
sortTracksByOrder,
|
||||||
|
ensureMainTrack,
|
||||||
} from "@/types/timeline";
|
} from "@/types/timeline";
|
||||||
import { useEditorStore } from "./editor-store";
|
import { useEditorStore } from "./editor-store";
|
||||||
import { useMediaStore, getMediaAspectRatio } from "./media-store";
|
import { useMediaStore, getMediaAspectRatio } from "./media-store";
|
||||||
@ -22,19 +25,18 @@ const getElementNameWithSuffix = (
|
|||||||
return `${baseName} (${suffix})`;
|
return `${baseName} (${suffix})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TimelineTrack {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: TrackType;
|
|
||||||
elements: TimelineElement[];
|
|
||||||
muted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimelineStore {
|
interface TimelineStore {
|
||||||
tracks: TimelineTrack[];
|
// Private track storage
|
||||||
|
_tracks: TimelineTrack[];
|
||||||
history: TimelineTrack[][];
|
history: TimelineTrack[][];
|
||||||
redoStack: TimelineTrack[][];
|
redoStack: TimelineTrack[][];
|
||||||
|
|
||||||
|
// Always returns properly ordered tracks with main track ensured
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
|
||||||
|
// Manual method if you need to force recomputation
|
||||||
|
getSortedTracks: () => TimelineTrack[];
|
||||||
|
|
||||||
// Multi-selection
|
// Multi-selection
|
||||||
selectedElements: { trackId: string; elementId: string }[];
|
selectedElements: { trackId: string; elementId: string }[];
|
||||||
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
|
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
|
||||||
@ -116,28 +118,50 @@ interface TimelineStore {
|
|||||||
pushHistory: () => void;
|
pushHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||||
tracks: [],
|
// Helper to update tracks and maintain ordering
|
||||||
|
const updateTracks = (newTracks: TimelineTrack[]) => {
|
||||||
|
const tracksWithMain = ensureMainTrack(newTracks);
|
||||||
|
const sortedTracks = sortTracksByOrder(tracksWithMain);
|
||||||
|
set({
|
||||||
|
_tracks: tracksWithMain,
|
||||||
|
tracks: sortedTracks,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize with proper track ordering
|
||||||
|
const initialTracks = ensureMainTrack([]);
|
||||||
|
const sortedInitialTracks = sortTracksByOrder(initialTracks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
_tracks: initialTracks,
|
||||||
|
tracks: sortedInitialTracks,
|
||||||
history: [],
|
history: [],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
selectedElements: [],
|
selectedElements: [],
|
||||||
|
|
||||||
|
getSortedTracks: () => {
|
||||||
|
const { _tracks } = get();
|
||||||
|
const tracksWithMain = ensureMainTrack(_tracks);
|
||||||
|
return sortTracksByOrder(tracksWithMain);
|
||||||
|
},
|
||||||
|
|
||||||
pushHistory: () => {
|
pushHistory: () => {
|
||||||
const { tracks, history } = get();
|
const { _tracks, history } = get();
|
||||||
set({
|
set({
|
||||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
history: [...history, JSON.parse(JSON.stringify(_tracks))],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
undo: () => {
|
undo: () => {
|
||||||
const { history, redoStack, tracks } = get();
|
const { history, redoStack, _tracks } = get();
|
||||||
if (history.length === 0) return;
|
if (history.length === 0) return;
|
||||||
const prev = history[history.length - 1];
|
const prev = history[history.length - 1];
|
||||||
|
updateTracks(prev);
|
||||||
set({
|
set({
|
||||||
tracks: prev,
|
|
||||||
history: history.slice(0, -1),
|
history: history.slice(0, -1),
|
||||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
|
redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -199,9 +223,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
elements: [],
|
elements: [],
|
||||||
muted: false,
|
muted: false,
|
||||||
};
|
};
|
||||||
set((state) => ({
|
|
||||||
tracks: [...state.tracks, newTrack],
|
updateTracks([...get()._tracks, newTrack]);
|
||||||
}));
|
|
||||||
return newTrack.id;
|
return newTrack.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -226,26 +249,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
muted: false,
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state) => {
|
const newTracks = [...get()._tracks];
|
||||||
const newTracks = [...state.tracks];
|
|
||||||
newTracks.splice(index, 0, newTrack);
|
newTracks.splice(index, 0, newTrack);
|
||||||
return { tracks: newTracks };
|
updateTracks(newTracks);
|
||||||
});
|
|
||||||
return newTrack.id;
|
return newTrack.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
removeTrack: (trackId) => {
|
removeTrack: (trackId) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
set((state) => ({
|
updateTracks(get()._tracks.filter((track) => track.id !== trackId));
|
||||||
tracks: state.tracks.filter((track) => track.id !== trackId),
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
addElementToTrack: (trackId, elementData) => {
|
addElementToTrack: (trackId, elementData) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// Validate element type matches track type
|
// Validate element type matches track type
|
||||||
const track = get().tracks.find((t) => t.id === trackId);
|
const track = get()._tracks.find((t) => t.id === trackId);
|
||||||
if (!track) {
|
if (!track) {
|
||||||
console.error("Track not found:", trackId);
|
console.error("Track not found:", trackId);
|
||||||
return;
|
return;
|
||||||
@ -279,7 +298,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
|
|
||||||
// Check if this is the first element being added to the timeline
|
// Check if this is the first element being added to the timeline
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
const totalElementsInTimeline = currentState.tracks.reduce(
|
const totalElementsInTimeline = currentState._tracks.reduce(
|
||||||
(total, track) => total + track.elements.length,
|
(total, track) => total + track.elements.length,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
@ -312,20 +331,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? { ...track, elements: [...track.elements, newElement] }
|
? { ...track, elements: [...track.elements, newElement] }
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
removeElementFromTrack: (trackId, elementId) => {
|
removeElementFromTrack: (trackId, elementId) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks
|
get()
|
||||||
.map((track) =>
|
._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -335,23 +354,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
)
|
)
|
||||||
.filter((track) => track.elements.length > 0),
|
.filter((track) => track.elements.length > 0)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
|
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
set((state) => {
|
|
||||||
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
|
||||||
const elementToMove = fromTrack?.elements.find(
|
const elementToMove = fromTrack?.elements.find(
|
||||||
(element) => element.id === elementId
|
(element) => element.id === elementId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!elementToMove) return state;
|
if (!elementToMove) return;
|
||||||
|
|
||||||
return {
|
const newTracks = get()
|
||||||
tracks: state.tracks
|
._tracks.map((track) => {
|
||||||
.map((track) => {
|
|
||||||
if (track.id === fromTrackId) {
|
if (track.id === fromTrackId) {
|
||||||
return {
|
return {
|
||||||
...track,
|
...track,
|
||||||
@ -367,15 +385,15 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return track;
|
return track;
|
||||||
})
|
})
|
||||||
.filter((track) => track.elements.length > 0),
|
.filter((track) => track.elements.length > 0);
|
||||||
};
|
|
||||||
});
|
updateTracks(newTracks);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
|
updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -386,14 +404,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateElementStartTime: (trackId, elementId, startTime) => {
|
updateElementStartTime: (trackId, elementId, startTime) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -402,22 +420,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTrackMute: (trackId) => {
|
toggleTrackMute: (trackId) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId ? { ...track, muted: !track.muted } : track
|
track.id === trackId ? { ...track, muted: !track.muted } : track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
splitElement: (trackId, elementId, splitTime) => {
|
splitElement: (trackId, elementId, splitTime) => {
|
||||||
const { tracks } = get();
|
const { _tracks } = get();
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
|
|
||||||
if (!element) return null;
|
if (!element) return null;
|
||||||
@ -438,8 +456,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
|
|
||||||
const secondElementId = crypto.randomUUID();
|
const secondElementId = crypto.randomUUID();
|
||||||
|
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -463,16 +481,16 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
|
|
||||||
return secondElementId;
|
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();
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
|
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@ -490,8 +508,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
const durationToRemove =
|
const durationToRemove =
|
||||||
element.duration - element.trimStart - element.trimEnd - relativeTime;
|
element.duration - element.trimStart - element.trimEnd - relativeTime;
|
||||||
|
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -506,14 +524,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Split element and keep only the right portion
|
// Split element and keep only the right portion
|
||||||
splitAndKeepRight: (trackId, elementId, splitTime) => {
|
splitAndKeepRight: (trackId, elementId, splitTime) => {
|
||||||
const { tracks } = get();
|
const { _tracks } = get();
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
|
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@ -529,8 +547,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
|
|
||||||
const relativeTime = splitTime - element.startTime;
|
const relativeTime = splitTime - element.startTime;
|
||||||
|
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -546,14 +564,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Extract audio from video element to an audio track
|
// Extract audio from video element to an audio track
|
||||||
separateAudio: (trackId, elementId) => {
|
separateAudio: (trackId, elementId) => {
|
||||||
const { tracks } = get();
|
const { _tracks } = get();
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
|
|
||||||
if (!element || track?.type !== "media") return null;
|
if (!element || track?.type !== "media") return null;
|
||||||
@ -561,13 +579,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// Find existing audio track or prepare to create one
|
// Find existing audio track or prepare to create one
|
||||||
const existingAudioTrack = tracks.find((t) => t.type === "audio");
|
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
|
||||||
const audioElementId = crypto.randomUUID();
|
const audioElementId = crypto.randomUUID();
|
||||||
|
|
||||||
if (existingAudioTrack) {
|
if (existingAudioTrack) {
|
||||||
// Add audio element to existing audio track
|
// Add audio element to existing audio track
|
||||||
set((state) => ({
|
updateTracks(
|
||||||
tracks: state.tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === existingAudioTrack.id
|
track.id === existingAudioTrack.id
|
||||||
? {
|
? {
|
||||||
...track,
|
...track,
|
||||||
@ -581,8 +599,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: track
|
: track
|
||||||
),
|
)
|
||||||
}));
|
);
|
||||||
} else {
|
} else {
|
||||||
// Create new audio track with the audio element in a single atomic update
|
// Create new audio track with the audio element in a single atomic update
|
||||||
const newAudioTrack: TimelineTrack = {
|
const newAudioTrack: TimelineTrack = {
|
||||||
@ -599,19 +617,17 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
muted: false,
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state) => ({
|
updateTracks([...get()._tracks, newAudioTrack]);
|
||||||
tracks: [...state.tracks, newAudioTrack],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return audioElementId;
|
return audioElementId;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTotalDuration: () => {
|
getTotalDuration: () => {
|
||||||
const { tracks } = get();
|
const { _tracks } = get();
|
||||||
if (tracks.length === 0) return 0;
|
if (_tracks.length === 0) return 0;
|
||||||
|
|
||||||
const trackEndTimes = tracks.map((track) =>
|
const trackEndTimes = _tracks.map((track) =>
|
||||||
track.elements.reduce((maxEnd, element) => {
|
track.elements.reduce((maxEnd, element) => {
|
||||||
const elementEnd =
|
const elementEnd =
|
||||||
element.startTime +
|
element.startTime +
|
||||||
@ -629,7 +645,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
const { redoStack } = get();
|
const { redoStack } = get();
|
||||||
if (redoStack.length === 0) return;
|
if (redoStack.length === 0) return;
|
||||||
const next = redoStack[redoStack.length - 1];
|
const next = redoStack[redoStack.length - 1];
|
||||||
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
updateTracks(next);
|
||||||
|
set({ redoStack: redoStack.slice(0, -1) });
|
||||||
},
|
},
|
||||||
|
|
||||||
dragState: {
|
dragState: {
|
||||||
@ -689,4 +706,5 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { MediaType } from "@/stores/media-store";
|
import { MediaType } from "@/stores/media-store";
|
||||||
import { TimelineTrack } from "@/stores/timeline-store";
|
|
||||||
|
|
||||||
export type TrackType = "media" | "text" | "audio";
|
export type TrackType = "media" | "text" | "audio";
|
||||||
|
|
||||||
@ -77,3 +76,50 @@ export interface TextItemDragData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DragData = MediaItemDragData | TextItemDragData;
|
export type DragData = MediaItemDragData | TextItemDragData;
|
||||||
|
|
||||||
|
export interface TimelineTrack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TrackType;
|
||||||
|
elements: TimelineElement[];
|
||||||
|
muted?: boolean;
|
||||||
|
isMain?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] {
|
||||||
|
return [...tracks].sort((a, b) => {
|
||||||
|
// Audio tracks always go to bottom
|
||||||
|
if (a.type === "audio" && b.type !== "audio") return 1;
|
||||||
|
if (b.type === "audio" && a.type !== "audio") return -1;
|
||||||
|
|
||||||
|
// Main track goes above audio but below other tracks
|
||||||
|
if (a.isMain && !b.isMain && b.type !== "audio") return 1;
|
||||||
|
if (b.isMain && !a.isMain && a.type !== "audio") return -1;
|
||||||
|
|
||||||
|
// Within same category, maintain creation order
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainTrack(tracks: TimelineTrack[]): TimelineTrack | null {
|
||||||
|
return tracks.find((track) => track.isMain) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
|
||||||
|
const hasMainTrack = tracks.some((track) => track.isMain);
|
||||||
|
|
||||||
|
if (!hasMainTrack) {
|
||||||
|
// Create main track if it doesn't exist
|
||||||
|
const mainTrack: TimelineTrack = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: "Main Track",
|
||||||
|
type: "media",
|
||||||
|
elements: [],
|
||||||
|
muted: false,
|
||||||
|
isMain: true,
|
||||||
|
};
|
||||||
|
return [mainTrack, ...tracks];
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user