feat: 放映画笔工具添加形状和箭头标注

This commit is contained in:
zxc 2025-03-15 22:22:46 +08:00
parent 82dc18f1f1
commit 6c8ed6d5ef
6 changed files with 195 additions and 26 deletions

View File

@ -147,7 +147,7 @@ Browser access: http://127.0.0.1:5173/
- Formula line thickness settings - Formula line thickness settings
### Slide Show ### Slide Show
- Preview all slides - Preview all slides
- Pen and blackboard tools - Brush tools (pen/shape/arrow/highlighter annotation, eraser, blackboard mode)
- Timer tool - Timer tool
- Laser pointer - Laser pointer
- Auto play - Auto play

View File

@ -141,7 +141,7 @@ npm run dev
- 公式线条粗细设置 - 公式线条粗细设置
### 幻灯片放映 ### 幻灯片放映
- 全部幻灯片预览 - 全部幻灯片预览
- 画笔、黑板工具 - 画笔工具(画笔/形状/箭头/荧光笔标注、橡皮擦除、黑板模式)
- 计时器工具 - 计时器工具
- 激光笔 - 激光笔
- 自动放映 - 自动放映

View File

@ -76,7 +76,7 @@ onMounted(() => {
else x.value = document.body.clientWidth + props.left - props.width else x.value = document.body.clientWidth + props.left - props.width
if (props.top >= 0) y.value = props.top if (props.top >= 0) y.value = props.top
else y.value = document.body.clientHeight + props.top - realHeight.value else y.value = document.body.clientHeight + props.top - (props.height || realHeight.value)
w.value = props.width w.value = props.width
h.value = props.height h.value = props.height

View File

@ -37,7 +37,7 @@
}" }"
v-if="model === 'pen'" v-if="model === 'pen'"
> >
<IconWrite class="icon" :size="penSize * 6" v-if="model === 'pen'" /> <IconWrite class="icon" :size="penSize * 6" />
</div> </div>
<div <div
class="pen" class="pen"
@ -48,7 +48,18 @@
}" }"
v-if="model === 'mark'" v-if="model === 'mark'"
> >
<IconHighLight class="icon" :size="markSize * 1.5" 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> </div>
</template> </template>
</div> </div>
@ -59,18 +70,22 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
color?: string color?: string
model?: 'pen' | 'eraser' | 'mark' model?: 'pen' | 'eraser' | 'mark' | 'shape'
shapeType?: 'rect' | 'circle' | 'arrow'
blackboard?: boolean blackboard?: boolean
penSize?: number penSize?: number
markSize?: number markSize?: number
rubberSize?: number rubberSize?: number
shapeSize?: number
}>(), { }>(), {
color: '#ffcc00', color: '#ffcc00',
model: 'pen', model: 'pen',
shapeType: 'rect',
blackboard: false, blackboard: false,
penSize: 6, penSize: 6,
markSize: 24, markSize: 24,
rubberSize: 80, rubberSize: 80,
shapeSize: 4,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -89,6 +104,8 @@ let isMouseDown = false
let lastTime = 0 let lastTime = 0
let lastLineWidth = -1 let lastLineWidth = -1
let initialImageData: ImageData | null = null
// //
const mouse = ref({ const mouse = ref({
x: 0, x: 0,
@ -140,7 +157,7 @@ const updateCtx = () => {
ctx.globalCompositeOperation = 'xor' ctx.globalCompositeOperation = 'xor'
ctx.globalAlpha = 0.5 ctx.globalAlpha = 0.5
} }
else if (props.model === 'pen') { else if (props.model === 'pen' || props.model === 'shape') {
ctx.globalCompositeOperation = 'source-over' ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = 1 ctx.globalAlpha = 1
} }
@ -221,6 +238,92 @@ const getLineWidth = (s: number, t: number) => {
return lineWidth * 1 / 3 + lastLineWidth * 2 / 3 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 handleMove = (x: number, y: number) => {
const time = new Date().getTime() const time = new Date().getTime()
@ -232,12 +335,21 @@ const handleMove = (x: number, y: number) => {
draw(x, y, lineWidth) draw(x, y, lineWidth)
lastLineWidth = lineWidth lastLineWidth = lineWidth
}
else if (props.model === 'mark') draw(x, y, props.markSize)
else erase(x, y)
lastPos = { x, y } lastPos = { x, y }
lastTime = new Date().getTime() 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 // canvas
@ -257,6 +369,9 @@ const handleMousedown = (e: MouseEvent | TouchEvent) => {
const x = mouseX / widthScale.value const x = mouseX / widthScale.value
const y = mouseY / heightScale.value const y = mouseY / heightScale.value
if (props.model === 'shape') {
initialImageData = ctx!.getImageData(0, 0, canvasRef.value!.width, canvasRef.value!.height)
}
isMouseDown = true isMouseDown = true
lastPos = { x, y } lastPos = { x, y }
lastTime = new Date().getTime() lastTime = new Date().getTime()

View File

@ -78,6 +78,7 @@ import {
Click, Click,
Theme, Theme,
ArrowCircleLeft, ArrowCircleLeft,
ArrowRight,
GraphicDesign, GraphicDesign,
Logout, Logout,
Erase, Erase,
@ -209,6 +210,7 @@ export const icons: Icons = {
IconClick: Click, IconClick: Click,
IconTheme: Theme, IconTheme: Theme,
IconArrowCircleLeft: ArrowCircleLeft, IconArrowCircleLeft: ArrowCircleLeft,
IconArrowRight: ArrowRight,
IconGraphicDesign: GraphicDesign, IconGraphicDesign: GraphicDesign,
IconLogout: Logout, IconLogout: Logout,
IconErase: Erase, IconErase: Erase,

View File

@ -14,23 +14,24 @@
:penSize="penSize" :penSize="penSize"
:markSize="markSize" :markSize="markSize"
:rubberSize="rubberSize" :rubberSize="rubberSize"
:shapeSize="shapeSize"
:shapeType="shapeType"
@end="hanldeWritingEnd()" @end="hanldeWritingEnd()"
/> />
</div> </div>
<MoveablePanel <MoveablePanel
class="tools-panel" class="tools-panel"
:width="520" :width="510"
:height="50" :height="50"
:left="left" :left="left"
:top="top" :top="top"
:moveable="sizePopoverType === ''"
> >
<div class="tools" @mousedown.stop> <div class="tools" @mousedown.stop>
<div class="tool-content"> <div class="tool-content">
<Popover trigger="manual" :value="sizePopoverType === 'pen'"> <Popover placement="top" trigger="manual" :value="sizePopoverType === 'pen'">
<template #content> <template #content>
<div class="size"> <div class="setting">
<div class="label">墨迹粗细</div> <div class="label">墨迹粗细</div>
<Slider class="size-slider" :min="4" :max="10" :step="2" v-model:value="penSize" /> <Slider class="size-slider" :min="4" :max="10" :step="2" v-model:value="penSize" />
</div> </div>
@ -39,9 +40,26 @@
<IconWrite class="icon" /> <IconWrite class="icon" />
</div> </div>
</Popover> </Popover>
<Popover trigger="manual" :value="sizePopoverType === 'mark'"> <Popover placement="top" trigger="manual" :value="sizePopoverType === 'shape'">
<template #content> <template #content>
<div class="size"> <div class="setting shape">
<div class="shapes">
<IconSquare class="icon" :class="{ 'active': shapeType === 'rect' }" @click="shapeType = 'rect'" />
<IconRound class="icon" :class="{ 'active': shapeType === 'circle' }" @click="shapeType = 'circle'" />
<IconArrowRight class="icon" :class="{ 'active': shapeType === 'arrow' }" @click="shapeType = 'arrow'" />
</div>
<Divider type="vertical" />
<div class="label">墨迹粗细</div>
<Slider class="size-slider" :min="2" :max="8" :step="2" v-model:value="shapeSize" />
</div>
</template>
<div class="btn" :class="{ 'active': writingBoardModel === 'shape' }" v-tooltip="'形状'" @click="changeModel('shape')">
<IconGraphicDesign class="icon" />
</div>
</Popover>
<Popover placement="top" trigger="manual" :value="sizePopoverType === 'mark'">
<template #content>
<div class="setting">
<div class="label">墨迹粗细</div> <div class="label">墨迹粗细</div>
<Slider class="size-slider" :min="16" :max="40" :step="4" v-model:value="markSize" /> <Slider class="size-slider" :min="16" :max="40" :step="4" v-model:value="markSize" />
</div> </div>
@ -50,9 +68,9 @@
<IconHighLight class="icon" /> <IconHighLight class="icon" />
</div> </div>
</Popover> </Popover>
<Popover trigger="manual" :value="sizePopoverType === 'eraser'"> <Popover placement="top" trigger="manual" :value="sizePopoverType === 'eraser'">
<template #content> <template #content>
<div class="size"> <div class="setting">
<div class="label">橡皮大小</div> <div class="label">橡皮大小</div>
<Slider class="size-slider" :min="20" :max="200" :step="20" v-model:value="rubberSize" /> <Slider class="size-slider" :min="20" :max="200" :step="20" v-model:value="rubberSize" />
</div> </div>
@ -70,7 +88,7 @@
<div class="colors"> <div class="colors">
<div <div
class="color" class="color"
:class="{ 'active': color === writingBoardColor }" :class="{ 'active': color === writingBoardColor, 'white': color === '#ffffff' }"
v-for="color in writingBoardColors" v-for="color in writingBoardColors"
:key="color" :key="color"
:style="{ backgroundColor: color }" :style="{ backgroundColor: color }"
@ -78,7 +96,7 @@
></div> ></div>
</div> </div>
</div> </div>
<div class="btn" v-tooltip="'关闭画笔'" @click="closeWritingBoard()"> <div class="btn close" v-tooltip="'关闭画笔'" @click="closeWritingBoard()">
<IconClose class="icon" /> <IconClose class="icon" />
</div> </div>
</div> </div>
@ -96,10 +114,11 @@ import WritingBoard from '@/components/WritingBoard.vue'
import MoveablePanel from '@/components/MoveablePanel.vue' import MoveablePanel from '@/components/MoveablePanel.vue'
import Slider from '@/components/Slider.vue' import Slider from '@/components/Slider.vue'
import Popover from '@/components/Popover.vue' import Popover from '@/components/Popover.vue'
import Divider from '@/components//Divider.vue'
const writingBoardColors = ['#000000', '#ffffff', '#1e497b', '#4e81bb', '#e2534d', '#9aba60', '#8165a0', '#47acc5', '#f9974c', '#ffff3a'] const writingBoardColors = ['#000000', '#ffffff', '#1e497b', '#4e81bb', '#e2534d', '#9aba60', '#8165a0', '#47acc5', '#f9974c', '#ffff3a']
type WritingBoardModel = 'pen' | 'mark' | 'eraser' type WritingBoardModel = 'pen' | 'mark' | 'eraser' | 'shape'
withDefaults(defineProps<{ withDefaults(defineProps<{
slideWidth: number slideWidth: number
@ -122,10 +141,12 @@ const writingBoardColor = ref('#e2534d')
const writingBoardModel = ref<WritingBoardModel>('pen') const writingBoardModel = ref<WritingBoardModel>('pen')
const blackboard = ref(false) const blackboard = ref(false)
const sizePopoverType = ref<'' | WritingBoardModel>('') const sizePopoverType = ref<'' | WritingBoardModel>('')
const shapeType = ref<'rect' | 'circle' | 'arrow'>('rect')
const penSize = ref(6) const penSize = ref(6)
const markSize = ref(24) const markSize = ref(24)
const rubberSize = ref(80) const rubberSize = ref(80)
const shapeSize = ref(4)
const changeModel = (model: WritingBoardModel) => { const changeModel = (model: WritingBoardModel) => {
if (writingBoardModel.value === model) { if (writingBoardModel.value === model) {
@ -199,7 +220,9 @@ const hanldeWritingEnd = () => {
align-items: center; align-items: center;
} }
.btn { .btn {
padding: 5px 10px; padding: 5px;
margin-right: 5px;
border-radius: $borderRadius;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
@ -209,13 +232,17 @@ const hanldeWritingEnd = () => {
background-color: rgba($color: $themeColor, $alpha: .5); background-color: rgba($color: $themeColor, $alpha: .5);
color: #fff; color: #fff;
} }
&.close {
margin-right: 0;
margin-left: 5px;
}
} }
.icon { .icon {
font-size: 20px; font-size: 20px;
} }
.colors { .colors {
display: flex; display: flex;
padding: 0 10px; padding: 0 5px;
} }
.color { .color {
width: 16px; width: 16px;
@ -229,19 +256,44 @@ const hanldeWritingEnd = () => {
&.active { &.active {
transform: scale(1.3); transform: scale(1.3);
} }
&.white {
border: 1px solid #f1f1f1;
}
& + .color { & + .color {
margin-left: 8px; margin-left: 8px;
} }
} }
} }
.size { .setting {
width: 200px; width: 200px;
display: flex; display: flex;
align-items: center; align-items: center;
user-select: none; user-select: none;
font-size: 13px; font-size: 13px;
&.shape {
width: 280px;
}
.shapes {
display: flex;
align-items: center;
.icon {
font-size: 20px;
cursor: pointer;
& + .icon {
margin-left: 6px;
}
&.active {
color: $themeColor;
}
}
}
.label { .label {
width: 70px; width: 70px;
} }