CoursewarePreview.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  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. width="200"
  51. trigger="click"
  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. <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
  73. <span class="line"></span>
  74. <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
  75. </div>
  76. </div>
  77. </template>
  78. <script>
  79. import { previewComponentList } from '@/views/book/courseware/data/bookType';
  80. export default {
  81. name: 'CoursewarePreview',
  82. provide() {
  83. return {
  84. getDragStatus: () => false,
  85. bookInfo: this.bookInfo,
  86. };
  87. },
  88. props: {
  89. data: {
  90. type: Object,
  91. default: () => ({}),
  92. },
  93. coursewareId: {
  94. type: String,
  95. default: '',
  96. },
  97. background: {
  98. type: Object,
  99. default: () => ({}),
  100. },
  101. componentList: {
  102. type: Array,
  103. required: true,
  104. },
  105. canRemark: {
  106. type: Boolean,
  107. default: false,
  108. },
  109. showRemark: {
  110. type: Boolean,
  111. default: false,
  112. },
  113. componentRemarkObj: {
  114. type: Object,
  115. default: () => ({}),
  116. },
  117. groupRowList: {
  118. type: Array,
  119. default: () => [],
  120. },
  121. isShowGroup: {
  122. type: Boolean,
  123. default: false,
  124. },
  125. groupShowAll: {
  126. type: Boolean,
  127. default: true,
  128. },
  129. project: {
  130. type: Object,
  131. default: () => ({}),
  132. },
  133. },
  134. data() {
  135. return {
  136. previewComponentList,
  137. courseware_id: this.coursewareId,
  138. bookInfo: {
  139. theme_color: '',
  140. },
  141. showMenu: false,
  142. divPosition: {
  143. left: 0,
  144. top: 0,
  145. }, // courserware盒子原始距离页面顶部和左边的距离
  146. menuPosition: { x: 0, y: 0, select_node: '' }, // 用于存储菜单的位置
  147. componentId: '', // 添加批注的组件id
  148. rowCheckList: {},
  149. showToolbar: false,
  150. contentmenu: {
  151. left: 0,
  152. top: 0,
  153. },
  154. selectedInfo: null,
  155. selectHandleInfo: null,
  156. };
  157. },
  158. watch: {
  159. groupRowList: {
  160. handler(val) {
  161. if (!val) return;
  162. this.rowCheckList = val
  163. .filter(({ is_pre_same_group }) => !is_pre_same_group)
  164. .reduce((acc, row) => {
  165. acc[row.row_id] = false;
  166. return acc;
  167. }, {});
  168. },
  169. },
  170. coursewareId: {
  171. handler(val) {
  172. this.courseware_id = val;
  173. },
  174. },
  175. },
  176. mounted() {
  177. const element = this.$refs.courserware;
  178. const rect = element.getBoundingClientRect();
  179. this.divPosition = {
  180. left: rect.left,
  181. top: rect.top,
  182. };
  183. window.addEventListener('mousedown', this.handleMouseDown);
  184. },
  185. beforeDestroy() {
  186. window.removeEventListener('mousedown', this.handleMouseDown);
  187. },
  188. methods: {
  189. /**
  190. * 计算组件内容
  191. * @param {string} id 组件id
  192. * @returns {string} 组件内容
  193. */
  194. computedColContent(id) {
  195. if (!id) return '';
  196. return this.componentList.find((item) => item.component_id === id)?.content || '';
  197. },
  198. getMultipleColStyle(i) {
  199. let row = this.data.row_list[i];
  200. let col = row.col_list;
  201. if (col.length <= 1) {
  202. return {
  203. gridTemplateColumns: '100fr',
  204. };
  205. }
  206. let gridTemplateColumns = row.width_list.join(' ');
  207. return {
  208. gridAutoFlow: 'column',
  209. gridTemplateColumns,
  210. gridTemplateRows: 'auto',
  211. };
  212. },
  213. /**
  214. * 计算行的可见性
  215. * @params {string} rowId 行的ID
  216. */
  217. computedRowVisibility(rowId) {
  218. if (this.groupShowAll) return true;
  219. let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
  220. if (is_pre_same_group) {
  221. const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
  222. if (index === -1) return false;
  223. for (let i = index - 1; i >= 0; i--) {
  224. if (!this.groupRowList[i].is_pre_same_group) {
  225. return this.rowCheckList[this.groupRowList[i].row_id];
  226. }
  227. }
  228. return false;
  229. }
  230. return this.rowCheckList[row_id];
  231. },
  232. /**
  233. * 分割整数为多个 1的倍数
  234. * @param {number} num
  235. * @param {number} parts
  236. */
  237. splitInteger(num, parts) {
  238. let base = Math.floor(num / parts);
  239. let arr = Array(parts).fill(base);
  240. let remainder = num - base * parts;
  241. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  242. arr[i] += 1;
  243. remainder -= 1;
  244. }
  245. return arr;
  246. },
  247. computedColStyle(col) {
  248. const grid = col.grid_list;
  249. let maxCol = 0; // 最大列数
  250. let rowList = new Map();
  251. grid.forEach(({ row }) => {
  252. rowList.set(row, (rowList.get(row) || 0) + 1);
  253. });
  254. let curMaxRow = 0; // 当前数量最大 row 的值
  255. rowList.forEach((value, key) => {
  256. if (value > maxCol) {
  257. maxCol = value;
  258. curMaxRow = key;
  259. }
  260. });
  261. // 计算 grid_template_areas
  262. let gridTemplateAreas = '';
  263. let gridArr = [];
  264. grid.forEach(({ grid_area, row }) => {
  265. if (!gridArr[row - 1]) {
  266. gridArr[row - 1] = [];
  267. }
  268. if (curMaxRow === row) {
  269. gridArr[row - 1].push(`${grid_area}`);
  270. } else {
  271. let filter = grid.filter((item) => item.row === row);
  272. let find = filter.findIndex((item) => item.grid_area === grid_area);
  273. let needNum = maxCol - filter.length; // 需要的数量
  274. let str = '';
  275. if (filter.length === 1) {
  276. str = ` ${grid_area} `.repeat(needNum + 1);
  277. } else {
  278. let arr = this.splitInteger(needNum, filter.length);
  279. str = arr[find] === 0 ? ` ${grid_area} ` : ` ${grid_area} `.repeat(arr[find] + 1);
  280. }
  281. gridArr[row - 1].push(`${str}`);
  282. }
  283. });
  284. gridArr.forEach((item) => {
  285. gridTemplateAreas += `'${item.join(' ')}' `;
  286. });
  287. // 计算 grid_template_columns
  288. let gridTemplateColumns = '';
  289. let max = { row: 0, num: 0 };
  290. grid.forEach(({ row }) => {
  291. // 计算出 row 的哪个值最多
  292. let len = grid.filter((item) => item.row === row).length;
  293. if (max.num < len) {
  294. max.num = len;
  295. max.row = row;
  296. }
  297. });
  298. grid.forEach((item) => {
  299. if (item.row === max.row) {
  300. gridTemplateColumns += `${item.width} `;
  301. }
  302. });
  303. // 计算 grid_template_rows
  304. let gridTemplateRows = '';
  305. // 将 grid 按照 row 分组
  306. let gridMap = new Map();
  307. grid.forEach((item) => {
  308. if (!gridMap.has(item.row)) {
  309. gridMap.set(item.row, []);
  310. }
  311. gridMap.get(item.row).push(item.height);
  312. });
  313. gridMap.forEach((value) => {
  314. if (value.length === 1) {
  315. gridTemplateRows += `${value[0]} `;
  316. } else {
  317. let isAllAuto = value.every((item) => item === 'auto'); // 是否全是 auto
  318. gridTemplateRows += isAllAuto ? 'auto ' : `max(${value.join(', ')}) `;
  319. }
  320. });
  321. return {
  322. width: col.width,
  323. gridTemplateAreas,
  324. gridTemplateColumns,
  325. gridTemplateRows,
  326. };
  327. },
  328. /**
  329. * 计算课件背景样式
  330. * @returns {Object} 课件背景样式对象
  331. */
  332. computedCourserwareStyle() {
  333. const { background_image_url: bcImgUrl = '', background_position: pos = {} } = this.background || {};
  334. const hasNoRows = !Array.isArray(this.data?.row_list) || this.data.row_list.length === 0;
  335. const projectCover = this.project?.cover_image_file_url || '';
  336. // 优先在空行时使用背景图或项目封面
  337. const backgroundImage = hasNoRows ? bcImgUrl || projectCover : '';
  338. // 保护性读取位置/大小值,避免 undefined 导致字符串 "undefined%"
  339. const widthPct = typeof pos.width === 'undefined' ? '' : pos.width;
  340. const heightPct = typeof pos.height === 'undefined' ? '' : pos.height;
  341. const leftPct = typeof pos.left === 'undefined' ? '' : pos.left;
  342. const topPct = typeof pos.top === 'undefined' ? '' : pos.top;
  343. const hasBcImg = Boolean(bcImgUrl);
  344. const backgroundSize = hasBcImg ? `${widthPct}% ${heightPct}%` : hasNoRows ? 'contain' : '';
  345. const backgroundPosition = hasBcImg ? `${leftPct}% ${topPct}%` : hasNoRows ? 'center' : '';
  346. return {
  347. backgroundImage: backgroundImage ? `url(${backgroundImage})` : '',
  348. backgroundSize,
  349. backgroundPosition,
  350. };
  351. },
  352. handleContextMenu(event, id) {
  353. if (this.canRemark) {
  354. event.preventDefault(); // 阻止默认的上下文菜单显示
  355. this.menuPosition = {
  356. x: event.clientX - this.divPosition.left,
  357. y: event.clientY - this.divPosition.top,
  358. }; // 设置菜单位置
  359. this.componentId = id;
  360. this.$emit('computeScroll');
  361. }
  362. },
  363. handleResult(top, left, select_node) {
  364. this.menuPosition = {
  365. x: this.menuPosition.x + left,
  366. y: this.menuPosition.y + top,
  367. select_node,
  368. }; // 设置菜单位置
  369. this.showMenu = true; // 显示菜单
  370. },
  371. handleMenuItemClick() {
  372. this.showMenu = false; // 隐藏菜单
  373. this.$emit(
  374. 'addRemark',
  375. this.menuPosition.select_node,
  376. this.menuPosition.x,
  377. this.menuPosition.y,
  378. this.componentId,
  379. );
  380. },
  381. handleMouseDown(event) {
  382. if (event.button === 0 && event.target.className !== 'custom-context-menu') {
  383. // 0 表示左键
  384. this.showMenu = false;
  385. }
  386. },
  387. /**
  388. * 查找子组件
  389. * @param {string} id 组件的唯一标识符
  390. * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
  391. */
  392. async findChildComponentByKey(id) {
  393. await this.$nextTick();
  394. return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
  395. },
  396. /**
  397. * 模拟回答
  398. * @param {boolean} isJudgingRightWrong 是否判断对错
  399. * @param {boolean} isShowRightAnswer 是否显示正确答案
  400. * @param {boolean} disabled 是否禁用
  401. */
  402. simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true) {
  403. this.$refs.preview.forEach((item) => {
  404. item.showAnswer(isJudgingRightWrong, isShowRightAnswer, null, disabled);
  405. });
  406. },
  407. /**
  408. * 处理组件高度变化事件
  409. * @param {string} id 组件id
  410. * @param {string} newHeight 组件的新高度
  411. */
  412. handleHeightChange(id, newHeight) {
  413. this.data.row_list.forEach((row) => {
  414. row.col_list.forEach((col) => {
  415. col.grid_list.forEach((grid) => {
  416. if (grid.id === id) {
  417. grid.height = newHeight;
  418. }
  419. });
  420. });
  421. });
  422. },
  423. // 处理选中文本
  424. handleTextSelection() {
  425. this.showToolbar = false;
  426. // 延迟处理,确保选择已完成
  427. setTimeout(() => {
  428. const selection = window.getSelection();
  429. if (selection.toString().trim() === '') return null;
  430. const selectedText = selection.toString().trim();
  431. const range = selection.getRangeAt(0);
  432. this.selectedInfo = {
  433. text: selectedText,
  434. range,
  435. };
  436. let selectHandleInfo = this.getSelectionInfo();
  437. if (!selectHandleInfo || !selectHandleInfo.text) return;
  438. this.selectHandleInfo = selectHandleInfo;
  439. this.showToolbar = true;
  440. const container = document.querySelector('.courserware');
  441. const boxRect = container.getBoundingClientRect();
  442. const selectRect = range.getBoundingClientRect();
  443. this.contentmenu = {
  444. left: `${Math.round(selectRect.left - boxRect.left + selectRect.width / 2 - 63)}px`,
  445. top: `${Math.round(selectRect.top - boxRect.top + selectRect.height)}px`, // 向上偏移10px
  446. };
  447. }, 100);
  448. },
  449. // 笔记
  450. setNote() {
  451. this.showToolbar = false;
  452. this.oldRichData = {};
  453. let info = this.selectHandleInfo;
  454. if (!info) return;
  455. info.coursewareId = this.courseware_id;
  456. this.$emit('editNote', info);
  457. this.selectedInfo = null;
  458. },
  459. // 加入收藏
  460. setCollect() {
  461. this.showToolbar = false;
  462. let info = this.selectHandleInfo;
  463. if (!info) return;
  464. info.coursewareId = this.courseware_id;
  465. this.$emit('saveCollect', info);
  466. this.selectedInfo = null;
  467. },
  468. // 定位
  469. handLocation(item) {
  470. this.scrollToDataId(item.blockId);
  471. },
  472. getSelectionInfo() {
  473. if (!this.selectedInfo) return;
  474. const range = this.selectedInfo.range;
  475. let selectedText = this.selectedInfo.text;
  476. if (!selectedText) return null;
  477. let commonAncestor = range.commonAncestorContainer;
  478. if (commonAncestor.nodeType === Node.TEXT_NODE) {
  479. commonAncestor = commonAncestor.parentNode;
  480. }
  481. const blockElement = commonAncestor.closest('[data-id]');
  482. if (!blockElement) return null;
  483. const blockId = blockElement.dataset.id;
  484. // 获取所有汉字元素
  485. const charElements = blockElement.querySelectorAll('.py-char,.rich-text,.NNPE-chs');
  486. // 构建包含位置信息的文本数组
  487. const textFragments = Array.from(charElements)
  488. .map((el, index) => {
  489. let text = '';
  490. if (el.classList.contains('rich-text')) {
  491. const pElements = Array.from(el.querySelectorAll('p'));
  492. text = pElements.map((p) => p.textContent.trim()).join('');
  493. } else if (el.classList.contains('NNPE-chs')) {
  494. const spanElements = Array.from(el.querySelectorAll('span'));
  495. spanElements.push(el);
  496. text = spanElements.map((span) => span.textContent.trim()).join('');
  497. } else {
  498. text = el.textContent.trim();
  499. }
  500. // 过滤掉拼音和空文本
  501. if (!text || /^[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]+$/i.test(text)) {
  502. return { text: '', element: el, index };
  503. }
  504. return { text, element: el, index };
  505. })
  506. .filter((fragment) => fragment.text);
  507. // 获取完整的纯文本
  508. const fullText = textFragments.map((f) => f.text).join('');
  509. // 清理选中文本
  510. let cleanSelectedText = selectedText.replace(/\n/g, '').trim();
  511. cleanSelectedText = cleanSelectedText.replace(/[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]/gi, '').trim();
  512. if (!cleanSelectedText) return null;
  513. // 方案1A:使用Range的边界点精确定位
  514. try {
  515. const startContainer = range.startContainer;
  516. const startOffset = range.startOffset;
  517. // 找到选择开始的元素在textFragments中的位置
  518. let startFragmentIndex = -1;
  519. let cumulativeLength = 0;
  520. let startIndexInFullText = -1;
  521. for (let i = 0; i < textFragments.length; i++) {
  522. const fragment = textFragments[i];
  523. // 检查这个元素是否包含选择起点
  524. if (fragment.element.contains(startContainer) || fragment.element === startContainer) {
  525. // 计算在这个元素内的起始位置
  526. if (startContainer.nodeType === Node.TEXT_NODE) {
  527. // 如果是文本节点,需要计算在父元素中的偏移
  528. const elementText = fragment.text;
  529. startFragmentIndex = i;
  530. startIndexInFullText = cumulativeLength + Math.min(startOffset, elementText.length);
  531. break;
  532. } else {
  533. // 如果是元素节点,从0开始
  534. startFragmentIndex = i;
  535. startIndexInFullText = cumulativeLength;
  536. break;
  537. }
  538. }
  539. cumulativeLength += fragment.text.length;
  540. }
  541. if (startIndexInFullText === -1) {
  542. // 如果精确定位失败,回退到文本匹配(但使用更智能的匹配)
  543. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  544. }
  545. const endIndexInFullText = startIndexInFullText + cleanSelectedText.length;
  546. return {
  547. blockId,
  548. text: cleanSelectedText,
  549. startIndex: startIndexInFullText,
  550. endIndex: endIndexInFullText,
  551. fullText,
  552. };
  553. } catch (error) {
  554. console.warn('精确位置计算失败,使用备选方案:', error);
  555. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  556. }
  557. },
  558. // 备选方案:基于DOM位置的智能匹配
  559. fallbackToTextMatch(fullText, selectedText, range, textFragments) {
  560. // 获取选择范围的近似位置
  561. const rangeRect = range.getBoundingClientRect();
  562. // 找到最接近选择中心的文本片段
  563. let closestFragment = null;
  564. let minDistance = Infinity;
  565. textFragments.forEach((fragment) => {
  566. const rect = fragment.element.getBoundingClientRect();
  567. if (rect.width > 0 && rect.height > 0) {
  568. // 确保元素可见
  569. const centerX = rect.left + rect.width / 2;
  570. const centerY = rect.top + rect.height / 2;
  571. const rangeCenterX = rangeRect.left + rangeRect.width / 2;
  572. const rangeCenterY = rangeRect.top + rangeRect.height / 2;
  573. const distance = Math.sqrt(Math.pow(centerX - rangeCenterX, 2) + Math.pow(centerY - rangeCenterY, 2));
  574. if (distance < minDistance) {
  575. minDistance = distance;
  576. closestFragment = fragment;
  577. }
  578. }
  579. });
  580. if (closestFragment) {
  581. // 从最近的片段开始向前后搜索匹配
  582. const fragmentIndex = textFragments.indexOf(closestFragment);
  583. let cumulativeLength = 0;
  584. // 计算到当前片段的累计长度
  585. for (let i = 0; i < fragmentIndex; i++) {
  586. cumulativeLength += textFragments[i].text.length;
  587. }
  588. // 在当前片段附近搜索匹配
  589. const searchStart = Math.max(0, cumulativeLength - selectedText.length * 3);
  590. const searchEnd = Math.min(
  591. fullText.length,
  592. cumulativeLength + closestFragment.text.length + selectedText.length * 3,
  593. );
  594. const searchArea = fullText.substring(searchStart, searchEnd);
  595. const localIndex = searchArea.indexOf(selectedText);
  596. if (localIndex !== -1) {
  597. return {
  598. startIndex: searchStart + localIndex,
  599. endIndex: searchStart + localIndex + selectedText.length,
  600. text: selectedText,
  601. fullText,
  602. };
  603. }
  604. }
  605. // 最终回退:使用所有匹配位置,选择最合理的一个
  606. const allMatches = [];
  607. let searchIndex = 0;
  608. while ((searchIndex = fullText.indexOf(selectedText, searchIndex)) !== -1) {
  609. allMatches.push(searchIndex);
  610. searchIndex += selectedText.length;
  611. }
  612. if (allMatches.length === 1) {
  613. return {
  614. startIndex: allMatches[0],
  615. endIndex: allMatches[0] + selectedText.length,
  616. text: selectedText,
  617. fullText,
  618. };
  619. } else if (allMatches.length > 1) {
  620. // 如果有多个匹配,选择位置最接近选择中心的
  621. if (closestFragment) {
  622. let cumulativeLength = 0;
  623. let fragmentStartIndex = 0;
  624. for (let i = 0; i < textFragments.length; i++) {
  625. if (textFragments[i] === closestFragment) {
  626. fragmentStartIndex = cumulativeLength;
  627. break;
  628. }
  629. cumulativeLength += textFragments[i].text.length;
  630. }
  631. // 选择最接近当前片段起始位置的匹配
  632. const bestMatch = allMatches.reduce((best, current) => {
  633. return Math.abs(current - fragmentStartIndex) < Math.abs(best - fragmentStartIndex) ? current : best;
  634. });
  635. return {
  636. startIndex: bestMatch,
  637. endIndex: bestMatch + selectedText.length,
  638. text: selectedText,
  639. fullText,
  640. };
  641. }
  642. }
  643. return null;
  644. },
  645. /**
  646. * 滚动到指定data-id的元素
  647. * @param {string} dataId 元素的data-id属性值
  648. * @param {number} offset 偏移量
  649. */
  650. scrollToDataId(dataId, offset) {
  651. let _offset = offset;
  652. if (!_offset) _offset = 0;
  653. const element = document.querySelector(`div[data-id="${dataId}"]`);
  654. if (element) {
  655. const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
  656. const offsetPosition = elementPosition - _offset;
  657. element.scrollIntoView({
  658. behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
  659. block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
  660. inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
  661. });
  662. }
  663. },
  664. },
  665. };
  666. </script>
  667. <style lang="scss" scoped>
  668. .courserware {
  669. position: relative;
  670. display: flex;
  671. flex-direction: column;
  672. row-gap: $component-spacing;
  673. width: 100%;
  674. height: 100%;
  675. min-height: calc(100vh - 226px);
  676. padding-top: $courseware-top-padding;
  677. padding-bottom: $courseware-bottom-padding;
  678. margin: 15px 0;
  679. background-color: #fff;
  680. background-repeat: no-repeat;
  681. border-bottom-right-radius: 12px;
  682. border-bottom-left-radius: 12px;
  683. &::before,
  684. &::after {
  685. position: absolute;
  686. left: 0;
  687. width: 100%;
  688. height: 15px;
  689. pointer-events: none;
  690. content: '';
  691. background: $courseware-bgColor;
  692. }
  693. &::before {
  694. top: -15px;
  695. }
  696. &::after {
  697. bottom: -15px;
  698. }
  699. .row {
  700. display: grid;
  701. gap: $component-spacing;
  702. .col {
  703. display: grid;
  704. gap: $component-spacing;
  705. overflow: hidden;
  706. }
  707. .row-checkbox {
  708. position: absolute;
  709. left: 4px;
  710. }
  711. }
  712. .custom-context-menu,
  713. .remark-info {
  714. position: absolute;
  715. z-index: 999;
  716. display: flex;
  717. gap: 3px;
  718. align-items: center;
  719. font-size: 14px;
  720. cursor: pointer;
  721. }
  722. .custom-context-menu {
  723. padding-left: 30px;
  724. background: url('../../../../assets/icon-publish.png') left center no-repeat;
  725. background-size: 24px;
  726. }
  727. .contentmenu {
  728. position: absolute;
  729. z-index: 999;
  730. display: flex;
  731. column-gap: 4px;
  732. align-items: center;
  733. padding: 8px;
  734. font-size: 14px;
  735. color: #000;
  736. background-color: #e7e7e7;
  737. border-radius: 4px;
  738. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 30%);
  739. .svg-icon,
  740. .button {
  741. cursor: pointer;
  742. }
  743. .line {
  744. min-height: 16px;
  745. margin: 0 4px;
  746. }
  747. }
  748. }
  749. </style>