From 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 03:56:22 +0200 Subject: 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 --- apps/web/src/timeline/components/index.tsx | 69 +- .../src/timeline/components/timeline-element.tsx | 23 +- .../web/src/timeline/components/timeline-track.tsx | 26 +- .../timeline/controllers/drag-drop-controller.ts | 577 +++++++++++++++ .../controllers/element-interaction-controller.ts | 691 ++++++++++++++++++ .../controllers/keyframe-drag-controller.ts | 342 +++++++++ .../timeline/controllers/playhead-controller.ts | 314 +++++++++ .../src/timeline/controllers/resize-controller.ts | 329 +++++++++ .../src/timeline/controllers/seek-controller.ts | 210 ++++++ .../src/timeline/controllers/zoom-controller.ts | 285 ++++++++ apps/web/src/timeline/creation.ts | 17 + apps/web/src/timeline/drag-data.ts | 48 -- apps/web/src/timeline/drag-source.ts | 40 ++ .../src/timeline/group-resize/compute-resize.ts | 58 +- apps/web/src/timeline/group-resize/types.ts | 4 +- .../hooks/element/use-element-interaction.ts | 777 ++------------------- .../timeline/hooks/element/use-keyframe-drag.ts | 334 ++------- .../src/timeline/hooks/use-timeline-drag-drop.ts | 659 +---------------- .../src/timeline/hooks/use-timeline-playhead.ts | 327 ++------- apps/web/src/timeline/hooks/use-timeline-resize.ts | 353 ++-------- apps/web/src/timeline/hooks/use-timeline-seek.ts | 195 +----- apps/web/src/timeline/hooks/use-timeline-zoom.ts | 277 ++------ apps/web/src/timeline/types.ts | 631 ++++++++--------- 23 files changed, 3521 insertions(+), 3065 deletions(-) create mode 100644 apps/web/src/timeline/controllers/drag-drop-controller.ts create mode 100644 apps/web/src/timeline/controllers/element-interaction-controller.ts create mode 100644 apps/web/src/timeline/controllers/keyframe-drag-controller.ts create mode 100644 apps/web/src/timeline/controllers/playhead-controller.ts create mode 100644 apps/web/src/timeline/controllers/resize-controller.ts create mode 100644 apps/web/src/timeline/controllers/seek-controller.ts create mode 100644 apps/web/src/timeline/controllers/zoom-controller.ts delete mode 100644 apps/web/src/timeline/drag-data.ts create mode 100644 apps/web/src/timeline/drag-source.ts (limited to 'apps/web/src/timeline') diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index e6a9df49..88fb93b7 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,7 +29,7 @@ import { useState, type ReactNode, } from "react"; -import type { ElementDragState, DropTarget } from "@/timeline"; +import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "@/selection/selection-box"; @@ -287,20 +287,15 @@ export function Timeline() { isReady: tracks.length > 0, }); - const { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - } = useElementInteraction({ - zoomLevel, - timelineRef, - tracksContainerRef, - tracksScrollRef, - snappingEnabled, - onSnapPointChange: handleSnapPointChange, - }); + const { dragView, handleElementMouseDown, handleElementClick } = + useElementInteraction({ + zoomLevel, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + const isElementDragging = dragView.kind === "dragging"; const { dragState: bookmarkDragState, @@ -391,10 +386,19 @@ export function Timeline() { contentWidth: dynamicTimelineWidth, }); + useEdgeAutoScroll({ + isActive: isElementDragging, + getMouseClientX: () => + dragView.kind === "dragging" ? dragView.currentMouseX : 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + (isElementDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -457,9 +461,9 @@ export function Timeline() { headerHeight={timelineHeaderHeight} /> @@ -540,9 +544,7 @@ export function Timeline() { ; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: React.ComponentProps< typeof TimelineTrackContent >["onResizeStart"]; @@ -776,8 +774,16 @@ function TimelineTrackRows({ [tracks, expandedElementIds], ); + const draggingElementIds = useMemo( + () => + dragView.kind === "dragging" + ? dragView.memberTimeOffsets + : (null as ReadonlyMap | null), + [dragView], + ); const sortedTracks = useMemo(() => { - const draggingElementIds = new Set(dragState.dragElementIds); + if (!draggingElementIds) + return tracks.map((track, index) => ({ track, index })); return [...tracks] .map((track, index) => ({ track, index })) .sort((a, b) => { @@ -791,7 +797,7 @@ function TimelineTrackRows({ if (bHasDragged) return -1; return 0; }); - }, [tracks, dragState.dragElementIds]); + }, [tracks, draggingElementIds]); return ( <> @@ -811,10 +817,7 @@ function TimelineTrackRows({ void; - dragState: ElementDragState; + dragView: ElementDragView; isDropTarget?: boolean; } @@ -226,7 +226,7 @@ export function TimelineElement({ onResizeStart, onElementMouseDown, onElementClick, - dragState, + dragView, isDropTarget = false, }: TimelineElementProps) { const mediaAssets = useEditor((e) => e.media.getAssets()); @@ -252,15 +252,18 @@ export function TimelineElement({ selected.elementId === element.id && selected.trackId === track.id, ); - const isBeingDragged = dragState.dragElementIds.includes(element.id); + const isDragging = dragView.kind === "dragging"; + const dragTimeOffset = isDragging + ? dragView.memberTimeOffsets.get(element.id) + : undefined; + const isBeingDragged = dragTimeOffset !== undefined; const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY + isDragging && isBeingDragged + ? dragView.currentMouseY - dragView.startMouseY : 0; - const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime + dragTimeOffset + isDragging && isBeingDragged + ? dragView.currentTime + dragTimeOffset : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; @@ -382,7 +385,7 @@ export function TimelineElement({ ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: - isBeingDragged && dragState.isDragging + isBeingDragged && isDragging ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 6c40637e..7608e6df 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/timeline"; import type { TimelineElement as TimelineElementType } from "@/timeline"; import { TIMELINE_LAYERS } from "./layers"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; -import type { ElementDragState } from "@/timeline"; -import { useEditor } from "@/editor/use-editor"; +import type { ElementDragView } from "@/timeline"; interface TimelineTrackContentProps { track: TimelineTrack; zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -42,10 +36,7 @@ interface TimelineTrackContentProps { export function TimelineTrackContent({ track, zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, + dragView, onResizeStart, onElementMouseDown, onElementClick, @@ -55,15 +46,6 @@ export function TimelineTrackContent({ targetElementId = null, }: TimelineTrackContentProps) { const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, - }); return (
@@ -120,7 +102,7 @@ export function TimelineTrackContent({ onElementClick={(event, element) => onElementClick({ event, element, track }) } - dragState={dragState} + dragView={dragView} isDropTarget={element.id === targetElementId} /> ); diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts new file mode 100644 index 00000000..cd03300e --- /dev/null +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -0,0 +1,577 @@ +import type { DragEvent } from "react"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { + DEFAULT_NEW_ELEMENT_DURATION, + toElementDurationTicks, +} from "@/timeline/creation"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { roundToFrame, type FrameRate } from "opencut-wasm"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import type { Command } from "@/commands/base-command"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import type { TimelineDragSource } from "@/timeline/drag-source"; +import type { + TrackType, + DropTarget, + ElementType, + SceneTracks, + TimelineTrack, + CreateTimelineElement, +} from "@/timeline"; +import type { TimelineDragData } from "@/timeline/drag"; +import type { MediaAsset } from "@/media/types"; +import type { ProcessedMediaAsset } from "@/media/processing"; + +// --- Config --- + +export interface DragDropConfig { + zoomLevel: number; + getContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + getActiveProjectId: () => string | null; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => number; + getMediaAssets: () => MediaAsset[]; + dragSource: TimelineDragSource; + addMediaAsset: (args: { + projectId: string; + asset: ProcessedMediaAsset; + }) => Promise; + executeCommand: (command: Command) => void; + insertElement: (args: { + placement: { mode: "explicit"; trackId: string }; + element: CreateTimelineElement; + }) => void; + addClipEffect: (args: { + trackId: string; + elementId: string; + effectType: string; + }) => void; +} + +export interface DragDropConfigRef { + readonly current: DragDropConfig; +} + +// --- State --- + +interface DragOverState { + kind: "over"; + dropTarget: DropTarget | null; + elementType: ElementType | null; +} + +type DragDropState = { kind: "idle" } | DragOverState; + +interface TimelineCoords { + mouseX: number; + mouseY: number; +} + +// --- Pure helpers --- + +function elementTypeFromDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): ElementType { + switch (dragData.type) { + case "text": + return "text"; + case "graphic": + return "graphic"; + case "sticker": + return "sticker"; + case "effect": + return "effect"; + case "media": + return dragData.mediaType; + } +} + +function getTargetElementTypesForDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): string[] | undefined { + if (dragData.type === "effect") return dragData.targetElementTypes; + if (dragData.type === "media") return dragData.targetElementTypes; + return undefined; +} + +function getDurationForDrag({ + dragData, + mediaAssets, +}: { + dragData: TimelineDragData; + mediaAssets: MediaAsset[]; +}): number { + if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; + const media = mediaAssets.find((asset) => asset.id === dragData.id); + return toElementDurationTicks({ seconds: media?.duration }); +} + +function orderedTracks({ + sceneTracks, +}: { + sceneTracks: SceneTracks; +}): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +// --- Controller --- + +export class DragDropController { + private state: DragDropState = { kind: "idle" }; + private enterCount = 0; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: DragDropConfigRef; + + constructor(deps: { configRef: DragDropConfigRef }) { + this.configRef = deps.configRef; + this.onDragEnter = this.onDragEnter.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDragLeave = this.onDragLeave.bind(this); + this.onDrop = this.onDrop.bind(this); + } + + private get config(): DragDropConfig { + return this.configRef.current; + } + + get isDragOver(): boolean { + return this.state.kind !== "idle"; + } + + get dropTarget(): DropTarget | null { + return this.state.kind === "over" ? this.state.dropTarget : null; + } + + get dragElementType(): ElementType | null { + return this.state.kind === "over" ? this.state.elementType : null; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + this.subscribers.clear(); + } + + // --- Drag event handlers (bound, stable, passed as React props) --- + + onDragEnter(event: DragEvent): void { + event.preventDefault(); + const hasAsset = this.config.dragSource.isActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + if (!hasAsset && !hasFiles) return; + + this.enterCount += 1; + if (this.state.kind === "idle") { + this.setOver({ dropTarget: null, elementType: null }); + } + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + const isExternal = hasFiles && !dragData; + + if (!dragData) { + if (hasFiles && isExternal) { + this.setOver({ dropTarget: null, elementType: null }); + } + return; + } + + const elementType = elementTypeFromDrag({ dragData }); + const duration = getDurationForDrag({ + dragData, + mediaAssets: this.config.getMediaAssets(), + }); + const targetElementTypes = getTargetElementTypesForDrag({ dragData }); + + const sceneTracks = this.config.getSceneTracks(); + const target = computeDropTarget({ + elementType, + mouseX: coords.mouseX, + mouseY: coords.mouseY, + tracks: sceneTracks, + playheadTime: this.config.getCurrentPlayheadTime(), + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + targetElementTypes, + }); + + const fps = this.config.getActiveProjectFps(); + target.xPosition = fps + ? (roundToFrame({ time: target.xPosition, rate: fps }) ?? + target.xPosition) + : target.xPosition; + + this.setOver({ dropTarget: target, elementType }); + event.dataTransfer.dropEffect = "copy"; + } + + onDragLeave(event: DragEvent): void { + event.preventDefault(); + if (this.enterCount === 0) return; + this.enterCount -= 1; + if (this.enterCount === 0) { + this.setIdle(); + } + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + this.enterCount = 0; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.files?.length > 0; + if (!dragData && !hasFiles) return; + + const currentTarget = this.dropTarget; + this.setIdle(); + + try { + if (dragData) { + if (!currentTarget) return; + this.executeAssetDrop({ target: currentTarget, dragData }); + return; + } + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + this.executeFileDrop({ + files: Array.from(event.dataTransfer.files), + mouseX: coords.mouseX, + mouseY: coords.mouseY, + }).catch((error) => { + console.error("Failed to process file drop:", error); + }); + } catch (error) { + console.error("Failed to process drop:", error); + } + } + + // --- Private --- + + private setOver(state: { + dropTarget: DropTarget | null; + elementType: ElementType | null; + }): void { + this.state = { kind: "over", ...state }; + this.notify(); + } + + private setIdle(): void { + this.state = { kind: "idle" }; + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private getMouseTimelineCoords({ + event, + }: { + event: DragEvent; + }): TimelineCoords | null { + const scrollContainer = this.config.getTracksScrollEl(); + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + this.config.getContainerEl()?.getBoundingClientRect(); + if (!referenceRect) return null; + + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const headerHeight = + this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return { + mouseX: event.clientX - referenceRect.left + scrollLeft, + mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight, + }; + } + + // Shared insertion logic — new track vs existing track. + private insertAtTarget({ + element, + target, + trackType, + }: { + element: CreateTimelineElement; + target: DropTarget; + trackType: TrackType; + }): void { + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + this.config.executeCommand( + new BatchCommand([ + addTrackCmd, + new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }), + ]), + ); + return; + } + + const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() }); + const track = tracks[target.trackIndex]; + if (!track) return; + this.config.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + } + + private executeAssetDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: TimelineDragData; + }): void { + switch (dragData.type) { + case "text": + this.executeTextDrop({ target, dragData }); + return; + case "graphic": + this.executeGraphicDrop({ target, dragData }); + return; + case "sticker": + this.executeStickerDrop({ target, dragData }); + return; + case "effect": + this.executeEffectDrop({ target, dragData }); + return; + case "media": + this.executeMediaDrop({ target, dragData }); + return; + } + } + + private executeTextDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildTextElement({ + raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "text" }); + } + + private executeStickerDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildStickerElement({ + stickerId: dragData.stickerId, + name: dragData.name, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeGraphicDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeMediaDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + // Replace media source — not yet implemented + return; + } + + const mediaAsset = this.config + .getMediaAssets() + .find((asset) => asset.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration: toElementDurationTicks({ seconds: mediaAsset.duration }), + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType }); + } + + private executeEffectDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + this.config.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + const existingEffectTrack = orderedTracks({ + sceneTracks: this.config.getSceneTracks(), + }).find((track) => track.type === "effect"); + + if (existingEffectTrack) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: existingEffectTrack.id }, + element, + }); + return; + } + + this.insertAtTarget({ element, target, trackType: "effect" }); + } + + private async executeFileDrop({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }): Promise { + const projectId = this.config.getActiveProjectId(); + if (!projectId) return; + + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + + // Sequential on purpose: each iteration reads getSceneTracks() + // to decide placement (reuse empty main vs new track) and that + // decision depends on the effects of prior inserts. + for (const asset of processedAssets) { + const createdAsset = await this.config.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = toElementDurationTicks({ + seconds: createdAsset.duration, + }); + + const sceneTracks = this.config.getSceneTracks(); + const currentTime = this.config.getCurrentPlayheadTime(); + + const reuseMainTrackId = + createdAsset.type !== "audio" && + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id + : null; + + if (reuseMainTrackId) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: reuseMainTrackId }, + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: currentTime, + }), + }); + continue; + } + + const dropTarget = computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: sceneTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + this.insertAtTarget({ + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget.xPosition, + }), + target: dropTarget, + trackType, + }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } +} diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts new file mode 100644 index 00000000..cc19daaa --- /dev/null +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -0,0 +1,691 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/timeline/group-move"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame, type FrameRate } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { + DropTarget, + ElementRef, + ElementDragView, + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/timeline"; + +const MOUSE_BUTTON_RIGHT = 2; + +// --- Config --- + +export interface ViewportAdapter { + getZoomLevel: () => number; + getTracksScrollEl: () => HTMLDivElement | null; + getTracksContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getActiveFps: () => FrameRate | null; +} + +export interface ElementSelectionApi { + getSelected: () => readonly ElementRef[]; + isSelected: (ref: ElementRef) => boolean; + select: (ref: ElementRef) => void; + handleClick: (args: ElementRef & { isMultiKey: boolean }) => void; + clearKeyframeSelection: () => void; +} + +export interface PlaybackReader { + getCurrentTime: () => number; +} + +export interface TimelineOps { + moveElements: (args: Pick) => void; +} + +export interface SnapConfig { + isEnabled: () => boolean; + onChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ElementInteractionDeps { + viewport: ViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: ElementSelectionApi; + playback: PlaybackReader; + timeline: TimelineOps; + snap: SnapConfig; +} + +export interface ElementInteractionDepsRef { + readonly current: ElementInteractionDeps; +} + +// --- Session --- + +type Point = { readonly x: number; readonly y: number }; + +interface MousedownSnapshot { + readonly origin: Point; + readonly elementId: string; + readonly trackId: string; + readonly startElementTime: number; + readonly clickOffsetTime: number; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragProgress { + moveGroup: MoveGroup; + // Pre-minted per member so the identity of any "new track" created by + // this drag stays stable across mousemove-driven drop-target recomputes. + // `resolveGroupMoveForDrop` runs every mousemove and emits a + // `createTracks[]` carrying these IDs; downstream consumers (snap + // indicator, drop-line, commit path) see the same entity every frame + // instead of a churning UUID. + reservedNewTrackIds: readonly string[]; + currentTime: number; + currentMouseX: number; + currentMouseY: number; + groupMoveResult: GroupMoveResult | null; + dropTarget: DropTarget | null; +} + +type Session = + | { kind: "idle" } + | { kind: "pending"; mousedown: MousedownSnapshot } + | { kind: "dragging"; mousedown: MousedownSnapshot; drag: DragProgress }; + +const IDLE_VIEW: ElementDragView = { kind: "idle" }; + +// --- Pure helpers --- + +function pixelToClickOffsetTime( + clientX: number, + elementRect: DOMRect, + zoomLevel: number, +): number { + const clickOffsetX = clientX - elementRect.left; + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return Math.round(seconds * TICKS_PER_SECOND); +} + +function verticalDirection( + startMouseY: number, + currentMouseY: number, +): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +function movedPastDragThreshold(current: Point, origin: Point): boolean { + return ( + Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || + Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX + ); +} + +function frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime, + fps, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + clickOffsetTime: number; + fps: FrameRate; +}): number { + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + }); + const adjusted = Math.max(0, mouseTime - clickOffsetTime); + return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted; +} + +function resolveDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: SceneTracks; + viewport: ViewportAdapter; + zoomLevel: number; + snappedTime: number; + verticalDragDirection: "up" | "down" | null; +}): DropTarget | null { + const containerRect = viewport + .getTracksContainerEl() + ?.getBoundingClientRect(); + const scrollContainer = viewport.getTracksScrollEl(); + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = orderedTracks(tracks).find(({ id }) => id === trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const scrollRect = scrollContainer.getBoundingClientRect(); + const headerHeight = + viewport.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX: clientX - scrollRect.left + scrollContainer.scrollLeft, + mouseY: clientY - scrollRect.top + scrollContainer.scrollTop - headerHeight, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration: movingElement.duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +function resolveGroupMoveForDrop({ + group, + tracks, + anchorStartTime, + dropTarget, + reservedNewTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + dropTarget: DropTarget; + reservedNewTrackIds: readonly string[]; +}): GroupMoveResult | null { + const newTracksFallback = () => + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: [...reservedNewTrackIds], + }, + }); + + if (dropTarget.isNewTrack) return newTracksFallback(); + + const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex]; + if (!targetTrack) return null; + + return ( + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id }, + }) ?? newTracksFallback() + ); +} + +// --- Controller --- + +export class ElementInteractionController { + private session: Session = { kind: "idle" }; + // True once the active gesture crossed the drag threshold. Read by + // onElementClick, which fires after mouseup — by which point the session + // has already returned to idle, so the "was this a drag?" answer must + // outlive the session. Reset on the next mousedown. + private lastGestureWasDrag = false; + + private readonly subscribers = new Set<() => void>(); + private readonly depsRef: ElementInteractionDepsRef; + + constructor(args: { depsRef: ElementInteractionDepsRef }) { + this.depsRef = args.depsRef; + } + + private get deps(): ElementInteractionDeps { + return this.depsRef.current; + } + + get view(): ElementDragView { + if (this.session.kind !== "dragging") return IDLE_VIEW; + const { mousedown, drag } = this.session; + const memberTimeOffsets = new Map(); + for (const member of drag.moveGroup.members) { + memberTimeOffsets.set(member.elementId, member.timeOffset); + } + return { + kind: "dragging", + anchorElementId: mousedown.elementId, + trackId: mousedown.trackId, + memberTimeOffsets, + startMouseX: mousedown.origin.x, + startMouseY: mousedown.origin.y, + startElementTime: mousedown.startElementTime, + clickOffsetTime: mousedown.clickOffsetTime, + currentTime: drag.currentTime, + currentMouseX: drag.currentMouseX, + currentMouseY: drag.currentMouseY, + dropTarget: drag.dropTarget, + }; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel = (): void => { + this.lastGestureWasDrag = false; + this.finishSession(); + }; + + destroy(): void { + this.cancel(); + this.subscribers.clear(); + } + + onElementMouseDown = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + // Right-click must not stopPropagation — ContextMenu needs the bubble. + if (event.button === MOUSE_BUTTON_RIGHT) { + const ref = { trackId: track.id, elementId: element.id }; + if (!this.deps.selection.isSelected(ref)) { + this.deps.selection.handleClick({ ...ref, isMultiKey: false }); + } + return; + } + + event.stopPropagation(); + this.lastGestureWasDrag = false; + + const ref = { trackId: track.id, elementId: element.id }; + + if (event.metaKey || event.ctrlKey || event.shiftKey) { + this.deps.selection.handleClick({ ...ref, isMultiKey: true }); + } + + const selectedElements = this.deps.selection.isSelected(ref) + ? this.deps.selection.getSelected() + : [ref]; + + this.session = { + kind: "pending", + mousedown: { + origin: { x: event.clientX, y: event.clientY }, + elementId: element.id, + trackId: track.id, + startElementTime: element.startTime, + clickOffsetTime: pixelToClickOffsetTime( + event.clientX, + event.currentTarget.getBoundingClientRect(), + this.deps.viewport.getZoomLevel(), + ), + selectedElements, + }, + }; + this.activate(); + this.notify(); + }; + + onElementClick = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + event.stopPropagation(); + + if (this.lastGestureWasDrag) { + this.lastGestureWasDrag = false; + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const ref = { trackId: track.id, elementId: element.id }; + if ( + !this.deps.selection.isSelected(ref) || + this.deps.selection.getSelected().length > 1 + ) { + this.deps.selection.select(ref); + return; + } + + this.deps.selection.clearKeyframeSelection(); + }; + + 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.deps.snap.onChange?.(null); + this.notify(); + } + + private snapResult( + frameSnappedTime: number, + group: MoveGroup, + ): { snappedTime: number; snapPoint: SnapPoint | null } { + const { snap, input, scene, viewport, playback } = this.deps; + + if (!snap.isEnabled() || input.isShiftHeld()) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const result = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, + tracks: scene.getTracks(), + playheadTime: playback.getCurrentTime(), + zoomLevel: viewport.getZoomLevel(), + }); + + return { + snappedTime: result.snappedAnchorStartTime, + snapPoint: result.snapPoint, + }; + } + + private updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }: { + clientX: number; + clientY: number; + mousedown: MousedownSnapshot; + drag: DragProgress; + snappedTime: number; + }): void { + const { scene, viewport } = this.deps; + const tracks = scene.getTracks(); + const zoomLevel = viewport.getZoomLevel(); + + const anchorDropTarget = resolveDropTarget({ + clientX, + clientY, + elementId: mousedown.elementId, + trackId: mousedown.trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + }); + + const nextGroupMoveResult = anchorDropTarget + ? resolveGroupMoveForDrop({ + group: drag.moveGroup, + tracks, + anchorStartTime: snappedTime, + dropTarget: anchorDropTarget, + reservedNewTrackIds: drag.reservedNewTrackIds, + }) + : null; + + drag.groupMoveResult = nextGroupMoveResult; + drag.dropTarget = + anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { ...anchorDropTarget, isNewTrack: true } + : null; + } + + private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => { + const scrollContainer = this.deps.viewport.getTracksScrollEl(); + if (!scrollContainer) return; + + if (this.session.kind === "pending") { + this.beginDragFromPending({ + mousedown: this.session.mousedown, + clientX, + clientY, + scrollContainer, + }); + return; + } + + if (this.session.kind === "dragging") { + this.updateActiveDrag({ + mousedown: this.session.mousedown, + drag: this.session.drag, + clientX, + clientY, + scrollContainer, + }); + } + }; + + private beginDragFromPending({ + mousedown, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + return; + } + + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }, + selectedElements: [...mousedown.selectedElements], + tracks: this.deps.scene.getTracks(), + }); + if (!moveGroup) return; + + const zoomLevel = this.deps.viewport.getZoomLevel(); + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + moveGroup, + ); + + // Ensure the anchor is selected before we render the drag — covers the + // case where the selection store hasn't committed the mousedown-time + // selection click yet. + const anchorRef = { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }; + if (!this.deps.selection.isSelected(anchorRef)) { + this.deps.selection.select(anchorRef); + } + + const drag: DragProgress = { + moveGroup, + reservedNewTrackIds: moveGroup.members.map(() => generateUUID()), + currentTime: snappedTime, + currentMouseX: clientX, + currentMouseY: clientY, + groupMoveResult: null, + dropTarget: null, + }; + + this.session = { kind: "dragging", mousedown, drag }; + this.lastGestureWasDrag = true; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private updateActiveDrag({ + mousedown, + drag, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + drag: DragProgress; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel: this.deps.viewport.getZoomLevel(), + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + drag.moveGroup, + ); + + drag.currentTime = snappedTime; + drag.currentMouseX = clientX; + drag.currentMouseY = clientY; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "dragging") return; + + const { mousedown, drag } = this.session; + + // If the drag returned within the click threshold of its origin, treat + // this as a cancel rather than a commit — the user dragged then put the + // element back. + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + this.lastGestureWasDrag = false; + this.finishSession(); + return; + } + + const { moveGroup, groupMoveResult } = drag; + if (!groupMoveResult) { + this.finishSession(); + return; + } + + const didMove = groupMoveResult.moves.some((move) => { + const member = moveGroup.members.find( + (m) => m.elementId === move.elementId, + ); + const originalStartTime = + mousedown.startElementTime + (member?.timeOffset ?? 0); + return ( + member?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + + if (didMove || groupMoveResult.createTracks.length > 0) { + this.deps.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); + } + + this.finishSession(); + }; +} diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts new file mode 100644 index 00000000..51c60dee --- /dev/null +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -0,0 +1,342 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { getKeyframeById } from "@/animation"; +import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/commands"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { Command } from "@/commands/base-command"; + +// --- Session --- + +interface PendingSession { + kind: "pending"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +interface ActiveSession { + kind: "active"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; + deltaTicks: number; +} + +type Session = { kind: "idle" } | PendingSession | ActiveSession; + +// --- Public state --- + +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTicks: number; +} + +const IDLE_DRAG_STATE: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTicks: 0, +}; + +// --- Config --- + +export interface KeyframeDragConfig { + zoomLevel: number; + getFps: () => FrameRate | null; + element: TimelineElement; + displayedStartTime: number; + selectedKeyframes: SelectedKeyframeRef[]; + isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean; + setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void; + toggleKeyframeSelection: (args: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => void; + selectKeyframeRange: (args: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => void; + executeCommand: (command: Command) => void; + seek: (args: { time: number }) => void; + getTotalDuration: () => number; +} + +export interface KeyframeDragConfigRef { + readonly current: KeyframeDragConfig; +} + +// --- Controller --- + +export class KeyframeDragController { + private session: Session = { kind: "idle" }; + // Persists through mouseup so the click handler can detect drag vs click + private mouseDownX: number | null = null; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: KeyframeDragConfigRef; + + constructor(deps: { configRef: KeyframeDragConfigRef }) { + this.configRef = deps.configRef; + this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this); + this.onKeyframeClick = this.onKeyframeClick.bind(this); + this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): KeyframeDragConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + get keyframeDragState(): KeyframeDragState { + if (this.session.kind !== "active") return IDLE_DRAG_STATE; + return { + isDragging: true, + draggingKeyframeIds: new Set( + this.session.keyframeRefs.map((kf) => kf.keyframeId), + ), + deltaTicks: this.session.deltaTicks, + }; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.mouseDownX = null; + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onKeyframeMouseDown({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }): void { + event.preventDefault(); + event.stopPropagation(); + + this.mouseDownX = event.clientX; + + const anySelected = keyframes.some((kf) => + this.config.isKeyframeSelected({ keyframe: kf }), + ); + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + + if (!anySelected && !isModifierKey) { + this.config.setKeyframeSelection({ keyframes }); + } + + this.session = { + kind: "pending", + keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes, + startMouseX: event.clientX, + }; + this.activate(); + this.notify(); + } + + onKeyframeClick({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }): void { + event.stopPropagation(); + + const wasDrag = + this.mouseDownX !== null && + Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX; + this.mouseDownX = null; + + if (wasDrag) return; + + const { displayedStartTime, getFps, getTotalDuration, seek } = this.config; + const fps = getFps(); + const seekTime = + fps != null + ? (snappedSeekTime({ + time: displayedStartTime + indicatorTime, + duration: getTotalDuration(), + rate: fps, + }) ?? displayedStartTime + indicatorTime) + : displayedStartTime + indicatorTime; + seek({ time: seekTime }); + + if (event.shiftKey) { + this.config.selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + this.config.toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + } + + getVisualOffsetPx({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number { + if (!isBeingDragged || this.session.kind !== "active") + return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min( + this.config.element.duration, + indicatorTime + this.session.deltaTicks, + ), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel: this.config.zoomLevel, + }) - elementLeft + ); + } + + 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.notify(); + } + + private commitDrag({ + keyframeRefs, + deltaTicks, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTicks: number; + }): void { + const { element } = this.config; + const commands: Command[] = keyframeRefs.flatMap((ref) => { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + }); + if (!keyframe) return []; + return [ + new RetimeKeyframeCommand({ + trackId: ref.trackId, + elementId: ref.elementId, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + nextTime: Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTicks), + ), + }), + ]; + }); + + const [first, ...rest] = commands; + if (!first) return; + if (rest.length === 0) { + this.config.executeCommand(first); + } else { + this.config.executeCommand(new BatchCommand([first, ...rest])); + } + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind === "pending") { + const deltaX = Math.abs(clientX - this.session.startMouseX); + if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; + + this.session = { + kind: "active", + keyframeRefs: this.session.keyframeRefs, + startMouseX: this.session.startMouseX, + deltaTicks: 0, + }; + this.notify(); + return; + } + + if (this.session.kind !== "active") return; + + const fps = this.config.getFps(); + if (!fps) return; + + const pixelsPerSecond = + BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel; + const rawDeltaTicks = Math.round( + ((clientX - this.session.startMouseX) / pixelsPerSecond) * + TICKS_PER_SECOND, + ); + this.session.deltaTicks = + roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks; + this.notify(); + } + + private handleMouseUp(): void { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "active") return; + + const { selectedKeyframes, element } = this.config; + const { keyframeRefs, deltaTicks } = this.session; + const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId)); + const draggingRefs = selectedKeyframes.filter( + (kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId), + ); + + if (draggingRefs.length > 0 && deltaTicks !== 0) { + this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks }); + } + + this.finishSession(); + } +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts new file mode 100644 index 00000000..5260c046 --- /dev/null +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -0,0 +1,314 @@ +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 "@/animation/timeline-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(); + } +} 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(); + } +} diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts new file mode 100644 index 00000000..f32c855e --- /dev/null +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -0,0 +1,210 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { snappedSeekTime, type FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; + +type SeekSource = "ruler" | "tracks"; + +interface PendingSeekSession { + kind: "pending"; + source: SeekSource; + downX: number; + downY: number; + downTime: number; +} + +type Session = { kind: "idle" } | PendingSeekSession; + +export interface SeekConfig { + zoomLevel: number; + duration: number; + isSelecting: boolean; + getPlayheadEl: () => HTMLDivElement | null; + getTrackLabelsEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + clearSelectedElements: () => void; + seek: (time: number) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: number; + }) => void; +} + +export interface SeekConfigRef { + readonly current: SeekConfig; +} + +function pixelToTime({ + clientX, + scrollContainer, + zoomLevel, + duration, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + duration: number; +}): number { + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + + return Math.round(rawTimeSeconds * TICKS_PER_SECOND); +} + +function isClickGesture({ + event, + session, +}: { + event: ReactMouseEvent; + session: PendingSeekSession; +}): boolean { + const deltaX = Math.abs(event.clientX - session.downX); + const deltaY = Math.abs(event.clientY - session.downY); + const deltaTime = event.timeStamp - session.downTime; + + return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500; +} + +export class SeekController { + private session: Session = { kind: "idle" }; + private readonly configRef: SeekConfigRef; + + constructor(deps: { configRef: SeekConfigRef }) { + this.configRef = deps.configRef; + this.onTracksMouseDown = this.onTracksMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.onTracksClick = this.onTracksClick.bind(this); + this.onRulerClick = this.onRulerClick.bind(this); + } + + private get config(): SeekConfig { + return this.configRef.current; + } + + destroy(): void { + this.session = { kind: "idle" }; + } + + onTracksMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "tracks" }); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "ruler" }); + } + + onTracksClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "tracks" }); + } + + onRulerClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "ruler" }); + } + + private beginPendingSeek({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + if (event.button !== 0) return; + + this.session = { + kind: "pending", + source, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; + } + + private handleClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const shouldProcess = this.shouldProcessClick({ event, source }); + this.session = { kind: "idle" }; + + if (!shouldProcess) return; + + this.config.clearSelectedElements(); + this.seekFromEvent({ event, source }); + } + + private shouldProcessClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): boolean { + if (this.session.kind !== "pending") return false; + if (this.session.source !== source) return false; + if (!isClickGesture({ event, session: this.session })) return false; + if (this.config.isSelecting) return false; + + const target = event.target as HTMLElement; + if (this.config.getPlayheadEl()?.contains(target)) return false; + + if (this.config.getTrackLabelsEl()?.contains(target)) { + this.config.clearSelectedElements(); + return false; + } + + return true; + } + + private seekFromEvent({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const scrollContainer = + source === "ruler" + ? this.config.getRulerScrollEl() + : this.config.getTracksScrollEl(); + if (!scrollContainer) return; + + const rawTime = pixelToTime({ + clientX: event.clientX, + scrollContainer, + zoomLevel: this.config.zoomLevel, + duration: this.config.duration, + }); + + const fps = this.config.getActiveProjectFps(); + const time = + fps != null + ? (snappedSeekTime({ + time: rawTime, + duration: this.config.duration, + rate: fps, + }) ?? rawTime) + : rawTime; + + this.config.seek(time); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }); + } +} diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts new file mode 100644 index 00000000..8e0af4fe --- /dev/null +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -0,0 +1,285 @@ +import type { WheelEvent as ReactWheelEvent } from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; +import { zoomToSlider } from "@/timeline/zoom-utils"; + +type ZoomUpdater = number | ((prev: number) => number); + +export interface ZoomConfig { + minZoom: number; + getContainerEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getCurrentPlayheadTime: () => number; + seek: (time: number) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: number; + }) => void; +} + +export interface ZoomConfigRef { + readonly current: ZoomConfig; +} + +function clampZoom(zoomLevel: number, minZoom: number): number { + return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); +} + +export class ZoomController { + private readonly configRef: ZoomConfigRef; + private readonly subscribers = new Set<() => void>(); + + private zoomLevelValue: number; + private hasInitialized = false; + private hasRestoredPlayhead = false; + private hasRestoredScroll = false; + private previousZoom: number; + private preZoomScrollLeft = 0; + private prePlayheadAnchorScrollLeft = 0; + private isInPlayheadAnchorMode = false; + private scrollSaveTimeout: ReturnType | null = null; + + constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) { + this.configRef = deps.configRef; + + const minZoom = this.config.minZoom; + this.zoomLevelValue = + deps.initialZoom !== undefined + ? clampZoom(deps.initialZoom, minZoom) + : minZoom; + this.previousZoom = this.zoomLevelValue; + this.hasInitialized = deps.initialZoom !== undefined; + + this.setZoomLevel = this.setZoomLevel.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.saveScrollPosition = this.saveScrollPosition.bind(this); + } + + private get config(): ZoomConfig { + return this.configRef.current; + } + + get zoomLevel(): number { + return this.zoomLevelValue; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + this.scrollSaveTimeout = null; + } + } + + setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void { + const scrollElement = this.config.getTracksScrollEl(); + if (scrollElement) { + this.preZoomScrollLeft = scrollElement.scrollLeft; + } + + const nextZoomRaw = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(this.zoomLevelValue) + : zoomLevelOrUpdater; + const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + if (nextZoom === this.zoomLevelValue) return; + + this.zoomLevelValue = nextZoom; + this.notify(); + } + + handleWheel(event: ReactWheelEvent): void { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + this.setZoomLevel((prev) => prev * zoomFactor); + } + } + + reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + if (initialZoom !== undefined && !this.hasInitialized) { + this.hasInitialized = true; + this.setZoomLevel(clampZoom(initialZoom, minZoom)); + return; + } + + if (this.zoomLevelValue < minZoom) { + this.setZoomLevel(minZoom); + } + } + + applyZoomLayout(zoomLevel: number): void { + const previousZoom = this.previousZoom; + if (previousZoom === zoomLevel) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) { + this.previousZoom = zoomLevel; + return; + } + + const currentScrollLeft = this.preZoomScrollLeft; + const playheadTime = this.config.getCurrentPlayheadTime(); + const sliderPercent = zoomToSlider({ + zoomLevel, + minZoom: this.config.minZoom, + }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom: this.config.minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + this.prePlayheadAnchorScrollLeft = currentScrollLeft; + this.isInPlayheadAnchorMode = true; + } + + if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = timelineTimeToPixels({ + time: playheadTime, + zoomLevel: previousZoom, + }); + const playheadPixelsAfter = timelineTimeToPixels({ + time: playheadTime, + zoomLevel, + }); + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const nextScrollLeft = playheadPixelsAfter - viewportOffset; + syncScroll(clampScrollLeft(nextScrollLeft)); + } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) { + syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft)); + this.isInPlayheadAnchorMode = false; + } + + this.previousZoom = zoomLevel; + + this.config.setTimelineViewState({ + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }); + } + + saveScrollPosition(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + } + + this.scrollSaveTimeout = setTimeout(() => { + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + this.config.setTimelineViewState({ + zoomLevel: this.zoomLevelValue, + scrollLeft: scrollElement.scrollLeft, + playheadTime: this.config.getCurrentPlayheadTime(), + }); + }, 300); + } + + restoreInitialScrollIfNeeded( + initialScrollLeft?: number, + ): (() => void) | undefined { + if (initialScrollLeft === undefined) return; + if (this.hasRestoredScroll) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = initialScrollLeft; + } + this.hasRestoredScroll = true; + this.preZoomScrollLeft = initialScrollLeft; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + return; + } + + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void { + if (initialPlayheadTime === undefined) return; + if (this.hasRestoredPlayhead) return; + + this.hasRestoredPlayhead = true; + this.config.seek(initialPlayheadTime); + } + + bindPreventBrowserZoom(): () => void { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const container = this.config.getContainerEl(); + const isInContainer = container?.contains(event.target as Node) ?? false; + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + } + + private notify(): void { + for (const fn of this.subscribers) { + fn(); + } + } +} diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2ac7f246..2dec3186 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,3 +1,20 @@ import { TICKS_PER_SECOND } from "@/wasm/ticks"; export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; + +/** + * Resolves the duration in ticks that a newly created timeline element + * should have, given an optional source duration in seconds. + * + * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has + * no known duration (e.g. stickers, graphics, text, or media whose + * probe failed). + */ +export function toElementDurationTicks({ + seconds, +}: { + seconds: number | null | undefined; +}): number { + if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION; + return Math.round(seconds * TICKS_PER_SECOND); +} diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts deleted file mode 100644 index 1fc9ce9a..00000000 --- a/apps/web/src/timeline/drag-data.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TimelineDragData } from "@/timeline/drag"; - -const MIME_TYPE = "application/x-timeline-drag"; -let lastDragData: TimelineDragData | null = null; - -export function setDragData({ - dataTransfer, - dragData, -}: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; -}): void { - dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData)); - dataTransfer.setData("text/plain", JSON.stringify(dragData)); - lastDragData = dragData; -} - -export function getDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): TimelineDragData | null { - const data = dataTransfer.getData(MIME_TYPE); - if (data) return JSON.parse(data) as TimelineDragData; - - const textData = dataTransfer.getData("text/plain"); - if (textData) { - try { - return JSON.parse(textData) as TimelineDragData; - } catch { - return lastDragData; - } - } - - return lastDragData; -} - -export function hasDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): boolean { - return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null; -} - -export function clearDragData(): void { - lastDragData = null; -} diff --git a/apps/web/src/timeline/drag-source.ts b/apps/web/src/timeline/drag-source.ts new file mode 100644 index 00000000..503c547b --- /dev/null +++ b/apps/web/src/timeline/drag-source.ts @@ -0,0 +1,40 @@ +import type { TimelineDragData } from "@/timeline/drag"; + +const TIMELINE_DRAG_MIME = "application/x-timeline-drag"; + +/** + * Owns the state of an in-progress timeline drag session. + * + * Exists because browsers restrict `DataTransfer.getData()` to the `drop` + * event for security — during `dragover`/`dragenter` only `types` is + * readable. The drop target needs the payload (element type, target + * element types, source duration) while the pointer is hovering, so we + * keep a live copy here and hand it out via {@link getActive}. + */ +export class TimelineDragSource { + private active: TimelineDragData | null = null; + + begin({ + dataTransfer, + dragData, + }: { + dataTransfer: DataTransfer; + dragData: TimelineDragData; + }): void { + dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData)); + dataTransfer.effectAllowed = "copy"; + this.active = dragData; + } + + end(): void { + this.active = null; + } + + getActive(): TimelineDragData | null { + return this.active; + } + + isActive(): boolean { + return this.active !== null; + } +} diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index dd542967..d22b6891 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -15,63 +15,63 @@ import type { export function computeGroupResize({ members, side, - deltaTime, + deltaTicks, fps, }: ComputeGroupResizeArgs): GroupResizeResult { const minDuration = Math.round( (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ); - const minimumDeltaTime = Math.max( + const minimumDeltaTicks = Math.max( ...members.map((member) => - getMinimumAllowedDeltaTime({ + getMinimumAllowedDeltaTicks({ member, side, minDuration, }), ), ); - const maximumDeltaTime = Math.min( + const maximumDeltaTicks = Math.min( ...members.map((member) => - getMaximumAllowedDeltaTime({ + getMaximumAllowedDeltaTicks({ member, side, minDuration, }), ), ); - const clampedDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime - : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + const clampedDeltaTicks = + minimumDeltaTicks > maximumDeltaTicks + ? minimumDeltaTicks + : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks)); // Snap the drag delta to a frame exactly once, then derive every patch // field from that single snapped value. This keeps the invariant // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same // delta is added on one side of the element and removed from the other, - // so the rounding cancels by construction. Per-field rounding (the old - // approach) couldn't preserve this because the individual rounds don't - // compose when `sourceDuration` isn't frame-aligned. - const snappedDeltaTime = - roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime; + // so the rounding cancels by construction. Rounding each field + // independently would break this — the individual rounds don't compose + // when `sourceDuration` isn't frame-aligned. + const snappedDeltaTicks = + roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks; // Re-clamp after rounding. Bounds derived from other elements are // frame-aligned, so this is normally a no-op; at the source-extent limit // the bound may not be frame-aligned, and honouring the bound takes // precedence over frame alignment (you can't extend past real content). - const finalDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime + const finalDeltaTicks = + minimumDeltaTicks > maximumDeltaTicks + ? minimumDeltaTicks : Math.min( - maximumDeltaTime, - Math.max(minimumDeltaTime, snappedDeltaTime), + maximumDeltaTicks, + Math.max(minimumDeltaTicks, snappedDeltaTicks), ); return { - deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime, + deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks, updates: members.map((member) => buildResizeUpdate({ member, side, - deltaTime: finalDeltaTime, + deltaTicks: finalDeltaTicks, }), ), }; @@ -80,15 +80,15 @@ export function computeGroupResize({ function buildResizeUpdate({ member, side, - deltaTime, + deltaTicks, }: { member: GroupResizeMember; side: ResizeSide; - deltaTime: number; + deltaTicks: number; }): GroupResizeUpdate { const sourceDelta = getSourceDeltaForClipDelta({ member, - clipDelta: deltaTime, + clipDelta: deltaTicks, }); if (side === "left") { @@ -98,8 +98,8 @@ function buildResizeUpdate({ patch: { trimStart: Math.max(0, member.trimStart + sourceDelta), trimEnd: member.trimEnd, - startTime: member.startTime + deltaTime, - duration: member.duration - deltaTime, + startTime: member.startTime + deltaTicks, + duration: member.duration - deltaTicks, }, }; } @@ -111,12 +111,12 @@ function buildResizeUpdate({ trimStart: member.trimStart, trimEnd: Math.max(0, member.trimEnd - sourceDelta), startTime: member.startTime, - duration: member.duration + deltaTime, + duration: member.duration + deltaTicks, }, }; } -function getMinimumAllowedDeltaTime({ +function getMinimumAllowedDeltaTicks({ member, side, minDuration, @@ -148,7 +148,7 @@ function getMinimumAllowedDeltaTime({ return Math.max(leftNeighborFloor, -maximumSourceExtension); } -function getMaximumAllowedDeltaTime({ +function getMaximumAllowedDeltaTicks({ member, side, minDuration, diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts index b07e6fe6..511358ba 100644 --- a/apps/web/src/timeline/group-resize/types.ts +++ b/apps/web/src/timeline/group-resize/types.ts @@ -24,13 +24,13 @@ export interface GroupResizeUpdate extends ElementRef { } export interface GroupResizeResult { - deltaTime: number; + deltaTicks: number; updates: GroupResizeUpdate[]; } export interface ComputeGroupResizeArgs { members: GroupResizeMember[]; side: ResizeSide; - deltaTime: number; + deltaTicks: number; fps: FrameRate; } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 621259b9..26a21f85 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,42 +1,17 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { registerCanceller } from "@/editor/cancel-interaction"; import { - buildMoveGroup, - resolveGroupMove, - snapGroupEdges, - type GroupMoveResult, - type MoveGroup, -} from "@/timeline/group-move"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; -import { generateUUID } from "@/utils/id"; + ElementInteractionController, + type ElementInteractionDeps, + type ElementInteractionDepsRef, +} from "@/timeline/controllers/element-interaction-controller"; import type { SnapPoint } from "@/timeline/snapping"; -import { registerCanceller } from "@/editor/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/timeline"; interface UseElementInteractionProps { zoomLevel: number; - timelineRef: RefObject; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -44,131 +19,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): number { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject; - tracksScrollRef: RefObject; - headerRef?: RefObject; - zoomLevel: number; - snappedTime: number; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: number; - initialCurrentMouseY: number; -} - export function useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, headerRef, @@ -177,584 +29,65 @@ export function useElementInteraction({ }: UseElementInteractionProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); - - const [dragState, setDragState] = - useState(initialDragState); - const [dragDropTarget, setDragDropTarget] = useState(null); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(null); - const moveGroupRef = useRef(null); - const newTrackIdsRef = useRef([]); - const groupMoveResultRef = useRef(null); - const lastMouseXRef = useRef(0); - const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); - - const startDrag = useCallback( - ({ - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); + const selection = useElementSelection(); + + const deps: ElementInteractionDeps = { + viewport: { + getZoomLevel: () => zoomLevel, + getTracksScrollEl: () => tracksScrollRef.current, + getTracksContainerEl: () => tracksContainerRef.current, + getHeaderEl: () => headerRef?.current ?? null, }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - onSnapPointChange?.(null); - }, [endDrag, onSnapPointChange]); - - const resolveGroupDragMove = useCallback( - ({ - group, - snappedTime, - dropTarget, - }: { - group: MoveGroup; - snappedTime: number; - dropTarget: DropTarget | null; - }): GroupMoveResult | null => { - if (!dropTarget) { - return null; - } - - if (dropTarget.isNewTrack) { - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - } - - const orderedTracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; - const targetTrack = orderedTracks[dropTarget.trackIndex]; - if (!targetTrack) { - return null; - } - - const existingTrackResult = resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "existingTrack", - anchorTargetTrackId: targetTrack.id, - }, - }); - if (existingTrackResult) { - return existingTrackResult; - } - - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [sceneTracks], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: number; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getActiveFps: () => editor.project.getActive()?.settings.fps ?? null, }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = - roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime; - const moveGroup = buildMoveGroup({ - anchorRef: { - trackId: pendingDragRef.current.trackId, - elementId: pendingDragRef.current.elementId, - }, - selectedElements: pendingDragRef.current.selectedElements, - tracks: sceneTracks, - }); - if (!moveGroup) { - return; - } - - moveGroupRef.current = moveGroup; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const didMove = groupMoveResult.moves.some((move) => { - const currentMember = moveGroup.members.find( - (member) => member.elementId === move.elementId, - ); - const originalStartTime = - dragState.startElementTime + (currentMember?.timeOffset ?? 0); - return ( - currentMember?.trackId !== move.targetTrackId || - originalStartTime !== move.newStartTime - ); - }); - if (!didMove && groupMoveResult.createTracks.length === 0) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - editor.timeline.moveElements({ - moves: groupMoveResult.moves, - createTracks: groupMoveResult.createTracks, - }); - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - elementRect: event.currentTarget.getBoundingClientRect(), - zoomLevel, - }); - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const pendingSelectedElements = isElementSelected(elementRef) - ? selectedElements - : [elementRef]; - pendingDragRef.current = { - elementId: element.id, - trackId: track.id, - selectedElements: pendingSelectedElements, - startMouseX: event.clientX, - startMouseY: event.clientY, - startElementTime: element.startTime, - clickOffsetTime, - }; - setIsPendingDrag(true); + selection: { + getSelected: () => selection.selectedElements, + isSelected: selection.isElementSelected, + select: selection.selectElement, + handleClick: selection.handleElementClick, + clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(), + }, + playback: { + getCurrentTime: () => editor.playback.getCurrentTime(), }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); + timeline: { + moveElements: (args) => editor.timeline.moveElements(args), + }, + snap: { + isEnabled: () => snappingEnabled, + onChange: onSnapPointChange, + }, + }; - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); + const depsRef = useRef(deps); + depsRef.current = deps; - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ElementInteractionController({ + depsRef: depsRef as ElementInteractionDepsRef, + }); + } + const controller = controllerRef.current; - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } + useEffect(() => { + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, + dragView: controller.view, + handleElementMouseDown: controller.onElementMouseDown, + handleElementClick: controller.onElementClick, }; } diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index 92b6f0d8..5a1ff1f0 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,40 +1,15 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { getKeyframeById } from "@/animation"; import { useKeyframeSelection } from "./use-keyframe-selection"; -import { roundToFrame, snappedSeekTime } from "opencut-wasm"; -import { timelineTimeToSnappedPixels } from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; -import { BatchCommand } from "@/commands"; -import type { SelectedKeyframeRef } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import type { Command } from "@/commands/base-command"; import { registerCanceller } from "@/editor/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set; - deltaTime: number; -} - -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: 0, -}; +import { + KeyframeDragController, + type KeyframeDragConfig, + type KeyframeDragState, +} from "@/timeline/controllers/keyframe-drag-controller"; +import type { TimelineElement } from "@/timeline"; -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} +export type { KeyframeDragState }; export function useKeyframeDrag({ zoomLevel, @@ -54,275 +29,44 @@ export function useKeyframeDrag({ selectKeyframeRange, } = useKeyframeSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef(null); - const mouseDownXRef = useRef(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); - return [ - new RetimeKeyframeCommand({ - trackId: keyframeRef.trackId, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: 0, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = Math.round( - ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, - ); - const snappedDelta = - roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; - - setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, + const config: KeyframeDragConfig = { + zoomLevel, + element, + displayedStartTime, + getFps: () => editor.project.getActive()?.settings.fps ?? null, selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], - ); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + executeCommand: (command) => editor.command.execute({ command }), + seek: (args) => editor.playback.seek(args), + getTotalDuration: () => editor.timeline.getTotalDuration(), + }; - if (wasDrag) return; + const configRef = useRef(config); + configRef.current = config; - const duration = editor.timeline.getTotalDuration(); - const seekTime = - snappedSeekTime({ - time: displayedStartTime + indicatorTime, - duration, - rate: fps, - }) ?? displayedStartTime + indicatorTime; - editor.playback.seek({ time: seekTime }); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new KeyframeDragController({ configRef }); + } + const controller = controllerRef.current; - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [ - toggleKeyframeSelection, - selectKeyframeRange, - editor, - displayedStartTime, - fps, - ], - ); + useEffect(() => { + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, + keyframeDragState: controller.keyframeDragState, + handleKeyframeMouseDown: controller.onKeyframeMouseDown, + handleKeyframeClick: controller.onKeyframeClick, + getVisualOffsetPx: controller.getVisualOffsetPx, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 775b7337..4ec2b80b 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,30 +1,9 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; -import { processMediaAssets } from "@/media/processing"; -import { toast } from "sonner"; -import { showMediaUploadToast } from "@/media/upload-toast"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; -import { BatchCommand } from "@/commands"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getDragData, hasDragData } from "@/timeline/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/timeline/drag"; + DragDropController, + type DragDropConfig, +} from "@/timeline/controllers/drag-drop-controller"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -40,609 +19,47 @@ export function useTimelineDragDrop({ zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(null); - const getSnappedTime = useCallback( - ({ time }: { time: number }) => { - const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; - }, - [editor], - ); - - const getElementType = useCallback( - ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { - const dragData = getDragData({ dataTransfer }); - if (!dragData) return null; - - if (dragData.type === "text") return "text"; - if (dragData.type === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); - - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): number => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? Math.round(media.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_ELEMENT_DURATION; - }, - [editor], - ); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.types.includes("Files"); - if (!hasAsset && !hasFiles) return; - setIsDragOver(true); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const hasFiles = e.dataTransfer.types.includes("Files"); - const isExternal = - hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); - - const elementType = getElementType({ dataTransfer: e.dataTransfer }); - - if (!elementType && hasFiles && isExternal) { - setDropTarget(null); - setElementType(null); - return; - } - - if (!elementType) return; - - setElementType(elementType); - - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - const duration = getElementDuration({ - elementType, - mediaId: dragData?.type === "media" ? dragData.id : undefined, - }); - - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const { clientX, clientY } = e; - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - } - } - }, - [containerRef], - ); - - const executeTextDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: { name?: string; content?: string }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration != null - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration != null - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, zoomLevel], - ); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasAsset && !hasFiles) return; + const config: DragDropConfig = { + zoomLevel, + getContainerEl: () => containerRef.current, + getHeaderEl: () => headerRef?.current ?? null, + getTracksScrollEl: () => tracksScrollRef?.current ?? null, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + getActiveProjectId: () => + editor.project.getActiveOrNull()?.metadata.id ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + dragSource: editor.timeline.dragSource, + addMediaAsset: (args) => editor.media.addMediaAsset(args), + executeCommand: (command) => editor.command.execute({ command }), + insertElement: (args) => editor.timeline.insertElement(args), + addClipEffect: (args) => editor.timeline.addClipEffect(args), + }; - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); + const configRef = useRef(config); + configRef.current = config; - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new DragDropController({ configRef }); + } + const controller = controllerRef.current; - if (dragData.type === "text") { - executeTextDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + useEffect(() => () => controller.destroy(), [controller]); return { - isDragOver, - dropTarget, - dragElementType, + isDragOver: controller.isDragOver, + dropTarget: controller.dropTarget, + dragElementType: controller.dragElementType, dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, + onDragEnter: controller.onDragEnter, + onDragOver: controller.onDragOver, + onDragLeave: controller.onDragLeave, + onDrop: controller.onDrop, }, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 7d9cf447..12bfe76a 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,23 +1,12 @@ -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEffect, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { timelineTimeToPixels } from "@/timeline"; import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, -} from "@/timeline/snapping"; -import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; + PlayheadController, + type PlayheadConfig, +} from "@/timeline/controllers/playhead-controller"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -35,267 +24,75 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); const isShiftHeldRef = useShiftKey(); + // isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor, + // so this reactive read naturally reflects whether scrubbing is active. + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); - - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: number }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ - time: duration, - zoomLevel, - }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - - const rate = activeProject.settings.fps; - const frameTime = - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks }), - () => getBookmarkSnapPoints({ bookmarks }), - () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), - ], - }); - const snapResult = resolveTimelineSnap({ - targetTime: frameTime, - snapPoints, - maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), - }); - return snapResult.snapPoint ? snapResult.snappedTime : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); + const config: PlayheadConfig = { + zoomLevel, + duration: editor.timeline.getTotalDuration(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + isShiftHeld: () => isShiftHeldRef.current, + getIsPlaying: () => editor.playback.getIsPlaying(), + getRulerEl: () => rulerRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getPlayheadEl: () => playheadRef?.current ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [], + seek: (time) => editor.playback.seek({ time }), + setScrubbing: (scrubbing) => + editor.playback.setScrubbing({ isScrubbing: scrubbing }), + setTimelineViewState: (viewState) => + editor.project.setTimelineViewState({ viewState }), + }; + const configRef = useRef(config); + configRef.current = config; - const updatePlayheadLeft = useCallback( - (time: number) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); + const ctrlRef = useRef(null); + if (!ctrlRef.current) { + ctrlRef.current = new PlayheadController({ configRef }); + } + const ctrl = ctrlRef.current; + // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { const scrollEl = rulerScrollRef.current; if (!scrollEl) return; + const handler = () => + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + scrollEl.addEventListener("scroll", handler, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handler); + }, [ctrl, editor.playback, rulerScrollRef]); - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - + // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handlePlaybackUpdate = (e: Event) => { - const time = (e as CustomEvent<{ time: number }>).detail.time; - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } + const handler = (time: number) => ctrl.handlePlaybackUpdate(time); + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + const unsubscribeUpdate = editor.playback.onUpdate(handler); + const unsubscribeSeek = editor.playback.onSeek(handler); + return () => { + unsubscribeUpdate(); + unsubscribeSeek(); }; + }, [ctrl, editor.playback]); - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackUpdate({ - detail: { time: initialTime }, - } as CustomEvent<{ time: number }>); + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => ctrl.getLastMouseClientX(), + rulerScrollRef, + tracksScrollRef, + contentWidth: timelineTimeToPixels({ + time: editor.timeline.getTotalDuration(), + zoomLevel, + }), + }); - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); - return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); - }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); + useEffect(() => () => ctrl.destroy(), [ctrl]); return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, + handlePlayheadMouseDown: ctrl.onPlayheadMouseDown, + handleRulerMouseDown: ctrl.onRulerMouseDown, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index fb8da291..46363fb6 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,332 +1,81 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EditorCore } from "@/core"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; -import { - computeGroupResize, - type GroupResizeMember, - type GroupResizeResult, - 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 type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; -import { isRetimableElement } from "@/timeline"; import { registerCanceller } from "@/editor/cancel-interaction"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + ResizeController, + type ResizeConfig, +} from "@/timeline/controllers/resize-controller"; +import type { ResizeSide } from "@/timeline/group-resize"; +import type { SnapPoint } from "@/timeline/snapping"; + +export type { ResizeSide }; -interface ResizeInteractionState { - side: ResizeSide; - startX: number; - members: GroupResizeMember[]; +interface UseTimelineResizeProps { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } export function useTimelineResize({ zoomLevel, onSnapPointChange, -}: { - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { +}: UseTimelineResizeProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { selectedElements } = useElementSelection(); - const [resizeState, setResizeState] = useState( - null, - ); - const latestResultRef = useRef(null); - - const cancelResize = useCallback(() => { - editor.timeline.discardPreview(); - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }, [editor.timeline, onSnapPointChange]); - useEffect(() => { - if (!resizeState) { - return; - } - - return registerCanceller({ fn: cancelResize }); - }, [resizeState, cancelResize]); - - const handleResizeStart = useCallback( - ({ - event, - element, - track, - side, - }: { - event: React.MouseEvent; - element: TimelineElement; - track: TimelineTrack; - side: ResizeSide; - }) => { - event.stopPropagation(); - event.preventDefault(); - - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const activeSelection = selectedElements.some( - (selectedElement) => - selectedElement.trackId === track.id && - selectedElement.elementId === element.id, - ) - ? selectedElements - : [elementRef]; - const members = buildResizeMembers({ - tracks: editor.scenes.getActiveScene().tracks, - selectedElements: activeSelection, - }); - if (members.length === 0) { - return; - } - - editor.timeline.discardPreview(); - latestResultRef.current = null; - setResizeState({ - side, - startX: event.clientX, - members, - }); - }, - [selectedElements, editor], - ); - - useEffect(() => { - if (!resizeState) { - return; - } - - const handleMouseMove = ({ clientX }: MouseEvent) => { - const deltaX = clientX - resizeState.startX; - const rawDeltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * - TICKS_PER_SECOND, - ); - const snappedDeltaTime = getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap: snappingEnabled && !isShiftHeldRef.current, - onSnapPointChange, - }); - const fps = editor.project.getActive().settings.fps; - const result = computeGroupResize({ - members: resizeState.members, - side: resizeState.side, - deltaTime: snappedDeltaTime.deltaTime, - fps, - }); - - latestResultRef.current = result; + const config: ResizeConfig = { + zoomLevel, + snappingEnabled, + isShiftHeld: () => isShiftHeldRef.current, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + selectedElements, + discardPreview: () => editor.timeline.discardPreview(), + previewElements: (updates) => editor.timeline.previewElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ + updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, updates: patch, })), - }); - }; - - const handleMouseUp = () => { - const result = latestResultRef.current; - editor.timeline.discardPreview(); - if ( - result && - hasResizeChanges({ members: resizeState.members, result }) - ) { - editor.timeline.updateElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - patch, - })), - }); - } - - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - resizeState, - zoomLevel, - snappingEnabled, - isShiftHeldRef, - editor, + }), + commitElements: (updates) => + editor.timeline.updateElements({ + updates: updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch, + })), + }), onSnapPointChange, - ]); - - return { - isResizing: resizeState !== null, - handleResizeStart, }; -} - -function buildResizeMembers({ - tracks, - selectedElements, -}: { - tracks: SceneTracks; - selectedElements: Array<{ trackId: string; elementId: string }>; -}): GroupResizeMember[] { - const selectedElementIds = new Set( - selectedElements.map((selectedElement) => selectedElement.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( - (trackElement) => trackElement.id === elementId, - ); - if (!track || !element) { - return []; - } - const otherElements = track.elements.filter( - (trackElement) => !selectedElementIds.has(trackElement.id), - ); - const leftNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime + trackElement.duration <= element.startTime, - ) - .reduce((bound, trackElement) => { - return Math.max(bound, trackElement.startTime + trackElement.duration); - }, -Infinity); - const rightNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime >= element.startTime + element.duration, - ) - .reduce((bound, trackElement) => { - return Math.min(bound, trackElement.startTime); - }, Infinity); + const configRef = useRef(config); + configRef.current = config; - 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 getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap, - onSnapPointChange, -}: { - editor: EditorCore; - resizeState: ResizeInteractionState; - rawDeltaTime: number; - zoomLevel: number; - shouldSnap: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}): { deltaTime: number } { - if (!shouldSnap) { - onSnapPointChange?.(null); - return { deltaTime: rawDeltaTime }; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ResizeController({ configRef }); } + const controller = controllerRef.current; - const tracks = editor.scenes.getActiveScene().tracks; - const playheadTime = editor.playback.getCurrentTime(); - const excludeElementIds = new Set( - resizeState.members.map((member) => member.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 deltaTime = rawDeltaTime; + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - for (const member of resizeState.members) { - const baseEdgeTime = - resizeState.side === "left" - ? member.startTime - : member.startTime + member.duration; - const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTime, - snapPoints, - maxSnapDistance, - }); - if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { - closestSnapDistance = snapResult.snapDistance; - closestSnapPoint = snapResult.snapPoint; - deltaTime = snapResult.snappedTime - baseEdgeTime; - } - } + useEffect(() => { + if (!controller.isResizing) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isResizing, controller]); - onSnapPointChange?.(closestSnapPoint); - return { deltaTime }; -} + useEffect(() => () => controller.destroy(), [controller]); -function hasResizeChanges({ - members, - result, -}: { - members: GroupResizeMember[]; - result: GroupResizeResult; -}): boolean { - return result.updates.some((update) => { - const member = members.find( - (candidateMember) => candidateMember.elementId === update.elementId, - ); - return ( - member?.trimStart !== update.patch.trimStart || - member?.trimEnd !== update.patch.trimEnd || - member?.startTime !== update.patch.startTime || - member?.duration !== update.patch.duration - ); - }); + return { + isResizing: controller.isResizing, + handleResizeStart: controller.onResizeStart, + }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 889177c9..56f68ed6 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,9 @@ -import { useCallback, useRef } from "react"; -import type { MutableRefObject, RefObject } from "react"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { + SeekController, + type SeekConfig, +} from "@/timeline/controllers/seek-controller"; interface UseTimelineSeekProps { playheadRef: RefObject; @@ -17,44 +17,6 @@ interface UseTimelineSeekProps { seek: (time: number) => void; } -function resetMouseTracking({ - mouseTrackingRef, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; -}) { - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; -} - -function setMouseTracking({ - mouseTrackingRef, - event, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; - event: React.MouseEvent; -}) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: event.clientX, - downY: event.clientY, - downTime: event.timeStamp, - }; -} - export function useTimelineSeek({ playheadRef, trackLabelsRef, @@ -67,131 +29,36 @@ export function useTimelineSeek({ seek, }: UseTimelineSeekProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); - - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const shouldProcessTimelineClick = useCallback( - ({ event }: { event: React.MouseEvent }) => { - const target = event.target as HTMLElement; - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const deltaX = Math.abs(event.clientX - downX); - const deltaY = Math.abs(event.clientY - downY); - const deltaTime = event.timeStamp - downTime; - const isPlayhead = !!playheadRef.current?.contains(target); - const isTrackLabels = !!trackLabelsRef.current?.contains(target); - const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; - - if (!isMouseDown) return false; - if (shouldBlockForDrag) return false; - if (isSelecting) return false; - if (isPlayhead) return false; - if (isTrackLabels) { - clearSelectedElements(); - return false; - } - - return true; - }, - [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], - ); - - const handleTimelineSeek = useCallback( - ({ - event, - source, - }: { - event: React.MouseEvent; - source: "ruler" | "tracks"; - }) => { - const scrollContainer = - source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; - - if (!scrollContainer) return; - - const rect = scrollContainer.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const scrollLeft = scrollContainer.scrollLeft; - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - - const rate = activeProject?.settings.fps; - const time = rate - ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) - : rawTime; - seek(time); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, - }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); - - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); + const config: SeekConfig = { + zoomLevel, + duration, + isSelecting, + getPlayheadEl: () => playheadRef.current, + getTrackLabelsEl: () => trackLabelsRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + clearSelectedElements, + seek, + setTimelineViewState: (viewState) => + editor.project.setTimelineViewState({ viewState }), + }; - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + const configRef = useRef(config); + configRef.current = config; - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SeekController({ configRef }); + } + const controller = controllerRef.current; - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, + handleTracksMouseDown: controller.onTracksMouseDown, + handleTracksClick: controller.onTracksClick, + handleRulerMouseDown: controller.onRulerMouseDown, + handleRulerClick: controller.onRulerClick, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 7929d495..a8825a37 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -1,17 +1,17 @@ import { type WheelEvent as ReactWheelEvent, type RefObject, - useCallback, useEffect, useLayoutEffect, + useReducer, useRef, - useState, } from "react"; -import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; -import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale"; -import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { useEditor } from "@/editor/use-editor"; -import { zoomToSlider } from "@/timeline/zoom-utils"; +import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { + ZoomController, + type ZoomConfig, +} from "@/timeline/controllers/zoom-controller"; interface UseTimelineZoomProps { containerRef: RefObject; @@ -40,252 +40,53 @@ export function useTimelineZoom({ rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef | null>( - null, - ); - - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); - - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + const config: ZoomConfig = { + minZoom, + getContainerEl: () => containerRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + seek: (time) => editor.playback.seek({ time }), + setTimelineViewState: (viewState) => + editor.project.setTimelineViewState({ viewState }), + }; + const configRef = useRef(config); + configRef.current = config; - if (isHorizontalScrollGesture) { - return; - } + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ZoomController({ configRef, initialZoom }); + } + const controller = controllerRef.current; + const zoomLevel = controller.zoomLevel; - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); - return; - } - setZoomLevel((prev) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - 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_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); + controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ - time: playheadTime, - zoomLevel: previousZoom, - }); - const playheadPixelsAfter = timelineTimeToPixels({ - time: playheadTime, - zoomLevel, - }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // 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, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); + controller.applyZoomLayout(zoomLevel); + }, [controller, zoomLevel]); useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); + return controller.restoreInitialScrollIfNeeded(initialScrollLeft); + }, [controller, initialScrollLeft]); - // prevent browser zoom in the timeline useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; + controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime); + }, [controller, initialPlayheadTime]); - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); + useEffect(() => controller.bindPreventBrowserZoom(), [controller]); - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); + useEffect(() => () => controller.destroy(), [controller]); return { zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, + setZoomLevel: controller.setZoomLevel, + handleWheel: controller.handleWheel, + saveScrollPosition: controller.saveScrollPosition, }; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index cd543cdf..338da9d9 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -1,314 +1,317 @@ -import type { ElementAnimations } from "@/animation/types"; -import type { Effect } from "@/effects/types"; -import type { Mask } from "@/masks/types"; -import type { ParamValues } from "@/params"; -import type { BlendMode, Transform } from "@/rendering"; - -export type ElementRef = { - trackId: string; - elementId: string; -}; - -export interface Bookmark { - time: number; - note?: string; - color?: string; - duration?: number; -} - -export interface TScene { - id: string; - name: string; - isMain: boolean; - tracks: SceneTracks; - bookmarks: Bookmark[]; - createdAt: Date; - updatedAt: Date; -} - -export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; - -interface BaseTrack { - id: string; - name: string; -} - -export interface VideoTrack extends BaseTrack { - type: "video"; - elements: (VideoElement | ImageElement)[]; - muted: boolean; - hidden: boolean; -} - -export interface TextTrack extends BaseTrack { - type: "text"; - elements: TextElement[]; - hidden: boolean; -} - -export interface AudioTrack extends BaseTrack { - type: "audio"; - elements: AudioElement[]; - muted: boolean; -} - -export interface GraphicTrack extends BaseTrack { - type: "graphic"; - elements: (StickerElement | GraphicElement)[]; - hidden: boolean; -} - -export interface EffectTrack extends BaseTrack { - type: "effect"; - elements: EffectElement[]; - hidden: boolean; -} - -export type TimelineTrack = - | VideoTrack - | TextTrack - | AudioTrack - | GraphicTrack - | EffectTrack; - -export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; - -export interface SceneTracks { - overlay: OverlayTrack[]; - main: VideoTrack; - audio: AudioTrack[]; -} - -export interface RetimeConfig { - rate: number; - maintainPitch?: boolean; -} - -interface BaseAudioElement extends BaseTimelineElement { - type: "audio"; - volume: number; - muted?: boolean; - buffer?: AudioBuffer; - retime?: RetimeConfig; -} - -export interface UploadAudioElement extends BaseAudioElement { - sourceType: "upload"; - mediaId: string; -} - -export interface LibraryAudioElement extends BaseAudioElement { - sourceType: "library"; - sourceUrl: string; -} - -export type AudioElement = UploadAudioElement | LibraryAudioElement; - -interface BaseTimelineElement { - id: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; - animations?: ElementAnimations; -} - -export interface VideoElement extends BaseTimelineElement { - type: "video"; - mediaId: string; - volume?: number; - muted?: boolean; - isSourceAudioEnabled?: boolean; - hidden?: boolean; - retime?: RetimeConfig; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface ImageElement extends BaseTimelineElement { - type: "image"; - mediaId: string; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface TextBackground { - enabled: boolean; - color: string; - cornerRadius?: number; - paddingX?: number; - paddingY?: number; - offsetX?: number; - offsetY?: number; -} - -export interface TextElement extends BaseTimelineElement { - type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - background: TextBackground; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - letterSpacing?: number; - lineHeight?: number; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; -} - -export interface StickerElement extends BaseTimelineElement { - type: "sticker"; - stickerId: string; - /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ - intrinsicWidth?: number; - intrinsicHeight?: number; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; -} - -export interface GraphicElement extends BaseTimelineElement { - type: "graphic"; - definitionId: string; - params: ParamValues; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface EffectElement extends BaseTimelineElement { - type: "effect"; - effectType: string; - params: ParamValues; -} - -export type ElementUpdatePatch = - | { transform: Transform } - | { opacity: number } - | { volume: number }; - -export type TimelineElement = - | AudioElement - | VideoElement - | ImageElement - | TextElement - | StickerElement - | GraphicElement - | EffectElement; - -export type ElementType = TimelineElement["type"]; - -function elementTypes(...types: T): T { - return types; -} - -export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); - -export type MaskableElement = Extract< - TimelineElement, - { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } ->; - -export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); - -export type RetimableElement = Extract< - TimelineElement, - { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } ->; - -export const VISUAL_ELEMENT_TYPES = elementTypes( - "video", - "image", - "text", - "sticker", - "graphic", -); - -export type VisualElement = Extract< - TimelineElement, - { type: (typeof VISUAL_ELEMENT_TYPES)[number] } ->; - -export type CreateUploadAudioElement = Omit; -export type CreateLibraryAudioElement = Omit; -export type CreateAudioElement = - | CreateUploadAudioElement - | CreateLibraryAudioElement; -export type CreateVideoElement = Omit; -export type CreateImageElement = Omit; -export type CreateTextElement = Omit; -export type CreateStickerElement = Omit; -export type CreateGraphicElement = Omit; -export type CreateEffectElement = Omit; -export type CreateTimelineElement = - | CreateAudioElement - | CreateVideoElement - | CreateImageElement - | CreateTextElement - | CreateStickerElement - | CreateGraphicElement - | CreateEffectElement; - -export interface ElementDragState { - isDragging: boolean; - elementId: string | null; - dragElementIds: string[]; - dragTimeOffsets: Record; - trackId: string | null; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; - currentMouseY: number; -} - -export interface DropTarget { - trackIndex: number; - isNewTrack: boolean; - insertPosition: "above" | "below" | null; - xPosition: number; - targetElement: { elementId: string; trackId: string } | null; -} - -export interface ComputeDropTargetParams { - elementType: ElementType; - mouseX: number; - mouseY: number; - tracks: SceneTracks; - playheadTime: number; - isExternalDrop: boolean; - elementDuration: number; - pixelsPerSecond: number; - zoomLevel: number; - verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; - excludeElementId?: string; - targetElementTypes?: string[]; -} - -export interface ClipboardItem { - trackId: string; - trackType: TrackType; - element: CreateTimelineElement; -} +import type { ElementAnimations } from "@/animation/types"; +import type { Effect } from "@/effects/types"; +import type { Mask } from "@/masks/types"; +import type { ParamValues } from "@/params"; +import type { BlendMode, Transform } from "@/rendering"; + +export type ElementRef = { + trackId: string; + elementId: string; +}; + +export interface Bookmark { + time: number; + note?: string; + color?: string; + duration?: number; +} + +export interface TScene { + id: string; + name: string; + isMain: boolean; + tracks: SceneTracks; + bookmarks: Bookmark[]; + createdAt: Date; + updatedAt: Date; +} + +export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; + +interface BaseTrack { + id: string; + name: string; +} + +export interface VideoTrack extends BaseTrack { + type: "video"; + elements: (VideoElement | ImageElement)[]; + muted: boolean; + hidden: boolean; +} + +export interface TextTrack extends BaseTrack { + type: "text"; + elements: TextElement[]; + hidden: boolean; +} + +export interface AudioTrack extends BaseTrack { + type: "audio"; + elements: AudioElement[]; + muted: boolean; +} + +export interface GraphicTrack extends BaseTrack { + type: "graphic"; + elements: (StickerElement | GraphicElement)[]; + hidden: boolean; +} + +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | GraphicTrack + | EffectTrack; + +export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; + +export interface SceneTracks { + overlay: OverlayTrack[]; + main: VideoTrack; + audio: AudioTrack[]; +} + +export interface RetimeConfig { + rate: number; + maintainPitch?: boolean; +} + +interface BaseAudioElement extends BaseTimelineElement { + type: "audio"; + volume: number; + muted?: boolean; + buffer?: AudioBuffer; + retime?: RetimeConfig; +} + +export interface UploadAudioElement extends BaseAudioElement { + sourceType: "upload"; + mediaId: string; +} + +export interface LibraryAudioElement extends BaseAudioElement { + sourceType: "library"; + sourceUrl: string; +} + +export type AudioElement = UploadAudioElement | LibraryAudioElement; + +interface BaseTimelineElement { + id: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; + sourceDuration?: number; + animations?: ElementAnimations; +} + +export interface VideoElement extends BaseTimelineElement { + type: "video"; + mediaId: string; + volume?: number; + muted?: boolean; + isSourceAudioEnabled?: boolean; + hidden?: boolean; + retime?: RetimeConfig; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface ImageElement extends BaseTimelineElement { + type: "image"; + mediaId: string; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; +} + +export interface TextElement extends BaseTimelineElement { + type: "text"; + content: string; + fontSize: number; + fontFamily: string; + color: string; + background: TextBackground; + textAlign: "left" | "center" | "right"; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline" | "line-through"; + letterSpacing?: number; + lineHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface StickerElement extends BaseTimelineElement { + type: "sticker"; + stickerId: string; + /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ + intrinsicWidth?: number; + intrinsicHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface GraphicElement extends BaseTimelineElement { + type: "graphic"; + definitionId: string; + params: ParamValues; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: ParamValues; +} + +export type ElementUpdatePatch = + | { transform: Transform } + | { opacity: number } + | { volume: number }; + +export type TimelineElement = + | AudioElement + | VideoElement + | ImageElement + | TextElement + | StickerElement + | GraphicElement + | EffectElement; + +export type ElementType = TimelineElement["type"]; + +function elementTypes(...types: T): T { + return types; +} + +export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); + +export type MaskableElement = Extract< + TimelineElement, + { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } +>; + +export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); + +export type RetimableElement = Extract< + TimelineElement, + { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } +>; + +export const VISUAL_ELEMENT_TYPES = elementTypes( + "video", + "image", + "text", + "sticker", + "graphic", +); + +export type VisualElement = Extract< + TimelineElement, + { type: (typeof VISUAL_ELEMENT_TYPES)[number] } +>; + +export type CreateUploadAudioElement = Omit; +export type CreateLibraryAudioElement = Omit; +export type CreateAudioElement = + | CreateUploadAudioElement + | CreateLibraryAudioElement; +export type CreateVideoElement = Omit; +export type CreateImageElement = Omit; +export type CreateTextElement = Omit; +export type CreateStickerElement = Omit; +export type CreateGraphicElement = Omit; +export type CreateEffectElement = Omit; +export type CreateTimelineElement = + | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | CreateStickerElement + | CreateGraphicElement + | CreateEffectElement; + +export type ElementDragView = + | { readonly kind: "idle" } + | { + readonly kind: "dragging"; + readonly anchorElementId: string; + readonly trackId: string; + readonly memberTimeOffsets: ReadonlyMap; + readonly startMouseX: number; + readonly startMouseY: number; + readonly startElementTime: number; + readonly clickOffsetTime: number; + readonly currentTime: number; + readonly currentMouseX: number; + readonly currentMouseY: number; + readonly dropTarget: DropTarget | null; + }; + +export interface DropTarget { + trackIndex: number; + isNewTrack: boolean; + insertPosition: "above" | "below" | null; + xPosition: number; + targetElement: { elementId: string; trackId: string } | null; +} + +export interface ComputeDropTargetParams { + elementType: ElementType; + mouseX: number; + mouseY: number; + tracks: SceneTracks; + playheadTime: number; + isExternalDrop: boolean; + elementDuration: number; + pixelsPerSecond: number; + zoomLevel: number; + verticalDragDirection?: "up" | "down" | null; + startTimeOverride?: number; + excludeElementId?: string; + targetElementTypes?: string[]; +} + +export interface ClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} -- cgit v1.3.1 From 0b7597b31fa956c7f3d4838923b545ba5e341422 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 04:04:30 +0200 Subject: refactor: split animation helpers by domain --- .../animation/__tests__/animated-params.test.ts | 241 ++++++++ apps/web/src/animation/animated-params.ts | 83 +++ apps/web/src/animation/effect-param-channel.ts | 16 +- apps/web/src/animation/graphic-param-channel.ts | 32 +- apps/web/src/animation/index.ts | 30 +- apps/web/src/animation/interpolation.ts | 2 +- apps/web/src/animation/keyframe-query.ts | 2 +- apps/web/src/animation/keyframes.ts | 48 +- apps/web/src/animation/path.ts | 22 + apps/web/src/animation/property-registry.ts | 390 ------------- apps/web/src/animation/resolve.ts | 105 ---- apps/web/src/animation/target-resolver.ts | 285 ---------- apps/web/src/animation/timeline-snap-points.ts | 35 -- apps/web/src/animation/types.ts | 10 +- apps/web/src/animation/values.ts | 61 ++ .../commands/timeline/clipboard/paste-keyframes.ts | 2 +- .../timeline/element/keyframes/remove-keyframe.ts | 2 +- .../timeline/element/keyframes/retime-keyframe.ts | 3 +- .../keyframes/update-scalar-keyframe-curve.ts | 2 +- .../keyframes/upsert-effect-param-keyframe.ts | 2 +- .../timeline/element/keyframes/upsert-keyframe.ts | 3 +- .../hooks/use-keyframed-color-property.ts | 190 +++---- .../hooks/use-keyframed-number-property.ts | 348 ++++++------ .../hooks/use-keyframed-param-property.ts | 14 +- apps/web/src/core/managers/timeline-manager.ts | 2 +- apps/web/src/graphics/components/graphic-tab.tsx | 479 ++++++++-------- apps/web/src/graphics/index.ts | 24 + .../src/preview/components/text-edit-overlay.tsx | 2 +- .../controllers/transform-handle-controller.ts | 2 +- apps/web/src/preview/element-bounds.ts | 616 ++++++++++----------- apps/web/src/rendering/NOTES.md | 27 + apps/web/src/rendering/animation-values.ts | 49 ++ apps/web/src/rendering/components/blending-tab.tsx | 2 +- .../web/src/rendering/components/transform-tab.tsx | 2 +- apps/web/src/services/renderer/resolve.ts | 20 +- apps/web/src/text/components/text-tab.tsx | 5 +- apps/web/src/text/measure-element.ts | 2 +- apps/web/src/timeline/animation-properties.ts | 425 ++++++++++++++ apps/web/src/timeline/animation-snap-points.ts | 35 ++ apps/web/src/timeline/animation-targets.ts | 214 +++++++ apps/web/src/timeline/audio-state.ts | 2 +- .../timeline/bookmarks/hooks/use-bookmark-drag.ts | 2 +- apps/web/src/timeline/components/audio-tab.tsx | 302 +++++----- .../timeline/controllers/playhead-controller.ts | 2 +- .../src/timeline/controllers/resize-controller.ts | 2 +- apps/web/src/timeline/group-move/snap.ts | 2 +- 46 files changed, 2226 insertions(+), 1920 deletions(-) create mode 100644 apps/web/src/animation/__tests__/animated-params.test.ts create mode 100644 apps/web/src/animation/animated-params.ts create mode 100644 apps/web/src/animation/path.ts delete mode 100644 apps/web/src/animation/property-registry.ts delete mode 100644 apps/web/src/animation/target-resolver.ts delete mode 100644 apps/web/src/animation/timeline-snap-points.ts create mode 100644 apps/web/src/animation/values.ts create mode 100644 apps/web/src/rendering/NOTES.md create mode 100644 apps/web/src/rendering/animation-values.ts create mode 100644 apps/web/src/timeline/animation-properties.ts create mode 100644 apps/web/src/timeline/animation-snap-points.ts create mode 100644 apps/web/src/timeline/animation-targets.ts (limited to 'apps/web/src/timeline') diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts new file mode 100644 index 00000000..7b9d312d --- /dev/null +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; + +describe("animated params", () => { + test("snaps and clamps number params", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 0.62, + }), + ).toBe(0.5); + + expect( + coerceAnimationParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 1.2, + }), + ).toBe(1); + }); + + test("rejects NaN and non-number values for number params", () => { + const param = { + key: "intensity", + label: "Intensity", + type: "number" as const, + default: 0, + min: 0, + max: 1, + step: 0.25, + }; + expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: true })).toBeNull(); + }); + + test("passthrough with step <= 0 guard", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "x", + label: "X", + type: "number", + default: 0, + min: 0, + step: 0, + }, + value: 0.123, + }), + ).toBe(0.123); + }); + + test("accepts valid select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }; + expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); + }); + + test("rejects select values outside the allowed options", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "blend", + label: "Blend", + type: "select", + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }, + value: "screen", + }), + ).toBeNull(); + }); + + test("rejects non-string select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [{ value: "normal", label: "Normal" }], + }; + expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull(); + }); + + test("boolean params accept booleans and reject other types", () => { + const param = { + key: "visible", + label: "Visible", + type: "boolean" as const, + default: true, + }; + expect(coerceAnimationParamValue({ param, value: true })).toBe(true); + expect(coerceAnimationParamValue({ param, value: false })).toBe(false); + expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull(); + }); + + test("color params accept strings and reject other types", () => { + const param = { + key: "fill", + label: "Fill", + type: "color" as const, + default: "#ffffff", + }; + expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + }); + + test("getAnimationParamValueKind maps param type to binding kind", () => { + expect( + getAnimationParamValueKind({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("number"); + expect( + getAnimationParamValueKind({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("color"); + expect( + getAnimationParamValueKind({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("discrete"); + expect( + getAnimationParamValueKind({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("discrete"); + }); + + test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { + expect( + getAnimationParamDefaultInterpolation({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("linear"); + expect( + getAnimationParamDefaultInterpolation({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("linear"); + expect( + getAnimationParamDefaultInterpolation({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("hold"); + expect( + getAnimationParamDefaultInterpolation({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("hold"); + }); + + test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { + expect( + getAnimationParamNumericRange({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0.5, + min: 0, + max: 1, + step: 0.1, + }, + }), + ).toEqual({ min: 0, max: 1, step: 0.1 }); + expect( + getAnimationParamNumericRange({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBeUndefined(); + expect( + getAnimationParamNumericRange({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts new file mode 100644 index 00000000..bc27b15d --- /dev/null +++ b/apps/web/src/animation/animated-params.ts @@ -0,0 +1,83 @@ +import { snapToStep } from "@/utils/math"; +import type { ParamDefinition } from "@/params"; +import type { DynamicAnimationPathValue, NumericSpec } from "./types"; + +export function getAnimationParamValueKind({ + param, +}: { + param: ParamDefinition; +}): "number" | "color" | "discrete" { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getAnimationParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): "linear" | "hold" { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +export function getAnimationParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): NumericSpec | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +/** + * `coerceAnimationParamValue` accepts `unknown` rather than a narrow + * `DynamicAnimationPathValue` because it doubles as a runtime gate for + * untrusted inputs (persisted state, paste payloads, programmatic updates). + * The caller does not need to pre-validate; null means "this value cannot live + * on this param". + */ +export function coerceAnimationParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: unknown; +}): DynamicAnimationPathValue | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts index c635bb82..e1c442f7 100644 --- a/apps/web/src/animation/effect-param-channel.ts +++ b/apps/web/src/animation/effect-param-channel.ts @@ -1,9 +1,8 @@ -import type { ParamValues } from "@/params"; -import type { Effect } from "@/effects/types"; import type { ElementAnimations, EffectParamPath, } from "@/animation/types"; +import type { ParamValues } from "@/params"; import { removeElementKeyframe } from "./keyframes"; import { resolveAnimationPathValueAtTime } from "./resolve"; @@ -56,23 +55,26 @@ export function parseEffectParamPath({ } export function resolveEffectParamsAtTime({ - effect, + effectId, + params, animations, localTime, }: { - effect: Effect; + effectId: string; + params: ParamValues; animations: ElementAnimations | undefined; localTime: number; }): ParamValues { + const safeLocalTime = Math.max(0, localTime); const resolved: ParamValues = {}; - for (const [paramKey, staticValue] of Object.entries(effect.params)) { - const path = buildEffectParamPath({ effectId: effect.id, paramKey }); + for (const [paramKey, staticValue] of Object.entries(params)) { + const path = buildEffectParamPath({ effectId, paramKey }); resolved[paramKey] = animations?.bindings[path] ? resolveAnimationPathValueAtTime({ animations, propertyPath: path, - localTime, + localTime: safeLocalTime, fallbackValue: staticValue, }) : staticValue; diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts index ebf0f940..d0bfdded 100644 --- a/apps/web/src/animation/graphic-param-channel.ts +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -2,11 +2,7 @@ import type { ElementAnimations, GraphicParamPath, } from "@/animation/types"; -import type { ParamValues } from "@/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/graphics"; +import type { ParamDefinition, ParamValues } from "@/params"; import { resolveAnimationPathValueAtTime } from "./resolve"; export const GRAPHIC_PARAM_PATH_PREFIX = "params."; @@ -39,33 +35,29 @@ export function parseGraphicParamPath({ } export function resolveGraphicParamsAtTime({ - element, + params, + definitions, + animations, localTime, }: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; + params: ParamValues; + definitions: ParamDefinition[]; + animations?: ElementAnimations; localTime: number; }): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; + const resolved: ParamValues = { ...params }; - for (const param of definition.params) { + for (const param of definitions) { const path = buildGraphicParamPath({ paramKey: param.key }); - if (!element.animations?.bindings[path]) { + if (!animations?.bindings[path]) { continue; } resolved[param.key] = resolveAnimationPathValueAtTime({ - animations: element.animations, + animations, propertyPath: path, localTime: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, + fallbackValue: params[param.key] ?? param.default, }); } diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts index 384f3d17..af3b910d 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -16,31 +16,14 @@ export { setChannel, splitAnimationsAtTime, updateScalarKeyframeCurve, - upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; export { getElementLocalTime, resolveAnimationPathValueAtTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, } from "./resolve"; -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - export { getElementKeyframes, getKeyframeById, @@ -75,15 +58,6 @@ export { resolveEffectParamsAtTime, } from "./effect-param-channel"; -export { - isAnimationPath, - coerceAnimationValueForParam, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - export { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, @@ -94,3 +68,7 @@ export { type EasingMode, getEasingModeForKind, } from "./binding-values"; + +export { + isAnimationPath, +} from "./path"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 277bfbb7..6bab66c4 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -8,13 +8,13 @@ import type { ScalarAnimationKey, ScalarSegmentType, } from "@/animation/types"; -import { clamp } from "@/utils/math"; import { getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, } from "./bezier"; +import { clamp } from "@/utils/math"; function byTimeAscending({ leftTime, diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts index 231aa5f2..e65db881 100644 --- a/apps/web/src/animation/keyframe-query.ts +++ b/apps/web/src/animation/keyframe-query.ts @@ -13,7 +13,7 @@ import { getChannelValueAtTime, getScalarSegmentInterpolation, } from "./interpolation"; -import { isAnimationPath } from "./target-resolver"; +import { isAnimationPath } from "./path"; function getBindingFallbackValue({ channel, diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 12dc777d..ff0cef8f 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -4,7 +4,6 @@ import type { AnimationChannel, AnimationInterpolation, AnimationPath, - AnimationPropertyPath, AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, @@ -14,7 +13,6 @@ import type { ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/animation/types"; -import { generateUUID } from "@/utils/id"; import { cloneAnimationBinding, createAnimationBinding, @@ -31,10 +29,7 @@ import { getScalarSegmentInterpolation, normalizeChannel, } from "./interpolation"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; +import { generateUUID } from "@/utils/id"; function isNearlySameTime({ leftTime, @@ -527,47 +522,6 @@ export function upsertPathKeyframe({ }); } -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} - export function upsertKeyframe({ channel, time, diff --git a/apps/web/src/animation/path.ts b/apps/web/src/animation/path.ts new file mode 100644 index 00000000..69e5e228 --- /dev/null +++ b/apps/web/src/animation/path.ts @@ -0,0 +1,22 @@ +import type { AnimationPath, AnimationPropertyPath } from "@/animation/types"; +import { ANIMATION_PROPERTY_PATHS } from "./types"; +import { isEffectParamPath } from "./effect-param-channel"; +import { isGraphicParamPath } from "./graphic-param-channel"; + +const ANIMATION_PROPERTY_PATH_SET = new Set(ANIMATION_PROPERTY_PATHS); + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return ANIMATION_PROPERTY_PATH_SET.has(propertyPath); +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts deleted file mode 100644 index aacb9972..00000000 --- a/apps/web/src/animation/property-registry.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, -} from "@/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { DEFAULTS } from "@/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts index 1b51c126..19685cea 100644 --- a/apps/web/src/animation/resolve.ts +++ b/apps/web/src/animation/resolve.ts @@ -1,12 +1,8 @@ import type { - AnimationColorPropertyPath, - AnimationNumericPropertyPath, AnimationPath, - AnimationPropertyPath, AnimationValueForPath, ElementAnimations, } from "@/animation/types"; -import type { Transform } from "@/rendering"; import { type AnimationComponentValue, composeAnimationValue, @@ -37,107 +33,6 @@ export function getElementLocalTime({ return localTime; } -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: { - x: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionX", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.x, - }), - y: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionY", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.y, - }), - }, - scaleX: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleX", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleY", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.rotate", - localTime: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath: "opacity", - localTime: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationNumericPropertyPath; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationColorPropertyPath; - localTime: number; -}): string { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} - export function resolveAnimationPathValueAtTime({ animations, propertyPath, diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/animation/target-resolver.ts deleted file mode 100644 index 32e7917c..00000000 --- a/apps/web/src/animation/target-resolver.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPath, - AnimationValue, -} from "@/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/params"; -import { effectsRegistry, registerDefaultEffects } from "@/effects"; -import { getGraphicDefinition } from "@/graphics"; -import type { TimelineElement } from "@/timeline"; -import { isVisualElement } from "@/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; -import { parseColorToLinearRgba } from "./binding-values"; - -export interface AnimationPathDescriptor { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - getBaseValue: () => AnimationValue | null; - setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; -} - -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationBindingKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): Partial> | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - value: { - min: param.min, - max: param.max, - step: param.step, - }, - }; -} - -export function coerceAnimationValueForParam({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts deleted file mode 100644 index f7bf549a..00000000 --- a/apps/web/src/animation/timeline-snap-points.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getElementKeyframes } from "@/animation"; -import type { SceneTracks } from "@/timeline"; -import type { SnapPoint } from "@/timeline/snapping"; - -export function getAnimationKeyframeSnapPointsForTimeline({ - tracks, - excludeElementIds, -}: { - tracks: SceneTracks; - excludeElementIds?: Set; -}): SnapPoint[] { - const snapPoints: SnapPoint[] = []; - const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - - for (const track of orderedTracks) { - for (const element of track.elements) { - if (excludeElementIds?.has(element.id)) { - continue; - } - - for (const keyframe of getElementKeyframes({ - animations: element.animations, - })) { - snapPoints.push({ - time: element.startTime + keyframe.time, - type: "keyframe", - elementId: element.id, - trackId: track.id, - }); - } - } - } - - return snapPoints; -} diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 3afddfa1..7e241994 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,5 +1,3 @@ -import type { ParamValues } from "@/params"; - export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", "transform.positionY", @@ -50,7 +48,13 @@ export interface AnimationPropertyValueMap { "background.offsetY": number; "background.cornerRadius": number; } -export type DynamicAnimationPathValue = ParamValues[string]; +export type DynamicAnimationPathValue = number | string | boolean; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} export type AnimationValueForPath = TPath extends AnimationPropertyPath ? AnimationPropertyValueMap[TPath] diff --git a/apps/web/src/animation/values.ts b/apps/web/src/animation/values.ts new file mode 100644 index 00000000..7733ec4c --- /dev/null +++ b/apps/web/src/animation/values.ts @@ -0,0 +1,61 @@ +import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + ElementAnimations, +} from "./types"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationNumericPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationColorPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index e05b49fa..c19bd526 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -1,7 +1,6 @@ import { EditorCore } from "@/core"; import { getKeyframeAtTime, - resolveAnimationTarget, updateScalarKeyframeCurve, upsertPathKeyframe, } from "@/animation"; @@ -9,6 +8,7 @@ import { Command, type CommandResult } from "@/commands/base-command"; import type { KeyframeClipboardItem } from "@/clipboard"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { generateUUID } from "@/utils/id"; function pasteKeyframesIntoElement({ diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts index ed917406..07abe2be 100644 --- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -2,12 +2,12 @@ import { EditorCore } from "@/core"; import { hasKeyframesForPath, removeElementKeyframe, - resolveAnimationTarget, } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath, AnimationValue } from "@/animation/types"; import type { SceneTracks, TimelineElement } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; function removeKeyframeAndPersist({ element, diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index ccbdbc5d..aa04e2bc 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,9 +1,10 @@ import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation"; +import { retimeElementKeyframe } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; export class RetimeKeyframeCommand extends Command { private savedState: SceneTracks | null = null; diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts index 4a3db39d..51f10ee9 100644 --- a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts +++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -1,10 +1,10 @@ import { EditorCore } from "@/core"; import { - resolveAnimationTarget, updateScalarKeyframeCurve, } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, ScalarCurveKeyframePatch, diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index eae8f039..7bd1b2e5 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -2,11 +2,11 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { buildEffectParamPath, - resolveAnimationTarget, upsertPathKeyframe, } from "@/animation"; import { updateElementInSceneTracks } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 30514146..15c18bfc 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,8 +1,9 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation"; +import { upsertPathKeyframe } from "@/animation"; import { updateElementInSceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, AnimationInterpolation, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 734b514f..80fc171b 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,95 +1,95 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} +import { useEditor } from "@/editor/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { upsertElementKeyframe } from "@/timeline/animation-properties"; + +export function useKeyframedColorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedColor, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedColor: string; + buildBaseUpdates: ({ value }: { value: string }) => Partial; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const onChange = ({ color }: { color: string }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: color, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], + }); + }; + + const onChangeEnd = () => editor.timeline.commitPreview(); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, + ], + }); + }; + + return { + isKeyframedAtTime, + hasAnimatedKeyframes, + keyframeIdAtTime, + onChange, + onChangeEnd, + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 9dd39768..32222b7a 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,174 +1,174 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, - buildAdditionalKeyframes, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial; - buildAdditionalKeyframes?: ({ - value, - }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - const updatedAnimations = [ - { propertyPath, value: nextValue }, - ...additionalKeyframes, - ].reduce( - (currentAnimations, keyframe) => - upsertElementKeyframe({ - animations: currentAnimations, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - }), - animations, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { animations: updatedAnimations }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: nextValue }, - ...additionalKeyframes.map((keyframe) => ({ - trackId, - elementId, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - })), - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - patch: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} +import { useEditor } from "@/editor/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { upsertElementKeyframe } from "@/timeline/animation-properties"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + step, + buildBaseUpdates, + buildAdditionalKeyframes, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + step?: number; + buildBaseUpdates: ({ value }: { value: number }) => Partial; + buildAdditionalKeyframes?: ({ + value, + }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; +}) { + const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + const updatedAnimations = [ + { propertyPath, value: nextValue }, + ...additionalKeyframes, + ].reduce( + (currentAnimations, keyframe) => + upsertElementKeyframe({ + animations: currentAnimations, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + }), + animations, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { animations: updatedAnimations }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: snapValue(valueAtPlayhead), + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: nextValue }, + ...additionalKeyframes.map((keyframe) => ({ + trackId, + elementId, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + })), + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + patch: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index b0b6f01a..18e027c7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -3,16 +3,18 @@ import { useEditor } from "@/editor/use-editor"; import { buildGraphicParamPath, - coerceAnimationValueForParam, getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, hasKeyframesForPath, upsertPathKeyframe, } from "@/animation"; import type { ElementAnimations, } from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamValueKind, +} from "@/animation/animated-params"; import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; @@ -79,12 +81,12 @@ export function useKeyframedParamProperty({ propertyPath, time: localTime, value, - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param, }), coerceValue: ({ value: nextValue }) => - coerceAnimationValueForParam({ + coerceAnimationParamValue({ param, value: nextValue, }), diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 6f54c5aa..a2bf7451 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -24,9 +24,9 @@ import type { } from "@/animation/types"; import { getElementLocalTime, - resolveAnimationTarget, resolveAnimationPathValueAtTime, } from "@/animation"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/commands"; import { diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index 60599772..e0ae0236 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -1,238 +1,241 @@ -"use client"; - -import { useRef } from "react"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { - useKeyframedParamProperty, - type KeyframedParamPropertyResult, -} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; -import { resolveGraphicParamsAtTime } from "@/animation"; -import type { ParamDefinition, ParamValues } from "@/params"; -import type { GraphicElement } from "@/timeline"; -import { graphicsRegistry, registerDefaultGraphics } from "@/graphics"; -import { useElementPreview } from "@/timeline/hooks/use-element-preview"; -import { useEditor } from "@/editor/use-editor"; -import { - Section, - SectionContent, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -registerDefaultGraphics(); - -const DEFAULT_STROKE_WIDTH = 2; - -export function GraphicTab({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - - const shapeParams = definition.params.filter((p) => p.group !== "stroke"); - const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); - - return ( -
-
- - {definition.name} - - - - {shapeParams.map((param) => ( - - ))} - - -
- {hasStrokeParams && } -
- ); -} - -function StrokeSection({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - const strokeParams = definition.params.filter((p) => p.group === "stroke"); - const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); - const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; - - const toggleStroke = () => { - if (isStrokeEnabled) { - lastStrokeWidth.current = Number( - element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, - ); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( -
- { - event.stopPropagation(); - toggleStroke(); - }} - > - - - } - > - Stroke - - - - {strokeParams.map((param) => ( - - ))} - - -
- ); -} - -function AnimatedGraphicParamField({ - param, - trackId, - element, - localTime, - isPlayheadWithinElementRange, - resolvedParams, -}: { - param: ParamDefinition; - trackId: string; - element: GraphicElement; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedParams: ParamValues; -}) { - const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty( - { - param, - trackId, - elementId: element.id, - animations: element.animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedParams[param.key] ?? param.default, - buildBaseUpdates: ({ value }) => ({ - params: { - ...element.params, - [param.key]: value, - }, - }), - }, - ); - - return ( - - ); -} +"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import type { ParamDefinition, ParamValues } from "@/params"; +import type { GraphicElement } from "@/timeline"; +import { + graphicsRegistry, + registerDefaultGraphics, + resolveGraphicElementParamsAtTime, +} from "@/graphics"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { useEditor } from "@/editor/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( +
+
+ + {definition.name} + + + + {shapeParams.map((param) => ( + + ))} + + +
+ {hasStrokeParams && } +
+ ); +} + +function StrokeSection({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + const strokeParams = definition.params.filter((p) => p.group === "stroke"); + const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); + const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; + + const toggleStroke = () => { + if (isStrokeEnabled) { + lastStrokeWidth.current = Number( + element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { params: { ...element.params, strokeWidth: 0 } }, + }, + ], + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { + params: { + ...element.params, + strokeWidth: lastStrokeWidth.current, + }, + }, + }, + ], + }); + } + }; + + return ( +
+ { + event.stopPropagation(); + toggleStroke(); + }} + > + + + } + > + Stroke + + + + {strokeParams.map((param) => ( + + ))} + + +
+ ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedParams: ParamValues; +}) { + const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty( + { + param, + trackId, + elementId: element.id, + animations: element.animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedParams[param.key] ?? param.default, + buildBaseUpdates: ({ value }) => ({ + params: { + ...element.params, + [param.key]: value, + }, + }), + }, + ); + + return ( + + ); +} diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index c69a4ab0..3f277d40 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -1,3 +1,5 @@ +import { resolveGraphicParamsAtTime } from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; import { graphicsRegistry } from "./registry"; @@ -68,6 +70,28 @@ export function resolveGraphicParams( }; } +export function resolveGraphicElementParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + return resolveGraphicParamsAtTime({ + params: resolveGraphicParams(definition, element.params), + definitions: definition.params, + animations: element.animations, + localTime, + }); +} + export function buildGraphicPreviewUrl({ definitionId, params, diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index bc27c724..1d599b3b 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline"; import { DEFAULTS } from "@/timeline/defaults"; import { getElementLocalTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { resolveTextLayout } from "@/text/primitives"; export function TextEditOverlay({ diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index e9d251e3..67f677f6 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils"; import { getElementLocalTime, hasKeyframesForPath, - resolveTransformAtTime, setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; import type { Transform } from "@/rendering"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, SceneTracks, diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index ac556181..0afc6e4e 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -1,308 +1,308 @@ -import type { SceneTracks, TimelineElement } from "@/timeline"; -import type { MediaAsset } from "@/media/types"; -import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; -import { measureTextElement } from "@/text/measure-element"; -import { - getElementLocalTime, - resolveTransformAtTime, -} from "@/animation"; - -export interface ElementBounds { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; -} - -export interface ElementWithBounds { - trackId: string; - elementId: string; - element: TimelineElement; - bounds: ElementBounds; -} - -function getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - sourceWidth: number; - sourceHeight: number; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const containScale = Math.min( - canvasWidth / sourceWidth, - canvasHeight / sourceHeight, - ); - const scaledWidth = sourceWidth * containScale * transform.scaleX; - const scaledHeight = sourceHeight * containScale * transform.scaleY; - const cx = canvasWidth / 2 + transform.position.x; - const cy = canvasHeight / 2 + transform.position.y; - - return { - cx, - cy, - width: scaledWidth, - height: scaledHeight, - rotation: transform.rotate, - }; -} - -function getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - rect: { left: number; top: number; width: number; height: number }; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const localCenterX = rect.left + rect.width / 2; - const localCenterY = rect.top + rect.height / 2; - const scaledCenterX = localCenterX * transform.scaleX; - const scaledCenterY = localCenterY * transform.scaleY; - const rotationRad = (transform.rotate * Math.PI) / 180; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - return { - cx: - canvasWidth / 2 + - transform.position.x + - scaledCenterX * cos - - scaledCenterY * sin, - cy: - canvasHeight / 2 + - transform.position.y + - scaledCenterX * sin + - scaledCenterY * cos, - width: rect.width * transform.scaleX, - height: rect.height * transform.scaleY, - rotation: transform.rotate, - }; -} - -/** - * Bounds policy: bounds reflect base content geometry (text glyphs + background, - * sticker/image/video content area) and base transform. Post-effect spill (blur, - * glow) and mask-clipped regions are intentionally excluded — handles manipulate - * the canonical element geometry, not visual effect output. - */ -function getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, -}: { - element: TimelineElement; - canvasSize: { width: number; height: number }; - mediaAsset?: MediaAsset | null; - localTime: number; -}): ElementBounds | null { - if (element.type === "audio" || element.type === "effect") return null; - if ("hidden" in element && element.hidden) return null; - - const { width: canvasWidth, height: canvasHeight } = canvasSize; - - if (element.type === "video" || element.type === "image") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - const sourceWidth = mediaAsset?.width ?? canvasWidth; - const sourceHeight = mediaAsset?.height ?? canvasHeight; - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, - }); - } - - if (element.type === "sticker") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, - sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, - transform, - }); - } - - if (element.type === "graphic") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, - sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, - transform, - }); - } - - if (element.type === "text") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - const measured = measureTextElement({ - element, - canvasHeight, - localTime, - ctx, - }); - - return getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect: measured.visualRect, - transform, - }); - } - - return null; -} - -export const ROTATION_HANDLE_OFFSET = 24; - -export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -export type Edge = "right" | "left" | "bottom"; - -export function getCornerPosition({ - bounds, - corner, -}: { - bounds: ElementBounds; - corner: Corner; -}): { x: number; y: number } { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; - const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getEdgeHandlePosition({ - bounds, - edge, -}: { - bounds: ElementBounds; - edge: Edge; -}): { x: number; y: number } { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; - const localY = edge === "bottom" ? halfHeight : 0; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, -}: { - tracks: SceneTracks; - currentTime: number; - canvasSize: { width: number; height: number }; - mediaAssets: MediaAsset[]; -}): ElementWithBounds[] { - const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const orderedTracks = [ - ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), - ...(!tracks.main.hidden ? [tracks.main] : []), - ].reverse(); - - const result: ElementWithBounds[] = []; - - for (const track of orderedTracks) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .filter( - (element) => - currentTime >= element.startTime && - currentTime < element.startTime + element.duration, - ) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - const localTime = getElementLocalTime({ - timelineTime: currentTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const mediaAsset = - element.type === "video" || element.type === "image" - ? mediaMap.get(element.mediaId) - : undefined; - const bounds = getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, - }); - if (bounds) { - result.push({ - trackId: track.id, - elementId: element.id, - element, - bounds, - }); - } - } - } - - return result; -} +import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; +import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { measureTextElement } from "@/text/measure-element"; +import { + getElementLocalTime, +} from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; + +export interface ElementBounds { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; +} + +export interface ElementWithBounds { + trackId: string; + elementId: string; + element: TimelineElement; + bounds: ElementBounds; +} + +function getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + sourceWidth: number; + sourceHeight: number; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const containScale = Math.min( + canvasWidth / sourceWidth, + canvasHeight / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scaleX; + const scaledHeight = sourceHeight * containScale * transform.scaleY; + const cx = canvasWidth / 2 + transform.position.x; + const cy = canvasHeight / 2 + transform.position.y; + + return { + cx, + cy, + width: scaledWidth, + height: scaledHeight, + rotation: transform.rotate, + }; +} + +function getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + rect: { left: number; top: number; width: number; height: number }; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const localCenterX = rect.left + rect.width / 2; + const localCenterY = rect.top + rect.height / 2; + const scaledCenterX = localCenterX * transform.scaleX; + const scaledCenterY = localCenterY * transform.scaleY; + const rotationRad = (transform.rotate * Math.PI) / 180; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + return { + cx: + canvasWidth / 2 + + transform.position.x + + scaledCenterX * cos - + scaledCenterY * sin, + cy: + canvasHeight / 2 + + transform.position.y + + scaledCenterX * sin + + scaledCenterY * cos, + width: rect.width * transform.scaleX, + height: rect.height * transform.scaleY, + rotation: transform.rotate, + }; +} + +/** + * Bounds policy: bounds reflect base content geometry (text glyphs + background, + * sticker/image/video content area) and base transform. Post-effect spill (blur, + * glow) and mask-clipped regions are intentionally excluded — handles manipulate + * the canonical element geometry, not visual effect output. + */ +function getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, +}: { + element: TimelineElement; + canvasSize: { width: number; height: number }; + mediaAsset?: MediaAsset | null; + localTime: number; +}): ElementBounds | null { + if (element.type === "audio" || element.type === "effect") return null; + if ("hidden" in element && element.hidden) return null; + + const { width: canvasWidth, height: canvasHeight } = canvasSize; + + if (element.type === "video" || element.type === "image") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const sourceWidth = mediaAsset?.width ?? canvasWidth; + const sourceHeight = mediaAsset?.height ?? canvasHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, + }); + } + + if (element.type === "sticker") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, + sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, + transform, + }); + } + + if (element.type === "graphic") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + transform, + }); + } + + if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const measured = measureTextElement({ + element, + canvasHeight, + localTime, + ctx, + }); + + return getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect: measured.visualRect, + transform, + }); + } + + return null; +} + +export const ROTATION_HANDLE_OFFSET = 24; + +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type Edge = "right" | "left" | "bottom"; + +export function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getEdgeHandlePosition({ + bounds, + edge, +}: { + bounds: ElementBounds; + edge: Edge; +}): { x: number; y: number } { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; + const localY = edge === "bottom" ? halfHeight : 0; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, +}: { + tracks: SceneTracks; + currentTime: number; + canvasSize: { width: number; height: number }; + mediaAssets: MediaAsset[]; +}): ElementWithBounds[] { + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + const orderedTracks = [ + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), + ].reverse(); + + const result: ElementWithBounds[] = []; + + for (const track of orderedTracks) { + const elements = track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .filter( + (element) => + currentTime >= element.startTime && + currentTime < element.startTime + element.duration, + ) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); + + for (const element of elements) { + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const mediaAsset = + element.type === "video" || element.type === "image" + ? mediaMap.get(element.mediaId) + : undefined; + const bounds = getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, + }); + if (bounds) { + result.push({ + trackId: track.id, + elementId: element.id, + element, + bounds, + }); + } + } + } + + return result; +} diff --git a/apps/web/src/rendering/NOTES.md b/apps/web/src/rendering/NOTES.md new file mode 100644 index 00000000..38055c14 --- /dev/null +++ b/apps/web/src/rendering/NOTES.md @@ -0,0 +1,27 @@ +# Notes + +## `Transform` is misplaced + +`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's +exported from the rendering domain because rendering happens to be one of its +consumers — but every other domain that touches scene geometry consumes it too +(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not +"of" rendering any more than `Vector2` would be "of" math. + +`Transform` is closer to a primitive than a domain concept. It's infrastructure: +a small value type with no behavior, no dependencies, no domain-specific +invariants. Anything that wants to position something on a 2D canvas needs it. + +The codebase should be refactored so it's defined lower-level, rather than +sitting in a domain. Candidate homes: + +- `apps/web/src/primitives/transform.ts` (new dir for these value types) +- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there +- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering` +becomes a primitives folder rather than a domain (it's borderline today — +it also exports `BlendMode`, which has the same problem). + +Side effect of fixing this: `@/rendering/animation-values.ts` (which exists +only because `Transform` lives next to it) can move into `@/animation/values.ts` +alongside the other resolve-at-time helpers, since `Transform` would no longer +pull in a domain dependency. \ No newline at end of file diff --git a/apps/web/src/rendering/animation-values.ts b/apps/web/src/rendering/animation-values.ts new file mode 100644 index 00000000..8e8b4528 --- /dev/null +++ b/apps/web/src/rendering/animation-values.ts @@ -0,0 +1,49 @@ +import type { ElementAnimations } from "@/animation/types"; +import { resolveAnimationPathValueAtTime } from "@/animation"; +import type { Transform } from "./index"; + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: { + x: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionX", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionY", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx index 3e0db46c..48b1d0e5 100644 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -27,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/animation"; +import { resolveOpacityAtTime } from "@/animation/values"; import { DEFAULTS } from "@/timeline/defaults"; import { isPropertyAtDefault } from "./transform-tab"; diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx index 60d4c7df..f4d5a183 100644 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ b/apps/web/src/rendering/components/transform-tab.tsx @@ -20,8 +20,8 @@ import { import { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { DEFAULTS } from "@/timeline/defaults"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 5495ba3b..809f7103 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -1,11 +1,5 @@ import { mediaTimeToSeconds } from "opencut-wasm"; -import { - getElementLocalTime, - resolveColorAtTime, - resolveGraphicParamsAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "@/animation"; +import { getElementLocalTime } from "@/animation"; import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel"; import { buildGaussianBlurPasses, @@ -14,11 +8,16 @@ import { import { effectsRegistry, resolveEffectPasses } from "@/effects"; import type { Effect, EffectPass } from "@/effects/types"; import { getSourceTimeAtClipTime } from "@/retime"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + resolveGraphicElementParamsAtTime, +} from "@/graphics"; import { getTextMeasurementContext, measureTextElement, } from "@/text/measure-element"; +import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { videoCache } from "@/services/video-cache/service"; import type { CanvasRenderer } from "./canvas-renderer"; import type { AnyBaseNode } from "./nodes/base-node"; @@ -113,7 +112,8 @@ function resolveEffectPassGroups({ .filter((effect) => effect.enabled) .map((effect) => { const resolvedParams = resolveEffectParamsAtTime({ - effect, + effectId: effect.id, + params: effect.params, animations, localTime, }); @@ -304,7 +304,7 @@ function resolveGraphicNode({ return { ...visualState, - resolvedParams: resolveGraphicParamsAtTime({ + resolvedParams: resolveGraphicElementParamsAtTime({ element: node.params, localTime: visualState.localTime, }), diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx index bf14efba..0f0b274a 100644 --- a/apps/web/src/text/components/text-tab.tsx +++ b/apps/web/src/text/components/text-tab.tsx @@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/animation"; +import { + resolveColorAtTime, + resolveNumberAtTime, +} from "@/animation/values"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts index 3a49696f..ec14421b 100644 --- a/apps/web/src/text/measure-element.ts +++ b/apps/web/src/text/measure-element.ts @@ -1,7 +1,7 @@ import { CORNER_RADIUS_MIN } from "@/text/background"; -import { resolveNumberAtTime } from "@/animation"; import { DEFAULTS } from "@/timeline/defaults"; import type { TextBackground, TextElement } from "@/timeline"; +import { resolveNumberAtTime } from "@/animation/values"; import { getTextVisualRect, } from "./layout"; diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts new file mode 100644 index 00000000..31479a58 --- /dev/null +++ b/apps/web/src/timeline/animation-properties.ts @@ -0,0 +1,425 @@ +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + ElementAnimations, + NumericSpec, +} from "@/animation/types"; +import { upsertPathKeyframe } from "@/animation"; +import { parseColorToLinearRgba } from "@/animation/binding-values"; +import { isAnimationPropertyPath } from "@/animation/path"; +import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import { snapToStep } from "@/utils/math"; +import type { TimelineElement } from "@/timeline"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/text/background"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { DEFAULTS } from "@/timeline/defaults"; + +export interface AnimationPropertyDefinition { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +function applyNumericSpec({ + value, + numericRange, +}: { + value: number; + numericRange: NumericSpec | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceNumberValue({ + value, + numericRange, +}: { + value: AnimationValue; + numericRange?: NumericSpec; +}): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return applyNumericSpec({ value, numericRange }); +} + +function coerceColorValue({ + value, +}: { + value: AnimationValue; +}): string | null { + return typeof value === "string" && parseColorToLinearRgba({ color: value }) + ? value + : null; +} + +function createNumberPropertyDefinition({ + numericRange, + supportsElement, + getValue, + setValue, +}: { + numericRange?: NumericSpec; + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: AnimationPropertyDefinition["setValue"]; +}): AnimationPropertyDefinition { + return { + kind: "number", + defaultInterpolation: "linear", + numericRanges: numericRange ? { value: numericRange } : undefined, + supportsElement, + getValue, + coerceValue: ({ value }) => + coerceNumberValue({ + value, + numericRange, + }), + setValue, + }; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.positionX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.x : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, x: value as number }, + }, + } + : element, + }), + "transform.positionY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.y : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, y: value as number }, + }, + } + : element, + }), + "transform.scaleX": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleX : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleX: value as number }, + } + : element, + }), + "transform.scaleY": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleY : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleY: value as number }, + } + : element, + }), + "transform.rotate": createNumberPropertyDefinition({ + numericRange: { min: -360, max: 360, step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }), + opacity: createNumberPropertyDefinition({ + numericRange: { min: 0, max: 1, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }), + volume: createNumberPropertyDefinition({ + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), + getValue: ({ element }) => + canElementHaveAudio(element) ? element.volume ?? 0 : null, + setValue: ({ element, value }) => + canElementHaveAudio(element) + ? { ...element, volume: value as number } + : element, + }), + color: { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }), + "background.paddingY": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }), + "background.offsetX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }), + "background.offsetY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }), + "background.cornerRadius": createNumberPropertyDefinition({ + numericRange: { + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }), +}; + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const definition = getAnimationPropertyDefinition({ propertyPath }); + const coercedValue = definition.coerceValue({ value }); + if (coercedValue === null || !definition.supportsElement({ element })) { + return element; + } + + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.coerceValue({ value }); +} + +export function upsertElementKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): ElementAnimations | undefined { + const coercedValue = coerceAnimationValueForProperty({ + propertyPath, + value, + }); + if (coercedValue === null) { + return animations; + } + + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return upsertPathKeyframe({ + animations, + propertyPath, + time, + value: coercedValue, + interpolation, + keyframeId, + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + coerceValue: ({ value: nextValue }) => + coerceAnimationValueForProperty({ + propertyPath, + value: nextValue, + }), + }); +} diff --git a/apps/web/src/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts new file mode 100644 index 00000000..f7bf549a --- /dev/null +++ b/apps/web/src/timeline/animation-snap-points.ts @@ -0,0 +1,35 @@ +import { getElementKeyframes } from "@/animation"; +import type { SceneTracks } from "@/timeline"; +import type { SnapPoint } from "@/timeline/snapping"; + +export function getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, +}: { + tracks: SceneTracks; + excludeElementIds?: Set; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + if (excludeElementIds?.has(element.id)) { + continue; + } + + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } + } + + return snapPoints; +} diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts new file mode 100644 index 00000000..f9d53fcc --- /dev/null +++ b/apps/web/src/timeline/animation-targets.ts @@ -0,0 +1,214 @@ +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPath, + AnimationValue, + NumericSpec, +} from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; +import { + parseEffectParamPath, +} from "@/animation/effect-param-channel"; +import { + parseGraphicParamPath, +} from "@/animation/graphic-param-channel"; +import { effectsRegistry, registerDefaultEffects } from "@/effects"; +import { getGraphicDefinition } from "@/graphics"; +import type { ParamDefinition } from "@/params"; +import type { TimelineElement } from "@/timeline"; +import { isVisualElement } from "@/timeline/element-utils"; +import { isAnimationPropertyPath } from "@/animation/path"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + withElementBaseValueForProperty, +} from "./animation-properties"; + +export interface AnimationPathDescriptor { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + getBaseValue: () => AnimationValue | null; + setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; +} + +// Number/discrete bindings expose a single component named "value" +// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry +// numeric ranges yet — revisit when one does. +function paramNumericRanges({ + param, +}: { + param: ParamDefinition; +}): Partial> | undefined { + const range = getAnimationParamNumericRange({ param }); + return range ? { value: range } : undefined; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), + getBaseValue: () => element.params[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceAnimationParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), + getBaseValue: () => effect.params[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceAnimationParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath(path)) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRanges: propertyDefinition.numericRanges, + coerceValue: ({ value }) => + coerceAnimationValueForProperty({ + propertyPath: path, + value, + }), + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: ({ value }) => { + const coercedValue = propertyDefinition.coerceValue({ value }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 4ef559ea..44df5aaf 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -1,5 +1,5 @@ import { hasKeyframesForPath } from "@/animation/keyframe-query"; -import { resolveNumberAtTime } from "@/animation/resolve"; +import { resolveNumberAtTime } from "@/animation/values"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; import type { TimelineElement } from "./types"; const DEFAULT_STEP_SECONDS = 1 / 60; diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 1071c6dd..35ee9cca 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -19,7 +19,7 @@ import { import { getBookmarkSnapPoints } from "../snap-source"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { Bookmark } from "@/timeline"; export interface BookmarkDragState { diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx index 1dbb3d1a..1e47dcc8 100644 --- a/apps/web/src/timeline/components/audio-tab.tsx +++ b/apps/web/src/timeline/components/audio-tab.tsx @@ -1,151 +1,151 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/timeline/audio-separation"; -import { DEFAULTS } from "@/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/timeline"; -import { resolveNumberAtTime } from "@/animation"; -import { useEditor } from "@/editor/use-editor"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( -
-

