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_GAP
export const TIMELINE_CONSTANTS
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState
export const TRACK_ICONS: Record<TrackType, React.ReactNode>
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"

View File

@ -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();
}}
>
<div
className="relative flex-1"

View File

@ -1,3 +1,4 @@
import type { TTimelineViewState } from "@/types/project";
import type { TrackType } from "@/types/timeline";
import {
Happy01Icon,
@ -41,8 +42,14 @@ export const TIMELINE_CONSTANTS = {
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,
};
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: (

View File

@ -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;
}

View File

@ -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<HTMLDivElement>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
tracksScrollRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
}
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<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(
(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,
};
}

View File

@ -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 };

View File

@ -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<T> {
@ -41,6 +45,7 @@ export type SerializedProjectMetadata = Omit<
export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & {
metadata: SerializedProjectMetadata;
scenes: SerializedScene[];
timelineViewState?: TTimelineViewState;
};
export interface StorageConfig {

View File

@ -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";

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.
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