// 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 const GradientDial = ({ deg = 180, knob1Style, knob2Style, onChange, onClick, selectedKnob, }: PropsType): JSX.Element => { const containerRef = useRef(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 (
{knobDim.start && (
{ if (selectedKnob === KnobType.start) { handleMouseDown(ev); } }} onClick={() => { onClick(KnobType.start); }} role="button" style={{ ...knob1Style, ...knobDim.start, }} tabIndex={0} /> )} {knobDim.end && (
{ if (selectedKnob === KnobType.end) { handleMouseDown(ev); } }} onClick={() => { onClick(KnobType.end); }} role="button" style={{ ...knob2Style, ...knobDim.end, }} tabIndex={0} /> )} {knobDim.start && knobDim.end && (
)}
); };