How to account for page scrolling with react-DND drop events? - reactjs

I have a react DND item on a 'board' which triggers a page scroll if you pull it far up/left/right/down from its original position.
The scrolling during hover is working well, but I have an issue where when I drop the item, it only drops relative to the cursor's position on the screen. It does not account for the additional movement from the auto scrolling. (e.g. if you pull it to the right of the screen and scroll all the way to the right of the board, when you drop the item it will only drop a few hundred pixels to the right, as if no scrolling had occurred).
I have tried to cater for this by adding a window scroll event listener and adding the distance scrolled onto the drop position. BUT, the state in the DOM is using the old state from when the item was picked up and not adding any of the scrolling to the final coordinates of the page.
Am I going about this all wrong?
I have tried some packages like react-dnd-scrollzone, but it looks like they are no longer supported with newer versions of react.
Can anyone provide some insight on how you can account for window scrolling with react DND drop events?
The code I have below ALMOST works, except the item drop only accepts the x and y adjustments the next time AFTER a drop event has been completed, it cannot take the updated state on item drop (it is working on the old state), so the next time you drop an item it moves to the correct position factoring in the scrolling from your last drop. It is always one item drop behind the current state because x and y adjustment state isn't pulled into the DOM until after a drop event is complete....
Here is my code for an item drop event and for page scrolling and the scroll window event listener.
DND Item Drop and Hover Event. The originating scroll coordinates are set on the first instance of 'hover'
const [dragStarted, setDragStarted] = useState(false);
const [origXScroll, setOrigXScroll] = useState(0);
const [origYScroll, setOrigYScroll] = useState(0);
const [xAdjustment, setXAdjustment] = useState(0);
const [yAdjustment, setYAdjustment] = useState(0);
const updatePageState = useCallback((droppedPage) => {
const updatedPages = pages.map(page => droppedPage._id === page._id ? droppedPage : page);
changedPagesCallback(updatedPages);
setTreePages(updatedPages);
}, [pages]);
const [{}, drop] = useDrop(() => ({
accept: ItemTypes.PAGECARD,
hover(page, monitor) {
if(!dragStarted) {
setDragStarted(true)
setOrigXScroll(xAdjustment)
setOrigYScroll(yAdjustment)};
const clientOffset = monitor.getClientOffset();
const origPosition = monitor.getInitialClientOffset();
const origX = origPosition.x;
const origY = origPosition.y;
const hoverY = clientOffset.y;
const hoverX = clientOffset.x;
checkPageScroll(origX, origY, hoverX, hoverY);
},
drop(page, monitor) {
setDragStarted(false);
const delta = monitor.getDifferenceFromInitialOffset();
//The issue lies in that difference from initial offset only gives mouse position on the screen and doesn't account for any auto scrolling on the page...
const xAdjuster = xAdjustment < origXScroll ? (xAdjustment - origXScroll) : xAdjustment;
const yAdjuster = yAdjustment < origYScroll ? (yAdjustment - origYScroll) : yAdjustment;
let x = Math.round(page.x + (delta.x)*(1/pageZoomFactor) + xAdjuster);
let y = Math.round(page.y + (delta.y) + yAdjuster);
page = Object.assign(page, {x: x, y: y})
saveUpdatedPage({x: x, y:y}, page._id);
updatePageState(page);
setOrigXScroll(0);
setOrigYScroll(0);
setXAdjustment(0);
setYAdjustment(0);
return undefined;
},
}), [updatePageState]);
Auto Scroller if you pull item far to any side
function checkPageScroll(origX, origY, hoverX, hoverY){
const xScroll = (hoverX - origX)/10;
const yScroll = (hoverY - origY)/10;
const allowedXScroll = xScroll > 8 || xScroll < -8 ? xScroll : 0;
const allowedYScroll = yScroll > 8 || yScroll < -8 ? yScroll : 0;
window.scrollBy(allowedXScroll, allowedYScroll);
}
Window Event Listener to Set the Amount you Have Scrolled. At the start of drag the scroll coordinates are locked as origXScroll and origYScroll, the idea is to add/subtract distance travelled by scrolling left/right up/down on the drop event
useEffect(() => {
window.addEventListener('scroll', () => {
const yScroll = window.scrollY;
const xScroll = window.scrollX;
setXAdjustment(xScroll);
setYAdjustment(yScroll);
})
}, [])

