当前位置: 首页 > news >正文

白板功能文档

一、功能概述

医学白板是一个支持多人协作的绘图工具,主要用于医疗场景下的图形标注、测量及文字说明。支持多种绘图工具(手绘笔、直线、箭头、矩形、圆形等),并具备图形选择、移动、删除等编辑功能,同时支持直线距离测量(以厘米为单位)。

二、核心功能模块

1. 组件结构设计

html

预览

<CustomDialog> <!-- 外层弹窗容器 --><div class="whiteboard-container"><canvas> <!-- 绘图画布 --> </canvas><div class="toolbar"> <!-- 工具栏 --> </div></div>
</CustomDialog>

实现步骤:

  1. 使用CustomDialog作为外层容器,控制白板的显示与隐藏
  2. 核心绘图区域使用canvas元素实现
  3. 工具栏根据用户权限(isInitiator)决定是否显示
  4. 通过v-model:visible控制弹窗显示状态

2. 工具栏实现

工具栏组成
  • 线宽控制(滑块调节 1-20px)
  • 绘图工具(手绘笔、直线、箭头、矩形、圆形)
  • 编辑工具(橡皮擦、移动选择)
  • 操作工具(一键清除、颜色选择)
  • 文字添加功能(输入框 + 添加按钮)

实现代码片段:

html

预览

<div v-if="isInitiator" class="toolbar"><!-- 线宽控制 --><div class="line-width-controls"><XmBtn icon-text="线宽选择">...</XmBtn><el-slider v-model="strokeWidthPx" :min="1" :max="20" ...></el-slider></div><!-- 绘图工具按钮 --><XmBtn icon-text="手绘笔" @click="selectTool('pen')">...</XmBtn><XmBtn icon-text="画直线" @click="selectTool('line')">...</XmBtn><!-- 其他工具按钮 --><!-- 文字添加区域 --><div class="xiaoanMeeting-bottomMenuBtn"><el-input v-model="textContent" ...></el-input><el-button @click="confirmAddText">添加</el-button></div>
</div>

实现步骤:

  1. 使用条件渲染v-if="isInitiator"控制工具栏权限
  2. 通过selectTool方法切换当前激活工具
  3. 使用el-slider组件实现线宽调节功能
  4. 文字添加通过输入框 + 按钮触发添加模式

3. 绘图功能实现

核心绘图逻辑
  1. 绘图状态管理

typescript

// 定义绘图工具类型
type DrawingTool = 'pen' | 'rectangle' | 'circle' | 'arrow' | 'eraser' | 'text' | 'line' | 'select'// 定义绘图动作接口
interface DrawingAction {tool: DrawingToolpoints: Point[]  // 坐标点集合color: string    // 颜色width: number    // 线宽text?: string    // 文字内容measurement?: {  // 测量信息(仅直线)distance: numberunit: string}
}
  1. 绘图事件绑定

html

预览

<canvasref="canvasRef"@mousedown="startDrawing"  // 开始绘图@mousemove="draw"         // 绘图过程@mouseup="stopDrawing"     // 结束绘图@mouseleave="stopDrawing"> // 离开画布
</canvas>
  1. 开始绘图(startDrawing)

typescript

const startDrawing = (e: MouseEvent) => {// 获取鼠标在画布上的百分比坐标const rect = canvasRef.value.getBoundingClientRect()const xPercent = (e.clientX - rect.left) / rect.widthconst yPercent = (e.clientY - rect.top) / rect.height// 根据当前工具类型初始化绘图动作currentAction = {tool: activeTool.value,points: [{ x: xPercent, y: yPercent }],color: strokeColor.value,width: strokeWidth.value}isDrawing.value = true
}
  1. 绘图过程(draw)

typescript

const draw = (e: MouseEvent) => {if (!isDrawing.value || !currentAction) return// 计算当前坐标(百分比)const rect = canvasRef.value.getBoundingClientRect()const xPercent = (e.clientX - rect.left) / rect.widthconst yPercent = (e.clientY - rect.top) / rect.height// 根据工具类型处理不同绘图逻辑switch(currentAction.tool) {case 'pen':// 手绘笔添加所有点currentAction.points.push({ x: xPercent, y: yPercent })breakcase 'line':case 'arrow':// 直线和箭头只保留起点和当前点currentAction.points = [currentAction.points[0], { x: xPercent, y: yPercent }]break// 其他工具处理...}// 实时重绘redrawCanvas()
}
  1. 结束绘图(stopDrawing)

typescript

const stopDrawing = () => {if (!isDrawing.value || !currentAction) returnisDrawing.value = false// 对于直线工具,计算测量数据if (currentAction.tool === 'line' && currentAction.points.length >= 2) {// 计算实际距离(像素转厘米)const pixelDistance = ... // 计算像素距离const cmDistance = Number((pixelDistance / 37.8).toFixed(2)) // 1cm = 37.8像素// 存储测量信息currentAction.measurement = {distance: cmDistance,unit: 'cm'}}// 保存到历史记录并发送给其他用户drawingHistory.value.push(currentAction)sendDrawingAction(currentAction)
}
  1. 重绘机制(redrawCanvas)

