课堂直播弹幕功能

首先利用vue的循环语句来绑定html中的列表元素

<div class="card-body" id="danmaku">
    <li v-for="chat in chats">
        {{ chat.name }}: {{ chat.content }}
    </li>
</div>
new Vue({
    el: '#danmaku',
    data: {
        chats: [
            { name: "学生1", content: "233"},
            { name: "学生2", content: "233"},
            { name: "学生3", content: "233"},
            { name: "学生4", content: "233"}
        ]
    }
})

然后增加弹幕发送框,利用v-model绑定输入框

var msg = new Vue({
        el: '#send',
        data: {
            message: ''
        }
    })

    $('#btn-send').on('click', () => {
        addDanmaku('‘你’', msg.message);
    })

function addDanmaku(name, content) {
    while (chats.length >= 15) {
        chats.shift();
    }
    chats.push({
        name: name,
        content: content
    })
    msg.message = '';
}

接着使用websocket进行所有客户端弹幕的同步

// 客户端js
var socket = io('ws://' + window.location.host);
socket.on('down', function(data) {
        addDanmaku(data.name, data.content);
    })

socket.emit('up', {
    name: name, 
    content: msg.message
});
// 服务端js
// 接收弹幕
socket.on('up', (chat) => {
    socket.broadcast.emit('down', chat);
})

最后添加利用canvas绘制弹幕的功能,学习了一下他人的思路,先对canvas创建一个弹幕对象,设置canvas的属性并创建容器,然后利用setInterval不断更新更新弹幕文本的位置,并重绘canvas。控制绘图的draw函数:

// 绘制弹幕
        this.draw = function () {
            if (this.interval != "") return; // 如果已经有重绘,则返回
            var _this = this; // 传入this(弹幕对象)
            this.interval = setInterval(function () { // 每20毫秒进行一次绘制
                _this.ctx.clearRect(0, 0, _this.width, _this.height); // 先擦除画布
                _this.ctx.save();   // 然后将当前画布保存
                for (var i = 0; i < _this.msgs.length; i++) {
                    if (!(_this.msgs[i] == null)) {
                        if (_this.msgs[i].left==null) { // 新创建的弹幕,不存在作为左坐标left属性
                            _this.msgs[i].left = _this.width; // 新弹幕的位置在最右边
                            _this.msgs[i].top = parseInt(Math.random() * 200) + 30; // 设置弹幕的高度
                            _this.msgs[i].speed = 4; // 设置弹幕的速度
                            _this.msgs[i].color = _this.colorArr[Math.floor(Math.random() * _this.colorArr.length)]; // 设置弹幕的颜色 
                        }else{
                            if(_this.msgs[i].left < -200){
                                _this.msgs[i]=null;  // 弹幕离开屏幕后删除
                            }else {
                                _this.msgs[i].left = parseInt(_this.msgs[i].left - _this.msgs[i].speed); // 弹幕移动
                                _this.ctx.fillStyle = _this.msgs[i].color; // 在画板上设置颜色
                                _this.ctx.fillText(_this.msgs[i].msg, _this.msgs[i].left, _this.msgs[i].top); // 在画板上重绘弹幕
                                _this.ctx.restore(); // 写入画板
                            }
                        }
                    }
                }
            }, 20);
        };

并且同时将创建弹幕的函数调用放在addDanmuku函数中,这样不论是自己发弹幕还是接收弹幕,都可以看见飘过的弹幕

基于Express实现云课堂远程共享白板(canvas+websocket)

利用canvas搭建白板

首先在html中设置一个白板区域,添加一个清除白板的按钮

<canvas id="whiteboard" width="1280px" height="720px" class="whiteboard-canvas"></canvas>
<div>
    <button id="clear" class="btn-warning">清除画板</button>
</div>

接着在js中创建一个App对象,用于管理所有白板相关的属性和方法,并且添加处理鼠标事件的方法。

App.whiteboard = $('#whiteboard');
App.ctx = App.whiteboard[0].getContext("2d");
// 标记是否开始绘图
App._startedDrawing = false;
// 鼠标事件关联绘图:下笔、移动、提笔
App.whiteboard.on('mousedown mouseup mousemove', null, function(e) {
    // 如果没有开始画画并且事件不是下笔 则不开始画画
    if (!App._startedDrawing && e.type != "mousedown") return;
    App._startedDrawing = true;
    // 获得偏移坐标
    var offset = $(this).offset();
    // 所有需要的数据
    var data = {
        x: (e.pageX - offset.left),
        y: (e.pageY - offset.top),
        type: e.handleObj.type,
        color: App.ctx.strokeStyle,
        imageData: App.whiteboard[0].toDataURL()
    }
    // 呈现在自己的画板上
    App.draw(data);
})
// 鼠标事件关联清除画板
$('#clear').on('click', function() {
    App.clear();
})

