一、功能概述
医学白板是一个支持多人协作的绘图工具,主要用于医疗场景下的图形标注、测量及文字说明。支持多种绘图工具(手绘笔、直线、箭头、矩形、圆形等),并具备图形选择、移动、删除等编辑功能,同时支持直线距离测量(以厘米为单位)。
二、核心功能模块
1. 组件结构设计
html
预览
<CustomDialog> <!-- 外层弹窗容器 -->
<div class="whiteboard-container">
<canvas> <!-- 绘图画布 --> </canvas>
<div class="toolbar"> <!-- 工具栏 --> </div>
</div>
</CustomDialog>
实现步骤:
- 使用
CustomDialog
作为外层容器,控制白板的显示与隐藏 - 核心绘图区域使用
canvas
元素实现 - 工具栏根据用户权限(
isInitiator
)决定是否显示 - 通过
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>
实现步骤:
- 使用条件渲染
v-if="isInitiator"
控制工具栏权限 - 通过
selectTool
方法切换当前激活工具 - 使用
el-slider
组件实现线宽调节功能 - 文字添加通过输入框 + 按钮触发添加模式
3. 绘图功能实现
核心绘图逻辑
- 绘图状态管理
typescript
// 定义绘图工具类型
type DrawingTool = 'pen' | 'rectangle' | 'circle' | 'arrow' | 'eraser' | 'text' | 'line' | 'select'
// 定义绘图动作接口
interface DrawingAction {
tool: DrawingTool
points: Point[] // 坐标点集合
color: string // 颜色
width: number // 线宽
text?: string // 文字内容
measurement?: { // 测量信息(仅直线)
distance: number
unit: string
}
}
- 绘图事件绑定
html
预览
<canvas
ref="canvasRef"
@mousedown="startDrawing" // 开始绘图
@mousemove="draw" // 绘图过程
@mouseup="stopDrawing" // 结束绘图
@mouseleave="stopDrawing"> // 离开画布
</canvas>
- 开始绘图(startDrawing)
typescript
const startDrawing = (e: MouseEvent) => {
// 获取鼠标在画布上的百分比坐标
const rect = canvasRef.value.getBoundingClientRect()
const xPercent = (e.clientX - rect.left) / rect.width
const 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
}
- 绘图过程(draw)
typescript
const draw = (e: MouseEvent) => {
if (!isDrawing.value || !currentAction) return
// 计算当前坐标(百分比)
const rect = canvasRef.value.getBoundingClientRect()
const xPercent = (e.clientX - rect.left) / rect.width
const yPercent = (e.clientY - rect.top) / rect.height
// 根据工具类型处理不同绘图逻辑
switch(currentAction.tool) {
case 'pen':
// 手绘笔添加所有点
currentAction.points.push({ x: xPercent, y: yPercent })
break
case 'line':
case 'arrow':
// 直线和箭头只保留起点和当前点
currentAction.points = [currentAction.points[0], { x: xPercent, y: yPercent }]
break
// 其他工具处理...
}
// 实时重绘
redrawCanvas()
}
- 结束绘图(stopDrawing)
typescript
const stopDrawing = () => {
if (!isDrawing.value || !currentAction) return
isDrawing.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)
}
- 重绘机制(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. 图形编辑功能(选择、移动、删除)
- 选择功能实现
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
}
- 移动功能实现
typescript
// 在mousemove事件中处理移动逻辑
if (activeTool.value === 'select' && isMoving.value && selectedActionIndex.value !== -1) {
// 计算新位置
const newRefPointX = xPercent - offset.value.x
const newRefPointY = yPercent - offset.value.y
// 计算位移量
const dx = newRefPointX - originalRefPoint.x
const dy = newRefPointY - originalRefPoint.y
// 更新所有点的位置
action.points = action.points.map(point => ({
x: point.x + dx,
y: point.y + dy
}))
// 重绘
redrawCanvas()
}
- 删除功能实现
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. 多人协作功能
- 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
})
}
}
- 处理接收的数据
typescript
const handleDrawingData = (data: any) => {
// 忽略自己发送的消息
if (data.userId !== props.userId) {
switch(data.whiteboardType) {
case 'clear':
// 处理清空操作
break
case 'remove':
// 处理删除操作
break
case 'draw':
// 处理绘图操作
break
case 'move':
// 处理移动操作
break
}
}
}
三、样式设计
- 画布样式
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;
}
- 工具栏样式
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;
}
四、关键技术点总结
- 坐标系统:使用百分比坐标而非像素坐标,确保在不同尺寸的画布上正确显示
- 绘图历史:通过数组存储所有绘图动作,支持撤销、重绘和协作同步
- 图形命中检测:实现了不同图形的点击检测算法,支持精确选择
- 测量功能:通过像素距离与实际尺寸的转换(1cm = 37.8px)实现距离测量
- 协作机制:基于 WebSocket 的操作同步,确保多人协作时的一致性
完整代码
<template>
<CustomDialog
v-model:visible="visible"
title="医学白板"
width="72.91%"
:confirmTxt="confirmTxt"
@open="handleOpen"
@close="handleClose">
<div class="whiteboard-container">
<canvas
ref="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-slider
v-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: DrawingTool
points: Point[]
color: string
width: number
text?: string // 文字内容
measurement?: {
distance: number // 实际距离值
unit: string // 单位,如"cm"
}
}
const props = defineProps<{
isInitiator: boolean // 是否是发起者(拥有工具栏和操作权限)
socket: any // WebSocket连接
userId: string
history: any[]
referenceWidth: number
referenceHeight: 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) return
const canvas = canvasRef.value
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
canvasContext = canvas.getContext('2d')
if (canvasContext) {
canvasContext.lineJoin = 'round'
canvasContext.lineCap = 'round'
canvasContext.font = '16px Arial'
}
}
// 选择工具
const selectTool = (tool: DrawingTool) => {
activeTool.value = tool
isAddingText.value = false
selectedActionIndex.value = -1 // 切换工具时取消选择
}
// 确认添加文字
const confirmAddText = () => {
if (!textContent.value.trim()) return
activeTool.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) return
const rect = canvasRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 转换为百分比坐标
const xPercent = x / rect.width
const yPercent = y / rect.height
// 选择工具逻辑 - 支持所有图形
if (activeTool.value === 'select') {
// 查找点击的图形(任何类型)
const clickedIndex = findClickedAction(xPercent, yPercent)
if (clickedIndex !== -1) {
selectedActionIndex.value = clickedIndex
isMoving.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 = -1
redrawCanvas()
}
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 = true
startPoint = { 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') return
if (!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.left
const y = e.clientY - rect.top
// 转换为百分比坐标
const xPercent = x / rect.width
const yPercent = y / rect.height
// 获取选中的动作
const action = drawingHistory.value[selectedActionIndex.value]
// 计算新的参考点位置(考虑偏移量)
const newRefPointX = xPercent - offset.value.x
const newRefPointY = yPercent - offset.value.y
// 获取原始参考点
const originalRefPoint = getReferencePoint(action)
// 计算位移量
const dx = newRefPointX - originalRefPoint.x
const dy = newRefPointY - originalRefPoint.y
// 更新所有点的位置
action.points = action.points.map(point => ({
x: point.x + dx,
y: point.y + dy
}))
// 重绘画布
redrawCanvas()
return
}
if (!isDrawing.value || !currentAction) return
const rect = canvasRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 转换为百分比坐标
const xPercent = x / rect.width
const yPercent = y / rect.height
if (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.x
const dy = actualEnd.y - actualStart.y
const 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) return
isDrawing.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.x
const dy = actualEnd.y - actualStart.y
const 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 = null
startPoint = null
showDistance.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].x
let maxX = actualPoints[0].x
let minY = actualPoints[0].y
let maxY = actualPoints[0].y
actualPoints.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) / 2
const 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)) / 2
// 转换为实际坐标
const actualCenterX = centerX * canvasRef.value!.width
const actualCenterY = centerY * canvasRef.value!.height
const actualRadius = radius * canvasRef.value!.width
return {
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) return
const { tool, points, color, width, text } = action
// 保存当前上下文状态
canvasContext.save()
// 如果是选中状态,绘制高亮边框
if (isSelected) {
// 绘制边界框
const boundingBox = getBoundingBox(action)
canvasContext.strokeStyle = '#00ff00' // 绿色高亮
canvasContext.lineWidth = 2
canvasContext.strokeRect(boundingBox.x - 5, boundingBox.y - 5, boundingBox.width + 10, boundingBox.height + 10)
// 绘制控制点
drawControlPoints(boundingBox)
}
// 绘制图形本身
canvasContext.strokeStyle = color
canvasContext.lineWidth = width // 使用动作中保存的线宽
canvasContext.fillStyle = color
const actualPoints = points.map(p => ({
x: p.x * canvasRef.value!.width,
y: p.y * canvasRef.value!.height
}))
switch (tool) {
case 'pen':
drawFreehand(actualPoints)
break
case 'line':
// 绘制直线时显示测量信息
drawLine(actualPoints, action)
break
case 'rectangle':
drawRectangle(actualPoints)
break
case 'circle':
drawCircle(actualPoints)
break
case 'arrow':
drawArrow(actualPoints)
break
case '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) return
const 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 = 1
canvasContext.stroke()
})
}
// 绘制自由线条
const drawFreehand = (points: Point[]) => {
if (!canvasContext || points.length < 2) return
canvasContext.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) return
const 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) / 2
const midY = (start.y + end.y) / 2
// 绘制文本背景(避免与图形重叠)
canvasContext.fillStyle = 'rgba(255, 255, 255, 0.8)'
const textWidth = canvasContext.measureText(displayText).width
canvasContext.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) return
const start = points[0]
const end = points[points.length - 1]
const width = end.x - start.x
const height = end.y - start.y
canvasContext.beginPath()
canvasContext.rect(start.x, start.y, width, height)
canvasContext.stroke()
}
//绘制圆形 - 从起点开始画圆
const drawCircle = (points: Point[]) => {
if (!canvasContext || points.length < 2) return
const start = points[0]
const end = points[points.length - 1]
// 计算矩形的中心点作为圆心
const centerX = start.x + (end.x - start.x) / 2
const 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)) / 2
canvasContext.beginPath()
canvasContext.arc(centerX, centerY, radius, 0, Math.PI * 2)
canvasContext.stroke()
}
// 绘制箭头
const drawArrow = (points: Point[]) => {
if (!canvasContext || points.length < 2) return
const 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 = 15
const 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 = color
canvasContext.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.width
const 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 false
switch (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) < 20
default:
return false
}
}
// 判断点是否在线段附近
const isPointNearLine = (point: Point, linePoints: Point[]): boolean => {
if (linePoints.length < 2) return false
for (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 false
const 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 false
const start = circlePoints[0]
const end = circlePoints[circlePoints.length - 1]
// 计算圆心和半径
const centerX = start.x + (end.x - start.x) / 2
const 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)) / 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.x
const B = point.y - lineStart.y
const C = lineEnd.x - lineStart.x
const D = lineEnd.y - lineStart.y
const dot = A * C + B * D
const len_sq = C * C + D * D
let param = -1
if (len_sq !== 0) param = dot / len_sq
let xx, yy
if (param < 0) {
xx = lineStart.x
yy = lineStart.y
} else if (param > 1) {
xx = lineEnd.x
yy = lineEnd.y
} else {
xx = lineStart.x + param * C
yy = lineStart.y + param * D
}
const dx = point.x - xx
const dy = point.y - yy
return Math.sqrt(dx * dx + dy * dy)
}
// 清除画布
const clearCanvas = () => {
ElMessageBox.confirm('确定要清除所有标注吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
if (!canvasContext || !canvasRef.value) return
canvasContext.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 = -1
if (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.points
redrawCanvas()
}
}
}
}
// 打开弹窗
const open = () => {
visible.value = true
setTimeout(() => {
drawingHistory.value = props.history
redrawCanvas()
}, 500)
}
const close = () => {
visible.value = false
}
// 关闭弹窗
const handleClose = () => {
emit('close')
if (props.isInitiator) {
//清空内容
drawingHistory.value = []
selectedActionIndex.value = -1
props.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>