HudCanvas: extract HudCanvasState
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Rob Watson 2022-01-24 20:33:16 +01:00
parent 4f443af8fa
commit 5af8f0c319
8 changed files with 799 additions and 244 deletions

View File

@ -11,6 +11,8 @@ import { AudioFormat } from './generated/media_set';
import { VideoPreview } from './VideoPreview'; import { VideoPreview } from './VideoPreview';
import { Overview, CanvasLogicalWidth } from './Overview'; import { Overview, CanvasLogicalWidth } from './Overview';
import { Waveform } from './Waveform'; import { Waveform } from './Waveform';
import { SelectionChangeEvent } from './HudCanvas';
import { Selection, SelectionMode } from './HudCanvasState';
import { ControlBar } from './ControlBar'; import { ControlBar } from './ControlBar';
import { SeekBar } from './SeekBar'; import { SeekBar } from './SeekBar';
import { firstValueFrom, from, Observable } from 'rxjs'; import { firstValueFrom, from, Observable } from 'rxjs';
@ -107,6 +109,7 @@ function App(): JSX.Element {
// check if the end of selection has been passed, and pause if so: // check if the end of selection has been passed, and pause if so:
if ( if (
selection.start != selection.end &&
currentTimeToFrame(positionRef.current.currentTime) < selection.end && currentTimeToFrame(positionRef.current.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end currentTimeToFrame(currTime) >= selection.end
) { ) {
@ -205,7 +208,9 @@ function App(): JSX.Element {
}; };
// handler called when the selection in the overview (zoom setting) is changed. // handler called when the selection in the overview (zoom setting) is changed.
const handleOverviewSelectionChange = (newViewport: Frames) => { const handleOverviewSelectionChange = ({
selection: newViewport,
}: SelectionChangeEvent) => {
if (mediaSet == null) { if (mediaSet == null) {
return; return;
} }
@ -219,28 +224,39 @@ function App(): JSX.Element {
setPositionFromFrame(newViewport.start); setPositionFromFrame(newViewport.start);
}; };
// handler called when the selection in the main waveform view is changed. const setPositionAfterSelectionChange = (
const handleWaveformSelectionChange = ( newSelection: Selection,
newSelection: Frames, mode: SelectionMode,
final: boolean prevMode: SelectionMode
) => { ): boolean => {
if (mediaSet == null) { // if creating a new selection from scratch, reset position on mouseup.
return; if (prevMode == SelectionMode.Selecting && mode == SelectionMode.Normal) {
return true;
} }
// if re-entering normal mode, reset position if the current position is
// outside the new selection on mouseup.
if (prevMode != SelectionMode.Normal && mode == SelectionMode.Normal) {
const currFrame = currentTimeToFrame(positionRef.current.currentTime);
if (currFrame < newSelection.start || currFrame > newSelection.end) {
return true;
}
}
return false;
};
// handler called when the selection in the main waveform view is changed.
const handleWaveformSelectionChange = ({
selection: newSelection,
mode,
prevMode,
}: SelectionChangeEvent) => {
setSelection(newSelection); setSelection(newSelection);
// only update playback position when the selection is final. if (setPositionAfterSelectionChange(newSelection, mode, prevMode)) {
if (!final) { setPositionFromFrame(newSelection.start);
return;
} }
// move playback position to start of selection
const ratio = newSelection.start / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
}; };
const togglePlay = () => { const togglePlay = () => {
@ -262,9 +278,7 @@ function App(): JSX.Element {
video.pause(); video.pause();
audio.pause(); audio.pause();
if (selection.start != selection.end) { setPositionFromFrame(selection.start);
setPositionFromFrame(selection.start);
}
setPlayState(PlayState.Paused); setPlayState(PlayState.Paused);
}; };
@ -454,7 +468,10 @@ function App(): JSX.Element {
<ControlBar <ControlBar
playState={playState} playState={playState}
zoomInEnabled={canZoomViewportIn(viewport, selection, zoomFactor)} zoomInEnabled={canZoomViewportIn(viewport, selection, zoomFactor)}
zoomOutEnabled={canZoomViewportOut(viewport, mediaSet.audioFrames)} zoomOutEnabled={canZoomViewportOut(
viewport,
mediaSet.audioFrames
)}
onTogglePlay={togglePlay} onTogglePlay={togglePlay}
onClip={handleClip} onClip={handleClip}
onZoomIn={handleZoomIn} onZoomIn={handleZoomIn}

View File

@ -1,4 +1,14 @@
import { useState, useEffect, useRef, MouseEvent } from 'react'; import { useEffect, useRef, useReducer, MouseEvent } from 'react';
import {
stateReducer,
State,
SelectionMode,
HoverState,
Selection,
EmptySelectionAction,
} from './HudCanvasState';
import constrainNumeric from './helpers/constrainNumeric';
export { EmptySelectionAction } from './HudCanvasState';
interface Styles { interface Styles {
borderLineWidth: number; borderLineWidth: number;
@ -15,36 +25,39 @@ interface Props {
styles: Styles; styles: Styles;
position: number | null; position: number | null;
selection: Selection; selection: Selection;
onSelectionChange: (selection: Selection, final: boolean) => void; onSelectionChange: (selectionState: SelectionChangeEvent) => void;
} }
enum Mode { export interface SelectionChangeEvent {
Normal, selection: Selection;
Selecting, mode: SelectionMode;
Dragging, prevMode: SelectionMode;
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 }; const emptySelection: Selection = { start: 0, end: 0 };
const initialState: State = {
width: 0,
emptySelectionAction: EmptySelectionAction.SelectNothing,
hoverX: 0,
selection: emptySelection,
origSelection: emptySelection,
mousedownX: 0,
mode: SelectionMode.Normal,
prevMode: SelectionMode.Normal,
cursorClass: 'cursor-auto',
hoverState: HoverState.Normal,
shouldPublish: false,
};
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
const rect = evt.currentTarget.getBoundingClientRect();
const x = Math.round(
((evt.clientX - rect.left) / rect.width) * evt.currentTarget.width
);
return constrainNumeric(x, evt.currentTarget.width);
};
export const HudCanvas: React.FC<Props> = ({ export const HudCanvas: React.FC<Props> = ({
width, width,
height, height,
@ -57,34 +70,45 @@ export const HudCanvas: React.FC<Props> = ({
hoverPositionStrokeStyle, hoverPositionStrokeStyle,
}, },
position, position,
selection, selection: initialSelection,
onSelectionChange, onSelectionChange,
}: Props) => { }: Props) => {
// selection and newSelection are in canvas logical pixels:
const [newSelection, setNewSelection] = useState({
...emptySelection,
});
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
const [mode, setMode] = useState(Mode.Normal);
const [hoverState, setHoverState] = useState(HoverState.Normal);
const [cursor, setCursor] = useState('cursor-auto');
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const moveOffsetX = useRef(0);
const [state, dispatch] = useReducer(stateReducer, {
...initialState,
width,
emptySelectionAction,
selection: initialSelection,
});
// side effects // side effects
useEffect(() => {
dispatch({ selection: initialSelection, x: 0, type: 'setselection' });
}, [initialSelection]);
// handle global mouse up // handle global mouse up
useEffect(() => { useEffect(() => {
window.addEventListener('mouseup', handleMouseUp); window.addEventListener('mouseup', handleMouseUp);
return () => { return () => {
window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('mouseup', handleMouseUp);
}; };
}, [mode, newSelection]); }, [state]);
// trigger onSelectionChange callback.
useEffect(() => { useEffect(() => {
onSelectionChange({ ...newSelection }, mode == Mode.Normal); // really we only care if hoverX hasn't changed, not checking this will
}, [mode, newSelection]); // result in a flood of events being published.
if (!state.shouldPublish) {
return;
}
onSelectionChange({
selection: state.selection,
mode: state.mode,
prevMode: state.prevMode,
});
}, [state]);
// draw the overview HUD // draw the overview HUD
useEffect(() => { useEffect(() => {
@ -103,22 +127,13 @@ export const HudCanvas: React.FC<Props> = ({
// draw selection // draw selection
let currentSelection: Selection; const currentSelection = state.selection;
if (
mode == Mode.Selecting ||
mode == Mode.Dragging ||
mode == Mode.ResizingStart ||
mode == Mode.ResizingEnd
) {
currentSelection = newSelection;
} else {
currentSelection = selection;
}
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = borderStrokeStyle; ctx.strokeStyle = borderStrokeStyle;
ctx.lineWidth = borderLineWidth; ctx.lineWidth = borderLineWidth;
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13'; const alpha =
state.hoverState == HoverState.OverSelection ? '0.15' : '0.13';
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.rect( ctx.rect(
currentSelection.start, currentSelection.start,
@ -130,16 +145,17 @@ export const HudCanvas: React.FC<Props> = ({
ctx.stroke(); ctx.stroke();
// draw hover position // draw hover position
const hoverX = state.hoverX;
if ( if (
hoverPosition != null && hoverX != null &&
(hoverPosition < currentSelection.start || (hoverX < currentSelection.start || hoverX > currentSelection.end)
hoverPosition > currentSelection.end)
) { ) {
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = hoverPositionStrokeStyle; ctx.strokeStyle = hoverPositionStrokeStyle;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.moveTo(hoverPosition, 0); ctx.moveTo(hoverX, 0);
ctx.lineTo(hoverPosition, canvas.height); ctx.lineTo(hoverX, canvas.height);
ctx.stroke(); ctx.stroke();
} }
@ -157,174 +173,35 @@ export const HudCanvas: React.FC<Props> = ({
ctx.lineTo(position, canvas.height); ctx.lineTo(position, canvas.height);
ctx.stroke(); ctx.stroke();
}); });
}, [selection, newSelection, position, hoverPosition]); }, [state, 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>) => { const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
if (mode != Mode.Normal) { if (state.mode != SelectionMode.Normal) {
return; return;
} }
dispatch({ x: getCanvasX(evt), type: 'mousedown' });
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<HTMLCanvasElement>) => { const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
const x = getCanvasX(evt); dispatch({ x: getCanvasX(evt), type: 'mousemove' });
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 = () => { const handleMouseUp = () => {
if (mode == Mode.Normal) { if (state.mode == SelectionMode.Normal) {
return;
}
setMode(Mode.Normal);
setCursor('cursor-auto');
if (newSelection.start == newSelection.end) {
handleEmptySelectionAction();
return; return;
} }
dispatch({ x: state.hoverX, type: 'mouseup' });
}; };
const handleEmptySelectionAction = () => { const handleMouseLeave = () => {
switch (emptySelectionAction) { dispatch({ x: state.hoverX, type: 'mouseleave' });
case EmptySelectionAction.SelectPrevious:
setNewSelection({ ...selection });
break;
case EmptySelectionAction.SelectNothing:
setMode(Mode.Normal);
setNewSelection({ start: 0, end: 0 });
break;
}
};
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
setHoverState(HoverState.Normal);
setHoverPosition(0);
}; };
return ( return (
<> <>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={`block absolute w-full h-full ${cursor} z-20`} className={`block absolute w-full h-full ${state.cursorClass} z-20`}
width={width} width={width}
height={height} height={height}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}

View File

@ -0,0 +1,371 @@
import {
stateReducer,
SelectionMode,
EmptySelectionAction,
HoverState,
} from './HudCanvasState';
const initialState = {
width: 5000,
emptySelectionAction: EmptySelectionAction.SelectNothing,
hoverX: 0,
selection: { start: 0, end: 0 },
origSelection: { start: 0, end: 0 },
mousedownX: 0,
mode: SelectionMode.Normal,
prevMode: SelectionMode.Normal,
cursorClass: 'cursor-auto',
hoverState: HoverState.Normal,
shouldPublish: false,
};
describe('stateReducer', () => {
describe('setselection', () => {
it('sets the selection', () => {
const state = stateReducer(
{ ...initialState },
{ type: 'setselection', x: 0, selection: { start: 100, end: 200 } }
);
expect(state.selection).toEqual({ start: 100, end: 200 });
});
});
describe('mousedown', () => {
describe('when hovering over the selection start', () => {
it('updates the state', () => {
const state = stateReducer(
{ ...initialState, selection: { start: 1000, end: 2000 } },
{ type: 'mousedown', x: 995 }
);
expect(state.mode).toEqual(SelectionMode.ResizingStart);
expect(state.selection).toEqual({ start: 1000, end: 2000 });
expect(state.mousedownX).toEqual(995);
});
});
describe('when hovering over the selection end', () => {
it('updates the state', () => {
const state = stateReducer(
{ ...initialState, selection: { start: 1000, end: 2000 } },
{ type: 'mousedown', x: 2003 }
);
expect(state.mode).toEqual(SelectionMode.ResizingEnd);
expect(state.selection).toEqual({ start: 1000, end: 2000 });
expect(state.mousedownX).toEqual(2003);
});
});
describe('when hovering over the selection', () => {
it('updates the state', () => {
const state = stateReducer(
{ ...initialState, selection: { start: 1000, end: 2000 } },
{ type: 'mousedown', x: 1500 }
);
expect(state.mode).toEqual(SelectionMode.Dragging);
expect(state.selection).toEqual({ start: 1000, end: 2000 });
expect(state.mousedownX).toEqual(1500);
});
});
describe('when not hovering over the selection', () => {
it('updates the state', () => {
const state = stateReducer(
{ ...initialState, selection: { start: 1000, end: 2000 } },
{ type: 'mousedown', x: 3000 }
);
expect(state.mode).toEqual(SelectionMode.Selecting);
expect(state.selection).toEqual({ start: 3000, end: 3000 });
expect(state.mousedownX).toEqual(3000);
});
});
});
describe('mouseup', () => {
describe('when re-entering normal mode', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 2000 },
mode: SelectionMode.Selecting,
mousedownX: 1200,
},
{ type: 'mouseup', x: 0 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 1000, end: 2000 });
expect(state.mousedownX).toEqual(1200);
});
});
describe('nothing is selected and emptySelectionAction is SelectNothing', () => {
it('clears the selection at the mouse x coord', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 1000 },
mode: SelectionMode.Selecting,
emptySelectionAction: EmptySelectionAction.SelectNothing,
},
{ type: 'mouseup', x: 500 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 500, end: 500 });
});
});
describe('nothing is selected and emptySelectionAction is SelectPrevious', () => {
it('reverts to the previous selection', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 2000 },
mode: SelectionMode.Selecting,
emptySelectionAction: EmptySelectionAction.SelectNothing,
},
{ type: 'mouseup', x: 0 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 1000, end: 2000 });
});
});
});
describe('mouseup', () => {
it('sets the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 2000, end: 3000 },
mode: SelectionMode.Dragging,
mousedownX: 475,
},
{ type: 'mouseleave', x: 500 }
);
expect(state.mode).toEqual(SelectionMode.Dragging);
expect(state.selection).toEqual({ start: 2000, end: 3000 });
expect(state.mousedownX).toEqual(475);
});
});
describe('mousemove', () => {
describe('in normal mode', () => {
describe('when hovering over the selection start', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 3000 },
mode: SelectionMode.Normal,
},
{ type: 'mousemove', x: 997 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 1000, end: 3000 });
expect(state.hoverState).toEqual(HoverState.OverSelectionStart);
});
});
describe('when hovering over the selection end', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 3000 },
mode: SelectionMode.Normal,
},
{ type: 'mousemove', x: 3009 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 1000, end: 3000 });
expect(state.hoverState).toEqual(HoverState.OverSelectionEnd);
});
});
describe('when hovering over the selection', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 3000 },
mode: SelectionMode.Normal,
},
{ type: 'mousemove', x: 1200 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 1000, end: 3000 });
expect(state.hoverState).toEqual(HoverState.OverSelection);
});
});
describe('when hovering elsewhere', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 3000 },
mode: SelectionMode.Normal,
},
{ type: 'mousemove', x: 10 }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.selection).toEqual({ start: 1000, end: 3000 });
expect(state.hoverState).toEqual(HoverState.Normal);
});
});
});
describe('in selecting mode', () => {
describe('a normal selection', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 2000, end: 3000 },
mode: SelectionMode.Selecting,
mousedownX: 2000,
},
{ type: 'mousemove', x: 3005 }
);
expect(state.mode).toEqual(SelectionMode.Selecting);
expect(state.selection).toEqual({ start: 2000, end: 3005 });
});
});
describe('when crossing over', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 2000, end: 2002 },
mode: SelectionMode.Selecting,
mousedownX: 2000,
},
{ type: 'mousemove', x: 1995 }
);
expect(state.mode).toEqual(SelectionMode.Selecting);
expect(state.selection).toEqual({ start: 1995, end: 2000 });
});
});
});
describe('in dragging mode', () => {
describe('in the middle of the canvas', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 1500 },
origSelection: { start: 1000, end: 1500 },
mode: SelectionMode.Dragging,
mousedownX: 1200,
},
{ type: 'mousemove', x: 1220 }
);
expect(state.mode).toEqual(SelectionMode.Dragging);
expect(state.selection).toEqual({ start: 1020, end: 1520 });
});
});
describe('at the start of the canvas', () => {
it('constrains the movement and updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 10, end: 210 },
origSelection: { start: 10, end: 210 },
mode: SelectionMode.Dragging,
mousedownX: 50,
},
{ type: 'mousemove', x: 30 }
);
expect(state.mode).toEqual(SelectionMode.Dragging);
expect(state.selection).toEqual({ start: 0, end: 200 });
});
});
describe('at the end of the canvas', () => {
it('constrains the movement and updates the state', () => {
const state = stateReducer(
{
...initialState,
width: 3000,
selection: { start: 2800, end: 2900 },
origSelection: { start: 2800, end: 2900 },
mode: SelectionMode.Dragging,
mousedownX: 1200,
},
{ type: 'mousemove', x: 1350 }
);
expect(state.mode).toEqual(SelectionMode.Dragging);
expect(state.selection).toEqual({ start: 2900, end: 3000 });
});
});
});
describe('in resizing start mode', () => {
describe('a normal resize', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 2000, end: 3000 },
origSelection: { start: 2000, end: 3000 },
mode: SelectionMode.ResizingStart,
},
{ type: 'mousemove', x: 2020 }
);
expect(state.mode).toEqual(SelectionMode.ResizingStart);
expect(state.selection).toEqual({ start: 2020, end: 3000 });
});
});
describe('when crossing over', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 2000, end: 2002 },
origSelection: { start: 2000, end: 2002 },
mode: SelectionMode.ResizingStart,
},
{ type: 'mousemove', x: 2010 }
);
expect(state.mode).toEqual(SelectionMode.ResizingStart);
expect(state.selection).toEqual({ start: 2002, end: 2010 });
});
});
});
describe('in resizing end mode', () => {
describe('a normal resize', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 1000, end: 2000 },
origSelection: { start: 1000, end: 2000 },
mode: SelectionMode.ResizingEnd,
},
{ type: 'mousemove', x: 2007 }
);
expect(state.mode).toEqual(SelectionMode.ResizingEnd);
expect(state.selection).toEqual({ start: 1000, end: 2007 });
});
});
describe('when crossing over', () => {
it('updates the state', () => {
const state = stateReducer(
{
...initialState,
selection: { start: 2000, end: 2002 },
origSelection: { start: 2000, end: 2002 },
mode: SelectionMode.ResizingEnd,
},
{ type: 'mousemove', x: 1995 }
);
expect(state.mode).toEqual(SelectionMode.ResizingEnd);
expect(state.selection).toEqual({ start: 1995, end: 2000 });
});
});
});
});
});

