import { useState, useEffect, useRef, MouseEvent } from 'react'; interface Styles { borderLineWidth: number; borderStrokeStyle: string; positionLineWidth: number; positionStrokeStyle: string; hoverPositionStrokeStyle: string; } interface Props { width: number; height: number; emptySelectionAction: EmptySelectionAction; styles: Styles; position: number | null; selection: Selection; onSelectionChange: (selection: Selection, final: boolean) => void; } enum Mode { Normal, Selecting, Dragging, ResizingStart, ResizingEnd, } enum HoverState { Normal, OverSelectionStart, OverSelectionEnd, OverSelection, } export enum EmptySelectionAction { SelectNothing, SelectPrevious, } export interface Selection { start: number; end: number; } const emptySelection: Selection = { start: 0, end: 0 }; export const HudCanvas: React.FC = ({ width, height, emptySelectionAction, styles: { borderLineWidth, borderStrokeStyle, positionLineWidth, positionStrokeStyle, hoverPositionStrokeStyle, }, position, selection, onSelectionChange, }: Props) => { // selection and newSelection are in canvas logical pixels: const [newSelection, setNewSelection] = useState({ ...emptySelection, }); const [hoverPosition, setHoverPosition] = useState(null); const [mode, setMode] = useState(Mode.Normal); const [hoverState, setHoverState] = useState(HoverState.Normal); const [cursor, setCursor] = useState('cursor-auto'); const canvasRef = useRef(null); const moveOffsetX = useRef(0); // side effects // handle global mouse up useEffect(() => { window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mouseup', handleMouseUp); }; }, [mode, newSelection]); useEffect(() => { onSelectionChange({ ...newSelection }, mode == Mode.Normal); }, [mode, newSelection]); // draw the overview HUD useEffect(() => { requestAnimationFrame(() => { const canvas = canvasRef.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 = borderStrokeStyle; ctx.lineWidth = borderLineWidth; const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13'; ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; ctx.rect( currentSelection.start, borderLineWidth, currentSelection.end - currentSelection.start, canvas.height - borderLineWidth * 2 ); ctx.fill(); ctx.stroke(); // draw hover position if ( hoverPosition != null && (hoverPosition < currentSelection.start || hoverPosition > currentSelection.end) ) { ctx.beginPath(); ctx.strokeStyle = hoverPositionStrokeStyle; ctx.lineWidth = 2; ctx.moveTo(hoverPosition, 0); ctx.lineTo(hoverPosition, canvas.height); ctx.stroke(); } // draw position marker if (position == null) { return; } ctx.beginPath(); ctx.strokeStyle = positionStrokeStyle; ctx.lineWidth = positionLineWidth; ctx.moveTo(position, 0); ctx.lineWidth = position == 0 ? 8 : 4; ctx.lineTo(position, canvas.height); ctx.stroke(); }); }, [selection, newSelection, position, hoverPosition]); // 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) * width); return constrainXToCanvas(x); }; const constrainXToCanvas = (x: number): number => { if (x < 0) { return 0; } if (x > width) { return width; } return x; }; const handleMouseDown = (evt: MouseEvent) => { if (mode != Mode.Normal) { return; } const x = getCanvasX(evt); if (isHoveringSelectionStart(x)) { setMode(Mode.ResizingStart); moveOffsetX.current = x; } else if (isHoveringSelectionEnd(x)) { setMode(Mode.ResizingEnd); moveOffsetX.current = x; } else if (isHoveringSelection(x)) { setMode(Mode.Dragging); setCursor('cursor-move'); moveOffsetX.current = x; } else { setMode(Mode.Selecting); setCursor('cursor-col-resize'); moveOffsetX.current = x; setNewSelection({ start: x, end: x }); } }; const handleMouseMove = (evt: MouseEvent) => { const x = getCanvasX(evt); setHoverPosition(x); switch (mode) { case Mode.Normal: { if (isHoveringSelectionStart(x)) { setHoverState(HoverState.OverSelectionStart); setCursor('cursor-col-resize'); } else if (isHoveringSelectionEnd(x)) { setHoverState(HoverState.OverSelectionEnd); setCursor('cursor-col-resize'); } else if (isHoveringSelection(x)) { setHoverState(HoverState.OverSelection); setCursor('cursor-move'); } else { setCursor('cursor-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({ ...selection, start: start }); break; } case Mode.ResizingEnd: { const diff = x - moveOffsetX.current; const end = constrainXToCanvas(selection.end + diff); if (end < selection.start) { setNewSelection({ start: Math.max(0, end), end: selection.start }); break; } setNewSelection({ ...selection, end: end }); break; } case Mode.Dragging: { const diff = x - moveOffsetX.current; const selectionWidth = selection.end - selection.start; let start = Math.max(0, selection.start + diff); let end = start + selectionWidth; if (end > width) { end = width; start = end - selectionWidth; } 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('cursor-auto'); if (newSelection.start == newSelection.end) { handleEmptySelectionAction(); return; } }; const handleEmptySelectionAction = () => { switch (emptySelectionAction) { case EmptySelectionAction.SelectPrevious: setNewSelection({ ...selection }); break; case EmptySelectionAction.SelectNothing: setMode(Mode.Normal); setNewSelection({ start: 0, end: 0 }); break; } }; const handleMouseLeave = (_evt: MouseEvent) => { setHoverState(HoverState.Normal); setHoverPosition(0); }; return ( <> ); };