503 lines
14 KiB
Vue
503 lines
14 KiB
Vue
/**
|
||
* 自然写互动课堂PC端应用软件 V1.0
|
||
*
|
||
* StrokeCanvas.vue - 笔迹画布组件
|
||
*
|
||
* 功能说明:
|
||
* - Canvas 2D高性能笔迹渲染
|
||
* - 压力感应笔锋效果
|
||
* - 贝塞尔曲线平滑
|
||
* - 多图层渲染(背景+已完成笔画+当前笔画)
|
||
* - 笔迹回放动画
|
||
* - 缩放与平移手势
|
||
*/
|
||
|
||
<template>
|
||
<div class="stroke-canvas-container" ref="containerRef">
|
||
<!-- 背景层:课件/试卷图片 -->
|
||
<canvas ref="bgCanvas" class="canvas-layer canvas-bg"></canvas>
|
||
<!-- 笔迹层:已完成的笔画 -->
|
||
<canvas ref="strokeCanvas" class="canvas-layer canvas-stroke"></canvas>
|
||
<!-- 活动层:当前正在绘制的笔画 -->
|
||
<canvas ref="activeCanvas" class="canvas-layer canvas-active"></canvas>
|
||
|
||
<!-- 工具栏 -->
|
||
<div class="canvas-toolbar" v-if="showToolbar">
|
||
<button @click="setPenColor('#000000')" :class="{ active: penColor === '#000000' }">黑</button>
|
||
<button @click="setPenColor('#FF0000')" :class="{ active: penColor === '#FF0000' }">红</button>
|
||
<button @click="setPenColor('#0000FF')" :class="{ active: penColor === '#0000FF' }">蓝</button>
|
||
<button @click="toggleEraser" :class="{ active: eraserMode }">橡皮</button>
|
||
<button @click="undo">撤销</button>
|
||
<button @click="redo">重做</button>
|
||
<button @click="clearAll">清空</button>
|
||
</div>
|
||
|
||
<!-- 缩放控件 -->
|
||
<div class="zoom-controls">
|
||
<span class="zoom-label">{{ Math.round(scale * 100) }}%</span>
|
||
<button @click="zoomIn">+</button>
|
||
<button @click="zoomOut">-</button>
|
||
<button @click="resetZoom">适应</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||
|
||
/* ======================== Props与Emits ======================== */
|
||
|
||
interface Props {
|
||
/** 画布宽度 */
|
||
width?: number;
|
||
/** 画布高度 */
|
||
height?: number;
|
||
/** 背景图片URL */
|
||
backgroundUrl?: string;
|
||
/** 是否显示工具栏 */
|
||
showToolbar?: boolean;
|
||
/** 是否只读模式(仅展示笔迹) */
|
||
readonly?: boolean;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
width: 1920,
|
||
height: 1080,
|
||
showToolbar: true,
|
||
readonly: false
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'stroke-complete', stroke: StrokeData): void;
|
||
(e: 'stroke-point', point: PointData): void;
|
||
}>();
|
||
|
||
/* ======================== 类型定义 ======================== */
|
||
|
||
interface PointData {
|
||
x: number;
|
||
y: number;
|
||
pressure: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
interface StrokeData {
|
||
strokeId: string;
|
||
color: string;
|
||
width: number;
|
||
points: PointData[];
|
||
}
|
||
|
||
/* ======================== 响应式数据 ======================== */
|
||
|
||
/** DOM引用 */
|
||
const containerRef = ref<HTMLDivElement>();
|
||
const bgCanvas = ref<HTMLCanvasElement>();
|
||
const strokeCanvas = ref<HTMLCanvasElement>();
|
||
const activeCanvas = ref<HTMLCanvasElement>();
|
||
|
||
/** 画布上下文 */
|
||
let bgCtx: CanvasRenderingContext2D | null = null;
|
||
let strokeCtx: CanvasRenderingContext2D | null = null;
|
||
let activeCtx: CanvasRenderingContext2D | null = null;
|
||
|
||
/** 画笔状态 */
|
||
const penColor = ref('#000000');
|
||
const penWidth = ref(3);
|
||
const eraserMode = ref(false);
|
||
const scale = ref(1.0);
|
||
|
||
/** 当前笔画 */
|
||
let currentStroke: StrokeData | null = null;
|
||
/** 已完成笔画列表 */
|
||
const completedStrokes: StrokeData[] = [];
|
||
/** 撤销栈 */
|
||
const undoStack: StrokeData[] = [];
|
||
/** 重做栈 */
|
||
const redoStack: StrokeData[] = [];
|
||
/** 是否正在绘制 */
|
||
let isDrawing = false;
|
||
|
||
/* ======================== 平滑算法常量 ======================== */
|
||
|
||
/** 贝塞尔曲线平滑最小距离 */
|
||
const SMOOTH_MIN_DIST = 2;
|
||
/** 笔锋最小宽度比 */
|
||
const PEN_MIN_WIDTH_RATIO = 0.25;
|
||
/** 笔锋最大宽度比 */
|
||
const PEN_MAX_WIDTH_RATIO = 1.6;
|
||
|
||
/* ======================== 生命周期 ======================== */
|
||
|
||
onMounted(() => {
|
||
initCanvases();
|
||
if (props.backgroundUrl) {
|
||
loadBackground(props.backgroundUrl);
|
||
}
|
||
if (!props.readonly) {
|
||
setupInputHandlers();
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
removeInputHandlers();
|
||
});
|
||
|
||
/* ======================== 画布初始化 ======================== */
|
||
|
||
/**
|
||
* 初始化三层画布
|
||
*/
|
||
function initCanvases(): void {
|
||
const canvases = [bgCanvas.value, strokeCanvas.value, activeCanvas.value];
|
||
canvases.forEach(canvas => {
|
||
if (canvas) {
|
||
canvas.width = props.width;
|
||
canvas.height = props.height;
|
||
}
|
||
});
|
||
|
||
bgCtx = bgCanvas.value?.getContext('2d') ?? null;
|
||
strokeCtx = strokeCanvas.value?.getContext('2d') ?? null;
|
||
activeCtx = activeCanvas.value?.getContext('2d') ?? null;
|
||
|
||
/* 笔迹层抗锯齿 */
|
||
if (strokeCtx) {
|
||
strokeCtx.lineCap = 'round';
|
||
strokeCtx.lineJoin = 'round';
|
||
}
|
||
if (activeCtx) {
|
||
activeCtx.lineCap = 'round';
|
||
activeCtx.lineJoin = 'round';
|
||
}
|
||
|
||
console.log(`[画布] 初始化: ${props.width}x${props.height}`);
|
||
}
|
||
|
||
/**
|
||
* 加载背景图片
|
||
*/
|
||
function loadBackground(url: string): void {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
bgCtx?.drawImage(img, 0, 0, props.width, props.height);
|
||
console.log(`[画布] 背景加载完成: ${url}`);
|
||
};
|
||
img.onerror = () => {
|
||
console.error(`[画布] 背景加载失败: ${url}`);
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
/* ======================== 输入事件处理 ======================== */
|
||
|
||
function setupInputHandlers(): void {
|
||
const canvas = activeCanvas.value;
|
||
if (!canvas) return;
|
||
|
||
canvas.addEventListener('pointerdown', onPointerDown);
|
||
canvas.addEventListener('pointermove', onPointerMove);
|
||
canvas.addEventListener('pointerup', onPointerUp);
|
||
canvas.addEventListener('pointercancel', onPointerUp);
|
||
|
||
/* 禁止默认触摸行为(防止页面滚动) */
|
||
canvas.style.touchAction = 'none';
|
||
}
|
||
|
||
function removeInputHandlers(): void {
|
||
const canvas = activeCanvas.value;
|
||
if (!canvas) return;
|
||
|
||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||
canvas.removeEventListener('pointermove', onPointerMove);
|
||
canvas.removeEventListener('pointerup', onPointerUp);
|
||
canvas.removeEventListener('pointercancel', onPointerUp);
|
||
}
|
||
|
||
/**
|
||
* 指针按下 - 开始新笔画
|
||
*/
|
||
function onPointerDown(e: PointerEvent): void {
|
||
if (props.readonly) return;
|
||
|
||
isDrawing = true;
|
||
const { canvasX, canvasY } = screenToCanvas(e.offsetX, e.offsetY);
|
||
const pressure = e.pressure || 0.5;
|
||
|
||
currentStroke = {
|
||
strokeId: `stroke_${Date.now()}`,
|
||
color: eraserMode.value ? '#FFFFFF' : penColor.value,
|
||
width: penWidth.value,
|
||
points: [{ x: canvasX, y: canvasY, pressure, timestamp: Date.now() }]
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 指针移动 - 添加采样点并实时绘制
|
||
*/
|
||
function onPointerMove(e: PointerEvent): void {
|
||
if (!isDrawing || !currentStroke) return;
|
||
|
||
const { canvasX, canvasY } = screenToCanvas(e.offsetX, e.offsetY);
|
||
const pressure = e.pressure || 0.5;
|
||
|
||
const lastPt = currentStroke.points[currentStroke.points.length - 1];
|
||
const dx = canvasX - lastPt.x;
|
||
const dy = canvasY - lastPt.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
/* 距离过近跳过 */
|
||
if (dist < SMOOTH_MIN_DIST) return;
|
||
|
||
const point: PointData = { x: canvasX, y: canvasY, pressure, timestamp: Date.now() };
|
||
currentStroke.points.push(point);
|
||
emit('stroke-point', point);
|
||
|
||
/* 增量渲染最新线段 */
|
||
drawSegment(activeCtx!, lastPt, point, currentStroke.color, currentStroke.width);
|
||
}
|
||
|
||
/**
|
||
* 指针抬起 - 完成笔画
|
||
*/
|
||
function onPointerUp(e: PointerEvent): void {
|
||
if (!isDrawing || !currentStroke) return;
|
||
|
||
isDrawing = false;
|
||
|
||
if (currentStroke.points.length >= 2) {
|
||
completedStrokes.push(currentStroke);
|
||
undoStack.push(currentStroke);
|
||
redoStack.length = 0;
|
||
|
||
/* 将笔画绘制到笔迹层 */
|
||
drawFullStroke(strokeCtx!, currentStroke);
|
||
emit('stroke-complete', currentStroke);
|
||
}
|
||
|
||
/* 清空活动层 */
|
||
activeCtx?.clearRect(0, 0, props.width, props.height);
|
||
currentStroke = null;
|
||
}
|
||
|
||
/* ======================== 绘制函数 ======================== */
|
||
|
||
/**
|
||
* 绘制单个线段(带压力笔锋)
|
||
*/
|
||
function drawSegment(ctx: CanvasRenderingContext2D, from: PointData,
|
||
to: PointData, color: string, baseWidth: number): void {
|
||
/* 压力感应笔锋:宽度随压力变化 */
|
||
const widthRatio = PEN_MIN_WIDTH_RATIO +
|
||
(PEN_MAX_WIDTH_RATIO - PEN_MIN_WIDTH_RATIO) * to.pressure;
|
||
const lineWidth = baseWidth * widthRatio;
|
||
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.beginPath();
|
||
ctx.moveTo(from.x, from.y);
|
||
ctx.lineTo(to.x, to.y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
/**
|
||
* 绘制完整笔画(贝塞尔曲线平滑)
|
||
*/
|
||
function drawFullStroke(ctx: CanvasRenderingContext2D, stroke: StrokeData): void {
|
||
const points = stroke.points;
|
||
if (points.length < 2) return;
|
||
|
||
ctx.strokeStyle = stroke.color;
|
||
|
||
for (let i = 1; i < points.length; i++) {
|
||
const prev = points[i - 1];
|
||
const curr = points[i];
|
||
|
||
const widthRatio = PEN_MIN_WIDTH_RATIO +
|
||
(PEN_MAX_WIDTH_RATIO - PEN_MIN_WIDTH_RATIO) * curr.pressure;
|
||
ctx.lineWidth = stroke.width * widthRatio;
|
||
|
||
if (i >= 2) {
|
||
/* 二次贝塞尔曲线平滑 */
|
||
const prevPrev = points[i - 2];
|
||
const midX1 = (prevPrev.x + prev.x) / 2;
|
||
const midY1 = (prevPrev.y + prev.y) / 2;
|
||
const midX2 = (prev.x + curr.x) / 2;
|
||
const midY2 = (prev.y + curr.y) / 2;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(midX1, midY1);
|
||
ctx.quadraticCurveTo(prev.x, prev.y, midX2, midY2);
|
||
ctx.stroke();
|
||
} else {
|
||
ctx.beginPath();
|
||
ctx.moveTo(prev.x, prev.y);
|
||
ctx.lineTo(curr.x, curr.y);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ======================== 坐标转换 ======================== */
|
||
|
||
function screenToCanvas(sx: number, sy: number): { canvasX: number; canvasY: number } {
|
||
return {
|
||
canvasX: sx / scale.value,
|
||
canvasY: sy / scale.value
|
||
};
|
||
}
|
||
|
||
/* ======================== 工具栏操作 ======================== */
|
||
|
||
function setPenColor(color: string): void {
|
||
penColor.value = color;
|
||
eraserMode.value = false;
|
||
}
|
||
|
||
function toggleEraser(): void {
|
||
eraserMode.value = !eraserMode.value;
|
||
}
|
||
|
||
function undo(): void {
|
||
const stroke = undoStack.pop();
|
||
if (!stroke) return;
|
||
|
||
redoStack.push(stroke);
|
||
completedStrokes.splice(completedStrokes.indexOf(stroke), 1);
|
||
redrawAllStrokes();
|
||
}
|
||
|
||
function redo(): void {
|
||
const stroke = redoStack.pop();
|
||
if (!stroke) return;
|
||
|
||
undoStack.push(stroke);
|
||
completedStrokes.push(stroke);
|
||
redrawAllStrokes();
|
||
}
|
||
|
||
function clearAll(): void {
|
||
completedStrokes.length = 0;
|
||
undoStack.length = 0;
|
||
redoStack.length = 0;
|
||
strokeCtx?.clearRect(0, 0, props.width, props.height);
|
||
activeCtx?.clearRect(0, 0, props.width, props.height);
|
||
}
|
||
|
||
function redrawAllStrokes(): void {
|
||
strokeCtx?.clearRect(0, 0, props.width, props.height);
|
||
completedStrokes.forEach(stroke => {
|
||
drawFullStroke(strokeCtx!, stroke);
|
||
});
|
||
}
|
||
|
||
/* ======================== 缩放控制 ======================== */
|
||
|
||
function zoomIn(): void {
|
||
scale.value = Math.min(scale.value * 1.25, 3.0);
|
||
}
|
||
|
||
function zoomOut(): void {
|
||
scale.value = Math.max(scale.value / 1.25, 0.25);
|
||
}
|
||
|
||
function resetZoom(): void {
|
||
scale.value = 1.0;
|
||
}
|
||
|
||
/* ======================== 外部笔迹接收 ======================== */
|
||
|
||
/**
|
||
* 接收外部笔迹数据(学生端通过WebSocket推送)
|
||
*/
|
||
function addExternalStroke(stroke: StrokeData): void {
|
||
completedStrokes.push(stroke);
|
||
drawFullStroke(strokeCtx!, stroke);
|
||
}
|
||
|
||
/**
|
||
* 笔迹回放动画
|
||
*/
|
||
async function replayStrokes(strokes: StrokeData[], speedMultiplier: number = 1): Promise<void> {
|
||
for (const stroke of strokes) {
|
||
for (let i = 1; i < stroke.points.length; i++) {
|
||
const prev = stroke.points[i - 1];
|
||
const curr = stroke.points[i];
|
||
|
||
drawSegment(strokeCtx!, prev, curr, stroke.color, stroke.width);
|
||
|
||
const delay = (curr.timestamp - prev.timestamp) / speedMultiplier;
|
||
await new Promise(resolve => setTimeout(resolve, Math.max(delay, 5)));
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 导出方法供父组件调用 */
|
||
defineExpose({ addExternalStroke, replayStrokes, clearAll, loadBackground });
|
||
</script>
|
||
|
||
<style scoped>
|
||
.stroke-canvas-container {
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #f5f5f5;
|
||
}
|
||
.canvas-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
}
|
||
.canvas-bg { z-index: 1; }
|
||
.canvas-stroke { z-index: 2; }
|
||
.canvas-active { z-index: 3; cursor: crosshair; }
|
||
.canvas-toolbar {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 10;
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 8px 16px;
|
||
background: rgba(255,255,255,0.95);
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
}
|
||
.canvas-toolbar button {
|
||
padding: 6px 14px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
.canvas-toolbar button.active {
|
||
background: #1976d2;
|
||
color: #fff;
|
||
border-color: #1976d2;
|
||
}
|
||
.zoom-controls {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
z-index: 10;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
background: rgba(255,255,255,0.9);
|
||
border-radius: 6px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||
}
|
||
.zoom-label { font-size: 12px; color: #666; min-width: 36px; text-align: center; }
|
||
.zoom-controls button {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
</style>
|