summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers/resize-controller.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline/controllers/resize-controller.ts')
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts329
1 files changed, 329 insertions, 0 deletions
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
new file mode 100644
index 00000000..8677dd15
--- /dev/null
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -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();
+ }
+}