summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 00:37:56 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commita9b9cf6bf50f08723896659634f2e6afc17bcd42 (patch)
tree9175b49ff21f4326872ac9e541d2765cc7994ebc /apps/web/src/timeline/components
parentd6622dc6b3523a5658cd512b44f15f7a66fd540c (diff)
downloadopencut-a9b9cf6bf50f08723896659634f2e6afc17bcd42.tar.gz
chore: switch from biome to eslint + prettier; fix ton of lint issues
Diffstat (limited to 'apps/web/src/timeline/components')
-rw-r--r--apps/web/src/timeline/components/audio-volume-line.tsx3
-rw-r--r--apps/web/src/timeline/components/audio-waveform.tsx60
-rw-r--r--apps/web/src/timeline/components/graph-editor/bezier-graph.tsx5
-rw-r--r--apps/web/src/timeline/components/index.tsx41
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx59
-rw-r--r--apps/web/src/timeline/components/timeline-playhead.tsx12
-rw-r--r--apps/web/src/timeline/components/timeline-ruler.tsx19
-rw-r--r--apps/web/src/timeline/components/timeline-track.tsx6
8 files changed, 98 insertions, 107 deletions
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}