typescript

const redrawCanvas = () => {if (!canvasContext || !canvasRef.value) return// 清空画布canvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)// 重绘历史记录drawingHistory.value.forEach((action, index) => {drawAction(action, index === selectedActionIndex.value)})// 绘制当前正在进行的动作if (currentAction) {drawAction(currentAction, false)}
}

4. 图形编辑功能(选择、移动、删除)

  1. 选择功能实现

typescript

// 查找点击的图形
const findClickedAction = (xPercent: number, yPercent: number): number => {// 从后往前检查,优先选中最上层的图形for (let i = drawingHistory.value.length - 1; i >= 0; i--) {const action = drawingHistory.value[i]const points = action.points.map(p => ({x: p.x * canvasRef.value!.width,y: p.y * canvasRef.value!.height}))if (isPointInAction({ x, y }, action.tool, points)) {return i}}return -1
}
  1. 移动功能实现

typescript

// 在mousemove事件中处理移动逻辑
if (activeTool.value === 'select' && isMoving.value && selectedActionIndex.value !== -1) {// 计算新位置const newRefPointX = xPercent - offset.value.xconst newRefPointY = yPercent - offset.value.y// 计算位移量const dx = newRefPointX - originalRefPoint.xconst dy = newRefPointY - originalRefPoint.y// 更新所有点的位置action.points = action.points.map(point => ({x: point.x + dx,y: point.y + dy}))// 重绘redrawCanvas()
}
  1. 删除功能实现

typescript

// 橡皮擦工具逻辑
if (activeTool.value === 'eraser') {const clickedActionIndex = findClickedAction(xPercent, yPercent)if (clickedActionIndex !== -1) {// 移除被点击的图形drawingHistory.value.splice(clickedActionIndex, 1)// 发送删除指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'remove',data: clickedActionIndex,userId: props.userId})}redrawCanvas()}
}

5. 多人协作功能

  1. WebSocket 通信

typescript

// 监听WebSocket消息
watch(() => props.socket, () => {if (props.socket) {props.socket.on('message', event => {try {const data = JSON.parse(event.data)if (data.incidentType === 'whiteboard') {handleDrawingData(data)}} catch (e) {console.error('Failed to parse WebSocket message:', e)}})}
})// 发送绘图动作
const sendDrawingAction = (action: DrawingAction) => {if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'draw',data: action,userId: props.userId})}
}
  1. 处理接收的数据

typescript

const handleDrawingData = (data: any) => {// 忽略自己发送的消息if (data.userId !== props.userId) {switch(data.whiteboardType) {case 'clear':// 处理清空操作breakcase 'remove':// 处理删除操作breakcase 'draw':// 处理绘图操作breakcase 'move':// 处理移动操作break}}
}

三、样式设计

  1. 画布样式

scss

.whiteboard-container {position: relative;width: 100%;background-color: white;border: 1px solid #ddd;display: flex;flex-direction: column;
}.whiteboard {width: 100%;height: 65.92vh;cursor: crosshair;touch-action: none;
}// 选中状态鼠标样式
.whiteboard.selecting {cursor: move;
}

  1. 工具栏样式

scss

.toolbar {display: flex;align-items: center;gap: 10px;padding: 10px;background-color: #fff;border-top: 1px solid #e5e6eb;flex-wrap: wrap;
}.line-width-controls {display: flex;align-items: center;gap: 8px;
}

四、关键技术点总结

  1. 坐标系统:使用百分比坐标而非像素坐标,确保在不同尺寸的画布上正确显示
  2. 绘图历史:通过数组存储所有绘图动作,支持撤销、重绘和协作同步
  3. 图形命中检测:实现了不同图形的点击检测算法,支持精确选择
  4. 测量功能:通过像素距离与实际尺寸的转换(1cm = 37.8px)实现距离测量
  5. 协作机制:基于 WebSocket 的操作同步,确保多人协作时的一致性

完整代码

