326 lines
7.9 KiB
TypeScript
326 lines
7.9 KiB
TypeScript
import { useState, useEffect, useRef, MouseEvent } from 'react';
|
|
|
|
interface Styles {
|
|
borderLineWidth: number;
|
|
borderStrokeStyle: string;
|
|
positionLineWidth: number;
|
|
positionStrokeStyle: string;
|
|
}
|
|
|
|
interface Props {
|
|
width: number;
|
|
height: number;
|
|
zIndex: number;
|
|
emptySelectionAction: EmptySelectionAction;
|
|
styles: Styles;
|
|
position: number | null;
|
|
selection: Selection;
|
|
onSelectionChange: (selection: Selection) => 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;
|
|
}
|
|
|
|
const emptySelection: Selection = { start: 0, end: 0 };
|
|
|
|
export const HudCanvas: React.FC<Props> = ({
|
|
width,
|
|
height,
|
|
zIndex,
|
|
emptySelectionAction,
|
|
styles: {
|
|
borderLineWidth,
|
|
borderStrokeStyle,
|
|
positionLineWidth,
|
|
positionStrokeStyle,
|
|
},
|
|
position,
|
|
selection,
|
|
onSelectionChange,
|
|
}: Props) => {
|
|
// selection and newSelection are in canvas logical pixels:
|
|
const [newSelection, setNewSelection] = useState({
|
|
...emptySelection,
|
|
});
|
|
const [mode, setMode] = useState(Mode.Normal);
|
|
const [hoverState, setHoverState] = useState(HoverState.Normal);
|
|
const [cursor, setCursor] = useState('auto');
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const moveOffsetX = useRef(0);
|
|
|
|
// side effects
|
|
|
|
// handle global mouse up
|
|
useEffect(() => {
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
return () => {
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [mode, newSelection]);
|
|
|
|
// draw the overview HUD
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => {
|
|
const canvas = canvasRef.current;
|
|
if (canvas == null) {
|
|
console.error('no hud canvas ref available');
|
|
return;
|
|
}
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx == null) {
|
|
console.error('no hud 2d context available');
|
|
return;
|
|
}
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// draw selection
|
|
|
|
let currentSelection: Selection;
|
|
if (
|
|
mode == Mode.Selecting ||
|
|
mode == Mode.Dragging ||
|
|
mode == Mode.ResizingStart ||
|
|
mode == Mode.ResizingEnd
|
|
) {
|
|
currentSelection = newSelection;
|
|
} else {
|
|
currentSelection = selection;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = borderStrokeStyle;
|
|
ctx.lineWidth = borderLineWidth;
|
|
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
|
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
|
ctx.rect(
|
|
currentSelection.start,
|
|
borderLineWidth,
|
|
currentSelection.end - currentSelection.start,
|
|
canvas.height - borderLineWidth * 2
|
|
);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// draw position marker
|
|
|
|
if (position == null) {
|
|
return;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = positionStrokeStyle;
|
|
ctx.lineWidth = positionLineWidth;
|
|
ctx.moveTo(position, 0);
|
|
ctx.lineWidth = 4;
|
|
ctx.lineTo(position, canvas.height - 4);
|
|
ctx.stroke();
|
|
});
|
|
}, [selection, newSelection, position]);
|
|
|
|
// handlers
|
|
|
|
const hoverOffset = 10;
|
|
|
|
const isHoveringSelectionStart = (x: number): boolean => {
|
|
return (
|
|
x > selection.start - hoverOffset && x < selection.start + hoverOffset
|
|
);
|
|
};
|
|
|
|
const isHoveringSelectionEnd = (x: number): boolean => {
|
|
return x > selection.end - hoverOffset && x < selection.end + hoverOffset;
|
|
};
|
|
|
|
const isHoveringSelection = (x: number): boolean => {
|
|
return x >= selection.start && x <= selection.end;
|
|
};
|
|
|
|
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
|
|
const rect = evt.currentTarget.getBoundingClientRect();
|
|
const x = Math.round(((evt.clientX - rect.left) / rect.width) * width);
|
|
return constrainXToCanvas(x);
|
|
};
|
|
|
|
const constrainXToCanvas = (x: number): number => {
|
|
if (x < 0) {
|
|
return 0;
|
|
}
|
|
if (x > width) {
|
|
return width;
|
|
}
|
|
return x;
|
|
};
|
|
|
|
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
|
if (mode != Mode.Normal) {
|
|
return;
|
|
}
|
|
|
|
const x = getCanvasX(evt);
|
|
|
|
if (isHoveringSelectionStart(x)) {
|
|
setMode(Mode.ResizingStart);
|
|
moveOffsetX.current = x;
|
|
} else if (isHoveringSelectionEnd(x)) {
|
|
setMode(Mode.ResizingEnd);
|
|
moveOffsetX.current = x;
|
|
} else if (isHoveringSelection(x)) {
|
|
setMode(Mode.Dragging);
|
|
setCursor('pointer');
|
|
moveOffsetX.current = x;
|
|
} else {
|
|
setMode(Mode.Selecting);
|
|
setCursor('col-resize');
|
|
moveOffsetX.current = x;
|
|
setNewSelection({ start: x, end: x });
|
|
}
|
|
};
|
|
|
|
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
|
const x = getCanvasX(evt);
|
|
|
|
switch (mode) {
|
|
case Mode.Normal: {
|
|
if (isHoveringSelectionStart(x)) {
|
|
setHoverState(HoverState.OverSelectionStart);
|
|
setCursor('col-resize');
|
|
} else if (isHoveringSelectionEnd(x)) {
|
|
setHoverState(HoverState.OverSelectionEnd);
|
|
setCursor('col-resize');
|
|
} else if (isHoveringSelection(x)) {
|
|
setHoverState(HoverState.OverSelection);
|
|
setCursor('pointer');
|
|
} else {
|
|
setCursor('auto');
|
|
}
|
|
break;
|
|
}
|
|
case Mode.ResizingStart: {
|
|
const diff = x - moveOffsetX.current;
|
|
const start = constrainXToCanvas(selection.start + diff);
|
|
|
|
if (start > selection.end) {
|
|
setNewSelection({ start: selection.end, end: start });
|
|
break;
|
|
}
|
|
|
|
setNewSelection({ ...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 = () => {
|
|
if (mode == Mode.Normal) {
|
|
return;
|
|
}
|
|
|
|
setMode(Mode.Normal);
|
|
setCursor('auto');
|
|
|
|
if (newSelection.start == newSelection.end) {
|
|
handleEmptySelectionAction();
|
|
return;
|
|
}
|
|
|
|
onSelectionChange({ ...newSelection });
|
|
};
|
|
|
|
const handleEmptySelectionAction = () => {
|
|
switch (emptySelectionAction) {
|
|
case EmptySelectionAction.SelectPrevious:
|
|
setNewSelection({ ...selection });
|
|
break;
|
|
case EmptySelectionAction.SelectNothing:
|
|
onSelectionChange({ start: 0, end: 0 });
|
|
break;
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
|
|
setHoverState(HoverState.Normal);
|
|
};
|
|
|
|
const canvasStyles = {
|
|
display: 'block',
|
|
position: 'absolute',
|
|
width: '100%',
|
|
height: '100%',
|
|
zIndex: zIndex,
|
|
cursor: cursor,
|
|
} as React.CSSProperties;
|
|
|
|
return (
|
|
<>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={width}
|
|
height={height}
|
|
style={canvasStyles}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
></canvas>
|
|
</>
|
|
);
|
|
};
|