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 { 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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 { 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({
|
||||||
|
...selectionState,
|
||||||
|
selection: {
|
||||||
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
|
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||||
end: Math.round((end / 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 { 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
|
||||||
|
|
|
@ -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