HTML5 Canvas 形状选择、调整大小与旋转
Transformer
是一种特殊的 Konva.Group
。它允许你轻松地调整任何节点或节点集合的大小和旋转。
要启用它,你需要:
- 使用
new Konva.Transformer()
创建新的实例 - 将其添加到图层
- 使用
transformer.nodes([shape]);
附加到节点
注意: 变换工具在调整大小时不会改变节点的 width
和 height
属性,而是改变 scaleX
和 scaleY
属性。
说明:尝试调整形状大小和旋转。点击空白区域以取消选择。使用 SHIFT 或 CTRL 键来添加/移除选择中的形状。尝试在画布上选择区域。
- 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); // 创建矩形 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); // 创建变换器 const tr = new Konva.Transformer(); layer.add(tr); // 添加一个新功能,增加绘制选择矩形的能力 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) => { // 如果在任何形状上按下鼠标,则不做任何操作 if (e.target !== stage) { 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, }); }); stage.on('mousemove touchmove', () => { // 如果没有开始选择,则不做任何操作 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', () => { // 如果没有开始选择,则不做任何操作 if (!selectionRectangle.visible()) { return; } // 在超时内更新可见性,以便我们可以在点击事件中检查 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); }); // 点击应该选择/取消选择形状 stage.on('click tap', function (e) { // 如果我们正在用矩形选择,则不做任何操作 if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) { return; } // 如果点击空白区域 - 移除所有选择 if (e.target === stage) { tr.nodes([]); return; } // 如果点击的不是我们的矩形则不做任何操作 if (!e.target.hasName('rect')) { return; } // 检查我们是否按下了shift或ctrl? const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; const isSelected = tr.nodes().indexOf(e.target) >= 0; if (!metaPressed && !isSelected) { // 如果没有按下键且节点未被选中 // 仅选择一个 tr.nodes([e.target]); } else if (metaPressed && isSelected) { // 如果我们按下了键且节点已被选中 // 我们需要将其移除选择: const nodes = tr.nodes().slice(); // 使用slice以获得数组的新副本 // 从数组中移除节点 nodes.splice(nodes.indexOf(e.target), 1); tr.nodes(nodes); } else if (metaPressed && !isSelected) { // 将节点添加到选择中 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', rotation: 0, }, { x: 250, y: 100, width: 150, height: 90, fill: 'green', id: 'rect2', name: 'rect', rotation: 0, }, ]; // Helper functions for calculating bounding boxes of rotated rectangles const degToRad = (angle) => (angle / 180) * Math.PI; const getCorner = (pivotX, pivotY, diffX, diffY, angle) => { const distance = Math.sqrt(diffX * diffX + diffY * diffY); angle += Math.atan2(diffY, diffX); const x = pivotX + distance * Math.cos(angle); const y = pivotY + distance * Math.sin(angle); return { x, y }; }; const getClientRect = (element) => { const { x, y, width, height, rotation = 0 } = element; const rad = degToRad(rotation); const p1 = getCorner(x, y, 0, 0, rad); const p2 = getCorner(x, y, width, 0, rad); const p3 = getCorner(x, y, width, height, rad); const p4 = getCorner(x, y, 0, height, rad); const minX = Math.min(p1.x, p2.x, p3.x, p4.x); const minY = Math.min(p1.y, p2.y, p3.y, p4.y); const maxX = Math.max(p1.x, p2.x, p3.x, p4.x); const maxY = Math.max(p1.y, p2.y, p3.y, p4.y); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; }; 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()); // 更新变换器,当选择发生变化时 useEffect(() => { if (selectedIds.length && transformerRef.current) { // 从refs映射中获取节点 const nodes = selectedIds .map(id => rectRefs.current.get(id)) .filter(node => node); transformerRef.current.nodes(nodes); } else if (transformerRef.current) { // 清除选择 transformerRef.current.nodes([]); } }, [selectedIds]); // 点击事件处理函数 const handleStageClick = (e) => { // 如果我们正在用矩形选择,则不做任何操作 if (selectionRectangle.visible) { return; } // 如果点击空白区域 - 移除所有选择 if (e.target === e.target.getStage()) { setSelectedIds([]); return; } // 如果点击的不是我们的矩形则不做任何操作 if (!e.target.hasName('rect')) { return; } const clickedId = e.target.id(); // 检查我们是否按下了shift或ctrl? const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; const isSelected = selectedIds.includes(clickedId); if (!metaPressed && !isSelected) { // 如果未按下任何键且节点未被选中 // 仅选择一个 setSelectedIds([clickedId]); } else if (metaPressed && isSelected) { // 如果我们按下了键且节点已被选中 // 我们需要将其移除选择 setSelectedIds(selectedIds.filter(id => id !== clickedId)); } else if (metaPressed && !isSelected) { // 将节点添加到选择中 setSelectedIds([...selectedIds, clickedId]); } }; const handleMouseDown = (e) => { // 如果在任何形状上按下鼠标,则不做任何操作 if (e.target !== e.target.getStage()) { return; } // 开始选择矩形 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) => { // 如果没有开始选择,则不做任何操作 if (!isSelecting.current) { return; } const pos = e.target.getStage().getPointerPosition(); setSelectionRectangle({ ...selectionRectangle, x2: pos.x, y2: pos.y, }); }; const handleMouseUp = () => { // 如果没有开始选择,则不做任何操作 if (!isSelecting.current) { return; } isSelecting.current = false; // 在超时内更新可见性,以便我们可以在点击事件中检查 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 => { // 检查矩形是否与选择框相交 return Konva.Util.haveIntersection(selBox, getClientRect(rect)); }); setSelectedIds(selected.map(rect => rect.id)); }; const handleDragEnd = (e) => { const id = e.target.id(); setRectangles(prevRects => { const newRects = [...prevRects]; const index = newRects.findIndex(r => r.id === id); if (index !== -1) { newRects[index] = { ...newRects[index], x: e.target.x(), y: e.target.y() }; } return newRects; }); }; const handleTransformEnd = (e) => { // 查找被变换的矩形 const id = e.target.id(); const node = e.target; setRectangles(prevRects => { const newRects = [...prevRects]; // 更新每个变换的节点 const index = newRects.findIndex(r => r.id === id); if (index !== -1) { const scaleX = node.scaleX(); const scaleY = node.scaleY(); // 重置缩放 node.scaleX(1); node.scaleY(1); // 更新状态以反映新值 newRects[index] = { ...newRects[index], x: node.x(), y: node.y(), width: Math.max(5, node.width() * scaleX), height: Math.max(node.height() * scaleY), rotation: node.rotation(), }; } return newRects; }); }; return ( <Stage width={window.innerWidth} height={window.innerHeight} onMouseDown={handleMouseDown} onMousemove={handleMouseMove} onMouseup={handleMouseUp} onClick={handleStageClick} > <Layer> {/* 直接渲染矩形 */} {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} rotation={rect.rotation} draggable ref={node => { if (node) { rectRefs.current.set(rect.id, node); } }} onDragEnd={handleDragEnd} onTransformEnd={handleTransformEnd} /> ))} {/* 所有选中形状的变换器 */} <Transformer ref={transformerRef} boundBoxFunc={(oldBox, newBox) => { // 限制调整大小 if (newBox.width < 5 || newBox.height < 5) { return oldBox; } return newBox; }} /> {/* 选择矩形 */} {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', // 与纯JavaScript版本的逻辑相匹配非常重要 draggable: true }" @dragend="(e) => handleDragEnd(e, i)" @transformend="(e) => handleTransformEnd(e, i)" ref="rectRefs" /> <v-transformer ref="transformerRef" :config="{ boundBoxFunc: (oldBox, newBox) => { // 限制调整大小 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', rotation: 0, }, { x: 250, y: 100, width: 150, height: 90, fill: 'green', id: 'rect2', rotation: 0, }, ]); 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 }); // 计算旋转矩形边界框的辅助函数 const degToRad = (angle) => (angle / 180) * Math.PI; const getCorner = (pivotX, pivotY, diffX, diffY, angle) => { const distance = Math.sqrt(diffX * diffX + diffY * diffY); angle += Math.atan2(diffY, diffX); const x = pivotX + distance * Math.cos(angle); const y = pivotY + distance * Math.sin(angle); return { x, y }; }; const getClientRect = (element) => { const { x, y, width, height, rotation = 0 } = element; const rad = degToRad(rotation); const p1 = getCorner(x, y, 0, 0, rad); const p2 = getCorner(x, y, width, 0, rad); const p3 = getCorner(x, y, width, height, rad); const p4 = getCorner(x, y, 0, height, rad); const minX = Math.min(p1.x, p2.x, p3.x, p4.x); const minY = Math.min(p1.y, p2.y, p3.y, p4.y); const maxX = Math.max(p1.x, p2.x, p3.x, p4.x); const maxY = Math.max(p1.y, p2.y, p3.y, p4.y); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; }; // 当选择变化时更新变换器节点 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 (selectionRectangle.visible) { return; } // 如果点击空白区域 - 移除所有选择 if (e.target === e.target.getStage()) { selectedIds.value = []; return; } // 如果点击的不是我们的矩形则不做任何操作 if (!e.target.hasName('rect')) { return; } const clickedId = e.target.attrs.id; // 检查我们是否按下了shift或ctrl? const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; const isSelected = selectedIds.value.includes(clickedId); if (!metaPressed && !isSelected) { // 如果没有按下任何键且节点未被选中 // 仅选择一个 selectedIds.value = [clickedId]; } else if (metaPressed && isSelected) { // 如果我们按下了键且节点已被选中 // 我们需要将其移除选择: selectedIds.value = selectedIds.value.filter(id => id !== clickedId); } else if (metaPressed && !isSelected) { // 将节点添加到选择中 selectedIds.value = [...selectedIds.value, clickedId]; } }; const handleMouseDown = (e) => { // 如果在任何形状上按下鼠标,则不做任何操作 if (e.target !== e.target.getStage()) { return; } // 开始选择矩形 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) => { // 如果没有开始选择,则不做任何操作 if (!isSelecting.value) { return; } const pos = e.target.getStage().getPointerPosition(); selectionRectangle.x2 = pos.x; selectionRectangle.y2 = pos.y; }; const handleMouseUp = () => { // 如果没有开始选择,则不做任何操作 if (!isSelecting.value) { return; } isSelecting.value = false; // 在超时内更新可见性,以便我们可以在点击事件中检查 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 => { // 检查矩形是否与选择框相交 return Konva.Util.haveIntersection(selBox, getClientRect(rect)); }); 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), rotation: node.rotation(), }; rectangles.value = rects; }; </script>