web_ppt / frontend /src /components /WritingBoard.vue
CatPtain's picture
Upload 339 files
89ce340 verified
raw
history blame
12.3 kB
<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 } 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 = ref<HTMLElement>()
const canvasRef = ref<HTMLCanvasElement>()
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>