refactor: centralize editor interaction state

Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events.

This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync.

Made-with: Cursor
This commit is contained in:
Maze Winther
2026-04-25 03:56:22 +02:00
parent 0dffefdd59
commit 6a22a3ecbd
38 changed files with 5692 additions and 4773 deletions
@@ -0,0 +1,329 @@
import type { MouseEvent as ReactMouseEvent } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { TICKS_PER_SECOND } from "@/wasm";
import {
computeGroupResize,
type GroupResizeMember,
type GroupResizeResult,
type GroupResizeUpdate,
type ResizeSide,
} from "@/timeline/group-resize";
import {
buildTimelineSnapPoints,
getTimelineSnapThresholdInTicks,
resolveTimelineSnap,
type SnapPoint,
} from "@/timeline/snapping";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
import {
isRetimableElement,
type SceneTracks,
type TimelineElement,
type TimelineTrack,
} from "@/timeline";
import type { ElementRef } from "@/timeline/types";
import type { FrameRate } from "opencut-wasm";
// --- Session ---
interface ResizeSession {
kind: "active";
side: ResizeSide;
startX: number;
fps: FrameRate;
members: GroupResizeMember[];
result: GroupResizeResult | null;
}
type Session = { kind: "idle" } | ResizeSession;
// --- Config ---
export interface ResizeConfig {
zoomLevel: number;
snappingEnabled: boolean;
isShiftHeld: () => boolean;
getSceneTracks: () => SceneTracks;
getCurrentPlayheadTime: () => number;
getActiveProjectFps: () => FrameRate | null;
selectedElements: ElementRef[];
discardPreview: () => void;
previewElements: (updates: GroupResizeUpdate[]) => void;
commitElements: (updates: GroupResizeUpdate[]) => void;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export interface ResizeConfigRef {
readonly current: ResizeConfig;
}
// --- Pure helpers ---
export function buildResizeMembers({
tracks,
selectedElements,
}: {
tracks: SceneTracks;
selectedElements: ElementRef[];
}): GroupResizeMember[] {
const selectedElementIds = new Set(
selectedElements.map((el) => el.elementId),
);
const trackMap = new Map(
[...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
track.id,
track,
]),
);
return selectedElements.flatMap(({ trackId, elementId }) => {
const track = trackMap.get(trackId);
const element = track?.elements.find((el) => el.id === elementId);
if (!track || !element) return [];
const otherElements = track.elements.filter(
(el) => !selectedElementIds.has(el.id),
);
const leftNeighborBound = otherElements
.filter((el) => el.startTime + el.duration <= element.startTime)
.reduce(
(bound, el) => Math.max(bound, el.startTime + el.duration),
-Infinity,
);
const rightNeighborBound = otherElements
.filter((el) => el.startTime >= element.startTime + element.duration)
.reduce((bound, el) => Math.min(bound, el.startTime), Infinity);
return [
{
trackId,
elementId,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
sourceDuration: element.sourceDuration,
retime: isRetimableElement(element) ? element.retime : undefined,
leftNeighborBound,
rightNeighborBound,
},
];
});
}
function hasResizeChanges({
members,
result,
}: {
members: GroupResizeMember[];
result: GroupResizeResult;
}): boolean {
return result.updates.some((update) => {
const member = members.find((m) => m.elementId === update.elementId);
return (
member?.trimStart !== update.patch.trimStart ||
member?.trimEnd !== update.patch.trimEnd ||
member?.startTime !== update.patch.startTime ||
member?.duration !== update.patch.duration
);
});
}
// --- Controller ---
export class ResizeController {
private session: Session = { kind: "idle" };
private readonly subscribers = new Set<() => void>();
private readonly configRef: ResizeConfigRef;
constructor(deps: { configRef: ResizeConfigRef }) {
this.configRef = deps.configRef;
this.onResizeStart = this.onResizeStart.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
}
private get config(): ResizeConfig {
return this.configRef.current;
}
get isResizing(): boolean {
return this.session.kind === "active";
}
subscribe(fn: () => void): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
cancel(): void {
this.config.discardPreview();
this.finishSession();
}
destroy(): void {
this.deactivate();
this.subscribers.clear();
}
onResizeStart({
event,
element,
track,
side,
}: {
event: ReactMouseEvent;
element: TimelineElement;
track: TimelineTrack;
side: ResizeSide;
}): void {
event.stopPropagation();
event.preventDefault();
// UI should prevent this, but be explicit: a new resize start
// means the previous one is abandoned, not silently replaced.
if (this.session.kind === "active") this.cancel();
const fps = this.config.getActiveProjectFps();
if (!fps) return;
const ref = { trackId: track.id, elementId: element.id };
const activeSelection = this.config.selectedElements.some(
(el) => el.trackId === track.id && el.elementId === element.id,
)
? this.config.selectedElements
: [ref];
const members = buildResizeMembers({
tracks: this.config.getSceneTracks(),
selectedElements: activeSelection,
});
if (members.length === 0) return;
this.config.discardPreview();
this.session = {
kind: "active",
side,
startX: event.clientX,
fps,
members,
result: null,
};
this.activate();
this.notify();
}
private activate(): void {
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
}
private deactivate(): void {
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
}
private notify(): void {
for (const fn of this.subscribers) fn();
}
private finishSession(): void {
this.session = { kind: "idle" };
this.deactivate();
this.config.onSnapPointChange?.(null);
this.notify();
}
private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number {
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
if (!snappingEnabled || isShiftHeld()) {
this.config.onSnapPointChange?.(null);
return rawDeltaTicks;
}
const tracks = this.config.getSceneTracks();
const playheadTime = this.config.getCurrentPlayheadTime();
const excludeElementIds = new Set(session.members.map((m) => m.elementId));
const snapPoints = buildTimelineSnapPoints({
sources: [
() => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
() => getPlayheadSnapPoints({ playheadTime }),
() =>
getAnimationKeyframeSnapPointsForTimeline({
tracks,
excludeElementIds,
}),
],
});
const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
let closestSnapPoint: SnapPoint | null = null;
let closestSnapDistance = Infinity;
let deltaTicks = rawDeltaTicks;
for (const member of session.members) {
const baseEdgeTime =
session.side === "left"
? member.startTime
: member.startTime + member.duration;
const snapResult = resolveTimelineSnap({
targetTime: baseEdgeTime + rawDeltaTicks,
snapPoints,
maxSnapDistance,
});
if (
snapResult.snapPoint &&
snapResult.snapDistance < closestSnapDistance
) {
closestSnapDistance = snapResult.snapDistance;
closestSnapPoint = snapResult.snapPoint;
deltaTicks = snapResult.snappedTime - baseEdgeTime;
}
}
this.config.onSnapPointChange?.(closestSnapPoint);
return deltaTicks;
}
private handleMouseMove({ clientX }: MouseEvent): void {
if (this.session.kind !== "active") return;
const session = this.session;
const rawDeltaTicks = Math.round(
((clientX - session.startX) /
(BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
TICKS_PER_SECOND,
);
const deltaTicks = this.snappedDelta(session, rawDeltaTicks);
const result = computeGroupResize({
members: session.members,
side: session.side,
deltaTicks,
fps: session.fps,
});
session.result = result;
this.config.previewElements(result.updates);
}
private handleMouseUp(): void {
if (this.session.kind !== "active") return;
const session = this.session;
this.config.discardPreview();
if (
session.result &&
hasResizeChanges({ members: session.members, result: session.result })
) {
this.config.commitElements(session.result.updates);
}
this.finishSession();
}
}