CoursewarePreview.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042
  1. <template>
  2. <div ref="courserware" class="courserware" :style="computedCourserwareStyle()" @mouseup="handleTextSelection">
  3. <template v-for="(row, i) in data.row_list">
  4. <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
  5. <el-checkbox
  6. v-if="
  7. isShowGroup &&
  8. groupRowList.find(({ row_id, is_pre_same_group }) => row.row_id === row_id && !is_pre_same_group)
  9. "
  10. v-model="rowCheckList[row.row_id]"
  11. :class="['row-checkbox', `${row.row_id}`]"
  12. />
  13. <!-- 列 -->
  14. <template v-for="(col, j) in row.col_list">
  15. <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
  16. <!-- 网格 -->
  17. <template v-for="(grid, k) in col.grid_list">
  18. <component
  19. :is="previewComponentList[grid.type]"
  20. :id="grid.id"
  21. :key="k"
  22. ref="preview"
  23. :content="computedColContent(grid.id)"
  24. :class="[grid.id]"
  25. :data-id="grid.id"
  26. :style="{
  27. gridArea: grid.grid_area,
  28. height: grid.height,
  29. }"
  30. @contextmenu.native.prevent="handleContextMenu($event, grid.id)"
  31. @handleHeightChange="handleHeightChange"
  32. />
  33. <div
  34. v-if="showMenu && componentId === grid.id"
  35. :key="'menu' + grid.id + k"
  36. class="custom-context-menu"
  37. :style="{ left: menuPosition.x + 'px', top: menuPosition.y + 'px' }"
  38. @click="handleMenuItemClick"
  39. >
  40. 添加批注
  41. </div>
  42. <div
  43. v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj[grid.id]"
  44. :key="'show' + grid.id + k"
  45. >
  46. <el-popover
  47. v-for="(items, indexs) in componentRemarkObj[grid.id]"
  48. :key="indexs"
  49. placement="bottom"
  50. trigger="click"
  51. popper-class="menu-remark-info"
  52. >
  53. <div v-html="items.content"></div>
  54. <template #reference>
  55. <SvgIcon
  56. slot="reference"
  57. icon-class="icon-info"
  58. size="24"
  59. class="remark-info"
  60. :style="{ left: items.position_x - 12 + 'px', top: items.position_y - 12 + 'px' }"
  61. />
  62. </template>
  63. </el-popover>
  64. </div>
  65. </template>
  66. </div>
  67. </template>
  68. </div>
  69. </template>
  70. <!-- 选中文本的工具栏 -->
  71. <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
  72. <template v-if="canRemark">
  73. <span class="button" @click="handleMenuItemClick($event, 'tool')">
  74. <SvgIcon icon-class="sidebar-pushpin" size="14" /> 添加批注
  75. </span>
  76. </template>
  77. <template v-else>
  78. <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
  79. <span class="line"></span>
  80. <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
  81. </template>
  82. </div>
  83. </div>
  84. </template>
  85. <script>
  86. import { previewComponentList } from '@/views/book/courseware/data/bookType';
  87. import _ from 'lodash';
  88. export default {
  89. name: 'CoursewarePreview',
  90. provide() {
  91. return {
  92. getDragStatus: () => false,
  93. bookInfo: this.bookInfo,
  94. };
  95. },
  96. props: {
  97. data: {
  98. type: Object,
  99. default: () => ({}),
  100. },
  101. coursewareId: {
  102. type: String,
  103. default: '',
  104. },
  105. background: {
  106. type: Object,
  107. default: () => ({}),
  108. },
  109. componentList: {
  110. type: Array,
  111. required: true,
  112. },
  113. canRemark: {
  114. type: Boolean,
  115. default: false,
  116. },
  117. showRemark: {
  118. type: Boolean,
  119. default: false,
  120. },
  121. componentRemarkObj: {
  122. type: Object,
  123. default: () => ({}),
  124. },
  125. groupRowList: {
  126. type: Array,
  127. default: () => [],
  128. },
  129. isShowGroup: {
  130. type: Boolean,
  131. default: false,
  132. },
  133. groupShowAll: {
  134. type: Boolean,
  135. default: true,
  136. },
  137. project: {
  138. type: Object,
  139. default: () => ({}),
  140. },
  141. },
  142. data() {
  143. return {
  144. previewComponentList,
  145. courseware_id: this.coursewareId,
  146. bookInfo: {
  147. theme_color: '',
  148. },
  149. showMenu: false,
  150. divPosition: {
  151. left: 0,
  152. top: 0,
  153. }, // courserware盒子原始距离页面顶部和左边的距离
  154. menuPosition: { x: 0, y: 0, select_node: '' }, // 用于存储菜单的位置
  155. componentId: '', // 添加批注的组件id
  156. rowCheckList: {},
  157. showToolbar: false,
  158. contentmenu: {
  159. left: 0,
  160. top: 0,
  161. },
  162. selectedInfo: null,
  163. selectHandleInfo: null,
  164. };
  165. },
  166. watch: {
  167. groupRowList: {
  168. handler(val) {
  169. if (!val) return;
  170. this.rowCheckList = val
  171. .filter(({ is_pre_same_group }) => !is_pre_same_group)
  172. .reduce((acc, row) => {
  173. acc[row.row_id] = false;
  174. return acc;
  175. }, {});
  176. },
  177. },
  178. coursewareId: {
  179. handler(val) {
  180. this.courseware_id = val;
  181. },
  182. },
  183. },
  184. mounted() {
  185. const element = this.$refs.courserware;
  186. const rect = element.getBoundingClientRect();
  187. this.divPosition = {
  188. left: rect.left,
  189. top: rect.top,
  190. };
  191. window.addEventListener('mousedown', this.handleMouseDown);
  192. },
  193. beforeDestroy() {
  194. window.removeEventListener('mousedown', this.handleMouseDown);
  195. },
  196. methods: {
  197. /**
  198. * 计算组件内容
  199. * @param {string} id 组件id
  200. * @returns {string} 组件内容
  201. */
  202. computedColContent(id) {
  203. if (!id) return '';
  204. return this.componentList.find((item) => item.component_id === id)?.content || '';
  205. },
  206. getMultipleColStyle(i) {
  207. let row = this.data.row_list[i];
  208. let col = row.col_list;
  209. if (col.length <= 1) {
  210. return {
  211. gridTemplateColumns: '100fr',
  212. };
  213. }
  214. let gridTemplateColumns = row.width_list.join(' ');
  215. return {
  216. gridAutoFlow: 'column',
  217. gridTemplateColumns,
  218. gridTemplateRows: 'auto',
  219. };
  220. },
  221. /**
  222. * 计算行的可见性
  223. * @params {string} rowId 行的ID
  224. * @return {boolean} 行是否可见
  225. */
  226. computedRowVisibility(rowId) {
  227. if (this.groupShowAll) return true;
  228. let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
  229. if (is_pre_same_group) {
  230. const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
  231. if (index === -1) return false;
  232. for (let i = index - 1; i >= 0; i--) {
  233. if (!this.groupRowList[i].is_pre_same_group) {
  234. return this.rowCheckList[this.groupRowList[i].row_id];
  235. }
  236. }
  237. return false;
  238. }
  239. return this.rowCheckList[row_id];
  240. },
  241. /**
  242. * 计算选中分组行的课件信息
  243. * @returns {object} 选中分组行的课件信息
  244. */
  245. computedSelectedGroupCoursewareInfo() {
  246. if (Object.keys(this.rowCheckList).length === 0) {
  247. return {};
  248. }
  249. // 根据 rowCheckList 过滤出选中的行,获取这些行的组件信息
  250. let coursewareInfo = structuredClone(this.data);
  251. coursewareInfo.row_list = coursewareInfo.row_list.filter((row) => {
  252. let groupRow = this.groupRowList.find(({ row_id }) => row_id === row.row_id);
  253. if (!groupRow.is_pre_same_group) {
  254. return this.rowCheckList[groupRow.row_id];
  255. }
  256. const index = this.groupRowList.findIndex(({ row_id }) => row_id === row.row_id);
  257. if (index === -1) return false;
  258. for (let i = index - 1; i >= 0; i--) {
  259. if (!this.groupRowList[i].is_pre_same_group) {
  260. return this.rowCheckList[this.groupRowList[i].row_id];
  261. }
  262. }
  263. return false;
  264. });
  265. // 获取选中行的所有组件id列表
  266. let component_id_list = coursewareInfo.row_list.flatMap((row) =>
  267. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  268. );
  269. // 获取选中行的分组列表描述
  270. let content_group_row_list = this.groupRowList.filter(({ row_id, is_pre_same_group }) => {
  271. if (!is_pre_same_group) {
  272. return this.rowCheckList[row_id];
  273. }
  274. const index = this.groupRowList.findIndex(({ row_id: id }) => id === row_id);
  275. if (index === -1) return false;
  276. for (let i = index - 1; i >= 0; i--) {
  277. if (!this.groupRowList[i].is_pre_same_group) {
  278. return this.rowCheckList[this.groupRowList[i].row_id];
  279. }
  280. }
  281. return false;
  282. });
  283. let groupIdList = _.cloneDeep(content_group_row_list);
  284. let groupList = [];
  285. // 通过判断 is_pre_same_group 将组合并
  286. for (let i = 0; i < groupIdList.length; i++) {
  287. if (groupIdList[i].is_pre_same_group) {
  288. groupList[groupList.length - 1].row_id_list.push(groupIdList[i].row_id);
  289. } else {
  290. groupList.push({
  291. name: '',
  292. row_id_list: [groupIdList[i].row_id],
  293. component_id_list: [],
  294. });
  295. }
  296. }
  297. // 通过合并后的分组,获取对应的组件 id 和分组名称
  298. groupList.forEach(({ row_id_list, component_id_list }, i) => {
  299. row_id_list.forEach((row_id, j) => {
  300. let row = this.data.row_list.find((row) => {
  301. return row.row_id === row_id;
  302. });
  303. // 当前行所有组件id列表
  304. let gridIdList = row.col_list.map((col) => col.grid_list.map((grid) => grid.id)).flat();
  305. component_id_list.push(...gridIdList);
  306. // 查找每组第一行中第一个包含 describe、label 或 stem 的组件
  307. if (j === 0) {
  308. let findKey = '';
  309. let findType = '';
  310. row.col_list.some((col) => {
  311. const findItem = col.grid_list.find(({ type }) => {
  312. return ['describe', 'label', 'stem'].includes(type);
  313. });
  314. if (findItem) {
  315. findKey = findItem.id;
  316. findType = findItem.type;
  317. return true;
  318. }
  319. });
  320. let groupName = `组${i + 1}`;
  321. // 如果有标签类组件,获取对应名称
  322. if (findKey) {
  323. let item = this.isEdit
  324. ? this.findChildComponentByKey(`grid-${findKey}`)
  325. : this.$refs.previewEdit.findChildComponentByKey(`preview-${findKey}`);
  326. if (['describe', 'stem'].includes(findType)) {
  327. groupName = item.data.content.replace(/<[^>]+>/g, '');
  328. } else if (findType === 'label') {
  329. groupName = item.data.dynamicTags.map((tag) => tag.text).join(', ');
  330. }
  331. }
  332. groupList[i].name = groupName;
  333. }
  334. });
  335. });
  336. return {
  337. content: JSON.stringify(coursewareInfo),
  338. component_id_list,
  339. content_group_row_list: JSON.stringify(content_group_row_list),
  340. content_group_component_list: JSON.stringify(groupList),
  341. };
  342. },
  343. /**
  344. * 分割整数为多个 1的倍数
  345. * @param {number} num
  346. * @param {number} parts
  347. */
  348. splitInteger(num, parts) {
  349. let base = Math.floor(num / parts);
  350. let arr = Array(parts).fill(base);
  351. let remainder = num - base * parts;
  352. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  353. arr[i] += 1;
  354. remainder -= 1;
  355. }
  356. return arr;
  357. },
  358. /**
  359. * 计算列的样式
  360. * @param {Object} col 列对象
  361. * @returns {Object} 列的样式对象
  362. */
  363. computedColStyle(col) {
  364. const grid = col.grid_list || [];
  365. if (grid.length === 0) {
  366. return {
  367. width: col.width,
  368. gridTemplateAreas: '',
  369. gridTemplateColumns: '',
  370. gridTemplateRows: '',
  371. };
  372. }
  373. // 先按 row 分组,避免后续重复 filter 扫描
  374. const rowMap = new Map();
  375. grid.forEach((item) => {
  376. if (!rowMap.has(item.row)) {
  377. rowMap.set(item.row, []);
  378. }
  379. rowMap.get(item.row).push(item);
  380. });
  381. let maxCol = 0;
  382. let curMaxRow = 0;
  383. rowMap.forEach((items, row) => {
  384. if (items.length > maxCol) {
  385. maxCol = items.length;
  386. curMaxRow = row;
  387. }
  388. });
  389. // 计算 grid_template_areas
  390. let gridTemplateAreas = '';
  391. rowMap.forEach((items, row) => {
  392. let rowAreas = [];
  393. if (row === curMaxRow) {
  394. rowAreas = items.map((item) => item.grid_area);
  395. } else {
  396. const needNum = maxCol - items.length;
  397. if (items.length === 1) {
  398. rowAreas = Array(needNum + 1).fill(items[0].grid_area);
  399. } else {
  400. const splitArr = this.splitInteger(needNum, items.length);
  401. rowAreas = items.flatMap((item, index) => Array(splitArr[index] + 1).fill(item.grid_area));
  402. }
  403. }
  404. gridTemplateAreas += `'${rowAreas.join(' ')}' `;
  405. });
  406. // 计算 grid_template_columns
  407. const maxRowItems = rowMap.get(curMaxRow) || [];
  408. const gridTemplateColumns = maxRowItems.length ? `${maxRowItems.map((item) => item.width).join(' ')} ` : '';
  409. // 计算 grid_template_rows
  410. const previewById = new Map();
  411. (this.$refs.preview || []).forEach((child) => {
  412. const id = child?.$el?.dataset?.id;
  413. if (id) {
  414. previewById.set(id, child);
  415. }
  416. });
  417. const hasOperationById = (id) => {
  418. const component = previewById.get(id);
  419. return Boolean(component && component.$el.querySelector('.operation'));
  420. };
  421. const toNumberHeight = (height) => {
  422. const num = Number(String(height).replace('px', ''));
  423. return Number.isFinite(num) ? num : NaN;
  424. };
  425. let gridTemplateRows = '';
  426. rowMap.forEach((items) => {
  427. if (items.length === 1) {
  428. const current = items[0];
  429. if (current.height === 'auto') {
  430. gridTemplateRows += 'auto ';
  431. return;
  432. }
  433. let baseHeight = toNumberHeight(current.height);
  434. if (Number.isNaN(baseHeight)) {
  435. gridTemplateRows += `${current.height} `;
  436. return;
  437. }
  438. if (hasOperationById(current.id)) {
  439. baseHeight += 48;
  440. }
  441. gridTemplateRows += `${baseHeight}px `;
  442. return;
  443. }
  444. const nonAutoItems = items.filter((item) => item.height !== 'auto');
  445. if (nonAutoItems.length === 0) {
  446. gridTemplateRows += 'auto ';
  447. return;
  448. }
  449. let maxItem = null;
  450. let maxHeight = 0;
  451. nonAutoItems.forEach((item) => {
  452. const current = toNumberHeight(item.height);
  453. if (!Number.isNaN(current) && current > maxHeight) {
  454. maxHeight = current;
  455. maxItem = item;
  456. }
  457. });
  458. if (maxItem && hasOperationById(maxItem.id)) {
  459. maxHeight += 48;
  460. }
  461. gridTemplateRows += `${maxHeight}px `;
  462. });
  463. return {
  464. width: col.width,
  465. gridTemplateAreas,
  466. gridTemplateColumns,
  467. gridTemplateRows,
  468. };
  469. },
  470. /**
  471. * 计算课件背景样式
  472. * @returns {Object} 课件背景样式对象
  473. */
  474. computedCourserwareStyle() {
  475. const {
  476. background_image_url: bcImgUrl = '',
  477. background_position: pos = {},
  478. background: back,
  479. } = this.background || {};
  480. let canvasStyle = {
  481. backgroundSize: bcImgUrl ? `${pos.width}% ${pos.height}%` : '',
  482. backgroundPosition: bcImgUrl ? `${pos.left}% ${pos.top}%` : '',
  483. backgroundImage: bcImgUrl ? `url(${bcImgUrl})` : '',
  484. backgroundColor: bcImgUrl ? `rgba(255, 255, 255, ${1 - back?.image_opacity / 100})` : '',
  485. };
  486. if (back) {
  487. if (back.mode === 'image') {
  488. canvasStyle['backgroundBlendMode'] = 'lighten';
  489. } else {
  490. canvasStyle['backgroundBlendMode'] = '';
  491. }
  492. if (back.imageMode === 'fill') {
  493. canvasStyle['backgroundRepeat'] = 'repeat';
  494. canvasStyle['backgroundSize'] = '';
  495. canvasStyle['backgroundPosition'] = '';
  496. } else {
  497. canvasStyle['backgroundRepeat'] = 'no-repeat';
  498. }
  499. if (back.imageMode === 'stretch') {
  500. canvasStyle['backgroundSize'] = '100% 100%';
  501. }
  502. if (back.imageMode === 'adapt') {
  503. canvasStyle['backgroundSize'] = 'contain';
  504. }
  505. if (back.imageMode === 'auto') {
  506. canvasStyle['backgroundPosition'] = `${pos.imgX}% ${pos.imgY}%`;
  507. }
  508. if (back.mode === 'color') {
  509. canvasStyle['backgroundColor'] = back.color;
  510. canvasStyle['backgroundImage'] = '';
  511. canvasStyle['backgroundRepeat'] = '';
  512. canvasStyle['backgroundPosition'] = '';
  513. canvasStyle['backgroundSize'] = '';
  514. }
  515. if (back.enable_border) {
  516. canvasStyle['border'] = `${back.border_width}px ${back.border_style} ${back.border_color}`;
  517. } else {
  518. canvasStyle['border'] = 'none';
  519. }
  520. if (back.enable_radius) {
  521. canvasStyle['border-top-left-radius'] = `${back.top_left_radius}px`;
  522. canvasStyle['border-top-right-radius'] = `${back.top_right_radius}px`;
  523. canvasStyle['border-bottom-left-radius'] = `${back.bottom_left_radius}px`;
  524. canvasStyle['border-bottom-right-radius'] = `${back.bottom_right_radius}px`;
  525. } else {
  526. canvasStyle['border-radius'] = '0';
  527. }
  528. }
  529. return canvasStyle;
  530. },
  531. handleContextMenu(event, id) {
  532. if (this.canRemark) {
  533. event.preventDefault(); // 阻止默认的上下文菜单显示
  534. this.menuPosition = {
  535. x: event.clientX - this.divPosition.left,
  536. y: event.clientY - this.divPosition.top,
  537. }; // 设置菜单位置
  538. this.componentId = id;
  539. this.$emit('computeScroll');
  540. }
  541. },
  542. handleResult(top, left, select_node) {
  543. this.menuPosition = {
  544. x: this.menuPosition.x + left,
  545. y: this.menuPosition.y + top,
  546. select_node,
  547. }; // 设置菜单位置
  548. this.showMenu = true; // 显示菜单
  549. },
  550. handleMenuItemClick(event, type) {
  551. this.showMenu = false; // 隐藏菜单
  552. let text = '';
  553. if (type && type === 'tool') {
  554. let info = this.selectHandleInfo;
  555. this.menuPosition = {
  556. x: event.clientX - this.divPosition.left,
  557. y: event.clientY - this.divPosition.top - 20,
  558. }; // 设置菜单位置
  559. this.componentId = info.blockId;
  560. text = info.text;
  561. this.$emit('computeScroll');
  562. this.showMenu = false;
  563. }
  564. this.$emit(
  565. 'addRemark',
  566. this.menuPosition.select_node,
  567. this.menuPosition.x,
  568. this.menuPosition.y,
  569. this.componentId,
  570. text,
  571. );
  572. },
  573. handleMouseDown(event) {
  574. if (event.button === 0 && event.target.className !== 'custom-context-menu') {
  575. // 0 表示左键
  576. this.showMenu = false;
  577. }
  578. },
  579. /**
  580. * 查找子组件
  581. * @param {string} id 组件的唯一标识符
  582. * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
  583. */
  584. async findChildComponentByKey(id) {
  585. await this.$nextTick();
  586. if (!this.$refs.preview) {
  587. // 最多等待 1000ms
  588. for (let i = 0; i < 20; i++) {
  589. await this.$nextTick();
  590. await new Promise((resolve) => setTimeout(resolve, 50));
  591. if (this.$refs.preview) break;
  592. }
  593. }
  594. // 如果等待后还是不存在,那就返回null
  595. if (!this.$refs.preview) {
  596. console.error('$refs.preview 不存在');
  597. return null;
  598. }
  599. return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
  600. },
  601. /**
  602. * 模拟回答
  603. * @param {boolean} isJudgingRightWrong 是否判断对错
  604. * @param {boolean} isShowRightAnswer 是否显示正确答案
  605. * @param {boolean} disabled 是否禁用
  606. */
  607. simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true) {
  608. this.$refs.preview.forEach((item) => {
  609. item.showAnswer(isJudgingRightWrong, isShowRightAnswer, null, disabled);
  610. });
  611. },
  612. /**
  613. * 处理组件高度变化事件
  614. * @param {string} id 组件id
  615. * @param {string} newHeight 组件的新高度
  616. */
  617. handleHeightChange(id, newHeight) {
  618. this.data.row_list.forEach((row) => {
  619. row.col_list.forEach((col) => {
  620. col.grid_list.forEach((grid) => {
  621. if (grid.id === id) {
  622. grid.height = newHeight;
  623. }
  624. });
  625. });
  626. });
  627. },
  628. // 处理选中文本
  629. handleTextSelection() {
  630. this.showToolbar = false;
  631. // 延迟处理,确保选择已完成
  632. setTimeout(() => {
  633. const selection = window.getSelection();
  634. if (selection.toString().trim() === '') return null;
  635. const selectedText = selection.toString().trim();
  636. const range = selection.getRangeAt(0);
  637. this.selectedInfo = {
  638. text: selectedText,
  639. range,
  640. };
  641. let selectHandleInfo = this.getSelectionInfo();
  642. if (!selectHandleInfo || !selectHandleInfo.text) return;
  643. this.selectHandleInfo = selectHandleInfo;
  644. this.showToolbar = true;
  645. const container = document.querySelector('.courserware');
  646. const boxRect = container.getBoundingClientRect();
  647. const selectRect = range.getBoundingClientRect();
  648. this.contentmenu = {
  649. left: `${Math.round(selectRect.left - boxRect.left + selectRect.width / 2 - 63)}px`,
  650. top: `${Math.round(selectRect.top - boxRect.top + selectRect.height)}px`, // 向上偏移10px
  651. };
  652. }, 100);
  653. },
  654. // 笔记
  655. setNote() {
  656. this.showToolbar = false;
  657. this.oldRichData = {};
  658. let info = this.selectHandleInfo;
  659. if (!info) return;
  660. info.coursewareId = this.courseware_id;
  661. this.$emit('editNote', info);
  662. this.selectedInfo = null;
  663. },
  664. // 加入收藏
  665. setCollect() {
  666. this.showToolbar = false;
  667. let info = this.selectHandleInfo;
  668. if (!info) return;
  669. info.coursewareId = this.courseware_id;
  670. this.$emit('saveCollect', info);
  671. this.selectedInfo = null;
  672. },
  673. // 定位
  674. handleLocation(item) {
  675. this.scrollToDataId(item.blockId);
  676. },
  677. getSelectionInfo() {
  678. if (!this.selectedInfo) return;
  679. const range = this.selectedInfo.range;
  680. let selectedText = this.selectedInfo.text;
  681. if (!selectedText) return null;
  682. let commonAncestor = range.commonAncestorContainer;
  683. if (commonAncestor.nodeType === Node.TEXT_NODE) {
  684. commonAncestor = commonAncestor.parentNode;
  685. }
  686. const blockElement = commonAncestor.closest('[data-id]');
  687. if (!blockElement) return null;
  688. const blockId = blockElement.dataset.id;
  689. // 获取所有汉字元素
  690. const charElements = blockElement.querySelectorAll('.py-char,.rich-text,.NNPE-chs');
  691. // 构建包含位置信息的文本数组
  692. const textFragments = Array.from(charElements)
  693. .map((el, index) => {
  694. let text = '';
  695. if (el.classList.contains('rich-text')) {
  696. const pElements = Array.from(el.querySelectorAll('p'));
  697. text = pElements.map((p) => p.textContent.trim()).join('');
  698. } else if (el.classList.contains('NNPE-chs')) {
  699. const spanElements = Array.from(el.querySelectorAll('span'));
  700. spanElements.push(el);
  701. text = spanElements.map((span) => span.textContent.trim()).join('');
  702. } else {
  703. text = el.textContent.trim();
  704. }
  705. // 过滤掉拼音和空文本
  706. if (!text || /^[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]+$/i.test(text)) {
  707. return { text: '', element: el, index };
  708. }
  709. return { text, element: el, index };
  710. })
  711. .filter((fragment) => fragment.text);
  712. // 获取完整的纯文本
  713. const fullText = textFragments.map((f) => f.text).join('');
  714. // 清理选中文本
  715. let cleanSelectedText = selectedText.replace(/\n/g, '').trim();
  716. cleanSelectedText = cleanSelectedText.replace(/[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]/gi, '').trim();
  717. if (!cleanSelectedText) return null;
  718. // 方案1A:使用Range的边界点精确定位
  719. try {
  720. const startContainer = range.startContainer;
  721. const startOffset = range.startOffset;
  722. // 找到选择开始的元素在textFragments中的位置
  723. let startFragmentIndex = -1;
  724. let cumulativeLength = 0;
  725. let startIndexInFullText = -1;
  726. for (let i = 0; i < textFragments.length; i++) {
  727. const fragment = textFragments[i];
  728. // 检查这个元素是否包含选择起点
  729. if (fragment.element.contains(startContainer) || fragment.element === startContainer) {
  730. // 计算在这个元素内的起始位置
  731. if (startContainer.nodeType === Node.TEXT_NODE) {
  732. // 如果是文本节点,需要计算在父元素中的偏移
  733. const elementText = fragment.text;
  734. startFragmentIndex = i;
  735. startIndexInFullText = cumulativeLength + Math.min(startOffset, elementText.length);
  736. break;
  737. } else {
  738. // 如果是元素节点,从0开始
  739. startFragmentIndex = i;
  740. startIndexInFullText = cumulativeLength;
  741. break;
  742. }
  743. }
  744. cumulativeLength += fragment.text.length;
  745. }
  746. if (startIndexInFullText === -1) {
  747. // 如果精确定位失败,回退到文本匹配(但使用更智能的匹配)
  748. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  749. }
  750. const endIndexInFullText = startIndexInFullText + cleanSelectedText.length;
  751. return {
  752. blockId,
  753. text: cleanSelectedText,
  754. startIndex: startIndexInFullText,
  755. endIndex: endIndexInFullText,
  756. fullText,
  757. };
  758. } catch (error) {
  759. console.warn('精确位置计算失败,使用备选方案:', error);
  760. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  761. }
  762. },
  763. // 备选方案:基于DOM位置的智能匹配
  764. fallbackToTextMatch(fullText, selectedText, range, textFragments) {
  765. // 获取选择范围的近似位置
  766. const rangeRect = range.getBoundingClientRect();
  767. // 找到最接近选择中心的文本片段
  768. let closestFragment = null;
  769. let minDistance = Infinity;
  770. textFragments.forEach((fragment) => {
  771. const rect = fragment.element.getBoundingClientRect();
  772. if (rect.width > 0 && rect.height > 0) {
  773. // 确保元素可见
  774. const centerX = rect.left + rect.width / 2;
  775. const centerY = rect.top + rect.height / 2;
  776. const rangeCenterX = rangeRect.left + rangeRect.width / 2;
  777. const rangeCenterY = rangeRect.top + rangeRect.height / 2;
  778. const distance = Math.sqrt(Math.pow(centerX - rangeCenterX, 2) + Math.pow(centerY - rangeCenterY, 2));
  779. if (distance < minDistance) {
  780. minDistance = distance;
  781. closestFragment = fragment;
  782. }
  783. }
  784. });
  785. if (closestFragment) {
  786. // 从最近的片段开始向前后搜索匹配
  787. const fragmentIndex = textFragments.indexOf(closestFragment);
  788. let cumulativeLength = 0;
  789. // 计算到当前片段的累计长度
  790. for (let i = 0; i < fragmentIndex; i++) {
  791. cumulativeLength += textFragments[i].text.length;
  792. }
  793. // 在当前片段附近搜索匹配
  794. const searchStart = Math.max(0, cumulativeLength - selectedText.length * 3);
  795. const searchEnd = Math.min(
  796. fullText.length,
  797. cumulativeLength + closestFragment.text.length + selectedText.length * 3,
  798. );
  799. const searchArea = fullText.substring(searchStart, searchEnd);
  800. const localIndex = searchArea.indexOf(selectedText);
  801. if (localIndex !== -1) {
  802. return {
  803. startIndex: searchStart + localIndex,
  804. endIndex: searchStart + localIndex + selectedText.length,
  805. text: selectedText,
  806. fullText,
  807. };
  808. }
  809. }
  810. // 最终回退:使用所有匹配位置,选择最合理的一个
  811. const allMatches = [];
  812. let searchIndex = 0;
  813. while ((searchIndex = fullText.indexOf(selectedText, searchIndex)) !== -1) {
  814. allMatches.push(searchIndex);
  815. searchIndex += selectedText.length;
  816. }
  817. if (allMatches.length === 1) {
  818. return {
  819. startIndex: allMatches[0],
  820. endIndex: allMatches[0] + selectedText.length,
  821. text: selectedText,
  822. fullText,
  823. };
  824. } else if (allMatches.length > 1) {
  825. // 如果有多个匹配,选择位置最接近选择中心的
  826. if (closestFragment) {
  827. let cumulativeLength = 0;
  828. let fragmentStartIndex = 0;
  829. for (let i = 0; i < textFragments.length; i++) {
  830. if (textFragments[i] === closestFragment) {
  831. fragmentStartIndex = cumulativeLength;
  832. break;
  833. }
  834. cumulativeLength += textFragments[i].text.length;
  835. }
  836. // 选择最接近当前片段起始位置的匹配
  837. const bestMatch = allMatches.reduce((best, current) => {
  838. return Math.abs(current - fragmentStartIndex) < Math.abs(best - fragmentStartIndex) ? current : best;
  839. });
  840. return {
  841. startIndex: bestMatch,
  842. endIndex: bestMatch + selectedText.length,
  843. text: selectedText,
  844. fullText,
  845. };
  846. }
  847. }
  848. return null;
  849. },
  850. /**
  851. * 滚动到指定data-id的元素
  852. * @param {string} dataId 元素的data-id属性值
  853. * @param {number} offset 偏移量
  854. */
  855. scrollToDataId(dataId, offset) {
  856. let _offset = offset;
  857. if (!_offset) _offset = 0;
  858. const element = document.querySelector(`div[data-id="${dataId}"]`);
  859. if (element) {
  860. element.scrollIntoView({
  861. behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
  862. block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
  863. inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
  864. });
  865. }
  866. },
  867. },
  868. };
  869. </script>
  870. <style lang="scss" scoped>
  871. .courserware {
  872. position: relative;
  873. display: flex;
  874. flex-direction: column;
  875. row-gap: $component-spacing;
  876. width: 100%;
  877. height: 100%;
  878. min-height: calc(100vh - 226px);
  879. padding-top: $courseware-top-padding;
  880. padding-bottom: $courseware-bottom-padding;
  881. margin: 15px 0;
  882. background-color: #fff;
  883. background-repeat: no-repeat;
  884. border-bottom-right-radius: 12px;
  885. border-bottom-left-radius: 12px;
  886. &::before {
  887. top: -15px;
  888. }
  889. &::after {
  890. bottom: -15px;
  891. }
  892. .row {
  893. display: grid;
  894. gap: $component-spacing;
  895. .col {
  896. display: grid;
  897. gap: $component-spacing;
  898. overflow: hidden;
  899. }
  900. .row-checkbox {
  901. position: absolute;
  902. left: -20px;
  903. }
  904. }
  905. .custom-context-menu,
  906. .remark-info {
  907. position: absolute;
  908. z-index: 999;
  909. display: flex;
  910. gap: 3px;
  911. align-items: center;
  912. font-size: 14px;
  913. cursor: pointer;
  914. }
  915. .custom-context-menu {
  916. padding-left: 30px;
  917. background: url('../../../../assets/icon-publish.png') left center no-repeat;
  918. background-size: 24px;
  919. }
  920. .contentmenu {
  921. position: absolute;
  922. z-index: 999;
  923. display: flex;
  924. column-gap: 4px;
  925. align-items: center;
  926. padding: 8px;
  927. font-size: 14px;
  928. color: #000;
  929. background-color: #e7e7e7;
  930. border-radius: 4px;
  931. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 30%);
  932. .svg-icon,
  933. .button {
  934. cursor: pointer;
  935. }
  936. .line {
  937. min-height: 16px;
  938. margin: 0 4px;
  939. }
  940. }
  941. }
  942. </style>
  943. <style lang="scss">
  944. .menu-remark-info {
  945. min-width: 2%;
  946. max-width: 450px;
  947. video,
  948. img {
  949. max-width: 100%;
  950. height: auto;
  951. }
  952. audio {
  953. max-width: 100%;
  954. }
  955. }
  956. </style>