// 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 React, { MouseEvent, PointerEvent, Component } from 'react'; import { Theme } from '@mui/material/styles'; import createStyles from '@mui/styles/createStyles'; import { Property } from "csstype"; import { nullCast} from './Utility' import {isDarkMode} from './DarkMode'; const SELECT_SCALE = 1.0; const AUTOSCROLL_TICK_DELAY = 30; const AUTOSCROLL_THRESHOLD = 48; const AUTOSCROLL_SCROLL_PER_SECOND = 300; const AUTOSCROLL_SCROLL_RATE = AUTOSCROLL_SCROLL_PER_SECOND * AUTOSCROLL_TICK_DELAY / 1000; const LONG_PRESS_TIME_MS = 250; const ANIMATION_DURATION_MS = 100; class AnimationData { constructor(element: HTMLElement, originalPosition: DOMRect) { this.element = element; this.originalPosition = originalPosition; this.fromY = this.toY = this.currentY = this.originalPosition.y; this.key = (element as any)["key"]; } key: any; element: HTMLElement; originalPosition: DOMRect; fromY: number; toY: number; currentY: number; animating: boolean = false; setPosition(y: number) { this.fromY = this.toY; this.toY = y; this.animating = true; } snapToPosition() { this.fromY = this.toY; this.currentY = this.toY; this.animating = true; } } // eslint-disable-next-line @typescript-eslint/no-unused-vars const styles = (theme: Theme) => createStyles({ frame: { position: "relative", margin: "12px", background: theme.palette.background.paper } }); type Point = { x: number, y: number }; export enum ScrollDirection { None, X, Y } export interface DraggableGridProps { fullWidth?: boolean; // defaults to true fullHeight?: boolean; // defaults to true. onDragStart?: (itemIndex: number, clientX: number, clientY: number) => void; onDragMove?: (currentItemIndex: number, clientX: number, clientY: number) => void; onDragEnd?: (fromIndex: number, toIndex: number, clientX: number, clientY: number) => void; onDragCancel?: (clientX: number, clientY: number) => void; defaultSelectedIndex?: number; onLongPress?: (itemIndex: number) => void; canDrag?: boolean; children: React.ReactNode[]; moveElement: (from: number, to: number) => void; scroll: ScrollDirection; } type DraggableGridState = { indexToSelect?: number; }; const DraggableGrid = ( class extends Component { refScrollContainer: React.RefObject; refGrid: React.RefObject; constructor(props: DraggableGridProps) { super(props); this.refScrollContainer = React.createRef(); this.refGrid = React.createRef(); this.state = { indexToSelect: props.defaultSelectedIndex }; this.handlePointerDownCapture = this.handlePointerDownCapture.bind(this); this.handlePointerDown = this.handlePointerDown.bind(this); this.handlePointerCancel = this.handlePointerCancel.bind(this); this.handlePointerMove = this.handlePointerMove.bind(this); this.handlePointerUp = this.handlePointerUp.bind(this); this.onClick = this.onClick.bind(this); this.handleAutoScrollTick = this.handleAutoScrollTick.bind(this); this.animationTick = this.animationTick.bind(this); this.longPressTimerTick = this.longPressTimerTick.bind(this); this.handleTouchMove = this.handleTouchMove.bind(this); this.handleTouchEnd = this.handleTouchEnd.bind(this); } animationData: AnimationData[] = []; handleTouchMove(e: any) { if (this.dragStarted) { e.preventDefault(); } } handleTouchEnd(e: any) { if (this.dragStarted) { e.preventDefault(); } } animationStartTime: number = 0; timerHandle: number| null = null; animationTick() { let t = (new Date().getTime() - this.animationStartTime) / ANIMATION_DURATION_MS; if (t > 1) t = 1; // easing function. //t = 1-(1-t)*(1-t); let didAnimate: boolean = false; for (let i = 0; i < this.animationData.length; ++i) { let animationData = this.animationData[i]; if (animationData.animating) { if (i !== this.startIndex) { //if ((animationData.xFrom !== animationData.xTo) || (animationData.yFrom !== animationData.yTo)) let y = t * (animationData.toY - animationData.fromY) + animationData.fromY; let style = animationData.element.style; style.left = "0px"; style.top = y-animationData.originalPosition.y + "px"; animationData.currentY = y; animationData.animating = animationData.toY !== animationData.fromY; if (animationData.animating) didAnimate = true; } } } if (t < 1 && didAnimate) { this.timerHandle = requestAnimationFrame(this.animationTick) } else { this.timerHandle = null; } } startAnimation() { this.animationStartTime = new Date().getTime(); if (this.timerHandle === null) { this.timerHandle = requestAnimationFrame(this.animationTick); this.animationTick(); } } stopAnimation() { if (this.timerHandle !== null) { cancelAnimationFrame(this.timerHandle); } this.timerHandle = null; } 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; } bringSelectedItemIntoView() { if (this.state.indexToSelect && this.state.indexToSelect >= 0) { let grid = this.refGrid.current; if (grid) { if (this.state.indexToSelect >= 0 && this.state.indexToSelect < grid.childNodes.length) { let element = grid.childNodes[this.state.indexToSelect] as HTMLElement; element.scrollIntoView({ block: "nearest", inline: "nearest" }); } } } } componentDidMount() { this.bringSelectedItemIntoView(); // this.refGrid.current!.addEventListener("touchstart",(e)=> this.handleTouchStart(e),{passive: false}); } componentWillUnmount() { //this.refGrid.current!.removeEventListener("touchstart",(e) => this.handleTouchStart(e)); this.stopAnimation(); } componentDidUpdate() { if (!this.mouseDown) { this.bringSelectedItemIntoView(); } if (this.mouseDown) { this.reprepareChildren(); this.reflowChildren(); this.stopAnimation(); this.animationTick(); } } mouseDown: boolean = false; pointerId: number = 0; pointerType: string = ""; lastOnDragTime: number = 0; onClick(e: MouseEvent) { // if the click event immediately follows drag end, suppress it. let dt = new Date().getTime() - this.lastOnDragTime; if (dt < 150) { e.preventDefault(); e.stopPropagation(); } } hasClass(element: HTMLElement, className: string) { let classes = element.classList; for (let i = 0; i < classes.length; ++i) { if (classes[i] === className) return true; } return false; } findGridItem(element: any | null): HTMLDivElement | null { let grid = this.refGrid.current; if(!grid) return null; while (element !== null && element !== grid) { if (element.parentElement === grid) { return element as HTMLDivElement; } element = element.parentElement; } return null; } isCapturedPointer(e: PointerEvent): 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 = ""; savedBackground: string = ""; lastPointerDown: number = 0; ptItemStart: DOMPoint = new DOMPoint(0, 0); captureElement?: HTMLDivElement; handlePointerDownCapture(e: any) { // a new pointer down of any type cancels capture this.cancelDrag(); e.preventDefault(); e.stopPropagation(); } cancelDrag() { //window.removeEventListener("touchstart",this.handleTouchStart); window.removeEventListener("touchmove",this.handleTouchMove); window.removeEventListener("touchend",this.handleTouchEnd); if (this.originalOverscrollBehaviourY) { this.getScrollContainer()!.style.setProperty("overscroll-behaviour-y",this.originalOverscrollBehaviourY); this.originalOverscrollBehaviourY = undefined; } if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = undefined; } this.stopAnimation(); 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.dragTarget) { this.clearDragTransform(this.dragTarget); this.dragTarget = null; } this.captureElement = undefined; this.mouseDown = false; this.unprepareChildren(); document.body.removeEventListener("pointerdown", this.handlePointerDownCapture, true); } prepareChildren() { if (this.refGrid.current === null) return; let grid = this.refGrid.current; let nodes = grid.childNodes; let childRects = []; let gridClientRect = grid.getBoundingClientRect(); for (let i = 0; i < nodes.length; ++i) { let child = nodes[i] as HTMLDivElement; let rect = child.getBoundingClientRect(); rect.x -= gridClientRect.left; rect.y -= gridClientRect.top; childRects[i] = rect; } for (let i = 0; i < nodes.length; ++i) { let child = nodes[i] as HTMLDivElement; child.style.position = "relative"; child.style.left = "0px"; child.style.top = "0px"; } this.animationData = []; for (let i = 0; i < nodes.length; ++i) { this.animationData[i] = new AnimationData(nodes[i] as HTMLElement, childRects[i]); } grid.style.height = this.gridBounds.height + "px"; grid.style.width = this.gridBounds.width + "px"; } reprepareChildren() { if (this.refGrid.current === null) return; let grid = this.refGrid.current; let nodes = grid.childNodes; for (let i = 0; i < nodes.length; ++i) { let child = nodes[i] as HTMLDivElement; child.style.position = "relative"; child.style.left = "0px"; child.style.top = "0px"; } if (nodes.length !== this.animationData.length) { this.cancelDrag(); return; } for (let i = 0; i < nodes.length; ++i) { let key: any = (nodes[i] as any).key; if (nodes[i] as HTMLElement !== this.animationData[i].element) { console.log("Animation failed: Element " + i + " changed."); } if (key !== this.animationData[i].key) { console.log("Animation failed: Element " + i + " key has changed."); } this.animationData[i].element = nodes[i] as HTMLElement; if (i !== this.startIndex) { this.animationData[i].animating = true; } } grid.style.height = this.gridBounds.height + "px"; grid.style.width = this.gridBounds.width + "px"; this.animationTick(); } getIndexFromPosition(clientX: number, clientY: number) { let grid = nullCast(this.refGrid.current); let gridBounds = grid.getBoundingClientRect(); clientX -= gridBounds.left; clientY -= gridBounds.top; if (clientY < 0) return -1; for (let i = 0; i < this.animationData.length; ++i) { let item = this.animationData[i]; if (clientY >= item.originalPosition.x && clientY < item.originalPosition.bottom) { return i; } } return -1; } getDragIndexFromPosition(clientX: number, clientY: number): number { let grid = nullCast(this.refGrid.current); let originalBounds = this.animationData[this.startIndex].originalPosition; // Base test on the CENTER point of the currently dragged item. clientX += originalBounds.width / 2 - this.ptItemStart.x; clientY += originalBounds.height / 2 - this.ptItemStart.y; let gridBounds = grid.getBoundingClientRect(); clientX -= gridBounds.x; clientY -= gridBounds.top; // clientX/Y is now the position of the center of the currently-dragged item, in the coordinates of the contenst of the scroll view (scroll applied) if (clientY < 0) return 0; for (let i = 0; i < this.animationData.length; ++i) { let rect = this.animationData[i].originalPosition; if (clientY >= rect.top && clientY < rect.bottom) { return i; } } return this.animationData.length-1; } unprepareChildren() { if (this.refGrid.current === null) return; let grid = this.refGrid.current; let nodes = grid.childNodes; for (let i = 0; i < nodes.length; ++i) { let child = nodes[i] as HTMLDivElement; child.style.position = ""; child.style.top = "" child.style.left = ""; child.style.width = ""; child.style.height = ""; } grid.style.height = ""; grid.style.width = "100%"; // releast refs and storage. this.dragTarget = null; this.animationData = []; } dragTarget: HTMLDivElement | null = null; elementKeys: any[] = []; clearDragTransform(element: HTMLDivElement): void { element.style.transform = ""; element.style.zIndex = this.savedIndex; element.style.background = this.savedBackground; element.style.opacity = "1"; } handlePointerCancel(e: PointerEvent) { if (this.isCapturedPointer(e)) { this.cancelDrag(); if (this.props.onDragCancel) { this.props.onDragCancel(e.clientX, e.clientY); } } } handlePointerUp(e: PointerEvent) { if (this.isCapturedPointer(e)) { this.assertKeysNotChanged(this.elementKeys); // make sure that a late render hasn't swapped elements while we're dragging. this.handlePointerMove(e); e.preventDefault(); e.stopPropagation(); if (this.dragStarted) { this.lastOnDragTime = new Date().getTime(); if (this.props.onDragEnd) { this.props.onDragEnd( this.startIndex, this.currentIndex, e.clientX, e.clientY); } if (this.startIndex !== this.currentIndex) { this.props.moveElement(this.startIndex, this.currentIndex); } } this.cancelDrag(); } } dragThresholdExceeded(e: PointerEvent) { if (this.pointerType === "touch") { let timeMs = new Date().getTime(); let dt = timeMs -this.lastPointerDown; if (dt < LONG_PRESS_TIME_MS) return false; } let dx = e.clientX - this.startX; let dy = e.clientY - this.startY; let d2 = dx * dx + dy * dy; let DRAG_THRESHOLD = 5; if (this.pointerType === "touch") { DRAG_THRESHOLD = 15; } return (d2 > DRAG_THRESHOLD * DRAG_THRESHOLD); } 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 }; } makeTransform(targetElement: HTMLElement, clientX: number, clientY: number): string { // the bounds of the translated element must COMPLETELY fit within the scroll range of the container. let originalBounds = this.animationData[this.startIndex].originalPosition; let newLocation = new DOMRect( originalBounds.x + clientX - this.startX, originalBounds.y + clientY - this.startY, originalBounds.width, originalBounds.height); if (newLocation.x < 0) { newLocation.x = 0; } else if (newLocation.right >= this.gridBounds.width) { newLocation.x -= newLocation.right - this.gridBounds.width; } // chrome: inconsistent scroll height calculations. let maxY = this.gridBounds.height; if (newLocation.top < 0) { newLocation.y = 0; } else if (newLocation.bottom > maxY) { newLocation.y -= newLocation.bottom - maxY; } return "translate(" + (newLocation.x - originalBounds.x) + "px," + (newLocation.y - originalBounds.y) + "px) scale(" + SELECT_SCALE + ")"; } createOriginalBounds(): DOMRect { // bounds of the target element, in ScrollContainer coordinates. let rcTarget = nullCast(this.dragTarget).getBoundingClientRect(); let rcScroll = nullCast(this.getScrollContainer()).getBoundingClientRect(); rcTarget.x -= rcScroll.x; rcTarget.y -= rcScroll.y; return rcTarget; } startIndex: number = 0; currentIndex: number = 0; reflowChildren(): void { let y = 0; for (let i = 0; i < this.animationData.length; ++i) { if (i === this.startIndex) { if (i === this.currentIndex) { y += this.animationData[this.startIndex].originalPosition.height; } } else { if (i === this.currentIndex && this.currentIndex < this.startIndex) { y += this.animationData[this.startIndex].originalPosition.height; } let animationItem = this.animationData[i]; animationItem.setPosition(y); y += animationItem.originalPosition.height; if (i === this.currentIndex && this.currentIndex > this.startIndex) { y += this.animationData[this.startIndex].originalPosition.height; } } } this.stopAnimation(); this.animationStartTime = new Date().getTime(); this.animationTick(); } itemOffsetX: number = 0; itemOffsetY: number = 0; longPressTimer?: NodeJS.Timeout = undefined; longPressTimerTick() { if (navigator.vibrate) { navigator.vibrate([5]); } if (!this.dragStarted) { this.startDrag(); } } handlePointerDown(e: PointerEvent): void { if (this.props.canDrag !== undefined && !this.props.canDrag) return; // any new pointer down cancels 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; let gridElement = this.findGridItem(e.target); if (gridElement === null) return; if (!this.mouseDown && this.isValidPointer(e)) { this.dragTarget = gridElement; document.body.addEventListener("pointerdown", this.handlePointerDownCapture, true); this.captureElement = e.currentTarget; this.elementKeys = this.getElementKeys(); this.longPressTimer = setTimeout(this.longPressTimerTick,LONG_PRESS_TIME_MS); this.mouseDown = true; ///window.addEventListener("touchstart",this.handleTouchStart, {passive: false}); 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 = gridElement.style.zIndex; gridElement.style.zIndex = "3"; this.savedBackground = gridElement.style.background; gridElement.style.background = isDarkMode()? "333": "#EEE"; gridElement.style.opacity = "0.8"; } } originalOverscrollBehaviourY?: string; gridBounds: DOMRect = new DOMRect(); startDrag() { this.dragStarted = true; let grid = this.refGrid.current!; this.gridBounds = grid.getBoundingClientRect(); window.addEventListener("touchmove",this.handleTouchMove, {passive: false}); window.addEventListener("touchend",this.handleTouchEnd, {passive: false}); this.prepareChildren(); this.startIndex = this.getIndexFromPosition(this.startX, this.startY); if (this.startIndex === -1) { this.cancelDrag(); return; } let t = grid.childNodes[this.startIndex]; if (t !== this.dragTarget) { console.count("dragTarget mismatch!"); this.startIndex = this.getIndexFromPosition(this.startX,this.startY); this.dragTarget = grid.childNodes[this.startIndex] as HTMLDivElement; } this.currentIndex = this.startIndex; if (this.props.onDragStart) { this.props.onDragStart(this.startIndex, this.startX, this.startY); } this.dragTarget!.setPointerCapture(this.pointerId); let rcItem = this.dragTarget!.getBoundingClientRect(); this.ptItemStart = new DOMPoint(this.startX - rcItem.x, this.startY - rcItem.y); this.originalOverscrollBehaviourY = this.getScrollContainer()!.style.getPropertyValue("overscroll-behaviour-y"); this.getScrollContainer()!.style.setProperty("overscroll-behaviour-y","none"); if (this.pointerType === "touch") { this.startY -= 4; // causes the selected item to "pop" } } handlePointerMove(e: PointerEvent): void { if (this.isCapturedPointer(e)) { if (!this.dragStarted && this.dragThresholdExceeded(e)) { this.startDrag(); } if (this.dragStarted) { this.lastX = e.clientX; this.lastY = e.clientY; if (this.dragTarget !== null) { this.dragTarget.style.transform = this.makeTransform(this.dragTarget, e.clientX, e.clientY); let index = this.getDragIndexFromPosition(e.clientX, e.clientY); if (index !== this.currentIndex) { this.currentIndex = index; this.reflowChildren(); } } e.stopPropagation(); e.preventDefault(); if (this.props.onDragMove) { this.props.onDragMove(this.currentIndex, e.clientX, e.clientY); } this.checkForAutoScroll(e.currentTarget); } } } autoScrollTimer?: NodeJS.Timeout; getElementKeys(): any[] { let grid = nullCast(this.refGrid.current); let children = grid.childNodes; let result: any[] = []; for (let i = 0; i < children.length; ++i) { result[i] = (children[i] as any).key; } return result; } assertKeysNotChanged(oldKeys: any[]) { let newKeys = this.getElementKeys(); for (let i = 0; i < newKeys.length; ++i) { if (newKeys[i] !== oldKeys[i]) { throw new Error("Keys have changed."); } } } handleAutoScrollTick(currentTarget: HTMLElement, dx: number, dy: number) { if (!this.mouseDown) return; let scrollContainer = this.getScrollContainer(); if (scrollContainer) { let tx = scrollContainer.scrollLeft; let ty = scrollContainer.scrollTop; let x = scrollContainer.scrollLeft + dx; let y = scrollContainer.scrollTop + dy; x = Math.min(x,scrollContainer.scrollWidth-scrollContainer.clientWidth); y = Math.min(y,scrollContainer.scrollHeight-scrollContainer.clientHeight); if (x < 0) x = 0; if (y < 0) y = 0; scrollContainer.scrollTo(x, y); this.startX -= (scrollContainer.scrollLeft - tx); this.startY -= (scrollContainer.scrollTop - ty); if (this.dragTarget) { this.dragTarget.style.transform = this.makeTransform(this.dragTarget, this.lastX, this.lastY); } this.checkForAutoScroll(currentTarget); } } stopAutoScroll() { if (this.autoScrollTimer !== undefined) { clearTimeout(this.autoScrollTimer); this.autoScrollTimer = undefined; } } startAutoScroll(scrollContainer: HTMLElement, dx: number, dy: number) { this.stopAutoScroll(); this.autoScrollTimer = setTimeout( () => { this.handleAutoScrollTick(scrollContainer, dx, dy) }, AUTOSCROLL_TICK_DELAY); } 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.bottom - 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 { return this.refScrollContainer.current; } overflowX(): Property.OverflowX { return this.props.scroll === ScrollDirection.X ? "auto" : "hidden"; } overflowY(): Property.OverflowX { return this.props.scroll === ScrollDirection.Y ? "auto" : "hidden"; } render() { return (
{ e.preventDefault(); e.stopPropagation(); }} onClick={this.onClick} ref={this.refScrollContainer} >
{this.props.children}
) } } ); export default DraggableGrid;