如何为 HTML5 画布形状显示上下文菜单?
你想为画布形状显示上下文菜单吗?
要显示上下文菜单,我们需要:
- 监听画布容器(舞台)的
contextmenu
事件 - 阻止默认的浏览器行为,以便不显示原生的上下文菜单
- 使用
Konva
工具或常规 HTML 创建我们自己的上下文菜单
说明:双击舞台以创建一个圆形。尝试在形状上右键单击(上下文菜单)以获取菜单。
- Vanilla
- React
- Vue
import Konva from 'konva'; // 创建一个 div 用作上下文菜单 const menuNode = document.createElement('div'); menuNode.id = 'menu'; menuNode.style.display = 'none'; menuNode.style.position = 'fixed'; menuNode.style.width = '60px'; menuNode.style.backgroundColor = 'white'; menuNode.style.boxShadow = '0 0 5px grey'; menuNode.style.borderRadius = '3px'; // 为菜单创建按钮 const pulseButton = document.createElement('button'); pulseButton.textContent = '脉冲'; pulseButton.style.width = '100%'; pulseButton.style.backgroundColor = 'white'; pulseButton.style.border = 'none'; pulseButton.style.margin = '0'; pulseButton.style.padding = '10px'; const deleteButton = document.createElement('button'); deleteButton.textContent = '删除'; deleteButton.style.width = '100%'; deleteButton.style.backgroundColor = 'white'; deleteButton.style.border = 'none'; deleteButton.style.margin = '0'; deleteButton.style.padding = '10px'; // 添加悬停效果 pulseButton.addEventListener('mouseover', () => { pulseButton.style.backgroundColor = 'lightgray'; }); pulseButton.addEventListener('mouseout', () => { pulseButton.style.backgroundColor = 'white'; }); deleteButton.addEventListener('mouseover', () => { deleteButton.style.backgroundColor = 'lightgray'; }); deleteButton.addEventListener('mouseout', () => { deleteButton.style.backgroundColor = 'white'; }); // 将按钮添加到菜单 menuNode.appendChild(pulseButton); menuNode.appendChild(deleteButton); document.body.appendChild(menuNode); // 设置舞台 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 shape = new Konva.Circle({ x: stage.width() / 2, y: stage.height() / 2, radius: 50, fill: 'red', shadowBlur: 10, }); layer.add(shape); let currentShape; // 设置菜单功能 pulseButton.addEventListener('click', () => { currentShape.to({ scaleX: 2, scaleY: 2, onFinish: () => { currentShape.to({ scaleX: 1, scaleY: 1 }); }, }); }); deleteButton.addEventListener('click', () => { currentShape.destroy(); }); // 在文档点击时隐藏菜单 window.addEventListener('click', () => { menuNode.style.display = 'none'; }); // 添加双击事件以创建新形状 stage.on('dblclick dbltap', function () { // 添加一个新形状 const newShape = new Konva.Circle({ x: stage.getPointerPosition().x, y: stage.getPointerPosition().y, radius: 10 + Math.random() * 30, fill: Konva.Util.getRandomColor(), shadowBlur: 10, }); layer.add(newShape); }); // 添加上下文菜单事件 stage.on('contextmenu', function (e) { // 防止默认行为 e.evt.preventDefault(); if (e.target === stage) { // 如果我们在舞台的空白区域,将不执行任何操作 return; } currentShape = e.target; // 显示菜单 menuNode.style.display = 'initial'; const containerRect = stage.container().getBoundingClientRect(); menuNode.style.top = containerRect.top + stage.getPointerPosition().y + 4 + 'px'; menuNode.style.left = containerRect.left + stage.getPointerPosition().x + 4 + 'px'; });
import { useState, useRef, useEffect } from 'react'; import { Stage, Layer, Circle } from 'react-konva'; const App = () => { const [circles, setCircles] = useState([ { id: 'initial-circle', x: window.innerWidth / 2, y: window.innerHeight / 2, radius: 50, fill: 'red', shadowBlur: 10 } ]); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const [showMenu, setShowMenu] = useState(false); const [selectedId, setSelectedId] = useState(null); const stageRef = useRef(null); const width = window.innerWidth; const height = window.innerHeight; // 创建和清理上下文菜单 useEffect(() => { // 在窗口点击时隐藏菜单 const handleWindowClick = () => { setShowMenu(false); }; window.addEventListener('click', handleWindowClick); return () => { window.removeEventListener('click', handleWindowClick); }; }, []); // 处理双击以创建新圆形 const handleDblClick = (e) => { const stage = e.target.getStage(); const pointerPosition = stage.getPointerPosition(); const newCircle = { id: Date.now().toString(), x: pointerPosition.x, y: pointerPosition.y, radius: 10 + Math.random() * 30, fill: getRandomColor(), shadowBlur: 10 }; setCircles([...circles, newCircle]); }; // 处理圆形的上下文菜单 const handleContextMenu = (e) => { e.evt.preventDefault(); if (e.target === e.target.getStage()) { return; } const stage = e.target.getStage(); const containerRect = stage.container().getBoundingClientRect(); const pointerPosition = stage.getPointerPosition(); setMenuPosition({ x: containerRect.left + pointerPosition.x + 4, y: containerRect.top + pointerPosition.y + 4 }); setShowMenu(true); setSelectedId(e.target.id()); e.cancelBubble = true; }; // 菜单动作处理器 const handlePulse = () => { const newCircles = circles.map(circle => { if (circle.id === selectedId) { return { ...circle, scaleX: 2, scaleY: 2, animation: 'pulse' }; } return circle; }); setCircles(newCircles); // 动画后重置缩放 setTimeout(() => { const resetCircles = circles.map(circle => { if (circle.id === selectedId) { return { ...circle, scaleX: 1, scaleY: 1, animation: null }; } return circle; }); setCircles(resetCircles); }, 300); }; const handleDelete = () => { const newCircles = circles.filter(circle => circle.id !== selectedId); setCircles(newCircles); setShowMenu(false); }; // 随机颜色的实用函数 const getRandomColor = () => { const letters = '0123456789ABCDEF'; let color = '#'; for (let i = 0; i < 6; i++) { color += letters[Math.floor(Math.random() * 16)]; } return color; }; return ( <div style={{ position: 'relative' }}> <Stage width={width} height={height} onDblClick={handleDblClick} onContextMenu={handleContextMenu} ref={stageRef} > <Layer> {circles.map((circle) => ( <Circle key={circle.id} id={circle.id} x={circle.x} y={circle.y} radius={circle.radius} fill={circle.fill} shadowBlur={circle.shadowBlur} scaleX={circle.scaleX || 1} scaleY={circle.scaleY || 1} /> ))} </Layer> </Stage> {/* 上下文菜单 */} {showMenu && ( <div style={{ position: 'fixed', top: menuPosition.y, left: menuPosition.x, width: '60px', backgroundColor: 'white', boxShadow: '0 0 5px grey', borderRadius: '3px', zIndex: 10 }} onClick={(e) => e.stopPropagation()} > <button style={{ width: '100%', backgroundColor: 'white', border: 'none', margin: 0, padding: '10px', cursor: 'pointer' }} onMouseOver={(e) => e.target.style.backgroundColor = 'lightgray'} onMouseOut={(e) => e.target.style.backgroundColor = 'white'} onClick={handlePulse} > 脉冲 </button> <button style={{ width: '100%', backgroundColor: 'white', border: 'none', margin: 0, padding: '10px', cursor: 'pointer' }} onMouseOver={(e) => e.target.style.backgroundColor = 'lightgray'} onMouseOut={(e) => e.target.style.backgroundColor = 'white'} onClick={handleDelete} > 删除 </button> </div> )} </div> ); }; export default App;
<template> <div style="position: relative"> <v-stage ref="stageRef" :config="stageConfig" @dblclick="handleDblClick" @contextmenu="handleContextMenu" > <v-layer> <v-circle v-for="circle in circles" :key="circle.id" :config="{ id: circle.id, x: circle.x, y: circle.y, radius: circle.radius, fill: circle.fill, shadowBlur: circle.shadowBlur, scaleX: circle.scaleX || 1, scaleY: circle.scaleY || 1, }" /> </v-layer> </v-stage> <!-- 上下文菜单 --> <div v-if="showMenu" :style="{ position: 'fixed', top: menuPosition.y + 'px', left: menuPosition.x + 'px', width: '60px', backgroundColor: 'white', boxShadow: '0 0 5px grey', borderRadius: '3px', zIndex: 10 }" @click.stop > <button :style="{ width: '100%', backgroundColor: 'white', border: 'none', margin: 0, padding: '10px', cursor: 'pointer' }" @mouseover="e => e.target.style.backgroundColor = 'lightgray'" @mouseout="e => e.target.style.backgroundColor = 'white'" @click="handlePulse" > 脉冲 </button> <button :style="{ width: '100%', backgroundColor: 'white', border: 'none', margin: 0, padding: '10px', cursor: 'pointer' }" @mouseover="e => e.target.style.backgroundColor = 'lightgray'" @mouseout="e => e.target.style.backgroundColor = 'white'" @click="handleDelete" > 删除 </button> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; const stageRef = ref(null); const stageConfig = { width: window.innerWidth, height: window.innerHeight }; // 圆形和上下文菜单的状态 const circles = ref([ { id: 'initial-circle', x: window.innerWidth / 2, y: window.innerHeight / 2, radius: 50, fill: 'red', shadowBlur: 10 } ]); const menuPosition = ref({ x: 0, y: 0 }); const showMenu = ref(false); const selectedId = ref(null); // 创建和清理上下文菜单 onMounted(() => { window.addEventListener('click', handleWindowClick); }); onUnmounted(() => { window.removeEventListener('click', handleWindowClick); }); // 在窗口点击时隐藏菜单 const handleWindowClick = () => { showMenu.value = false; }; // 处理双击以创建新圆形 const handleDblClick = (e) => { const stage = e.target.getStage(); const pointerPosition = stage.getPointerPosition(); const newCircle = { id: Date.now().toString(), x: pointerPosition.x, y: pointerPosition.y, radius: 10 + Math.random() * 30, fill: getRandomColor(), shadowBlur: 10 }; circles.value.push(newCircle); }; // 处理圆形的上下文菜单 const handleContextMenu = (e) => { e.evt.preventDefault(); if (e.target === e.target.getStage()) { return; } const stage = e.target.getStage(); const containerRect = stage.container().getBoundingClientRect(); const pointerPosition = stage.getPointerPosition(); menuPosition.value = { x: containerRect.left + pointerPosition.x + 4, y: containerRect.top + pointerPosition.y + 4 }; showMenu.value = true; selectedId.value = e.target.id(); e.cancelBubble = true; }; // 菜单动作处理器 const handlePulse = () => { circles.value = circles.value.map(circle => { if (circle.id === selectedId.value) { return { ...circle, scaleX: 2, scaleY: 2 }; } return circle; }); // 动画后重置缩放 setTimeout(() => { circles.value = circles.value.map(circle => { if (circle.id === selectedId.value) { return { ...circle, scaleX: 1, scaleY: 1 }; } return circle; }); }, 300); }; const handleDelete = () => { circles.value = circles.value.filter(circle => circle.id !== selectedId.value); showMenu.value = false; }; // 随机颜色的实用函数 const getRandomColor = () => { const letters = '0123456789ABCDEF'; let color = '#'; for (let i = 0; i < 6; i++) { color += letters[Math.floor(Math.random() * 16)]; } return color; }; </script>