removed timeline anchor
This commit is contained in:
parent
d719460f32
commit
1fd74904e9
|
|
@ -219,8 +219,8 @@ use-edge-auto-scroll.ts
|
||||||
|
|
||||||
use-scroll-sync.ts
|
use-scroll-sync.ts
|
||||||
export function useScrollSync({
|
export function useScrollSync({
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
rulerScrollRef,
|
||||||
trackLabelsScrollRef,
|
trackLabelsScrollRef,
|
||||||
bookmarksScrollRef,
|
bookmarksScrollRef,
|
||||||
}: UseScrollSyncProps)
|
}: UseScrollSyncProps)
|
||||||
|
|
@ -247,6 +247,7 @@ use-snap-indicator-position.ts
|
||||||
use-timeline-drag-drop.ts
|
use-timeline-drag-drop.ts
|
||||||
export function useTimelineDragDrop({
|
export function useTimelineDragDrop({
|
||||||
containerRef,
|
containerRef,
|
||||||
|
headerRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
}: UseTimelineDragDropProps)
|
}: UseTimelineDragDropProps)
|
||||||
|
|
||||||
|
|
@ -296,13 +297,14 @@ use-timeline-snapping.ts
|
||||||
}: UseTimelineSnappingOptions = {})
|
}: UseTimelineSnappingOptions = {})
|
||||||
|
|
||||||
use-timeline-zoom.ts
|
use-timeline-zoom.ts
|
||||||
export function useTimelineZoom({
|
export function useTimelineZoom({
|
||||||
containerRef,
|
containerRef,
|
||||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||||
initialZoom,
|
initialZoom,
|
||||||
initialScrollLeft,
|
initialScrollLeft,
|
||||||
tracksScrollRef,
|
initialPlayheadTime,
|
||||||
rulerScrollRef,
|
tracksScrollRef,
|
||||||
|
rulerScrollRef,
|
||||||
}: UseTimelineZoomProps): UseTimelineZoomReturn
|
}: UseTimelineZoomProps): UseTimelineZoomReturn
|
||||||
|
|
||||||
## apps/web/src/hooks/timeline/element
|
## apps/web/src/hooks/timeline/element
|
||||||
|
|
@ -313,6 +315,7 @@ use-element-interaction.ts
|
||||||
timelineRef,
|
timelineRef,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef,
|
||||||
snappingEnabled,
|
snappingEnabled,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
}: UseElementInteractionProps)
|
}: UseElementInteractionProps)
|
||||||
|
|
@ -554,6 +557,13 @@ time.ts
|
||||||
duration: number;
|
duration: number;
|
||||||
fps: number;
|
fps: number;
|
||||||
}): number
|
}): number
|
||||||
|
export function getLastFrameTime({
|
||||||
|
duration,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
duration: number;
|
||||||
|
fps: number;
|
||||||
|
}): number
|
||||||
|
|
||||||
## apps/web/src/lib/actions
|
## apps/web/src/lib/actions
|
||||||
|
|
||||||
|
|
@ -1045,12 +1055,28 @@ track-utils.ts
|
||||||
}): { isValid: boolean; errorMessage?: string }
|
}): { isValid: boolean; errorMessage?: string }
|
||||||
|
|
||||||
zoom-utils.ts
|
zoom-utils.ts
|
||||||
export function getTimelineZoomMin({
|
export function getTimelineZoomMin({
|
||||||
duration,
|
duration,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
}: {
|
}: {
|
||||||
duration: number;
|
duration: number;
|
||||||
containerWidth: number | null | undefined;
|
containerWidth: number | null | undefined;
|
||||||
|
}): number
|
||||||
|
export function getTimelinePaddingPx({
|
||||||
|
containerWidth,
|
||||||
|
zoomLevel,
|
||||||
|
minZoom,
|
||||||
|
}: {
|
||||||
|
containerWidth: number;
|
||||||
|
zoomLevel: number;
|
||||||
|
minZoom: number;
|
||||||
|
}): number
|
||||||
|
export function getZoomPercent({
|
||||||
|
zoomLevel,
|
||||||
|
minZoom,
|
||||||
|
}: {
|
||||||
|
zoomLevel: number;
|
||||||
|
minZoom: number;
|
||||||
}): number
|
}): number
|
||||||
|
|
||||||
## apps/web/src/lib/transcription
|
## apps/web/src/lib/transcription
|
||||||
|
|
@ -1460,6 +1486,7 @@ project.ts
|
||||||
export interface TTimelineViewState {
|
export interface TTimelineViewState {
|
||||||
zoomLevel: number
|
zoomLevel: number
|
||||||
scrollLeft: number
|
scrollLeft: number
|
||||||
|
playheadTime: number
|
||||||
}
|
}
|
||||||
export interface TProject {
|
export interface TProject {
|
||||||
metadata: TProjectMetadata
|
metadata: TProjectMetadata
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
|
import { getLastFrameTime } from "@/lib/time";
|
||||||
|
|
||||||
function usePreviewSize() {
|
function usePreviewSize() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
@ -77,7 +78,12 @@ function PreviewCanvas() {
|
||||||
const render = useCallback(() => {
|
const render = useCallback(() => {
|
||||||
if (ref.current && renderTree && !renderingRef.current) {
|
if (ref.current && renderTree && !renderingRef.current) {
|
||||||
const time = editor.playback.getCurrentTime();
|
const time = editor.playback.getCurrentTime();
|
||||||
const frame = Math.floor(time * renderer.fps);
|
const lastFrameTime = getLastFrameTime({
|
||||||
|
duration: renderTree.duration,
|
||||||
|
fps: renderer.fps,
|
||||||
|
});
|
||||||
|
const renderTime = Math.min(time, lastFrameTime);
|
||||||
|
const frame = Math.floor(renderTime * renderer.fps);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
frame !== lastFrameRef.current ||
|
frame !== lastFrameRef.current ||
|
||||||
|
|
@ -87,7 +93,11 @@ function PreviewCanvas() {
|
||||||
lastSceneRef.current = renderTree;
|
lastSceneRef.current = renderTree;
|
||||||
lastFrameRef.current = frame;
|
lastFrameRef.current = frame;
|
||||||
renderer
|
renderer
|
||||||
.renderToCanvas({ node: renderTree, time, targetCanvas: ref.current })
|
.renderToCanvas({
|
||||||
|
node: renderTree,
|
||||||
|
time: renderTime,
|
||||||
|
targetCanvas: ref.current,
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
renderingRef.current = false;
|
renderingRef.current = false;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { getSnappedSeekTime } from "@/lib/time";
|
import { getSnappedSeekTime } from "@/lib/time";
|
||||||
|
|
@ -8,7 +7,6 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
interface TimelineBookmarksRowProps {
|
interface TimelineBookmarksRowProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dynamicTimelineWidth: number;
|
dynamicTimelineWidth: number;
|
||||||
bookmarksScrollRef: React.RefObject<HTMLDivElement>;
|
|
||||||
handleWheel: (e: React.WheelEvent) => void;
|
handleWheel: (e: React.WheelEvent) => void;
|
||||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||||
|
|
@ -18,7 +16,6 @@ interface TimelineBookmarksRowProps {
|
||||||
export function TimelineBookmarksRow({
|
export function TimelineBookmarksRow({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dynamicTimelineWidth,
|
dynamicTimelineWidth,
|
||||||
bookmarksScrollRef,
|
|
||||||
handleWheel,
|
handleWheel,
|
||||||
handleTimelineContentClick,
|
handleTimelineContentClick,
|
||||||
handleRulerTrackingMouseDown,
|
handleRulerTrackingMouseDown,
|
||||||
|
|
@ -29,30 +26,28 @@ export function TimelineBookmarksRow({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-4 flex-1 overflow-hidden">
|
<div className="relative h-4 flex-1 overflow-hidden">
|
||||||
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
|
<button
|
||||||
<button
|
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
|
||||||
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
|
style={{
|
||||||
style={{
|
width: `${dynamicTimelineWidth}px`,
|
||||||
width: `${dynamicTimelineWidth}px`,
|
}}
|
||||||
}}
|
aria-label="Timeline ruler"
|
||||||
aria-label="Timeline ruler"
|
type="button"
|
||||||
type="button"
|
onWheel={handleWheel}
|
||||||
onWheel={handleWheel}
|
onClick={handleTimelineContentClick}
|
||||||
onClick={handleTimelineContentClick}
|
onMouseDown={(event) => {
|
||||||
onMouseDown={(event) => {
|
handleRulerMouseDown(event);
|
||||||
handleRulerMouseDown(event);
|
handleRulerTrackingMouseDown(event);
|
||||||
handleRulerTrackingMouseDown(event);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{activeScene.bookmarks.map((time: number) => (
|
||||||
{activeScene.bookmarks.map((time: number) => (
|
<TimelineBookmark
|
||||||
<TimelineBookmark
|
key={`bookmark-row-${time}`}
|
||||||
key={`bookmark-row-${time}`}
|
time={time}
|
||||||
time={time}
|
zoomLevel={zoomLevel}
|
||||||
zoomLevel={zoomLevel}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</button>
|
||||||
</button>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,24 @@ interface DragLineProps {
|
||||||
dropTarget: DropTarget | null;
|
dropTarget: DropTarget | null;
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
headerHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) {
|
export function DragLine({
|
||||||
|
dropTarget,
|
||||||
|
tracks,
|
||||||
|
isVisible,
|
||||||
|
headerHeight = 0,
|
||||||
|
}: DragLineProps) {
|
||||||
if (!isVisible || !dropTarget) return null;
|
if (!isVisible || !dropTarget) return null;
|
||||||
|
|
||||||
const y = getDropLineY({ dropTarget, tracks });
|
const y = getDropLineY({ dropTarget, tracks });
|
||||||
|
const lineTop = y + headerHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
|
className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
|
||||||
style={{ top: `${y}px` }}
|
style={{ top: `${lineTop}px` }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
canTracktHaveAudio,
|
canTracktHaveAudio,
|
||||||
canTrackBeHidden,
|
canTrackBeHidden,
|
||||||
getTimelineZoomMin,
|
getTimelineZoomMin,
|
||||||
|
getTimelinePaddingPx,
|
||||||
isMainTrack,
|
isMainTrack,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { TimelineToolbar } from "./timeline-toolbar";
|
import { TimelineToolbar } from "./timeline-toolbar";
|
||||||
|
|
@ -63,14 +64,13 @@ export function Timeline() {
|
||||||
|
|
||||||
// refs
|
// refs
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineHeaderRef = useRef<HTMLDivElement>(null);
|
||||||
const rulerRef = useRef<HTMLDivElement>(null);
|
const rulerRef = useRef<HTMLDivElement>(null);
|
||||||
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const rulerScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||||
const playheadRef = useRef<HTMLDivElement>(null);
|
const playheadRef = useRef<HTMLDivElement>(null);
|
||||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const bookmarksScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// state
|
// state
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
@ -94,7 +94,7 @@ export function Timeline() {
|
||||||
const timelineDuration = timeline.getTotalDuration() || 0;
|
const timelineDuration = timeline.getTotalDuration() || 0;
|
||||||
const minZoomLevel = getTimelineZoomMin({
|
const minZoomLevel = getTimelineZoomMin({
|
||||||
duration: timelineDuration,
|
duration: timelineDuration,
|
||||||
containerWidth: timelineRef.current?.clientWidth,
|
containerWidth: tracksContainerRef.current?.clientWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedViewState = editor.project.getTimelineViewState();
|
const savedViewState = editor.project.getTimelineViewState();
|
||||||
|
|
@ -105,8 +105,9 @@ export function Timeline() {
|
||||||
minZoom: minZoomLevel,
|
minZoom: minZoomLevel,
|
||||||
initialZoom: savedViewState?.zoomLevel,
|
initialZoom: savedViewState?.zoomLevel,
|
||||||
initialScrollLeft: savedViewState?.scrollLeft,
|
initialScrollLeft: savedViewState?.scrollLeft,
|
||||||
|
initialPlayheadTime: savedViewState?.playheadTime,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
rulerScrollRef,
|
rulerScrollRef: tracksScrollRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -120,6 +121,7 @@ export function Timeline() {
|
||||||
timelineRef,
|
timelineRef,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef: timelineHeaderRef,
|
||||||
snappingEnabled,
|
snappingEnabled,
|
||||||
onSnapPointChange: handleSnapPointChange,
|
onSnapPointChange: handleSnapPointChange,
|
||||||
});
|
});
|
||||||
|
|
@ -128,13 +130,14 @@ export function Timeline() {
|
||||||
useTimelinePlayhead({
|
useTimelinePlayhead({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
rulerRef,
|
rulerRef,
|
||||||
rulerScrollRef,
|
rulerScrollRef: tracksScrollRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
playheadRef,
|
playheadRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
|
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
|
||||||
containerRef: tracksContainerRef,
|
containerRef: tracksContainerRef,
|
||||||
|
headerRef: timelineHeaderRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -152,11 +155,17 @@ export function Timeline() {
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const paddedDuration =
|
const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
|
||||||
timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
|
const contentWidth =
|
||||||
|
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||||
|
const paddingPx = getTimelinePaddingPx({
|
||||||
|
containerWidth,
|
||||||
|
zoomLevel,
|
||||||
|
minZoom: minZoomLevel,
|
||||||
|
});
|
||||||
const dynamicTimelineWidth = Math.max(
|
const dynamicTimelineWidth = Math.max(
|
||||||
paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
contentWidth + paddingPx,
|
||||||
timelineRef.current?.clientWidth || 1000,
|
containerWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
const showSnapIndicator =
|
const showSnapIndicator =
|
||||||
|
|
@ -172,7 +181,7 @@ export function Timeline() {
|
||||||
} = useTimelineSeek({
|
} = useTimelineSeek({
|
||||||
playheadRef,
|
playheadRef,
|
||||||
trackLabelsRef,
|
trackLabelsRef,
|
||||||
rulerScrollRef,
|
rulerScrollRef: tracksScrollRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
duration: timeline.getTotalDuration(),
|
duration: timeline.getTotalDuration(),
|
||||||
|
|
@ -182,12 +191,13 @@ export function Timeline() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useScrollSync({
|
useScrollSync({
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
trackLabelsScrollRef,
|
trackLabelsScrollRef,
|
||||||
bookmarksScrollRef,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timelineHeaderHeight =
|
||||||
|
timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={
|
className={
|
||||||
|
|
@ -206,18 +216,6 @@ export function Timeline() {
|
||||||
className="relative flex flex-1 flex-col overflow-hidden"
|
className="relative flex flex-1 flex-col overflow-hidden"
|
||||||
ref={timelineRef}
|
ref={timelineRef}
|
||||||
>
|
>
|
||||||
<TimelinePlayhead
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
rulerRef={rulerRef}
|
|
||||||
rulerScrollRef={rulerScrollRef}
|
|
||||||
tracksScrollRef={tracksScrollRef}
|
|
||||||
trackLabelsRef={trackLabelsRef}
|
|
||||||
timelineRef={timelineRef}
|
|
||||||
playheadRef={playheadRef}
|
|
||||||
isSnappingToPlayhead={
|
|
||||||
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SnapIndicator
|
<SnapIndicator
|
||||||
snapPoint={currentSnapPoint}
|
snapPoint={currentSnapPoint}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
|
|
@ -227,106 +225,75 @@ export function Timeline() {
|
||||||
tracksScrollRef={tracksScrollRef}
|
tracksScrollRef={tracksScrollRef}
|
||||||
isVisible={showSnapIndicator}
|
isVisible={showSnapIndicator}
|
||||||
/>
|
/>
|
||||||
<div className="bg-panel sticky top-0 z-10 flex flex-col">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
|
|
||||||
<span className="opacity-0">.</span>
|
|
||||||
</div>
|
|
||||||
<TimelineRuler
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
|
||||||
rulerRef={rulerRef}
|
|
||||||
rulerScrollRef={rulerScrollRef}
|
|
||||||
handleWheel={handleWheel}
|
|
||||||
handleTimelineContentClick={handleRulerClick}
|
|
||||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
|
||||||
handleRulerMouseDown={handlePlayheadRulerMouseDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
|
|
||||||
<span className="opacity-0">.</span>
|
|
||||||
</div>
|
|
||||||
<TimelineBookmarksRow
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
|
||||||
bookmarksScrollRef={bookmarksScrollRef}
|
|
||||||
handleWheel={handleWheel}
|
|
||||||
handleTimelineContentClick={handleRulerClick}
|
|
||||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
|
||||||
handleRulerMouseDown={handlePlayheadRulerMouseDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{tracks.length > 0 && (
|
<div className="bg-panel flex w-28 shrink-0 flex-col border-r">
|
||||||
<div
|
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
||||||
ref={trackLabelsRef}
|
<span className="opacity-0">.</span>
|
||||||
className="bg-panel z-100 w-28 shrink-0 overflow-y-auto border-r"
|
|
||||||
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
|
|
||||||
>
|
|
||||||
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{tracks.map((track) => (
|
|
||||||
<div
|
|
||||||
key={track.id}
|
|
||||||
className="group flex items-center px-3"
|
|
||||||
style={{
|
|
||||||
height: `${getTrackHeight({ type: track.type })}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
|
||||||
{process.env.NODE_ENV === "development" &&
|
|
||||||
isMainTrack(track) && (
|
|
||||||
<div className="bg-red-500 size-1.5 rounded-full" />
|
|
||||||
)}
|
|
||||||
{canTracktHaveAudio(track) && (
|
|
||||||
<TrackToggleIcon
|
|
||||||
isOff={track.muted}
|
|
||||||
icons={{
|
|
||||||
on: VolumeHighIcon,
|
|
||||||
off: VolumeOffIcon,
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
editor.timeline.toggleTrackMute({
|
|
||||||
trackId: track.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canTrackBeHidden(track) && (
|
|
||||||
<TrackToggleIcon
|
|
||||||
isOff={track.hidden}
|
|
||||||
icons={{
|
|
||||||
on: ViewIcon,
|
|
||||||
off: ViewOffSlashIcon,
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
editor.timeline.toggleTrackVisibility({
|
|
||||||
trackId: track.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TrackIcon track={track} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
||||||
|
<span className="opacity-0">.</span>
|
||||||
|
</div>
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={trackLabelsRef}
|
||||||
|
className="bg-panel flex-1 overflow-y-auto"
|
||||||
|
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
|
||||||
|
>
|
||||||
|
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="group flex items-center px-3"
|
||||||
|
style={{
|
||||||
|
height: `${getTrackHeight({ type: track.type })}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||||
|
{process.env.NODE_ENV === "development" &&
|
||||||
|
isMainTrack(track) && (
|
||||||
|
<div className="bg-red-500 size-1.5 rounded-full" />
|
||||||
|
)}
|
||||||
|
{canTracktHaveAudio(track) && (
|
||||||
|
<TrackToggleIcon
|
||||||
|
isOff={track.muted}
|
||||||
|
icons={{
|
||||||
|
on: VolumeHighIcon,
|
||||||
|
off: VolumeOffIcon,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
editor.timeline.toggleTrackMute({
|
||||||
|
trackId: track.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canTrackBeHidden(track) && (
|
||||||
|
<TrackToggleIcon
|
||||||
|
isOff={track.hidden}
|
||||||
|
icons={{
|
||||||
|
on: ViewIcon,
|
||||||
|
off: ViewOffSlashIcon,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
editor.timeline.toggleTrackVisibility({
|
||||||
|
trackId: track.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TrackIcon track={track} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative flex-1 overflow-hidden"
|
className="relative flex flex-1 flex-col overflow-hidden"
|
||||||
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
|
|
||||||
onWheel={(e) => {
|
|
||||||
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleWheel(e);
|
|
||||||
}}
|
|
||||||
ref={tracksContainerRef}
|
ref={tracksContainerRef}
|
||||||
>
|
>
|
||||||
<SelectionBox
|
<SelectionBox
|
||||||
|
|
@ -339,15 +306,16 @@ export function Timeline() {
|
||||||
dropTarget={dropTarget}
|
dropTarget={dropTarget}
|
||||||
tracks={timeline.getTracks()}
|
tracks={timeline.getTracks()}
|
||||||
isVisible={isDragOver}
|
isVisible={isDragOver}
|
||||||
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
<DragLine
|
<DragLine
|
||||||
dropTarget={dragDropTarget}
|
dropTarget={dragDropTarget}
|
||||||
tracks={timeline.getTracks()}
|
tracks={timeline.getTracks()}
|
||||||
isVisible={dragState.isDragging}
|
isVisible={dragState.isDragging}
|
||||||
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="size-full"
|
className="size-full overflow-y-hidden"
|
||||||
ref={tracksScrollRef}
|
ref={tracksScrollRef}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
const isDirectTarget = event.target === event.currentTarget;
|
const isDirectTarget = event.target === event.currentTarget;
|
||||||
|
|
@ -362,111 +330,161 @@ export function Timeline() {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleTracksClick(event);
|
handleTracksClick(event);
|
||||||
}}
|
}}
|
||||||
|
onWheel={(event) => {
|
||||||
|
if (
|
||||||
|
event.shiftKey ||
|
||||||
|
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleWheel(event);
|
||||||
|
}}
|
||||||
onScroll={() => {
|
onScroll={() => {
|
||||||
saveScrollPosition();
|
saveScrollPosition();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative flex-1"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
height: `${Math.max(
|
|
||||||
tracksContainerHeight.min,
|
|
||||||
Math.min(
|
|
||||||
tracksContainerHeight.max,
|
|
||||||
getTotalTracksHeight({ tracks }),
|
|
||||||
),
|
|
||||||
)}px`,
|
|
||||||
width: `${dynamicTimelineWidth}px`,
|
width: `${dynamicTimelineWidth}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tracks.length === 0 ? (
|
<div
|
||||||
<div />
|
ref={timelineHeaderRef}
|
||||||
) : (
|
className="bg-panel sticky top-0 z-30 flex flex-col"
|
||||||
tracks.map((track, index) => (
|
>
|
||||||
<ContextMenu key={track.id}>
|
<TimelineRuler
|
||||||
<ContextMenuTrigger asChild>
|
zoomLevel={zoomLevel}
|
||||||
<div
|
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||||
className="absolute right-0 left-0"
|
rulerRef={rulerRef}
|
||||||
style={{
|
handleWheel={handleWheel}
|
||||||
top: `${getCumulativeHeightBefore({
|
handleTimelineContentClick={handleRulerClick}
|
||||||
tracks,
|
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||||
trackIndex: index,
|
handleRulerMouseDown={handlePlayheadRulerMouseDown}
|
||||||
})}px`,
|
/>
|
||||||
height: `${getTrackHeight({ type: track.type })}px`,
|
<TimelineBookmarksRow
|
||||||
}}
|
zoomLevel={zoomLevel}
|
||||||
>
|
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||||
<TimelineTrackContent
|
handleWheel={handleWheel}
|
||||||
track={track}
|
handleTimelineContentClick={handleRulerClick}
|
||||||
zoomLevel={zoomLevel}
|
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||||
dragState={dragState}
|
handleRulerMouseDown={handlePlayheadRulerMouseDown}
|
||||||
rulerScrollRef={rulerScrollRef}
|
/>
|
||||||
tracksScrollRef={tracksScrollRef}
|
</div>
|
||||||
lastMouseXRef={lastMouseXRef}
|
<TimelinePlayhead
|
||||||
onSnapPointChange={handleSnapPointChange}
|
zoomLevel={zoomLevel}
|
||||||
onResizeStateChange={handleResizeStateChange}
|
rulerRef={rulerRef}
|
||||||
onElementMouseDown={handleElementMouseDown}
|
rulerScrollRef={tracksScrollRef}
|
||||||
onElementClick={handleElementClick}
|
tracksScrollRef={tracksScrollRef}
|
||||||
onTrackMouseDown={handleSelectionMouseDown}
|
timelineRef={timelineRef}
|
||||||
shouldIgnoreClick={shouldIgnoreClick}
|
playheadRef={playheadRef}
|
||||||
/>
|
isSnappingToPlayhead={
|
||||||
</div>
|
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
||||||
</ContextMenuTrigger>
|
}
|
||||||
<ContextMenuContent className="z-200 w-40">
|
/>
|
||||||
<ContextMenuItem
|
<div
|
||||||
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
|
className="relative"
|
||||||
onClick={(e) => {
|
style={{
|
||||||
e.stopPropagation();
|
height: `${Math.max(
|
||||||
invokeAction("paste-copied");
|
tracksContainerHeight.min,
|
||||||
}}
|
Math.min(
|
||||||
>
|
tracksContainerHeight.max,
|
||||||
Paste elements
|
getTotalTracksHeight({ tracks }),
|
||||||
</ContextMenuItem>
|
),
|
||||||
<ContextMenuItem
|
)}px`,
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
>
|
||||||
timeline.toggleTrackMute({
|
{tracks.length === 0 ? (
|
||||||
trackId: track.id,
|
<div />
|
||||||
});
|
) : (
|
||||||
}}
|
tracks.map((track, index) => (
|
||||||
>
|
<ContextMenu key={track.id}>
|
||||||
<HugeiconsIcon icon={VolumeHighIcon} />
|
<ContextMenuTrigger asChild>
|
||||||
<span>
|
<div
|
||||||
{canTracktHaveAudio(track) && track.muted
|
className="absolute right-0 left-0"
|
||||||
? "Unmute track"
|
style={{
|
||||||
: "Mute track"}
|
top: `${getCumulativeHeightBefore({
|
||||||
</span>
|
tracks,
|
||||||
</ContextMenuItem>
|
trackIndex: index,
|
||||||
<ContextMenuItem
|
})}px`,
|
||||||
onClick={(e) => {
|
height: `${getTrackHeight({
|
||||||
e.stopPropagation();
|
type: track.type,
|
||||||
timeline.toggleTrackVisibility({
|
})}px`,
|
||||||
trackId: track.id,
|
}}
|
||||||
});
|
>
|
||||||
}}
|
<TimelineTrackContent
|
||||||
>
|
track={track}
|
||||||
<HugeiconsIcon icon={ViewIcon} />
|
zoomLevel={zoomLevel}
|
||||||
<span>
|
dragState={dragState}
|
||||||
{canTrackBeHidden(track) && track.hidden
|
rulerScrollRef={tracksScrollRef}
|
||||||
? "Show track"
|
tracksScrollRef={tracksScrollRef}
|
||||||
: "Hide track"}
|
lastMouseXRef={lastMouseXRef}
|
||||||
</span>
|
onSnapPointChange={handleSnapPointChange}
|
||||||
</ContextMenuItem>
|
onResizeStateChange={handleResizeStateChange}
|
||||||
<ContextMenuItem
|
onElementMouseDown={handleElementMouseDown}
|
||||||
onClick={(e) => {
|
onElementClick={handleElementClick}
|
||||||
e.stopPropagation();
|
onTrackMouseDown={handleSelectionMouseDown}
|
||||||
timeline.removeTrack({
|
shouldIgnoreClick={shouldIgnoreClick}
|
||||||
trackId: track.id,
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
</ContextMenuTrigger>
|
||||||
variant="destructive"
|
<ContextMenuContent className="z-200 w-40">
|
||||||
>
|
<ContextMenuItem
|
||||||
<HugeiconsIcon icon={Delete02Icon} />
|
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
|
||||||
Delete track
|
onClick={(e) => {
|
||||||
</ContextMenuItem>
|
e.stopPropagation();
|
||||||
</ContextMenuContent>
|
invokeAction("paste-copied");
|
||||||
</ContextMenu>
|
}}
|
||||||
))
|
>
|
||||||
)}
|
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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} />
|
||||||
|
Delete track
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef } from "react";
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
|
@ -10,7 +10,6 @@ interface TimelinePlayheadProps {
|
||||||
rulerRef: React.RefObject<HTMLDivElement>;
|
rulerRef: React.RefObject<HTMLDivElement>;
|
||||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
timelineRef: React.RefObject<HTMLDivElement>;
|
timelineRef: React.RefObject<HTMLDivElement>;
|
||||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||||
isSnappingToPlayhead?: boolean;
|
isSnappingToPlayhead?: boolean;
|
||||||
|
|
@ -21,17 +20,14 @@ export function TimelinePlayhead({
|
||||||
rulerRef,
|
rulerRef,
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
trackLabelsRef,
|
|
||||||
timelineRef,
|
timelineRef,
|
||||||
playheadRef: externalPlayheadRef,
|
playheadRef: externalPlayheadRef,
|
||||||
isSnappingToPlayhead = false,
|
isSnappingToPlayhead = false,
|
||||||
}: TimelinePlayheadProps) {
|
}: TimelinePlayheadProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const duration = editor.timeline.getTotalDuration();
|
const duration = editor.timeline.getTotalDuration();
|
||||||
const tracks = editor.timeline.getTracks();
|
|
||||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||||
const [scrollLeft, setScrollLeft] = useState(0);
|
|
||||||
|
|
||||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -41,48 +37,15 @@ export function TimelinePlayhead({
|
||||||
playheadRef,
|
playheadRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const timelineContainerHeight =
|
||||||
const tracksViewport = tracksScrollRef.current;
|
tracksScrollRef.current?.clientHeight ??
|
||||||
|
timelineRef.current?.clientHeight ??
|
||||||
if (!tracksViewport) return;
|
400;
|
||||||
|
const totalHeight = Math.max(0, timelineContainerHeight - 4);
|
||||||
const handleScroll = () => {
|
|
||||||
setScrollLeft(tracksViewport.scrollLeft);
|
|
||||||
};
|
|
||||||
|
|
||||||
setScrollLeft(tracksViewport.scrollLeft);
|
|
||||||
|
|
||||||
tracksViewport.addEventListener("scroll", handleScroll);
|
|
||||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [tracksScrollRef]);
|
|
||||||
|
|
||||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
|
||||||
const totalHeight = timelineContainerHeight - 4;
|
|
||||||
|
|
||||||
const trackLabelsWidth =
|
|
||||||
tracks.length > 0 && trackLabelsRef?.current
|
|
||||||
? trackLabelsRef.current.offsetWidth
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const timelinePosition =
|
const timelinePosition =
|
||||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||||
const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
const leftPosition = timelinePosition;
|
||||||
|
|
||||||
const timelineContentWidth =
|
|
||||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
const tracksViewport = tracksScrollRef.current;
|
|
||||||
const viewportWidth = tracksViewport?.clientWidth || 1000;
|
|
||||||
|
|
||||||
const leftBoundary = trackLabelsWidth;
|
|
||||||
const rightBoundary = Math.min(
|
|
||||||
trackLabelsWidth + timelineContentWidth - scrollLeft,
|
|
||||||
trackLabelsWidth + viewportWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
const leftPosition = Math.max(
|
|
||||||
leftBoundary,
|
|
||||||
Math.min(rightBoundary, rawLeftPosition),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePlayheadKeyDown = (
|
const handlePlayheadKeyDown = (
|
||||||
event: React.KeyboardEvent<HTMLDivElement>,
|
event: React.KeyboardEvent<HTMLDivElement>,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
|
@ -8,7 +7,6 @@ interface TimelineRulerProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dynamicTimelineWidth: number;
|
dynamicTimelineWidth: number;
|
||||||
rulerRef: React.RefObject<HTMLDivElement>;
|
rulerRef: React.RefObject<HTMLDivElement>;
|
||||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
|
||||||
handleWheel: (e: React.WheelEvent) => void;
|
handleWheel: (e: React.WheelEvent) => void;
|
||||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||||
|
|
@ -19,7 +17,6 @@ export function TimelineRuler({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dynamicTimelineWidth,
|
dynamicTimelineWidth,
|
||||||
rulerRef,
|
rulerRef,
|
||||||
rulerScrollRef,
|
|
||||||
handleWheel,
|
handleWheel,
|
||||||
handleTimelineContentClick,
|
handleTimelineContentClick,
|
||||||
handleRulerTrackingMouseDown,
|
handleRulerTrackingMouseDown,
|
||||||
|
|
@ -59,19 +56,17 @@ export function TimelineRuler({
|
||||||
onMouseDown={handleRulerTrackingMouseDown}
|
onMouseDown={handleRulerTrackingMouseDown}
|
||||||
onKeyDown={() => {}}
|
onKeyDown={() => {}}
|
||||||
>
|
>
|
||||||
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
|
<div
|
||||||
<div
|
role="none"
|
||||||
role="none"
|
ref={rulerRef}
|
||||||
ref={rulerRef}
|
className="relative h-4 cursor-default select-none"
|
||||||
className="relative h-4 cursor-default select-none"
|
style={{
|
||||||
style={{
|
width: `${dynamicTimelineWidth}px`,
|
||||||
width: `${dynamicTimelineWidth}px`,
|
}}
|
||||||
}}
|
onMouseDown={handleRulerMouseDown}
|
||||||
onMouseDown={handleRulerMouseDown}
|
>
|
||||||
>
|
{timelineTicks}
|
||||||
{timelineTicks}
|
</div>
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,18 +36,17 @@ export const TIMELINE_CONSTANTS = {
|
||||||
PIXELS_PER_SECOND: 50,
|
PIXELS_PER_SECOND: 50,
|
||||||
DEFAULT_ELEMENT_DURATION: 5,
|
DEFAULT_ELEMENT_DURATION: 5,
|
||||||
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
|
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
|
||||||
PADDING_TOP: 0, // px
|
PADDING_TOP_PX: 0,
|
||||||
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
|
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
|
||||||
ZOOM_MIN: 0.1,
|
ZOOM_MIN: 0.1,
|
||||||
ZOOM_MAX: 50,
|
ZOOM_MAX: 50,
|
||||||
ZOOM_STEP: 0.1,
|
ZOOM_STEP: 0.1,
|
||||||
ZOOM_OUT_FACTOR: 4,
|
|
||||||
ZOOM_PLAYHEAD_ANCHOR_THRESHOLD: 0.0002,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
|
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
|
||||||
zoomLevel: 1,
|
zoomLevel: 1,
|
||||||
scrollLeft: 0,
|
scrollLeft: 0,
|
||||||
|
playheadTime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
|
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,7 @@ export class PlaybackManager {
|
||||||
const duration = this.editor.timeline.getTotalDuration();
|
const duration = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const activeProject = this.editor.project.getActive();
|
if (this.currentTime >= duration) {
|
||||||
const fps = activeProject.settings.fps;
|
|
||||||
const frameOffset = 1 / fps;
|
|
||||||
const endThreshold = Math.max(0, duration - frameOffset);
|
|
||||||
|
|
||||||
if (this.currentTime >= endThreshold) {
|
|
||||||
this.seek({ time: 0 });
|
this.seek({ time: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,18 +137,13 @@ export class PlaybackManager {
|
||||||
const duration = this.editor.timeline.getTotalDuration();
|
const duration = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
if (duration > 0 && newTime >= duration) {
|
if (duration > 0 && newTime >= duration) {
|
||||||
const activeProject = this.editor.project.getActive();
|
|
||||||
const fps = activeProject.settings.fps;
|
|
||||||
const frameOffset = 1 / fps;
|
|
||||||
const stopTime = Math.max(0, duration - frameOffset);
|
|
||||||
|
|
||||||
this.pause();
|
this.pause();
|
||||||
this.currentTime = stopTime;
|
this.currentTime = duration;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("playback-seek", {
|
new CustomEvent("playback-seek", {
|
||||||
detail: { time: stopTime },
|
detail: { time: duration },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -156,16 +156,16 @@ export function useEditorActions() {
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (elementsToSplit.length === 0) return;
|
if (elementsToSplit.length === 0) return;
|
||||||
|
|
||||||
editor.timeline.splitElements({
|
editor.timeline.splitElements({
|
||||||
elements: elementsToSplit,
|
elements: elementsToSplit,
|
||||||
splitTime: currentTime,
|
splitTime: currentTime,
|
||||||
retainSide: "right",
|
retainSide: "right",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"split-right",
|
"split-right",
|
||||||
|
|
@ -220,9 +220,9 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"duplicate-selected",
|
"duplicate-selected",
|
||||||
() => {
|
() => {
|
||||||
editor.timeline.duplicateElements({
|
editor.timeline.duplicateElements({
|
||||||
elements: selectedElements,
|
elements: selectedElements,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -276,12 +276,12 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"paste-copied",
|
"paste-copied",
|
||||||
() => {
|
() => {
|
||||||
if (!clipboard?.items.length) return;
|
if (!clipboard?.items.length) return;
|
||||||
|
|
||||||
editor.timeline.pasteAtTime({
|
editor.timeline.pasteAtTime({
|
||||||
time: editor.playback.getCurrentTime(),
|
time: editor.playback.getCurrentTime(),
|
||||||
clipboardItems: clipboard.items,
|
clipboardItems: clipboard.items,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ interface UseElementInteractionProps {
|
||||||
timelineRef: RefObject<HTMLDivElement | null>;
|
timelineRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
snappingEnabled: boolean;
|
snappingEnabled: boolean;
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +105,7 @@ function getDragDropTarget({
|
||||||
tracks,
|
tracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
snappedTime,
|
snappedTime,
|
||||||
verticalDragDirection,
|
verticalDragDirection,
|
||||||
|
|
@ -115,6 +117,7 @@ function getDragDropTarget({
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
snappedTime: number;
|
snappedTime: number;
|
||||||
verticalDragDirection?: "up" | "down" | null;
|
verticalDragDirection?: "up" | "down" | null;
|
||||||
|
|
@ -131,9 +134,11 @@ function getDragDropTarget({
|
||||||
|
|
||||||
const elementDuration = movingElement.duration;
|
const elementDuration = movingElement.duration;
|
||||||
const scrollLeft = scrollContainer.scrollLeft;
|
const scrollLeft = scrollContainer.scrollLeft;
|
||||||
|
const scrollTop = scrollContainer.scrollTop;
|
||||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||||
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
|
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
|
||||||
const mouseY = clientY - containerRect.top;
|
const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
|
||||||
|
|
||||||
return computeDropTarget({
|
return computeDropTarget({
|
||||||
elementType: movingElement.type,
|
elementType: movingElement.type,
|
||||||
|
|
@ -165,6 +170,7 @@ export function useElementInteraction({
|
||||||
timelineRef,
|
timelineRef,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef,
|
||||||
snappingEnabled,
|
snappingEnabled,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
}: UseElementInteractionProps) {
|
}: UseElementInteractionProps) {
|
||||||
|
|
@ -368,6 +374,7 @@ export function useElementInteraction({
|
||||||
tracks,
|
tracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
snappedTime,
|
snappedTime,
|
||||||
verticalDragDirection,
|
verticalDragDirection,
|
||||||
|
|
@ -391,6 +398,7 @@ export function useElementInteraction({
|
||||||
timelineRef,
|
timelineRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
|
headerRef,
|
||||||
tracks,
|
tracks,
|
||||||
isPendingDrag,
|
isPendingDrag,
|
||||||
startDrag,
|
startDrag,
|
||||||
|
|
@ -423,6 +431,7 @@ export function useElementInteraction({
|
||||||
tracks,
|
tracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
snappedTime: dragState.currentTime,
|
snappedTime: dragState.currentTime,
|
||||||
verticalDragDirection: getVerticalDragDirection({
|
verticalDragDirection: getVerticalDragDirection({
|
||||||
|
|
@ -485,6 +494,7 @@ export function useElementInteraction({
|
||||||
editor.timeline,
|
editor.timeline,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
headerRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface UseScrollSyncProps {
|
interface UseScrollSyncProps {
|
||||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
rulerScrollRef?: React.RefObject<HTMLDivElement>;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||||
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
|
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
|
||||||
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
|
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScrollSync({
|
export function useScrollSync({
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
rulerScrollRef,
|
||||||
trackLabelsScrollRef,
|
trackLabelsScrollRef,
|
||||||
bookmarksScrollRef,
|
bookmarksScrollRef,
|
||||||
}: UseScrollSyncProps) {
|
}: UseScrollSyncProps) {
|
||||||
|
|
@ -20,23 +20,36 @@ export function useScrollSync({
|
||||||
const lastBookmarksSync = useRef(0);
|
const lastBookmarksSync = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rulerViewport = rulerScrollRef.current;
|
const rulerViewport = rulerScrollRef?.current ?? null;
|
||||||
const tracksViewport = tracksScrollRef.current;
|
const tracksViewport = tracksScrollRef.current;
|
||||||
const trackLabelsViewport = trackLabelsScrollRef?.current;
|
const trackLabelsViewport = trackLabelsScrollRef?.current;
|
||||||
const bookmarksViewport = bookmarksScrollRef?.current;
|
const bookmarksViewport = bookmarksScrollRef?.current;
|
||||||
let handleBookmarksScroll: (() => void) | null = null;
|
let handleBookmarksScroll: (() => void) | null = null;
|
||||||
|
|
||||||
if (!rulerViewport || !tracksViewport) return;
|
if (!tracksViewport) return;
|
||||||
|
|
||||||
|
const syncScrollLeft = ({
|
||||||
|
target,
|
||||||
|
scrollLeft,
|
||||||
|
}: {
|
||||||
|
target: HTMLDivElement | null | undefined;
|
||||||
|
scrollLeft: number;
|
||||||
|
}) => {
|
||||||
|
if (target) {
|
||||||
|
target.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRulerScroll = () => {
|
const handleRulerScroll = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
||||||
lastRulerSync.current = now;
|
lastRulerSync.current = now;
|
||||||
isUpdatingRef.current = true;
|
isUpdatingRef.current = true;
|
||||||
tracksViewport.scrollLeft = rulerViewport.scrollLeft;
|
tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
|
||||||
if (bookmarksViewport) {
|
syncScrollLeft({
|
||||||
bookmarksViewport.scrollLeft = rulerViewport.scrollLeft;
|
target: bookmarksViewport,
|
||||||
}
|
scrollLeft: rulerViewport?.scrollLeft ?? 0,
|
||||||
|
});
|
||||||
isUpdatingRef.current = false;
|
isUpdatingRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -45,15 +58,23 @@ export function useScrollSync({
|
||||||
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
|
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
|
||||||
lastTracksSync.current = now;
|
lastTracksSync.current = now;
|
||||||
isUpdatingRef.current = true;
|
isUpdatingRef.current = true;
|
||||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
|
syncScrollLeft({
|
||||||
if (bookmarksViewport) {
|
target: rulerViewport,
|
||||||
bookmarksViewport.scrollLeft = tracksViewport.scrollLeft;
|
scrollLeft: tracksViewport.scrollLeft,
|
||||||
}
|
});
|
||||||
|
syncScrollLeft({
|
||||||
|
target: bookmarksViewport,
|
||||||
|
scrollLeft: tracksViewport.scrollLeft,
|
||||||
|
});
|
||||||
isUpdatingRef.current = false;
|
isUpdatingRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
if (rulerViewport && rulerViewport !== tracksViewport) {
|
||||||
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
||||||
|
}
|
||||||
|
if (tracksViewport !== rulerViewport) {
|
||||||
|
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
||||||
|
}
|
||||||
|
|
||||||
if (bookmarksViewport) {
|
if (bookmarksViewport) {
|
||||||
handleBookmarksScroll = () => {
|
handleBookmarksScroll = () => {
|
||||||
|
|
@ -62,12 +83,18 @@ export function useScrollSync({
|
||||||
return;
|
return;
|
||||||
lastBookmarksSync.current = now;
|
lastBookmarksSync.current = now;
|
||||||
isUpdatingRef.current = true;
|
isUpdatingRef.current = true;
|
||||||
tracksViewport.scrollLeft = bookmarksViewport.scrollLeft;
|
const nextScrollLeft = bookmarksViewport.scrollLeft;
|
||||||
rulerViewport.scrollLeft = bookmarksViewport.scrollLeft;
|
tracksViewport.scrollLeft = nextScrollLeft;
|
||||||
|
syncScrollLeft({
|
||||||
|
target: rulerViewport,
|
||||||
|
scrollLeft: nextScrollLeft,
|
||||||
|
});
|
||||||
isUpdatingRef.current = false;
|
isUpdatingRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
|
if (bookmarksViewport !== tracksViewport) {
|
||||||
|
bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackLabelsViewport) {
|
if (trackLabelsViewport) {
|
||||||
|
|
@ -95,9 +122,17 @@ export function useScrollSync({
|
||||||
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
|
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
if (rulerViewport && rulerViewport !== tracksViewport) {
|
||||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||||
if (bookmarksViewport && handleBookmarksScroll) {
|
}
|
||||||
|
if (tracksViewport !== rulerViewport) {
|
||||||
|
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bookmarksViewport &&
|
||||||
|
bookmarksViewport !== tracksViewport &&
|
||||||
|
handleBookmarksScroll
|
||||||
|
) {
|
||||||
bookmarksViewport.removeEventListener(
|
bookmarksViewport.removeEventListener(
|
||||||
"scroll",
|
"scroll",
|
||||||
handleBookmarksScroll,
|
handleBookmarksScroll,
|
||||||
|
|
@ -115,9 +150,17 @@ export function useScrollSync({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
if (rulerViewport && rulerViewport !== tracksViewport) {
|
||||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||||
if (bookmarksViewport && handleBookmarksScroll) {
|
}
|
||||||
|
if (tracksViewport !== rulerViewport) {
|
||||||
|
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bookmarksViewport &&
|
||||||
|
bookmarksViewport !== tracksViewport &&
|
||||||
|
handleBookmarksScroll
|
||||||
|
) {
|
||||||
bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
|
bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ import type { MediaDragData, StickerDragData } from "@/types/drag";
|
||||||
|
|
||||||
interface UseTimelineDragDropProps {
|
interface UseTimelineDragDropProps {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimelineDragDrop({
|
export function useTimelineDragDrop({
|
||||||
containerRef,
|
containerRef,
|
||||||
|
headerRef,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
}: UseTimelineDragDropProps) {
|
}: UseTimelineDragDropProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
@ -93,6 +95,8 @@ export function useTimelineDragDrop({
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
|
const headerHeight =
|
||||||
|
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||||
const isExternal =
|
const isExternal =
|
||||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||||
|
|
@ -116,7 +120,7 @@ export function useTimelineDragDrop({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||||
|
|
||||||
const target = computeDropTarget({
|
const target = computeDropTarget({
|
||||||
elementType,
|
elementType,
|
||||||
|
|
@ -137,6 +141,7 @@ export function useTimelineDragDrop({
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
containerRef,
|
containerRef,
|
||||||
|
headerRef,
|
||||||
tracks,
|
tracks,
|
||||||
currentTime,
|
currentTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -416,7 +421,9 @@ export function useTimelineDragDrop({
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
const mouseY = e.clientY - rect.top;
|
const headerHeight =
|
||||||
|
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||||
|
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||||
await executeFileDrop({
|
await executeFileDrop({
|
||||||
files: Array.from(e.dataTransfer.files),
|
files: Array.from(e.dataTransfer.files),
|
||||||
mouseX,
|
mouseX,
|
||||||
|
|
@ -435,6 +442,7 @@ export function useTimelineDragDrop({
|
||||||
executeMediaDrop,
|
executeMediaDrop,
|
||||||
executeFileDrop,
|
executeFileDrop,
|
||||||
containerRef,
|
containerRef,
|
||||||
|
headerRef,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,7 @@ export function useTimelinePlayhead({
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
duration,
|
duration,
|
||||||
clampedMouseX /
|
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const framesPerSecond = activeProject.settings.fps;
|
const framesPerSecond = activeProject.settings.fps;
|
||||||
|
|
@ -134,7 +133,16 @@ export function useTimelinePlayhead({
|
||||||
|
|
||||||
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
|
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
|
||||||
setIsScrubbing(false);
|
setIsScrubbing(false);
|
||||||
if (scrubTime !== null) seek({ time: scrubTime });
|
if (scrubTime !== null) {
|
||||||
|
seek({ time: scrubTime });
|
||||||
|
editor.project.setTimelineViewState({
|
||||||
|
viewState: {
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
|
||||||
|
playheadTime: scrubTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
setScrubTime(null);
|
setScrubTime(null);
|
||||||
|
|
||||||
if (isDraggingRuler) {
|
if (isDraggingRuler) {
|
||||||
|
|
@ -163,6 +171,9 @@ export function useTimelinePlayhead({
|
||||||
handleScrub,
|
handleScrub,
|
||||||
isDraggingRuler,
|
isDraggingRuler,
|
||||||
hasDraggedRuler,
|
hasDraggedRuler,
|
||||||
|
editor,
|
||||||
|
tracksScrollRef,
|
||||||
|
zoomLevel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,13 @@ export function useTimelineSeek({
|
||||||
fps: projectFps,
|
fps: projectFps,
|
||||||
});
|
});
|
||||||
seek(time);
|
seek(time);
|
||||||
|
editor.project.setTimelineViewState({
|
||||||
|
viewState: {
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft: scrollContainer.scrollLeft,
|
||||||
|
playheadTime: time,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
duration,
|
duration,
|
||||||
|
|
@ -150,6 +157,7 @@ export function useTimelineSeek({
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
seek,
|
seek,
|
||||||
|
editor,
|
||||||
activeProject?.settings.fps,
|
activeProject?.settings.fps,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
type RefObject,
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
@ -14,6 +15,7 @@ interface UseTimelineZoomProps {
|
||||||
minZoom?: number;
|
minZoom?: number;
|
||||||
initialZoom?: number;
|
initialZoom?: number;
|
||||||
initialScrollLeft?: number;
|
initialScrollLeft?: number;
|
||||||
|
initialPlayheadTime?: number;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
@ -30,11 +32,13 @@ export function useTimelineZoom({
|
||||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||||
initialZoom,
|
initialZoom,
|
||||||
initialScrollLeft,
|
initialScrollLeft,
|
||||||
|
initialPlayheadTime,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
const hasRestoredPlayheadRef = useRef(false);
|
||||||
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -49,50 +53,8 @@ export function useTimelineZoom({
|
||||||
}
|
}
|
||||||
return minZoom;
|
return minZoom;
|
||||||
});
|
});
|
||||||
|
const previousZoomRef = useRef(zoomLevel);
|
||||||
const applyZoomAnchor = useCallback(
|
const hasRestoredScrollRef = useRef(false);
|
||||||
({
|
|
||||||
previousZoom,
|
|
||||||
nextZoom,
|
|
||||||
}: {
|
|
||||||
previousZoom: number;
|
|
||||||
nextZoom: number;
|
|
||||||
}) => {
|
|
||||||
const scrollElement = tracksScrollRef.current;
|
|
||||||
if (!scrollElement) return;
|
|
||||||
|
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
|
||||||
const zoomPercent =
|
|
||||||
(nextZoom - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
|
|
||||||
const shouldAnchorToPlayhead =
|
|
||||||
zoomPercent > TIMELINE_CONSTANTS.ZOOM_PLAYHEAD_ANCHOR_THRESHOLD;
|
|
||||||
|
|
||||||
let newScrollLeft: number;
|
|
||||||
|
|
||||||
if (shouldAnchorToPlayhead) {
|
|
||||||
const playheadPixelsBefore =
|
|
||||||
currentTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
|
|
||||||
const playheadViewportOffset =
|
|
||||||
playheadPixelsBefore - scrollElement.scrollLeft;
|
|
||||||
const playheadPixelsAfter =
|
|
||||||
currentTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * nextZoom;
|
|
||||||
newScrollLeft = playheadPixelsAfter - playheadViewportOffset;
|
|
||||||
} else {
|
|
||||||
const timeAtLeftEdge =
|
|
||||||
scrollElement.scrollLeft /
|
|
||||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom);
|
|
||||||
newScrollLeft =
|
|
||||||
timeAtLeftEdge * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * nextZoom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clampedScrollLeft = Math.max(0, newScrollLeft);
|
|
||||||
scrollElement.scrollLeft = clampedScrollLeft;
|
|
||||||
if (rulerScrollRef.current) {
|
|
||||||
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editor, minZoom, tracksScrollRef, rulerScrollRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(event: ReactWheelEvent) => {
|
(event: ReactWheelEvent) => {
|
||||||
|
|
@ -112,21 +74,6 @@ export function useTimelineZoom({
|
||||||
minZoom,
|
minZoom,
|
||||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
||||||
);
|
);
|
||||||
applyZoomAnchor({
|
|
||||||
previousZoom: prev,
|
|
||||||
nextZoom,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollElement = tracksScrollRef.current;
|
|
||||||
if (scrollElement) {
|
|
||||||
editor.project.setTimelineViewState({
|
|
||||||
viewState: {
|
|
||||||
zoomLevel: nextZoom,
|
|
||||||
scrollLeft: scrollElement.scrollLeft,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextZoom;
|
return nextZoom;
|
||||||
});
|
});
|
||||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||||
|
|
@ -134,7 +81,7 @@ export function useTimelineZoom({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[minZoom, applyZoomAnchor, editor, tracksScrollRef],
|
[minZoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -153,7 +100,6 @@ export function useTimelineZoom({
|
||||||
});
|
});
|
||||||
}, [minZoom, initialZoom]);
|
}, [minZoom, initialZoom]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
|
||||||
const wrappedSetZoomLevel = useCallback(
|
const wrappedSetZoomLevel = useCallback(
|
||||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
||||||
setZoomLevel((prev) => {
|
setZoomLevel((prev) => {
|
||||||
|
|
@ -165,27 +111,30 @@ export function useTimelineZoom({
|
||||||
minZoom,
|
minZoom,
|
||||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
|
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
|
||||||
);
|
);
|
||||||
applyZoomAnchor({
|
|
||||||
previousZoom: prev,
|
|
||||||
nextZoom: clampedZoom,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollElement = tracksScrollRef.current;
|
|
||||||
if (scrollElement) {
|
|
||||||
editor.project.setTimelineViewState({
|
|
||||||
viewState: {
|
|
||||||
zoomLevel: clampedZoom,
|
|
||||||
scrollLeft: scrollElement.scrollLeft,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return clampedZoom;
|
return clampedZoom;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[minZoom, applyZoomAnchor, editor],
|
[minZoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const previousZoom = previousZoomRef.current;
|
||||||
|
if (previousZoom === zoomLevel) return;
|
||||||
|
|
||||||
|
const scrollElement = tracksScrollRef.current;
|
||||||
|
if (scrollElement) {
|
||||||
|
editor.project.setTimelineViewState({
|
||||||
|
viewState: {
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft: scrollElement.scrollLeft,
|
||||||
|
playheadTime: editor.playback.getCurrentTime(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previousZoomRef.current = zoomLevel;
|
||||||
|
}, [zoomLevel, editor, tracksScrollRef]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
||||||
const saveScrollPosition = useCallback(() => {
|
const saveScrollPosition = useCallback(() => {
|
||||||
if (scrollSaveTimeoutRef.current) {
|
if (scrollSaveTimeoutRef.current) {
|
||||||
|
|
@ -198,6 +147,7 @@ export function useTimelineZoom({
|
||||||
viewState: {
|
viewState: {
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft: scrollElement.scrollLeft,
|
scrollLeft: scrollElement.scrollLeft,
|
||||||
|
playheadTime: editor.playback.getCurrentTime(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +157,7 @@ export function useTimelineZoom({
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialScrollLeft === undefined) return;
|
if (initialScrollLeft === undefined) return;
|
||||||
|
if (hasRestoredScrollRef.current) return;
|
||||||
const scrollElement = tracksScrollRef.current;
|
const scrollElement = tracksScrollRef.current;
|
||||||
if (!scrollElement) return;
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
|
@ -215,6 +166,7 @@ export function useTimelineZoom({
|
||||||
if (rulerScrollRef.current) {
|
if (rulerScrollRef.current) {
|
||||||
rulerScrollRef.current.scrollLeft = initialScrollLeft;
|
rulerScrollRef.current.scrollLeft = initialScrollLeft;
|
||||||
}
|
}
|
||||||
|
hasRestoredScrollRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (scrollElement.scrollWidth > 0) {
|
if (scrollElement.scrollWidth > 0) {
|
||||||
|
|
@ -223,6 +175,7 @@ export function useTimelineZoom({
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
if (scrollElement.scrollWidth > 0) {
|
if (scrollElement.scrollWidth > 0) {
|
||||||
restoreScroll();
|
restoreScroll();
|
||||||
|
hasRestoredScrollRef.current = true;
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -231,6 +184,13 @@ export function useTimelineZoom({
|
||||||
}
|
}
|
||||||
}, [initialScrollLeft]);
|
}, [initialScrollLeft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
|
||||||
|
hasRestoredPlayheadRef.current = true;
|
||||||
|
editor.playback.seek({ time: initialPlayheadTime });
|
||||||
|
}
|
||||||
|
}, [initialPlayheadTime, editor]);
|
||||||
|
|
||||||
// prevent browser zoom in the timeline
|
// prevent browser zoom in the timeline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preventZoom = (event: WheelEvent) => {
|
const preventZoom = (event: WheelEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -203,8 +203,18 @@ export function getSnappedSeekTime({
|
||||||
fps: number;
|
fps: number;
|
||||||
}): number {
|
}): number {
|
||||||
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
|
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
|
||||||
const frameOffset = fps > 0 ? 1 / fps : 0;
|
return Math.max(0, Math.min(duration, snappedTime));
|
||||||
const maxSeekTime =
|
}
|
||||||
frameOffset > 0 ? Math.max(0, duration - frameOffset) : duration;
|
|
||||||
return Math.max(0, Math.min(maxSeekTime, snappedTime));
|
export function getLastFrameTime({
|
||||||
|
duration,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
duration: number;
|
||||||
|
fps: number;
|
||||||
|
}): number {
|
||||||
|
if (duration <= 0) return 0;
|
||||||
|
if (fps <= 0) return duration;
|
||||||
|
const frameOffset = 1 / fps;
|
||||||
|
return Math.max(0, duration - frameOffset);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
|
|
||||||
|
const PADDING_MAX_RATIO = 0.75;
|
||||||
|
const PADDING_MIN_RATIO = 0.15;
|
||||||
|
const PADDING_MIN_AT_ZOOM_PERCENT = 0.2;
|
||||||
|
|
||||||
export function getTimelineZoomMin({
|
export function getTimelineZoomMin({
|
||||||
duration,
|
duration,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
|
|
@ -8,13 +12,42 @@ export function getTimelineZoomMin({
|
||||||
containerWidth: number | null | undefined;
|
containerWidth: number | null | undefined;
|
||||||
}): number {
|
}): number {
|
||||||
const safeDuration = Math.max(duration, 1);
|
const safeDuration = Math.max(duration, 1);
|
||||||
const paddedDuration =
|
|
||||||
safeDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
|
|
||||||
const safeContainerWidth = containerWidth ?? 1000;
|
const safeContainerWidth = containerWidth ?? 1000;
|
||||||
|
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
|
||||||
|
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
|
||||||
const zoomToFit =
|
const zoomToFit =
|
||||||
safeContainerWidth /
|
availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
|
||||||
(paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
|
|
||||||
const minZoom = zoomToFit / TIMELINE_CONSTANTS.ZOOM_OUT_FACTOR;
|
|
||||||
|
|
||||||
return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, minZoom);
|
return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimelinePaddingPx({
|
||||||
|
containerWidth,
|
||||||
|
zoomLevel,
|
||||||
|
minZoom,
|
||||||
|
}: {
|
||||||
|
containerWidth: number;
|
||||||
|
zoomLevel: number;
|
||||||
|
minZoom: number;
|
||||||
|
}): number {
|
||||||
|
const zoomPercent = getZoomPercent({ zoomLevel, minZoom });
|
||||||
|
const paddingTransitionPercent = Math.min(
|
||||||
|
zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const paddingRatio =
|
||||||
|
PADDING_MAX_RATIO -
|
||||||
|
(PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent;
|
||||||
|
|
||||||
|
return containerWidth * paddingRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoomPercent({
|
||||||
|
zoomLevel,
|
||||||
|
minZoom,
|
||||||
|
}: {
|
||||||
|
zoomLevel: number;
|
||||||
|
minZoom: number;
|
||||||
|
}): number {
|
||||||
|
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export interface TProjectSettings {
|
||||||
export interface TTimelineViewState {
|
export interface TTimelineViewState {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
scrollLeft: number;
|
scrollLeft: number;
|
||||||
|
playheadTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TProject {
|
export interface TProject {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue