Files
op-pedal/react/src/Draggable.tsx
T
2022-03-02 13:59:58 -05:00

502 lines
19 KiB
TypeScript

// Copyright (c) 2022 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 { MouseEvent, PointerEvent, ReactNode, Component } from 'react';
import { Theme } from '@mui/material/styles';
import { WithStyles } from '@mui/styles';
import createStyles from '@mui/styles/createStyles';
import withStyles from '@mui/styles/withStyles';
import { PiPedalStateError } from './PiPedalError';
const SELECT_SCALE = 1.5;
const AUTOSCROLL_TICK_DELAY = 30;
const AUTOSCROLL_THRESHOLD = 48;
const AUTOSCROLL_SCROLL_PER_SECOND = 100;
const AUTOSCROLL_SCROLL_RATE = AUTOSCROLL_SCROLL_PER_SECOND * AUTOSCROLL_TICK_DELAY / 1000;
const LONG_PRESS_TIME_MS = 250;
const styles = (theme: Theme) => createStyles({
frame: {
position: "relative",
margin: "12px"
}
});
type Point = { x: number, y: number };
export interface DraggableProps extends WithStyles<typeof styles> {
onDragStart?: (clientX: number, clientY: number) => void;
onDragMove?: (clientX: number, clientY: number) => void;
onDragEnd?: (clientX: number, clientY: number) => void;
onDragCancel?: (clientX: number, clientY: number) => void;
getScrollContainer: () => HTMLDivElement | null | undefined;
children?: ReactNode | ReactNode[];
draggable?: boolean;
}
type DraggableState = {
};
const Draggable =
withStyles(styles, { withTheme: true })(
class extends Component<DraggableProps, DraggableState>
{
constructor(props: DraggableProps) {
super(props);
this.onPointerDownCapture = this.onPointerDownCapture.bind(this);
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerCancel = this.onPointerCancel.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onClick = this.onClick.bind(this);
this.autoScrollTick = this.autoScrollTick.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
}
handleTouchStart(e: any) {
if (this.dragStarted) {
//e.preventDefault();
}
}
handleTouchMove(e: any) {
if (this.dragStarted) {
e.preventDefault();
}
}
handleTouchEnd(e: any) {
if (this.dragStarted) {
e.preventDefault();
}
}
isValidPointer(e: PointerEvent<HTMLDivElement>): 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;
}
mouseDown: boolean = false;
pointerId: number = 0;
pointerType: string = "";
lastOnDragTime: number = 0;
onClick(e: MouseEvent<HTMLDivElement>) {
// if the click event immediately follows drag end, suppress it.
let dt = new Date().getTime() - this.lastOnDragTime;
if (dt < 150) {
e.preventDefault();
e.stopPropagation();
}
}
componentDidMount()
{
}
componentWillUnmount()
{
}
isCapturedPointer(e: PointerEvent<HTMLDivElement>): boolean {
return this.mouseDown
&& e.pointerId === this.pointerId
&& e.pointerType === this.pointerType;
}
startX: number = 0;
startY: number = 0;
startClientX: number = 0;
startClientY: number = 0;
dragStarted: boolean = false;
savedIndex: string = "";
lastPointerDown: number = 0;
originalBounds?: DOMRect;
captureElement?: HTMLDivElement;
pointerDownTime: number = 0;
onPointerDownCapture(e: any) {
// a new pointer down of any type cancels capture
this.cancelDrag();
e.preventDefault();
e.stopPropagation();
}
longPressTimer?: NodeJS.Timeout = undefined;
longPressTimerTick() {
if (this.pointerType === "touch") {
if (navigator.vibrate) {
navigator.vibrate([10]);
}
if (!this.dragStarted) {
this.dragTarget!.style.transform = "scale(" + SELECT_SCALE + ")";
this.dragTarget!.style.zIndex = "3";
}
this.startDrag();
}
}
cancelDrag() {
window.removeEventListener("touchmove", this.handleTouchMove);
window.removeEventListener("touchend", this.handleTouchEnd);
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = undefined;
}
this.stopAutoScroll();
if (this.dragStarted && this.captureElement) {
try {
this.captureElement.releasePointerCapture(this.pointerId);
} catch (error)
{
// throws if we've already lost it.
}
}
this.dragStarted = false;
if (this.captureElement) {
this.clearDragTransform(this.captureElement);
this.captureElement = undefined;
}
this.mouseDown = false;
document.body.removeEventListener("pointerdown", this.onPointerDownCapture, true);
this.dragTarget = undefined;
}
dragTarget?: HTMLDivElement;
onPointerDown(e: PointerEvent<HTMLDivElement>): void {
if (this.props.draggable !== undefined && !this.props.draggable) return;
// any new pointer down cancles a drag in progress.
this.cancelDrag();
// avoid 2nd click of a double click (which is difficult to cancel)
let timeMs = new Date().getTime();
if (timeMs - this.lastPointerDown < 300) {
this.lastPointerDown = timeMs;
return;
}
this.lastPointerDown = timeMs;
if (!this.mouseDown && this.isValidPointer(e)) {
this.longPressTimer = setTimeout(() => this.longPressTimerTick(), LONG_PRESS_TIME_MS);
this.dragTarget = e.currentTarget as HTMLDivElement;
document.body.addEventListener("pointerdown", this.onPointerDownCapture, true);
this.captureElement = e.currentTarget;
// e.currentTarget.setPointerCapture(e.pointerId);
this.mouseDown = true;
this.dragStarted = false;
this.startX = e.clientX;
this.startY = e.clientY;
this.startClientX = e.clientX;
this.startClientY = e.clientY;
this.pointerId = e.pointerId;
this.pointerType = e.pointerType;
this.savedIndex = e.currentTarget.style.zIndex;
if (this.pointerType !== "touch") {
this.dragTarget.style.transform = "scale(" + SELECT_SCALE + ")";
this.dragTarget.style.zIndex = "3";
}
}
}
clearDragTransform(element: HTMLDivElement): void {
element.style.transform = "";
element.style.zIndex = this.savedIndex;
}
onPointerCancel(e: PointerEvent<HTMLDivElement>) {
if (this.isCapturedPointer(e)) {
this.cancelDrag();
if (this.props.onDragCancel) {
this.props.onDragCancel(e.clientX, e.clientY);
}
}
}
onPointerUp(e: PointerEvent<HTMLDivElement>) {
if (this.isCapturedPointer(e)) {
e.preventDefault();
e.stopPropagation();
if (this.dragStarted) {
this.lastOnDragTime = new Date().getTime();
if (this.props.onDragEnd) {
this.props.onDragEnd(e.clientX, e.clientY);
}
}
this.cancelDrag();
}
}
dragThresholdExceeded(e: PointerEvent<HTMLDivElement>) {
let dx = e.clientX - this.startX;
let dy = e.clientY - this.startY;
let d2 = dx * dx + dy * dy;
if (this.pointerType === "touch") {
return false; // wait for long press.
}
let DRAG_THRESHOLD = 5;
if (this.pointerType === "touch") {
DRAG_THRESHOLD = 15;
}
let result = (d2 > DRAG_THRESHOLD * DRAG_THRESHOLD);
return result;
}
lastX: number = 0;
lastY: number = 0;
translateViewPortPoint(fromElement: HTMLElement, toElement: HTMLElement, pt: Point): Point {
let rcFrom = fromElement.getBoundingClientRect();
let rcTo = toElement.getBoundingClientRect();
return {
x: pt.x + rcFrom.left + fromElement.scrollLeft - rcTo.left - toElement.scrollLeft,
y: pt.y + rcFrom.top + fromElement.scrollTop - rcTo.top - toElement.scrollTop
};
}
translateViewPortRect(fromElement: HTMLElement, toElement: HTMLElement, rect: DOMRect): DOMRect {
let rcFrom = fromElement.getBoundingClientRect();
let rcTo = toElement.getBoundingClientRect();
return new DOMRect(
rect.x + rcFrom.left - fromElement.scrollLeft - rcTo.left + toElement.scrollLeft,
rect.y + rcFrom.top - fromElement.scrollTop - rcTo.top + toElement.scrollTop,
rect.width,
rect.height);
}
makeTransform(targetElement: HTMLElement, clientX: number, clientY: number): string {
let scrollContainer = this.getScrollContainer();
if (scrollContainer && this.originalBounds) {
// the bounds of the translated element must COMPLETELY fit within the scroll range of the container.
let newLocation = new DOMRect(
this.originalBounds.x + clientX - this.startX,
this.originalBounds.y + clientY - this.startY,
this.originalBounds.width,
this.originalBounds.height);
if (newLocation.x < 0) {
clientX -= newLocation.x;
} else if (newLocation.right >= scrollContainer.scrollWidth) {
clientX -= newLocation.right - scrollContainer.scrollWidth;
}
if (newLocation.top < 0) {
clientY -= newLocation.top;
} else if (newLocation.bottom > scrollContainer.scrollHeight) {
clientY -= newLocation.bottom - scrollContainer.scrollHeight;
}
}
return "translate(" + (clientX - this.startX) + "px," + (clientY - this.startY) + "px) scale(" + SELECT_SCALE + ")";
}
createOriginalBounds() {
// bounds of the target element, in ScrollContainer coordinates.
let rcTarget = this.dragTarget!.getBoundingClientRect();
rcTarget.x = 0;
rcTarget.y = 0;
let scrollContainer = this.props.getScrollContainer();
if (!scrollContainer) {
throw new PiPedalStateError("ScrollContainer reference not valid.");
}
return this.translateViewPortRect(this.dragTarget!, scrollContainer, rcTarget);
}
startDrag() {
window.addEventListener("touchmove", this.handleTouchMove, { passive: false });
window.addEventListener("touchend", this.handleTouchEnd, { passive: false });
this.dragStarted = true;
if (this.props.onDragStart) {
this.props.onDragStart(this.startX, this.startY);
}
this.dragTarget!.setPointerCapture(this.pointerId);
this.originalBounds = this.createOriginalBounds();
}
onPointerMove(e: PointerEvent<HTMLDivElement>): void {
if (this.isCapturedPointer(e)) {
if (!this.dragStarted && this.dragThresholdExceeded(e)) {
this.startDrag();
}
if (this.dragStarted) {
this.lastX = e.clientX;
this.lastY = e.clientY;
e.currentTarget.style.transform = this.makeTransform(e.currentTarget, e.clientX, e.clientY);
e.stopPropagation();
e.preventDefault();
if (this.props.onDragMove) {
this.props.onDragMove(e.clientX, e.clientY);
}
this.checkForAutoScroll(e.currentTarget);
}
}
}
autoScrollTimer?: number;
autoScrollTick(currentTarget: HTMLElement, dx: number, dy: number) {
if (!this.mouseDown) return;
let scrollContainer = this.getScrollContainer();
if (scrollContainer) {
let tx = scrollContainer.scrollLeft;
let ty = scrollContainer.scrollTop;
scrollContainer.scrollBy(dx, dy);
this.startX -= (scrollContainer.scrollLeft - tx);
this.startY -= (scrollContainer.scrollTop - ty);
currentTarget.style.transform = this.makeTransform(currentTarget, this.lastX, this.lastY);
this.checkForAutoScroll(currentTarget);
}
}
stopAutoScroll() {
if (this.autoScrollTimer) {
cancelAnimationFrame(this.autoScrollTimer);
this.autoScrollTimer = undefined;
}
}
startAutoScroll(scrollContainer: HTMLElement, dx: number, dy: number) {
this.stopAutoScroll();
this.autoScrollTimer = requestAnimationFrame(
() => { this.autoScrollTick(scrollContainer, dx, dy) });
}
checkForAutoScroll(currentTarget: HTMLElement) {
let scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
this.stopAutoScroll();
return;
}
let scrollContainerRect = scrollContainer.getBoundingClientRect();
let dy: number = 0;
let dx: number = 0;
if (this.lastX < scrollContainerRect.x + AUTOSCROLL_THRESHOLD) {
dx = -AUTOSCROLL_SCROLL_RATE;
if (dx < -scrollContainer.scrollLeft) {
dx = -scrollContainer.scrollLeft;
}
} else if (this.lastX >= scrollContainerRect.right - AUTOSCROLL_THRESHOLD) {
dx = AUTOSCROLL_SCROLL_RATE;
let maxScroll = Math.max(scrollContainer.scrollWidth - scrollContainer.clientWidth, 0);
if (dx > maxScroll) dx = maxScroll;
}
if (this.lastY < scrollContainerRect.top) {
dy = -AUTOSCROLL_SCROLL_RATE;
if (dy < -scrollContainer.scrollTop) dy = -scrollContainer.scrollTop;
} else if (this.lastY >= scrollContainerRect.top - AUTOSCROLL_THRESHOLD) {
dy = AUTOSCROLL_SCROLL_RATE;
let maxScroll = Math.max(scrollContainer.scrollHeight - scrollContainer.clientHeight);
if (dy > maxScroll) dy = maxScroll;
}
if (dx === 0 && dy === 0) {
this.stopAutoScroll();
return;
} else {
this.startAutoScroll(currentTarget, dx, dy);
}
}
getScrollContainer(): HTMLDivElement | null {
if (this.props.getScrollContainer) {
let t = this.props.getScrollContainer();
if (!t) return null;
return t;
}
return null;
}
render() {
return (
<div style={{ transform: "" }}
onPointerDown={this.onPointerDown}
onPointerMove={this.onPointerMove}
onPointerCancel={this.onPointerCancel}
onPointerCancelCapture={this.onPointerCancel}
onPointerUp={this.onPointerUp}
onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={this.onClick}
>
{
this.props.children
}
</div>
)
}
}
);
export default Draggable;