WritingBoard.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. <template>
  2. <div class="writing-board" ref="writingBoardRef">
  3. <div class="blackboard" v-if="blackboard"></div>
  4. <canvas class="canvas" ref="canvasRef"
  5. :style="{
  6. width: canvasWidth + 'px',
  7. height: canvasHeight + 'px',
  8. pointerEvents: readonly ? 'none' : 'auto',
  9. }"
  10. @mousedown="$event => !readonly && handleMousedown($event)"
  11. @mousemove="$event => !readonly && handleMousemove($event)"
  12. @mouseup="!readonly && handleMouseup()"
  13. @touchstart="$event => !readonly && handleMousedown($event)"
  14. @touchmove="$event => !readonly && handleMousemove($event)"
  15. @touchend="!readonly && (handleMouseup(), mouseInCanvas = false)"
  16. @mouseleave="!readonly && (handleMouseup(), mouseInCanvas = false)"
  17. @mouseenter="!readonly && (mouseInCanvas = true)"
  18. ></canvas>
  19. <template v-if="mouseInCanvas && !readonly">
  20. <div
  21. class="eraser"
  22. :style="{
  23. left: mouse.x - rubberSize / 2 + 'px',
  24. top: mouse.y - rubberSize / 2 + 'px',
  25. width: rubberSize + 'px',
  26. height: rubberSize + 'px',
  27. }"
  28. v-if="model === 'eraser'"
  29. ></div>
  30. <div
  31. class="pen"
  32. :style="{
  33. left: mouse.x - penSize / 2 + 'px',
  34. top: mouse.y - penSize * 6 + penSize / 2 + 'px',
  35. color: color,
  36. }"
  37. v-if="model === 'pen'"
  38. >
  39. <IconWrite class="icon" :size="penSize * 6" />
  40. </div>
  41. <div
  42. class="pen"
  43. :style="{
  44. left: mouse.x - markSize / 2 + 'px',
  45. top: mouse.y + 'px',
  46. color: color,
  47. }"
  48. v-if="model === 'mark'"
  49. >
  50. <IconHighLight class="icon" :size="markSize * 1.5" />
  51. </div>
  52. <div
  53. class="pen"
  54. :style="{
  55. left: mouse.x - 20 + 'px',
  56. top: mouse.y - 20 + 'px',
  57. color: color,
  58. }"
  59. v-if="model === 'shape'"
  60. >
  61. <IconPlus class="icon" :size="40" />
  62. </div>
  63. </template>
  64. </div>
  65. </template>
  66. <script lang="ts" setup>
  67. import { computed, onMounted, onUnmounted, ref, watch, useTemplateRef } from 'vue'
  68. const props = withDefaults(defineProps<{
  69. color?: string
  70. model?: 'pen' | 'eraser' | 'mark' | 'shape'
  71. shapeType?: 'rect' | 'circle' | 'arrow'
  72. blackboard?: boolean
  73. penSize?: number
  74. markSize?: number
  75. rubberSize?: number
  76. shapeSize?: number
  77. readonly?: boolean
  78. }>(), {
  79. color: '#ffcc00',
  80. model: 'pen',
  81. shapeType: 'rect',
  82. blackboard: false,
  83. penSize: 6,
  84. markSize: 24,
  85. rubberSize: 80,
  86. shapeSize: 4,
  87. readonly: false,
  88. })
  89. const emit = defineEmits<{
  90. (event: 'end'): void
  91. }>()
  92. let ctx: CanvasRenderingContext2D | null = null
  93. const writingBoardRef = useTemplateRef<HTMLElement>('writingBoardRef')
  94. const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
  95. let lastPos = {
  96. x: 0,
  97. y: 0,
  98. }
  99. let isMouseDown = false
  100. let lastTime = 0
  101. let lastLineWidth = -1
  102. let initialImageData: ImageData | null = null
  103. // 鼠标位置坐标:用于画笔或橡皮位置跟随
  104. const mouse = ref({
  105. x: 0,
  106. y: 0,
  107. })
  108. // 鼠标是否处在画布范围内:处在范围内才会显示画笔或橡皮
  109. const mouseInCanvas = ref(false)
  110. // 监听更新canvas尺寸
  111. const canvasWidth = ref(0)
  112. const canvasHeight = ref(0)
  113. const widthScale = computed(() => canvasRef.value ? canvasWidth.value / canvasRef.value.width : 1)
  114. const heightScale = computed(() => canvasRef.value ? canvasHeight.value / canvasRef.value.height : 1)
  115. const updateCanvasSize = () => {
  116. if (!writingBoardRef.value) return
  117. canvasWidth.value = writingBoardRef.value.clientWidth
  118. canvasHeight.value = writingBoardRef.value.clientHeight
  119. }
  120. const resizeObserver = new ResizeObserver(updateCanvasSize)
  121. onMounted(() => {
  122. if (writingBoardRef.value) resizeObserver.observe(writingBoardRef.value)
  123. })
  124. onUnmounted(() => {
  125. if (writingBoardRef.value) resizeObserver.unobserve(writingBoardRef.value)
  126. })
  127. // 初始化画布
  128. const initCanvas = () => {
  129. if (!canvasRef.value || !writingBoardRef.value) return
  130. ctx = canvasRef.value.getContext('2d')
  131. if (!ctx) return
  132. canvasRef.value.width = writingBoardRef.value.clientWidth
  133. canvasRef.value.height = writingBoardRef.value.clientHeight
  134. ctx.lineCap = 'round'
  135. ctx.lineJoin = 'round'
  136. }
  137. onMounted(initCanvas)
  138. // 切换画笔模式时,更新 canvas ctx 配置
  139. const updateCtx = () => {
  140. if (!ctx) return
  141. if (props.model === 'mark') {
  142. ctx.globalCompositeOperation = 'xor'
  143. ctx.globalAlpha = 0.5
  144. }
  145. else if (props.model === 'pen' || props.model === 'shape') {
  146. ctx.globalCompositeOperation = 'source-over'
  147. ctx.globalAlpha = 1
  148. }
  149. }
  150. watch(() => props.model, updateCtx)
  151. // 绘制画笔墨迹方法
  152. const draw = (posX: number, posY: number, lineWidth: number) => {
  153. if (!ctx) return
  154. const lastPosX = lastPos.x
  155. const lastPosY = lastPos.y
  156. ctx.lineWidth = lineWidth
  157. ctx.strokeStyle = props.color
  158. ctx.beginPath()
  159. ctx.moveTo(lastPosX, lastPosY)
  160. ctx.lineTo(posX, posY)
  161. ctx.stroke()
  162. ctx.closePath()
  163. }
  164. // 擦除墨迹方法
  165. const erase = (posX: number, posY: number) => {
  166. if (!ctx || !canvasRef.value) return
  167. const lastPosX = lastPos.x
  168. const lastPosY = lastPos.y
  169. const radius = props.rubberSize / 2
  170. const sinRadius = radius * Math.sin(Math.atan((posY - lastPosY) / (posX - lastPosX)))
  171. const cosRadius = radius * Math.cos(Math.atan((posY - lastPosY) / (posX - lastPosX)))
  172. const rectPoint1: [number, number] = [lastPosX + sinRadius, lastPosY - cosRadius]
  173. const rectPoint2: [number, number] = [lastPosX - sinRadius, lastPosY + cosRadius]
  174. const rectPoint3: [number, number] = [posX + sinRadius, posY - cosRadius]
  175. const rectPoint4: [number, number] = [posX - sinRadius, posY + cosRadius]
  176. ctx.save()
  177. ctx.beginPath()
  178. ctx.arc(posX, posY, radius, 0, Math.PI * 2)
  179. ctx.clip()
  180. ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
  181. ctx.restore()
  182. ctx.save()
  183. ctx.beginPath()
  184. ctx.moveTo(...rectPoint1)
  185. ctx.lineTo(...rectPoint3)
  186. ctx.lineTo(...rectPoint4)
  187. ctx.lineTo(...rectPoint2)
  188. ctx.closePath()
  189. ctx.clip()
  190. ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
  191. ctx.restore()
  192. }
  193. // 计算鼠标两次移动之间的距离
  194. const getDistance = (posX: number, posY: number) => {
  195. const lastPosX = lastPos.x
  196. const lastPosY = lastPos.y
  197. return Math.sqrt((posX - lastPosX) * (posX - lastPosX) + (posY - lastPosY) * (posY - lastPosY))
  198. }
  199. // 根据鼠标两次移动之间的距离s和时间t计算绘制速度,速度越快,墨迹越细
  200. const getLineWidth = (s: number, t: number) => {
  201. const maxV = 10
  202. const minV = 0.1
  203. const maxWidth = props.penSize
  204. const minWidth = 3
  205. const v = s / t
  206. let lineWidth
  207. if (v <= minV) lineWidth = maxWidth
  208. else if (v >= maxV) lineWidth = minWidth
  209. else lineWidth = maxWidth - v / maxV * maxWidth
  210. if (lastLineWidth === -1) return lineWidth
  211. return lineWidth * 1 / 3 + lastLineWidth * 2 / 3
  212. }
  213. // 形状绘制
  214. const drawShape = (currentX: number, currentY: number) => {
  215. if (!ctx || !initialImageData) return
  216. ctx.putImageData(initialImageData, 0, 0)
  217. const startX = lastPos.x
  218. const startY = lastPos.y
  219. ctx.save()
  220. ctx.lineCap = 'butt'
  221. ctx.lineJoin = 'miter'
  222. ctx.beginPath()
  223. if (props.shapeType === 'rect') {
  224. const width = currentX - startX
  225. const height = currentY - startY
  226. ctx.rect(startX, startY, width, height)
  227. }
  228. else if (props.shapeType === 'circle') {
  229. const width = currentX - startX
  230. const height = currentY - startY
  231. const centerX = startX + width / 2
  232. const centerY = startY + height / 2
  233. const radiusX = Math.abs(width) / 2
  234. const radiusY = Math.abs(height) / 2
  235. ctx.ellipse(
  236. centerX,
  237. centerY,
  238. Math.abs(radiusX),
  239. Math.abs(radiusY),
  240. 0,
  241. 0,
  242. Math.PI * 2,
  243. )
  244. }
  245. else if (props.shapeType === 'arrow') {
  246. const dx = currentX - startX
  247. const dy = currentY - startY
  248. const angle = Math.atan2(dy, dx)
  249. const arrowLength = Math.max(props.shapeSize, 4) * 2
  250. const endX = currentX - (Math.cos(angle) * arrowLength)
  251. const endY = currentY - (Math.sin(angle) * arrowLength)
  252. ctx.moveTo(startX, startY)
  253. ctx.lineTo(endX, endY)
  254. }
  255. ctx.strokeStyle = props.color
  256. ctx.lineWidth = props.shapeSize
  257. ctx.stroke()
  258. ctx.restore()
  259. if (props.shapeType === 'arrow') {
  260. const dx = currentX - startX
  261. const dy = currentY - startY
  262. const angle = Math.atan2(dy, dx)
  263. const arrowLength = Math.max(props.shapeSize, 4) * 2.6
  264. const arrowWidth = Math.max(props.shapeSize, 4) * 1.6
  265. const arrowBaseX = currentX - (Math.cos(angle) * arrowLength)
  266. const arrowBaseY = currentY - (Math.sin(angle) * arrowLength)
  267. ctx.save()
  268. ctx.beginPath()
  269. ctx.moveTo(currentX, currentY)
  270. const leftX = arrowBaseX + arrowWidth * Math.cos(angle + Math.PI / 2)
  271. const leftY = arrowBaseY + arrowWidth * Math.sin(angle + Math.PI / 2)
  272. const rightX = arrowBaseX + arrowWidth * Math.cos(angle - Math.PI / 2)
  273. const rightY = arrowBaseY + arrowWidth * Math.sin(angle - Math.PI / 2)
  274. ctx.lineTo(leftX, leftY)
  275. ctx.lineTo(rightX, rightY)
  276. ctx.closePath()
  277. ctx.fillStyle = props.color
  278. ctx.fill()
  279. ctx.restore()
  280. }
  281. }
  282. // 路径操作
  283. const handleMove = (x: number, y: number) => {
  284. const time = new Date().getTime()
  285. if (props.model === 'pen') {
  286. const s = getDistance(x, y)
  287. const t = time - lastTime
  288. const lineWidth = getLineWidth(s, t)
  289. draw(x, y, lineWidth)
  290. lastLineWidth = lineWidth
  291. lastPos = { x, y }
  292. lastTime = new Date().getTime()
  293. }
  294. else if (props.model === 'mark') {
  295. draw(x, y, props.markSize)
  296. lastPos = { x, y }
  297. }
  298. else if (props.model === 'eraser') {
  299. erase(x, y)
  300. lastPos = { x, y }
  301. }
  302. else if (props.model === 'shape') {
  303. drawShape(x, y)
  304. }
  305. }
  306. // 获取鼠标在canvas中的相对位置
  307. const getMouseOffsetPosition = (e: MouseEvent | TouchEvent) => {
  308. if (!canvasRef.value) return [0, 0]
  309. const event = e instanceof MouseEvent ? e : e.changedTouches[0]
  310. const canvasRect = canvasRef.value.getBoundingClientRect()
  311. const x = event.pageX - canvasRect.x
  312. const y = event.pageY - canvasRect.y
  313. return [x, y]
  314. }
  315. // 处理鼠标(触摸)事件
  316. // 准备开始绘制/擦除墨迹(落笔)
  317. const handleMousedown = (e: MouseEvent | TouchEvent) => {
  318. const [mouseX, mouseY] = getMouseOffsetPosition(e)
  319. const x = mouseX / widthScale.value
  320. const y = mouseY / heightScale.value
  321. if (props.model === 'shape') {
  322. initialImageData = ctx!.getImageData(0, 0, canvasRef.value!.width, canvasRef.value!.height)
  323. }
  324. isMouseDown = true
  325. lastPos = { x, y }
  326. lastTime = new Date().getTime()
  327. if (!(e instanceof MouseEvent)) {
  328. mouse.value = { x: mouseX, y: mouseY }
  329. mouseInCanvas.value = true
  330. }
  331. }
  332. // 开始绘制/擦除墨迹(移动)
  333. const handleMousemove = (e: MouseEvent | TouchEvent) => {
  334. const [mouseX, mouseY] = getMouseOffsetPosition(e)
  335. const x = mouseX / widthScale.value
  336. const y = mouseY / heightScale.value
  337. mouse.value = { x: mouseX, y: mouseY }
  338. if (isMouseDown) handleMove(x, y)
  339. }
  340. // 结束绘制/擦除墨迹(停笔)
  341. const handleMouseup = () => {
  342. if (!isMouseDown) return
  343. isMouseDown = false
  344. emit('end')
  345. }
  346. // 清空画布
  347. const clearCanvas = () => {
  348. if (!ctx || !canvasRef.value) return
  349. ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
  350. emit('end')
  351. }
  352. // 获取 DataURL
  353. const getImageDataURL = () => {
  354. return canvasRef.value?.toDataURL()
  355. }
  356. // 设置 DataURL(绘制图片到 canvas)
  357. const setImageDataURL = (imageDataURL: string) => {
  358. if (!ctx || !canvasRef.value) return
  359. ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
  360. if (imageDataURL) {
  361. ctx.globalCompositeOperation = 'source-over'
  362. ctx.globalAlpha = 1
  363. const img = new Image()
  364. img.src = imageDataURL
  365. img.onload = () => {
  366. // 直接按canvas尺寸绘制,不缩放(因为画图数据本身就是按canvas尺寸保存的)
  367. // 如果canvas尺寸不同,需要按比例缩放
  368. const canvasWidth = canvasRef.value!.width
  369. const canvasHeight = canvasRef.value!.height
  370. const imgWidth = img.width
  371. const imgHeight = img.height
  372. // 如果图片尺寸和canvas尺寸一致,直接绘制
  373. if (imgWidth === canvasWidth && imgHeight === canvasHeight) {
  374. ctx!.drawImage(img, 0, 0)
  375. }
  376. else {
  377. // 如果尺寸不一致,按比例缩放(保持宽高比)
  378. const scaleX = canvasWidth / imgWidth
  379. const scaleY = canvasHeight / imgHeight
  380. const scale = Math.min(scaleX, scaleY)
  381. const scaledWidth = imgWidth * scale
  382. const scaledHeight = imgHeight * scale
  383. const x = (canvasWidth - scaledWidth) / 2
  384. const y = (canvasHeight - scaledHeight) / 2
  385. ctx!.drawImage(img, x, y, scaledWidth, scaledHeight)
  386. }
  387. updateCtx()
  388. }
  389. img.onerror = () => {
  390. console.warn('📝 画图图片加载失败:', imageDataURL.substring(0, 50) + '...')
  391. }
  392. }
  393. }
  394. defineExpose({
  395. clearCanvas,
  396. getImageDataURL,
  397. setImageDataURL,
  398. })
  399. </script>
  400. <style lang="scss" scoped>
  401. .writing-board {
  402. z-index: 8;
  403. cursor: none;
  404. @include absolute-0();
  405. }
  406. .blackboard {
  407. width: 100%;
  408. height: 100%;
  409. background-color: #0f392b;
  410. }
  411. .canvas {
  412. position: absolute;
  413. top: 0;
  414. left: 0;
  415. }
  416. .eraser, .pen {
  417. pointer-events: none;
  418. position: absolute;
  419. z-index: 9;
  420. .icon {
  421. filter: drop-shadow(2px 2px 2px #555);
  422. }
  423. }
  424. .eraser {
  425. display: flex;
  426. justify-content: center;
  427. align-items: center;
  428. border-radius: 50%;
  429. border: 4px solid rgba($color: #555, $alpha: .15);
  430. color: rgba($color: #555, $alpha: .75);
  431. }
  432. </style>