"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, )}
); }