save timeline view state

This commit is contained in:
Maze Winther 2026-01-30 15:08:43 +01:00
parent 488a9018b1
commit d719460f32
9 changed files with 242 additions and 10 deletions

View File

@ -88,6 +88,7 @@ timeline-constants.tsx
export const TRACK_HEIGHTS: Record<TrackType, number> export const TRACK_HEIGHTS: Record<TrackType, number>
export const TRACK_GAP export const TRACK_GAP
export const TIMELINE_CONSTANTS export const TIMELINE_CONSTANTS
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState
export const TRACK_ICONS: Record<TrackType, React.ReactNode> export const TRACK_ICONS: Record<TrackType, React.ReactNode>
transcription-constants.ts transcription-constants.ts
@ -298,6 +299,10 @@ use-timeline-zoom.ts
export function useTimelineZoom({ export function useTimelineZoom({
containerRef, containerRef,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
initialZoom,
initialScrollLeft,
tracksScrollRef,
rulerScrollRef,
}: UseTimelineZoomProps): UseTimelineZoomReturn }: UseTimelineZoomProps): UseTimelineZoomReturn
## apps/web/src/hooks/timeline/element ## apps/web/src/hooks/timeline/element
@ -1452,12 +1457,17 @@ project.ts
originalCanvasSize?: TCanvasSize | null originalCanvasSize?: TCanvasSize | null
background: TBackground background: TBackground
} }
export interface TTimelineViewState {
zoomLevel: number
scrollLeft: number
}
export interface TProject { export interface TProject {
metadata: TProjectMetadata metadata: TProjectMetadata
scenes: TScene[] scenes: TScene[]
currentSceneId: string currentSceneId: string
settings: TProjectSettings settings: TProjectSettings
version: number version: number
timelineViewState?: TTimelineViewState
} }
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration" export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"
export type TSortOrder = "asc" | "desc" export type TSortOrder = "asc" | "desc"

View File

@ -97,10 +97,17 @@ export function Timeline() {
containerWidth: timelineRef.current?.clientWidth, containerWidth: timelineRef.current?.clientWidth,
}); });
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ const savedViewState = editor.project.getTimelineViewState();
containerRef: timelineRef,
minZoom: minZoomLevel, const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
}); useTimelineZoom({
containerRef: timelineRef,
minZoom: minZoomLevel,
initialZoom: savedViewState?.zoomLevel,
initialScrollLeft: savedViewState?.scrollLeft,
tracksScrollRef,
rulerScrollRef,
});
const { const {
dragState, dragState,
@ -355,6 +362,9 @@ export function Timeline() {
event.stopPropagation(); event.stopPropagation();
handleTracksClick(event); handleTracksClick(event);
}} }}
onScroll={() => {
saveScrollPosition();
}}
> >
<div <div
className="relative flex-1" className="relative flex-1"

View File

