ARTICLE AD BOX
I have been trying to add scrollbars to the Stage that has a PNG image in the Layer. The stage is 1024 x 512 and the Layer is 10000 x 5000 size or bigger. As you can see, I am moving the Stage over the Layer using wheel and drag. I then added buttons to zoom in and out using that scale ratio. The problem is I now want to add a scrollbar around the image so I can drag the Stage around like I can with the mouse, but I can't set up the scrollbars correctly since the bars are around that Stage view and the Stage size is fixed and the Image is inside the Stage. I tried adding sliders for width and height to move the image, but as I zoom in and out the Slider bars don't look right and the ranges of the Slider are off. It could be that this is an either-or problem but if you know how to either fix the Sliders to adjust for the scale change or get the scrollbars to work that would be great.
The is are my 4 classes:
src/components/Button.js
import { Button as MuiButton } from "@mui/material"; import { styled } from "@mui/system"; import { FFA_COLOR_MAP } from "./ColorToHexNumber"; import { memo } from "react"; /* * The class will create a new button color theme for disable. * Use this Button instead the MUI Button in the project. */ const Button = styled(MuiButton)({ // button without the variant attribute being set "&.Mui-disabled": { color: "#e0e0e0", borderRadius: 4, }, // button with the variant=outlined attribute "&.MuiButton-outlined.Mui-disabled": { background: FFA_COLOR_MAP.Disable, color: "#FFF", borderRadius: 4, }, // button with the variant=contained attribute "&.MuiButton-contained.Mui-disabled": { background: FFA_COLOR_MAP.Disable, color: "#FFF", borderRadius: 4, }, // button without the variant attribute being set "&.Mui": { borderRadius: 4, }, // button with the variant=outlined attribute "&.MuiButton-outlined": { borderRadius: 4, }, // button with the variant=contained attribute "&.MuiButton-contained": { borderRadius: 4, }, }); export default memo(Button);src/components/common/ColorToHexNumber.js
/* * This is the FAA color map which we will use when setting * colors when possible. */ export const FFA_COLOR_MAP = { Aqua: '#07CDED', Black: '#000000', BlackLight: '#1F1F1F', /* 12.5% relative Luminance */ BlackLighter: '#1A2027', Brown: '#C5955B', Blue: '#5E8DF6', Gray: '#B3B3B3', GrayLight: '#C7C2C2', GrayBG: '#f5f3f3', GrayDark: 'rgba(179,179,179,0.51)', Green: '#23E162', GreenSignal: 'rgb(49,266,23)', Magenta: '#D822FF', Orange: '#FE930D', Pink: '#F684D8', Red: '#FF1320', White: '#FFFFFF', Yellow: '#DFF334', Primary: '#1976d2', /* MUI primary color */ Disable: '#6AA5E1', /* MUI disable primary color */ Secondary: '#9c27b0', /* MUI Secondary color */ };src/components/image/PaintImage.js
import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Stage, Layer, Image as KonvaImage, } from "react-konva"; import { Box, Grid } from "@mui/system"; import Button from "../Common/Button"; import ClickAndHold from "./ClickAndHold"; import { Slider } from "@mui/material"; import { FFA_COLOR_MAP } from "../Common/ColorToHexNumber"; /** * Load image into Stage layer to it can be viewed and edited. * * @returns {React.JSX.Element} * @constructor */ const PaintImage = () => { const btnUpRef = useRef(null); const btnDownRef = useRef(null); const [imageUrl, setImageUrl] = useState(null); const [image, setImage] = useState(null); const fileRef = useRef(null); const [imageDimension, setImageDimension] = useState({ width: 0, height: 0 }); const stageRef = useRef(null) const stageWidth = 1024; const stageHeight = 512; const [position, setPosition] = React.useState({ scale: 1, x: 0, y: 0 }); const [mouse, setMouse] = React.useState({ x: 0, y: 0 }); const imageRef = useRef(null) const scaleBy = 1.05; const maxScale = 6; /** * Open File Browser to load an image. * * @type {(function(): void)|*} */ const handleGetImage = useCallback(() => { fileRef?.current && fileRef?.current?.click(); }, []); /** * Verify the file is a png image and then load in. * * @type {(function(*): void)|*} */ const handleFileChange = useCallback( (e) => { if (e.target.files?.[0]) { if (e.target.files[0].type.startsWith('image/png')) { // Create a temporary URL for the file const objectUrl = URL.createObjectURL(e.target.files[0]); setImageUrl(objectUrl); // Optional: Revoke the previous object URL to free up memory if necessary // This is generally handled automatically when the component unmounts or a new URL is set, // but if you have complex logic, you might manage revocation manually. } else { setImageUrl(null); alert('Please select a PNG image file.'); } } e.target.files = null; }, [] ); /** * Load the image using Konva's utility. */ useEffect(() => { const img = new window.Image(); img.src = imageUrl; img.onload = (e) => { setImage(img); const { naturalWidth, naturalHeight } = e.currentTarget; setImageDimension({ width: naturalWidth, height: naturalHeight, }); }; }, [imageUrl]); /** * Cache the image for performance when it loads. */ useEffect(() => { if (imageRef.current) { imageRef.current.cache(); imageRef.current.getLayer().batchDraw(); } }, [image]) /** * Depending on the button press adjust the image scale. * * @param zoomAction What button was pressed. */ const handleZoom = (zoomAction) => { const stage = stageRef.current; const oldScale = stage.scaleX() let newScale; // 1. Calculate new scale switch (zoomAction) { case "in": { if (oldScale === maxScale) { return; } newScale = oldScale * scaleBy; if (newScale > maxScale) { newScale = maxScale; } break; } case "out": { newScale = oldScale / scaleBy; break; } case "max": { newScale = maxScale; break; } case "min": { newScale = Math.max(stageWidth / imageDimension.width, stageHeight / imageDimension.height) || 0.1; break; } case "50": { newScale = .5; break; } default: { console.error("Unsupported zoom action: " + zoomAction); return; } } // Constrain scale: minimum scale should keep the image covering the stage const minScale = Math.max(stageWidth / imageDimension.width, stageHeight / imageDimension.height) || 0.1; if (newScale < minScale) {return;} // Zoom relative to center const newPos = { x: stageWidth / 2 - (stageWidth / 2 - position.x) * (newScale / oldScale), y: stageHeight / 2 - (stageHeight / 2 - position.y) * (newScale / oldScale), }; switch (zoomAction) { case "in": case "out": setPosition({ scale: newScale, x: newPos.x, y: newPos.y }); break; case "max": case "min": case "50": const centerPos = getCenteredPosition(newScale); setPosition({ scale: newScale, x: centerPos.x, y: centerPos.y }); setMouse({x: centerPos.x, y: centerPos.y}); break; default: break; } } /** * Function to restrict stage movement so image stays inside. * * @param newPos New image position * @param scale Current image scale * @returns {{x: number, y: number}} */ const boundStagePosition = (newPos, scale) => { const minX = -imageDimension.width * scale + stageWidth; const maxX = 0; const minY = -imageDimension.height * scale + stageHeight; const maxY = 0; return { x: Math.max(minX, Math.min(maxX, newPos.x)), y: Math.max(minY, Math.min(maxY, newPos.y)), }; } /** * Zoom in and out using mouse wheel. * * @param e Wheel event. */ const handleWheel = (e) => { e.evt.preventDefault(); const stage = e.target.getStage(); const oldScale = stage.scaleX(); const pointer = stage.getPointerPosition(); // 3. Adjust position to zoom into mouse pointer const mousePointTo = { x: (pointer.x - position.x) / oldScale, y: (pointer.y - position.y) / oldScale, }; // 1. Calculate zoom direction const direction = e.evt.deltaY > 0 ? 1 : -1; // 2. Calculate new scale const newScale = direction > 0 ? oldScale / scaleBy : oldScale * scaleBy; // Constrain scale: minimum scale should keep the image covering the stage const minScale = Math.max(stageWidth / imageDimension.width, stageHeight / imageDimension.height) || 0.1; if (newScale < minScale) return; const newPos = { x: pointer.x - mousePointTo.x * newScale, y: pointer.y - mousePointTo.y * newScale, }; // Apply drag bounds immediately after calculating new position const boundedPos = boundStagePosition(newPos, newScale); stage.position(boundedPos); setPosition({ scale: newScale, x: boundedPos.x, y: boundedPos.y }); } /** * Update stage position in state on drag end to image edges are still in view. */ const handleDragEnd = () => { const stage = stageRef.current; const newPos = stage.position(); const oldScale = stage.scaleX(); // Apply bounds after drag ends to snap into place if necessary const boundedPos = boundStagePosition(newPos, oldScale); setPosition({...position, x: boundedPos.x, y: boundedPos.y}); setMouse({x: boundedPos.x, y: boundedPos.y}); } /** * Calculate position to center the image when scaled. * * @param scale Current image scale. * @returns {{x: number, y: number}|{x: number, y: number}} */ const getCenteredPosition = (scale) => { if (!image) return { x: 0, y: 0 }; const scaledWidth = imageDimension.width * scale; const scaledHeight = imageDimension.height * scale; // Calculate the top-left position (x, y) required to center the scaled image on the stage const x = (stageWidth - scaledWidth) / 2; const y = (stageHeight - scaledHeight) / 2; return { x, y }; } /** * Calculate the "real" value for your vertical slider (0 at top). * * @param sliderValue Slider value * @returns {number} Real value */ const calculateAppValue = (sliderValue) => { return imageDimension.height * position.scale - sliderValue; }; /** * Calculate the "real" value for your vertical slider (0 at top). * * @param appValue * @returns {number} */ const calculateSliderValue = (appValue) => { return imageDimension.height * position.scale - appValue; } /** * Calculate the position when the vertical bar moves. * * @param event Slider event * @param newValue New value */ const handleVerticalScroll = (event, newValue) => { const stage = stageRef.current; const oldScale = stage.scaleX(); let value = - calculateAppValue(newValue); // Apply bounds after drag ends to snap into place if necessary const boundedPos = boundStagePosition({ x: position.x, y: value }, oldScale); setPosition({ ...position, x: boundedPos.x, y: boundedPos.y }); setMouse({ ...mouse, y: newValue }); } /** * Calculate the position when the horizontal bar moves. * * @param event Slider event * @param newValue New value */ const handleHorizontalScroll = (event, newValue) => { const stage = stageRef.current; const oldScale = stage.scaleX(); let value = - newValue; // Apply bounds after drag ends to snap into place if necessary const boundedPos = boundStagePosition({ x: value, y: position.y }, oldScale); setPosition({ ...position, x: boundedPos.x, y: boundedPos.y }); setMouse({ ...mouse, x: newValue }); } /** * Setup onClick event listeners for in and out zoom buttons */ useEffect(() => { const removeListenerUp = ClickAndHold(btnUpRef.current); const removeListenerDown = ClickAndHold(btnDownRef.current); return ()=>{ if (removeListenerUp) { // noinspection JSValidateTypes removeListenerUp(); } if (removeListenerDown) { // noinspection JSValidateTypes removeListenerDown(); } } },[]); return ( <Box> <div style={{ marginBottom: '10px' }}> <Button variant="solid" ref={btnUpRef} onClick={() => handleZoom('in')} > Zoom In (+) </Button> <Button variant="solid" ref={btnDownRef} onClick={() => handleZoom('out')} > Zoom Out (-) </Button> <Button variant="solid" onClick={() => handleZoom('max')} > Zoom Max </Button> <Button variant="solid" onClick={() => handleZoom('min')} > Zoom Min </Button> <Button variant="solid" onClick={() => handleZoom('50')} > Zoom 50% </Button> <span style={{ marginLeft: '10px' }}> Current Scale: {position.scale.toFixed(2)} </span> </div> <Box sx={{ display: 'flex', gap: 4 }}> <input type="file" ref={fileRef} onChange={handleFileChange} style={{ display: "none" }} accept="image/*" /> <Button variant="solid" onClick={handleGetImage}> Import Image </Button> <span style={{ marginLeft: '10px' }}> Current position: x: {position.x} y: {position.y} </span> <span style={{ marginLeft: '10px' }}> mouse: x: {mouse.x} y: {mouse.y} </span> <span style={{ marginLeft: '10px' }}> image: width/x: {imageDimension.width} height/y: {imageDimension.height} </span> </Box> <Box sx={{ flexGrow: 1, m: 2, height: stageHeight+26}}> <Grid container sx={{ height: '100%' }}> <Grid item> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: stageWidth + 1 }} > <Box style={{ width: stageWidth + 1, height: stageHeight + 1, border: '1px solid black', }} > <Stage width={stageWidth} height={stageHeight} scaleX={position.scale} scaleY={position.scale} x={position.x} y={position.y} draggable // Allows panning onWheel={handleWheel} onDragEnd={handleDragEnd} dragBoundFunc={(pos) => boundStagePosition(pos, position.scale)} ref={stageRef} > <Layer> {image && <KonvaImage ref={imageRef} image={image} width={imageDimension.width} height={imageDimension.height} /> } </Layer> </Stage> {image && <Slider aria-label="holizontal-slider" sx={{ '& .MuiSlider-thumb': { borderRadius: '4px', // Changes the shape to a rectangle/square height: 20, // Optional: Adjust height width: 25, // Optional: Adjust width to make it a vertical rectangle }, '& .MuiSlider-track': { color: FFA_COLOR_MAP.Gray, // Color the track (left of the thumb) border: 'none', // Remove the default border }, '& .MuiSlider-rail': { color: FFA_COLOR_MAP.Gray, // Color the rail (right of the thumb) opacity: 1, // Ensure the rail is fully visible } }} value={-position.x} min={0} max={imageDimension.width} step={100} onChange={handleHorizontalScroll} valueLabelDisplay="auto" /> } </Box> </Box> </Grid> <Grid item xs> {/* 'xs' uses auto-layout to fill remaining width */} {image && <Slider aria-label="vertical-slider" sx={{ height: stageHeight, '& .MuiSlider-thumb': { borderRadius: '4px', // Changes the shape to a rectangle/square height: 25, // Optional: Adjust height width: 20, // Optional: Adjust width to make it a vertical rectangle }, '& .MuiSlider-track': { color: FFA_COLOR_MAP.Gray, // Color the track (left of the thumb) border: 'none', // Remove the default border }, '& .MuiSlider-rail': { color: FFA_COLOR_MAP.Gray, // Color the rail (right of the thumb) opacity: 1, // Ensure the rail is fully visible }, }} orientation="vertical" value={calculateSliderValue(-position.y)} scale={calculateAppValue} min={0} max={imageDimension.height * position.scale} step={100} onChange={handleVerticalScroll} valueLabelDisplay="auto" /> } </Grid> </Grid> </Box> </Box> ); }; export default PaintImage;src/App.js
import React from "react"; import { Box } from "@mui/system"; import PaintImage from "./components/image/PaintImage"; function App() { return ( <Box> <PaintImage /> </Box> ); } export default App;