WritingBoard.vue 12 KB

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