save timeline view state
This commit is contained in:
parent
488a9018b1
commit
d719460f32
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
1
rules.md
1
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue