MatchingPreview.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. <!-- eslint-disable vue/no-v-html -->
  2. <template>
  3. <div class="matching-preview">
  4. <div class="stem">
  5. <span class="question-number">{{ data.property.question_number }}.</span>
  6. <span v-html="sanitizeHTML(data.stem)"></span>
  7. </div>
  8. <div v-if="isEnable(data.property.is_enable_description)" class="description">{{ data.description }}</div>
  9. <ul ref="list" class="option-list">
  10. <li v-for="(item, i) in optionList" :key="i" class="list-item">
  11. <div v-for="({ content, mark }, j) in item" :key="mark" class="item-wrapper">
  12. <span
  13. v-if="j > 0 && j < item.length"
  14. :class="['connection', `pre-line-${i}-${j}`]"
  15. @mousedown="mousedown($event, i, j, 'pre', mark)"
  16. @mouseup="mouseup($event, i, j, 'pre', mark)"
  17. @click="handleClickConnection($event, i, j, 'pre', mark)"
  18. ></span>
  19. <span class="content" v-html="sanitizeHTML(content)"></span>
  20. <span
  21. v-if="j < item.length - 1"
  22. :class="['connection', `next-line-${i}-${j}`]"
  23. @mousedown="mousedown($event, i, j, 'next', mark)"
  24. @mouseup="mouseup($event, i, j, 'next', mark)"
  25. @click="handleClickConnection($event, i, j, 'next', mark)"
  26. ></span>
  27. </div>
  28. </li>
  29. </ul>
  30. </div>
  31. </template>
  32. <script>
  33. import { svgNS } from '@/views/exercise_questions/data/common';
  34. import PreviewMixin from './components/PreviewMixin';
  35. export default {
  36. name: 'MatchingPreview',
  37. mixins: [PreviewMixin],
  38. data() {
  39. return {
  40. answerList: [], // 答案列表
  41. curConnectionPoint: { i: -1, j: -1, position: 'pre', mark: '' }, // 当前连线点
  42. // 拖拽相关
  43. drag: false,
  44. mousePointer: { i: -1, j: -1, position: 'pre', mark: '' },
  45. };
  46. },
  47. computed: {
  48. optionList() {
  49. // 生成一个与选项列表相同的二维数组,但没有内容
  50. let arr = Array.from(Array(this.data.option_list.length), () => Array(this.data.option_list[0].length).fill({}));
  51. for (let i = 0; i < this.data.property.column_number; i++) {
  52. let rowNumList = Array.from(Array(this.data.option_list.length).keys()); // 行数列表
  53. for (let j = 0; j < this.data.option_list.length; j++) {
  54. let random = Math.floor(Math.random() * rowNumList.length); // 随机行数
  55. let item = this.data.option_list[j][i]; // 当前选项
  56. arr[rowNumList[random]][i] = item; // 将当前选项添加到随机行数的列中
  57. rowNumList.splice(random, 1); // 删除已经添加过选项的行数
  58. }
  59. }
  60. return arr;
  61. },
  62. },
  63. watch: {
  64. optionList: {
  65. handler(val) {
  66. this.clearLine();
  67. if (!val) return;
  68. let list = this.optionList.map((item) => {
  69. return item.map(({ mark }) => {
  70. return { mark, preMark: '', nextMark: '' };
  71. });
  72. });
  73. this.$set(this, 'answerList', list);
  74. },
  75. immediate: true,
  76. },
  77. answerList: {
  78. handler(list) {
  79. let arr = [];
  80. let column_number = this.data.property.column_number;
  81. // 从前往后遍历,如果有 nextMark,就往后找,直到没有 nextMark
  82. // 如果第一个选项的 nextMark 为空,则整个行都为空,等待后面的 preArr 填充
  83. list.forEach((item, i) => {
  84. let { mark, nextMark } = item[0];
  85. arr[i] = nextMark ? [mark, nextMark] : new Array(column_number).fill('');
  86. let _nextMark = nextMark;
  87. while (_nextMark) {
  88. let fMark = this.findMark(list, _nextMark, 'next');
  89. if (column_number > arr[i].length) {
  90. arr[i].push(fMark);
  91. }
  92. _nextMark = fMark;
  93. }
  94. });
  95. let emptyStringArray = []; // 无数据的列的下标数组
  96. arr.forEach((item, i) => {
  97. item.every((li) => li.length <= 0) ? emptyStringArray.push(i) : '';
  98. });
  99. if (column_number === 2) {
  100. this.answer.answer_list = arr;
  101. return;
  102. }
  103. // 从后向前遍历,如果有 preMark,就往前找,直到没有 preMark,并过滤掉无数据的列
  104. let preArr = [];
  105. list.forEach((item) => {
  106. let { mark, preMark } = item[column_number - 1];
  107. let _arr = new Array(column_number).fill('');
  108. let back = column_number - 1;
  109. let _preMark = preMark;
  110. while (_preMark) {
  111. _arr[back] = mark;
  112. back -= 1;
  113. _arr[back] = _preMark;
  114. back -= 1;
  115. let fMark = this.findMark(list, _preMark, 'pre');
  116. if (back >= 0 && fMark) {
  117. _arr[back] = fMark;
  118. }
  119. _preMark = fMark;
  120. }
  121. if (_arr.every((li) => li.length <= 0)) return;
  122. preArr.push(_arr);
  123. });
  124. // 将 preArr 中的数据填充到 arr 无数据的位置中
  125. if (preArr.length > 0 && emptyStringArray.length > 0) {
  126. preArr.forEach((item, i) => {
  127. arr[emptyStringArray[i]] = item;
  128. });
  129. }
  130. this.answer.answer_list = arr;
  131. },
  132. deep: true,
  133. },
  134. },
  135. mounted() {
  136. document.addEventListener('click', this.handleEventConnection);
  137. document.addEventListener('mousemove', this.documentMousemouse);
  138. document.addEventListener('mouseup', this.documentMouseup);
  139. },
  140. beforeDestroy() {
  141. document.removeEventListener('click', this.handleEventConnection);
  142. document.removeEventListener('mousemove', this.documentMousemouse);
  143. document.removeEventListener('mouseup', this.documentMouseup);
  144. },
  145. methods: {
  146. /* 用 mouse 事件模拟拖拽 开始*/
  147. documentMousemouse(e) {
  148. if (!this.drag) return;
  149. // 使用 svg 绘制跟随鼠标移动的连接线
  150. let svg = document.querySelector('.move-connection');
  151. if (!svg) {
  152. svg = document.createElementNS(svgNS, 'svg');
  153. svg.classList.add('move-connection');
  154. svg.setAttribute('style', `position: absolute; width: 100%; height: 100%;`);
  155. let path = document.createElementNS(svgNS, 'path');
  156. this.setPathAttr(path);
  157. svg.appendChild(path);
  158. this.$refs.list.appendChild(svg);
  159. }
  160. let { clientX, clientY } = e;
  161. let { i, j, position } = this.mousePointer;
  162. let dom = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0].getBoundingClientRect(); // 连线点的位置
  163. let list = this.$refs.list.getBoundingClientRect(); // 列表的位置
  164. let top = dom.top - list.top + 5; // 连线点距离列表顶部的距离 + 5 是为了让线条在连线点的中间
  165. let left = dom.left - list.left + 5; // 连线点距离列表左边的距离 + 5 是为了让线条在连线点的中间
  166. let mouseX = clientX - list.left; // 鼠标距离列表左边的距离
  167. let mouseY = clientY - list.top; // 鼠标距离列表顶部的距离
  168. let path = svg.querySelector('path');
  169. path.setAttribute('d', `M ${left} ${top} L ${mouseX} ${mouseY}`);
  170. },
  171. documentMouseup() {
  172. if (!this.drag) return;
  173. this.drag = false;
  174. document.querySelector('.move-connection')?.remove();
  175. document.body.style.userSelect = 'auto'; // 允许选中文本
  176. this.mousePointer = { i: -1, j: -1, position: 'pre', mark: '' };
  177. },
  178. mousedown(e, i, j, position, mark) {
  179. this.drag = true;
  180. document.body.style.userSelect = 'none'; // 禁止选中文本
  181. this.mousePointer = { i, j, position, mark };
  182. },
  183. /**
  184. * 鼠标抬起事件,如果是一个合适的连接点,则创建连接线
  185. * @param {PointerEvent} e 事件对象
  186. * @param {Number} i 选项列表索引
  187. * @param {Number} j 选项索引
  188. * @param {'pre'|'next'} position 连线点位置
  189. * @param {String} mark 选项标识
  190. */
  191. mouseup(e, i, j, position, mark) {
  192. let { i: curI, j: curJ, position: curPosition, mark: curMark } = this.mousePointer;
  193. if (curI === -1 && curJ === -1) return;
  194. if (Math.abs(curJ - j) > 1 || position === curPosition || mark === curMark) return;
  195. this.changeConnectionList(mark, position, true);
  196. this.createLine(i, j, position, mark, true);
  197. },
  198. /* 用 mouse 事件模拟拖拽 结束 */
  199. resetCurConnectionPoint() {
  200. this.curConnectionPoint = { i: -1, j: -1, position: 'pre', mark: '' };
  201. },
  202. /**
  203. * 当点击的不是连线点时,清除所有连线点的选中状态
  204. * @param {PointerEvent} e
  205. */
  206. handleEventConnection(e) {
  207. if (e.target.classList.contains('connection')) return;
  208. Array.from(document.getElementsByClassName('connection')).forEach((item) => {
  209. item.classList.remove('focus');
  210. });
  211. this.resetCurConnectionPoint();
  212. },
  213. /**
  214. * 处理点击连线
  215. * @param {PointerEvent} e 事件对象
  216. * @param {Number} i 选项列表索引
  217. * @param {Number} j 选项索引
  218. * @param {'pre'|'next'} position
  219. * @param {String} mark 选项标识
  220. */
  221. handleClickConnection(e, i, j, position, mark) {
  222. let { i: curI, j: curJ, position: curPosition, mark: curMark } = this.curConnectionPoint;
  223. // 如果当前连线点不存在,则设置当前连线点
  224. if (curI === -1 && curJ === -1) {
  225. this.curConnectionPoint = { i, j, mark, position };
  226. e.target.classList.add('focus');
  227. return;
  228. }
  229. // 如果当前连线点存在,清除所有连线点的选中状态
  230. Array.from(document.getElementsByClassName('connection')).forEach((item) => {
  231. item.classList.remove('focus');
  232. });
  233. // 如果当前连线点与上一个连线点不在相邻的位置,则设置点击的连接点为当前连线点
  234. if (Math.abs(curJ - j) > 1 || position === curPosition || mark === curMark) {
  235. this.curConnectionPoint = { i, j, mark, position };
  236. e.target.classList.add('focus');
  237. return;
  238. }
  239. this.changeConnectionList(mark, position);
  240. // 如果当前连线点与上一个连线点在相邻的位置,则创建连接线
  241. this.createLine(i, j, position, mark);
  242. },
  243. /**
  244. * 创建连接线
  245. * @param {Number} i 选项列表索引
  246. * @param {Number} j 选项索引
  247. * @param {'pre'|'next'} position
  248. * @param {String} mark 选项标识
  249. * @param {Boolean} isDrag 是否是拖拽
  250. */
  251. createLine(i, j, position, mark, isDrag = false) {
  252. let { offsetLeft, offsetTop } = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0];
  253. const { curOffsetLeft, curOffsetTop, curMark } = this.computedCurConnectionPoint(isDrag);
  254. let top = Math.min(offsetTop, curOffsetTop) + 5;
  255. let left = Math.min(offsetLeft, curOffsetLeft) + 5;
  256. let width = Math.abs(offsetLeft - curOffsetLeft);
  257. let height = Math.abs(offsetTop - curOffsetTop);
  258. let size = offsetLeft > curOffsetLeft ? offsetTop > curOffsetTop : offsetTop < curOffsetTop;
  259. // 创建一个空的SVG元素
  260. let svg = document.createElementNS(svgNS, 'svg');
  261. svg.setAttribute(
  262. 'style',
  263. `position:absolute; width: 128px; height: ${Math.max(8, height) + 4}px; top: ${top}px; left: ${left}px;`,
  264. );
  265. svg.classList.add('connection-line', `svg-${mark}-${curMark}`); // 添加类名
  266. // 向SVG元素添加 path 元素
  267. let path = document.createElementNS(svgNS, 'path');
  268. path.setAttribute('d', `M ${size ? 0 : width} 0 L ${size ? width : 0} ${height}`); // 设置路径数据
  269. this.setPathAttr(path);
  270. svg.appendChild(path);
  271. this.$refs.list.appendChild(svg); // 将SVG元素插入到文档中
  272. // 清除当前连线点
  273. this.resetCurConnectionPoint();
  274. },
  275. // 设置 path 公用属性
  276. setPathAttr(path) {
  277. path.setAttribute('stroke-width', '2'); // 设置线条宽度
  278. path.setAttribute('stroke-linecap', 'round'); // 设置线段的两端样式
  279. path.setAttribute('stroke', '#306eff'); // 设置填充颜色
  280. path.setAttribute('transform', `translate(2, 2)`); // 设置偏移量
  281. },
  282. /**
  283. * 计算当前连线点的位置
  284. * @param {Boolean} isDrag 是否是拖拽
  285. */
  286. computedCurConnectionPoint(isDrag = false) {
  287. const { i, j, position, mark } = isDrag ? this.mousePointer : this.curConnectionPoint;
  288. let dom = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0];
  289. return { curOffsetLeft: dom.offsetLeft, curOffsetTop: dom.offsetTop, curMark: mark };
  290. },
  291. // 清除所有连接线
  292. clearLine() {
  293. document.querySelectorAll('svg.connection-line').forEach((item) => {
  294. item.remove();
  295. });
  296. },
  297. /**
  298. * 修改连接列表
  299. * @param {String} mark 选项标识
  300. * @param {'pre'|'next'} position
  301. * @param {Boolean} isDrag 是否是拖拽
  302. */
  303. changeConnectionList(mark, position, isDrag = false) {
  304. const { mark: curMark, position: curPosition } = isDrag ? this.mousePointer : this.curConnectionPoint;
  305. this.changeAnswerList(curMark, mark, position);
  306. this.changeAnswerList(mark, curMark, curPosition);
  307. },
  308. /**
  309. * 改变答案列表
  310. * @param {String} curMark 当前选项标识
  311. * @param {String} mark 选项标识
  312. * @param {'pre'|'next'} position
  313. */
  314. changeAnswerList(curMark, mark, position) {
  315. let oldPointer = { mark: '', position: '' };
  316. // 找到当前选项,修改 preMark 或 nextMark
  317. this.answerList.find((item) =>
  318. item.find((li) => {
  319. if (li.mark === curMark) {
  320. if (position === 'pre') {
  321. if (li.nextMark) {
  322. oldPointer = { mark: li.nextMark, position: 'next' };
  323. }
  324. li.nextMark = mark;
  325. } else {
  326. if (li.preMark) {
  327. oldPointer = { mark: li.preMark, position: 'pre' };
  328. }
  329. li.preMark = mark;
  330. }
  331. return true;
  332. }
  333. }),
  334. );
  335. // 如果当前选项有 preMark 或 nextMark,则清除原来的连线
  336. if (!oldPointer.mark) return;
  337. document.querySelector(`svg.connection-line.svg-${curMark}-${oldPointer.mark}`)?.remove();
  338. document.querySelector(`svg.connection-line.svg-${oldPointer.mark}-${curMark}`)?.remove();
  339. // 找到原来的选项,清除 preMark 或 nextMark
  340. this.answerList.find((item) =>
  341. item.find((li) => {
  342. if (li.mark === oldPointer.mark) {
  343. if (oldPointer.position === 'pre') {
  344. li.nextMark = '';
  345. } else {
  346. li.preMark = '';
  347. }
  348. return true;
  349. }
  350. }),
  351. );
  352. },
  353. /**
  354. * 根据 mark 查找 nextMark 或 preMark
  355. * @param {Array} list 答案列表
  356. * @param {String} mark 标记
  357. * @param {'pre'|'next'} type 类型
  358. * @returns {String} 返回 nextMark 或 preMark
  359. */
  360. findMark(list, mark, type) {
  361. let fMark = '';
  362. list.find((item) => {
  363. return item.find((li) => {
  364. if (mark === li.mark) {
  365. fMark = type === 'pre' ? li.preMark : li.nextMark;
  366. return true;
  367. }
  368. });
  369. });
  370. return fMark;
  371. },
  372. },
  373. };
  374. </script>
  375. <style lang="scss" scoped>
  376. @use '@/styles/mixin.scss' as *;
  377. .matching-preview {
  378. @include preview;
  379. .option-list {
  380. position: relative;
  381. display: flex;
  382. flex-direction: column;
  383. row-gap: 16px;
  384. .list-item {
  385. display: flex;
  386. column-gap: 60px;
  387. .item-wrapper {
  388. display: flex;
  389. flex: 1;
  390. column-gap: 24px;
  391. align-items: center;
  392. min-height: 48px;
  393. padding: 12px 24px;
  394. background-color: $fill-color;
  395. border-radius: 40px;
  396. .content {
  397. flex: 1;
  398. }
  399. .connection {
  400. z-index: 1;
  401. width: 14px;
  402. height: 14px;
  403. cursor: pointer;
  404. border: 4px solid #306eff;
  405. border-radius: 50%;
  406. &.focus {
  407. background-color: #306eff;
  408. border-color: #fff;
  409. outline: 2px solid #306eff;
  410. }
  411. }
  412. }
  413. }
  414. }
  415. }
  416. </style>