浅谈 js+canvas 实现考试系统·拖拽连线题

概述

最近在做一个考试系统,其中有一个题型是 连线题,当时在网上查阅了大量的资料,没找到自己想要的效果,于是决定自己实现。

效果如下:

本示例主要使用原生js + canvas 实现,为了各位看官能够理解实现,代码中有大量的注释,并且没有过度封装和抽离。

需求

  1. 左右布局,支持 1对1双向拖拽连线(即从左侧连到右侧,从右侧连到左侧)。
  2. 支持【重置画板】【回退/撤销】【保存连线记录】【删除连线记录】【读取连线记录】【纠错】功能

实现

在开始实现之前,首先我们需要确定一下相关的数据结构:

  1. 连线答案结构

    js 复制代码
    [[L1, R1], [L2, R2], [L3, R3], ...]

    这个结构将作为连线题的 答案 发送至后端,查看试卷时,后端也会返回这个结构用于回显以及批阅时纠错。

  2. 连线记录结构

    js 复制代码
    [
      { anwser: [L1, R1], point: { x1, y1, x2, y2 } },
      { anwser: [L1, R1], point: { x1, y1, x2, y2 } },
      ...
    ];
    • anwser:存储每一条连线中,左侧元素的id(数组的第1个元素)和右侧元素的id(数组的第2个元素)
    • point:连线元素锚点(顺序无所谓,只要有两个点确保能连成一条线即可
  3. 纠错结构

    js 复制代码
    [
      { isOk, point: { x1, y1, x2, y2 } },
      { isOk, point: { x1, y1, x2, y2 } },
      ...
    ];
    • isOk:布尔值,标识当前连线是否正确,用于在绘制线条时赋予不同的颜色标注,比如 ❎用红色,✅用蓝色。
    • point:连线元素锚点(顺序无所谓,只要有两个点确保能连成一条线即可

提示:实现章节中,我将根据我的实现思路,按顺序编辑,所以你只需要从上往下阅读文章并敲代码实践即可,文章的最后,我会贴上脚本部分的完整代码。

布局 & 样式

两列布局,基于 flex 实现,画板用了两个 canvas 标签,一个用于实际连线,因为在连接的过程中,有可能会取消,此时会调用 crearRect 清除画板,为了避免将之前的记录一起给清楚了,所以需要另一个画板用于回显,主要展示已经连接好的路径。

index.html

html 复制代码
<div class="container">
  <!-- 工具栏 -->
  <div class="tools">
    <div class="button reset">重置</div>
    <div class="button back">回退</div>
    <div class="button save">保存</div>
    <div class="button delete">删除</div>
    <div class="button read">读取</div>
    <div class="button check">纠错</div>
  </div>
  <div class="content">
    <!-- 左侧 -->
    <div class="list left">
      <div class="item" id="L1" data-ownership="L">水果</div>
      <div class="item" id="L2" data-ownership="L">动物</div>
      <div class="item" id="L3" data-ownership="L">汽车</div>
      <div class="item" id="L4" data-ownership="L">蔬菜</div>
    </div>
    <!-- 右侧 -->
    <div class="list right">
      <div class="item" id="R1" data-ownership="R">🥕</div>
      <div class="item" id="R2" data-ownership="R">🚗</div>
      <div class="item" id="R3" data-ownership="R">🐒</div>
      <div class="item" id="R4" data-ownership="R">🍌</div>
    </div>
    <!-- 实际连线标签 -->
    <canvas id="canvas" width="400" height="250">您的浏览器不支持Canvas,请下载最新版本</canvas>
    <!-- 模拟连线标签 -->
    <canvas id="backCanvas" width="400" height="250">您的浏览器不支持Canvas,请下载最新版本</canvas>
  </div>
</div>

提示:在布局标签时,data-id 标识数据,data-ownership 标识元素所在的区间,L 表示左侧,R 表示右侧。

./css/index.css

css 复制代码
.container {
  width: 400px;
  margin: 100px auto;
}

.tools {
  height: 50px;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  background-color: #EEE;
  box-sizing: border-box;
  padding: 0 35px;
  border-bottom: 1px dashed #808080;
  font-size: 14px;
  color: #555;
  cursor: pointer;
}

.tools .button:not(:first-child) {
  margin-left: 16px;
}

.tools .button:hover {
  color: #1E90FF;
}

.content {
  width: 400px;
  height: 250px;
  background: #EEE;
  box-sizing: border-box;
  padding: 0 35px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  position: relative;
}

.list:last-child {
  font-size: 26px;
}

.item {
  width: 100px;
  height: 40px;
  background-color: #fff;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 4px;
  cursor: pointer;
  user-select: none;
  color: #555;
  position: relative;
  z-index: 1;
}

.item:not(:last-child) {
  margin-bottom: 10px;
}

.item.active {
  background: #6495ED;
  color: #FFF;
}

#canvas,
#backCanvas {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

获取Canvas & 配置画笔

js 复制代码
// 第1步:获取Canvas & 配置画笔
const canvas = document.getElementById("canvas");
const backCanvas = document.getElementById("backCanvas");
canvas.width = backCanvas.width = 400;
canvas.height = backCanvas.height = 250;

/** @type {CanvasRenderingContext2D} */
const ctx = canvas.getContext("2d");
const backCtx = backCanvas.getContext("2d");

ctx.strokeStyle = backCtx.strokeStyle = 'blue';
ctx.lineWidth = backCtx.lineWidth = 2;

获取必要元素,挂载数据

js 复制代码
// 第2步:获取列表元素,挂载后续操作所需的数据
const listItems = document.querySelectorAll('.list .item');
// 记录canvas距离屏幕左上角的位置,用于计算移动时鼠标在画布中的位置
let canvasTop = 0;
let canvasLeft = 0;
calcRect();
// 缩放窗口时,实时更新数据
window.onresize = calcRect;
function calcRect() {
  // TODO: 节流优化
  // 更新canvas距离屏幕左上角的位置
  const rect = canvas.getBoundingClientRect()
  canvasTop = rect.top;
  canvasLeft = rect.left;

  // 记录节点信息
  listItems.forEach(item => {
    // 获取元素在屏幕上的信息
    const { left, top, width, height } = item.getBoundingClientRect();
    // 获取元素归属:左侧还是右侧·用于计算元素锚点坐标
    const ownership = item.dataset.ownership;
    // 记录元素锚点坐标
    const anchorX = ownership === 'L' ? item.offsetLeft + width : item.offsetLeft;
    const anchorY = item.offsetTop + height / 2;
    item.dataset.anchorX = anchorX;
    item.dataset.anchorY = anchorY;

    // 标识当前元素是否连线
    item.dataset.checked = '0';

    // 绘制锚点,查看锚点位置是否准确(临时代码)
    // ctx.beginPath();
    // ctx.arc(anchorX, anchorY, 4, 0, Math.PI * 2);
    // ctx.stroke();
    // ctx.closePath();
  });
}

绑定事件

js 复制代码
// 第3步:绑定事件
listItems.forEach((item) => (item.onmousedown = onMousedown));
document.onmousemove = onMousemove;
document.onmouseup = onMouseup;

连线相关(核心逻辑)

分析:

  1. 当鼠标按在(mousedown)某个元素上时,该元素将作为 开始元素(不管左侧还是右侧)
  2. 在鼠标按下移动的过程中,需实时基于canvas显示路径(直线,将开始元素的锚点作为开始点,将鼠标移动的实时位置作为结束点)
  3. 当鼠标经过(mousemove) 目标元素 (如果起始元素在左侧,那目标元素一定是在右侧,反之亦然) 时,如果 目标元素 未被选中,则标识开始元素和目标元素的连线状态。
  4. 当鼠标抬起(mouseup)时:
    • 未命中任何目标元素,删除路径,并恢复开始元素的状态
    • 命中目标元素,判断目标元素是否已连线
      • 已连线:删除路径,并恢复开始元素的状态
      • 未连线:更新路径,直接将开始元素和目标元素的锚点连接在一起,并且高亮起始元素和目标元素,标识已选中。
  5. 如果开始元素已连线,可以修改连线,将其连接到未连线的目标元素上,同时将之前匹配的目标元素恢复初始状态。
  6. 具体的逻辑在代码中有体现...

代码:

js 复制代码
// 第4步:连线相关(核心逻辑)
let trigger = false; // 标识是否触发连线
let startPoint = { x: 0, y: 0 }; // 记录每一次连线开始点
let endPoint = { x: 0, y: 0 }; // 记录每一次连线结束点
let startElement = null; // 记录每一次连线开始元素
let endElement = null; // 记录每一次连线结束元素
let backLines = []; // 记录已经连接好的线·数据结构 → { anwser: [左侧元素ID, 右侧元素ID], point: {x1, y1, x2, y2}}[]
let pair = 0; // 记录连线次数

function onMousedown(event) {
  // 高亮显示按下的元素
  if (!this.classList.contains('active')) {
    this.classList.add('active');
  }
  // 记录每一次连线的开始元素
  startElement = this;

  // 更新每一次连线开始点信息
  startPoint.x = +this.dataset.anchorX;
  startPoint.y = +this.dataset.anchorY;

  // 标识触发连线,用于在mousemove中判断是否需要处理后续的逻辑
  trigger = true;
  // 阻止时间冒泡/默认行为
  event.stopPropagation();
  event.preventDefault();
}


function onMousemove(event) {
  if (trigger) {
    /****************
     * 处理连线
     ****************/
    // 获取鼠标在屏幕上的位置
    const { clientX, clientY } = event;
    // 计算鼠标在画板中的位置
    const endPoint = {
      x: clientX - canvasLeft,
      y: clientY - canvasTop
    }
    // 连线:实际画板
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();
    ctx.moveTo(startPoint.x, startPoint.y);
    ctx.lineTo(endPoint.x, endPoint.y);
    ctx.closePath();
    ctx.stroke();

    /****************
     * 处理后续逻辑
     ****************/

    // 获取鼠标经过的元素
    const overElement = document.elementFromPoint(clientX, clientY);
    // 获取开始元素归属:左侧还是右侧
    const ownership = startElement.dataset.ownership;
    // 如果鼠标经过的元素等于目标元素,不作任何处理
    if (overElement === endElement) return;
    // 判断是否命中目标元素,条件如下(同时满足)
    // ① 鼠标经过的元素必须包含类名 item
    // ② 鼠标经过的元素和开始元素不在同一侧
    // ③ 鼠标经过的元素未被连线
    const condition1 = overElement.classList.contains('item');
    const condition2 = overElement.dataset.ownership !== ownership;
    const condition3 = overElement.dataset.checked !== '1';
    if (condition1 && condition2 && condition3) {
      // 记录目标元素
      endElement = overElement;
      // 更新目标元素状态(高亮显示)
      endElement.classList.add('active');
      // 将开始元素和目标元素表示为已连线
      endElement.dataset.checked = '1';
      startElement.dataset.checked = '1';
    }
    // 如果没有命中目标元素,但是目标元素又存在,则移除相关状态
    else if (endElement) {
      endElement.classList.remove('active');
      endElement.dataset.checked = startElement.dataset.checked = '0';
      endElement = null;
    }
  }
  // 阻止时间冒泡/默认行为
  event.stopPropagation();
  event.preventDefault();
}

function onMouseup() {
  // 如果开始元素存在且未被连线,则恢复开始元素的状态
  if (startElement && startElement.dataset.checked !== '1') {
    startElement.classList.remove('active');
  }
  // 完成连线:开始元素和目标元素同时存在,并且被标识选中
  if (
    startElement && endElement && 
    startElement.dataset.checked === '1' && endElement.dataset.checked === '1'
  ) {
    // 获取连线始末坐标点
    const { anchorX: x1, anchorY: y1 } = startElement.dataset;
    const { anchorX: x2, anchorY: y2 } = endElement.dataset;
    // 获取开始元素归属:左侧还是右侧
    const ownership = startElement.dataset.ownership;
    // 获取开始元素的id
    const startId = startElement.id;
    // 判断开始元素是否已经完成连线·遍历backLines,判断存储答案的集合中是否包含开始元素的id,存在则更新index
    let index = -1;
    for (let i = 0; i < backLines.length; i++) {
      const item = backLines[i];
      if (item.anwser.includes(startId)) {
        index = i;
        break;
      }
    }
    // 如果元素已经完成连线,则需将连线的目标元素恢复成未连线状态,具体步骤
    // ① 获取目标元素的ID
    // ② 根据ID获取目标元素
    // ③ 恢复目标元素的状态(标识+高亮状态)
    // ④ 将对应的数据从记录中移出(因为后面会重新插入数据)
    if (index !== -1) {
      const tarElementId = backLines[index].anwser[ownership === 'L' ? 1 : 0];
      const tarElement = document.getElementById(tarElementId);
      tarElement.dataset.checked = '0';
      tarElement.classList.remove('active');
      backLines.splice(index, 1);
    }

    // 组装数据,存入记录
    backLines.push({
      anwser: ownership === 'L' ? [startElement.id, endElement.id] : [endElement.id, startElement.id],
      point: { x1, y1, x2, y2 }
    });
    // 绘制连线结果
    drawLines();
  }

  // 恢复元素状态
  trigger = false;
  startElement = null;
  endElement = null;
  // 清空实际连线画布
  ctx.clearRect(0, 0, canvas.width, canvas.height);
}
// -- 模拟连线
function drawLines() {
  backCtx.clearRect(0, 0, backCanvas.width, backCanvas.height);
  backLines.map(({ point: { x1, x2, y1, y2 } }) => {
    backCtx.beginPath();
    backCtx.moveTo(x1, y1);
    backCtx.lineTo(x2, y2);
    backCtx.closePath();
    backCtx.stroke();
  });
}

重置画板

思路:

① 清空模拟连线画布

② 恢复元素初始状态

③ 置空连线记录

js 复制代码
// 第5步:重置
const btnReset = document.querySelector('.reset');
btnReset.onclick = function () {
  backCtx.clearRect(0, 0, backCanvas.width, backCanvas.height);
  listItems.forEach(item => {
    item.classList.remove('active');
    item.dataset.checked = '0';
  });
  backLines = [];
}

回退/撤销

思路:

① 将最后一次连线的数据从连线记录中移除

② 获取连线元素并恢复其初始状态

③ 重新绘制模拟连线

js 复制代码
// 第6步:回退
const btnBack = document.querySelector('.back');
btnBack.onclick = function () {
  const { anwser: [startId, endId] } = backLines.pop();
  const startElement = document.getElementById(startId);
  const endElement = document.getElementById(endId);
  startElement.dataset.checked = endElement.dataset.checked = '0';
  startElement.classList.remove('active');
  endElement.classList.remove('active');
  drawLines();
}

保存连线记录

思路:

① 从连线记录中组装答案结构列表:[[id1, id2], [id3, id4]...]

② 将答案存储至本地

js 复制代码
// 第7步:保存答案
const getAnwsers = () => {
  const anwsers = [];
  backLines.forEach(({ anwser }) => anwsers.push([...anwser]));
  return anwsers;
}
const saveAnwsers = () => {
  const anwsers = getAnwsers();
  if (anwsers.length > 0) {
    localStorage.setItem('ANWSERS', JSON.stringify(anwsers));
    console.log('保存成功');
  } else {
    console.log('没有可保存的数据');
  }
}
const btnSave = document.querySelector('.save');
btnSave.onclick = saveAnwsers;

删除连线记录

js 复制代码
// 第8步:删除答案
const btnDelete = document.querySelector('.delete');
btnDelete.onclick = () => {
  localStorage.removeItem('ANWSERS');
  console.log('删除成功');
};

读取连线记录

思路:

① 从本地读取数据

② 判断数据是否存在,如果存在,则遍历数据做后续处理

③ 获取每一条线路的开始元素和目标元素

④ 更新开始元素和目标元素的状态:选中状态/高亮显示

⑤ 计算连线坐标

⑥ 拼装数据并绘制到模拟连线画板上

js 复制代码
// 第9步:读取(回显)答案

const showAnwsers = () => {
  const localAnwsers = localStorage.getItem('ANWSERS');
  if (localAnwsers) {
    const anwsers = JSON.parse(localAnwsers);
    anwsers.forEach(([startId, endId]) => {
      // 获取开始元素和目标元素
      const startElement = document.getElementById(startId);
      const endElement = document.getElementById(endId);
      // 更新选中状态
      startElement.dataset.checked = endElement.dataset.checked = '1';
      // 高亮显示元素
      startElement.classList.add('active');
      endElement.classList.add('active');
      // 计算坐标
      const { anchorX: x1, anchorY: y1 } = startElement.dataset;
      const { anchorX: x2, anchorY: y2 } = endElement.dataset;
      // 拼装数据
      backLines.push({
        anwser: [startId, endId],
        point: { x1, y1, x2, y2 }
      });
      drawLines();
    });
  } else {
    console.log("没有可回显的数据")
  }
}
const btnShow = document.querySelector('.read');
btnShow.onclick = showAnwsers;

纠错

思路:

① 从本地读取数据

② 判断数据是否存在,如果存在,则遍历数据做后续处理

③ 获取每一条线路的开始元素和目标元素

④ 更新开始元素和目标元素的状态:选中状态/高亮显示

⑤ 计算连线坐标

⑥ 拼装数据并绘制到模拟连线画板上

js 复制代码
// 第10步:纠错
const standardAnwsers = [
  ['L1', 'R4'],
  ['L2', 'R3'],
  ['L3', 'R2'],
  ['L4', 'R1'],
];
const checkAnwsers = () => {
  const localAnwsers = localStorage.getItem('ANWSERS');
  if (localAnwsers) {
    const anwsers = JSON.parse(localAnwsers);
    const lines = [];
    anwsers.forEach(([startId, endId]) => {
      /****************
      * 找到用户连线的数据
      ****************/
      // 获取开始元素和目标元素
      const startElement = document.getElementById(startId);
      const endElement = document.getElementById(endId);
      // 更新选中状态
      startElement.dataset.checked = endElement.dataset.checked = '1';
      // 高亮显示元素
      startElement.classList.add('active');
      endElement.classList.add('active');
      // 计算坐标
      const { anchorX: x1, anchorY: y1 } = startElement.dataset;
      const { anchorX: x2, anchorY: y2 } = endElement.dataset;
      /****************
      * 处理纠错逻辑
      ****************/
      // 找到当前连线数据对应的标准答案
      const standardAnwser = standardAnwsers.find(item => item[0] === startId);
      // 拼装数据
      lines.push({
        point: { x1, y1, x2, y2 },
        isOk: endId === standardAnwser[1]
      });
    });
    // 绘制模拟连线画板
    backCtx.clearRect(0, 0, backCanvas.width, backCanvas.height);
    lines.forEach(({ isOk, point: { x1, y1, x2, y2 } }) => {
      backCtx.strokeStyle = isOk ? 'blue' : 'red';
      backCtx.beginPath();
      backCtx.moveTo(x1, y1);
      backCtx.lineTo(x2, y2);
      backCtx.stroke();
    });
    backCtx.strokeStyle = 'blue';
  } else {
    console.log("没有可纠错的数据")
  }
}
const btnCheck = document.querySelector('.check');
btnCheck.onclick = checkAnwsers;

可以看到,【纠错】部分的代码和【读取连线记录】的代码大部分都是相同的,区别就在于,【纠错】时需根据标准答案判断连线的记录是否正确,通过 isOK 标识,在绘制到模拟连线画板上时,会根据状态决定绘制的颜色,实际上,在应用中我们应该抽离一部分公共代码,但是为了大家能够看懂,这里我并没有这么做。

完整代码

点击查看 完整代码

尾述

如果大家觉得这篇文章帮到了您,欢迎 点赞 + 关注 ,在 连线题 的实现中,我认为还有很多值得优化和推敲的地方,如果大家有什么更好的建议,欢迎评论区留言,我们一起探讨最优解。

后续计划:

  1. 封装成工具类,便于在开发中可以方便快速的集成。
  2. 完善基于 React/Vue 实现代码示例。

温馨提示:原创不易,转载请注明出处。

相关推荐
姑苏洛言28 分钟前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手1 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言1 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
你的人类朋友2 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手3 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3
一只小灿灿3 小时前
前端计算机视觉:使用 OpenCV.js 在浏览器中实现图像处理
前端·opencv·计算机视觉
前端小趴菜053 小时前
react状态管理库 - zustand
前端·react.js·前端框架
Jerry Lau4 小时前
go go go 出发咯 - go web开发入门系列(二) Gin 框架实战指南
前端·golang·gin
我命由我123454 小时前
前端开发问题:SyntaxError: “undefined“ is not valid JSON
开发语言·前端·javascript·vue.js·json·ecmascript·js