328 lines
7 KiB
TypeScript
328 lines
7 KiB
TypeScript
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { CSSProperties, KeyboardEvent } from 'react';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import classNames from 'classnames';
|
|
|
|
export enum KnobType {
|
|
start = 'start',
|
|
end = 'end',
|
|
}
|
|
|
|
export type PropsType = {
|
|
deg?: number;
|
|
knob1Style: CSSProperties;
|
|
knob2Style: CSSProperties;
|
|
onChange: (deg: number) => unknown;
|
|
onClick: (knob: KnobType) => unknown;
|
|
selectedKnob: KnobType;
|
|
};
|
|
|
|
// Converts from degrees to radians.
|
|
function toRadians(degrees: number): number {
|
|
return (degrees * Math.PI) / 180;
|
|
}
|
|
|
|
// Converts from radians to degrees.
|
|
function toDegrees(radians: number): number {
|
|
return (radians * 180) / Math.PI;
|
|
}
|
|
|
|
type CSSPosition = { left: number; top: number };
|
|
|
|
function getKnobCoordinates(
|
|
degrees: number,
|
|
rect: ClientRect
|
|
): { start: CSSPosition; end: CSSPosition } {
|
|
const center = {
|
|
x: rect.width / 2,
|
|
y: rect.height / 2,
|
|
};
|
|
const alpha = toDegrees(Math.atan(rect.height / rect.width));
|
|
const beta = (360.0 - alpha * 4) / 4;
|
|
|
|
if (degrees < alpha) {
|
|
// Right top
|
|
const a = center.x;
|
|
const b = a * Math.tan(toRadians(degrees));
|
|
|
|
return {
|
|
start: {
|
|
left: rect.width,
|
|
top: center.y - b,
|
|
},
|
|
end: {
|
|
left: 0,
|
|
top: center.y + b,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (degrees < 90) {
|
|
// Top right
|
|
const phi = 90 - degrees;
|
|
const a = center.y;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: center.x + b,
|
|
top: 0,
|
|
},
|
|
end: {
|
|
left: center.x - b,
|
|
top: rect.height,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (degrees < 90 + beta) {
|
|
// Top left
|
|
const phi = degrees - 90;
|
|
const a = center.y;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: center.x - b,
|
|
top: 0,
|
|
},
|
|
end: {
|
|
left: center.x + b,
|
|
top: rect.height,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (degrees < 180) {
|
|
// left top
|
|
const phi = 180 - degrees;
|
|
const a = center.x;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: 0,
|
|
top: center.y - b,
|
|
},
|
|
end: {
|
|
left: rect.width,
|
|
top: center.y + b,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (degrees < 180 + alpha) {
|
|
// left bottom
|
|
const phi = degrees - 180;
|
|
const a = center.x;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: 0,
|
|
top: center.y + b,
|
|
},
|
|
end: {
|
|
left: rect.width,
|
|
top: center.y - b,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (degrees < 270) {
|
|
// bottom left
|
|
const phi = 270 - degrees;
|
|
const a = center.y;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: center.x - b,
|
|
top: rect.height,
|
|
},
|
|
end: {
|
|
left: center.x + b,
|
|
top: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (degrees < 270 + beta) {
|
|
// bottom right
|
|
const phi = degrees - 270;
|
|
const a = center.y;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: center.x + b,
|
|
top: rect.height,
|
|
},
|
|
end: {
|
|
left: center.x - b,
|
|
top: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// right bottom
|
|
const phi = 360 - degrees;
|
|
const a = center.x;
|
|
const b = a * Math.tan(toRadians(phi));
|
|
|
|
return {
|
|
start: {
|
|
left: rect.width,
|
|
top: center.y + b,
|
|
},
|
|
end: {
|
|
left: 0,
|
|
top: center.y - b,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function GradientDial({
|
|
deg = 180,
|
|
knob1Style,
|
|
knob2Style,
|
|
onChange,
|
|
onClick,
|
|
selectedKnob,
|
|
}: PropsType): JSX.Element {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const [knobDim, setKnobDim] = useState<{
|
|
start?: CSSPosition;
|
|
end?: CSSPosition;
|
|
}>({});
|
|
|
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
if (!containerRef || !containerRef.current) {
|
|
return;
|
|
}
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const center = {
|
|
x: rect.width / 2,
|
|
y: rect.height / 2,
|
|
};
|
|
|
|
const a = {
|
|
x: ev.clientX - (rect.x + center.x),
|
|
y: ev.clientY - (rect.y + center.y),
|
|
};
|
|
const b = { x: center.x, y: 0 };
|
|
const dot = a.x * b.x + a.y * b.y;
|
|
const det = a.x * b.y - a.y * b.x;
|
|
|
|
const offset = selectedKnob === KnobType.end ? 180 : 0;
|
|
const degrees = (toDegrees(Math.atan2(det, dot)) + 360 + offset) % 360;
|
|
|
|
onChange(degrees);
|
|
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
};
|
|
|
|
// We want to use React.MouseEvent here because above we
|
|
// use the regular MouseEvent
|
|
const handleMouseDown = (ev: React.MouseEvent) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
|
let add = 1;
|
|
|
|
if (ev.key === 'ArrowDown' || ev.key === 'ArrowLeft') {
|
|
add = 1;
|
|
}
|
|
|
|
if (ev.key === 'ArrowRight' || ev.key === 'ArrowUp') {
|
|
add = -1;
|
|
}
|
|
|
|
onChange(Math.min(360, Math.max(0, deg + add)));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!containerRef || !containerRef.current) {
|
|
return;
|
|
}
|
|
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
setKnobDim(getKnobCoordinates(deg, containerRect));
|
|
}, [containerRef, deg]);
|
|
|
|
return (
|
|
<div className="GradientDial__container" ref={containerRef}>
|
|
{knobDim.start && (
|
|
<div
|
|
aria-label="0"
|
|
className={classNames('GradientDial__knob', {
|
|
'GradientDial__knob--selected': selectedKnob === KnobType.start,
|
|
})}
|
|
onKeyDown={handleKeyDown}
|
|
onMouseDown={ev => {
|
|
if (selectedKnob === KnobType.start) {
|
|
handleMouseDown(ev);
|
|
}
|
|
}}
|
|
onClick={() => {
|
|
onClick(KnobType.start);
|
|
}}
|
|
role="button"
|
|
style={{
|
|
...knob1Style,
|
|
...knobDim.start,
|
|
}}
|
|
tabIndex={0}
|
|
/>
|
|
)}
|
|
{knobDim.end && (
|
|
<div
|
|
aria-label="1"
|
|
className={classNames('GradientDial__knob', {
|
|
'GradientDial__knob--selected': selectedKnob === KnobType.end,
|
|
})}
|
|
onKeyDown={handleKeyDown}
|
|
onMouseDown={ev => {
|
|
if (selectedKnob === KnobType.end) {
|
|
handleMouseDown(ev);
|
|
}
|
|
}}
|
|
onClick={() => {
|
|
onClick(KnobType.end);
|
|
}}
|
|
role="button"
|
|
style={{
|
|
...knob2Style,
|
|
...knobDim.end,
|
|
}}
|
|
tabIndex={0}
|
|
/>
|
|
)}
|
|
{knobDim.start && knobDim.end && (
|
|
<div className="GradientDial__bar--container">
|
|
<div
|
|
className="GradientDial__bar--node"
|
|
style={{
|
|
transform: `translate(-50%, -50%) rotate(${90 - deg}deg)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|