315 lines
8.5 KiB
TypeScript
315 lines
8.5 KiB
TypeScript
import type { MouseEvent as ReactMouseEvent } from "react";
|
|
import { snappedSeekTime, type FrameRate } from "opencut-wasm";
|
|
import { TICKS_PER_SECOND } from "@/wasm";
|
|
import {
|
|
buildTimelineSnapPoints,
|
|
getTimelineSnapThresholdInTicks,
|
|
resolveTimelineSnap,
|
|
} from "@/timeline/snapping";
|
|
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
|
import {
|
|
getCenteredLineLeft,
|
|
timelineTimeToPixels,
|
|
timelineTimeToSnappedPixels,
|
|
} from "@/timeline";
|
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
|
import type { Bookmark, SceneTracks } from "@/timeline";
|
|
|
|
// --- Session ---
|
|
|
|
interface ScrubSession {
|
|
kind: "scrubbing";
|
|
/** True when scrub started from a ruler click (not the playhead handle). */
|
|
didStartFromRuler: boolean;
|
|
/** True once the mouse has moved during a ruler drag. */
|
|
hasMoved: boolean;
|
|
/** Most recent frame-snapped time set by scrub(). */
|
|
currentTime: number | null;
|
|
}
|
|
|
|
type Session = { kind: "idle" } | ScrubSession;
|
|
|
|
// --- Config ---
|
|
|
|
export interface PlayheadConfig {
|
|
zoomLevel: number;
|
|
duration: number;
|
|
getActiveProjectFps: () => FrameRate | null;
|
|
isShiftHeld: () => boolean;
|
|
getIsPlaying: () => boolean;
|
|
getRulerEl: () => HTMLDivElement | null;
|
|
getRulerScrollEl: () => HTMLDivElement | null;
|
|
getTracksScrollEl: () => HTMLDivElement | null;
|
|
getPlayheadEl: () => HTMLDivElement | null;
|
|
getSceneTracks: () => SceneTracks;
|
|
getSceneBookmarks: () => Bookmark[];
|
|
seek: (time: number) => void;
|
|
setScrubbing: (isScrubbing: boolean) => void;
|
|
setTimelineViewState: (viewState: {
|
|
zoomLevel: number;
|
|
scrollLeft: number;
|
|
playheadTime: number;
|
|
}) => void;
|
|
}
|
|
|
|
export interface PlayheadConfigRef {
|
|
readonly current: PlayheadConfig;
|
|
}
|
|
|
|
// --- Pure helpers (px → logical) ---
|
|
|
|
function pixelToTime({
|
|
clientX,
|
|
rulerEl,
|
|
zoomLevel,
|
|
duration,
|
|
}: {
|
|
clientX: number;
|
|
rulerEl: HTMLDivElement;
|
|
zoomLevel: number;
|
|
duration: number;
|
|
}): number {
|
|
const rulerRect = rulerEl.getBoundingClientRect();
|
|
const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
|
|
const clampedX = Math.max(
|
|
0,
|
|
Math.min(contentWidth, clientX - rulerRect.left),
|
|
);
|
|
const seconds = Math.max(
|
|
0,
|
|
Math.min(
|
|
duration / TICKS_PER_SECOND,
|
|
clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
|
),
|
|
);
|
|
return Math.round(seconds * TICKS_PER_SECOND);
|
|
}
|
|
|
|
// --- Controller ---
|
|
|
|
export class PlayheadController {
|
|
private lastMouseClientX = 0;
|
|
|
|
private session: Session = { kind: "idle" };
|
|
private readonly configRef: PlayheadConfigRef;
|
|
|
|
constructor(deps: { configRef: PlayheadConfigRef }) {
|
|
this.configRef = deps.configRef;
|
|
this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this);
|
|
this.onRulerMouseDown = this.onRulerMouseDown.bind(this);
|
|
this.handleMouseMove = this.handleMouseMove.bind(this);
|
|
this.handleMouseUp = this.handleMouseUp.bind(this);
|
|
}
|
|
|
|
private get config(): PlayheadConfig {
|
|
return this.configRef.current;
|
|
}
|
|
|
|
get isActive(): boolean {
|
|
return this.session.kind !== "idle";
|
|
}
|
|
|
|
getLastMouseClientX(): number {
|
|
return this.lastMouseClientX;
|
|
}
|
|
|
|
destroy(): void {
|
|
this.deactivate();
|
|
}
|
|
|
|
// --- Public event handlers (bound, stable references) ---
|
|
|
|
onPlayheadMouseDown(event: ReactMouseEvent): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.session = {
|
|
kind: "scrubbing",
|
|
didStartFromRuler: false,
|
|
hasMoved: false,
|
|
currentTime: null,
|
|
};
|
|
this.config.setScrubbing(true);
|
|
this.scrub({ event, isElementSnappingEnabled: true });
|
|
this.activate();
|
|
}
|
|
|
|
onRulerMouseDown(event: ReactMouseEvent): void {
|
|
if (event.button !== 0) return;
|
|
if (this.config.getPlayheadEl()?.contains(event.target as Node)) return;
|
|
|
|
event.preventDefault();
|
|
this.session = {
|
|
kind: "scrubbing",
|
|
didStartFromRuler: true,
|
|
hasMoved: false,
|
|
currentTime: null,
|
|
};
|
|
this.config.setScrubbing(true);
|
|
// No element-edge snapping on initial ruler click — avoids a jarring jump.
|
|
this.scrub({ event, isElementSnappingEnabled: false });
|
|
this.activate();
|
|
}
|
|
|
|
// --- Public non-session methods ---
|
|
|
|
/**
|
|
* Imperatively updates the playhead DOM element's `left` style.
|
|
* Called on scroll and playback events to avoid React re-renders
|
|
* during animation frame updates.
|
|
*/
|
|
updatePlayheadLeft(time: number): void {
|
|
const playheadEl = this.config.getPlayheadEl();
|
|
if (!playheadEl) return;
|
|
|
|
const centerPixel = timelineTimeToSnappedPixels({
|
|
time,
|
|
zoomLevel: this.config.zoomLevel,
|
|
});
|
|
const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0;
|
|
playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`;
|
|
}
|
|
|
|
/**
|
|
* Updates the playhead position and auto-scrolls to keep the playhead
|
|
* visible during playback.
|
|
*/
|
|
handlePlaybackUpdate(time: number): void {
|
|
this.updatePlayheadLeft(time);
|
|
|
|
// Auto-scroll only during playback, not while scrubbing.
|
|
if (!this.config.getIsPlaying() || this.session.kind === "scrubbing")
|
|
return;
|
|
|
|
const rulerViewport = this.config.getRulerScrollEl();
|
|
const tracksViewport = this.config.getTracksScrollEl();
|
|
if (!rulerViewport || !tracksViewport) return;
|
|
|
|
const playheadPixels = timelineTimeToPixels({
|
|
time,
|
|
zoomLevel: this.config.zoomLevel,
|
|
});
|
|
const viewportWidth = rulerViewport.clientWidth;
|
|
const isOutOfView =
|
|
playheadPixels < rulerViewport.scrollLeft ||
|
|
playheadPixels > rulerViewport.scrollLeft + viewportWidth;
|
|
|
|
if (isOutOfView) {
|
|
const desiredScroll = Math.max(
|
|
0,
|
|
Math.min(
|
|
rulerViewport.scrollWidth - viewportWidth,
|
|
playheadPixels - viewportWidth / 2,
|
|
),
|
|
);
|
|
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
|
}
|
|
}
|
|
|
|
// --- Private ---
|
|
|
|
private activate(): void {
|
|
window.addEventListener("mousemove", this.handleMouseMove);
|
|
window.addEventListener("mouseup", this.handleMouseUp);
|
|
}
|
|
|
|
private deactivate(): void {
|
|
window.removeEventListener("mousemove", this.handleMouseMove);
|
|
window.removeEventListener("mouseup", this.handleMouseUp);
|
|
}
|
|
|
|
/**
|
|
* Converts pointer position to a frame-snapped timeline time and seeks.
|
|
* `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping
|
|
* is always applied.
|
|
*/
|
|
private scrub({
|
|
event,
|
|
isElementSnappingEnabled,
|
|
}: {
|
|
event: MouseEvent | ReactMouseEvent;
|
|
isElementSnappingEnabled: boolean;
|
|
}): void {
|
|
const ruler = this.config.getRulerEl();
|
|
if (!ruler) return;
|
|
|
|
const fps = this.config.getActiveProjectFps();
|
|
if (!fps) return;
|
|
|
|
const { zoomLevel, duration } = this.config;
|
|
const rawTime = pixelToTime({
|
|
clientX: event.clientX,
|
|
rulerEl: ruler,
|
|
zoomLevel,
|
|
duration,
|
|
});
|
|
const frameTime =
|
|
snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
|
|
|
|
const time = (() => {
|
|
if (!isElementSnappingEnabled || this.config.isShiftHeld())
|
|
return frameTime;
|
|
|
|
const snapPoints = buildTimelineSnapPoints({
|
|
sources: [
|
|
() =>
|
|
getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }),
|
|
() =>
|
|
getBookmarkSnapPoints({
|
|
bookmarks: this.config.getSceneBookmarks(),
|
|
}),
|
|
() =>
|
|
getAnimationKeyframeSnapPointsForTimeline({
|
|
tracks: this.config.getSceneTracks(),
|
|
}),
|
|
],
|
|
});
|
|
const result = resolveTimelineSnap({
|
|
targetTime: frameTime,
|
|
snapPoints,
|
|
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
|
});
|
|
return result.snapPoint ? result.snappedTime : frameTime;
|
|
})();
|
|
|
|
if (this.session.kind === "scrubbing") {
|
|
this.session.currentTime = time;
|
|
}
|
|
this.config.seek(time);
|
|
this.lastMouseClientX = event.clientX;
|
|
}
|
|
|
|
private handleMouseMove(event: MouseEvent): void {
|
|
if (this.session.kind !== "scrubbing") return;
|
|
this.scrub({ event, isElementSnappingEnabled: true });
|
|
if (this.session.didStartFromRuler) {
|
|
this.session.hasMoved = true;
|
|
}
|
|
}
|
|
|
|
private handleMouseUp(event: MouseEvent): void {
|
|
if (this.session.kind !== "scrubbing") return;
|
|
|
|
const session = this.session;
|
|
this.config.setScrubbing(false);
|
|
|
|
if (session.currentTime !== null) {
|
|
this.config.seek(session.currentTime);
|
|
this.config.setTimelineViewState({
|
|
zoomLevel: this.config.zoomLevel,
|
|
scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0,
|
|
playheadTime: session.currentTime,
|
|
});
|
|
}
|
|
|
|
// Ruler click without drag: snap to clicked position on mouseup.
|
|
if (session.didStartFromRuler && !session.hasMoved) {
|
|
this.scrub({ event, isElementSnappingEnabled: false });
|
|
}
|
|
|
|
this.session = { kind: "idle" };
|
|
this.deactivate();
|
|
}
|
|
}
|