/**
 * 实时画图模块
 */

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)广播给其他客户端,同时接收远端绘制数据渲染到画布上;并实现了“绘图权限”分配(谁能画谁能操作工具栏)、颜色记忆、响应式布局与深色主题适配等。

下面按功能模块逐项说明:

  1. 构造与初始化
  • constructor()
    • 初始化状态变量(连接、打开状态、当前绘图用户、当前用户 id、是否正在绘制、canvas 上下文等)。
    • 获取默认颜色并将 UI 插入页面(createUI)。
    • 监听系统主题变化(setupThemeListener)。
  • init(connection)
    • 保存 connection(期望为 SignalR 或类似的实时连接对象)。
    • 订阅 connection 的两个事件:
      • "OnDrawingUserChanged":绘图用户变更,调用 handleDrawingUserChanged。
      • "OnDraw":接收到远端绘图数据,调用 handleRemoteDraw。
  • setUserId(userId) 用于设置当前客户端的用户 id(判断是否有绘图权限)。
  1. UI 创建与 DOM 元素
  • createUI()
    • 动态创建包含 header、canvas、工具栏(颜色选择、笔刷大小、清空按钮、画笔按钮)的 DOM 并 append 到 body。
    • 获取并保存常用元素引用(canvas、context、statusEl、toolbar、colorPicker 等)。
    • 绑定各类事件 bindEvents(),并调用 resizeCanvas() 初始化画布大小。
    • 监听窗口 resize、orientationchange 和 visualViewport 以便自适应画布尺寸。
  1. 主题与颜色管理
  • getDefaultColor()
    • 优先从 localStorage ('canvas-chat-color') 读取用户上次保存的颜色;否则根据系统深色模式给出白/黑色默认。
  • setupThemeListener()
    • 监听 prefers-color-scheme 变化,当用户没有自定义颜色(localStorage 为空)时自动切换默认颜色,并同步 color picker 值。
  1. 事件绑定与绘制交互
  • 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 的坐标。
  1. 绘图数据传输与格式
  • 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() 绘制。
  1. 权限与状态管理
  • canDraw()
    • 返回 true 当画板打开且 drawingUser 存在且 currentUserId 等于 drawingUser.id。
  • requestAccess() / releaseAccess()
    • 分别通过 connection.invoke 请求或释放绘图权限("RequestDrawingAccess"、"ReleaseDrawingAccess")。
  • handleDrawingUserChanged(user)
    • 更新 drawingUser 并根据是否是当前用户调整 UI:
      • 如果自己是绘图者:显示工具栏、提示并 open() 画板(若未打开)。
      • 如果是他人绘图:隐藏工具栏(观看者不能操作)、自动打开并提示。
      • 若 user 为 null:画板空闲,隐藏工具栏并关闭画板(若打开则提示并 close())。
  1. 画布大小与渲染细节
  • 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。
  1. 打开 / 关闭画板
  • open():设置 isOpen,添加 visible 类,设置 CSS 变量 --vh,并在下一帧调整 canvas 大小。
  • close():移除 visible 类,若自己是绘图者则在关闭时主动 releaseAccess()。
  1. 外部依赖
  • dialog(来自 '../dialog.js'):用于 toast 提示。
  • outPutError / outPutInfo / outPutSuccess(来自 '../console-logger.js'):日志输出(代码中只用到了 outPutError)。
  • this.connection:需要外部注入,必须实现 on(event, handler) 与 invoke(method, args...) 接口(典型为 SignalR 或类似实时服务)。
  1. 注意点与可能的改进(简短提示)
  • canvas 宽高变化会清空 canvas 上的绘制(改变 width/height 会重置 context);当前代码在 resize 时没有保存并重绘历史路径,可能导致窗口变动后内容丢失。解决方法包括维护绘画命令历史或在一个离屏 canvas 上做缩放/复制。
  • 发送的坐标以 canvas 当前宽高归一化,若接收端的 canvas 大小与发送时不同会按接收端的大小渲染(这是目的),但若发送端/接收端在绘制后 resize,历史数据不能自动缩放回原样。
  • 未处理网络抖动或丢包、合并路径以减少消息频率、也没有做节流(throttle)或压缩,可能在高频移动时产生大量消息。可考虑对 draw 事件节流或批量发送路径。
  • 对高 DPI(devicePixelRatio)支持不完整,若需要高清渲染建议根据 devicePixelRatio 调整 canvas 实际像素并相应调整绘制坐标。
  • 安全:localStorage 使用颜色字符串无关安全问题,但 connection 的错误处理较简单,可增加重试与用户提示。

总结:这个类封装了一个具有权限控制和实时同步功能的画板组件,支持鼠标与触摸操作、颜色记忆、主题响应和基本的画布自适应,配合后端实时连接可以实现多人观看/绘制的协作场景。

评论加载中...