I'm trying to build a image cropping tool with React and konva. I'd like to change the opacity outside of the cropping rectangle to blur the rest of the image.
I have so far tried to set different opacities to the rectangle and the image but failed. I have looked up and there is no direct way to doing this
Here's the cropping function that I adapted to react with the help of this answer
import React, { useState, useEffect, useRef } from "react";
import { render } from "react-dom";
import { Stage, Layer, Rect, Image } from "react-konva";
import Konva from "konva";
const App = () => {
// Stage dims
let sW = 720,
sH = 720,
sX = 0,
sY = 0;
let src = "https://dummyimage.com/720x720/e85de8/fff&text=SO Rocks!";
let img = document.createElement("img");
useEffect(() => {
img.src = src;
function loadStatus() {
setloading(false);
}
img.addEventListener("load", loadStatus);
return () => {
img.removeEventListener("load", loadStatus);
};
}, [img, src]);
let scale = 1;
const [loading, setloading] = useState(true);
const [posStart, setposStart] = useState({});
const [posNow, setposNow] = useState({});
const [mode, setmode] = useState("");
/**
* Sets the state of posStart and posNow for tracking the coordinates of the cropping rectangle
* #param {Object} posIn - Coordinates of the pointer when MouseDown is fired
*/
function startDrag(posIn) {
setposStart({ x: posIn.x, y: posIn.y });
setposNow({ x: posIn.x, y: posIn.y });
}
/**
* Updates the state accordingly when the MouseMove event is fired
* #param {Object} posIn - Coordiantes of the current position of the pointer
*/
function updateDrag(posIn) {
setposNow({ x: posIn.x, y: posIn.y });
let posRect = reverse(posStart, posNow);
r2.current.x(posRect.x1);
r2.current.y(posRect.y1);
r2.current.width(posRect.x2 - posRect.x1);
r2.current.height(posRect.y2 - posRect.y1);
r2.current.visible(true);
}
/**
* Reverse coordinates if dragged left or up
* #param {Object} r1 - Coordinates of the starting position of cropping rectangle
* #param {Object} r2 - Coordinates of the current position of cropping rectangle
*/
function reverse(r1, r2) {
let r1x = r1.x,
r1y = r1.y,
r2x = r2.x,
r2y = r2.y,
d;
if (r1x > r2x) {
d = Math.abs(r1x - r2x);
r1x = r2x;
r2x = r1x + d;
}
if (r1y > r2y) {
d = Math.abs(r1y - r2y);
r1y = r2y;
r2y = r1y + d;
}
return { x1: r1x, y1: r1y, x2: r2x, y2: r2y }; // return the corrected rect.
}
/**
* Crops the image and saves it in jpeg format
* #param {Konva.Rect} r - Ref of the cropping rectangle
*/
function setCrop(r) {
let jpeg = new Konva.Image({
image: img,
x: sX,
y: sY
});
jpeg.cropX(r.x());
jpeg.cropY(r.y());
jpeg.cropWidth(r.width() * scale);
jpeg.cropHeight(r.height() * scale);
jpeg.width(r.width());
jpeg.height(r.height());
const url = jpeg.toDataURL({ mimeType: "image/jpeg", quality: "1.0" });
const a = document.createElement("a");
a.href = url;
a.download = "cropped.jpg";
a.click();
}
// Foreground rect to capture events
const r1 = useRef();
// Cropping rect
const r2 = useRef();
const image = useRef();
return (
<div className="container">
<Stage width={sW} height={sH}>
<Layer>
{!loading && (
<Image
ref={image}
{...{
image: img,
x: sX,
y: sY
}}
/>
)}
<Rect
ref={r1}
{...{
x: 0,
y: 0,
width: sW,
height: sH,
fill: "white",
opacity: 0
}}
onMouseDown={function (e) {
setmode("drawing");
startDrag({ x: e.evt.layerX, y: e.evt.layerY });
}}
onMouseMove={function (e) {
if (mode === "drawing") {
updateDrag({ x: e.evt.layerX, y: e.evt.layerY });
image.current.opacity(0.5);
r2.current.opacity(1);
}
}}
onMouseUp={function (e) {
setmode("");
r2.current.visible(false);
setCrop(r2.current);
image.current.opacity(1);
}}
/>
<Rect
ref={r2}
listening={false}
{...{
x: 0,
y: 0,
width: 0,
height: 0,
stroke: "white",
dash: [5, 5]
}}
/>
</Layer>
</Stage>
</div>
);
};
render(<App />, document.getElementById("root"));
Demo for the above code
It can be done using Konva.Group and its clip property. Add a new Konva.Image to the group and set its clipping positions and size to be the same as the cropping rectangle. Don't forget to set the listening prop of the group to false otherwise it will complicate things. Here's the final result
import { render } from "react-dom";
import React, { useLayoutEffect, useRef, useState } from "react";
import { Stage, Layer, Image, Rect, Group } from "react-konva";
/**
* Crops a portion of image in Konva stage and saves it in jpeg format
* #param {*} props - Takes no props
*/
function Cropper(props) {
// Stage dims
let sW = 720,
sH = 720,
sX = 0,
sY = 0;
let src = "https://dummyimage.com/720x720/e85de8/fff&text=SO Rocks!";
let img = new window.Image();
useLayoutEffect(() => {
img.src = src;
function loadStatus() {
// img.crossOrigin = "Anonymous";
setloading(false);
}
img.addEventListener("load", loadStatus);
return () => {
img.removeEventListener("load", loadStatus);
};
}, [img, src]);
let i = new Konva.Image({
x: 0,
y: 0,
width: 0,
height: 0
});
let scale = 1;
const [loading, setloading] = useState(true);
const [posStart, setposStart] = useState({});
const [posNow, setposNow] = useState({});
const [mode, setmode] = useState("");
/**
* Sets the state of posStart and posNow for tracking the coordinates of the cropping rectangle
* #param {Object} posIn - Coordinates of the pointer when MouseDown is fired
*/
function startDrag(posIn) {
setposStart({ x: posIn.x, y: posIn.y });
setposNow({ x: posIn.x, y: posIn.y });
}
/**
* Updates the state accordingly when the MouseMove event is fired
* #param {Object} posIn - Coordiantes of the current position of the pointer
*/
function updateDrag(posIn) {
setposNow({ x: posIn.x, y: posIn.y });
let posRect = reverse(posStart, posNow);
r2.current.x(posRect.x1);
r2.current.y(posRect.y1);
r2.current.width(posRect.x2 - posRect.x1);
r2.current.height(posRect.y2 - posRect.y1);
r2.current.visible(true);
grp.current.clip({
x: posRect.x1,
y: posRect.y1,
width: posRect.x2 - posRect.x1,
height: posRect.y2 - posRect.y1
});
grp.current.add(i);
i.image(img);
i.width(img.width);
i.height(img.height);
i.opacity(1);
}
/**
* Reverse coordinates if dragged left or up
* #param {Object} r1 - Coordinates of the starting position of cropping rectangle
* #param {Object} r2 - Coordinates of the current position of cropping rectangle
*/
function reverse(r1, r2) {
let r1x = r1.x,
r1y = r1.y,
r2x = r2.x,
r2y = r2.y,
d;
if (r1x > r2x) {
d = Math.abs(r1x - r2x);
r1x = r2x;
r2x = r1x + d;
}
if (r1y > r2y) {
d = Math.abs(r1y - r2y);
r1y = r2y;
r2y = r1y + d;
}
return { x1: r1x, y1: r1y, x2: r2x, y2: r2y }; // return the corrected rect.
}
/**
* Crops the image and saves it in jpeg format
* #param {Konva.Rect} r - Ref of the cropping rectangle
*/
function setCrop(r) {
let jpeg = new Konva.Image({
image: img,
x: sX,
y: sY
});
jpeg.cropX(r.x());
jpeg.cropY(r.y());
jpeg.cropWidth(r.width() * scale);
jpeg.cropHeight(r.height() * scale);
jpeg.width(r.width());
jpeg.height(r.height());
const url = jpeg.toDataURL({ mimeType: "image/jpeg", quality: "1.0" });
const a = document.createElement("a");
a.href = url;
a.download = "cropped.jpg";
a.click();
}
// Foreground rect to capture events
const r1 = useRef();
// Cropping rect
const r2 = useRef();
const image = useRef();
const grp = useRef();
return (
<div className="container">
<Stage width={sW} height={sH}>
<Layer>
{!loading && (
<Image
ref={image}
listening={false}
{...{
image: img,
x: sX,
y: sY
}}
/>
)}
<Rect
ref={r1}
{...{
x: 0,
y: 0,
width: sW,
height: sH,
fill: "white",
opacity: 0
}}
onMouseDown={function (e) {
setmode("drawing");
startDrag({ x: e.evt.layerX, y: e.evt.layerY });
image.current.opacity(0.2);
}}
onMouseMove={function (e) {
if (mode === "drawing") {
updateDrag({ x: e.evt.layerX, y: e.evt.layerY });
}
}}
onMouseUp={function (e) {
setmode("");
r2.current.visible(false);
setCrop(r2.current);
image.current.opacity(1);
grp.current.removeChildren(i);
}}
/>
<Group listening={false} ref={grp}></Group>
<Rect
ref={r2}
listening={false}
{...{
x: 0,
y: 0,
width: 0,
height: 0,
stroke: "white",
dash: [5, 10]
}}
/>
</Layer>
</Stage>
</div>
);
}
render(<Cropper />, document.getElementById("root"));
Thanks to #Vanquished Wombat for all the precious inputs. This is an adaptation of his answer here
Demo of the above code
Related
My application, pulls data ( text, numbers) from a database and then places them in a canvas image. This is working as expected, and the data is being displayed along with a few lines of the final diagram. However the useEffect() function is not stopping and continues to display the same image, which I would not have realised had I not placed an alert.
I have seen the documentation and other questions that have discussed this problem. I understand that I need to put a variable whose value will not change. But I am simply unable to do this. I am sharing my code. Will be grateful if someone can help.
import React, { useEffect, useState, useRef } from "react";
import { useParams } from "react-router";
import './cindex.css';
export default function GetChartData() {
const params = useParams();
const [dbdata, setData] = useState('empty52');
const canvas = useRef();
let ctx = null;
let name = null
let Lalong = null
let T1 = { text: 'dummy', x: 180, y: 100 }
let T2 = { text: 'dummy', x: 180, y: 200 }
useEffect(() => {
async function getData() {
const response = await fetch(`http://localhost:5000/getchartdata/${params.id.toString()}`);
//const response = await fetch(`http://khona21.cleverapps.io/getchartdata/${params.id.toString()}`);
if (!response.ok) {
const message = `An error occurred: ${response.statusText}`;
window.alert(message);
return;
}
const dbdata = await response.json();
setData(dbdata);
}
getData();
return;
},[dbdata]);
// initialize the canvas context
useEffect(() => {
// dynamically assign the width and height to canvas
const canvasEle = canvas.current;
canvasEle.width = canvasEle.clientWidth;
canvasEle.height = canvasEle.clientHeight;
// get context of the canvas
ctx = canvasEle.getContext("2d");
//}, []);
}, [T1,T2]);
useEffect(() => {
let pid = dbdata.pid;
let spid = JSON.stringify(pid);
if (typeof spid !== 'undefined'){
name = dbdata.pid.name;
Lalong = dbdata.GLon.La
if (T1.text === 'dummy') {
T1.text = name
T2.text = Lalong.toString()
}
}
else {
//window.alert('undefined')
}
drawLine({ x: 150, y: 20, x1: 150, y1: 450 });
drawLine({ x: 300, y: 20, x1: 300, y1: 450 });
writeText(T1);
writeText(T2);
window.alert('finished drawing')
//}, []);
//}, [[]]);
}, [T1]);
const drawLine = (info, style = {}) => {
const { x, y, x1, y1 } = info;
const { color = 'black', width = 1 } = style;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x1, y1);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
}
// write a text
const writeText = (info, style = {}) => {
const { text, x, y } = info;
const { fontSize = 20, fontFamily = 'Arial', color = 'black', textAlign = 'left', textBaseline = 'top' } = style;
ctx.beginPath();
ctx.font = fontSize + 'px ' + fontFamily;
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
ctx.fillStyle = color;
ctx.fillText(text, x, y);
ctx.stroke();
}
return (
<div>
<h3>Chart Positions</h3>
<p>{JSON.stringify(dbdata)}</p>
<hr></hr>
<canvas ref={canvas}></canvas>
</div>
);
}
I have tried to ensure that that the text in T1 changes only once and hence should stop the loop. But it is not. What should I do?
the TRICK was to use [dbdata.length] instead of simply [dbdata] as the stopping condition in the useEffect() function. dbdata was being set each time as new, but dbdata.length was not changing.
import React, { useEffect, useState, useRef } from "react";
import { useParams } from "react-router";
import './cindex.css';
//let T1 = { text: 'dummy', x: 180, y: 100 }
//let T2 = { text: 'dummy', x: 180, y: 200 }
export default function GetChartData() {
const canvas = useRef();
let ctx = null;
const params = useParams();
const [dbdata, setData] = useState('empty52');
let name = null
let Lalong = null
const T1 = { text: 'dummy', x: 180, y: 100 }
const T2 = { text: 'dummy', x: 180, y: 200 }
useEffect(() => {
async function getData() {
const response = await fetch(`http://localhost:5000/getchartdata/${params.id.toString()}`);
//const response = await fetch(`http://khona21.cleverapps.io/getchartdata/${params.id.toString()}`);
if (!response.ok) {
const message = `An error occurred: ${response.statusText}`;
window.alert(message);
return;
}
const dbdata = await response.json();
setData(dbdata);
// *********************************************************************
let pid = dbdata.pid;
let spid = JSON.stringify(pid);
if (typeof spid !== 'undefined'){
name = dbdata.pid.name;
Lalong = dbdata.GLon.La
T1.text = name
T2.text = Lalong.toString()
}
else {
//window.alert('undefined')
}
// dynamically assign the width and height to canvas
const canvasEle = canvas.current;
canvasEle.width = canvasEle.clientWidth;
canvasEle.height = canvasEle.clientHeight;
// get context of the canvas
ctx = canvasEle.getContext("2d");
drawLine({ x: 150, y: 20, x1: 150, y1: 450 });
drawLine({ x: 300, y: 20, x1: 300, y1: 450 });
writeText(T1);
writeText(T2);
window.alert('image drawn')
// **********************************************************************
}
getData();
return;
},[dbdata.length]);
const drawLine = (info, style = {}) => {
const { x, y, x1, y1 } = info;
const { color = 'black', width = 1 } = style;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x1, y1);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
}
// write a text
const writeText = (info, style = {}) => {
const { text, x, y } = info;
const { fontSize = 20, fontFamily = 'Arial', color = 'black', textAlign = 'left', textBaseline = 'top' } = style;
ctx.beginPath();
ctx.font = fontSize + 'px ' + fontFamily;
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
ctx.fillStyle = color;
ctx.fillText(text, x, y);
ctx.stroke();
}
return (
<div>
<h3>Chart Positions</h3>
<p>{JSON.stringify(dbdata)}</p>
<hr></hr>
<canvas ref={canvas}></canvas>
</div>
);
}
I have a map element using an svg, and have functions to zoom and pan, however the zoom function, which uses the mouse wheel, wont stop the entire page from scrolling when the mouse is inside the map. I have tried to use the e.preventDefault call to stop this, however I keep getting the error Unable to preventDefault inside passive event listener invocation. I have been unable to find a solution to this that works. I know I am probably missing something small, but I have had no success in making it work.
Here is the code minus the stuff inside the SVG as it is too much to fit in the post.
import React, { useEffect, useRef, useState } from 'react'
export default function Map () {
const [isPanning, setPanning] = useState(false)
const [map, setMap] = useState()
const [position, setPosition] = useState({
oldX: 0,
oldY: 0,
x: 0,
y: 0,
z: 1,
})
const containerRef = useRef()
const onLoad = (e) => {
setMap({
width: 1000,
height: 1982
})
}
const onMouseDown = (e) => {
e.preventDefault()
setPanning(true)
setPosition({
...position,
oldX: e.clientX,
oldY: e.clientY
})
}
const onWheel = (e) => {
if (e.deltaY) {
const sign = Math.sign(e.deltaY) / 10
const scale = 1 - sign
const rect = containerRef.current.getBoundingClientRect()
console.log(map)
setPosition({
...position,
x: position.x * scale - (rect.width / 2 - e.clientX + rect.x) * sign,
y: position.y * scale - (1982 * rect.width / 1000 / 2 - e.clientY + rect.y) * sign,
z: position.z * scale,
})
}
}
useEffect(() => {
const mouseup = () => {
setPanning(false)
}
const mousemove = (event) => {
if (isPanning) {
setPosition({
...position,
x: position.x + event.clientX - position.oldX,
y: position.y + event.clientY - position.oldY,
oldX: event.clientX,
oldY: event.clientY
})
}
}
window.addEventListener('mouseup', mouseup)
window.addEventListener('mousemove', mousemove)
return () => {
window.removeEventListener('mouseup', mouseup)
window.removeEventListener('mousemove', mousemove)
}
})
return (
<div className='viewBox'>
<div
className="PanAndZoomImage-container"
ref={containerRef}
onMouseDown={onMouseDown}
onWheel={onWheel}
>
<svg baseProfile="tiny" fill="#7c7c7c" height="1982" stroke="#ffffff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" version="1.2" viewBox="0 0 1000 1982" width="1000" xmlns="http://www.w3.org/2000/svg" onLoad={onLoad}>
<g className='regions' style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${position.z})`,
}}>
If anyone knows a solution, any help would be greatly appreciated!
you can try
<YourComponent
onMouseEnter={(e) => {disableScroll.on()}}
onMouseLeave={(e) => {disableScroll.off()}}
/>
use lib from https://github.com/gilbarbara/disable-scroll
I have a canvas component in react. I am using useEffect to get the canvas element. So i have defined all needed functions in useEffect, as you can see below
import { useEffect, useRef } from "react"
import * as blobs2Animate from "blobs/v2/animate"
export const CornerExpand = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext("2d")
const animation = blobs2Animate.canvasPath()
const width = canvas.clientWidth * window.devicePixelRatio
const height = canvas.clientHeight * window.devicePixelRatio
canvas.width = width
canvas.height = height
const renderAnimation = () => {
if (!ctx) return
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = "#E0F2FE"
ctx.fill(animation.renderFrame())
requestAnimationFrame(renderAnimation)
}
requestAnimationFrame(renderAnimation)
const size = Math.min(width, height) * 1
const defaultOptions = () => ({
blobOptions: {
seed: Math.random(),
extraPoints: 36,
randomness: 0.7,
size,
},
canvasOptions: {
offsetX: -size / 2.2,
offsetY: -size / 2.2,
},
})
const loopAnimation = () => {
animation.transition({
duration: 4000,
timingFunction: "ease",
callback: loopAnimation,
...defaultOptions(),
})
}
animation.transition({
duration: 0,
callback: loopAnimation,
...defaultOptions(),
})
const fullscreen = () => {
const options = defaultOptions()
options.blobOptions.size = Math.max(width, height) * 1.6
options.blobOptions.randomness = 1.4
options.canvasOptions.offsetX = -size / 2
options.canvasOptions.offsetY = -size / 2
animation.transition({
duration: 2000,
timingFunction: "elasticEnd0",
...options,
})
}
}, [canvasRef])
return (
<div>
<canvas className="absolute top-0 left-0 h-full w-full" ref={canvasRef} />
</div>
)
}
export default CornerExpand
Everything works as well, but now I have a problem. I want to execute the fullscreen() function when a button is clicked in the parent component. Since I have defined the function in useEffect, I can't call it directly, isn't it? What can I do to solve this?
You can do it like this.
export const CornerExpand = React.forwardRef((props, ref) => {
//....
{
//...
const fullscreen = () => {
const options = defaultOptions()
options.blobOptions.size = Math.max(width, height) * 1.6
options.blobOptions.randomness = 1.4
options.canvasOptions.offsetX = -size / 2
options.canvasOptions.offsetY = -size / 2
animation.transition({
duration: 2000,
timingFunction: "elasticEnd0",
...options,
})
}
ref.current = fullscreen;
}, [canvasRef]);
You can wrap this comp with React.forwardRef. And call it from parent.
<CornerExpand ref={fullscreenCallbackRef} />
And then call like this
fullscreenCallbackRef.current()
I'm building a React component that generates a speedometer and I want to set the length of each segment (i.e. Red - 30%, Yellow - 30%, Green - 30%, and Gray - 10%).
I'm using React-ChartJS-2 and onComplete of the animation I'm drawing the text and needle.
I've checked the documentation and there's nothing for setting the length or width of each segment. In an ideal situation, the needle would be in the green... and yes, our numbers allow for 100+%
Anyone have an idea on how to do this, either a plug-in or a callback function that I can tie into that allows me to customize how each segment is drawn?
<Box>
<Box style={{ position: 'relative', paddingLeft: theme.spacing(1.25), height: 300 }}>
<Doughnut
ref={chartRef}
data={{
labels: labels ?? data,
datasets: [
{
data: data,
// Use backgroundColor if available or generate colors based on number of data points
backgroundColor: backgroundColor ?? generateColorArray(20, 360, data.length),
borderWidth: 0
}
]
}}
options={{
legend: {
display: false,
position: 'top',
fullWidth: true
},
layout: {
padding: {
top: 50,
left: 25,
right: 25,
bottom: 25
}
},
rotation: 1 * Math.PI,
circumference: Math.PI,
cutoutPercentage: 70,
animation: {
duration: 500,
easing: 'easeOutQuart',
onComplete: function(e): void {
drawNeedle(e.chart.innerRadius, e.chart.outerRadius);
drawText(`${needleValue.toString()}%`, e.chart.innerRadius, e.chart.outerRadius);
},
onProgress: function(e): void {
console.log('e: ', e);
}
},
tooltips: {
enabled: false
},
// Disable other events from firing which causes the chart elements to get pushed down onHover
events: []
}}
/>
</Box>
</Box>
const drawNeedle = (innerRadius: number, outerRadius: number): void => {
const chart = chartRef.current as Doughnut;
const ctx = chart.chartInstance.ctx;
const maxValue = 180;
if (ctx) {
const rotation = -Math.PI;
const circumference = Math.PI;
const origin = rotation + circumference * (0 / maxValue);
const target = rotation + circumference * (((needleValue / 100) * 180) / maxValue);
const angle = origin + (target - origin) * 1;
const chartArea = chart.chartInstance.chartArea;
const width = chartArea.right - chartArea.left;
const needleRadius = (2 / 100) * width;
const needleWidth = (3.2 / 100) * width;
const needleLength = (20 / 100) * (outerRadius - innerRadius) + innerRadius;
const cw = ctx.canvas.offsetWidth;
const ch = ctx.canvas.offsetHeight;
const cx = cw / 2;
const cy = ch - ch / 14;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
// draw circle
ctx.beginPath();
ctx.ellipse(0, 0, needleRadius, needleRadius, 0, 0, 2 * Math.PI);
ctx.fill();
// draw needle
ctx.beginPath();
ctx.moveTo(0, needleWidth / 2);
ctx.lineTo(needleLength, 0);
ctx.lineTo(0, -needleWidth / 2);
ctx.fill();
ctx.restore();
}
};
const drawText = (text: string, innerRadius: number, outerRadius: number): void => {
const chart = chartRef.current as Doughnut;
const ctx = chart.chartInstance.ctx;
const minValue = Math.min(...data);
const maxValue = Math.max(...data);
if (ctx) {
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
const chartArea = chart.chartInstance.chartArea;
const centerX = (chartArea.left + chartArea.right) / 2;
const centerY = (chartArea.top + chartArea.bottom) / 2;
const textMetrics = ctx.measureText(text);
const textHeight = Math.max(ctx.measureText('m').width, ctx.measureText('\uFF37').width);
const radialDiff = outerRadius - innerRadius;
// Min / Max values
ctx.font = '20px Arial';
ctx.fillText(`${minValue}%`, chartArea.left + radialDiff * 1.1, chartArea.bottom + textHeight * 2);
ctx.fillText(`${maxValue}%`, chartArea.right - radialDiff * 2, chartArea.bottom + textHeight * 2);
// Needle value
ctx.font = '30px Arial';
ctx.fillText(text, centerX - textMetrics.width, centerY + textHeight);
}
};
I am trying to implement drag and drop in React and using SVG elements. The problem is mouseMove does not get triggered if the user moves the mouse too fast. It basically loses the dragging quite frequently. To solve this I think I need to handle the mouseMove in the parent but not sure how to do this with React. I tried several different approaches to no avail.
I tried addEventListener('mousemove', ...) on the parent using a ref, but the problem is that the clientX is a different coordinate system than the current component. Also, the event handler does not have access to any of the state from the component (event with arrow functions). It maintains a stale reference to any state.
I tried setting the clientX and clientY in a context on the parent and then pulling it in from the DragMe component but it is always undefined the first time around for some strange reason even though I give it a default value.
Here's the code I'm working with:
const DragMe = ({ x = 50, y = 50, r = 10 }) => {
const [dragging, setDragging] = useState(false)
const [coord, setCoord] = useState({ x, y })
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [origin, setOrigin] = useState({ x: 0, y: 0 })
const xPos = coord.x + offset.x
const yPos = coord.y + offset.y
const transform = `translate(${xPos}, ${yPos})`
const fill = dragging ? 'red' : 'green'
const stroke = 'black'
const handleMouseDown = e => {
setDragging(true)
setOrigin({ x: e.clientX, y: e.clientY })
}
const handleMouseMove = e => {
if (!dragging) { return }
setOffset({
x: e.clientX - origin.x,
y: e.clientY - origin.y,
})
}
const handleMouseUp = e => {
setDragging(false)
setCoord({ x: xPos, y: yPos })
setOrigin({ x: 0, y: 0 })
setOffset({ x: 0, y: 0 })
}
return (
<svg style={{ userSelect: 'none' }}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseUp}
>
<circle transform={transform} cx="0" cy="0" r={r} fill={fill} stroke={stroke} />
</svg>
)
}
After much experimentation I was able to addEventListener to the parent canvas. I discovered that I needed to useRef in order to allow the mousemove handler to see the current state. The problem I had before was that the handleParentMouseMove handler had a stale reference to the state and never saw the startDragPos.
This is the solution I came up with. If anyone knows of a way to clean this up it would be much appreciated.
const DragMe = ({ x = 50, y = 50, r = 10, stroke = 'black' }) => {
// `mousemove` will not generate events if the user moves the mouse too fast
// because the `mousemove` only gets sent when the mouse is still over the object.
// To work around this issue, we `addEventListener` to the parent canvas.
const canvasRef = useContext(CanvasContext)
const [dragging, setDragging] = useState(false)
// Original position independent of any dragging. Updated when done dragging.
const [originalCoord, setOriginalCoord] = useState({ x, y })
// The distance the mouse has moved since `mousedown`.
const [delta, setDelta] = useState({ x: 0, y: 0 })
// Store startDragPos in a `ref` so handlers always have the latest value.
const startDragPos = useRef({ x: 0, y: 0 })
// The current object position is the original starting position + the distance
// the mouse has moved since the start of the drag.
const xPos = originalCoord.x + delta.x
const yPos = originalCoord.y + delta.y
const transform = `translate(${xPos}, ${yPos})`
// `useCallback` is needed because `removeEventListener`` requires the handler
// to be the same as `addEventListener`. Without `useCallback` React will
// create a new handler each render.
const handleParentMouseMove = useCallback(e => {
setDelta({
x: e.clientX - startDragPos.current.x,
y: e.clientY - startDragPos.current.y,
})
}, [])
const handleMouseDown = e => {
setDragging(true)
startDragPos.current = { x: e.clientX, y: e.clientY }
canvasRef.current.addEventListener('mousemove', handleParentMouseMove)
}
const handleMouseUp = e => {
setDragging(false)
setOriginalCoord({ x: xPos, y: yPos })
startDragPos.current = { x: 0, y: 0 }
setDelta({ x: 0, y: 0 })
canvasRef.current.removeEventListener('mousemove', handleParentMouseMove)
}
const fill = dragging ? 'red' : 'green'
return (
<svg style={{ userSelect: 'none' }}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<circle transform={transform} cx="0" cy="0" r={r} fill={fill} stroke={stroke} />
</svg>
)
}
Just to isolate the code that works here:
First go ahead and figure out what your top-level parent is in your render() function, where you may have something like:
render() {
return (
<div ref={(parent_div) => { this.parent_div = parent_div }}}>
// other div stuff
</div>
)
}
Then, using the ref assignment as from above, go ahead and assign the event listener to it like so:
this.parent_div.addEventListener('mousemove', function (event) {
console.log(event.clientX)
}