| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283 |
- <template>
- <div
- id="selectable-area-preview"
- ref="courseware"
- class="courseware preview"
- :style="computedCoursewareStyle"
- @mouseup="handleTextSelection"
- @mousedown="startSelection"
- @mousemove="updateSelection"
- >
- <!-- @mouseleave="endSelection" -->
- <template v-for="(row, i) in data.row_list">
- <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
- <el-checkbox
- v-if="
- isShowGroup &&
- groupRowList.find(({ row_id, is_pre_same_group }) => row.row_id === row_id && !is_pre_same_group)
- "
- v-model="rowCheckList[row.row_id]"
- :class="['row-checkbox', `${row.row_id}`]"
- />
- <!-- 列 -->
- <template v-for="(col, j) in row.col_list">
- <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
- <!-- 网格 -->
- <template v-for="(grid, k) in col.grid_list">
- <component
- :is="previewComponentList[grid.type]"
- :id="grid.id"
- :key="k"
- ref="preview"
- :content="computedColContent(grid.id)"
- :background="computedColBackground(grid.id)"
- :class="['grid', grid.id, { active: curSelectId === grid.id }]"
- :data-id="grid.id"
- :style="{
- gridArea: grid.grid_area,
- height: grid.height,
- }"
- @click.native="selectedComponent(grid.id)"
- @contextmenu.native.prevent="handleContextMenu($event, grid.id)"
- @handleHeightChange="handleHeightChange"
- />
- <!-- <div
- v-if="showMenu && componentId === grid.id"
- :key="'menu' + grid.id + k"
- class="custom-context-menu"
- :style="{ left: menuPosition.x + 'px', top: menuPosition.y + 'px' }"
- @click="handleMenuItemClick"
- >
- 添加批注
- </div> -->
- <div
- v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj[grid.id]"
- :key="'show' + grid.id + k"
- >
- <el-popover
- v-for="(items, indexs) in componentRemarkObj[grid.id]"
- :key="indexs"
- placement="bottom"
- trigger="click"
- popper-class="menu-remark-info"
- >
- <div v-html="items.content"></div>
- <template v-if="items.file_list.length > 0">
- <div v-for="(item, index) in items.file_list" :key="indexs + '-' + index" class="remark-file-item">
- <SvgIcon :icon-class="item.icon_type" />
- <span class="file-item-name">{{ item.file_name }}</span>
- <SvgIcon icon-class="uploadPreview" @click="viewDialog(item)" />
- <SvgIcon icon-class="download" @click="downLoad(item)" />
- </div>
- </template>
- <template #reference>
- <div
- v-if="items.position_br_x - items.position_x > 3 && items.position_br_y - items.position_y > 3"
- slot="reference"
- :style="{
- position: 'absolute',
- top: `${items.position_y}px`,
- left: `${items.position_x}px`,
- width: `${items.position_br_x - items.position_x}px`,
- height: `${items.position_br_y - items.position_y}px`,
- border: '2px solid #165DFF',
- zIndex: 10,
- }"
- ></div>
- </template>
- </el-popover>
- </div>
- </template>
- </div>
- </template>
- </div>
- </template>
- <div
- v-if="menuPosition.endX - menuPosition.startX > 3 && menuPosition.endY - menuPosition.startY > 3"
- :style="{
- position: 'absolute',
- top: `${menuPosition.startY}px`,
- left: `${menuPosition.startX}px`,
- width: `${menuPosition.endX - menuPosition.startX}px`,
- height: `${menuPosition.endY - menuPosition.startY}px`,
- border: '2px solid #165DFF',
- }"
- ></div>
- <!-- 选中文本的工具栏 -->
- <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
- <template v-if="canRemark">
- <!-- <span class="button" @click="handleMenuItemClick($event, 'tool')">
- <SvgIcon icon-class="sidebar-pushpin" size="14" /> 添加批注
- </span> -->
- </template>
- <template v-else>
- <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
- <span class="line"></span>
- <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
- <span class="line"></span>
- <span class="button" @click="setTranslate"> <SvgIcon icon-class="sidebar-translate" size="14" /> 翻译</span>
- <!-- <span class="line"></span>
- <span class="button" @click="setFeedback"> <SvgIcon icon-class="sidebar-feedback" size="14" /> 用户反馈</span> -->
- </template>
- </div>
- <template v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj['WHOLE']">
- <el-popover
- v-for="(items, indexs) in componentRemarkObj['WHOLE']"
- :key="'menu-remark-info' + indexs"
- placement="bottom"
- trigger="click"
- popper-class="menu-remark-info"
- >
- <div v-html="items.content"></div>
- <template v-if="items.file_list.length > 0">
- <div v-for="(item, index) in items.file_list" :key="indexs + '-' + index" class="remark-file-item">
- <SvgIcon :icon-class="item.icon_type" />
- <span class="file-item-name">{{ item.file_name }}</span>
- <SvgIcon icon-class="uploadPreview" @click="viewDialog(item)" />
- <SvgIcon icon-class="download" @click="downLoad(item)" />
- </div>
- </template>
- <template #reference>
- <div
- v-if="items.position_br_x - items.position_x > 3 && items.position_br_y - items.position_y > 3"
- slot="reference"
- :style="{
- position: 'absolute',
- top: `${items.position_y}px`,
- left: `${items.position_x}px`,
- width: `${items.position_br_x - items.position_x}px`,
- height: `${items.position_br_y - items.position_y}px`,
- border: '2px solid #165DFF',
- zIndex: 10,
- }"
- ></div>
- </template>
- </el-popover>
- </template>
- <el-dialog
- v-if="visible"
- :visible.sync="visible"
- :show-close="true"
- :close-on-click-modal="true"
- :modal-append-to-body="true"
- :append-to-body="true"
- :lock-scroll="true"
- :width="'80%'"
- top="0"
- >
- <iframe v-if="visible" :src="newpath" width="100%" :height="iframeHeight" frameborder="0"></iframe>
- </el-dialog>
- </div>
- </template>
- <script>
- import { previewComponentList } from '@/views/book/courseware/data/bookType';
- import { getToken, getConfig } from '@/utils/auth';
- import { buildCoursewareStyle } from '@/views/book/courseware/preview/common/utils/coursewareStyle';
- import _ from 'lodash';
- const Base64 = require('js-base64').Base64;
- export default {
- name: 'CoursewarePreview',
- provide() {
- return {
- getDragStatus: () => false,
- bookInfo: this.bookInfo,
- };
- },
- props: {
- data: {
- type: Object,
- default: () => ({}),
- },
- coursewareId: {
- type: String,
- default: '',
- },
- background: {
- type: Object,
- default: () => ({}),
- },
- componentList: {
- type: Array,
- required: true,
- },
- canRemark: {
- type: Boolean,
- default: false,
- },
- showRemark: {
- type: Boolean,
- default: false,
- },
- componentRemarkObj: {
- type: Object,
- default: () => ({}),
- },
- groupRowList: {
- type: Array,
- default: () => [],
- },
- isShowGroup: {
- type: Boolean,
- default: false,
- },
- groupShowAll: {
- type: Boolean,
- default: true,
- },
- project: {
- type: Object,
- default: () => ({}),
- },
- type: {
- type: String,
- default: '',
- },
- },
- data() {
- return {
- previewComponentList,
- courseware_id: this.coursewareId,
- bookInfo: {
- theme_color: '',
- },
- showMenu: false,
- divPosition: {
- left: 0,
- top: 0,
- }, // courserware盒子原始距离页面顶部和左边的距离
- menuPosition: { x: 0, y: 0, select_node: '', startX: null, startY: null, endX: null, endY: null }, // 用于存储菜单的位置
- componentId: '', // 添加批注的组件id
- rowCheckList: {},
- showToolbar: false,
- contentmenu: {
- left: 0,
- top: 0,
- },
- selectedInfo: null,
- selectHandleInfo: null,
- curSelectId: '', // 当前选中组件id
- isSelecting: false, // 是否开始框选内容
- file_preview_url: getConfig() ? getConfig().doc_preview_service_address : '',
- visible: false,
- newpath: '',
- iframeHeight: `${window.innerHeight - 100}px`,
- visible_id: this.$route.query?.visible_id || '', // 可见组件 id
- };
- },
- computed: {
- // 计算课件背景样式
- computedCoursewareStyle() {
- return buildCoursewareStyle(this.background, 'courseware');
- },
- },
- watch: {
- groupRowList: {
- handler(val) {
- if (!val) return;
- this.rowCheckList = val
- .filter(({ is_pre_same_group }) => !is_pre_same_group)
- .reduce((acc, row) => {
- acc[row.row_id] = false;
- return acc;
- }, {});
- },
- },
- coursewareId: {
- handler(val) {
- this.courseware_id = val;
- },
- },
- },
- mounted() {
- const element = this.$refs.courseware;
- const rect = element.getBoundingClientRect();
- this.divPosition = {
- left: rect.left,
- top: rect.top,
- };
- window.addEventListener('mousedown', this.handleMouseDown);
- this.handleScrollVisibleComponent();
- },
- beforeDestroy() {
- window.removeEventListener('mousedown', this.handleMouseDown);
- },
- methods: {
- /**
- * 滚动到 visible_id 的组件
- */
- async handleScrollVisibleComponent() {
- if (!this.visible_id) return;
- await this.$nextTick();
- const target = await this.findChildComponentByKey(this.visible_id);
- if (target && target.$el) {
- target.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- },
- /**
- * 处理选中组件事件
- * 这个事件只在编辑预览模式下触发
- * @param {string} component_id 组件在课件内部的 ID
- */
- selectedComponent(component_id) {
- if (this.type !== 'edit_preview') return;
- this.curSelectId = component_id;
- const selectedComponent = this.componentList.find((item) => item.component_id === component_id);
- this.$emit('selectedComponent', { courseware_id: selectedComponent?.courseware_id, component_id });
- },
- /**
- * 计算组件内容
- * @param {string} id 组件id
- * @returns {string} 组件内容
- */
- computedColContent(id) {
- if (!id) return '';
- return this.componentList.find((item) => item.component_id === id)?.content || '';
- },
- /**
- * 计算组件背景
- * @param {string} id 组件id
- * @returns {object} 组件背景样式
- */
- computedColBackground(id) {
- if (!id) return {};
- const background = this.componentList.find((item) => item.component_id === id)?.background;
- return background ? JSON.parse(background) : {};
- },
- getMultipleColStyle(i) {
- let row = this.data.row_list[i];
- let col = row.col_list;
- if (col.length <= 1) {
- return {
- gridTemplateColumns: '100fr',
- };
- }
- let gridTemplateColumns = row.width_list.join(' ');
- return {
- gridAutoFlow: 'column',
- gridTemplateColumns,
- gridTemplateRows: 'auto',
- };
- },
- /**
- * 计算行的可见性
- * @params {string} rowId 行的ID
- * @return {boolean} 行是否可见
- */
- computedRowVisibility(rowId) {
- if (this.groupShowAll) return true;
- let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
- if (is_pre_same_group) {
- const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
- if (index === -1) return false;
- for (let i = index - 1; i >= 0; i--) {
- if (!this.groupRowList[i].is_pre_same_group) {
- return this.rowCheckList[this.groupRowList[i].row_id];
- }
- }
- return false;
- }
- return this.rowCheckList[row_id];
- },
- /**
- * 计算选中分组行的课件信息
- * @returns {object} 选中分组行的课件信息
- */
- computedSelectedGroupCoursewareInfo() {
- if (Object.keys(this.rowCheckList).length === 0) {
- return {};
- }
- // 根据 rowCheckList 过滤出选中的行,获取这些行的组件信息
- let coursewareInfo = structuredClone(this.data);
- coursewareInfo.row_list = coursewareInfo.row_list.filter((row) => {
- let groupRow = this.groupRowList.find(({ row_id }) => row_id === row.row_id);
- if (!groupRow.is_pre_same_group) {
- return this.rowCheckList[groupRow.row_id];
- }
- const index = this.groupRowList.findIndex(({ row_id }) => row_id === row.row_id);
- if (index === -1) return false;
- for (let i = index - 1; i >= 0; i--) {
- if (!this.groupRowList[i].is_pre_same_group) {
- return this.rowCheckList[this.groupRowList[i].row_id];
- }
- }
- return false;
- });
- // 获取选中行的所有组件id列表
- let component_id_list = coursewareInfo.row_list.flatMap((row) =>
- row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
- );
- // 获取选中行的分组列表描述
- let content_group_row_list = this.groupRowList.filter(({ row_id, is_pre_same_group }) => {
- if (!is_pre_same_group) {
- return this.rowCheckList[row_id];
- }
- const index = this.groupRowList.findIndex(({ row_id: id }) => id === row_id);
- if (index === -1) return false;
- for (let i = index - 1; i >= 0; i--) {
- if (!this.groupRowList[i].is_pre_same_group) {
- return this.rowCheckList[this.groupRowList[i].row_id];
- }
- }
- return false;
- });
- let groupIdList = _.cloneDeep(content_group_row_list);
- let groupList = [];
- // 通过判断 is_pre_same_group 将组合并
- for (let i = 0; i < groupIdList.length; i++) {
- if (groupIdList[i].is_pre_same_group) {
- groupList[groupList.length - 1].row_id_list.push(groupIdList[i].row_id);
- } else {
- groupList.push({
- name: '',
- row_id_list: [groupIdList[i].row_id],
- component_id_list: [],
- });
- }
- }
- // 通过合并后的分组,获取对应的组件 id 和分组名称
- groupList.forEach(({ row_id_list, component_id_list }, i) => {
- row_id_list.forEach((row_id, j) => {
- let row = this.data.row_list.find((row) => {
- return row.row_id === row_id;
- });
- // 当前行所有组件id列表
- let gridIdList = row.col_list.map((col) => col.grid_list.map((grid) => grid.id)).flat();
- component_id_list.push(...gridIdList);
- // 查找每组第一行中第一个包含 describe、label 或 stem 的组件
- if (j === 0) {
- let findKey = '';
- let findType = '';
- row.col_list.some((col) => {
- const findItem = col.grid_list.find(({ type }) => {
- return ['describe', 'label', 'stem'].includes(type);
- });
- if (findItem) {
- findKey = findItem.id;
- findType = findItem.type;
- return true;
- }
- });
- let groupName = `组${i + 1}`;
- // 如果有标签类组件,获取对应名称
- if (findKey) {
- let item = this.$refs.preview.find(
- (child) => child.$el && child.$el.dataset && child.$el.dataset.id === findKey,
- );
- if (['describe', 'stem'].includes(findType)) {
- groupName = item.data.content.replace(/<[^>]+>/g, '');
- } else if (findType === 'label') {
- groupName = item.data.dynamicTags.map((tag) => tag.text).join(', ');
- }
- }
- groupList[i].name = groupName;
- }
- });
- });
- return {
- content: JSON.stringify(coursewareInfo),
- component_id_list,
- content_group_row_list: JSON.stringify(content_group_row_list),
- content_group_component_list: JSON.stringify(groupList),
- };
- },
- /**
- * 保存课节为样式模板
- * @param {object} param0 保存样式模板所需参数
- * @param {string} param0.courseware_id 课件id
- * @param {'true' | 'false'} param0.is_select_part_courseware_mode 是否选择部分课件模式
- * @param {string[]} param0.component_id_list 组件id列表
- */
- async saveCoursewareStyleTemplate({ courseware_id, is_select_part_courseware_mode, component_id_list = [] }) {
- const allComponentIds = this.data.row_list.flatMap((row) =>
- row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
- );
- // 如果是选择部分课件模式,则使用传入的 component_id_list,否则使用所有组件id列表
- const idList = is_select_part_courseware_mode === 'true' ? component_id_list : allComponentIds;
- const saveTasks = [];
- // 遍历组件id列表,找到对应组件并调用其保存样式模板方法
- for (const id of idList) {
- const component = await this.findChildComponentByKey(id);
- if (component && typeof component.saveStyleTemplate === 'function') {
- saveTasks.push(component.saveStyleTemplate(courseware_id));
- }
- }
- await Promise.all(saveTasks);
- this.$message.success('已保存为样式模板');
- },
- /**
- * 清空行选择列表
- */
- clearRowCheckList() {
- this.rowCheckList = {};
- },
- /**
- * 分割整数为多个 1的倍数
- * @param {number} num
- * @param {number} parts
- */
- splitInteger(num, parts) {
- let base = Math.floor(num / parts);
- let arr = Array(parts).fill(base);
- let remainder = num - base * parts;
- for (let i = 0; remainder > 0; i = (i + 1) % parts) {
- arr[i] += 1;
- remainder -= 1;
- }
- return arr;
- },
- /**
- * 计算列的样式
- * @param {Object} col 列对象
- * @returns {Object} 列的样式对象
- */
- computedColStyle(col) {
- const grid = col.grid_list || [];
- if (grid.length === 0) {
- return {
- width: col.width,
- gridTemplateAreas: '',
- gridTemplateColumns: '',
- gridTemplateRows: '',
- };
- }
- // 先按 row 分组,避免后续重复 filter 扫描
- const rowMap = new Map();
- grid.forEach((item) => {
- if (!rowMap.has(item.row)) {
- rowMap.set(item.row, []);
- }
- rowMap.get(item.row).push(item);
- });
- let maxCol = 0;
- let curMaxRow = 0;
- rowMap.forEach((items, row) => {
- if (items.length > maxCol) {
- maxCol = items.length;
- curMaxRow = row;
- }
- });
- // 计算 grid_template_areas
- let gridTemplateAreas = '';
- rowMap.forEach((items, row) => {
- let rowAreas = [];
- if (row === curMaxRow) {
- rowAreas = items.map((item) => item.grid_area);
- } else {
- const needNum = maxCol - items.length;
- if (items.length === 1) {
- rowAreas = Array(needNum + 1).fill(items[0].grid_area);
- } else {
- const splitArr = this.splitInteger(needNum, items.length);
- rowAreas = items.flatMap((item, index) => Array(splitArr[index] + 1).fill(item.grid_area));
- }
- }
- gridTemplateAreas += `'${rowAreas.join(' ')}' `;
- });
- // 计算 grid_template_columns
- const maxRowItems = rowMap.get(curMaxRow) || [];
- const gridTemplateColumns = maxRowItems.length ? `${maxRowItems.map((item) => item.width).join(' ')} ` : '';
- // 计算 grid_template_rows
- const previewById = new Map();
- (this.$refs.preview || []).forEach((child) => {
- const id = child?.$el?.dataset?.id;
- if (id) {
- previewById.set(id, child);
- }
- });
- const hasOperationById = (id) => {
- const component = previewById.get(id);
- return Boolean(component && component.$el.querySelector('.operation'));
- };
- const toNumberHeight = (height) => {
- const num = Number(String(height).replace('px', ''));
- return Number.isFinite(num) ? num : NaN;
- };
- let gridTemplateRows = '';
- rowMap.forEach((items) => {
- if (items.length === 1) {
- const current = items[0];
- if (current.height === 'auto') {
- gridTemplateRows += 'auto ';
- return;
- }
- let baseHeight = toNumberHeight(current.height);
- if (Number.isNaN(baseHeight)) {
- gridTemplateRows += `${current.height} `;
- return;
- }
- if (hasOperationById(current.id)) {
- baseHeight += 48;
- }
- gridTemplateRows += `${baseHeight}px `;
- return;
- }
- const nonAutoItems = items.filter((item) => item.height !== 'auto');
- if (nonAutoItems.length === 0) {
- gridTemplateRows += 'auto ';
- return;
- }
- let maxItem = null;
- let maxHeight = 0;
- nonAutoItems.forEach((item) => {
- const current = toNumberHeight(item.height);
- if (!Number.isNaN(current) && current > maxHeight) {
- maxHeight = current;
- maxItem = item;
- }
- });
- if (maxItem && hasOperationById(maxItem.id)) {
- maxHeight += 48;
- }
- gridTemplateRows += `${maxHeight}px `;
- });
- return {
- width: col.width,
- gridTemplateAreas,
- gridTemplateColumns,
- gridTemplateRows,
- };
- },
- handleContextMenu(event, id) {
- if (this.canRemark) {
- event.preventDefault(); // 阻止默认的上下文菜单显示
- this.menuPosition = {
- x: event.clientX - this.divPosition.left,
- y: event.clientY - this.divPosition.top,
- }; // 设置菜单位置
- this.componentId = id;
- this.$emit('computeScroll');
- }
- },
- handleResult(top, left, select_node) {
- this.menuPosition.x += left;
- this.menuPosition.y += top;
- this.menuPosition.select_node = select_node;
- // 设置菜单位置
- this.showMenu = true; // 显示菜单
- },
- handleMenuItemClick(event, type) {
- this.showMenu = false; // 隐藏菜单
- let text = '';
- if (type && type === 'tool') {
- let info = this.selectHandleInfo;
- this.menuPosition = {
- x: event.clientX - this.divPosition.left,
- y: event.clientY - this.divPosition.top - 20,
- }; // 设置菜单位置
- this.componentId = info.blockId;
- text = info.text;
- this.showMenu = false;
- } else {
- this.componentId = this.getElementFromPoint(
- (this.menuPosition.startX + this.menuPosition.endX) / 2,
- (this.menuPosition.startY + this.menuPosition.endY) / 2,
- );
- }
- this.$emit('computeScroll');
- setTimeout(() => {
- this.$emit(
- 'addRemark',
- this.menuPosition.select_node,
- this.menuPosition.startX,
- this.menuPosition.startY,
- this.menuPosition.endX,
- this.menuPosition.endY,
- this.componentId,
- text,
- );
- }, 10);
- },
- handleMouseDown(event) {
- if (event.button === 0 && event.target.className !== 'custom-context-menu') {
- // 0 表示左键
- this.showMenu = false;
- }
- },
- /**
- * 查找子组件
- * @param {string} id 组件的唯一标识符
- * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
- */
- async findChildComponentByKey(id) {
- await this.$nextTick();
- if (!this.$refs.preview) {
- // 最多等待 1000ms
- for (let i = 0; i < 20; i++) {
- await this.$nextTick();
- await new Promise((resolve) => setTimeout(resolve, 50));
- if (this.$refs.preview) break;
- }
- }
- // 如果等待后还是不存在,那就返回null
- if (!this.$refs.preview) {
- console.error('$refs.preview 不存在');
- return null;
- }
- return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
- },
- /**
- * 模拟回答
- * @param {boolean} isJudgingRightWrong 是否判断对错
- * @param {boolean} isShowRightAnswer 是否显示正确答案
- * @param {boolean} disabled 是否禁用
- */
- simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true) {
- this.$refs.preview.forEach((item) => {
- item.showAnswer(isJudgingRightWrong, isShowRightAnswer, null, disabled);
- });
- },
- /**
- * 处理组件高度变化事件
- * @param {string} id 组件id
- * @param {string} newHeight 组件的新高度
- */
- handleHeightChange(id, newHeight) {
- this.data.row_list.forEach((row) => {
- row.col_list.forEach((col) => {
- col.grid_list.forEach((grid) => {
- if (grid.id === id) {
- grid.height = newHeight;
- }
- });
- });
- });
- },
- // 处理选中文本
- handleTextSelection() {
- this.showToolbar = false;
- // 延迟处理,确保选择已完成
- setTimeout(() => {
- const selection = window.getSelection();
- if (selection.toString().trim() === '') return null;
- const selectedText = selection.toString().trim();
- const range = selection.getRangeAt(0);
- this.selectedInfo = {
- text: selectedText,
- range,
- };
- let selectHandleInfo = this.getSelectionInfo();
- if (!selectHandleInfo || !selectHandleInfo.text) return;
- this.selectHandleInfo = selectHandleInfo;
- if (!this.canRemark) this.showToolbar = true;
- const container = document.querySelector('.courseware');
- const boxRect = container.getBoundingClientRect();
- const selectRect = range.getBoundingClientRect();
- this.contentmenu = {
- left: `${Math.round(selectRect.left - boxRect.left + selectRect.width / 2 - 63)}px`,
- top: `${Math.round(selectRect.top - boxRect.top + selectRect.height)}px`, // 向上偏移10px
- };
- }, 100);
- if (this.canRemark) {
- this.endSelection();
- }
- },
- // 笔记
- setNote() {
- this.showToolbar = false;
- this.oldRichData = {};
- let info = this.selectHandleInfo;
- if (!info) return;
- info.coursewareId = this.courseware_id;
- this.$emit('editNote', info);
- this.selectedInfo = null;
- },
- // 加入收藏
- setCollect() {
- this.showToolbar = false;
- let info = this.selectHandleInfo;
- if (!info) return;
- info.coursewareId = this.courseware_id;
- this.$emit('saveCollect', info);
- this.selectedInfo = null;
- },
- // 翻译
- setTranslate() {
- this.showToolbar = false;
- let info = this.selectHandleInfo;
- if (!info) return;
- info.coursewareId = this.courseware_id;
- this.$emit('getTranslate', info);
- this.selectedInfo = null;
- },
- // 反馈
- setFeedback() {
- this.showToolbar = false;
- this.oldRichData = {};
- let info = this.selectHandleInfo;
- if (!info) return;
- info.coursewareId = this.courseware_id;
- this.$emit('editFeedback', info);
- this.selectedInfo = null;
- },
- // 定位
- handleLocation(item) {
- this.scrollToDataId(item.blockId);
- },
- getSelectionInfo() {
- if (!this.selectedInfo) return;
- const range = this.selectedInfo.range;
- let selectedText = this.selectedInfo.text;
- if (!selectedText) return null;
- let commonAncestor = range.commonAncestorContainer;
- if (commonAncestor.nodeType === Node.TEXT_NODE) {
- commonAncestor = commonAncestor.parentNode;
- }
- const blockElement = commonAncestor.closest('[data-id]');
- if (!blockElement) return null;
- const blockId = blockElement.dataset.id;
- // 获取所有汉字元素
- const charElements = blockElement.querySelectorAll('.py-char,.rich-text,.NNPE-chs');
- // 构建包含位置信息的文本数组
- const textFragments = Array.from(charElements)
- .map((el, index) => {
- let text = '';
- if (el.classList.contains('rich-text')) {
- const pElements = Array.from(el.querySelectorAll('p'));
- text = pElements.map((p) => p.textContent.trim()).join('');
- } else if (el.classList.contains('NNPE-chs')) {
- const spanElements = Array.from(el.querySelectorAll('span'));
- spanElements.push(el);
- text = spanElements.map((span) => span.textContent.trim()).join('');
- } else {
- text = el.textContent.trim();
- }
- // 过滤掉拼音和空文本
- if (!text || /^[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]+$/i.test(text)) {
- return { text: '', element: el, index };
- }
- return { text, element: el, index };
- })
- .filter((fragment) => fragment.text);
- // 获取完整的纯文本
- const fullText = textFragments.map((f) => f.text).join('');
- // 清理选中文本
- let cleanSelectedText = selectedText.replace(/\n/g, '').trim();
- cleanSelectedText = cleanSelectedText.replace(/[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]/gi, '').trim();
- if (!cleanSelectedText) return null;
- // 方案1A:使用Range的边界点精确定位
- try {
- const startContainer = range.startContainer;
- const startOffset = range.startOffset;
- // 找到选择开始的元素在textFragments中的位置
- let startFragmentIndex = -1;
- let cumulativeLength = 0;
- let startIndexInFullText = -1;
- for (let i = 0; i < textFragments.length; i++) {
- const fragment = textFragments[i];
- // 检查这个元素是否包含选择起点
- if (fragment.element.contains(startContainer) || fragment.element === startContainer) {
- // 计算在这个元素内的起始位置
- if (startContainer.nodeType === Node.TEXT_NODE) {
- // 如果是文本节点,需要计算在父元素中的偏移
- const elementText = fragment.text;
- startFragmentIndex = i;
- startIndexInFullText = cumulativeLength + Math.min(startOffset, elementText.length);
- break;
- } else {
- // 如果是元素节点,从0开始
- startFragmentIndex = i;
- startIndexInFullText = cumulativeLength;
- break;
- }
- }
- cumulativeLength += fragment.text.length;
- }
- if (startIndexInFullText === -1) {
- // 如果精确定位失败,回退到文本匹配(但使用更智能的匹配)
- return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
- }
- const endIndexInFullText = startIndexInFullText + cleanSelectedText.length;
- return {
- blockId,
- text: cleanSelectedText,
- startIndex: startIndexInFullText,
- endIndex: endIndexInFullText,
- fullText,
- };
- } catch (error) {
- console.warn('精确位置计算失败,使用备选方案:', error);
- return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
- }
- },
- // 备选方案:基于DOM位置的智能匹配
- fallbackToTextMatch(fullText, selectedText, range, textFragments) {
- // 获取选择范围的近似位置
- const rangeRect = range.getBoundingClientRect();
- // 找到最接近选择中心的文本片段
- let closestFragment = null;
- let minDistance = Infinity;
- textFragments.forEach((fragment) => {
- const rect = fragment.element.getBoundingClientRect();
- if (rect.width > 0 && rect.height > 0) {
- // 确保元素可见
- const centerX = rect.left + rect.width / 2;
- const centerY = rect.top + rect.height / 2;
- const rangeCenterX = rangeRect.left + rangeRect.width / 2;
- const rangeCenterY = rangeRect.top + rangeRect.height / 2;
- const distance = Math.sqrt(Math.pow(centerX - rangeCenterX, 2) + Math.pow(centerY - rangeCenterY, 2));
- if (distance < minDistance) {
- minDistance = distance;
- closestFragment = fragment;
- }
- }
- });
- if (closestFragment) {
- // 从最近的片段开始向前后搜索匹配
- const fragmentIndex = textFragments.indexOf(closestFragment);
- let cumulativeLength = 0;
- // 计算到当前片段的累计长度
- for (let i = 0; i < fragmentIndex; i++) {
- cumulativeLength += textFragments[i].text.length;
- }
- // 在当前片段附近搜索匹配
- const searchStart = Math.max(0, cumulativeLength - selectedText.length * 3);
- const searchEnd = Math.min(
- fullText.length,
- cumulativeLength + closestFragment.text.length + selectedText.length * 3,
- );
- const searchArea = fullText.substring(searchStart, searchEnd);
- const localIndex = searchArea.indexOf(selectedText);
- if (localIndex !== -1) {
- return {
- startIndex: searchStart + localIndex,
- endIndex: searchStart + localIndex + selectedText.length,
- text: selectedText,
- fullText,
- };
- }
- }
- // 最终回退:使用所有匹配位置,选择最合理的一个
- const allMatches = [];
- let searchIndex = 0;
- while ((searchIndex = fullText.indexOf(selectedText, searchIndex)) !== -1) {
- allMatches.push(searchIndex);
- searchIndex += selectedText.length;
- }
- if (allMatches.length === 1) {
- return {
- startIndex: allMatches[0],
- endIndex: allMatches[0] + selectedText.length,
- text: selectedText,
- fullText,
- };
- } else if (allMatches.length > 1) {
- // 如果有多个匹配,选择位置最接近选择中心的
- if (closestFragment) {
- let cumulativeLength = 0;
- let fragmentStartIndex = 0;
- for (let i = 0; i < textFragments.length; i++) {
- if (textFragments[i] === closestFragment) {
- fragmentStartIndex = cumulativeLength;
- break;
- }
- cumulativeLength += textFragments[i].text.length;
- }
- // 选择最接近当前片段起始位置的匹配
- const bestMatch = allMatches.reduce((best, current) => {
- return Math.abs(current - fragmentStartIndex) < Math.abs(best - fragmentStartIndex) ? current : best;
- });
- return {
- startIndex: bestMatch,
- endIndex: bestMatch + selectedText.length,
- text: selectedText,
- fullText,
- };
- }
- }
- return null;
- },
- /**
- * 滚动到指定data-id的元素
- * @param {string} dataId 元素的data-id属性值
- * @param {number} offset 偏移量
- */
- scrollToDataId(dataId, offset) {
- let _offset = offset;
- if (!_offset) _offset = 0;
- const element = document.querySelector(`div[data-id="${dataId}"]`);
- if (element) {
- element.scrollIntoView({
- behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
- block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
- inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
- });
- }
- },
- startSelection(event) {
- if (this.canRemark) {
- this.isSelecting = true;
- let clientRect = document.getElementById(`selectable-area-preview`).getBoundingClientRect();
- this.menuPosition.startX = event.clientX - clientRect.left;
- this.menuPosition.startY = event.clientY - clientRect.top;
- this.menuPosition.endX = null;
- this.menuPosition.endY = null;
- }
- },
- updateSelection(event) {
- if (!this.isSelecting || !this.canRemark) return;
- let clientRect = document.getElementById(`selectable-area-preview`).getBoundingClientRect();
- this.menuPosition.endX = event.clientX - clientRect.left;
- this.menuPosition.endY = event.clientY - clientRect.top;
- },
- endSelection() {
- this.isSelecting = false;
- if (this.menuPosition.startX === this.menuPosition.endX || !this.menuPosition.endX || !this.canRemark) return;
- const width = this.menuPosition.endX - this.menuPosition.startX;
- const height = this.menuPosition.endY - this.menuPosition.startY;
- const x =
- this.menuPosition.endX > this.menuPosition.startX
- ? `${this.menuPosition.startX}px`
- : `${this.menuPosition.endX}px`;
- const y =
- this.menuPosition.endY > this.menuPosition.startY
- ? `${this.menuPosition.startY}px`
- : `${this.menuPosition.endY}px`;
- if (width > 3 && height > 3) {
- this.handleMenuItemClick();
- } else {
- this.resetRemark();
- }
- },
- // 重置框选数据
- resetRemark() {
- this.menuPosition = { x: 0, y: 0, select_node: '', startX: null, startY: null, endX: null, endY: null };
- },
- // 下载文件
- downLoad(file) {
- let userInfo = getToken();
- let AccessToken = '';
- if (userInfo) {
- AccessToken = userInfo.access_token;
- }
- let FileID = file.file_id;
- let data = {
- AccessToken,
- FileID,
- };
- location.href = `${process.env.VUE_APP_EEP}/FileServer/WebFileDownload?AccessToken=${data.AccessToken}&FileID=${data.FileID}`;
- },
- // 预览
- viewDialog(file) {
- this.newpath = `${this.file_preview_url}onlinePreview?url=${Base64.encode(file.file_url)}`;
- this.visible = true;
- },
- /**
- * 根据x,y坐标获取组件id,x,y坐标相对于.courserware元素
- * @param {number} x x坐标
- * @param {number} y y坐标
- * @return {string|null} 组件id,如果没有找到则返回null
- */
- getElementFromPoint(x, y) {
- const courserwareRect = this.$el.getBoundingClientRect();
- const absoluteX = courserwareRect.left + x;
- const absoluteY = courserwareRect.top + y;
- let el = document.elementFromPoint(absoluteX, absoluteY);
- // 向上查找,直到找到具有 data-id 属性和 grid 类的元素
- while (el && (!el.dataset.id || !el.classList.contains('grid'))) {
- el = el.parentElement;
- }
- return el ? el.dataset.id : null;
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- .courseware {
- position: relative;
- display: flex;
- flex-direction: column;
- row-gap: $component-spacing;
- width: 100%;
- height: 100%;
- min-height: calc(100vh - 226px);
- padding-top: $courseware-top-padding;
- padding-bottom: $courseware-bottom-padding;
- margin: 15px 0;
- background-repeat: no-repeat;
- border-bottom-right-radius: 12px;
- border-bottom-left-radius: 12px;
- &::before {
- top: -15px;
- }
- &::after {
- bottom: -15px;
- }
- .row {
- display: grid;
- gap: $component-spacing;
- .col {
- display: grid;
- gap: $component-spacing;
- .active {
- box-shadow: 0 0 6px 1px $main-hover-color;
- }
- }
- .row-checkbox {
- position: absolute;
- left: -20px;
- }
- }
- .custom-context-menu,
- .remark-info {
- position: absolute;
- z-index: 999;
- display: flex;
- gap: 3px;
- align-items: center;
- font-size: 14px;
- cursor: pointer;
- }
- .custom-context-menu {
- padding-left: 30px;
- background: url('../../../../assets/icon-publish.png') left center no-repeat;
- background-size: 24px;
- }
- .contentmenu {
- position: absolute;
- z-index: 999;
- display: flex;
- column-gap: 4px;
- align-items: center;
- padding: 8px;
- font-size: 14px;
- color: #000;
- background-color: #e7e7e7;
- border-radius: 4px;
- box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 30%);
- .svg-icon,
- .button {
- cursor: pointer;
- }
- .line {
- min-height: 16px;
- margin: 0 4px;
- }
- }
- }
- </style>
- <style lang="scss">
- .menu-remark-info {
- min-width: 2%;
- max-width: 450px;
- video,
- img {
- max-width: 100%;
- height: auto;
- }
- audio {
- max-width: 100%;
- }
- }
- .remark-file-item {
- display: flex;
- gap: 5px;
- align-items: center;
- padding: 3px;
- margin: 5px 0;
- background: #f2f3f5;
- border-radius: 3px;
- &-name {
- flex: 1;
- font-size: 12px;
- word-break: break-all;
- }
- .svg-icon {
- flex-shrink: 0;
- font-size: 16px;
- }
- .uploadPreview,
- .download {
- cursor: pointer;
- }
- }
- </style>
|