Files
2026-03-22 15:24:40 +08:00

503 lines
14 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自然写互动课堂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>