OK, after a mountain of digging I came across the following forum posts in the react dnd issues and discovered a few more tools to get this done. https://github.com/react-dnd/react-dnd/issues/151
Instead of measuring the amount the page has scrolled and adding that to the final coordinates, a better method is to use getInitialSourceClientOffset() and getSourceClientOffset() (both of which I had no idea existed....) to compare the items start and end location.
To do this you will also need a referenced element to compare your items position (can just be a referenced div).
This method still needs a bit of tweaking to account for page zoom, but it is the best I've seen so far.
I'm still working on this, but here's a simplified version of the code for dropping new pages onto the board to see how it could potentially all be put together. I'd recommend using the solutions on the react DND issues link above and reading their posts to get a good idea on how the professionals manage this problem.
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useDrop } from "react-dnd";
import { ItemTypes } from "../Utils/items";
function PageTree({ pages }) {
const containerRef = useRef("pageBoard");
const [dragStarted, setDragStarted] = useState(false);
//this function to update the state of a page card when a change is made to its position or a widget added
const updatePageState = useCallback((droppedPage) => {
const updatedPages = pages.map(page => droppedPage._id === page._id ? droppedPage : page);
changedPagesCallback(updatedPages);
setTreePages(updatedPages);
}, [pages]);
const [{updateY, updateX}, drop] = useDrop(() => ({
accept: ItemTypes.PAGECARD,
hover(page, monitor) {
if(!dragStarted) {
setDragStarted(true)
setOrigXScroll(xAdjustment)
setOrigYScroll(yAdjustment)
};
const clientOffset = monitor.getClientOffset();
const origPosition = monitor.getInitialClientOffset();
const origX = origPosition.x;
const origY = origPosition.y;
const hoverY = clientOffset.y;
const hoverX = clientOffset.x;
checkPageScroll(origX, origY, hoverX, hoverY);
},
drop(page, monitor) {
setDragStarted(false);
const initialPosition = monitor.getInitialSourceClientOffset();
const finalPosition = monitor.getSourceClientOffset();
const container = containerRef.current.getBoundingClientRect();
const newXY = getCorrectDroppedOffsetValue(initialPosition,finalPosition,container);
page = Object.assign(page, {x: newXY.x, y: newXY.y})
saveUpdatedPage({x: newXY.x, y:newXY.y}, page._id);
updatePageState(page);
return undefined;
},
}), [updatePageState]);
useEffect(() => {
OriginalScrollFunc.current = scrollTo
}, [pages]);
function checkPageScroll(origX, origY, hoverX, hoverY){
const xScroll = (hoverX - origX)/10;
const yScroll = (hoverY - origY)/10;
const allowedXScroll = xScroll > 8 || xScroll < -8 ? xScroll : 0;
const allowedYScroll = yScroll > 8 || yScroll < -8 ? yScroll : 0;
window.scrollBy(allowedXScroll, allowedYScroll);
}
function getCorrectDroppedOffsetValue (initialPosition, finalPosition, dropTargetPosition) {
// get the container (view port) position by react ref...
//const dropTargetPosition = ref.current.getBoundingClientRect();
const { y: finalY, x: finalX } = finalPosition;
const { y: initialY, x: initialX } = initialPosition;
// calculate the correct position removing the viewport position.
// finalY > initialY, I'm dragging down, otherwise, dragging up
const newYposition =
finalY > initialY
? initialY + (finalY - initialY) - dropTargetPosition.top
: initialY - (initialY - finalY) - dropTargetPosition.top;
const newXposition =
finalX > initialX
? initialX + (finalX - initialX) - dropTargetPosition.left
: initialX - (initialX - finalX) - dropTargetPosition.left;
return {
x: newXposition,
y: newYposition,
};
};
}
return (
<div ref={containerRef} style={{postion:'relative'}}>
<div ref={drop} style={styles}>
{items.map = > things you drop around}
</div>
</div>
)
}
export default PageTree;