Audio has been separated.

- -
- )} -
- - Audio - - - - - } - > - } - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - - - -
- - ); -} +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { isSourceAudioSeparated } from "@/timeline/audio-separation"; +import { DEFAULTS } from "@/timeline/defaults"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + isNearlyEqual, + snapToStep, +} from "@/utils/math"; +import type { AudioElement, VideoElement } from "@/timeline"; +import { resolveNumberAtTime } from "@/animation/values"; +import { useEditor } from "@/editor/use-editor"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; +import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { VolumeHighIcon } from "@hugeicons/core-free-icons"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; + +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + +export function AudioTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedVolume = resolveNumberAtTime({ + baseValue: element.volume ?? DEFAULTS.element.volume, + animations: element.animations, + propertyPath: "volume", + localTime, + }); + const volume = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "volume", + localTime, + isPlayheadWithinElementRange, + displayValue: formatNumberForDisplay({ + value: resolvedVolume, + fractionDigits: VOLUME_FRACTION_DIGITS, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return null; + } + + return clamp({ + value: snapToStep({ value: parsed, step: VOLUME_STEP }), + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + }); + }, + valueAtPlayhead: resolvedVolume, + step: VOLUME_STEP, + buildBaseUpdates: ({ value }) => ({ + volume: value, + }), + }); + const isDefault = + volume.hasAnimatedKeyframes && isPlayheadWithinElementRange + ? isNearlyEqual({ + leftValue: resolvedVolume, + rightValue: DEFAULTS.element.volume, + }) + : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + const isSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); + + return ( + <> + {isSeparated && ( +
+

Audio has been separated.

+ +
+ )} +
+ + Audio + + + + + } + > + } + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> + + + +
+ + ); +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts index 5260c046..d2b7d82f 100644 --- a/apps/web/src/timeline/controllers/playhead-controller.ts +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -8,7 +8,7 @@ import { } from "@/timeline/snapping"; import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import { getCenteredLineLeft, timelineTimeToPixels, diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index 8677dd15..cdf502a1 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -16,7 +16,7 @@ import { } 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 { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import { isRetimableElement, type SceneTracks, diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 32c0a2f1..7f431e6e 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -7,7 +7,7 @@ import { } 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 { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { MoveGroup } from "./types"; export function snapGroupEdges({ -- cgit v1.3.1