diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc index 6dc3b8f2..94410024 100644 --- a/.cursor/rules/codebase-index.mdc +++ b/.cursor/rules/codebase-index.mdc @@ -88,6 +88,7 @@ timeline-constants.tsx export const TRACK_HEIGHTS: Record export const TRACK_GAP export const TIMELINE_CONSTANTS + export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState export const TRACK_ICONS: Record transcription-constants.ts @@ -298,6 +299,10 @@ use-timeline-zoom.ts export function useTimelineZoom({ containerRef, minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, + initialZoom, + initialScrollLeft, + tracksScrollRef, + rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn ## apps/web/src/hooks/timeline/element @@ -1452,12 +1457,17 @@ project.ts originalCanvasSize?: TCanvasSize | null background: TBackground } + export interface TTimelineViewState { + zoomLevel: number + scrollLeft: number + } export interface TProject { metadata: TProjectMetadata scenes: TScene[] currentSceneId: string settings: TProjectSettings version: number + timelineViewState?: TTimelineViewState } export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration" export type TSortOrder = "asc" | "desc" diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 1d6ccae1..eb31ef58 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -97,10 +97,17 @@ export function Timeline() { containerWidth: timelineRef.current?.clientWidth, }); - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - minZoom: minZoomLevel, - }); + const savedViewState = editor.project.getTimelineViewState(); + + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + tracksScrollRef, + rulerScrollRef, + }); const { dragState, @@ -355,6 +362,9 @@ export function Timeline() { event.stopPropagation(); handleTracksClick(event); }} + onScroll={() => { + saveScrollPosition(); + }} >
= { video: , text: ( diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index c7a91923..db9e3fad 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -5,6 +5,7 @@ import type { TProjectSortKey, TProjectSortOption, TProjectSettings, + TTimelineViewState, } from "@/types/project"; import type { ExportOptions, ExportResult } from "@/types/export"; import { storageService } from "@/services/storage/storage-service"; @@ -24,6 +25,7 @@ import { migrations, runStorageMigrations, } from "@/services/storage/migrations"; +import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants"; export interface MigrationState { isMigrating: boolean; @@ -541,6 +543,19 @@ export class ProjectManager { return this.active; } + getTimelineViewState(): TTimelineViewState { + return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE; + } + + setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void { + if (!this.active) return; + this.active = { + ...this.active, + timelineViewState: viewState ?? undefined, + }; + this.editor.save.markDirty(); + } + getSavedProjects(): TProjectMetadata[] { return this.savedProjects; } diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 0763c09d..9519c410 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -3,26 +3,96 @@ import { type RefObject, useCallback, useEffect, + useRef, useState, } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEditor } from "@/hooks/use-editor"; interface UseTimelineZoomProps { containerRef: RefObject; minZoom?: number; + initialZoom?: number; + initialScrollLeft?: number; + tracksScrollRef: RefObject; + rulerScrollRef: RefObject; } interface UseTimelineZoomReturn { zoomLevel: number; setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; handleWheel: (event: ReactWheelEvent) => void; + saveScrollPosition: () => void; } export function useTimelineZoom({ containerRef, minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, + initialZoom, + initialScrollLeft, + tracksScrollRef, + rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { - const [zoomLevel, setZoomLevel] = useState(1); + const editor = useEditor(); + const hasInitializedRef = useRef(false); + const scrollSaveTimeoutRef = useRef | null>( + null, + ); + + const [zoomLevel, setZoomLevel] = useState(() => { + if (initialZoom !== undefined) { + hasInitializedRef.current = true; + return Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom), + ); + } + 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 handleWheel = useCallback( (event: ReactWheelEvent) => { @@ -42,6 +112,21 @@ 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), @@ -49,12 +134,102 @@ export function useTimelineZoom({ return; } }, - [minZoom], + [minZoom, applyZoomAnchor, editor, tracksScrollRef], ); useEffect(() => { - setZoomLevel((prev) => (prev < minZoom ? minZoom : prev)); - }, [minZoom]); + if (initialZoom !== undefined && !hasInitializedRef.current) { + hasInitializedRef.current = true; + setZoomLevel( + Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)), + ); + return; + } + setZoomLevel((prev) => { + if (prev < minZoom) { + return minZoom; + } + return prev; + }); + }, [minZoom, initialZoom]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref + const wrappedSetZoomLevel = useCallback( + (zoomLevelOrUpdater: number | ((prev: number) => number)) => { + setZoomLevel((prev) => { + const nextZoom = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(prev) + : zoomLevelOrUpdater; + const clampedZoom = Math.max( + 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], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref + const saveScrollPosition = useCallback(() => { + if (scrollSaveTimeoutRef.current) { + clearTimeout(scrollSaveTimeoutRef.current); + } + scrollSaveTimeoutRef.current = setTimeout(() => { + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + }, + }); + } + }, 300); + }, [zoomLevel, editor]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable + useEffect(() => { + if (initialScrollLeft === undefined) return; + const scrollElement = tracksScrollRef.current; + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = initialScrollLeft; + } + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + } else { + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + }, [initialScrollLeft]); // prevent browser zoom in the timeline useEffect(() => { @@ -82,7 +257,8 @@ export function useTimelineZoom({ return { zoomLevel, - setZoomLevel, + setZoomLevel: wrappedSetZoomLevel, handleWheel, + saveScrollPosition, }; } diff --git a/apps/web/src/services/storage/storage-service.ts b/apps/web/src/services/storage/storage-service.ts index e0d8afd5..cb0b2ced 100644 --- a/apps/web/src/services/storage/storage-service.ts +++ b/apps/web/src/services/storage/storage-service.ts @@ -111,6 +111,7 @@ class StorageService { currentSceneId: project.currentSceneId, settings: project.settings, version: project.version, + timelineViewState: project.timelineViewState, }; await this.projectsAdapter.set(project.metadata.id, serializedProject); @@ -156,6 +157,7 @@ class StorageService { currentSceneId: serializedProject.currentSceneId || "", settings: serializedProject.settings, version: serializedProject.version, + timelineViewState: serializedProject.timelineViewState, }; return { project }; diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts index 1c349d32..e7e045f5 100644 --- a/apps/web/src/services/storage/types.ts +++ b/apps/web/src/services/storage/types.ts @@ -1,5 +1,9 @@ import type { MediaType } from "@/types/assets"; -import type { TProject, TProjectMetadata } from "@/types/project"; +import type { + TProject, + TProjectMetadata, + TTimelineViewState, +} from "@/types/project"; import type { TScene } from "@/types/timeline"; export interface StorageAdapter { @@ -41,6 +45,7 @@ export type SerializedProjectMetadata = Omit< export type SerializedProject = Omit & { metadata: SerializedProjectMetadata; scenes: SerializedScene[]; + timelineViewState?: TTimelineViewState; }; export interface StorageConfig { diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index e915150b..fcab04b4 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -31,12 +31,18 @@ export interface TProjectSettings { background: TBackground; } +export interface TTimelineViewState { + zoomLevel: number; + scrollLeft: number; +} + export interface TProject { metadata: TProjectMetadata; scenes: TScene[]; currentSceneId: string; settings: TProjectSettings; version: number; + timelineViewState?: TTimelineViewState; } export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"; diff --git a/rules.md b/rules.md index 361289b9..7294026a 100644 --- a/rules.md +++ b/rules.md @@ -28,6 +28,7 @@ Review every point below carefully for files to ensure they follow consistent co ``` 17. For components that need to subscribe to data from the editor api (`src/core`, `src/managers`), use the `useEditor` hook. + 18. In react components: store/manager methods should not be passed as props to sub-components. If a sub-component can access the same methods, it should do so. Example: ```tsx