CoursewarePreview.vue 48 KB

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