View File

@ -0,0 +1,247 @@
import constrainNumeric from './helpers/constrainNumeric';
export enum HoverState {
Normal,
OverSelectionStart,
OverSelectionEnd,
OverSelection,
}
export enum EmptySelectionAction {
SelectNothing,
SelectPrevious,
}
export interface Selection {
start: number;
end: number;
}
export enum SelectionMode {
Normal,
Selecting,
Dragging,
ResizingStart,
ResizingEnd,
}
export interface State {
width: number;
emptySelectionAction: EmptySelectionAction;
hoverX: number;
selection: Selection;
origSelection: Selection;
mousedownX: number;
mode: SelectionMode;
prevMode: SelectionMode;
cursorClass: string;
hoverState: HoverState;
shouldPublish: boolean;
}
interface SelectionAction {
type: string;
x: number;
// TODO: selection is only used for the setselection SelectionAction. Improve
// the typing here.
selection?: Selection;
}
export const stateReducer = (
{
selection: prevSelection,
origSelection,
mousedownX: prevMousedownX,
mode: prevMode,
width,
emptySelectionAction,
}: State,
{ x, type, selection: selectionToSet }: SelectionAction
): State => {
let mode: SelectionMode;
let newSelection: Selection;
let mousedownX: number;
let cursorClass: string;
let hoverState: HoverState;
switch (type) {
case 'setselection':
newSelection = selectionToSet || { start: 0, end: 0 };
mousedownX = prevMousedownX;
mode = SelectionMode.Normal;
cursorClass = 'cursor-auto';
hoverState = HoverState.Normal;
break;
case 'mousedown':
mousedownX = x;
cursorClass = 'cursor-auto';
hoverState = HoverState.Normal;
if (isHoveringSelectionStart(x, prevSelection)) {
newSelection = prevSelection;
mode = SelectionMode.ResizingStart;
} else if (isHoveringSelectionEnd(x, prevSelection)) {
newSelection = prevSelection;
mode = SelectionMode.ResizingEnd;
} else if (isHoveringSelection(x, prevSelection)) {
newSelection = prevSelection;
mode = SelectionMode.Dragging;
cursorClass = 'cursor-move';
} else {
newSelection = { start: x, end: x };
mode = SelectionMode.Selecting;
cursorClass = 'cursor-col-resize';
}
origSelection = newSelection;
break;
case 'mouseup':
newSelection = prevSelection;
mousedownX = prevMousedownX;
mode = SelectionMode.Normal;
cursorClass = 'cursor-auto';
hoverState = HoverState.Normal;
if (
newSelection.start == newSelection.end &&
emptySelectionAction == EmptySelectionAction.SelectNothing
) {
newSelection = { start: x, end: x };
}
break;
case 'mouseleave':
newSelection = prevSelection;
mousedownX = prevMousedownX;
mode = prevMode;
cursorClass = 'cursor-auto';
hoverState = HoverState.Normal;
break;
case 'mousemove':
mousedownX = prevMousedownX;
hoverState = HoverState.Normal;
switch (prevMode) {
case SelectionMode.Normal: {
newSelection = prevSelection;
mode = SelectionMode.Normal;
if (isHoveringSelectionStart(x, prevSelection)) {
cursorClass = 'cursor-col-resize';
hoverState = HoverState.OverSelectionStart;
} else if (isHoveringSelectionEnd(x, prevSelection)) {
cursorClass = 'cursor-col-resize';
hoverState = HoverState.OverSelectionEnd;
} else if (isHoveringSelection(x, prevSelection)) {
cursorClass = 'cursor-move';
hoverState = HoverState.OverSelection;
} else {
cursorClass = 'cursor-auto';
hoverState = HoverState.Normal;
}
break;
}
case SelectionMode.Selecting: {
cursorClass = 'cursor-col-resize';
mode = SelectionMode.Selecting;
if (x < prevMousedownX) {
newSelection = {
start: x,
end: prevMousedownX,
};
} else {
newSelection = {
start: prevMousedownX,
end: x,
};
}
break;
}
case SelectionMode.Dragging: {
mode = SelectionMode.Dragging;
cursorClass = 'cursor-move';
const diff = x - prevMousedownX;
const selectionWidth = origSelection.end - origSelection.start;
let start = Math.max(0, origSelection.start + diff);
let end = start + selectionWidth;
if (end > width) {
end = width;
start = end - selectionWidth;
}
newSelection = { start: start, end: end };
break;
}
case SelectionMode.ResizingStart: {
mode = SelectionMode.ResizingStart;
cursorClass = 'cursor-col-resize';
const start = constrainNumeric(x, width);
if (start > origSelection.end) {
newSelection = { start: origSelection.end, end: start };
} else {
newSelection = { ...origSelection, start: start };
}
break;
}
case SelectionMode.ResizingEnd: {
mode = SelectionMode.ResizingEnd;
cursorClass = 'cursor-col-resize';
const end = constrainNumeric(x, width);
if (end < origSelection.start) {
newSelection = {
start: end,
end: origSelection.start,
};
} else {
newSelection = { ...origSelection, end: x };
}
break;
}
}
break;
default:
throw new Error();
}
return {
width: width,
emptySelectionAction: emptySelectionAction,
hoverX: x,
selection: newSelection,
origSelection: origSelection,
mousedownX: mousedownX,
mode: mode,
prevMode: prevMode,
cursorClass: cursorClass,
hoverState: hoverState,
shouldPublish: newSelection != prevSelection || mode != prevMode,
};
};
// helpers
const hoverOffset = 10;
const isHoveringSelectionStart = (x: number, selection: Selection): boolean => {
return x > selection.start - hoverOffset && x < selection.start + hoverOffset;
};
const isHoveringSelectionEnd = (x: number, selection: Selection): boolean => {
return x > selection.end - hoverOffset && x < selection.end + hoverOffset;
};
const isHoveringSelection = (x: number, selection: Selection): boolean => {
return x >= selection.start && x <= selection.end;
};