然后写draw函数,将传入的data在画布上展现出来;以及clear函数,用于清空画板

// 绘图操作 
App.draw = function (data) {
    var originalColor = App.ctx.strokeStyle;
    App.ctx.strokeStyle = data.color;
    if (data.type == "mousedown") {
        App.ctx.beginPath();
        App.ctx.moveTo(data.x, data.y)
    } else if (data.type == "mouseup") {
        App.ctx.stroke();
        App.ctx.closePath();
        App._startedDrawing = false;
        App.socket.emit('save-data', App.whiteboard[0].toDataURL());
    } else {
        App.ctx.lineTo(data.x, data.y);
        App.ctx.stroke();
    }
    App.ctx.strokeStyle = originalColor;
};
App.clear = function () {
    App.ctx.clearRect(0, 0, App.whiteboard[0].width, App.whiteboard[0].height);
};

这个时候,就完成了鼠标路径到画布显示的路径

利用websocket同步画板

这里在服务端和客户端均是利用socket.io来实现websocket。首先在客户端js中写好连接websocket,发送笔画、接受笔画的方法。

// 建立websocket
App.socket = io('http://' + window.location.host);
// 初始化图片和色彩
App.socket.on('setup', function (color, dataUrl) {
    App.ctx.strokeStyle = color;
    if (dataUrl) {
        // 从url加载图片
        var imageObj = new Image();
        imageObj.onload = function () {
            App.ctx.drawImage(this, 0, 0);
        };
        imageObj.src = dataUrl;
    }
});
// 接受笔画信息
App.socket.on('draw', App.draw);
// 接受清除画板请求
App.socket.on('clear', App.clear);
// 发送笔画至服务端
App.socket.emit('do-the-draw', data);
// 发送画完一笔的信息至服务端
App.socket.emit('save-data', App.whiteboard[0].toDataURL());
// 发送清除画板请求
App.socket.emit('clear');

在客户端,由于socket.io是基于server进行websocket操作的,而server的架设在bin\www文件中,因此为了能够在路由中实现websocket,需要完成一个router.js→app.js→www的过程。

// eoutes\canvas.io
var imageData;
// 对不同用户给不同的颜色
var colors = [ "#CFF09E", "#A8DBA8", "#79BD9A", "#3B8686", "#0B486B" ];
var i = 0;
var IO = null;
// 这里定义了一个io函数
router.io = function(io) {
    io.on('connection', (socket) => {
        if (i == 5) i = 0;
      socket.emit('setup', colors[i++], imageData);
  
      socket.on('do-the-draw', (data) => {
        // 用户画了一笔
        socket.broadcast.emit('draw', data);
        imageData = data.imageData;
      })
  
      socket.on('clear', function() {
        // 用户清除画板
        socket.broadcast.emit('clear');
        imageData = null;
      })
  
      socket.on('save-data', (data) => {
        // 用户画完一笔
        imageData = data;
      })
    });
    IO = io;
    return io;
}

这里在路由中定义了一个对传入io进行socket操作的函数,并且将这个函数传出出去。

// app.js
// 引入路由
var canvasRouter = require('./routes/canvas')
// 引入路由中的io函数
app.canvasIO = canvasRouter.io;
// 便于在路由中响应http请求
app.use('/canvas', canvasRouter);

而app.js获得router传出的这个函数,并再次传出去

// bin\www
// 架设server
var server = http.createServer(app);
// 引入socket.io
var io = require('socket.io')(server);
// 将io传入app.js的canvasIO函数中,最终传入canvas.js的io函数中
app.canvasIO(io);

www获得app.js传出的对io进行操作的函数,并且传入server的io。这个时候,不同客户端之间就可以实时共享白板了。

添加上传画布背景功能

为了便于对某一张图片进行标记和讲解,需要添加一个上传画布背景的功能。

选用的依然是bootstrap-fileinput插件,在html中插入上传框,在上传初始化中将上传的文件格式限制为’jpg’, ‘gif’, ‘png’, ‘jpeg’。上传后,在路由中接受文件并暂存为临时文件。

