HTML5 Canvas Shape select, resize and rotate
Transformer
is a special kind of Konva.Group
. It allows you easily resize and rotate any node or set of nodes.
To enable it you need to:
- Create new instance with
new Konva.Transformer()
- Add it to layer
- attach to node with
transformer.nodes([shape]);
Note: Transforming tool is not changing width
and height
properties of nodes when you resize them. Instead it changes scaleX
and scaleY
properties.
Instructions: Try to resize and rotate shapes. Click on empty area to remove selection. Use SHIFT or CTRL to add/remove shapes into/from selection. Try to select area on a canvas.
- Vanilla
- React
- Vue
import Konva from 'konva'; const width = window.innerWidth; const height = window.innerHeight; const stage = new Konva.Stage({ container: 'container', width: width, height: height, }); const layer = new Konva.Layer(); stage.add(layer); // create rectangle const rect1 = new Konva.Rect({ x: 60, y: 60, width: 100, height: 90, fill: 'red', name: 'rect', draggable: true, }); layer.add(rect1); const rect2 = new Konva.Rect({ x: 250, y: 100, width: 150, height: 90, fill: 'green', name: 'rect', draggable: true, }); layer.add(rect2); // create transformer const tr = new Konva.Transformer(); layer.add(tr); // add a new feature, lets add ability to draw selection rectangle let selectionRectangle = new Konva.Rect({ fill: 'rgba(0,0,255,0.5)', visible: false, }); layer.add(selectionRectangle); let x1, y1, x2, y2; stage.on('mousedown touchstart', (e) => { // do nothing if we mousedown on any shape if (e.target !== stage) { return; } x1 = stage.getPointerPosition().x; y1 = stage.getPointerPosition().y; x2 = stage.getPointerPosition().x; y2 = stage.getPointerPosition().y; selectionRectangle.visible(true); selectionRectangle.width(0); selectionRectangle.height(0); }); 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', () => { // do 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('.rect'); var box = selectionRectangle.getClientRect(); var selected = shapes.filter((shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) ); tr.nodes(selected); }); // 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; } // do nothing if clicked NOT on our rectangles if (!e.target.hasName('rect')) { return; } // do we pressed shift or ctrl? const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; const isSelected = tr.nodes().indexOf(e.target) >= 0; if (!metaPressed && !isSelected) { // if no key pressed and the node is not selected // select just one tr.nodes([e.target]); } else if (metaPressed && isSelected) { // if we pressed keys and node was selected // we need to remove it from selection: const nodes = tr.nodes().slice(); // use slice to have new copy of array // remove node from array nodes.splice(nodes.indexOf(e.target), 1); tr.nodes(nodes); } else if (metaPressed && !isSelected) { // add the node into selection const nodes = tr.nodes().concat([e.target]); tr.nodes(nodes); } });
import { Stage, Layer, Rect, Transformer } from 'react-konva'; import { useState, useEffect, useRef } from 'react'; const initialRectangles = [ { x: 60, y: 60, width: 100, height: 90, fill: 'red', id: 'rect1', name: 'rect', }, { x: 250, y: 100, width: 150, height: 90, fill: 'green', id: 'rect2', name: 'rect', }, ]; const App = () => { const [rectangles, setRectangles] = useState(initialRectangles); const [selectedIds, setSelectedIds] = useState([]); const [selectionRectangle, setSelectionRectangle] = useState({ visible: false, x1: 0, y1: 0, x2: 0, y2: 0, }); const isSelecting = useRef(false); const transformerRef = useRef(); const rectRefs = useRef(new Map()); // Update transformer when selection changes useEffect(() => { if (selectedIds.length && transformerRef.current) { // Get the nodes from the refs Map const nodes = selectedIds .map(id => rectRefs.current.get(id)) .filter(node => node); transformerRef.current.nodes(nodes); } else if (transformerRef.current) { // Clear selection transformerRef.current.nodes([]); } }, [selectedIds]); // Click handler for stage const handleStageClick = (e) => { // If we are selecting with rect, do nothing if (selectionRectangle.visible) { return; } // If click on empty area - remove all selections if (e.target === e.target.getStage()) { setSelectedIds([]); return; } // Do nothing if clicked NOT on our rectangles if (!e.target.hasName('rect')) { return; } const clickedId = e.target.id(); // Do we pressed shift or ctrl? const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; const isSelected = selectedIds.includes(clickedId); if (!metaPressed && !isSelected) { // If no key pressed and the node is not selected // select just one setSelectedIds([clickedId]); } else if (metaPressed && isSelected) { // If we pressed keys and node was selected // we need to remove it from selection setSelectedIds(selectedIds.filter(id => id !== clickedId)); } else if (metaPressed && !isSelected) { // Add the node into selection setSelectedIds([...selectedIds, clickedId]); } }; const handleMouseDown = (e) => { // Do nothing if we mousedown on any shape if (e.target !== e.target.getStage()) { return; } // Start selection rectangle isSelecting.current = true; const pos = e.target.getStage().getPointerPosition(); setSelectionRectangle({ visible: true, x1: pos.x, y1: pos.y, x2: pos.x, y2: pos.y, }); }; const handleMouseMove = (e) => { // Do nothing if we didn't start selection if (!isSelecting.current) { return; } const pos = e.target.getStage().getPointerPosition(); setSelectionRectangle({ ...selectionRectangle, x2: pos.x, y2: pos.y, }); }; const handleMouseUp = () => { // Do nothing if we didn't start selection if (!isSelecting.current) { return; } isSelecting.current = false; // Update visibility in timeout, so we can check it in click event setTimeout(() => { setSelectionRectangle({ ...selectionRectangle, visible: false, }); }); const selBox = { x: Math.min(selectionRectangle.x1, selectionRectangle.x2), y: Math.min(selectionRectangle.y1, selectionRectangle.y2), width: Math.abs(selectionRectangle.x2 - selectionRectangle.x1), height: Math.abs(selectionRectangle.y2 - selectionRectangle.y1), }; const selected = rectangles.filter(rect => { // Check if rectangle intersects with selection box return Konva.Util.haveIntersection(selBox, { x: rect.x, y: rect.y, width: rect.width, height: rect.height, }); }); setSelectedIds(selected.map(rect => rect.id)); }; const handleDragEnd = (e) => { const id = e.target.id(); const index = rectangles.findIndex(r => r.id === id); if (index !== -1) { const rects = [...rectangles]; rects[index] = { ...rects[index], x: e.target.x(), y: e.target.y(), }; setRectangles(rects); } }; const handleTransformEnd = (e) => { // Find which rectangle(s) were transformed const nodes = transformerRef.current.nodes(); const newRects = [...rectangles]; // Update each transformed node nodes.forEach(node => { const id = node.id(); const index = newRects.findIndex(r => r.id === id); if (index !== -1) { const scaleX = node.scaleX(); const scaleY = node.scaleY(); // Reset scale node.scaleX(1); node.scaleY(1); // Update the state with new values newRects[index] = { ...newRects[index], x: node.x(), y: node.y(), width: Math.max(5, node.width() * scaleX), height: Math.max(node.height() * scaleY), }; } }); setRectangles(newRects); }; return ( <Stage width={window.innerWidth} height={window.innerHeight} onMouseDown={handleMouseDown} onMousemove={handleMouseMove} onMouseup={handleMouseUp} onClick={handleStageClick} > <Layer> {/* Render rectangles directly */} {rectangles.map(rect => ( <Rect key={rect.id} id={rect.id} x={rect.x} y={rect.y} width={rect.width} height={rect.height} fill={rect.fill} name={rect.name} draggable ref={node => { if (node) { rectRefs.current.set(rect.id, node); } }} onDragEnd={handleDragEnd} /> ))} {/* Single transformer for all selected shapes */} <Transformer ref={transformerRef} boundBoxFunc={(oldBox, newBox) => { // Limit resize if (newBox.width < 5 || newBox.height < 5) { return oldBox; } return newBox; }} onTransformEnd={handleTransformEnd} /> {/* Selection rectangle */} {selectionRectangle.visible && ( <Rect x={Math.min(selectionRectangle.x1, selectionRectangle.x2)} y={Math.min(selectionRectangle.y1, selectionRectangle.y2)} width={Math.abs(selectionRectangle.x2 - selectionRectangle.x1)} height={Math.abs(selectionRectangle.y2 - selectionRectangle.y1)} fill="rgba(0,0,255,0.5)" /> )} </Layer> </Stage> ); }; export default App;
<template> <v-stage :config="stageSize" @mousedown="handleMouseDown" @mousemove="handleMouseMove" @mouseup="handleMouseUp" @click="handleStageClick" ref="stageRef" > <v-layer ref="layerRef"> <v-rect v-for="(rect, i) in rectangles" :key="i" :config="{ ...rect, name: 'rect', // Important to match vanilla version's logic draggable: true }" @dragend="(e) => handleDragEnd(e, i)" @transformend="(e) => handleTransformEnd(e, i)" ref="rectRefs" /> <v-transformer ref="transformerRef" :config="{ boundBoxFunc: (oldBox, newBox) => { // limit resize if (newBox.width < 5 || newBox.height < 5) { return oldBox; } return newBox; }, }" /> <v-rect v-if="selectionRectangle.visible" :config="{ x: Math.min(selectionRectangle.x1, selectionRectangle.x2), y: Math.min(selectionRectangle.y1, selectionRectangle.y2), width: Math.abs(selectionRectangle.x2 - selectionRectangle.x1), height: Math.abs(selectionRectangle.y2 - selectionRectangle.y1), fill: 'rgba(0,0,255,0.5)' }" /> </v-layer> </v-stage> </template> <script setup> import { ref, watch, reactive, onMounted } from 'vue'; const stageSize = { width: window.innerWidth, height: window.innerHeight, }; const rectangles = ref([ { x: 60, y: 60, width: 100, height: 90, fill: 'red', id: 'rect1', }, { x: 250, y: 100, width: 150, height: 90, fill: 'green', id: 'rect2', }, ]); const selectedIds = ref([]); const rectRefs = ref([]); const transformerRef = ref(null); const stageRef = ref(null); const layerRef = ref(null); const isSelecting = ref(false); const selectionRectangle = reactive({ visible: false, x1: 0, y1: 0, x2: 0, y2: 0 }); // Update transformer nodes when selection changes watch(selectedIds, () => { if (!transformerRef.value) return; const nodes = selectedIds.value.map(id => { return rectRefs.value.find(ref => ref.getNode().attrs.id === id)?.getNode(); }).filter(Boolean); transformerRef.value.getNode().nodes(nodes); }); const handleStageClick = (e) => { // if we are selecting with rect, do nothing if (selectionRectangle.visible) { return; } // if click on empty area - remove all selections if (e.target === e.target.getStage()) { selectedIds.value = []; return; } // do nothing if clicked NOT on our rectangles if (!e.target.hasName('rect')) { return; } const clickedId = e.target.attrs.id; // do we pressed shift or ctrl? const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; const isSelected = selectedIds.value.includes(clickedId); if (!metaPressed && !isSelected) { // if no key pressed and the node is not selected // select just one selectedIds.value = [clickedId]; } else if (metaPressed && isSelected) { // if we pressed keys and node was selected // we need to remove it from selection: selectedIds.value = selectedIds.value.filter(id => id !== clickedId); } else if (metaPressed && !isSelected) { // add the node into selection selectedIds.value = [...selectedIds.value, clickedId]; } }; const handleMouseDown = (e) => { // do nothing if we mousedown on any shape if (e.target !== e.target.getStage()) { return; } // start selection rectangle isSelecting.value = true; const pos = e.target.getStage().getPointerPosition(); selectionRectangle.visible = true; selectionRectangle.x1 = pos.x; selectionRectangle.y1 = pos.y; selectionRectangle.x2 = pos.x; selectionRectangle.y2 = pos.y; }; const handleMouseMove = (e) => { // do nothing if we didn't start selection if (!isSelecting.value) { return; } const pos = e.target.getStage().getPointerPosition(); selectionRectangle.x2 = pos.x; selectionRectangle.y2 = pos.y; }; const handleMouseUp = () => { // do nothing if we didn't start selection if (!isSelecting.value) { return; } isSelecting.value = false; // update visibility in timeout, so we can check it in click event setTimeout(() => { selectionRectangle.visible = false; }); const selBox = { x: Math.min(selectionRectangle.x1, selectionRectangle.x2), y: Math.min(selectionRectangle.y1, selectionRectangle.y2), width: Math.abs(selectionRectangle.x2 - selectionRectangle.x1), height: Math.abs(selectionRectangle.y2 - selectionRectangle.y1), }; const selected = rectangles.value.filter(rect => { // Check if rectangle intersects with selection box return Konva.Util.haveIntersection(selBox, { x: rect.x, y: rect.y, width: rect.width, height: rect.height, }); }); selectedIds.value = selected.map(rect => rect.id); }; const handleDragEnd = (e, index) => { const rects = [...rectangles.value]; rects[index] = { ...rects[index], x: e.target.x(), y: e.target.y(), }; rectangles.value = rects; }; const handleTransformEnd = (e, index) => { const node = rectRefs.value[index].getNode(); const scaleX = node.scaleX(); const scaleY = node.scaleY(); node.scaleX(1); node.scaleY(1); const rects = [...rectangles.value]; rects[index] = { ...rects[index], x: node.x(), y: node.y(), width: Math.max(5, node.width() * scaleX), height: Math.max(node.height() * scaleY), }; rectangles.value = rects; }; </script>