CoursewarePreview.vue 26 KB

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