View File

@ -2,7 +2,12 @@ import { useState, useEffect } 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, EmptySelectionAction } from './HudCanvas'; import {
HudCanvas,
EmptySelectionAction,
SelectionChangeEvent,
} from './HudCanvas';
import { SelectionMode } from './HudCanvasState';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
export interface Selection { export interface Selection {
@ -15,7 +20,7 @@ interface Props {
mediaSet: MediaSet; mediaSet: MediaSet;
position: VideoPosition; position: VideoPosition;
viewport: Frames; viewport: Frames;
onSelectionChange: (selection: Selection) => void; onSelectionChange: (selectionState: SelectionChangeEvent) => void;
} }
export const CanvasLogicalWidth = 2_000; export const CanvasLogicalWidth = 2_000;
@ -34,6 +39,7 @@ export const Overview: React.FC<Props> = ({
// side effects // side effects
// convert viewport from frames to canvas pixels. // convert viewport from frames to canvas pixels.
// TODO: consider an adapter component to handle this.
useEffect(() => { useEffect(() => {
setSelectedPixels({ setSelectedPixels({
start: Math.round( start: Math.round(
@ -46,6 +52,7 @@ export const Overview: React.FC<Props> = ({
}, [viewport, mediaSet]); }, [viewport, mediaSet]);
// convert position from frames to canvas pixels: // convert position from frames to canvas pixels:
// TODO: consider an adapter component to handle this.
useEffect(() => { useEffect(() => {
const ratio = const ratio =
position.currentTime / (mediaSet.audioFrames / mediaSet.audioSampleRate); position.currentTime / (mediaSet.audioFrames / mediaSet.audioSampleRate);
@ -56,13 +63,23 @@ export const Overview: React.FC<Props> = ({
// handlers // handlers
// convert selection change from canvas pixels to frames, and trigger callback. // convert selection change from canvas pixels to frames, and trigger callback.
const handleSelectionChange = ({ start, end }: Selection, final: boolean) => { const handleSelectionChange = (selectionState: SelectionChangeEvent) => {
if (!final) { const {
mode,
prevMode,
selection: { start, end },
} = selectionState;
if (mode != SelectionMode.Normal || prevMode == SelectionMode.Normal) {
return; return;
} }
onSelectionChange({ onSelectionChange({
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames), ...selectionState,
end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames), selection: {
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames),
},
}); });
}; };

View File

@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import { Frames, VideoPosition, newRPC } from './App'; import { Frames, VideoPosition, newRPC } from './App';
import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set'; import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set';
import { WaveformCanvas } from './WaveformCanvas'; import { WaveformCanvas } from './WaveformCanvas';
import { Selection, HudCanvas, EmptySelectionAction } from './HudCanvas'; import { HudCanvas, SelectionChangeEvent } from './HudCanvas';
import { EmptySelectionAction, SelectionMode } from './HudCanvasState';
import { from, Observable } from 'rxjs'; import { from, Observable } from 'rxjs';
import { bufferCount } from 'rxjs/operators'; import { bufferCount } from 'rxjs/operators';
@ -10,7 +11,7 @@ interface Props {
mediaSet: MediaSet; mediaSet: MediaSet;
position: VideoPosition; position: VideoPosition;
viewport: Frames; viewport: Frames;
onSelectionChange: (selection: Selection, final: boolean) => void; onSelectionChange: (selectionState: SelectionChangeEvent) => void;
} }
export const CanvasLogicalWidth = 2000; export const CanvasLogicalWidth = 2000;
@ -43,13 +44,6 @@ export const Waveform: React.FC<Props> = ({
return; return;
} }
console.log(
'fetch audio segment, range',
viewport,
'numFrames',
viewport.end - viewport.start
);
const service = new MediaSetServiceClientImpl(newRPC()); const service = new MediaSetServiceClientImpl(newRPC());
const segment = await service.GetPeaksForSegment({ const segment = await service.GetPeaksForSegment({
id: mediaSet.id, id: mediaSet.id,
@ -91,19 +85,25 @@ export const Waveform: React.FC<Props> = ({
// handlers // handlers
const handleSelectionChange = (selection: Selection, final: boolean) => { // convert selection change from canvas pixels to frames, and trigger callback.
const handleSelectionChange = (selectionState: SelectionChangeEvent) => {
const { mode, prevMode, selection } = selectionState;
const framesPerPixel = (viewport.end - viewport.start) / CanvasLogicalWidth; const framesPerPixel = (viewport.end - viewport.start) / CanvasLogicalWidth;
const selectedFrames = { const selectedFrames = {
start: Math.round(viewport.start + selection.start * framesPerPixel), start: Math.round(viewport.start + selection.start * framesPerPixel),
end: Math.round(viewport.start + selection.end * framesPerPixel), end: Math.round(viewport.start + selection.end * framesPerPixel),
}; };
if (final) { if (mode == SelectionMode.Normal && prevMode != SelectionMode.Normal) {
setSelectedPixels(selection); setSelectedPixels(selection);
setSelectedFrames(selectedFrames); setSelectedFrames(selectedFrames);
} }
onSelectionChange(selectedFrames, final); onSelectionChange({
...selectionState,
selection: selectedFrames,
});
}; };
// helpers // helpers

View File

@ -0,0 +1,15 @@
import constrainNumeric from './constrainNumeric';
describe('constrainNumeric', () => {
it('constrains the value when it is less than 0', () => {
expect(constrainNumeric(-1, 10)).toEqual(0);
});
it('constrains the value when it is greater than max', () => {
expect(constrainNumeric(11, 10)).toEqual(10);
});
it('does not constrain an acceptable value', () => {
expect(constrainNumeric(3, 10)).toEqual(3);
});
});

View File

@ -0,0 +1,11 @@
function constrainNumeric(x: number, max: number): number {
if (x < 0) {
return 0;
}
if (x > max) {
return max;
}
return x;
}
export default constrainNumeric;