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 [selectedIds, setSelectedIds] = useState([]);
const [selectionRect, setSelectionRect] = useState({
visible: false,
x1: 0,
y1: 0,
x2: 0,
y2: 0,
});
const [groupKey, setGroupKey] = useState(0);
const groupRef = useRef();
const trRef = useRef();
const selectionRectRef = useRef();
// Generate 10k shapes once
useEffect(() => {
const items = [];
for (let i = 0; i < 10000; i++) {
items.push({
id: i,
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
radius: 10,
fill: Konva.Util.getRandomColor(),
});
}
setShapes(items);
}, []);
// Attach/Detach transformer & cache
useEffect(() => {
if (selectedIds.length && groupRef.current) {
trRef.current.nodes([groupRef.current]);
groupRef.current.cache();
} else {
trRef.current.nodes([]);
if (groupRef.current) groupRef.current.clearCache();
}
}, [selectedIds]);
// Utility to apply group transform to shapes and commit into React state
const applyGroupTransform = () => {
if (!selectedIds.length || !groupRef.current) return;
const transform = groupRef.current.getAbsoluteTransform();
const { scaleX } = transform.decompose();
setShapes((prev) =>
prev.map((shape) => {
if (!selectedIds.includes(shape.id)) return shape;
const pos = transform.point({ x: shape.x, y: shape.y });
return {
...shape,
x: pos.x,
y: pos.y,
radius: shape.radius * scaleX,
};
})
);
};
const pointerPos = (e) => e.target.getStage().getPointerPosition();
const handleMouseDown = (e) => {
// ignore click on transformer or group
if (e.target.getParent() === trRef.current || e.target.parent === groupRef.current) {
return;
}
// finalise previous selection (if any)
applyGroupTransform();
if (selectedIds.length) {
setSelectedIds([]);
setGroupKey((k) => k + 1); // reset group for fresh transform
}
const p = pointerPos(e);
setSelectionRect({ visible: true, x1: p.x, y1: p.y, x2: p.x, y2: p.y });
};
const handleMouseMove = (e) => {
if (!selectionRect.visible) return;
const p = pointerPos(e);
setSelectionRect((prev) => ({ ...prev, x2: p.x, y2: p.y }));
};
const handleMouseUp = (e) => {
if (!selectionRect.visible) return;
setTimeout(() => setSelectionRect((prev) => ({ ...prev, visible: false })), 0);
const stage = e.target.getStage();
const nodes = stage.find('.shape');
const 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),
};
const ids = [];
nodes.forEach((node) => {
if (Konva.Util.haveIntersection(box, node.getClientRect())) {
ids.push(Number(node.id()));
}
});
setSelectedIds(ids);
};
const handleStageClick = (e) => {
// ignore clicks that are part of selection rectangle drawing
if (selectionRect.visible) return;
if (e.target === e.target.getStage()) {
// clicked on empty area: apply transform and clear selection
applyGroupTransform();
if (selectedIds.length) {
setSelectedIds([]);
setGroupKey((k) => k + 1);
}
}
};
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={handleStageClick}
onTap={handleStageClick}
>
<Layer>
{shapes
.filter((s) => !selectedIds.includes(s.id))
.map((shape) => (
<Circle
key={shape.id}
id={String(shape.id)}
x={shape.x}
y={shape.y}
radius={shape.radius}
fill={shape.fill}
name="shape"
/>
))}
</Layer>
<Layer>
<Group key={groupKey} ref={groupRef} draggable>
{shapes
.filter((s) => selectedIds.includes(s.id))
.map((shape) => (
<Circle
key={shape.id}
id={String(shape.id)}
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;