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