From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../src/timeline/components/audio-volume-line.tsx | 275 +++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 apps/web/src/timeline/components/audio-volume-line.tsx (limited to 'apps/web/src/timeline/components/audio-volume-line.tsx') diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx new file mode 100644 index 00000000..1baac2bd --- /dev/null +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULTS } from "@/timeline/defaults"; +import { + getDbFromLinePos, + getLinePosFromDb, +} from "@/timeline/audio-display"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { hasAnimatedVolume } from "@/timeline/audio-state"; +import type { AudioElement } from "@/timeline/types"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + isNearlyEqual, + snapToStep, +} from "@/utils/math"; +import { cn } from "@/utils/ui"; + +const HIT_AREA_HEIGHT_PX = 14; +const TOOLTIP_OFFSET_PX = 10; +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + +function clampVolume({ value }: { value: number }): number { + return clamp({ + value: snapToStep({ value, step: VOLUME_STEP }), + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + }); +} + +function getVolumeFromPointer({ + clientY, + rect, +}: { + clientY: number; + rect: DOMRect; +}): number { + const clampedOffset = clamp({ + value: clientY - rect.top, + min: 0, + max: rect.height, + }); + const progressPercent = + rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100; + return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) }); +} + +export function AudioVolumeLine({ + element, + trackId, +}: { + element: AudioElement; + trackId: string; +}) { + const editor = useEditor(); + const surfaceRef = useRef(null); + const activePointerIdRef = useRef(null); + const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); + const lastPreviewVolumeRef = useRef( + element.volume ?? DEFAULTS.element.volume, + ); + const hasChangedRef = useRef(false); + const [isDragging, setIsDragging] = useState(false); + const [tooltipClientPos, setTooltipClientPos] = useState<{ + x: number; + y: number; + } | null>(null); + + const hasAnimatedEnvelope = hasAnimatedVolume({ element }); + const currentVolume = element.volume ?? DEFAULTS.element.volume; + const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; + + const volumeLabel = `${formatNumberForDisplay({ + value: currentVolume, + fractionDigits: VOLUME_FRACTION_DIGITS, + })} dB`; + + const previewVolume = useCallback( + (nextVolume: number) => { + if ( + isNearlyEqual({ + leftValue: nextVolume, + rightValue: lastPreviewVolumeRef.current, + }) + ) { + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { volume: nextVolume }, + }, + ], + }); + lastPreviewVolumeRef.current = nextVolume; + hasChangedRef.current = !isNearlyEqual({ + leftValue: startVolumeRef.current, + rightValue: nextVolume, + }); + }, + [editor, element.id, trackId], + ); + + const finishDrag = useCallback( + ({ shouldCommit }: { shouldCommit: boolean }) => { + activePointerIdRef.current = null; + setIsDragging(false); + + if (shouldCommit && hasChangedRef.current) { + editor.timeline.commitPreview(); + } else { + editor.timeline.discardPreview(); + } + + hasChangedRef.current = false; + lastPreviewVolumeRef.current = startVolumeRef.current; + setTooltipClientPos(null); + }, + [editor], + ); + + const updateFromPointer = useCallback( + ({ clientX, clientY }: { clientX: number; clientY: number }) => { + const rect = surfaceRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + setTooltipClientPos({ + x: clientX + TOOLTIP_OFFSET_PX, + y: clientY - TOOLTIP_OFFSET_PX, + }); + previewVolume(getVolumeFromPointer({ clientY, rect })); + }, + [previewVolume], + ); + + const handleClick = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + editor.selection.setSelectedElements({ + elements: [{ trackId, elementId: element.id }], + }); + activePointerIdRef.current = event.pointerId; + startVolumeRef.current = currentVolume; + lastPreviewVolumeRef.current = currentVolume; + hasChangedRef.current = false; + setIsDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + updateFromPointer({ + clientX: event.clientX, + clientY: event.clientY, + }); + }, + [currentVolume, editor.selection, element.id, trackId, updateFromPointer], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + updateFromPointer({ + clientX: event.clientX, + clientY: event.clientY, + }); + }, + [updateFromPointer], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + finishDrag({ shouldCommit: true }); + }, + [finishDrag], + ); + + const handlePointerCancel = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + finishDrag({ shouldCommit: false }); + }, + [finishDrag], + ); + + const handleLostPointerCapture = useCallback(() => { + if (activePointerIdRef.current === null) { + return; + } + + finishDrag({ shouldCommit: hasChangedRef.current }); + }, [finishDrag]); + + if (hasAnimatedEnvelope) { + return null; + } + + return ( +
+
+
+ {/* 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 */} +
+ {isDragging && + tooltipClientPos && + createPortal( +
+ {volumeLabel} +
, + document.body, + )} +
+
+ ); +} -- cgit v1.3.1