summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/index.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
commite7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch)
tree74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/components/editor/panels/timeline/index.tsx
parent93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff)
downloadopencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/index.tsx')
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx115
1 files changed, 65 insertions, 50 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index d89d270d..593976a8 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
+const TRACKS_CONTAINER_MAX_HEIGHT = 800;
+const FALLBACK_CONTAINER_WIDTH = 1000;
+
export function Timeline() {
- const tracksContainerHeight = { min: 0, max: 800 };
- const { snappingEnabled } = useTimelineStore();
+ const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
const { clearElementSelection, setElementSelection } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const tracks = timeline.getTracks();
const seek = (time: number) => editor.playback.seek({ time });
- // refs
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
@@ -74,7 +76,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- // state
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
@@ -151,6 +152,7 @@ export function Timeline() {
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
headerRef: timelineHeaderRef,
+ tracksScrollRef,
zoomLevel,
});
@@ -169,7 +171,7 @@ export function Timeline() {
zoomLevel,
});
- const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
+ const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@@ -422,7 +424,20 @@ export function Timeline() {
{tracks.length === 0 ? (
<div />
) : (
- tracks.map((track, index) => (
+ [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some(
+ (element) => element.id === dragState.elementId,
+ );
+ const bHasDragged = b.track.elements.some(
+ (element) => element.id === dragState.elementId,
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ })
+ .map(({ track, index }) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
@@ -465,50 +480,50 @@ export function Timeline() {
<ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(e) => {
- e.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={VolumeHighIcon} />
- <span>
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={ViewIcon} />
- <span>
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
+ onClick={(event) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ <span>
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={ViewIcon} />
+ <span>
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.removeTrack({
+ trackId: track.id,
+ });
+ }}
variant="destructive"
>
<HugeiconsIcon icon={Delete02Icon} />