@ -1,3 +1,4 @@
import type { TTimelineViewState } from "@/types/project";
import type { TrackType } from "@/types/timeline"; import type { TrackType } from "@/types/timeline";
import { import {
Happy01Icon, Happy01Icon,
@ -41,8 +42,14 @@ export const TIMELINE_CONSTANTS = {
ZOOM_MAX: 50, ZOOM_MAX: 50,
ZOOM_STEP: 0.1, ZOOM_STEP: 0.1,
ZOOM_OUT_FACTOR: 4, ZOOM_OUT_FACTOR: 4,
ZOOM_PLAYHEAD_ANCHOR_THRESHOLD: 0.0002,
} as const; } as const;
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
zoomLevel: 1,
scrollLeft: 0,
};
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = { export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: ( text: (

View File

@ -5,6 +5,7 @@ import type {
TProjectSortKey, TProjectSortKey,
TProjectSortOption, TProjectSortOption,
TProjectSettings, TProjectSettings,
TTimelineViewState,
} from "@/types/project"; } from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export"; import type { ExportOptions, ExportResult } from "@/types/export";
import { storageService } from "@/services/storage/storage-service"; import { storageService } from "@/services/storage/storage-service";
@ -24,6 +25,7 @@ import {
migrations, migrations,
runStorageMigrations, runStorageMigrations,
} from "@/services/storage/migrations"; } from "@/services/storage/migrations";
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
export interface MigrationState { export interface MigrationState {
isMigrating: boolean; isMigrating: boolean;
@ -541,6 +543,19 @@ export class ProjectManager {
return this.active; 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[] { getSavedProjects(): TProjectMetadata[] {
return this.savedProjects; return this.savedProjects;
} }

View File

@ -3,26 +3,96 @@ import {
type RefObject, type RefObject,
useCallback, useCallback,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
interface UseTimelineZoomProps { interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>; containerRef: RefObject<HTMLDivElement>;
minZoom?: number; minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
tracksScrollRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
} }
interface UseTimelineZoomReturn { interface UseTimelineZoomReturn {
zoomLevel: number; zoomLevel: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
handleWheel: (event: ReactWheelEvent) => void; handleWheel: (event: ReactWheelEvent) => void;
saveScrollPosition: () => void;
} }
export function useTimelineZoom({ export function useTimelineZoom({
containerRef, containerRef,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
initialZoom,
initialScrollLeft,
tracksScrollRef,
rulerScrollRef,
}: UseTimelineZoomProps): UseTimelineZoomReturn { }: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1); const editor = useEditor();
const hasInitializedRef = useRef(false);
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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( const handleWheel = useCallback(
(event: ReactWheelEvent) => { (event: ReactWheelEvent) => {
@ -42,6 +112,21 @@ 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),
@ -49,12 +134,102 @@ export function useTimelineZoom({
return; return;
} }
}, },
[minZoom], [minZoom, applyZoomAnchor, editor, tracksScrollRef],
); );
useEffect(() => { useEffect(() => {
setZoomLevel((prev) => (prev < minZoom ? minZoom : prev)); if (initialZoom !== undefined && !hasInitializedRef.current) {
}, [minZoom]); 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 // prevent browser zoom in the timeline
useEffect(() => { useEffect(() => {
@ -82,7 +257,8 @@ export function useTimelineZoom({
return { return {
zoomLevel, zoomLevel,
setZoomLevel, setZoomLevel: wrappedSetZoomLevel,
handleWheel, handleWheel,
saveScrollPosition,
}; };
} }

View File

@ -111,6 +111,7 @@ class StorageService {
currentSceneId: project.currentSceneId, currentSceneId: project.currentSceneId,
settings: project.settings, settings: project.settings,
version: project.version, version: project.version,
timelineViewState: project.timelineViewState,
}; };
await this.projectsAdapter.set(project.metadata.id, serializedProject); await this.projectsAdapter.set(project.metadata.id, serializedProject);
@ -156,6 +157,7 @@ class StorageService {
currentSceneId: serializedProject.currentSceneId || "", currentSceneId: serializedProject.currentSceneId || "",
settings: serializedProject.settings, settings: serializedProject.settings,
version: serializedProject.version, version: serializedProject.version,
timelineViewState: serializedProject.timelineViewState,
}; };
return { project }; return { project };

View File

@ -1,5 +1,9 @@
import type { MediaType } from "@/types/assets"; 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"; import type { TScene } from "@/types/timeline";
export interface StorageAdapter<T> { export interface StorageAdapter<T> {
@ -41,6 +45,7 @@ export type SerializedProjectMetadata = Omit<
export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & { export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & {
metadata: SerializedProjectMetadata; metadata: SerializedProjectMetadata;
scenes: SerializedScene[]; scenes: SerializedScene[];
timelineViewState?: TTimelineViewState;
}; };
export interface StorageConfig { export interface StorageConfig {

View File

@ -31,12 +31,18 @@ export interface TProjectSettings {
background: TBackground; background: TBackground;
} }
export interface TTimelineViewState {
zoomLevel: number;
scrollLeft: number;
}
export interface TProject { export interface TProject {
metadata: TProjectMetadata; metadata: TProjectMetadata;
scenes: TScene[]; scenes: TScene[];
currentSceneId: string; currentSceneId: string;
settings: TProjectSettings; settings: TProjectSettings;
version: number; version: number;
timelineViewState?: TTimelineViewState;
} }
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"; export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";

View File

@ -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. 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: 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 ```tsx