/**
* 实时画图模块
*/
import { dialog } from '../dialog.js';
import { outPutError, outPutInfo, outPutSuccess } from '../console-logger.js';
class CanvasChat {
constructor() {
this.connection = null;
this.isOpen = false;
this.drawingUser = null;
this.currentUserId = null;
this.isDrawing = false;
this.context = null;
this.lastPos = { x: 0, y: 0 };
this.color = this.getDefaultColor();
this.size = 2;
this.canvasContainer = null;
this.canvas = null;
// 添加到DOM
this.createUI();
// 监听主题变化
this.setupThemeListener();
}
init(connection) {
this.connection = connection;
if (!this.connection) return;
this.connection.on("OnDrawingUserChanged", (user) => {
this.handleDrawingUserChanged(user);
});
this.connection.on("OnDraw", (data) => {
this.handleRemoteDraw(data);
});
}
setUserId(userId) {
this.currentUserId = userId;
}
getDefaultColor() {
// 优先从localStorage读取用户保存的颜色
const savedColor = localStorage.getItem('canvas-chat-color');
if (savedColor) {
return savedColor;
}
// 如果没有保存的颜色,根据主题返回默认颜色
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return isDarkMode ? '#ffffff' : '#000000';
}
setupThemeListener() {
// 监听系统主题变化
if (window.matchMedia) {
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkModeQuery.addEventListener('change', (e) => {
// 只有在用户没有自定义颜色时才自动切换
const savedColor = localStorage.getItem('canvas-chat-color');
if (!savedColor) {
const newColor = e.matches ? '#ffffff' : '#000000';
this.color = newColor;
if (this.colorPicker) {
this.colorPicker.value = newColor;
}
}
});
}
}
createUI() {
const container = document.createElement('div');
container.className = 'canvas-chat';
container.innerHTML = `
<div class="canvas-chat__container">
<div class="canvas-chat__header">
<div style="display:flex; align-items:center;">
<h3 class="canvas-chat__title">你画我猜 (实时画板)</h3>
<span class="canvas-chat__status" id="canvas-chat-status">等待开始...</span>
</div>
<button class="canvas-chat__close" aria-label="关闭">
<i class="fa fa-times"></i>
</button>
</div>
<div class="canvas-chat__body" id="canvas-chat-body">
<canvas class="canvas-chat__canvas" id="canvas-chat-canvas"></canvas>
</div>
<div class="canvas-chat__toolbar" id="canvas-chat-toolbar">
<input type="color" class="canvas-chat__color-picker" id="canvas-chat-color" value="${this.getDefaultColor()}" title="选择颜色">
<input type="range" class="canvas-chat__size-slider" id="canvas-chat-size" min="1" max="20" value="2" title="笔刷大小">
<button class="canvas-chat__tool-btn" id="canvas-chat-clear" title="清空画板">
<i class="fa fa-trash"></i>
</button>
<button class="canvas-chat__tool-btn canvas-chat__tool-btn--active" id="canvas-chat-pencil" title="画笔">
<i class="fa fa-pencil"></i>
</button>
</div>
</div>
`;
document.body.appendChild(container);
this.canvasContainer = container;
this.canvas = container.querySelector('#canvas-chat-canvas');
this.context = this.canvas.getContext('2d');
this.statusEl = container.querySelector('#canvas-chat-status');
this.toolbar = container.querySelector('#canvas-chat-toolbar');
this.closeBtn = container.querySelector('.canvas-chat__close');
this.colorPicker = container.querySelector('#canvas-chat-color');
this.sizeSlider = container.querySelector('#canvas-chat-size');
this.clearBtn = container.querySelector('#canvas-chat-clear');
this.bindEvents();
this.resizeCanvas();
// 监听窗口大小变化
window.addEventListener('resize', () => this.resizeCanvas());
window.addEventListener('orientationchange', () => {
setTimeout(() => this.resizeCanvas(), 100);
});
// 监听视口变化(Visual Viewport API,如果支持)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => this.resizeCanvas());
window.visualViewport.addEventListener('scroll', () => this.resizeCanvas());
}
}
bindEvents() {
// 关闭按钮
this.closeBtn.addEventListener('click', () => {
this.close();
});
// 颜色选择
this.colorPicker.addEventListener('change', (e) => {
this.color = e.target.value;
// 保存用户选择的颜色到localStorage
localStorage.setItem('canvas-chat-color', e.target.value);
});
// 大小选择
this.sizeSlider.addEventListener('input', (e) => {
this.size = parseInt(e.target.value);
});
// 清空
this.clearBtn.addEventListener('click', () => {
if (this.canDraw()) {
this.clearCanvas();
this.sendDrawData({ type: 'clear' });
}
});
// 画布事件
const startDrawing = (e) => {
if (!this.canDraw()) return;
this.isDrawing = true;
const pos = this.getPos(e);
this.lastPos = pos;
// 发送开始点,确保单点也能画出来
this.draw(this.lastPos, pos, this.color, this.size);
this.sendDrawData({
type: 'path',
x0: this.lastPos.x / this.canvas.width,
y0: this.lastPos.y / this.canvas.height,
x1: pos.x / this.canvas.width,
y1: pos.y / this.canvas.height,
color: this.color,
size: this.size
});
};
const draw = (e) => {
if (!this.isDrawing || !this.canDraw()) return;
e.preventDefault(); // 防止触摸滚动
const pos = this.getPos(e);
// 绘制
this.draw(this.lastPos, pos, this.color, this.size);
// 发送数据 (归一化坐标)
this.sendDrawData({
type: 'path',
x0: this.lastPos.x / this.canvas.width,
y0: this.lastPos.y / this.canvas.height,
x1: pos.x / this.canvas.width,
y1: pos.y / this.canvas.height,
color: this.color,
size: this.size
});
this.lastPos = pos;
};
const stopDrawing = () => {
this.isDrawing = false;
};
// Mouse events
this.canvas.addEventListener('mousedown', startDrawing);
this.canvas.addEventListener('mousemove', draw);
this.canvas.addEventListener('mouseup', stopDrawing);
this.canvas.addEventListener('mouseout', stopDrawing);
// Touch events
this.canvas.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) startDrawing(e.touches[0]);
}, { passive: false });
this.canvas.addEventListener('touchmove', (e) => {
if (e.touches.length === 1) draw(e.touches[0]);
}, { passive: false });
this.canvas.addEventListener('touchend', stopDrawing);
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
resizeCanvas() {
if (!this.canvasContainer) return;
// 获取真实视口高度(移动端考虑地址栏)
const getViewportHeight = () => {
// Visual Viewport API
if (window.visualViewport) {
return window.visualViewport.height;
}
// 回退到 window.innerHeight
return window.innerHeight;
};
const viewportHeight = getViewportHeight();
const viewportWidth = window.innerWidth;
// 移动端使用更大的画布区域
const isMobile = viewportWidth <= 640;
const width = isMobile
? Math.min(viewportWidth * 0.95, viewportWidth - 20)
: Math.min(viewportWidth * 0.9, 800);
const height = isMobile
? Math.min(viewportHeight * 0.85, viewportHeight - 100)
: Math.min(viewportHeight * 0.7, 600);
// 设置canvas显示大小
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
// 设置canvas实际大小 (考虑DPI)
// 简单起见,这里直接使用显示大小,如果需要高清,可以 * devicePixelRatio
this.canvas.width = width;
this.canvas.height = height;
// 重设Context属性 (因为重设宽高会重置Context)
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
}
canDraw() {
return this.isOpen && this.drawingUser && this.currentUserId && this.drawingUser.id === this.currentUserId;
}
async requestAccess() {
if (!this.connection) return;
try {
await this.connection.invoke("RequestDrawingAccess");
} catch (err) {
outPutError("请求画图权限失败: " + err);
}
}
async releaseAccess() {
if (!this.connection) return;
try {
await this.connection.invoke("ReleaseDrawingAccess");
} catch (err) {
outPutError("释放画图权限失败: " + err);
}
}
async sendDrawData(data) {
if (!this.connection) return;
try {
await this.connection.invoke("SendDrawingData", data);
} catch (err) {
console.error("发送画图数据失败", err);
}
}
handleDrawingUserChanged(user) {
this.drawingUser = user;
if (user) {
if (user.id === this.currentUserId) {
this.statusEl.textContent = "正在作画:你自己";
this.statusEl.style.color = "#2196f3";
this.toolbar.style.display = 'flex';
dialog.toast('你已获得画图权限,开始展示吧!', 'success');
if (!this.isOpen) {
this.open();
}
} else {
this.statusEl.textContent = `正在作画:${user.name}`;
this.statusEl.style.color = "#666";
this.toolbar.style.display = 'none'; // 观看者不能操作工具栏
// 自动打开观看
if (!this.isOpen) {
this.open();
dialog.toast(`${user.name} 正在作画,已自动打开画板`, 'info');
} else {
dialog.toast(`${user.name} 开始作画了`, 'info');
}
}
} else {
this.statusEl.textContent = "画板空闲";
this.statusEl.style.color = "#666";
this.toolbar.style.display = 'none';
if (this.isOpen) {
dialog.toast('画图已结束', 'info');
this.close();
}
}
}
handleRemoteDraw(data) {
if (!this.isOpen) return;
if (data.type === 'clear') {
this.clearCanvas();
} else if (data.type === 'path') {
const x0 = data.x0 * this.canvas.width;
const y0 = data.y0 * this.canvas.height;
const x1 = data.x1 * this.canvas.width;
const y1 = data.y1 * this.canvas.height;
this.draw({x: x0, y: y0}, {x: x1, y: y1}, data.color, data.size);
}
}
draw(start, end, color, size) {
this.context.beginPath();
this.context.moveTo(start.x, start.y);
this.context.lineTo(end.x, end.y);
this.context.strokeStyle = color;
this.context.lineWidth = size;
this.context.stroke();
this.context.closePath();
}
clearCanvas() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
open() {
this.isOpen = true;
this.canvasContainer.classList.add('canvas-chat--visible');
// 确保视口高度已更新
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
// 延迟一下再调整画布,确保布局已完成
requestAnimationFrame(() => {
this.resizeCanvas();
});
}
close() {
this.isOpen = false;
this.canvasContainer.classList.remove('canvas-chat--visible');
// 如果是我在画,关闭时释放权限
if (this.drawingUser && this.currentUserId && this.drawingUser.id === this.currentUserId) {
this.releaseAccess();
}
}
}
export const canvasChat = new CanvasChat();
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
这是一个用于“你画我猜”场景的前端实时画板模块(CanvasChat)的实现。总体功能是:在页面插入一个画板 UI,支持本地绘制(鼠标/触摸),把绘制数据通过实时连接(connection)广播给其他客户端,同时接收远端绘制数据渲染到画布上;并实现了“绘图权限”分配(谁能画谁能操作工具栏)、颜色记忆、响应式布局与深色主题适配等。
下面按功能模块逐项说明:
- 构造与初始化
- constructor()
- 初始化状态变量(连接、打开状态、当前绘图用户、当前用户 id、是否正在绘制、canvas 上下文等)。
- 获取默认颜色并将 UI 插入页面(createUI)。
- 监听系统主题变化(setupThemeListener)。
- init(connection)
- 保存 connection(期望为 SignalR 或类似的实时连接对象)。
- 订阅 connection 的两个事件:
- "OnDrawingUserChanged":绘图用户变更,调用 handleDrawingUserChanged。
- "OnDraw":接收到远端绘图数据,调用 handleRemoteDraw。
- setUserId(userId) 用于设置当前客户端的用户 id(判断是否有绘图权限)。
- UI 创建与 DOM 元素
- createUI()
- 动态创建包含 header、canvas、工具栏(颜色选择、笔刷大小、清空按钮、画笔按钮)的 DOM 并 append 到 body。
- 获取并保存常用元素引用(canvas、context、statusEl、toolbar、colorPicker 等)。
- 绑定各类事件 bindEvents(),并调用 resizeCanvas() 初始化画布大小。
- 监听窗口 resize、orientationchange 和 visualViewport 以便自适应画布尺寸。
- 主题与颜色管理
- getDefaultColor()
- 优先从 localStorage ('canvas-chat-color') 读取用户上次保存的颜色;否则根据系统深色模式给出白/黑色默认。
- setupThemeListener()
- 监听 prefers-color-scheme 变化,当用户没有自定义颜色(localStorage 为空)时自动切换默认颜色,并同步 color picker 值。
- 事件绑定与绘制交互
- bindEvents()
- 关闭按钮:调用 close()。
- 颜色选择:更新 this.color 并保存到 localStorage。
- 大小滑块:更新 this.size。
- 清空按钮:如果有绘图权限(canDraw),执行 clearCanvas 并发送 type:'clear' 给其他端。
- Canvas 的鼠标/触摸事件:
- startDrawing(mousedown / touchstart):检查权限,设置 isDrawing,记录起点,绘制并发送首段数据,保证单点也能画出。
- draw(mousemove / touchmove):如果正在绘制,绘制并发送归一化后的路径数据。
- stopDrawing(mouseup / mouseout / touchend):结束绘制。
- 坐标通过 getPos(e) 从 clientX/clientY 转为相对 canvas 的坐标。
- 绘图数据传输与格式
- sendDrawData(data)
- 通过 this.connection.invoke("SendDrawingData", data) 发送(异步)。
- 发送的数据格式主要两种:
- { type: 'clear' }
- { type: 'path', x0, y0, x1, y1, color, size } —— x0/y0/x1/y1 是相对于 canvas 宽高的归一化坐标(0-1),以便不同分辨率下重绘。
- 接收端 handleRemoteDraw(data)
- 若 type==='clear' 则清空画布。
- 若 type==='path' 则反归一化到当前 canvas 大小并调用 draw() 绘制。
- 权限与状态管理
- canDraw()
- 返回 true 当画板打开且 drawingUser 存在且 currentUserId 等于 drawingUser.id。
- requestAccess() / releaseAccess()
- 分别通过 connection.invoke 请求或释放绘图权限("RequestDrawingAccess"、"ReleaseDrawingAccess")。
- handleDrawingUserChanged(user)
- 更新 drawingUser 并根据是否是当前用户调整 UI:
- 如果自己是绘图者:显示工具栏、提示并 open() 画板(若未打开)。
- 如果是他人绘图:隐藏工具栏(观看者不能操作)、自动打开并提示。
- 若 user 为 null:画板空闲,隐藏工具栏并关闭画板(若打开则提示并 close())。
- 更新 drawingUser 并根据是否是当前用户调整 UI:
- 画布大小与渲染细节
- resizeCanvas()
- 根据窗口 / visualViewport 大小计算 canvas 显示尺寸(移动端和桌面端使用不同策略),设置 style.width/style.height 与 canvas.width/canvas.height。
- 重设 context.lineCap/lineJoin(注意设置 canvas 的宽高会重置 context)。
- 当前实现未按 devicePixelRatio 缩放(注释提到可选兼容高 DPI)。
- draw(start,end,color,size)
- 在 context 上绘一段线(beginPath-moveTo-lineTo-stroke-closePath),使用 round 的 lineCap/lineJoin。
- 打开 / 关闭画板
- open():设置 isOpen,添加 visible 类,设置 CSS 变量 --vh,并在下一帧调整 canvas 大小。
- close():移除 visible 类,若自己是绘图者则在关闭时主动 releaseAccess()。
- 外部依赖
- dialog(来自 '../dialog.js'):用于 toast 提示。
- outPutError / outPutInfo / outPutSuccess(来自 '../console-logger.js'):日志输出(代码中只用到了 outPutError)。
- this.connection:需要外部注入,必须实现 on(event, handler) 与 invoke(method, args...) 接口(典型为 SignalR 或类似实时服务)。
- 注意点与可能的改进(简短提示)
- canvas 宽高变化会清空 canvas 上的绘制(改变 width/height 会重置 context);当前代码在 resize 时没有保存并重绘历史路径,可能导致窗口变动后内容丢失。解决方法包括维护绘画命令历史或在一个离屏 canvas 上做缩放/复制。
- 发送的坐标以 canvas 当前宽高归一化,若接收端的 canvas 大小与发送时不同会按接收端的大小渲染(这是目的),但若发送端/接收端在绘制后 resize,历史数据不能自动缩放回原样。
- 未处理网络抖动或丢包、合并路径以减少消息频率、也没有做节流(throttle)或压缩,可能在高频移动时产生大量消息。可考虑对 draw 事件节流或批量发送路径。
- 对高 DPI(devicePixelRatio)支持不完整,若需要高清渲染建议根据 devicePixelRatio 调整 canvas 实际像素并相应调整绘制坐标。
- 安全:localStorage 使用颜色字符串无关安全问题,但 connection 的错误处理较简单,可增加重试与用户提示。
总结:这个类封装了一个具有权限控制和实时同步功能的画板组件,支持鼠标与触摸操作、颜色记忆、主题响应和基本的画布自适应,配合后端实时连接可以实现多人观看/绘制的协作场景。
评论加载中...