From a9b9cf6bf50f08723896659634f2e6afc17bcd42 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 00:37:56 +0200 Subject: chore: switch from biome to eslint + prettier; fix ton of lint issues --- .../src/timeline/__tests__/update-pipeline.test.ts | 132 ++-- apps/web/src/timeline/animation-properties.ts | 426 ------------- apps/web/src/timeline/animation-targets.ts | 2 +- .../src/timeline/components/audio-volume-line.tsx | 3 +- .../web/src/timeline/components/audio-waveform.tsx | 60 +- .../components/graph-editor/bezier-graph.tsx | 5 +- apps/web/src/timeline/components/index.tsx | 41 +- .../src/timeline/components/timeline-element.tsx | 59 +- .../src/timeline/components/timeline-playhead.tsx | 12 +- .../web/src/timeline/components/timeline-ruler.tsx | 19 +- .../web/src/timeline/components/timeline-track.tsx | 6 +- .../timeline/controllers/drag-drop-controller.ts | 5 +- .../controllers/element-interaction-controller.ts | 85 ++- .../src/timeline/controllers/resize-controller.ts | 13 +- .../src/timeline/controllers/zoom-controller.ts | 25 +- .../hooks/element/use-element-interaction.ts | 18 +- .../timeline/hooks/element/use-keyframe-drag.ts | 16 +- .../timeline/hooks/use-snap-indicator-position.ts | 24 +- .../src/timeline/hooks/use-timeline-drag-drop.ts | 14 +- .../src/timeline/hooks/use-timeline-playhead.ts | 13 +- apps/web/src/timeline/hooks/use-timeline-resize.ts | 14 +- apps/web/src/timeline/hooks/use-timeline-seek.ts | 14 +- apps/web/src/timeline/hooks/use-timeline-zoom.ts | 17 +- apps/web/src/timeline/index.ts | 9 +- apps/web/src/timeline/types.ts | 664 ++++++++++----------- 25 files changed, 631 insertions(+), 1065 deletions(-) delete mode 100644 apps/web/src/timeline/animation-properties.ts (limited to 'apps/web/src/timeline') 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 { - 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 { + 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>; - 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 with a custom thumb. */}
(null); const containerRef = useRef(null); const summaryRef = useRef(null); - const gainSamplesRef = useRef(gainSamples); - const pixelsPerSecondRef = useRef(pixelsPerSecond); - const clipDurationSecRef = useRef(clipDurationSec); - const retimeRef = useRef(retime); - const sourceStartSecRef = useRef(sourceStartSec); + const waveformConfigRef = useCommittedRef({ + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color, + burnColor, + }); const scrollParentRef = useRef(null); const heightRef = useRef(0); const lastRenderSignatureRef = useRef(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(null); const [activeHandle, setActiveHandle] = useState(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( 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 (
- {/* 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. */}
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
; + 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. +
+ ); } 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 })} >
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.
); })} - {selectionBox && ( - - )} + {selectionBox && }
); } @@ -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