HudCanvas: extract HudCanvasState
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
4f443af8fa
commit
5af8f0c319
|
@ -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);
|
||||
}
|
||||
|
||||
setPlayState(PlayState.Paused);
|
||||
};
|
||||
|
@ -454,7 +468,10 @@ function App(): JSX.Element {
|
|||
<ControlBar
|
||||
playState={playState}
|
||||
zoomInEnabled={canZoomViewportIn(viewport, selection, zoomFactor)}
|
||||
zoomOutEnabled={canZoomViewportOut(viewport, mediaSet.audioFrames)}
|
||||
zoomOutEnabled={canZoomViewportOut(
|
||||
viewport,
|
||||
mediaSet.audioFrames
|
||||
)}
|
||||
onTogglePlay={togglePlay}
|
||||
onClip={handleClip}
|
||||
onZoomIn={handleZoomIn}
|
||||
|
|
|
@ -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 {
|
||||
borderLineWidth: number;
|
||||
|
@ -15,36 +25,39 @@ interface Props {
|
|||
styles: Styles;
|
||||
position: number | null;
|
||||
selection: Selection;
|
||||
onSelectionChange: (selection: Selection, final: boolean) => 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<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> = ({
|
||||
width,
|
||||
height,
|
||||
|
@ -57,34 +70,45 @@ export const HudCanvas: React.FC<Props> = ({
|
|||
hoverPositionStrokeStyle,
|
||||
},
|
||||
position,
|
||||
selection,
|
||||
selection: initialSelection,
|
||||
onSelectionChange,
|
||||
}: 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 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<Props> = ({
|
|||
|
||||
// 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<Props> = ({
|
|||
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<Props> = ({
|
|||
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<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;
|
||||
};
|
||||
}, [state, position]);
|
||||
|
||||
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
setHoverState(HoverState.Normal);
|
||||
setHoverPosition(0);
|
||||
const handleMouseLeave = () => {
|
||||
dispatch({ x: state.hoverX, type: 'mouseleave' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
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}
|
||||
height={height}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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<Props> = ({
|
|||
// 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<Props> = ({
|
|||
}, [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<Props> = ({
|
|||
// 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({
|
||||
...selectionState,
|
||||
selection: {
|
||||
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||
end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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<Props> = ({
|
|||
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<Props> = ({
|
|||
|
||||
// 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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
Loading…
Reference in New Issue