diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5893c0..d919960 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { AudioFormat } from './generated/media_set'; import { VideoPreview } from './VideoPreview'; import { Overview, CanvasLogicalWidth } from './Overview'; import { Waveform } from './Waveform'; +import { SelectionChangeEvent } from './HudCanvas'; +import { Selection, SelectionMode } from './HudCanvasState'; import { ControlBar } from './ControlBar'; import { SeekBar } from './SeekBar'; 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: if ( + selection.start != selection.end && currentTimeToFrame(positionRef.current.currentTime) < 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. - const handleOverviewSelectionChange = (newViewport: Frames) => { + const handleOverviewSelectionChange = ({ + selection: newViewport, + }: SelectionChangeEvent) => { if (mediaSet == null) { return; } @@ -219,28 +224,39 @@ function App(): JSX.Element { setPositionFromFrame(newViewport.start); }; - // handler called when the selection in the main waveform view is changed. - const handleWaveformSelectionChange = ( - newSelection: Frames, - final: boolean - ) => { - if (mediaSet == null) { - return; + const setPositionAfterSelectionChange = ( + newSelection: Selection, + mode: SelectionMode, + prevMode: SelectionMode + ): boolean => { + // if creating a new selection from scratch, reset position on mouseup. + 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); - // only update playback position when the selection is final. - if (!final) { - return; + if (setPositionAfterSelectionChange(newSelection, mode, prevMode)) { + setPositionFromFrame(newSelection.start); } - - // 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 = () => { @@ -262,9 +278,7 @@ function App(): JSX.Element { video.pause(); audio.pause(); - if (selection.start != selection.end) { - setPositionFromFrame(selection.start); - } + setPositionFromFrame(selection.start); setPlayState(PlayState.Paused); }; @@ -454,7 +468,10 @@ function App(): JSX.Element { void; + onSelectionChange: (selectionState: SelectionChangeEvent) => 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; +export interface SelectionChangeEvent { + selection: Selection; + mode: SelectionMode; + prevMode: SelectionMode; } 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): 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 = ({ width, height, @@ -57,34 +70,45 @@ export const HudCanvas: React.FC = ({ hoverPositionStrokeStyle, }, position, - selection, + selection: initialSelection, 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); + + const [state, dispatch] = useReducer(stateReducer, { + ...initialState, + width, + emptySelectionAction, + selection: initialSelection, + }); // side effects + useEffect(() => { + dispatch({ selection: initialSelection, x: 0, type: 'setselection' }); + }, [initialSelection]); + // handle global mouse up useEffect(() => { window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mouseup', handleMouseUp); }; - }, [mode, newSelection]); + }, [state]); + // trigger onSelectionChange callback. useEffect(() => { - onSelectionChange({ ...newSelection }, mode == Mode.Normal); - }, [mode, newSelection]); + // really we only care if hoverX hasn't changed, not checking this will + // 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 useEffect(() => { @@ -103,22 +127,13 @@ export const HudCanvas: React.FC = ({ // draw selection - let currentSelection: Selection; - if ( - mode == Mode.Selecting || - mode == Mode.Dragging || - mode == Mode.ResizingStart || - mode == Mode.ResizingEnd - ) { - currentSelection = newSelection; - } else { - currentSelection = selection; - } + const currentSelection = state.selection; ctx.beginPath(); ctx.strokeStyle = borderStrokeStyle; 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.rect( currentSelection.start, @@ -130,16 +145,17 @@ export const HudCanvas: React.FC = ({ ctx.stroke(); // draw hover position + + const hoverX = state.hoverX; if ( - hoverPosition != null && - (hoverPosition < currentSelection.start || - hoverPosition > currentSelection.end) + hoverX != null && + (hoverX < currentSelection.start || hoverX > currentSelection.end) ) { ctx.beginPath(); ctx.strokeStyle = hoverPositionStrokeStyle; ctx.lineWidth = 2; - ctx.moveTo(hoverPosition, 0); - ctx.lineTo(hoverPosition, canvas.height); + ctx.moveTo(hoverX, 0); + ctx.lineTo(hoverX, canvas.height); ctx.stroke(); } @@ -157,174 +173,35 @@ export const HudCanvas: React.FC = ({ 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; - }; + }, [state, position]); const handleMouseDown = (evt: MouseEvent) => { - if (mode != Mode.Normal) { + if (state.mode != SelectionMode.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 }); - } + dispatch({ x: getCanvasX(evt), type: 'mousedown' }); }; 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; - } - } + dispatch({ x: getCanvasX(evt), type: 'mousemove' }); }; const handleMouseUp = () => { - if (mode == Mode.Normal) { - return; - } - - setMode(Mode.Normal); - setCursor('cursor-auto'); - - if (newSelection.start == newSelection.end) { - handleEmptySelectionAction(); + if (state.mode == SelectionMode.Normal) { return; } + dispatch({ x: state.hoverX, type: 'mouseup' }); }; - 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); + const handleMouseLeave = () => { + dispatch({ x: state.hoverX, type: 'mouseleave' }); }; return ( <> { + 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 }); + }); + }); + }); + }); +}); diff --git a/frontend/src/HudCanvasState.ts b/frontend/src/HudCanvasState.ts new file mode 100644 index 0000000..b8e70d9 --- /dev/null +++ b/frontend/src/HudCanvasState.ts @@ -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; +}; diff --git a/frontend/src/Overview.tsx b/frontend/src/Overview.tsx index 6c4df41..d642bab 100644 --- a/frontend/src/Overview.tsx +++ b/frontend/src/Overview.tsx @@ -2,7 +2,12 @@ import { useState, useEffect } from 'react'; import { MediaSet } from './generated/media_set'; import { Frames, VideoPosition } from './App'; import { WaveformCanvas } from './WaveformCanvas'; -import { HudCanvas, EmptySelectionAction } from './HudCanvas'; +import { + HudCanvas, + EmptySelectionAction, + SelectionChangeEvent, +} from './HudCanvas'; +import { SelectionMode } from './HudCanvasState'; import { Observable } from 'rxjs'; export interface Selection { @@ -15,7 +20,7 @@ interface Props { mediaSet: MediaSet; position: VideoPosition; viewport: Frames; - onSelectionChange: (selection: Selection) => void; + onSelectionChange: (selectionState: SelectionChangeEvent) => void; } export const CanvasLogicalWidth = 2_000; @@ -34,6 +39,7 @@ export const Overview: React.FC = ({ // side effects // convert viewport from frames to canvas pixels. + // TODO: consider an adapter component to handle this. useEffect(() => { setSelectedPixels({ start: Math.round( @@ -46,6 +52,7 @@ export const Overview: React.FC = ({ }, [viewport, mediaSet]); // convert position from frames to canvas pixels: + // TODO: consider an adapter component to handle this. useEffect(() => { const ratio = position.currentTime / (mediaSet.audioFrames / mediaSet.audioSampleRate); @@ -56,13 +63,23 @@ export const Overview: React.FC = ({ // handlers // convert selection change from canvas pixels to frames, and trigger callback. - const handleSelectionChange = ({ start, end }: Selection, final: boolean) => { - if (!final) { + const handleSelectionChange = (selectionState: SelectionChangeEvent) => { + const { + mode, + prevMode, + selection: { start, end }, + } = selectionState; + + if (mode != SelectionMode.Normal || prevMode == SelectionMode.Normal) { return; } + onSelectionChange({ - start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames), - end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames), + ...selectionState, + selection: { + start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames), + end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames), + }, }); }; diff --git a/frontend/src/Waveform.tsx b/frontend/src/Waveform.tsx index 41c5178..a421cb5 100644 --- a/frontend/src/Waveform.tsx +++ b/frontend/src/Waveform.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; import { Frames, VideoPosition, newRPC } from './App'; import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set'; 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 { bufferCount } from 'rxjs/operators'; @@ -10,7 +11,7 @@ interface Props { mediaSet: MediaSet; position: VideoPosition; viewport: Frames; - onSelectionChange: (selection: Selection, final: boolean) => void; + onSelectionChange: (selectionState: SelectionChangeEvent) => void; } export const CanvasLogicalWidth = 2000; @@ -43,13 +44,6 @@ export const Waveform: React.FC = ({ return; } - console.log( - 'fetch audio segment, range', - viewport, - 'numFrames', - viewport.end - viewport.start - ); - const service = new MediaSetServiceClientImpl(newRPC()); const segment = await service.GetPeaksForSegment({ id: mediaSet.id, @@ -91,19 +85,25 @@ export const Waveform: React.FC = ({ // 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 selectedFrames = { start: Math.round(viewport.start + selection.start * framesPerPixel), end: Math.round(viewport.start + selection.end * framesPerPixel), }; - if (final) { + if (mode == SelectionMode.Normal && prevMode != SelectionMode.Normal) { setSelectedPixels(selection); setSelectedFrames(selectedFrames); } - onSelectionChange(selectedFrames, final); + onSelectionChange({ + ...selectionState, + selection: selectedFrames, + }); }; // helpers diff --git a/frontend/src/helpers/constrainNumeric.test.ts b/frontend/src/helpers/constrainNumeric.test.ts new file mode 100644 index 0000000..2a658d7 --- /dev/null +++ b/frontend/src/helpers/constrainNumeric.test.ts @@ -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); + }); +}); diff --git a/frontend/src/helpers/constrainNumeric.ts b/frontend/src/helpers/constrainNumeric.ts new file mode 100644 index 0000000..fe962eb --- /dev/null +++ b/frontend/src/helpers/constrainNumeric.ts @@ -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;