//上传背景图片
router.post('/upload', function(req, res) {
    var form = new formidable.IncomingForm();
	// ...
        // formidable对象的临时路径等属性的初始化
	form.parse(req, function(err, fields, files) {
        var filepath = '';
		for (var key in files) {
                        // ...
			// 获取到临时存放的文件地址
		}
		var fileExt = filepath.substring(filepath.lastIndexOf("."));
        // 文件类型校验
		if ((".jpg.png.bmp.jpeg").indexOf(fileExt.toLowerCase()) == -1) {
                        // ...
			// 格式不对的操作
		} else {
            // ...
            //  提供格式正确的返回值,并且利用socket向所有客户端发送背景临时文件的地址
        }
    });
})

然后在客户端js中加入更换图片背景的方法

// 接受图片背景
App.socket.on('drawImage', (data) => {
    var imgObj = new Image();
    imgObj.src = '/canvas/image?path=' + data.data; // 通过url获得服务端图像临时文件
    imgObj.onload = function() {
        App.ctx.drawImage(this, 0, 0, 1280, 720); // canvas 绘制图像
    }
    $("#downblock").hide();
    // 或者直接将图像作为背景,但不推荐
    //$('#whiteboard').css("background", "url(" + data.data + ") no-repeat");
})

通过上传图片→分发图片→绘制图片的过程,所有的客户端,不论是已经打开的,还是后续打开的,都可以在画布上看见新的图片。

切换黑白板功能

切换黑白板功能只需要在预先存好黑白两个底色,然后在路由中存储一个是否为白色的变量。每次客户端点击切换黑白板的时候,将请求emit到服务端,然后服务端修改这个变量,并且在emit到所有客户端即可

保存画板功能

保存画板需要用到canvas的一个toDataURL方法,然后将获得的url下载下来

// 保存画板
$(document).ready(() => {
    $('#save').click(() => {
        downLoad(whiteboard.toDataURL("image/png"));
    })
})

function downLoad(url){
    // 创建一个新的元素,将href值设为url,并点击它
}

通过点击新建的下载元素,可以通过canvas的url,将画布转为png文件下载下来

同样地,这个简单的共享白板也可以添加更多优化,例如撤销恢复功能,添加线条、方框、箭头等基本元素的功能,以及对学生访问白板的权限管理等。

express框架实现文件上传、下载及推送(使用Websocket)

文件上传功能往往是web应用非常重要的功能之一,使用express框架可以简单调用模块实现这一点。

文件上传

客户端上传文件:bootstrap-fileinput插件

插件安装

bootstrap-fileinput插件是基于jQuery和bootstrap的一款集合了文件上传功能优化和界面美化的插件,支持bootstrap3.x和4.x。其包含的css和js文件,需要在bootstrap的css和js文件后引入。
官方下载:https://plugins.krajee.com/file-input
另外需要再引入一个汉化js文件:https://github.com/kartik-v/bootstrap-fileinput/tree/master/js/locales

插件使用

在html中,可以将上传栏嵌套在bootstrap的折叠中

<button type="button" class="btn btn-secondary" data-toggle="collapse" data-target="#downblock">上传文件</button>
<div id="downblock" class="collapse">
	<!--上传框-->
	<input type="file" name="txt_file" id="txt_file" multiple class="file-loading" />
</div>

然后需要调用脚本初始化上传功能

$(document).ready(() => { //文档加载完后执行
	//新建上传对象
    $(function () {
        var oFileInput = new FileInput();
        oFileInput.Init("txt_file", "/file/upload");//上传时发送Post请求的地址
    });

    //初始化上传对象
    var FileInput = function () {
        var oFile = new Object();
        oFile.Init = function(ctrlName, uploadUrl) {
        var control = $('#' + ctrlName);
    
        //设置上传框的选项
        control.fileinput({
            language: 'zh', //语言
            uploadUrl: uploadUrl, //地址
            showUpload: true, //是否显示上传按钮
            showCaption: false,//是否显示标题
            browseClass: "btn btn-info", //按钮样式
            //maxFileSize: 0, //上传最大文件大小(kb),0则无上限
            //minFileCount: 0, //同时上传最小文件数
            maxFileCount: 5, //同时上传最大文件数
            enctype: 'multipart/form-data',
            validateInitialCount: true,
            previewFileIcon: "<i class='glyphicon glyphicon-king'></i>",
            msgFilesTooMany: "选择上传的文件数量({n}) 超过允许的最大数值{m}!",
        });
    
        //文件上传完成后触发
        $("#txt_file").on("fileuploaded", function (event, data, previewId, index) {
            window.location.reload(true);
            alert("上传成功!");
        });
    }
        return oFile;
};

});

服务端接受文件:formidable模块

首先在express项目所在文件夹安装好模块

npm install formidable --save

接着在路由中调用formidable模块以及fs模块并接收文件

