Resizing Stress Test with Konva
This is a stress test demo to select and resize many shapes at the same time.
The demo is using two core Konva
features to boost the performance:
1. Layers
Resizing shapes are moved into another layer (another canvas element). So while you resize selected shapes, we don't need to redraw other shapes.
2. Caching
On select
, I am moving all selected shapes into a group and cache that group. The cache action will convert group into bitmap image. It is mush faster to redraw such group on the screen.
Instructions: try to select several shapes and resize/rotate them.
- Vanilla
- React
import Konva from 'konva'; // first we need to create a stage var width = window.innerWidth; var height = window.innerHeight; var stage = new Konva.Stage({ container: 'container', width: width, height: height, }); // layer for all shapes var layer = new Konva.Layer(); stage.add(layer); for (var i = 0; i < 10000; i++) { var shape = new Konva.Circle({ x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, radius: 10, name: 'shape', fill: Konva.Util.getRandomColor(), }); layer.add(shape); } // top layer for transforming group var topLayer = new Konva.Layer(); stage.add(topLayer); var group = new Konva.Group({ draggable: true, }); topLayer.add(group); var tr = new Konva.Transformer(); topLayer.add(tr); // add a new feature, lets add ability to draw selection rectangle var selectionRectangle = new Konva.Rect({ fill: 'rgba(0,0,255,0.5)', visible: false, }); topLayer.add(selectionRectangle); var x1, y1, x2, y2; stage.on('mousedown touchstart', (e) => { // do nothing if we mousedown on the transformer if (e.target.getParent() === tr) { return; } // do nothing if we mousedown on the group if (e.target.parent === group) { return; } x1 = stage.getPointerPosition().x; y1 = stage.getPointerPosition().y; x2 = stage.getPointerPosition().x; y2 = stage.getPointerPosition().y; selectionRectangle.setAttrs({ x: x1, y: y1, width: 0, height: 0, visible: true, }); // move old selection back to original layer group.children.slice().forEach((shape) => { const transform = shape.getAbsoluteTransform(); shape.moveTo(layer); shape.setAttrs(transform.decompose()); }); // reset group transforms group.setAttrs({ x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, }); group.clearCache(); }); stage.on('mousemove touchmove', () => { // do nothing if we didn't start selection if (!selectionRectangle.visible()) { return; } x2 = stage.getPointerPosition().x; y2 = stage.getPointerPosition().y; selectionRectangle.setAttrs({ x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), }); }); stage.on('mouseup touchend', () => { // no nothing if we didn't start selection if (!selectionRectangle.visible()) { return; } // update visibility in timeout, so we can check it in click event setTimeout(() => { selectionRectangle.visible(false); }); var shapes = stage.find('.shape'); var box = selectionRectangle.getClientRect(); // remove all children for better performance layer.removeChildren(); // then check intersections and add all shape into correct container shapes.forEach((shape) => { var intersected = Konva.Util.haveIntersection( box, shape.getClientRect() ); if (intersected) { group.add(shape); shape.stroke('blue'); } else { layer.add(shape); shape.stroke(null); } }); if (group.children.length) { tr.nodes([group]); group.cache(); } else { tr.nodes([]); group.clearCache(); } }); // clicks should select/deselect shapes stage.on('click tap', function (e) { // if we are selecting with rect, do nothing if (selectionRectangle.visible()) { return; } // if click on empty area - remove all selections if (e.target === stage) { tr.nodes([]); return; } });
import { useState, useRef, useEffect } from 'react'; import { Stage, Layer, Circle, Group, Transformer, Rect } from 'react-konva'; import Konva from 'konva'; const App = () => { const [shapes, setShapes] = useState([]); const [selectedShapes, setSelectedShapes] = useState([]); const [selectionRect, setSelectionRect] = useState({ visible: false, x1: 0, y1: 0, x2: 0, y2: 0, }); const groupRef = useRef(); const trRef = useRef(); const layerRef = useRef(); const topLayerRef = useRef(); const selectionRectRef = useRef(); // Generate initial shapes useEffect(() => { const initialShapes = []; for (let i = 0; i < 10000; i++) { initialShapes.push({ id: i, x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, radius: 10, fill: Konva.Util.getRandomColor(), stroke: null, name: 'shape', }); } setShapes(initialShapes); }, []); useEffect(() => { if (selectedShapes.length > 0 && groupRef.current) { trRef.current.nodes([groupRef.current]); groupRef.current.cache(); } else { trRef.current.nodes([]); if (groupRef.current) { groupRef.current.clearCache(); } } }, [selectedShapes]); const handleMouseDown = (e) => { // do nothing if we mousedown on transformer or on the group const clickedOnTransformer = e.target.getParent() === trRef.current; const clickedOnGroup = e.target.parent === groupRef.current; if (clickedOnTransformer || clickedOnGroup) { return; } const pointerPos = e.target.getStage().getPointerPosition(); setSelectionRect({ visible: true, x1: pointerPos.x, y1: pointerPos.y, x2: pointerPos.x, y2: pointerPos.y, }); // move old selection back to original layer if (groupRef.current && selectedShapes.length > 0) { const updatedShapes = [...shapes]; const transform = groupRef.current.getAbsoluteTransform(); selectedShapes.forEach(selectedShape => { const shapeIndex = updatedShapes.findIndex(s => s.id === selectedShape.id); if (shapeIndex !== -1) { const shape = updatedShapes[shapeIndex]; const absPos = transform.point({ x: shape.x, y: shape.y }); const scale = transform.decompose().scaleX; updatedShapes[shapeIndex] = { ...shape, x: absPos.x, y: absPos.y, radius: shape.radius * scale, stroke: null }; } }); setShapes(updatedShapes); setSelectedShapes([]); } // reset group transforms if (groupRef.current) { groupRef.current.setAttrs({ x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, }); groupRef.current.clearCache(); } }; const handleMouseMove = (e) => { if (!selectionRect.visible) { return; } const pointerPos = e.target.getStage().getPointerPosition(); setSelectionRect({ ...selectionRect, x2: pointerPos.x, y2: pointerPos.y, }); }; const handleMouseUp = (e) => { if (!selectionRect.visible) { return; } // hide selection rectangle with timeout, so we can check it in click event setTimeout(() => { setSelectionRect({ ...selectionRect, visible: false, }); }); var shapes = e.target.getStage().find('.shape'); // Create box manually instead of using getClientRect() var box = { x: Math.min(selectionRect.x1, selectionRect.x2), y: Math.min(selectionRect.y1, selectionRect.y2), width: Math.abs(selectionRect.x2 - selectionRect.x1), height: Math.abs(selectionRect.y2 - selectionRect.y1) }; // remove all children for better performance layerRef.current.removeChildren(); // then check intersections and add all shape into correct container shapes.forEach((shape) => { var intersected = Konva.Util.haveIntersection( box, shape.getClientRect() ); if (intersected) { groupRef.current.add(shape); shape.stroke('blue'); } else { layerRef.current.add(shape); shape.stroke(null); } }); if (groupRef.current.children.length) { trRef.current.nodes([groupRef.current]); groupRef.current.cache(); } else { trRef.current.nodes([]); groupRef.current.clearCache(); } }; const handleClick = (e) => { // if we are selecting with rect, do nothing if (selectionRect.visible) { return; } // if click on empty area - remove all selections if (e.target === e.target.getStage()) { if (groupRef.current && selectedShapes.length > 0) { const updatedShapes = [...shapes]; const transform = groupRef.current.getAbsoluteTransform(); selectedShapes.forEach(selectedShape => { const shapeIndex = updatedShapes.findIndex(s => s.id === selectedShape.id); if (shapeIndex !== -1) { const shape = updatedShapes[shapeIndex]; const absPos = transform.point({ x: shape.x, y: shape.y }); const scale = transform.decompose().scaleX; updatedShapes[shapeIndex] = { ...shape, x: absPos.x, y: absPos.y, radius: shape.radius * scale, stroke: null }; } }); setShapes(updatedShapes); } setSelectedShapes([]); trRef.current.nodes([]); if (groupRef.current) { groupRef.current.clearCache(); } return; } }; // Calculate selection rectangle properties const selectionRectProps = { fill: 'rgba(0,0,255,0.5)', visible: selectionRect.visible, x: Math.min(selectionRect.x1, selectionRect.x2), y: Math.min(selectionRect.y1, selectionRect.y2), width: Math.abs(selectionRect.x2 - selectionRect.x1), height: Math.abs(selectionRect.y2 - selectionRect.y1), ref: selectionRectRef }; return ( <Stage width={window.innerWidth} height={window.innerHeight} onMouseDown={handleMouseDown} onTouchStart={handleMouseDown} onMouseMove={handleMouseMove} onTouchMove={handleMouseMove} onMouseUp={handleMouseUp} onTouchEnd={handleMouseUp} onClick={handleClick} onTap={handleClick} > <Layer ref={layerRef}> {shapes.filter(shape => !selectedShapes.find(s => s.id === shape.id)).map(shape => ( <Circle key={shape.id} id={shape.id.toString()} x={shape.x} y={shape.y} radius={shape.radius} fill={shape.fill} stroke={shape.stroke} name="shape" /> ))} </Layer> <Layer ref={topLayerRef}> <Group ref={groupRef} draggable > {selectedShapes.map(shape => ( <Circle key={shape.id} id={shape.id.toString()} x={shape.x} y={shape.y} radius={shape.radius} fill={shape.fill} stroke="blue" strokeWidth={2} name="shape" /> ))} </Group> <Transformer ref={trRef} /> <Rect {...selectionRectProps} /> </Layer> </Stage> ); }; export default App;