diff options
Diffstat (limited to 'apps/web/src/timeline/controllers/resize-controller.ts')
| -rw-r--r-- | apps/web/src/timeline/controllers/resize-controller.ts | 329 |
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(); + } +} |
