123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- <template>
- <div class="writing-board" ref="writingBoardRef">
- <div class="blackboard" v-if="blackboard"></div>
- <canvas class="canvas" ref="canvasRef"
- :style="{
- width: canvasWidth + 'px',
- height: canvasHeight + 'px',
- }"
- @mousedown="$event => handleMousedown($event)"
- @mousemove="$event => handleMousemove($event)"
- @mouseup="handleMouseup()"
- @touchstart="$event => handleMousedown($event)"
- @touchmove="$event => handleMousemove($event)"
- @touchend="handleMouseup(); mouseInCanvas = false"
- @mouseleave="handleMouseup(); mouseInCanvas = false"
- @mouseenter="mouseInCanvas = true"
- ></canvas>
- <template v-if="mouseInCanvas">
- <div
- class="eraser"
- :style="{
- left: mouse.x - rubberSize / 2 + 'px',
- top: mouse.y - rubberSize / 2 + 'px',
- width: rubberSize + 'px',
- height: rubberSize + 'px',
- }"
- v-if="model === 'eraser'"
- ></div>
- <div
- class="pen"
- :style="{
- left: mouse.x - penSize / 2 + 'px',
- top: mouse.y - penSize * 6 + penSize / 2 + 'px',
- color: color,
- }"
- v-if="model === 'pen'"
- >
- <IconWrite class="icon" :size="penSize * 6" />
- </div>
- <div
- class="pen"
- :style="{
- left: mouse.x - markSize / 2 + 'px',
- top: mouse.y + 'px',
- color: color,
- }"
- v-if="model === 'mark'"
- >
- <IconHighLight class="icon" :size="markSize * 1.5" />
- </div>
- <div
- class="pen"
- :style="{
- left: mouse.x - 20 + 'px',
- top: mouse.y - 20 + 'px',
- color: color,
- }"
- v-if="model === 'shape'"
- >
- <IconPlus class="icon" :size="40" />
- </div>
- </template>
- </div>
- </template>
- <script lang="ts" setup>
- import { computed, onMounted, onUnmounted, ref, watch, useTemplateRef } from 'vue'
- const props = withDefaults(defineProps<{
- color?: string
- model?: 'pen' | 'eraser' | 'mark' | 'shape'
- shapeType?: 'rect' | 'circle' | 'arrow'
- blackboard?: boolean
- penSize?: number
- markSize?: number
- rubberSize?: number
- shapeSize?: number
- }>(), {
- color: '#ffcc00',
- model: 'pen',
- shapeType: 'rect',
- blackboard: false,
- penSize: 6,
- markSize: 24,
- rubberSize: 80,
- shapeSize: 4,
- })
- const emit = defineEmits<{
- (event: 'end'): void
- }>()
- let ctx: CanvasRenderingContext2D | null = null
- const writingBoardRef = useTemplateRef<HTMLElement>('writingBoardRef')
- const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
- let lastPos = {
- x: 0,
- y: 0,
- }
- let isMouseDown = false
- let lastTime = 0
- let lastLineWidth = -1
- let initialImageData: ImageData | null = null
- // 鼠标位置坐标:用于画笔或橡皮位置跟随
- const mouse = ref({
- x: 0,
- y: 0,
- })
- // 鼠标是否处在画布范围内:处在范围内才会显示画笔或橡皮
- const mouseInCanvas = ref(false)
- // 监听更新canvas尺寸
- const canvasWidth = ref(0)
- const canvasHeight = ref(0)
- const widthScale = computed(() => canvasRef.value ? canvasWidth.value / canvasRef.value.width : 1)
- const heightScale = computed(() => canvasRef.value ? canvasHeight.value / canvasRef.value.height : 1)
- const updateCanvasSize = () => {
- if (!writingBoardRef.value) return
- canvasWidth.value = writingBoardRef.value.clientWidth
- canvasHeight.value = writingBoardRef.value.clientHeight
- }
- const resizeObserver = new ResizeObserver(updateCanvasSize)
- onMounted(() => {
- if (writingBoardRef.value) resizeObserver.observe(writingBoardRef.value)
- })
- onUnmounted(() => {
- if (writingBoardRef.value) resizeObserver.unobserve(writingBoardRef.value)
- })
- // 初始化画布
- const initCanvas = () => {
- if (!canvasRef.value || !writingBoardRef.value) return
- ctx = canvasRef.value.getContext('2d')
- if (!ctx) return
- canvasRef.value.width = writingBoardRef.value.clientWidth
- canvasRef.value.height = writingBoardRef.value.clientHeight
- ctx.lineCap = 'round'
- ctx.lineJoin = 'round'
- }
- onMounted(initCanvas)
- // 切换画笔模式时,更新 canvas ctx 配置
- const updateCtx = () => {
- if (!ctx) return
- if (props.model === 'mark') {
- ctx.globalCompositeOperation = 'xor'
- ctx.globalAlpha = 0.5
- }
- else if (props.model === 'pen' || props.model === 'shape') {
- ctx.globalCompositeOperation = 'source-over'
- ctx.globalAlpha = 1
- }
- }
- watch(() => props.model, updateCtx)
- // 绘制画笔墨迹方法
- const draw = (posX: number, posY: number, lineWidth: number) => {
- if (!ctx) return
- const lastPosX = lastPos.x
- const lastPosY = lastPos.y
- ctx.lineWidth = lineWidth
- ctx.strokeStyle = props.color
- ctx.beginPath()
- ctx.moveTo(lastPosX, lastPosY)
- ctx.lineTo(posX, posY)
- ctx.stroke()
- ctx.closePath()
- }
- // 擦除墨迹方法
- const erase = (posX: number, posY: number) => {
- if (!ctx || !canvasRef.value) return
- const lastPosX = lastPos.x
- const lastPosY = lastPos.y
- const radius = props.rubberSize / 2
- const sinRadius = radius * Math.sin(Math.atan((posY - lastPosY) / (posX - lastPosX)))
- const cosRadius = radius * Math.cos(Math.atan((posY - lastPosY) / (posX - lastPosX)))
- const rectPoint1: [number, number] = [lastPosX + sinRadius, lastPosY - cosRadius]
- const rectPoint2: [number, number] = [lastPosX - sinRadius, lastPosY + cosRadius]
- const rectPoint3: [number, number] = [posX + sinRadius, posY - cosRadius]
- const rectPoint4: [number, number] = [posX - sinRadius, posY + cosRadius]
- ctx.save()
- ctx.beginPath()
- ctx.arc(posX, posY, radius, 0, Math.PI * 2)
- ctx.clip()
- ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
- ctx.restore()
- ctx.save()
- ctx.beginPath()
- ctx.moveTo(...rectPoint1)
- ctx.lineTo(...rectPoint3)
- ctx.lineTo(...rectPoint4)
- ctx.lineTo(...rectPoint2)
- ctx.closePath()
- ctx.clip()
- ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
- ctx.restore()
- }
- // 计算鼠标两次移动之间的距离
- const getDistance = (posX: number, posY: number) => {
- const lastPosX = lastPos.x
- const lastPosY = lastPos.y
- return Math.sqrt((posX - lastPosX) * (posX - lastPosX) + (posY - lastPosY) * (posY - lastPosY))
- }
- // 根据鼠标两次移动之间的距离s和时间t计算绘制速度,速度越快,墨迹越细
- const getLineWidth = (s: number, t: number) => {
- const maxV = 10
- const minV = 0.1
- const maxWidth = props.penSize
- const minWidth = 3
- const v = s / t
- let lineWidth
- if (v <= minV) lineWidth = maxWidth
- else if (v >= maxV) lineWidth = minWidth
- else lineWidth = maxWidth - v / maxV * maxWidth
- if (lastLineWidth === -1) return lineWidth
- return lineWidth * 1 / 3 + lastLineWidth * 2 / 3
- }
- // 形状绘制
- const drawShape = (currentX: number, currentY: number) => {
- if (!ctx || !initialImageData) return
- ctx.putImageData(initialImageData, 0, 0)
- const startX = lastPos.x
- const startY = lastPos.y
- ctx.save()
- ctx.lineCap = 'butt'
- ctx.lineJoin = 'miter'
- ctx.beginPath()
- if (props.shapeType === 'rect') {
- const width = currentX - startX
- const height = currentY - startY
- ctx.rect(startX, startY, width, height)
- }
- else if (props.shapeType === 'circle') {
- const width = currentX - startX
- const height = currentY - startY
- const centerX = startX + width / 2
- const centerY = startY + height / 2
- const radiusX = Math.abs(width) / 2
- const radiusY = Math.abs(height) / 2
- ctx.ellipse(
- centerX,
- centerY,
- Math.abs(radiusX),
- Math.abs(radiusY),
- 0,
- 0,
- Math.PI * 2,
- )
- }
- else if (props.shapeType === 'arrow') {
- const dx = currentX - startX
- const dy = currentY - startY
- const angle = Math.atan2(dy, dx)
- const arrowLength = Math.max(props.shapeSize, 4) * 2
-
- const endX = currentX - (Math.cos(angle) * arrowLength)
- const endY = currentY - (Math.sin(angle) * arrowLength)
- ctx.moveTo(startX, startY)
- ctx.lineTo(endX, endY)
- }
- ctx.strokeStyle = props.color
- ctx.lineWidth = props.shapeSize
- ctx.stroke()
- ctx.restore()
- if (props.shapeType === 'arrow') {
- const dx = currentX - startX
- const dy = currentY - startY
- const angle = Math.atan2(dy, dx)
-
- const arrowLength = Math.max(props.shapeSize, 4) * 2.6
- const arrowWidth = Math.max(props.shapeSize, 4) * 1.6
-
- const arrowBaseX = currentX - (Math.cos(angle) * arrowLength)
- const arrowBaseY = currentY - (Math.sin(angle) * arrowLength)
- ctx.save()
- ctx.beginPath()
-
- ctx.moveTo(currentX, currentY)
-
- const leftX = arrowBaseX + arrowWidth * Math.cos(angle + Math.PI / 2)
- const leftY = arrowBaseY + arrowWidth * Math.sin(angle + Math.PI / 2)
- const rightX = arrowBaseX + arrowWidth * Math.cos(angle - Math.PI / 2)
- const rightY = arrowBaseY + arrowWidth * Math.sin(angle - Math.PI / 2)
- ctx.lineTo(leftX, leftY)
- ctx.lineTo(rightX, rightY)
- ctx.closePath()
- ctx.fillStyle = props.color
- ctx.fill()
- ctx.restore()
- }
- }
- // 路径操作
- const handleMove = (x: number, y: number) => {
- const time = new Date().getTime()
- if (props.model === 'pen') {
- const s = getDistance(x, y)
- const t = time - lastTime
- const lineWidth = getLineWidth(s, t)
- draw(x, y, lineWidth)
- lastLineWidth = lineWidth
- lastPos = { x, y }
- lastTime = new Date().getTime()
- }
- else if (props.model === 'mark') {
- draw(x, y, props.markSize)
- lastPos = { x, y }
- }
- else if (props.model ==='eraser') {
- erase(x, y)
- lastPos = { x, y }
- }
- else if (props.model === 'shape') {
- drawShape(x, y)
- }
- }
- // 获取鼠标在canvas中的相对位置
- const getMouseOffsetPosition = (e: MouseEvent | TouchEvent) => {
- if (!canvasRef.value) return [0, 0]
- const event = e instanceof MouseEvent ? e : e.changedTouches[0]
- const canvasRect = canvasRef.value.getBoundingClientRect()
- const x = event.pageX - canvasRect.x
- const y = event.pageY - canvasRect.y
- return [x, y]
- }
- // 处理鼠标(触摸)事件
- // 准备开始绘制/擦除墨迹(落笔)
- const handleMousedown = (e: MouseEvent | TouchEvent) => {
- const [mouseX, mouseY] = getMouseOffsetPosition(e)
- const x = mouseX / widthScale.value
- const y = mouseY / heightScale.value
- if (props.model === 'shape') {
- initialImageData = ctx!.getImageData(0, 0, canvasRef.value!.width, canvasRef.value!.height)
- }
- isMouseDown = true
- lastPos = { x, y }
- lastTime = new Date().getTime()
- if (!(e instanceof MouseEvent)) {
- mouse.value = { x: mouseX, y: mouseY }
- mouseInCanvas.value = true
- }
- }
- // 开始绘制/擦除墨迹(移动)
- const handleMousemove = (e: MouseEvent | TouchEvent) => {
- const [mouseX, mouseY] = getMouseOffsetPosition(e)
- const x = mouseX / widthScale.value
- const y = mouseY / heightScale.value
- mouse.value = { x: mouseX, y: mouseY }
- if (isMouseDown) handleMove(x, y)
- }
- // 结束绘制/擦除墨迹(停笔)
- const handleMouseup = () => {
- if (!isMouseDown) return
- isMouseDown = false
- emit('end')
- }
- // 清空画布
- const clearCanvas = () => {
- if (!ctx || !canvasRef.value) return
- ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
- emit('end')
- }
- // 获取 DataURL
- const getImageDataURL = () => {
- return canvasRef.value?.toDataURL()
- }
- // 设置 DataURL(绘制图片到 canvas)
- const setImageDataURL = (imageDataURL: string) => {
- if (!ctx || !canvasRef.value) return
-
- ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
- if (imageDataURL) {
- ctx.globalCompositeOperation = 'source-over'
- ctx.globalAlpha = 1
- const img = new Image()
- img.src = imageDataURL
- img.onload = () => {
- ctx!.drawImage(img, 0, 0)
- updateCtx()
- }
- }
- }
- defineExpose({
- clearCanvas,
- getImageDataURL,
- setImageDataURL,
- })
- </script>
- <style lang="scss" scoped>
- .writing-board {
- z-index: 8;
- cursor: none;
- @include absolute-0();
- }
- .blackboard {
- width: 100%;
- height: 100%;
- background-color: #0f392b;
- }
- .canvas {
- position: absolute;
- top: 0;
- left: 0;
- }
- .eraser, .pen {
- pointer-events: none;
- position: absolute;
- z-index: 9;
- .icon {
- filter: drop-shadow(2px 2px 2px #555);
- }
- }
- .eraser {
- display: flex;
- justify-content: center;
- align-items: center;
- border-radius: 50%;
- border: 4px solid rgba($color: #555, $alpha: .15);
- color: rgba($color: #555, $alpha: .75);
- }
- </style>
|