import { useState, useEffect, useRef, MouseEvent } from 'react'; import { MediaSet } from './generated/media_set'; import { Frames, VideoPosition } from './App'; import { WaveformCanvas } from './WaveformCanvas'; import { Observable } from 'rxjs'; export interface Selection { start: number; end: number; } interface Props { peaks: Observable; mediaSet: MediaSet; height: number; offsetPixels: number; position: VideoPosition; viewport: Frames; onSelectionChange: (selection: Frames) => void; } enum Mode { Normal, Selecting, Dragging, ResizingStart, ResizingEnd, } enum HoverState { Normal, OverSelectionStart, OverSelectionEnd, OverSelection, } export const CanvasLogicalWidth = 2_000; export const CanvasLogicalHeight = 500; const emptySelection = { start: 0, end: 0 }; export const Overview: React.FC = ({ peaks, mediaSet, height, offsetPixels, position, viewport, onSelectionChange, }: Props) => { const hudCanvasRef = useRef(null); const [mode, setMode] = useState(Mode.Normal); const [hoverState, setHoverState] = useState(HoverState.Normal); const [cursor, setCursor] = useState('auto'); // selection and newSelection relate to canvas logical pixels: const [selection, setSelection] = useState({ ...emptySelection }); const [newSelection, setNewSelection] = useState({ ...emptySelection, }); const moveOffsetX = useRef(0); // side effects // handle global mouse up. useEffect(() => { window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mouseup', handleMouseUp); }; }, [mode, newSelection]); // set selection state on viewport change useEffect(() => { if (mediaSet == null) { return; } setSelection({ start: Math.round( (viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth ), end: Math.round( (viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth ), }); }, [mediaSet, viewport]); // load peaks on mediaset change useEffect(() => { (async function () { if (mediaSet == null) { return; } const canvas = hudCanvasRef.current; if (canvas == null) { console.error('no hud canvas ref available'); return; } const ctx = canvas.getContext('2d'); if (ctx == null) { console.error('no hud 2d context available'); return; } })(); }, [mediaSet]); // draw the overview HUD useEffect(() => { requestAnimationFrame(() => { const canvas = hudCanvasRef.current; if (canvas == null) { console.error('no hud canvas ref available'); return; } const ctx = canvas.getContext('2d'); if (ctx == null) { console.error('no hud 2d context available'); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); // draw selection let currentSelection: Selection; if ( mode == Mode.Selecting || mode == Mode.Dragging || mode == Mode.ResizingStart || mode == Mode.ResizingEnd ) { currentSelection = newSelection; } else { currentSelection = selection; } ctx.beginPath(); ctx.strokeStyle = 'red'; ctx.lineWidth = 4; const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13'; ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; ctx.rect( currentSelection.start, 2, currentSelection.end - currentSelection.start, canvas.height - 10 ); ctx.fill(); ctx.stroke(); // draw position marker const markerX = canvas.width * (position.percent / 100); ctx.beginPath(); ctx.moveTo(markerX, 0); ctx.lineWidth = 4; ctx.lineTo(markerX, canvas.height - 4); ctx.stroke(); }); }, [mediaSet, selection, newSelection, position]); // handlers const hoverOffset = 10; const isHoveringSelectionStart = (x: number): boolean => { return ( x > selection.start - hoverOffset && x < selection.start + hoverOffset ); }; const isHoveringSelectionEnd = (x: number): boolean => { return x > selection.end - hoverOffset && x < selection.end + hoverOffset; }; const isHoveringSelection = (x: number): boolean => { return x >= selection.start && x <= selection.end; }; const getCanvasX = (evt: MouseEvent): number => { const rect = evt.currentTarget.getBoundingClientRect(); const x = Math.round( ((evt.clientX - rect.left) / rect.width) * CanvasLogicalWidth ); return constrainXToCanvas(x); }; const constrainXToCanvas = (x: number): number => { if (x < 0) { return 0; } if (x > CanvasLogicalWidth) { return CanvasLogicalWidth; } return x; }; const handleMouseDown = (evt: MouseEvent) => { if (mode != Mode.Normal) { return; } const x = getCanvasX(evt); if (isHoveringSelectionStart(x)) { setMode(Mode.ResizingStart); moveOffsetX.current = x; return; } else if (isHoveringSelectionEnd(x)) { setMode(Mode.ResizingEnd); moveOffsetX.current = x; return; } else if (isHoveringSelection(x)) { setMode(Mode.Dragging); setCursor('pointer'); moveOffsetX.current = x; return; } setMode(Mode.Selecting); setCursor('col-resize'); moveOffsetX.current = x; setNewSelection({ start: x, end: x }); }; const handleMouseMove = (evt: MouseEvent) => { const x = getCanvasX(evt); switch (mode) { case Mode.Normal: { if (isHoveringSelectionStart(x)) { setHoverState(HoverState.OverSelectionStart); setCursor('col-resize'); } else if (isHoveringSelectionEnd(x)) { setHoverState(HoverState.OverSelectionEnd); setCursor('col-resize'); } else if (isHoveringSelection(x)) { setHoverState(HoverState.OverSelection); setCursor('pointer'); } else { setCursor('auto'); } break; } case Mode.ResizingStart: { const diff = x - moveOffsetX.current; const start = constrainXToCanvas(selection.start + diff); if (start > selection.end) { setNewSelection({ start: selection.end, end: start }); break; } setNewSelection({ ...newSelection, start: start }); break; } case Mode.ResizingEnd: { const diff = x - moveOffsetX.current; const start = constrainXToCanvas(selection.end + diff); if (start < selection.start) { setNewSelection({ start: Math.max(0, start), end: selection.start }); break; } setNewSelection({ ...newSelection, end: start }); break; } case Mode.Dragging: { const diff = x - moveOffsetX.current; const width = selection.end - selection.start; let start = Math.max(0, selection.start + diff); let end = start + width; if (end > CanvasLogicalWidth) { end = CanvasLogicalWidth; start = end - width; } setNewSelection({ start: start, end: end }); break; } case Mode.Selecting: { if (x < moveOffsetX.current) { setNewSelection({ start: x, end: moveOffsetX.current, }); } else { setNewSelection({ start: moveOffsetX.current, end: x }); } break; } } }; const handleMouseUp = () => { if (mode == Mode.Normal) { return; } setMode(Mode.Normal); setCursor('auto'); const start = Math.round( (newSelection.start / CanvasLogicalWidth) * mediaSet.audioFrames ); const end = Math.round( (newSelection.end / CanvasLogicalWidth) * mediaSet.audioFrames ); onSelectionChange({ start, end }); }; const handleMouseLeave = (_evt: MouseEvent) => { setHoverState(HoverState.Normal); }; // render component const containerStyles = { flexGrow: 0, position: 'relative', margin: `0 ${offsetPixels}px`, height: `${height}px`, } as React.CSSProperties; const hudCanvasStyles = { position: 'absolute', width: '100%', height: '100%', display: 'block', zIndex: 2, cursor: cursor, } as React.CSSProperties; return ( <>
); };