Extract reusable HudCanvas component

This commit is contained in:
Rob Watson 2021-12-06 23:52:24 +01:00
parent 468ddf4e9a
commit c849b8d2e6
3 changed files with 315 additions and 289 deletions

View File

@ -21,7 +21,7 @@ import { first, map } from 'rxjs/operators';
const thumbnailWidth = 177; const thumbnailWidth = 177;
const thumbnailHeight = 100; const thumbnailHeight = 100;
const initialViewportSeconds = 10; const initialViewportCanvasPixels = 100;
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888'; const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
@ -147,7 +147,8 @@ function App(): JSX.Element {
} }
const numFrames = Math.min( const numFrames = Math.min(
mediaSet.audioSampleRate * initialViewportSeconds, Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
initialViewportCanvasPixels,
mediaSet.audioFrames mediaSet.audioFrames
); );

289
frontend/src/HudCanvas.tsx Normal file
View File

@ -0,0 +1,289 @@
import { useState, useEffect, useRef, MouseEvent } from 'react';
import { VideoPosition } from './App';
interface Props {
width: number;
height: number;
zIndex: number;
position: VideoPosition;
selection: Selection;
onSelectionChange: (selection: Selection) => void;
}
enum Mode {
Normal,
Selecting,
Dragging,
ResizingStart,
ResizingEnd,
}
enum HoverState {
Normal,
OverSelectionStart,
OverSelectionEnd,
OverSelection,
}
export interface Selection {
start: number;
end: number;
}
const emptySelection: Selection = { start: 0, end: 0 };
export const HudCanvas: React.FC<Props> = ({
width,
height,
zIndex,
position,
selection,
onSelectionChange,
}: Props) => {
// selection and newSelection are in canvas logical pixels:
const [newSelection, setNewSelection] = useState({
...emptySelection,
});
const [mode, setMode] = useState(Mode.Normal);
const [hoverState, setHoverState] = useState(HoverState.Normal);
const [cursor, setCursor] = useState('auto');
const canvasRef = useRef<HTMLCanvasElement>(null);
const moveOffsetX = useRef(0);
// side effects
// handle global mouse up
useEffect(() => {
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mouseup', handleMouseUp);
};
}, [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 = '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();
});
}, [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<HTMLCanvasElement>): 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<HTMLCanvasElement>) => {
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('pointer');
moveOffsetX.current = x;
} else {
setMode(Mode.Selecting);
setCursor('col-resize');
moveOffsetX.current = x;
setNewSelection({ start: x, end: x });
}
};
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
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 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('auto');
if (newSelection.start == newSelection.end) {
setNewSelection({ start: 0, end: 0 });
return;
}
onSelectionChange({ ...newSelection });
};
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
setHoverState(HoverState.Normal);
};
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: zIndex,
cursor: cursor,
} as React.CSSProperties;
return (
<>
<canvas
ref={canvasRef}
width={width}
height={height}
style={canvasStyles}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
></canvas>
</>
);
};

View File

@ -1,7 +1,8 @@
import { useState, useEffect, useRef, MouseEvent } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { MediaSet } from './generated/media_set'; import { MediaSet } from './generated/media_set';
import { Frames, VideoPosition } from './App'; import { Frames, VideoPosition } from './App';
import { WaveformCanvas } from './WaveformCanvas'; import { WaveformCanvas } from './WaveformCanvas';
import { HudCanvas } from './HudCanvas';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
export interface Selection { export interface Selection {
@ -16,29 +17,12 @@ interface Props {
offsetPixels: number; offsetPixels: number;
position: VideoPosition; position: VideoPosition;
viewport: Frames; viewport: Frames;
onSelectionChange: (selection: Frames) => void; onSelectionChange: (selection: Selection) => void;
}
enum Mode {
Normal,
Selecting,
Dragging,
ResizingStart,
ResizingEnd,
}
enum HoverState {
Normal,
OverSelectionStart,
OverSelectionEnd,
OverSelection,
} }
export const CanvasLogicalWidth = 2_000; export const CanvasLogicalWidth = 2_000;
export const CanvasLogicalHeight = 500; export const CanvasLogicalHeight = 500;
const emptySelection = { start: 0, end: 0 };
export const Overview: React.FC<Props> = ({ export const Overview: React.FC<Props> = ({
peaks, peaks,
mediaSet, mediaSet,
@ -48,36 +32,13 @@ export const Overview: React.FC<Props> = ({
viewport, viewport,
onSelectionChange, onSelectionChange,
}: Props) => { }: Props) => {
const hudCanvasRef = useRef<HTMLCanvasElement>(null); const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 });
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 // side effects
// handle global mouse up. // convert viewport from frames to canvas pixels.
useEffect(() => { useEffect(() => {
window.addEventListener('mouseup', handleMouseUp); setSelectedPixels({
return () => {
window.removeEventListener('mouseup', handleMouseUp);
};
}, [mode, newSelection]);
// set selection state on viewport change
useEffect(() => {
if (mediaSet == null) {
return;
}
setSelection({
start: Math.round( start: Math.round(
(viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth (viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth
), ),
@ -85,235 +46,20 @@ export const Overview: React.FC<Props> = ({
(viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth (viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth
), ),
}); });
}, [mediaSet, viewport]); }, [viewport, mediaSet]);
// 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 // handlers
const hoverOffset = 10; // convert selection change from canvas pixels to frames, and trigger callback.
const handleSelectionChange = useCallback(
const isHoveringSelectionStart = (x: number): boolean => { ({ start, end }: Selection) => {
return ( onSelectionChange({
x > selection.start - hoverOffset && x < selection.start + hoverOffset start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
); end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames),
};
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<HTMLCanvasElement>): 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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 }); [mediaSet]
}
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<HTMLCanvasElement>) => {
setHoverState(HoverState.Normal);
};
// render component // render component
@ -324,15 +70,6 @@ export const Overview: React.FC<Props> = ({
height: `${height}px`, height: `${height}px`,
} as React.CSSProperties; } as React.CSSProperties;
const hudCanvasStyles = {
position: 'absolute',
width: '100%',
height: '100%',
display: 'block',
zIndex: 2,
cursor: cursor,
} as React.CSSProperties;
return ( return (
<> <>
<div style={containerStyles}> <div style={containerStyles}>
@ -346,15 +83,14 @@ export const Overview: React.FC<Props> = ({
zIndex={1} zIndex={1}
alpha={1} alpha={1}
></WaveformCanvas> ></WaveformCanvas>
<canvas <HudCanvas
ref={hudCanvasRef}
width={CanvasLogicalWidth} width={CanvasLogicalWidth}
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
style={hudCanvasStyles} zIndex={1}
onMouseDown={handleMouseDown} position={position}
onMouseMove={handleMouseMove} selection={selectedPixels}
onMouseLeave={handleMouseLeave} onSelectionChange={handleSelectionChange}
></canvas> />
</div> </div>
</> </>
); );