|
|
@@ -1,5 +1,5 @@
|
|
|
<template>
|
|
|
- <div ref="courserware" class="courserware" :style="computedCourserwareStyle()">
|
|
|
+ <div ref="courserware" class="courserware" :style="computedCourserwareStyle()" @mouseup="handleTextSelection">
|
|
|
<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
|
|
|
@@ -68,6 +68,12 @@
|
|
|
</template>
|
|
|
</div>
|
|
|
</template>
|
|
|
+ <!-- 选中文本的工具栏 -->
|
|
|
+ <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
|
|
|
+ <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>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -87,6 +93,10 @@ export default {
|
|
|
type: Object,
|
|
|
default: () => ({}),
|
|
|
},
|
|
|
+ coursewareId: {
|
|
|
+ type: String,
|
|
|
+ default: '',
|
|
|
+ },
|
|
|
background: {
|
|
|
type: Object,
|
|
|
default: () => ({}),
|
|
|
@@ -127,6 +137,7 @@ export default {
|
|
|
data() {
|
|
|
return {
|
|
|
previewComponentList,
|
|
|
+ courseware_id: this.coursewareId,
|
|
|
bookInfo: {
|
|
|
theme_color: '',
|
|
|
},
|
|
|
@@ -138,6 +149,12 @@ export default {
|
|
|
menuPosition: { x: 0, y: 0, select_node: '' }, // 用于存储菜单的位置
|
|
|
componentId: '', // 添加批注的组件id
|
|
|
rowCheckList: {},
|
|
|
+ showToolbar: false,
|
|
|
+ contentmenu: {
|
|
|
+ left: 0,
|
|
|
+ top: 0,
|
|
|
+ },
|
|
|
+ selectedInfo: null,
|
|
|
};
|
|
|
},
|
|
|
watch: {
|
|
|
@@ -152,6 +169,11 @@ export default {
|
|
|
}, {});
|
|
|
},
|
|
|
},
|
|
|
+ coursewareId: {
|
|
|
+ handler(val) {
|
|
|
+ this.courseware_id = val;
|
|
|
+ },
|
|
|
+ },
|
|
|
},
|
|
|
mounted() {
|
|
|
const element = this.$refs.courserware;
|
|
|
@@ -411,6 +433,285 @@ export default {
|
|
|
});
|
|
|
});
|
|
|
},
|
|
|
+ // 处理选中文本
|
|
|
+ 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);
|
|
|
+ console.info(selectedText, range);
|
|
|
+ this.showToolbar = true;
|
|
|
+ const container = document.querySelector('.courserware');
|
|
|
+ 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
|
|
|
+ };
|
|
|
+
|
|
|
+ this.selectedInfo = {
|
|
|
+ text: selectedText,
|
|
|
+ range,
|
|
|
+ };
|
|
|
+ }, 100);
|
|
|
+ },
|
|
|
+ // 笔记
|
|
|
+ setNote() {
|
|
|
+ this.showToolbar = false;
|
|
|
+ this.oldRichData = {};
|
|
|
+ let info = this.getSelectionInfo();
|
|
|
+ if (!info) return;
|
|
|
+ info.coursewareId = this.courseware_id;
|
|
|
+
|
|
|
+ this.$emit('editNote', info);
|
|
|
+ this.selectedInfo = null;
|
|
|
+ },
|
|
|
+ // 加入收藏
|
|
|
+ setCollect() {
|
|
|
+ this.showToolbar = false;
|
|
|
+
|
|
|
+ let info = this.getSelectionInfo();
|
|
|
+ if (!info) return;
|
|
|
+ info.coursewareId = this.courseware_id;
|
|
|
+
|
|
|
+ this.$emit('saveCollect', info);
|
|
|
+ this.selectedInfo = null;
|
|
|
+ },
|
|
|
+ // 定位
|
|
|
+ handLocation(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) {
|
|
|
+ const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
|
|
|
+ const offsetPosition = elementPosition - _offset;
|
|
|
+
|
|
|
+ element.scrollIntoView({
|
|
|
+ behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
|
|
|
+ block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
|
|
|
+ inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
},
|
|
|
};
|
|
|
</script>
|
|
|
@@ -483,5 +784,29 @@ export default {
|
|
|
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>
|