Related

implmenting Scorll postion in reactwithout rerending the whole component?

I want to be able to listen to scroll event to get the current value, and if the value reaches a certain threshold render a div the current logic works well with useState but it is rendering every render.
useRef however doesn't seem do be doing what is should, is there any solution to this ? will callback solve this ? if possible could you refactor to a better logic.
const scrollRef = useRef<number>(0);
useEffect(() => {
const listenToScroll = () => {
const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
const scrolled = winScroll / height;
scrollRef.current = scrolled;
};
const fn = window.addEventListener('scroll', listenToScroll);
return fn;
}, []);

IonContent will not trigger scroll events if assigned a ref

I encountered the strangest problem today. My code (much has been stripped out) consists of basically the following:
const Element: React.FC<any> = (props: any) => {
const scrollRef = React.useRef<any>(null);
.
.
.
const rescrollTrigger = () => {
setTimeout(() => { /* Retry for 10 seconds to scroll the content to match the stored value, unless the user has scrolled since then */
const cb = (tries: number) => {
if (tries > 100)
return;
if (!scrollRef.current) {
setTimeout(cb, 100, [Number(tries) + 1]);
return;
}
scrollRef.current.getScrollElement().then((element: any) => {
var scrollHeight: any = window.sessionStorage.getItem(thisPath + "/scrollheight");
var scrollTop: any = window.sessionStorage.getItem(thisPath + "/scrolltop");
if (!scrollHeight || !scrollTop)
return;
scrollHeight = +scrollHeight;
scrollTop = +scrollTop;
if (element.scrollHeight == scrollHeight)
scrollRef.current.scrollToPoint(0, scrollTop);
else
setTimeout(cb, 100, [Number(tries) + 1]);
}).catch(() => {
setTimeout(cb, 100, [Number(tries) + 1]);
})
};
window.requestAnimationFrame(() => {
cb(1);
});
});
};
const saveScrollPosition = () => {
console.log("saveScrollPosition", scrollRef.current);
try {
scrollRef.current.getScrollElement().then((element: any) => {
console.log("Saving scroll position");
window.sessionStorage.setItem(thisPath + "/scrolltop", String(element.scrollTop));
window.sessionStorage.setItem(thisPath + "/scrollheight", String(element.scrollHeight));
});
} catch (err) {console.log("Error saving scroll position", err)}
};
return (
<IonPage>
<IonContent fullscreen ref={scrollRef} scrollEvents={true} onIonScrollEnd={e => saveScrollPosition()}>
.
.
.
}
I'm just trying to create a ref (in a hook) to the content instance so I can save and reset the scroll position as needed.
This code was working fine, but for some reason stopped triggering any scroll events if a ref is set on the content element. I've tried onIonScroll and onIonScrollEnd. Neither work if a ref is set. Removing the ref={scrollRef} starts triggering the scroll events. I'm using Ionic 6.12.2 and just recently upgraded from a prior version (I don't know what it was).
So my question is: Is this a bug in Ionic? Or if not, what should be done to fix the code? I've tried not setting a ref and just using the target returned from the scroll event, which allows me to save the position, but it doesn't preserve a reference to be able to reset the scroll position later.
This looks like an Ionic bug. Ionic 6.12.3 allows the event to fire with a ref and the behavior is not reproducible.

React State Updating after code requiring state change runs

I'm trying to create a grid of Nodes, which update when clicking/dragging on them. I'm running into a weird React State where the state is updating after the code following it runs.
Expected behaviour:
nodeTypePointer is set to 0 as default react state
Click on Blank Node, nodeTypePointer is set to 0 (unchanged) + logic uses 0
Click on Coloured Node, nodeTypePointer is set to 1 + logic uses 1
Actual behaviour
nodeTypePointer is set to 0 as default react state
Click on Blank Node, nodeTypePointer remains unchanged + logic uses 0
Click on Coloured Node for the first time, nodeTypePointer is set to 0 (unchanged) + logic uses 0, then after code is finished nodeTypePointer is set to 1
Click on Coloured Node for the second time, nodeTypePointer is now still set to 1, logic uses 1, then after code is finished nodeTypePointer is set to 0
I know that the state is async but unsure about the exact reason/problem/solution to go on. Unsure about how to pass parameters to the next functions if I were to use a UseEffect.
Here is the code:
import React, { useEffect, useState } from 'react';
import Node from './Node'
import './Grid.css';
const rectDiameter = 25
const gridHeightRatio = 0.9
const Grid: React.FC = () => {
const gridHeight: number = Math.floor(window.innerHeight * gridHeightRatio)
const gridWidth: number = window.innerWidth
const numX: number = Math.floor(gridHeight / rectDiameter)
const numY: number = Math.floor(gridWidth / rectDiameter)
const tempGrid: number[][] = [...Array(numX)].map(() => Array(numY).fill(0));
const [grid, setGrid] = useState(tempGrid)
const [isMousePressed, setMousePressed] = useState(false)
const [nodeTypePointer, setNodeTypePointer] = useState(0)
useEffect(() => {
console.log('Use Effect', nodeTypePointer)
}, [nodeTypePointer])
// Hacky el.ID to workaround React performance until I know how to do it better
const paintNode = (x: number, y: number) => {
const el = document.getElementById(`${x}-${y}`)
if (!el) return
if (nodeTypePointer === 0) {
el.classList.add("node-wall")
} else {
el.classList.remove("node-wall")
}
}
const updateGridPosition = (x: number, y: number) => {
const newValue = nodeTypePointer === 0 ? 1 : 0
let newGrid: number[][] = grid
newGrid[x][y] = newValue
setGrid(newGrid)
}
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>, x: number, y: number) => {
console.log('Node pointer type Before', nodeTypePointer)
setNodeTypePointer(grid[x][y])
console.log('Node pointer type After', nodeTypePointer)
setMousePressed(true)
console.log('Before updated Grid', grid[x][y])
updateGridPosition(x, y)
console.log('After updated Grid', grid[x][y])
paintNode(x, y) // <------------- Uses nodeTypePointer
}
const handleMouseUp = (event: React.MouseEvent<HTMLDivElement>, x: number, y: number) => {}
const handleHover = (event: React.MouseEvent<HTMLDivElement>, x: number, y: number) => {
if (!isMousePressed) return
updateGridPosition(x, y)
paintNode(x, y)
}
return (
<div className='gridContainer' >
{
grid.map((row: number[], i: number) =>
row.map((val: number, j: number) =>
<Node
x={i}
y={j}
d={rectDiameter}
key={`${i}-${j}`}
state={val}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onHover={handleHover} />
)
)
}
</div>
);
}
export default Grid
Any help on understanding the problem and suggested solutions would be greatly appreciated!
State changes are asynchronous (both component ones and hook ones). So your state hasn't update by the time you call paintNode. There's 2 ways I can think of around it. One is to put the call to paintNode in your nodeTypePointer's useEffect, like so:
useEffect(() => {
paintNode(x,y);
}, [nodeTypePointer])
but this means you'll need to store the x and y of clicked one in some standard variables, since you won't be able to pass them directly to useEffect. Another way (and maybe a better one, since I'm not a huge fan of state changes having side effects) is to just pass the x/y directly into paintNode during the handleMouseDown function:
paintNode(x, y, grid[x][y])
however it looks like updateGridPosition also needs the x/y, so maybe you would be better off storing the raw clicked one in state, then having a useEffect for the clicked one that does everything needed once a node is clicked. All depends on how this function is gonna end up looking.
But yeah, the reason for your bug is cause state changes are async so it hasn't updated by the time you call paintNode

Always getting initial value of other local state with React Hooks

So I've been trying out React Hooks for a change, but there's something I haven't been able to understand, and it's using multiple states in a functional component and interacting between them. Say I want to get the percentage of the window's scroll position and its height, & then display it like so:
import React, { useState, useLayoutEffect } from 'react'
const Page = () => {
const [scrollPos, setScrollPos] = useState(window.pageYOffset || document.documentElement.scrollTop)
const [windowSize, setWindowSize] = useState(window.innerHeight)
const [percent, setPercent] = useState(0)
useLayoutEffect(() => {
const handleScroll = () => {
const y = window.pageYOffset || document.documentElement.scrollTop
setScrollPos(y)
// windowSize is always its initial value, not its latest
console.log("handleScroll: " + y + " / " + windowSize + " = " + y / windowSize)
setPercent(y / windowSize)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
useLayoutEffect(() => {
const handleResize = () => {
const height = window.innerHeight
setWindowSize(height)
// scrollPos is always its initial value, not its latest
console.log("handleScroll: " + scrollPos + " / " + height + " = " + scrollPos / height)
setPercent(scrollPos / windowSize)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<div>
{/* Calculates percentage correctly upon scrolling and resizing */}
<p>{scrollPos} / {windowSize} = {scrollPos / windowSize}</p>
{/* Incorrect once I scroll after resizing and vice versa */}
<p>!= {percent}</p>
</div>
)
}
export default Page
If I run this, the console won't display the latest values of scrollPos and windowSize, but instead the initial ones; while rendering them does show their latest values. And percent gets mixed up with that as it grabs the initial value of one of them upon resizing or scrolling the window.
I think this is one of those things that's caused because of it being asynchronous, but it'd be nice to get a clearer answer on this. How would one be able to work with multiple "local" states, or is it just better to make one single merged state for cases like these?

Leafet setLatLng unmounts and mounts markers continuously, preventing events from firing

I'm trying to visualise 500+ vehicles using leaflet. When the position of a marker (vehicle) changes, it will move slowly to reach the destination (using requestAnimationFrame and leaflet's 'native' setLatLng since I don't want to update the state directly). It works well, but I also have a click listener on each marker and notice that it never fires. I soon realised that leaflet has been updating the marker continuously (the DOM element keeps blinking in the inspector). I attempted to log something to see if the component actually re-renders, but it doesn't. Seems like leaflet is messing with the DOM element under the hood.
const Marker = React.memo(function Marker({ plate, coors, prevCoors }) {
const markerRef = React.useRef();
const [activeVehicle, handleActiveVehicleUpdate] = useActiveVehicle();
const heading = prevCoors != null ? GeoHelpers.computeHeading(prevCoors, coors) : 0;
React.useEffect(() => {
if (prevCoors == null) return;
const [prevLat, prevLong] = prevCoors;
const [lat, long] = coors;
let animationStartTime;
const animateMarker = timestamp => {
if (animationStartTime == null) animationStartTime = timestamp;
const progress = (timestamp - animationStartTime) / 5000;
if (progress > 1) return;
const currLat = prevLat + (lat - prevLat) * progress;
const currLong = prevLong + (long - prevLong) * progress;
const position = new LatLng(currLat, currLong);
markerRef.current.leafletElement.setLatLng(position);
requestAnimationFrame(animateMarker);
};
const animationFrame = requestAnimationFrame(animateMarker);
// eslint-disable-next-line consistent-return
return () => cancelAnimationFrame(animationFrame);
}, [coors, prevCoors]);
React.useEffect(() => {
if (plate === '60C23403') console.log('re-render!');
// eslint-disable-next-line
});
return (
<LeafletMarker
icon={createIcon(plate === activeVehicle, heading)}
position={prevCoors != null ? prevCoors : coors}
onClick={handleActiveVehicleUpdate(plate, coors)}
ref={markerRef}
>
<Tooltip>{plate}</Tooltip>
</LeafletMarker>
);
});
How do I prevent this behaviour from leaflet? Any idea is appreciated. Thanks in advance :)

Resources