var express = require('express');
var router = express.Router();
var fs = require("fs");
var formidable = require("formidable");

//上传文件
router.post('/upload', function(req, res) {
    var form = new formidable.IncomingForm();
	form.encoding = 'utf-8';
	form.uploadDir =__dirname.slice(0, -6) + "uploadfiles/tmp"; // 临时文件夹
	form.keepExtensions = true; // 文件扩展名
	
    // 读取登录用户所在的班级,便于分文件夹存储
    var currentClass = req.session.class || '0';

	form.parse(req, function(err, fields, files) {
        var filepath = '';
		for (var key in files) {
			if (files[key].path && filepath == '') {
                filepath = files[key].path;
				break;
			}
		}
		var targetDir = __dirname.slice(0, -6) + "uploadfiles/" + currentClass + "/";
		// 创建存放文件的目录
		if (!fs.existsSync(targetDir)) {
			fs.mkdir(targetDir, {
				recursive: true
			}, (err) => {
				if (err) throw err;
			});
		}
		var fileExt = filepath.substring(filepath.lastIndexOf("."));
		//上传的原文件信息都在field中
        var originName = fields.fileId.slice(fields.fileId.indexOf("_") + 1, fields.fileId.lastIndexOf("."));
        //重新命名文件
        var filename = originName + "-" + getdate() + "-" + new Date().getTime() + fileExt;
        var targetFile = targetDir + filename;
        fs.rename(filepath, targetFile, err => {
            if (err) {
                console.info(err);
                res.json({
                    code: -1,
                    message: "操作失败"
                });
            } else {
                // 上传成功后触发
                var fileUrl = __dirname.slice(0, -6) + 'uploadfiles/' + currentClass + "/" + filename;
                res.json({
                    code: 200,
                    fileUrl: fileUrl
                });
                console.log("UPLOAD 用户 " + req.session.username + " 上传了文件: " + fileUrl);
            }
        });
	});
})

这样便完成了文件从客户端发送到服务端存储至本地的操作

文件列表推送

客户端主动获取文件列表:fs模块

为了减少一次获取的数据量,仅当用户点击“内容发布”时,向服务端发送get请求,利用AJAX显示文件的列表

var FileFull;
//读取文件列表
function read() {
    $.get('/file/list', (data) => {
        $("#filelist").children().remove(); // 移除原有列表
        FileFull = data;  // 存储新的文件列表
        data.forEach((item, index, array) => {
        	// 调节文件名的现显示格式,并且将节点加入表中
            showName = item.slice(0, item.lastIndexOf('-')) + item.substring(item.lastIndexOf('.'))
            $("#filelist").append('<li href="#" value="' + index + '" class="list-group-item list-group-item-action file" οnclick="showFile(this.innerText, this.value)">' + showName + '</li>');
        });
    }, "json")
}
//读取文件列表
router.get('/list', function(req, res) {
    var currentClass = req.session.class || "0";
    fs.readdir(__dirname.slice(0, -6) + "uploadfiles/" + currentClass + "/", (err, files) => {
        res.json(files);
        res.end();
    })
})

服务端只要简单的利用fs模块读取对应文件夹里的文件列表即可

服务端主动推送文件列表:socket.io模块

首先,socket.io基于http,http模块是在bin/www下导入并生成server,因此需要在此文件内引入该模块:

var app = require('../app');
var http = require('http');
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
var server = http.createServer(app);
var io = require('socket.io')(server);
app.io(io);

然后便可以在路由中使用socket.io模块

var IO;
router.io = function(io) {
    io.on('connection', (socket) => {
        socket.on('message', (data) => {
            console.log(data);
        });
    });
    IO = io;
    return io;
}

定义io函数,将路由绑定上使用Websocket发送信息的功能。
接下来,就可以在需要使用Websocket的地方调用io,例如在用户上传了文件之后,给其他用户发送更新文件列表的请求。

socket.broadcast.emit('updateFileList', {
	code: 200,
	data: '请立即更新文件列表'
});

为了避免代码重复,服务端主动发送的内容仅仅是触发客户端,使其再次发送获得最新文件列表的请求。于是,其他用户也能实时看见最新的文件列表。

文件下载、删除等操作

只需要很简单地在客户端发送请求,服务端接收后利用fs模块进行删除,或者利用res.download()发送文件即可。

function downloadFile() {
    let filefullname = FileFull[$("#operateFile").val()];
    window.open('/file/download?fileName=' + filefullname );
}

需要注意的是,如果直接发送get请求,浏览器将不会对返回的文件进行下载,仅仅保留在response里面。为了下载文件,需要另外打开窗口来替代get请求。