白板功能文档

发布于:2025-08-13 ⋅ 阅读:(21) ⋅ 点赞:(0)

一、功能概述

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

二、核心功能模块

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: DrawingTool
  points: Point[]  // 坐标点集合
  color: string    // 颜色
  width: number    // 线宽
  text?: string    // 文字内容
  measurement?: {  // 测量信息(仅直线)
    distance: number
    unit: string
  }
}
  1. 绘图事件绑定

html

预览

<canvas
  ref="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.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
}
  1. 绘图过程(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()
}
  1. 结束绘图(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)
}
  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.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()
}
  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':
        // 处理清空操作
        break
      case 'remove':
        // 处理删除操作
        break
      case 'draw':
        // 处理绘图操作
        break
      case '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>
	<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>


网站公告

今日签到

点亮在社区的每一天
去签到