<template><CustomDialogv-model:visible="visible"title="医学白板"width="72.91%":confirmTxt="confirmTxt"@open="handleOpen"@close="handleClose"><div class="whiteboard-container"><canvasref="canvasRef"class="whiteboard"id="whiteboardCanvas":class="{ selecting: activeTool === 'select' }"@mousedown="startDrawing"@mousemove="draw"@mouseup="stopDrawing"@mouseleave="stopDrawing"></canvas><div v-if="isInitiator" class="toolbar"><!-- 线宽控制项 --><div class="line-width-controls"><XmBtn icon-text="线宽选择"><template #icon><span class="iconfont icon-zhixian"></span></template></XmBtn><el-sliderv-model="strokeWidthPx":min="1":max="20":step="1":show-input="true"style="width: 140px"tooltip="always"label="线宽"></el-slider></div><XmBtn icon-text="手绘笔" @click="selectTool('pen')"><template #icon><span class="iconfont icon-shouhuibi"></span></template></XmBtn><XmBtn icon-text="画直线" @click="selectTool('line')"><template #icon><span class="iconfont icon-zhixian"></span></template></XmBtn><XmBtn icon-text="画箭头" @click="selectTool('arrow')"><template #icon><span class="iconfont icon-jiantou"></span></template></XmBtn><XmBtn icon-text="画矩形" @click="selectTool('rectangle')"><template #icon><span class="iconfont icon-juxing"></span></template></XmBtn><XmBtn icon-text="画圆形" @click="selectTool('circle')"><template #icon><span class="iconfont icon-yuanxing"></span></template></XmBtn><XmBtn icon-text="橡皮擦" @click="selectTool('eraser')"><template #icon><span class="iconfont icon-eraser"></span></template></XmBtn><XmBtn icon-text="移动" @click="selectTool('select')"><template #icon><span class="iconfont icon-yidong"></span></template></XmBtn><XmBtn icon-text="一键清除" @click="clearCanvas" :disabled="drawingHistory.length === 0"><template #icon><span class="iconfont icon-delete"></span></template></XmBtn><XmBtn icon-text="颜色" type="color" @colorChange="colorChange" class="ml10"><template #icon><span class="iconfont icon-Color-Selected" :style="`color: ${strokeColor};`"></span></template></XmBtn><div class="xiaoanMeeting-bottomMenuBtn ml10"><div class="xiaoanMeeting-bottomMenuBtn-box"><el-input v-model="textContent" placeholder="请输入内容" style="width: 300px"></el-input><el-button type="primary" class="ml10" @click="confirmAddText">添加</el-button></div><div class="xiaoanMeeting-bottomMenuBtn-box-text">添加文字</div></div></div></div></CustomDialog>
</template><script lang="ts">
export default {title: '医学白板',icon: '',description: ''
}
</script><script lang="ts" setup>
import { ElMessageBox } from 'element-plus'
import XmBtn from '/@/components/Meet/bottomMenuBtn.vue'
import { ref, onMounted, onBeforeUnmount, watch, watchEffect, nextTick } from 'vue'
import CustomDialog from '/@/components/CustomDialog/customDialog.vue'// 定义绘图操作类型
type DrawingTool = 'pen' | 'rectangle' | 'circle' | 'arrow' | 'eraser' | 'text' | 'line' | 'select'
type Point = { x: number; y: number }// 扩展绘图动作接口,添加测量信息
interface DrawingAction {tool: DrawingToolpoints: Point[]color: stringwidth: numbertext?: string // 文字内容measurement?: {distance: number // 实际距离值unit: string // 单位,如"cm"}
}const props = defineProps<{isInitiator: boolean // 是否是发起者(拥有工具栏和操作权限)socket: any // WebSocket连接userId: stringhistory: any[]referenceWidth: numberreferenceHeight: number
}>()const emit = defineEmits(['close', 'change'])const visible = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
let canvasContext: CanvasRenderingContext2D | null = null
const confirmTxt = ref<string>('')// 绘图状态
const isDrawing = ref(false)
const activeTool = ref<DrawingTool>('pen')
const strokeColor = ref('red')
const strokeWidthPx = ref(3) // 线宽(像素)
const strokeWidth = ref(3) // 实际使用的线宽
const textContent = ref('')
const isAddingText = ref(false) // 是否正在添加文字// 选择和移动相关状态
const isMoving = ref(false)
const selectedActionIndex = ref(-1)
const offset = ref<Point>({ x: 0, y: 0 })// 存储绘图历史
const drawingHistory = ref<DrawingAction[]>([])
const showDistance = ref(false) // 是否显示距离
const currentDistance = ref('') // 当前距离值// 监听线宽变化,实时更新到实际使用的线宽
watch(() => strokeWidthPx.value,newValue => {strokeWidth.value = newValue}
)watch(() => drawingHistory,() => {console.log('drawingHistory.value change', drawingHistory.value)emit('change', drawingHistory.value)},{ deep: true }
)watchEffect(() => {if (props.isInitiator) {confirmTxt.value = '确认关闭此次白板?'} else {confirmTxt.value = '关闭他人共享的白板后无法再次打开, 是否继续?'}
})let currentAction: DrawingAction | null = null
let startPoint: Point | null = null// 工具栏配置
const tools: { type: DrawingTool; icon: string; label: string }[] = [{ type: 'pen', icon: '', label: '画笔' },{ type: 'rectangle', icon: '', label: '矩形' },{ type: 'circle', icon: '', label: '圆形' },{ type: 'arrow', icon: '', label: '箭头' },{ type: 'eraser', icon: '', label: '橡皮擦' },{ type: 'text', icon: '', label: '文字' },{ type: 'select', icon: '', label: '选择移动' }
]// 初始化画布
const initCanvas = () => {if (!canvasRef.value) returnconst canvas = canvasRef.valuecanvas.width = canvas.offsetWidthcanvas.height = canvas.offsetHeightcanvasContext = canvas.getContext('2d')if (canvasContext) {canvasContext.lineJoin = 'round'canvasContext.lineCap = 'round'canvasContext.font = '16px Arial'}
}// 选择工具
const selectTool = (tool: DrawingTool) => {activeTool.value = toolisAddingText.value = falseselectedActionIndex.value = -1 // 切换工具时取消选择
}// 确认添加文字
const confirmAddText = () => {if (!textContent.value.trim()) returnactiveTool.value = 'text'isAddingText.value = true
}// 获取图形的参考点(用于移动操作)
const getReferencePoint = (action: DrawingAction): Point => {switch (action.tool) {case 'rectangle':case 'line':case 'arrow':// 使用第一个点作为参考点return action.points[0]case 'circle':// 对于圆形,使用圆心作为参考点if (action.points.length >= 2) {const start = action.points[0]const end = action.points[1]return {x: start.x + (end.x - start.x) / 2,y: start.y + (end.y - start.y) / 2}}return action.points[0]case 'pen':// 对于手绘线,使用第一个点作为参考点return action.points[0]case 'text':// 对于文字,使用文字位置作为参考点return action.points[0]default:return action.points[0]}
}// 开始绘图
const startDrawing = (e: MouseEvent) => {if (!props.isInitiator || !canvasContext || !canvasRef.value) returnconst rect = canvasRef.value.getBoundingClientRect()const x = e.clientX - rect.leftconst y = e.clientY - rect.top// 转换为百分比坐标const xPercent = x / rect.widthconst yPercent = y / rect.height// 选择工具逻辑 - 支持所有图形if (activeTool.value === 'select') {// 查找点击的图形(任何类型)const clickedIndex = findClickedAction(xPercent, yPercent)if (clickedIndex !== -1) {selectedActionIndex.value = clickedIndexisMoving.value = true// 计算偏移量(鼠标点击位置相对于图形参考点的偏移)const action = drawingHistory.value[clickedIndex]if (action.points.length > 0) {const refPoint = getReferencePoint(action)offset.value = {x: xPercent - refPoint.x,y: yPercent - refPoint.y}}redrawCanvas()} else {// 点击空白处取消选择selectedActionIndex.value = -1redrawCanvas()}return}// 橡皮擦工具特殊处理if (activeTool.value === 'eraser') {const clickedActionIndex = findClickedAction(xPercent, yPercent)if (clickedActionIndex !== -1) {// 移除被点击的图形drawingHistory.value.splice(clickedActionIndex, 1)// 发送删除指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'remove',data: clickedActionIndex,userId: props.userId})}redrawCanvas()}return}// 文字工具特殊处理if (activeTool.value === 'text') {if (isAddingText.value && textContent.value.trim()) {// 添加文字到画布const textAction: DrawingAction = {tool: 'text',points: [{ x: xPercent, y: yPercent }],color: strokeColor.value,width: strokeWidth.value,text: textContent.value}drawingHistory.value.push(textAction)sendDrawingAction(textAction)redrawCanvas()// 重置状态textContent.value = ''isAddingText.value = false}return}// 其他工具正常处理isDrawing.value = truestartPoint = { x: xPercent, y: yPercent }currentAction = {tool: activeTool.value,points: [{ x: xPercent, y: yPercent }],color: strokeColor.value,width: strokeWidth.value // 使用当前选择的线宽}
}// 绘图过程
const draw = (e: MouseEvent) => {if (activeTool.value === 'text') returnif (!props.isInitiator || !canvasContext || !canvasRef.value) return// 处理移动逻辑 - 支持所有图形if (activeTool.value === 'select' && isMoving.value && selectedActionIndex.value !== -1) {const rect = canvasRef.value.getBoundingClientRect()const x = e.clientX - rect.leftconst y = e.clientY - rect.top// 转换为百分比坐标const xPercent = x / rect.widthconst yPercent = y / rect.height// 获取选中的动作const action = drawingHistory.value[selectedActionIndex.value]// 计算新的参考点位置(考虑偏移量)const newRefPointX = xPercent - offset.value.xconst newRefPointY = yPercent - offset.value.y// 获取原始参考点const originalRefPoint = getReferencePoint(action)// 计算位移量const dx = newRefPointX - originalRefPoint.xconst dy = newRefPointY - originalRefPoint.y// 更新所有点的位置action.points = action.points.map(point => ({x: point.x + dx,y: point.y + dy}))// 重绘画布redrawCanvas()return}if (!isDrawing.value || !currentAction) returnconst rect = canvasRef.value.getBoundingClientRect()const x = e.clientX - rect.leftconst y = e.clientY - rect.top// 转换为百分比坐标const xPercent = x / rect.widthconst yPercent = y / rect.heightif (currentAction.tool === 'line' || currentAction.tool === 'arrow') {currentAction.points = [currentAction.points[0], { x: xPercent, y: yPercent }]showDistance.value = currentAction.tool === 'line' // 仅直线显示距离测量if (currentAction.tool === 'line') {// 计算实时距离用于预览const start = currentAction.points[0]const end = currentAction.points[1]const actualStart = {x: start.x * canvasRef.value.width,y: start.y * canvasRef.value.height}const actualEnd = {x: end.x * canvasRef.value.width,y: end.y * canvasRef.value.height}const dx = actualEnd.x - actualStart.xconst dy = actualEnd.y - actualStart.yconst pixelDistance = Math.sqrt(dx * dx + dy * dy)const cmDistance = (pixelDistance / 37.8).toFixed(2)currentDistance.value = `${cmDistance} cm`}} else if (currentAction.tool === 'rectangle' || currentAction.tool === 'circle') {// 对于矩形和圆形,只保留起点和当前点currentAction.points = [currentAction.points[0], { x: xPercent, y: yPercent }]showDistance.value = false} else {// 手绘笔添加所有点currentAction.points.push({ x: xPercent, y: yPercent })showDistance.value = false}// 实时绘制预览redrawCanvas()
}// 停止绘图
const stopDrawing = () => {if (activeTool.value === 'text') return// 处理移动结束if (activeTool.value === 'select' && isMoving.value && selectedActionIndex.value !== -1) {isMoving.value = false// 发送移动指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'move',data: {index: selectedActionIndex.value,points: drawingHistory.value[selectedActionIndex.value].points},userId: props.userId})}return}if (!isDrawing.value || !currentAction) returnisDrawing.value = false// 若为直线工具,计算并存储测量数据if (currentAction.tool === 'line' && currentAction.points.length >= 2) {const start = currentAction.points[0]const end = currentAction.points[1]// 转换为实际像素坐标const actualStart = {x: start.x * canvasRef.value!.width,y: start.y * canvasRef.value!.height}const actualEnd = {x: end.x * canvasRef.value!.width,y: end.y * canvasRef.value!.height}// 计算像素距离const dx = actualEnd.x - actualStart.xconst dy = actualEnd.y - actualStart.yconst pixelDistance = Math.sqrt(dx * dx + dy * dy)// 转换为实际距离(1cm = 37.8像素,可根据实际需求调整)const cmDistance = Number((pixelDistance / 37.8).toFixed(2))// 存储测量信息currentAction.measurement = {distance: cmDistance,unit: 'cm'}}// 发送完整的绘图动作sendDrawingAction(currentAction)// 保存到历史记录drawingHistory.value.push(currentAction)currentAction = nullstartPoint = nullshowDistance.value = false
}// 重绘整个画布
const redrawCanvas = () => {if (!canvasContext || !canvasRef.value) return// 清空画布canvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)// 重绘历史记录drawingHistory.value.forEach((action, index) => {drawAction(action, index === selectedActionIndex.value)})// 绘制当前正在进行的动作if (currentAction) {drawAction(currentAction, false)}
}// 获取图形的边界框(用于显示选中状态)
const getBoundingBox = (action: DrawingAction): { x: number; y: number; width: number; height: number } => {if (action.points.length === 0) return { x: 0, y: 0, width: 0, height: 0 }// 转换为实际坐标const actualPoints = action.points.map(p => ({x: p.x * canvasRef.value!.width,y: p.y * canvasRef.value!.height}))// 找到所有点的极值let minX = actualPoints[0].xlet maxX = actualPoints[0].xlet minY = actualPoints[0].ylet maxY = actualPoints[0].yactualPoints.forEach(point => {minX = Math.min(minX, point.x)maxX = Math.max(maxX, point.x)minY = Math.min(minY, point.y)maxY = Math.max(maxY, point.y)})// 对于圆形特殊处理if (action.tool === 'circle' && action.points.length >= 2) {const start = action.points[0]const end = action.points[1]const centerX = start.x + (end.x - start.x) / 2const centerY = start.y + (end.y - start.y) / 2const radius = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)) / 2// 转换为实际坐标const actualCenterX = centerX * canvasRef.value!.widthconst actualCenterY = centerY * canvasRef.value!.heightconst actualRadius = radius * canvasRef.value!.widthreturn {x: actualCenterX - actualRadius,y: actualCenterY - actualRadius,width: actualRadius * 2,height: actualRadius * 2}}return {x: minX,y: minY,width: maxX - minX,height: maxY - minY}
}// 绘制单个动作,添加isSelected参数用于高亮显示选中的图形
const drawAction = (action: DrawingAction, isSelected: boolean) => {if (!canvasContext) returnconst { tool, points, color, width, text } = action// 保存当前上下文状态canvasContext.save()// 如果是选中状态,绘制高亮边框if (isSelected) {// 绘制边界框const boundingBox = getBoundingBox(action)canvasContext.strokeStyle = '#00ff00' // 绿色高亮canvasContext.lineWidth = 2canvasContext.strokeRect(boundingBox.x - 5, boundingBox.y - 5, boundingBox.width + 10, boundingBox.height + 10)// 绘制控制点drawControlPoints(boundingBox)}// 绘制图形本身canvasContext.strokeStyle = colorcanvasContext.lineWidth = width // 使用动作中保存的线宽canvasContext.fillStyle = colorconst actualPoints = points.map(p => ({x: p.x * canvasRef.value!.width,y: p.y * canvasRef.value!.height}))switch (tool) {case 'pen':drawFreehand(actualPoints)breakcase 'line':// 绘制直线时显示测量信息drawLine(actualPoints, action)breakcase 'rectangle':drawRectangle(actualPoints)breakcase 'circle':drawCircle(actualPoints)breakcase 'arrow':drawArrow(actualPoints)breakcase 'text':if (text && actualPoints.length > 0) {drawText(actualPoints[0], text, color, width)}break}// 恢复上下文状态canvasContext.restore()
}// 绘制控制点(用于显示选中状态)
const drawControlPoints = (boundingBox: { x: number; y: number; width: number; height: number }) => {if (!canvasContext) returnconst controlPointSize = 6 // 控制点大小const points = [{ x: boundingBox.x, y: boundingBox.y }, // 左上角{ x: boundingBox.x + boundingBox.width, y: boundingBox.y }, // 右上角{ x: boundingBox.x, y: boundingBox.y + boundingBox.height }, // 左下角{ x: boundingBox.x + boundingBox.width, y: boundingBox.y + boundingBox.height } // 右下角]points.forEach(point => {canvasContext.beginPath()canvasContext.fillStyle = '#00ff00' // 绿色控制点canvasContext.arc(point.x, point.y, controlPointSize, 0, Math.PI * 2)canvasContext.fill()canvasContext.strokeStyle = '#ffffff' // 白色边框canvasContext.lineWidth = 1canvasContext.stroke()})
}// 绘制自由线条
const drawFreehand = (points: Point[]) => {if (!canvasContext || points.length < 2) returncanvasContext.beginPath()canvasContext.moveTo(points[0].x, points[0].y)for (let i = 1; i < points.length; i++) {canvasContext.lineTo(points[i].x, points[i].y)}canvasContext.stroke()
}// 绘制直线(包含测量信息)
const drawLine = (points: Point[], action: DrawingAction) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 绘制直线canvasContext.beginPath()canvasContext.moveTo(start.x, start.y)canvasContext.lineTo(end.x, end.y)canvasContext.stroke()// 显示测量信息if (action.measurement) {const displayText = `${action.measurement.distance} ${action.measurement.unit}`// 计算直线中点(显示文本位置)const midX = (start.x + end.x) / 2const midY = (start.y + end.y) / 2// 绘制文本背景(避免与图形重叠)canvasContext.fillStyle = 'rgba(255, 255, 255, 0.8)'const textWidth = canvasContext.measureText(displayText).widthcanvasContext.fillRect(midX - textWidth / 2 - 5, midY - 15, textWidth + 10, 20)// 绘制测量文本canvasContext.fillStyle = 'black'canvasContext.font = '12px Arial'canvasContext.textAlign = 'center'canvasContext.fillText(displayText, midX, midY)canvasContext.textAlign = 'left' // 恢复默认对齐}
}// 绘制矩形
const drawRectangle = (points: Point[]) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]const width = end.x - start.xconst height = end.y - start.ycanvasContext.beginPath()canvasContext.rect(start.x, start.y, width, height)canvasContext.stroke()
}//绘制圆形 - 从起点开始画圆
const drawCircle = (points: Point[]) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 计算矩形的中心点作为圆心const centerX = start.x + (end.x - start.x) / 2const centerY = start.y + (end.y - start.y) / 2// 计算半径为矩形对角线的一半const radius = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)) / 2canvasContext.beginPath()canvasContext.arc(centerX, centerY, radius, 0, Math.PI * 2)canvasContext.stroke()
}// 绘制箭头
const drawArrow = (points: Point[]) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 绘制线条canvasContext.beginPath()canvasContext.moveTo(start.x, start.y)canvasContext.lineTo(end.x, end.y)canvasContext.stroke()// 绘制箭头const headLength = 15const angle = Math.atan2(end.y - start.y, end.x - start.x)canvasContext.beginPath()canvasContext.moveTo(end.x, end.y)canvasContext.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6),end.y - headLength * Math.sin(angle - Math.PI / 6))canvasContext.moveTo(end.x, end.y)canvasContext.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6),end.y - headLength * Math.sin(angle + Math.PI / 6))canvasContext.stroke()
}// 绘制文字
const drawText = (point: Point, text: string, color: string, width: number) => {if (!canvasContext) return// 文字大小也基于画布宽度百分比const fontSize = width * 5 * (canvasRef.value!.width / props.referenceWidth)canvasContext.fillStyle = colorcanvasContext.font = `${fontSize}px Arial`canvasContext.fillText(text, point.x, point.y)
}// 查找点击的图形
const findClickedAction = (xPercent: number, yPercent: number): number => {if (!canvasRef.value) return -1// 转换为实际坐标用于检测const x = xPercent * canvasRef.value.widthconst y = yPercent * canvasRef.value.height// 从后往前检查,优先选中最上层的图形for (let i = drawingHistory.value.length - 1; i >= 0; i--) {const action = drawingHistory.value[i]const points = action.points.map(p => ({x: p.x * canvasRef.value!.width,y: p.y * canvasRef.value!.height}))if (isPointInAction({ x, y }, action.tool, points)) {return i}}return -1
}// 检测点是否在图形内
const isPointInAction = (point: Point, tool: DrawingTool, actionPoints: Point[]): boolean => {if (actionPoints.length === 0) return falseswitch (tool) {case 'pen':case 'line':case 'arrow':return isPointNearLine(point, actionPoints)case 'rectangle':return isPointInRectangle(point, actionPoints)case 'circle':return isPointInCircle(point, actionPoints)case 'text':// 简单判断点击点是否在文字起点附近return Math.abs(point.x - actionPoints[0].x) < 20 && Math.abs(point.y - actionPoints[0].y) < 20default:return false}
}// 判断点是否在线段附近
const isPointNearLine = (point: Point, linePoints: Point[]): boolean => {if (linePoints.length < 2) return falsefor (let i = 0; i < linePoints.length - 1; i++) {const start = linePoints[i]const end = linePoints[i + 1]const distance = distanceToLine(point, start, end)if (distance < 10) {// 10像素内的容差return true}}return false
}// 矩形检测
const isPointInRectangle = (point: Point, rectPoints: Point[]): boolean => {if (rectPoints.length < 2) return falseconst start = rectPoints[0]const end = rectPoints[rectPoints.length - 1]const left = Math.min(start.x, end.x)const right = Math.max(start.x, end.x)const top = Math.min(start.y, end.y)const bottom = Math.max(start.y, end.y)return point.x >= left - 5 && point.x <= right + 5 && point.y >= top - 5 && point.y <= bottom + 5
}// 圆形检测
const isPointInCircle = (point: Point, circlePoints: Point[]): boolean => {if (circlePoints.length < 2) return falseconst start = circlePoints[0]const end = circlePoints[circlePoints.length - 1]// 计算圆心和半径const centerX = start.x + (end.x - start.x) / 2const centerY = start.y + (end.y - start.y) / 2const radius = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)) / 2// 计算点到圆心的距离const distance = Math.sqrt(Math.pow(point.x - centerX, 2) + Math.pow(point.y - centerY, 2))return distance <= radius + 5
}// 计算点到线段的距离
const distanceToLine = (point: Point, lineStart: Point, lineEnd: Point): number => {const A = point.x - lineStart.xconst B = point.y - lineStart.yconst C = lineEnd.x - lineStart.xconst D = lineEnd.y - lineStart.yconst dot = A * C + B * Dconst len_sq = C * C + D * Dlet param = -1if (len_sq !== 0) param = dot / len_sqlet xx, yyif (param < 0) {xx = lineStart.xyy = lineStart.y} else if (param > 1) {xx = lineEnd.xyy = lineEnd.y} else {xx = lineStart.x + param * Cyy = lineStart.y + param * D}const dx = point.x - xxconst dy = point.y - yyreturn Math.sqrt(dx * dx + dy * dy)
}// 清除画布
const clearCanvas = () => {ElMessageBox.confirm('确定要清除所有标注吗?', '警告', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning'}).then(() => {if (!canvasContext || !canvasRef.value) returncanvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)drawingHistory.value = []selectedActionIndex.value = -1// 发送清除指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'clear'})}})
}// 发送绘图动作
const sendDrawingAction = (action: DrawingAction) => {if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'draw',data: action, // 包含measurement(若为直线)userId: props.userId})}
}// 处理接收到的绘图数据
const handleDrawingData = (data: any) => {console.log('handleDrawingData', data)if (data.userId !== props.userId) {if (data.whiteboardType === 'clear') {drawingHistory.value = []selectedActionIndex.value = -1if (canvasContext && canvasRef.value) {canvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)}} else if (data.whiteboardType === 'remove') {drawingHistory.value.splice(data.data, 1)// 如果删除的是选中的元素,取消选择if (selectedActionIndex.value === data.data) {selectedActionIndex.value = -1} else if (selectedActionIndex.value > data.data) {// 调整索引selectedActionIndex.value--}redrawCanvas()} else if (data.whiteboardType === 'draw') {drawingHistory.value.push(data.data)redrawCanvas()} else if (data.whiteboardType === 'move') {// 处理移动操作if (data.data.index >= 0 && data.data.index < drawingHistory.value.length) {drawingHistory.value[data.data.index].points = data.data.pointsredrawCanvas()}}}
}// 打开弹窗
const open = () => {visible.value = truesetTimeout(() => {drawingHistory.value = props.historyredrawCanvas()}, 500)
}const close = () => {visible.value = false
}// 关闭弹窗
const handleClose = () => {emit('close')if (props.isInitiator) {//清空内容drawingHistory.value = []selectedActionIndex.value = -1props.socket.sendJson({incidentType: 'whiteboard',whiteboardType: 'close'})}
}// 弹窗打开时初始化
const handleOpen = () => {nextTick(() => {initCanvas()})// 如果是发起者,发送打开通知if (props.isInitiator && props.socket) {props.socket.send(JSON.stringify({type: 'open'}))}
}watch(() => props.socket,() => {if (props.socket) {props.socket.on('message', event => {try {const data = JSON.parse(event.data)if (data.incidentType === 'whiteboard') {handleDrawingData(data)}} catch (e) {console.error('Failed to parse WebSocket message:', e)}})}}
)function colorChange(color) {strokeColor.value = color
}// 组件挂载时初始化
onMounted(() => {window.addEventListener('resize', initCanvas)
})// 组件卸载时清理
onBeforeUnmount(() => {window.removeEventListener('resize', initCanvas)
})// 暴露方法
defineExpose({open,close
})
</script><style scoped lang="scss">
.whiteboard-container {position: relative;width: 100%;background-color: white;border: 1px solid #ddd;display: flex;flex-direction: column;
}.toolbar {display: flex;align-items: center;gap: 10px;padding: 10px;background-color: #fff;border-top: 1px solid #e5e6eb;flex-wrap: wrap;
}// 线宽控制样式
// 线宽控制样式
.line-width-controls {display: flex;align-items: center;gap: 8px;
}.text-input-area {display: flex;align-items: center;margin-right: 10px;
}.whiteboard {width: 100%;height: 65.92vh;cursor: crosshair;touch-action: none;
}// 选中工具时改变鼠标样式
.whiteboard.selecting {cursor: move;
}:deep(.xiaoanMeeting-bottomMenuBtn-box) {margin-bottom: 5px;.el-input__inner,.el-input__wrapper,.el-button {height: 24px;font-size: 12px;line-height: 24px;}
}:deep(.xiaoanMeeting-bottomMenuBtn-box-text) {font-size: 12px;
}// 调整滑块样式
:deep(.el-slider) {margin: 0;
}:deep(.el-slider__input) {width: 50px !important;
}
</style>

http://www.dtcms.com/a/328034.html

相关文章:

  • golang的继承
  • [Metrics] RMSE vs ADE
  • 衡量机器学习模型的指标
  • 【基于Redis的手语翻译序列存储设计】
  • Ansible 自动化介绍
  • 飞算AI:企业智能化转型的新引擎
  • react+Zustand来管理公共数据,类似vue的pinia
  • React 腾讯面试手写题
  • Orange的运维学习日记--40.LNMP-LAMP架构最佳实践
  • 【前端:Html】--3.进阶:图形
  • [激光原理与应用-252]:理论 - 几何光学 - 传统透镜焦距固定,但近年出现的可变形透镜(如液态透镜、弹性膜透镜)可通过改变自身形状动态调整焦距。
  • 虚拟机环境部署Ceph集群的详细指南
  • 「让AI大脑直连Windows桌面」:深度解析Windows-MCP,开启操作系统下一代智能交互
  • Hi3DEval:以分层有效性推进三维(3D)生成评估
  • 【树状数组】Range Update Queries
  • 《Leetcode》-面试题-hot100-栈
  • Apache SeaTunnel 新定位!迈向多模态数据集成的统一工具
  • 亚马逊与UPS规则双调整:从视觉营销革新到物流成本重构的运营战略升级
  • linux下安装php
  • Linux内核编译ARM架构 linux-6.16
  • Node.js 和 npm 的关系详解
  • 能刷java题的网站
  • FPGA即插即用Verilog驱动系列——按键消抖
  • 【JavaEE】多线程之线程安全(中)
  • 第5章 AB实验的随机分流
  • 圆柱电池自动分选机:新能源时代的“质量卫士”
  • 各版本IDEA体验
  • Next.js 中间件:自定义请求处理
  • LeetCode 分割回文串
  • 终端互动媒体业务VS终端小艺业务