// Copyright (c) 2021 Robin Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of // the Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React, { TouchEvent, PointerEvent, SyntheticEvent } from 'react'; import { Theme, withStyles, WithStyles, createStyles } from '@material-ui/core/styles'; import { PiPedalModel, PiPedalModelFactory, ZoomedControlInfo } from './PiPedalModel'; const SELECTED_OPACITY = 0.8; const DEFAULT_OPACITY = 0.6; const FINE_RANGE_SCALE = 0.1; // const ULTRA_FINE_RANGE_SCALE = 0.02; // 12000 pixels to move from 0 to 1. const MIN_ANGLE = -135; const MAX_ANGLE = 135; const DEAD_ZONE_SIZE = 5; const styles = (theme: Theme) => createStyles({ }); interface ZoomedDialProps extends WithStyles { theme: Theme, size: number, controlInfo: ZoomedControlInfo | undefined, onPreviewValue(value: number): void, onSetValue(value: number): void }; interface ZoomedDialState { }; const ZoomedDial = withStyles(styles, { withTheme: true })( class extends React.Component { model: PiPedalModel = PiPedalModelFactory.getInstance(); imgRef: React.RefObject = React.createRef(); constructor(props: ZoomedDialProps) { super(props); this.state = { }; this.onTouchStart = this.onTouchStart.bind(this); this.onTouchMove = this.onTouchMove.bind(this); this.onBodyPointerDownCapture = this.onBodyPointerDownCapture.bind(this); this.onPointerDown = this.onPointerDown.bind(this); this.onPointerUp = this.onPointerUp.bind(this); this.onPointerMove = this.onPointerMove.bind(this); this.onPointerLostCapture = this.onPointerLostCapture.bind(this); } onPointerUp(e: PointerEvent) { if (this.isCapturedPointer(e)) { --this.pointersDown; e.preventDefault(); this.updateAngle(e); this.commitAngle(); this.releaseCapture(e); } else { --this.pointersDown; } } releaseCapture(e: PointerEvent) { let img = this.imgRef.current; if (img && img.style) { img.releasePointerCapture(e.pointerId); img.style.opacity = "" + DEFAULT_OPACITY; // they get automaticlly released. // for (let i = 0; i < this.capturedPointers.length; ++i) // { // img.releasePointerCapture (this.capturedPointers[i]); // } this.capturedPointers = []; } document.body.removeEventListener( "pointerdown", this.onBodyPointerDownCapture,true ); this.mouseDown = false; } onPointerLostCapture(e: PointerEvent) { if (this.isCapturedPointer(e)) { --this.pointersDown; this.releaseCapture(e); } } onTouchStart(e: TouchEvent) { //must be defined to get onTouchMove } onTouchMove(e: TouchEvent) { // e.preventDefault(); e.stopPropagation(); // cancels scroll!!! } capturedPointers: number[] = []; isExtraTouch(e: PointerEvent): boolean { return (this.mouseDown && this.pointerType === "touch" && e.pointerType === "touch" && e.pointerId !== this.pointerId); } mouseDown: boolean = false; pointerId: number = -1; pointerType: string = ""; startX: number = 0; startY: number = 0; currentAngle: number = 0; lastPointerAngle: number = 0; isValidPointer(e: PointerEvent): boolean { if (e.pointerType === "mouse") { return e.button === 0; } else if (e.pointerType === "pen") { return true; } else if (e.pointerType === "touch") { return true; } return false; } defaultValue: number = 0; getCurrentValue(): number { if (this.props.controlInfo == null) { return this.defaultValue; } else { let uiControl = this.props.controlInfo.uiControl; let instanceId = this.props.controlInfo.instanceId; if (instanceId === -1) return 0; let pedalBoardItem = this.model.pedalBoard.get()?.getItem(instanceId); let value: number = pedalBoardItem?.getControlValue(uiControl.symbol) ?? 0; this.defaultValue = value; return value; } } angleToValue(angle: number): number { if (this.props.controlInfo) { if (angle > MAX_ANGLE) angle = MAX_ANGLE; if (angle < MIN_ANGLE) angle = MIN_ANGLE; let r = (angle - MIN_ANGLE) / (MAX_ANGLE - MIN_ANGLE); let uiControl = this.props.controlInfo.uiControl; return r * (uiControl.max_value-uiControl.min_value)+uiControl.min_value; } return 0; } valueToAngle(value: number): number { if (this.props.controlInfo) { let uiControl = this.props.controlInfo.uiControl; let range = (value - uiControl.min_value) / (uiControl.max_value - uiControl.min_value); if (range < 0) range = 0; if (range > 1) range = 1; return (MAX_ANGLE - MIN_ANGLE) * range + MIN_ANGLE; } return 0; } pointerToAngle(e: PointerEvent): number { if (this.imgRef.current) { let img = this.imgRef.current; let rc = img.getBoundingClientRect(); let x = e.clientX - (rc.left+ rc.width / 2); let y = e.clientY - (rc.top + rc.height / 2); // dead zone in center of image. if (x*x+y*y < DEAD_ZONE_SIZE*DEAD_ZONE_SIZE) return NaN; let angle = -Math.atan2(-y, x) * 180 / Math.PI + 90; return angle; } return NaN; } captureElement?: HTMLElement; pointersDown: number = 0; onPointerDown(e: PointerEvent): void { if (!this.mouseDown && this.isValidPointer(e)) { e.preventDefault(); e.stopPropagation(); this.mouseDown = true; this.pointersDown = 1; this.pointerId = e.pointerId; this.pointerType = e.pointerType; this.startX = e.clientX; this.startY = e.clientY; this.currentAngle = this.valueToAngle(this.getCurrentValue()); this.lastPointerAngle = this.pointerToAngle(e); let img = this.imgRef.current; if (img) { this.captureElement = img; document.body.addEventListener( "pointerdown", this.onBodyPointerDownCapture, true ); img.setPointerCapture(e.pointerId); if (img.style) { img.style.opacity = "" + SELECTED_OPACITY; } } } else { if (this.isExtraTouch(e)) { ++this.pointersDown; } } } isCapturedPointer(e: PointerEvent): boolean { return this.mouseDown && e.pointerId === this.pointerId && e.pointerType === this.pointerType; } onPointerMove(e: PointerEvent): void { if (this.isCapturedPointer(e)) { e.preventDefault(); this.updateAngle(e) } } commitAngle() { let previewValue = this.angleToValue(this.currentAngle); this.props.onSetValue(previewValue); this.defaultValue = previewValue; } updateAngle(e: PointerEvent): void { let angle: number = this.pointerToAngle(e); if (!isNaN(angle)) { if (!isNaN(this.lastPointerAngle)) { let dAngle = angle-this.lastPointerAngle; while (dAngle > 180) dAngle -= 360; while (dAngle < -180) dAngle += 360; let scale = 1.0; if (this.pointersDown === 2) { scale = FINE_RANGE_SCALE; } else if (this.pointersDown >= 3) { scale = ULTRA_FINE_RANGE_SCALE; } this.currentAngle += dAngle*scale; let previewValue = this.angleToValue(this.currentAngle); this.previewValue(previewValue); this.props.onPreviewValue(previewValue); } this.lastPointerAngle = angle; } } makeRotationTransform(angle: number): string { return "rotate(" + angle + "deg)"; } getDefaultRotationTransform(): string { let v = this.getCurrentValue(); let a = this.valueToAngle(v); return this.makeRotationTransform(a); } previewValue(value: number): void { if (this.imgRef.current) { let img = this.imgRef.current; let angle = this.valueToAngle(value); img.style.transform = this.makeRotationTransform(angle); } } onBodyPointerDownCapture(e_: any): any { let e = e_ as PointerEvent; if (this.isExtraTouch(e)) { this.captureElement!.setPointerCapture(e.pointerId); this.capturedPointers.push(e.pointerId); ++this.pointersDown; } } onDrag(e: SyntheticEvent) { e.preventDefault(); e.stopPropagation(); return false; } render() { return ( ); } } ); export default ZoomedDial;