CoursewarePreview.vue 44 KB

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