CoursewarePreview.vue 40 KB

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