diff options
Diffstat (limited to 'apps/web/src/timeline')
25 files changed, 631 insertions, 1065 deletions
diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts index 94b32ced..e6d63bd6 100644 --- a/apps/web/src/timeline/__tests__/update-pipeline.test.ts +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -1,66 +1,66 @@ -import { describe, expect, test } from "bun:test";
-import type { Transform } from "@/rendering";
-import type { SceneTracks, VideoElement } from "@/timeline";
-import { applyElementUpdate } from "@/timeline/update-pipeline";
-import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
-
-function buildTransform(): Transform {
- return {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
- };
-}
-
-function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
- return {
- id: "video-1",
- type: "video",
- name: "Video 1",
- startTime: ZERO_MEDIA_TIME,
- duration: mediaTime({ ticks: 10 }),
- trimStart: ZERO_MEDIA_TIME,
- trimEnd: ZERO_MEDIA_TIME,
- mediaId: "media-1",
- transform: buildTransform(),
- opacity: 1,
- ...overrides,
- };
-}
-
-function buildTracks(element: VideoElement): SceneTracks {
- return {
- overlay: [],
- main: {
- id: "main-track",
- type: "video",
- name: "Main",
- muted: false,
- hidden: false,
- elements: [element],
- },
- audio: [],
- };
-}
-
-describe("applyElementUpdate", () => {
- test("rounds retimed durations back to integer media time", () => {
- const element = buildVideoElement();
- const tracks = buildTracks(element);
-
- const updatedElement = applyElementUpdate({
- element,
- patch: {
- retime: { rate: 1.5 },
- },
- context: {
- tracks,
- trackId: tracks.main.id,
- },
- });
-
- expect(updatedElement.duration).toBe(7);
- expect(Number.isInteger(updatedElement.duration)).toBe(true);
- });
-});
+import { describe, expect, test } from "bun:test"; +import type { Transform } from "@/rendering"; +import type { SceneTracks, VideoElement } from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement { + return { + id: "video-1", + type: "video", + name: "Video 1", + startTime: ZERO_MEDIA_TIME, + duration: mediaTime({ ticks: 10 }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-1", + transform: buildTransform(), + opacity: 1, + ...overrides, + }; +} + +function buildTracks(element: VideoElement): SceneTracks { + return { + overlay: [], + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [element], + }, + audio: [], + }; +} + +describe("applyElementUpdate", () => { + test("rounds retimed durations back to integer media time", () => { + const element = buildVideoElement(); + const tracks = buildTracks(element); + + const updatedElement = applyElementUpdate({ + element, + patch: { + retime: { rate: 1.5 }, + }, + context: { + tracks, + trackId: tracks.main.id, + }, + }); + + expect(updatedElement.duration).toBe(7); + expect(Number.isInteger(updatedElement.duration)).toBe(true); + }); +}); diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts deleted file mode 100644 index 308f6cf0..00000000 --- a/apps/web/src/timeline/animation-properties.ts +++ /dev/null @@ -1,426 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - ElementAnimations, - NumericSpec, -} from "@/animation/types"; -import { upsertPathKeyframe } from "@/animation"; -import { parseColorToLinearRgba } from "@/animation/binding-values"; -import type { MediaTime } from "@/wasm"; -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<Record<string, NumericSpec>>; - 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: MediaTime; - 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-targets.ts b/apps/web/src/timeline/animation-targets.ts index f9d53fcc..fe683f92 100644 --- a/apps/web/src/timeline/animation-targets.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -28,7 +28,7 @@ import { getAnimationPropertyDefinition, getElementBaseValueForProperty, withElementBaseValueForProperty, -} from "./animation-properties"; +} from "@/animation"; export interface AnimationPathDescriptor { kind: AnimationBindingKind; diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx index 1baac2bd..ac2fd32a 100644 --- a/apps/web/src/timeline/components/audio-volume-line.tsx +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -242,8 +242,7 @@ export function AudioVolumeLine({ )} style={{ top: lineTop }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- custom drag widget for clip volume; pointer events drive the interaction, the click/mousedown handlers are propagation-stoppers. The a11y-correct long-term shape is <input type="range"> with a custom thumb. */} <div className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto" style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }} diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx index 7777bc09..87178f17 100644 --- a/apps/web/src/timeline/components/audio-waveform.tsx +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useResizeObserver } from "@/hooks/use-resize-observer"; import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; import { @@ -72,21 +73,19 @@ export function AudioWaveform({ const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); const summaryRef = useRef<SourceWaveformSummary | null>(null); - const gainSamplesRef = useRef<number[] | undefined>(gainSamples); - const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); - const clipDurationSecRef = useRef<number>(clipDurationSec); - const retimeRef = useRef<RetimeConfig | undefined>(retime); - const sourceStartSecRef = useRef<number>(sourceStartSec); + const waveformConfigRef = useCommittedRef({ + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color, + burnColor, + }); const scrollParentRef = useRef<HTMLElement | null>(null); const heightRef = useRef<number>(0); const lastRenderSignatureRef = useRef<string | null>(null); - gainSamplesRef.current = gainSamples; - pixelsPerSecondRef.current = pixelsPerSecond; - clipDurationSecRef.current = clipDurationSec; - retimeRef.current = retime; - sourceStartSecRef.current = sourceStartSec; - const clearCanvas = useCallback(() => { const canvas = canvasRef.current; if (!canvas) { @@ -142,13 +141,19 @@ export function AudioWaveform({ return; } + const { + gainSamples: gainSamplesValue, + pixelsPerSecond: pixelsPerSecondValue, + clipDurationSec: clipDurationSecValue, + retime: retimeValue, + sourceStartSec: sourceStartSecValue, + color: colorValue, + burnColor: burnColorValue, + } = waveformConfigRef.current; const dpr = window.devicePixelRatio || 1; const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); const canvasH = Math.max(1, Math.round(height * dpr)); const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); - const pixelsPerSecondValue = pixelsPerSecondRef.current; - const clipDurationSecValue = clipDurationSecRef.current; - const samples = gainSamplesRef.current; const renderSignature = JSON.stringify({ elementWidth, clipLeft, @@ -159,16 +164,16 @@ export function AudioWaveform({ barCount, dpr, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, + sourceStartSec: sourceStartSecValue, pixelsPerSecond: pixelsPerSecondValue, - retime: retimeRef.current ?? null, + retime: retimeValue ?? null, summarySourceKey: summary.sourceKey, summarySampleRate: summary.sampleRate, summaryTotalSamples: summary.totalSamples, summaryBucketSize: summary.bucketSize, - gainSamples: samples ?? null, - color, - burnColor, + gainSamples: gainSamplesValue ?? null, + color: colorValue, + burnColor: burnColorValue, }); if (lastRenderSignatureRef.current === renderSignature) { return; @@ -190,8 +195,8 @@ export function AudioWaveform({ barCount, pixelsPerSecond: pixelsPerSecondValue, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, - retime: retimeRef.current, + sourceStartSec: sourceStartSecValue, + retime: retimeValue, sampleRate: summary.sampleRate, maxSampleExclusive: summary.totalSamples, barStepPx: BAR_STEP, @@ -218,9 +223,9 @@ export function AudioWaveform({ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), ); const gain = - samples != null + gainSamplesValue != null ? sampleGainAtClipTime({ - samples, + samples: gainSamplesValue, clipTimeSec: clipCenterSec, clipDurationSec: clipDurationSecValue, }) @@ -243,7 +248,7 @@ export function AudioWaveform({ const deviceTop = Math.round((height - barH) * backingScaleY); const deviceHeight = Math.max(1, clipBottom - deviceTop); - ctx.fillStyle = color; + ctx.fillStyle = colorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -253,7 +258,7 @@ export function AudioWaveform({ if (outputAmplitude > 1) { const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); - ctx.fillStyle = burnColor; + ctx.fillStyle = burnColorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -262,7 +267,7 @@ export function AudioWaveform({ ); } } - }, [burnColor, clearCanvas, color]); + }, [clearCanvas, waveformConfigRef]); useEffect(() => { let isCancelled = false; @@ -296,7 +301,6 @@ export function AudioWaveform({ }; }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); - // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. useLayoutEffect(() => { drawVisible(); }, [ @@ -306,6 +310,8 @@ export function AudioWaveform({ clipDurationSec, retime, sourceStartSec, + color, + burnColor, ]); useEffect(() => { diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx index 7d77b985..ff4219c5 100644 --- a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -2,6 +2,7 @@ import { useRef, useState, type PointerEvent } from "react"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { getBezierPoint } from "@/animation/bezier"; import type { NormalizedCubicBezier } from "@/animation/types"; import { cn } from "@/utils/ui"; @@ -87,9 +88,7 @@ export function BezierGraph({ const svgRef = useRef<SVGSVGElement>(null); const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); const isShiftPressedRef = useShiftKey(); - const latestValueRef = useRef(value); - - latestValueRef.current = value; + const latestValueRef = useCommittedRef(value); function getPointerPosition({ event, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index 490ad1cc..8edbb6fc 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,7 @@ import { useState, type ReactNode, } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; import type { MediaTime } from "@/wasm"; import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; @@ -80,6 +81,7 @@ import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bott import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useEditor } from "@/editor/use-editor"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/actions"; @@ -148,15 +150,25 @@ export function Timeline() { const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, ); + const { width: tracksContainerWidth } = useContainerSize({ + containerRef: tracksContainerRef, + }); + const { height: timelineHeaderHeightValue } = useContainerSize({ + containerRef: timelineHeaderRef, + }); + const { viewportWidth: tracksViewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); const timelineDuration = timeline.getTotalDuration() || 0; + const containerWidth = tracksContainerWidth || FALLBACK_CONTAINER_WIDTH; const minZoomLevel = getTimelineZoomMin({ duration: timelineDuration, - containerWidth: tracksContainerRef.current?.clientWidth, + containerWidth, }); const savedViewState = editor.project.getTimelineViewState(); @@ -358,8 +370,6 @@ export function Timeline() { }, }); - const containerWidth = - tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel, @@ -373,11 +383,8 @@ export function Timeline() { contentWidth + paddingPx, containerWidth, ); - const tracksViewportWidth = - tracksScrollRef.current?.clientWidth ?? - tracksContainerRef.current?.clientWidth ?? - containerWidth; - const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; + const hasHorizontalScrollbar = + dynamicTimelineWidth > (tracksViewportWidth || containerWidth); useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, @@ -419,8 +426,7 @@ export function Timeline() { }); const timelineHeaderHeight = - (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + - TIMELINE_CONTENT_TOP_PADDING_PX || 0; + timelineHeaderHeightValue + TIMELINE_CONTENT_TOP_PADDING_PX; return ( <section @@ -450,10 +456,7 @@ export function Timeline() { ref={tracksContainerRef} > <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} + bounds={selectionBox?.bounds ?? null} /> <DragLine dropTarget={dropTarget} @@ -509,8 +512,7 @@ export function Timeline() { className="flex min-h-full flex-col" style={{ width: `${dynamicTimelineWidth}px` }} > - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (tracks container background); direct-target clicks here originate box-select or clear selection. Keyboard control is global timeline shortcuts. */} <div className="relative shrink-0" style={{ @@ -891,9 +893,10 @@ function TimelineGutter({ onMouseDown: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void; }) { - // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system - // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system - return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (empty space below tracks); clicks here clear selection. Keyboard control is global timeline shortcuts. + <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} /> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index cb102c71..be8409f3 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -31,6 +31,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import type { SelectionBoxBounds } from "@/selection/types"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -51,7 +52,7 @@ import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { - getActionDefinition, + getActionDefaultShortcuts, type TAction, type TActionWithOptionalArgs, invokeAction, @@ -185,7 +186,7 @@ export function getKeyframeIndicators({ } export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); + const defaultShortcuts = getActionDefaultShortcuts({ action }); if (!defaultShortcuts?.length) { return ""; } @@ -206,14 +207,14 @@ interface TimelineElementProps { track: TimelineTrack; side: "left" | "right"; }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; dragView: ElementDragView; isDropTarget?: boolean; } @@ -530,14 +531,14 @@ function ElementInner({ isExpanded: boolean; baseTrackHeight: number; expandedContent: React.ReactNode; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -578,8 +579,8 @@ function ElementInner({ type="button" tabIndex={-1} className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} + onClick={(event) => onElementClick({ event, element })} + onMouseDown={(event) => onElementMouseDown({ event, element })} > <div className={cn( @@ -770,9 +771,7 @@ function ExpandedKeyframeLanes({ onLaneMouseDown: (event: React.MouseEvent) => void; onLaneClick: (event: React.MouseEvent) => void; selectionBox: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; + bounds: SelectionBoxBounds; } | null; isBoxSelecting: boolean; onKeyframeClick: (params: { @@ -808,8 +807,7 @@ function ExpandedKeyframeLanes({ ); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface - // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (keyframe lanes); keyboard control over keyframes is via global timeline shortcuts, not per-element focus. <div ref={containerRef} className="relative flex flex-col" @@ -893,14 +891,7 @@ function ExpandedKeyframeLanes({ </div> ); })} - {selectionBox && ( - <SelectionBox - startPos={selectionBox.startPos} - currentPos={selectionBox.currentPos} - containerRef={containerRef} - isActive={selectionBox.isActive} - /> - )} + {selectionBox && <SelectionBox bounds={selectionBox.bounds} />} </div> ); } @@ -1073,7 +1064,7 @@ function EffectsButton({ editor.selection.setSelectedElements({ elements: [{ trackId: track.id, elementId: element.id }], }); - setActiveTab(element.type, "effects"); + setActiveTab({ elementType: element.type, tabId: "effects" }); }; return ( diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 5b1e45ab..6d77789e 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -1,11 +1,13 @@ "use client"; import { useRef } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; import { getCenteredLineLeft, TIMELINE_INDICATOR_LINE_WIDTH_PX, timelineTimeToSnappedPixels, } from "@/timeline"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { addMediaTime, @@ -52,11 +54,14 @@ export function TimelinePlayhead({ tracksScrollRef, playheadRef, }); + const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef }); + const { height: tracksHeight } = useContainerSize({ + containerRef: tracksScrollRef, + }); + const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef }); const timelineContainerHeight = - timelineRef.current?.clientHeight ?? - tracksScrollRef.current?.clientHeight ?? - 400; + timelineHeight || tracksHeight || 400; const totalHeight = Math.max( 0, timelineContainerHeight - @@ -68,7 +73,6 @@ export function TimelinePlayhead({ time: currentTime, zoomLevel, }); - const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx index e2a379ee..ccacdae3 100644 --- a/apps/web/src/timeline/components/timeline-ruler.tsx +++ b/apps/web/src/timeline/components/timeline-ruler.tsx @@ -1,4 +1,4 @@ -import { type JSX, useLayoutEffect, useRef } from "react"; +import { type JSX } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { mediaTimeToSeconds } from "opencut-wasm"; import { TICKS_PER_SECOND } from "@/wasm"; @@ -51,20 +51,9 @@ export function TimelineRuler({ scrollRef: tracksScrollRef, }); - /** - * widens the virtualization buffer during zoom transitions. - * useScrollPosition lags one frame behind the scroll adjustment - * that useLayoutEffect applies after a zoom change. - */ - const prevZoomRef = useRef(zoomLevel); - const isZoomTransition = zoomLevel !== prevZoomRef.current; - const bufferPx = isZoomTransition - ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) - : 200; - - useLayoutEffect(() => { - prevZoomRef.current = zoomLevel; - }, [zoomLevel]); + // Keep extra buffer because zoom layout and scroll position can briefly + // settle on different frames. + const bufferPx = Math.max(200, (scrollLeft + viewportWidth) * 0.15); const visibleStartTimeSeconds = Math.max( 0, diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 7608e6df..2603f511 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -62,7 +62,7 @@ export function TimelineTrackContent({ onTrackMouseDown?.(event); }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- spatial gesture surface; the wrapping <button> handles keyboard track selection, this <div> only forwards background clicks for box-select / deselect. */} <div className="relative h-full min-w-full" style={{ zIndex: TIMELINE_LAYERS.trackContent }} @@ -96,10 +96,10 @@ export function TimelineTrackContent({ onResizeStart={({ event, element, side }) => onResizeStart({ event, element, track, side }) } - onElementMouseDown={(event, element) => + onElementMouseDown={({ event, element }) => onElementMouseDown({ event, element, track }) } - onElementClick={(event, element) => + onElementClick={({ event, element }) => onElementClick({ event, element, track }) } dragView={dragView} diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index 7fca2676..e0624a74 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -326,7 +326,10 @@ export class DragDropController { trackType: TrackType; }): void { if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const addTrackCmd = new AddTrackCommand({ + type: trackType, + index: target.trackIndex, + }); this.config.executeCommand( new BatchCommand([ addTrackCmd, diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index d0d6426a..5afc3a93 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -124,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" }; // --- Pure helpers --- -function pixelToClickOffsetTime( - clientX: number, - elementRect: DOMRect, - zoomLevel: number, -): MediaTime { +function pixelToClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } -function verticalDirection( - startMouseY: number, - currentMouseY: number, -): "up" | "down" | null { +function verticalDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { if (currentMouseY < startMouseY) return "up"; if (currentMouseY > startMouseY) return "down"; return null; @@ -147,7 +154,13 @@ function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; } -function movedPastDragThreshold(current: Point, origin: Point): boolean { +function movedPastDragThreshold({ + current, + origin, +}: { + 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 @@ -374,11 +387,11 @@ export class ElementInteractionController { elementId: element.id, trackId: track.id, startElementTime: element.startTime, - clickOffsetTime: pixelToClickOffsetTime( - event.clientX, - event.currentTarget.getBoundingClientRect(), - this.deps.viewport.getZoomLevel(), - ), + clickOffsetTime: pixelToClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel: this.deps.viewport.getZoomLevel(), + }), selectedElements, }, }; @@ -437,10 +450,13 @@ export class ElementInteractionController { this.notify(); } - private snapResult( - frameSnappedTime: MediaTime, - group: MoveGroup, - ): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { + private snapResult({ + frameSnappedTime, + group, + }: { + frameSnappedTime: MediaTime; + group: MoveGroup; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { const { snap, input, scene, viewport, playback } = this.deps; if (!snap.isEnabled() || input.isShiftHeld()) { @@ -487,7 +503,10 @@ export class ElementInteractionController { viewport, zoomLevel, snappedTime, - verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + verticalDragDirection: verticalDirection({ + startMouseY: mousedown.origin.y, + currentMouseY: clientY, + }), }); const nextGroupMoveResult = anchorDropTarget @@ -543,7 +562,12 @@ export class ElementInteractionController { clientY: number; scrollContainer: HTMLDivElement; }): void { - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { return; } @@ -568,10 +592,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - moveGroup, - ); + group: moveGroup, + }); // Ensure the anchor is selected before we render the drag — covers the // case where the selection store hasn't committed the mousedown-time @@ -632,10 +656,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - drag.moveGroup, - ); + group: drag.moveGroup, + }); drag.currentTime = snappedTime; drag.currentMouseX = clientX; @@ -666,7 +690,12 @@ export class ElementInteractionController { // 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)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { this.lastGestureWasDrag = false; this.finishSession(); return; diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index e8cbaa80..a1098915 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -263,10 +263,13 @@ export class ResizeController { this.notify(); } - private snappedDelta( - session: ResizeSession, - rawDeltaTime: MediaTime, - ): MediaTime { + private snappedDelta({ + session, + rawDeltaTime, + }: { + session: ResizeSession; + rawDeltaTime: MediaTime; + }): MediaTime { const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; if (!snappingEnabled || isShiftHeld()) { @@ -330,7 +333,7 @@ export class ResizeController { TICKS_PER_SECOND, ), }); - const deltaTime = this.snappedDelta(session, rawDeltaTime); + const deltaTime = this.snappedDelta({ session, rawDeltaTime }); const result = computeGroupResize({ members: session.members, side: session.side, diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts index c5090e16..91147354 100644 --- a/apps/web/src/timeline/controllers/zoom-controller.ts +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -25,7 +25,13 @@ export interface ZoomConfigRef { readonly current: ZoomConfig; } -function clampZoom(zoomLevel: number, minZoom: number): number { +function clampZoom({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); } @@ -49,7 +55,7 @@ export class ZoomController { const minZoom = this.config.minZoom; this.zoomLevelValue = deps.initialZoom !== undefined - ? clampZoom(deps.initialZoom, minZoom) + ? clampZoom({ zoomLevel: deps.initialZoom, minZoom }) : minZoom; this.previousZoom = this.zoomLevelValue; this.hasInitialized = deps.initialZoom !== undefined; @@ -89,7 +95,10 @@ export class ZoomController { typeof zoomLevelOrUpdater === "function" ? zoomLevelOrUpdater(this.zoomLevelValue) : zoomLevelOrUpdater; - const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + const nextZoom = clampZoom({ + zoomLevel: nextZoomRaw, + minZoom: this.config.minZoom, + }); if (nextZoom === this.zoomLevelValue) return; this.zoomLevelValue = nextZoom; @@ -115,10 +124,16 @@ export class ZoomController { } } - reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + reconcileInitialAndMinZoom({ + minZoom, + initialZoom, + }: { + minZoom: number; + initialZoom?: number; + }): void { if (initialZoom !== undefined && !this.hasInitialized) { this.hasInitialized = true; - this.setZoomLevel(clampZoom(initialZoom, minZoom)); + this.setZoomLevel(clampZoom({ zoomLevel: initialZoom, minZoom })); return; } 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 26a21f85..220f3fec 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; @@ -63,17 +64,10 @@ export function useElementInteraction({ onChange: onSnapPointChange, }, }; - - const depsRef = useRef<ElementInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<ElementInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ElementInteractionController({ - depsRef: depsRef as ElementInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as ElementInteractionDepsRef; + const [controller] = useState( + () => new ElementInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); 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 ea03cb5c..141bba8f 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useKeyframeSelection } from "./use-keyframe-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -44,15 +45,10 @@ export function useKeyframeDrag({ seek: ({ time }) => editor.playback.seek({ time }), getTotalDuration: () => editor.timeline.getTotalDuration(), }; - - const configRef = useRef<KeyframeDragConfig>(config); - configRef.current = config; - - const controllerRef = useRef<KeyframeDragController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new KeyframeDragController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new KeyframeDragController({ configRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts index 2081b939..ac6d91eb 100644 --- a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+import { useScrollPosition } from "./use-scroll-position";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -20,24 +21,9 @@ export function useSnapIndicatorPosition({ timelineRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef });
+ const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef });
+ const timelineContainerHeight = timelineHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const timelinePosition = timelineTimeToSnappedPixels({
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 4ec2b80b..c405819e 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { DragDropController, type DragDropConfig, @@ -37,15 +38,8 @@ export function useTimelineDragDrop({ insertElement: (args) => editor.timeline.insertElement(args), addClipEffect: (args) => editor.timeline.addClipEffect(args), }; - - const configRef = useRef<DragDropConfig>(config); - configRef.current = config; - - const controllerRef = useRef<DragDropController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new DragDropController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new DragDropController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 15ef3575..9f10c4a5 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { timelineTimeToPixels } from "@/timeline"; @@ -54,14 +55,8 @@ export function useTimelinePlayhead({ }, }), }; - const configRef = useRef(config); - configRef.current = config; - - const ctrlRef = useRef<PlayheadController | null>(null); - if (!ctrlRef.current) { - ctrlRef.current = new PlayheadController({ configRef }); - } - const ctrl = ctrlRef.current; + const configRef = useCommittedRef(config); + const [ctrl] = useState(() => new PlayheadController({ configRef })); // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index 3579bd75..6bd0c606 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; @@ -55,15 +56,8 @@ export function useTimelineResize({ }), onSnapPointChange, }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ResizeController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ResizeController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new ResizeController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index bf033dae..ea9b44a7 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef, type RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { SeekController, type SeekConfig, @@ -50,15 +51,8 @@ export function useTimelineSeek({ }, }), }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<SeekController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new SeekController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new SeekController({ configRef })); useEffect(() => () => controller.destroy(), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 3020504f..60768963 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -4,9 +4,10 @@ import { useEffect, useLayoutEffect, useReducer, - useRef, + useState, } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; import { ZoomController, @@ -57,21 +58,17 @@ export function useTimelineZoom({ }, }), }; - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ZoomController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ZoomController({ configRef, initialZoom }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new ZoomController({ configRef, initialZoom }), + ); const zoomLevel = controller.zoomLevel; const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + controller.reconcileInitialAndMinZoom({ minZoom, initialZoom }); }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 3775e006..21944561 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,4 +1,4 @@ -import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import { addMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import type { SceneTracks } from "./types"; export * from "./types"; @@ -22,9 +22,10 @@ export function calculateTotalDuration({ let maxEnd: MediaTime = ZERO_MEDIA_TIME; for (const track of orderedTracks) { for (const element of track.elements) { - // `startTime + duration` is integer-by-construction (both `MediaTime`), - // so the cast re-establishes the brand without runtime work. - const elementEnd = (element.startTime + element.duration) as MediaTime; + const elementEnd = addMediaTime({ + a: element.startTime, + b: element.duration, + }); if (elementEnd > maxEnd) maxEnd = elementEnd; } } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 99df3128..e84a4f4e 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -1,332 +1,332 @@ -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";
-import type { MediaTime } from "@/wasm";
-
-export type ElementRef = {
- trackId: string;
- elementId: string;
-};
-
-export interface Bookmark {
- time: MediaTime;
- note?: string;
- color?: string;
- duration?: MediaTime;
-}
-
-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: MediaTime;
- startTime: MediaTime;
- trimStart: MediaTime;
- trimEnd: MediaTime;
- sourceDuration?: MediaTime;
- 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<T extends ElementType[]>(...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<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export interface ElementDragState {
- isDragging: boolean;
- elementId: string | null;
- dragElementIds: string[];
- dragTimeOffsets: Record<string, MediaTime>;
- trackId: string | null;
- startMouseX: number;
- startMouseY: number;
- startElementTime: MediaTime;
- clickOffsetTime: MediaTime;
- currentTime: MediaTime;
- currentMouseY: number;
-}
-
-export type ElementDragView =
- | { readonly kind: "idle" }
- | {
- readonly kind: "dragging";
- readonly anchorElementId: string;
- readonly trackId: string;
- readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>;
- readonly startMouseX: number;
- readonly startMouseY: number;
- readonly startElementTime: MediaTime;
- readonly clickOffsetTime: MediaTime;
- readonly currentTime: MediaTime;
- readonly currentMouseX: number;
- readonly currentMouseY: number;
- readonly dropTarget: DropTarget | null;
- };
-
-export interface DropTarget {
- trackIndex: number;
- isNewTrack: boolean;
- insertPosition: "above" | "below" | null;
- xPosition: MediaTime;
- targetElement: { elementId: string; trackId: string } | null;
-}
-
-export interface ComputeDropTargetParams {
- elementType: ElementType;
- mouseX: number;
- mouseY: number;
- tracks: SceneTracks;
- playheadTime: MediaTime;
- isExternalDrop: boolean;
- elementDuration: MediaTime;
- pixelsPerSecond: number;
- zoomLevel: number;
- verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: MediaTime;
- 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"; +import type { MediaTime } from "@/wasm"; + +export type ElementRef = { + trackId: string; + elementId: string; +}; + +export interface Bookmark { + time: MediaTime; + note?: string; + color?: string; + duration?: MediaTime; +} + +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: MediaTime; + startTime: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; + 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<T extends ElementType[]>(...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<UploadAudioElement, "id">; +export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">; +export type CreateAudioElement = + | CreateUploadAudioElement + | CreateLibraryAudioElement; +export type CreateVideoElement = Omit<VideoElement, "id">; +export type CreateImageElement = Omit<ImageElement, "id">; +export type CreateTextElement = Omit<TextElement, "id">; +export type CreateStickerElement = Omit<StickerElement, "id">; +export type CreateGraphicElement = Omit<GraphicElement, "id">; +export type CreateEffectElement = Omit<EffectElement, "id">; +export type CreateTimelineElement = + | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | CreateStickerElement + | CreateGraphicElement + | CreateEffectElement; + +export interface ElementDragState { + isDragging: boolean; + elementId: string | null; + dragElementIds: string[]; + dragTimeOffsets: Record<string, MediaTime>; + trackId: string | null; + startMouseX: number; + startMouseY: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; + currentTime: MediaTime; + currentMouseY: number; +} + +export type ElementDragView = + | { readonly kind: "idle" } + | { + readonly kind: "dragging"; + readonly anchorElementId: string; + readonly trackId: string; + readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>; + readonly startMouseX: number; + readonly startMouseY: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly currentTime: MediaTime; + readonly currentMouseX: number; + readonly currentMouseY: number; + readonly dropTarget: DropTarget | null; + }; + +export interface DropTarget { + trackIndex: number; + isNewTrack: boolean; + insertPosition: "above" | "below" | null; + xPosition: MediaTime; + targetElement: { elementId: string; trackId: string } | null; +} + +export interface ComputeDropTargetParams { + elementType: ElementType; + mouseX: number; + mouseY: number; + tracks: SceneTracks; + playheadTime: MediaTime; + isExternalDrop: boolean; + elementDuration: MediaTime; + pixelsPerSecond: number; + zoomLevel: number; + verticalDragDirection?: "up" | "down" | null; + startTimeOverride?: MediaTime; + excludeElementId?: string; + targetElementTypes?: string[]; +} + +export interface ClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} |
