Browse Source

笔记与收藏

zq 2 weeks ago
parent
commit
24fdb314bb

+ 50 - 0
src/api/book.js

@@ -282,3 +282,53 @@ export function ApplyBookUnifiedAttrib(data) {
 export function GetBookUnifiedAttrib(data) {
   return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-GetBookUnifiedAttrib`, data);
 }
+
+/**
+ *@description 添加我的笔记
+ * @param {object} data  
+ */
+export function AddMyNote(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-AddMyNote`, data);
+}
+/**
+ *@description 更新我的笔记
+ * @param {object} data  
+ */
+export function UpdateMyNote(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-UpdateMyNote`, data);
+}
+/**
+ * @description 得到我的笔记列表
+ * @param {object} data 
+ */
+export function GetMyNoteList(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-GetMyNoteList`, data);
+}
+/**
+ * @description 删除我的笔记
+ * @param {object} data 
+ */
+export function DeleteMyNote(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-DeleteMyNote`, data);
+}
+/**
+ *@description 添加我的收藏
+ * @param {object} data  
+ */
+export function AddMyCollect(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-AddMyCollect`, data);
+}
+/**
+ * @description 得到我的收藏列表
+ * @param {object} data 
+ */
+export function GetMyCollectList(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-GetMyCollectList`, data);
+}
+/**
+ * @description 删除我的收藏
+ * @param {object} data 
+ */
+export function DeleteMyCollect(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-DeleteMyCollect`, data);
+}

+ 269 - 2
src/components/CommonPreview.vue

@@ -77,6 +77,7 @@
             :group-show-all="groupShowAll"
             :group-row-list="content_group_row_list"
             :data="data"
+            :courseware-id="curSelectId"
             :component-list="component_list"
             :background="background"
             :can-remark="isTrue(courseware_info.is_my_audit_task) && isTrue(courseware_info.is_can_add_audit_remark)"
@@ -85,6 +86,8 @@
             :project="project"
             @computeScroll="computeScroll"
             @addRemark="addRemark"
+            @editNote="handEditNote"
+            @saveCollect="saveCollect"
           />
           <div class="preview-right"></div>
         </main>
@@ -171,6 +174,40 @@
             <p v-if="loading">加载中...</p>
             <p v-if="noMore">没有更多了</p>
           </div>
+          <div v-if="curToolbarIcon === 'note'" class="resource_box">
+            <h5>{{ drawerTitle }}</h5>
+            <div style="height: 40px"></div>
+            <ul v-if="allNoteList.length > 0" class="card-box">
+              <li v-for="item in allNoteList" :key="item.id">
+                <span class="el-icon-notebook-2"> 原文</span>
+                <span>{{ item.text }}</span>
+                <el-divider class="mt10" />
+                <span v-html="item.note"></span>
+                <div class="remark-bottom">
+                  <el-button type="text" class="el-icon-edit" @click="handEditNote(item)"> 编辑</el-button>
+                  <el-divider direction="vertical" />
+                  <el-button type="text" class="el-icon-delete" @click="handDelNote(item.id)"> 删除</el-button>
+                  <el-divider direction="vertical" />
+                  <el-button type="text" class="el-icon-place" @click="handLocation(item, 1)"> 定位</el-button>
+                </div>
+              </li>
+            </ul>
+          </div>
+          <div v-if="curToolbarIcon === 'collect'" class="resource_box">
+            <h5>{{ drawerTitle }}</h5>
+            <div style="height: 40px"></div>
+            <ul v-if="allCottectList.length > 0" class="card-box">
+              <li v-for="item in allCottectList" :key="item.id">
+                <span class="el-icon-notebook-2"> 原文</span>
+                <span>{{ item.text }}</span>
+                <div class="remark-bottom">
+                  <el-button type="text" class="el-icon-delete" @click="handDelCollect(item.id)"> 删除</el-button>
+                  <el-divider direction="vertical" />
+                  <el-button type="text" class="el-icon-place" @click="handLocation(item, 2)"> 定位</el-button>
+                </div>
+              </li>
+            </ul>
+          </div>
         </div>
 
         <div class="back-top" @click="backTop">
@@ -211,6 +248,15 @@
         @child-click="handleNodeClick"
       />
     </el-dialog>
+
+    <ExplanatoryNoteDialog
+      ref="explanatoryNote"
+      :open.sync="editDialogOpen"
+      :init-data="oldRichData"
+      title-text="笔记"
+      @confirm="saveNote"
+      @cancel="delNote"
+    />
   </div>
 </template>
 
@@ -222,6 +268,7 @@ import MindMap from '@/components/MindMap.vue';
 import VideoPlay from '@/views/book/courseware/preview/components/common/VideoPlay.vue';
 import AudioPlay from '@/views/book/courseware/preview/components/common/AudioPlay.vue';
 import AuditRemark from '@/components/AuditRemark.vue';
+import ExplanatoryNoteDialog from '@/components/ExplanatoryNoteDialog.vue';
 import * as OpenCC from 'opencc-js';
 
 import {
@@ -240,6 +287,13 @@ import {
   PageQueryBookResourceList,
   GetLanguageTypeList,
   GetBookUnifiedAttrib,
+  GetMyNoteList,
+  DeleteMyNote,
+  AddMyNote,
+  UpdateMyNote,
+  AddMyCollect,
+  GetMyCollectList,
+  DeleteMyCollect,
 } from '@/api/book';
 
 export default {
@@ -251,6 +305,7 @@ export default {
     AudioPlay,
     VideoPlay,
     AuditRemark,
+    ExplanatoryNoteDialog,
   },
   provide() {
     return {
@@ -289,11 +344,11 @@ export default {
       { icon: 'mindmap', title: '思维导图', handle: 'openMindMap', param: {} },
       // { icon: 'knowledge', title: '知识图谱', handle: '', param: {} },
       // { icon: 'totalResources', title: '总资源', handle: '', param: {} },
-      // { icon: 'collect', title: '收藏', handle: '', param: {} },
+      { icon: 'collect', title: '收藏', handle: 'getCollect', param: { type: '3' } },
       { icon: 'audio', title: '音频', handle: 'openDrawer', param: { type: '1' } },
       { icon: 'image', title: '图片', handle: 'openDrawer', param: { type: '0' } },
       { icon: 'video', title: '视频', handle: 'openDrawer', param: { type: '2' } },
-      // { icon: 'note', title: '笔记', handle: '', param: {} },
+      { icon: 'note', title: '笔记', handle: 'getNote', param: { type: '4' } },
       // { icon: 'translate', title: '翻译', handle: '', param: {} },
       // { icon: 'setting', title: '设置', handle: '', param: {} },
     ];
@@ -374,6 +429,11 @@ export default {
         cover_image_file_id: null, // 封面图片ID
         cover_image_file_url: '', // 封面图片URL
       },
+      allNoteList: [],
+      editDialogOpen: false,
+      oldRichData: {},
+      newSelectedInfo: null,
+      allCottectList: [],
     };
   },
   computed: {
@@ -390,6 +450,8 @@ export default {
         0: '图片资源',
         1: '音频资源',
         2: '视频资源',
+        3: '收藏列表',
+        4: '笔记列表',
       };
       return titleMap[this.drawerType] || '资源列表';
     },
@@ -404,6 +466,13 @@ export default {
     isShowAnswer() {
       this.simulateAnswer();
     },
+    curSelectId() {
+      if (this.curToolbarIcon == 'note') {
+        this.getNote();
+      } else if (this.curToolbarIcon == 'collect') {
+        this.getCollect();
+      }
+    },
   },
   created() {
     if (this.id) {
@@ -805,6 +874,187 @@ export default {
         behavior: 'smooth',
       });
     },
+    handLocation(item, type) {
+      if (this.$refs.courserware && this.$refs.courserware.handLocation) {
+        item.type = type;
+        this.$refs.courserware.handLocation(item);
+      }
+    },
+    async getNote(params) {
+      if (params && params.type) this.drawerType = Number(params.type);
+      this.allNoteList = [];
+      await GetMyNoteList({ courseware_id: this.curSelectId }).then((res) => {
+        if (res.status === 1) {
+          res.note_list.forEach((x) => {
+            if (x.note_desc) {
+              let n = JSON.parse(x.note_desc);
+              let obj = {
+                coursewareId: x.courseware_id,
+                id: x.id,
+                blockId: n.blockId,
+                startIndex: n.startIndex,
+                endIndex: n.endIndex,
+                text: n.text,
+                note: n.note,
+              };
+              this.allNoteList.push(obj);
+            }
+          });
+        }
+      });
+    },
+    async handEditNote(note) {
+      this.oldRichData = {};
+      if (this.allNoteList.length === 0) {
+        await this.getNote();
+      }
+      let old = this.allNoteList.find(
+        (x) =>
+          x.coursewareId === note.coursewareId &&
+          x.blockId === note.blockId &&
+          x.startIndex === note.startIndex &&
+          x.endIndex === note.endIndex,
+      );
+      if (old) {
+        this.oldRichData = old;
+      }
+      this.newSelectedInfo = note;
+      this.editDialogOpen = true;
+    },
+    saveNote(note) {
+      let noteInfo = {
+        blockId: this.newSelectedInfo.blockId,
+        startIndex: this.newSelectedInfo.startIndex,
+        endIndex: this.newSelectedInfo.endIndex,
+        note: note.note,
+        text: this.newSelectedInfo.text,
+      };
+
+      let reqData = {
+        courseware_id: this.newSelectedInfo.coursewareId, // 课件 ID
+        component_id: 'WHOLE',
+        note_desc: JSON.stringify(noteInfo), // 位置描述
+      };
+      if (note.id) {
+        if (!noteInfo.note) {
+          this.delNote(note.id);
+          return;
+        }
+        let upDate = {
+          id: note.id,
+          note_desc: reqData.note_desc,
+        };
+        UpdateMyNote(upDate).then(() => {
+          this.getNote();
+        });
+      } else {
+        AddMyNote(reqData).then(() => {
+          this.getNote();
+        });
+      }
+      this.editDialogOpen = false;
+      this.newSelectedInfo = null;
+      this.selectedInfo = null;
+    },
+    handDelNote(id) {
+      this.$confirm('确定要删除此条笔记吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      })
+        .then(() => {
+          this.delNote(id);
+        })
+        .catch(() => {});
+    },
+    delNote(id) {
+      if (!id) {
+        if (!this.oldRichData || !this.oldRichData.id) return;
+        id = this.oldRichData.id;
+      }
+      DeleteMyNote({ id }).then(() => {
+        this.allNoteList = this.allNoteList.filter((x) => x.id !== id);
+      });
+    },
+
+    async getCollect(params) {
+      if (params && params.type) this.drawerType = Number(params.type);
+      this.allCottectList = [];
+      await GetMyCollectList({ courseware_id: this.curSelectId }).then((res) => {
+        if (res.status === 1) {
+          res.collect_list.forEach((x) => {
+            if (x.collect_desc) {
+              let n = JSON.parse(x.collect_desc);
+              let obj = {
+                coursewareId: x.courseware_id,
+                id: x.id,
+                blockId: n.blockId,
+                startIndex: n.startIndex,
+                endIndex: n.endIndex,
+                text: n.text,
+              };
+              this.allCottectList.push(obj);
+            }
+          });
+        }
+      });
+    },
+    async saveCollect(collect) {
+      if (this.allCottectList.length === 0) {
+        await this.getCollect();
+      }
+
+      let old = this.allCottectList.find(
+        (x) =>
+          x.coursewareId === collect.coursewareId &&
+          x.blockId === collect.blockId &&
+          x.startIndex === collect.startIndex &&
+          x.endIndex === collect.endIndex,
+      );
+      if (old) {
+        this.$message({
+          dangerouslyUseHTMLString: true,
+          message: "<i class='el-icon-check' />已收藏",
+        });
+        return;
+      }
+      this.newSelectedInfo = collect;
+      let collectInfo = {
+        blockId: this.newSelectedInfo.blockId,
+        startIndex: this.newSelectedInfo.startIndex,
+        endIndex: this.newSelectedInfo.endIndex,
+        text: this.newSelectedInfo.text,
+      };
+
+      let reqData = {
+        courseware_id: this.newSelectedInfo.coursewareId, // 课件 ID
+        component_id: 'WHOLE',
+        collect_desc: JSON.stringify(collectInfo), // 位置描述
+      };
+      AddMyCollect(reqData)
+        .then(() => {
+          this.getCollect();
+        })
+        .then(() => {
+          this.$message({
+            dangerouslyUseHTMLString: true,
+            message: "<i class='el-icon-check' />已收藏",
+          });
+        });
+    },
+    handDelCollect(id) {
+      this.$confirm('确定要删除此条收藏吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      })
+        .then(() => {
+          DeleteMyCollect({ id: id }).then(() => {
+            this.allCottectList = this.allCottectList.filter((x) => x.id !== id);
+          });
+        })
+        .catch(() => {});
+    },
   },
 };
 </script>
@@ -1216,6 +1466,18 @@ $total-width: $courseware-width + $courseware-left-margin + $courseware-right-ma
             color: #999;
             text-align: center;
           }
+
+          .card-box li {
+            padding: 10px;
+            border-bottom: 1px solid #ccc;
+
+            .el-icon-notebook-2 {
+              display: block;
+              margin-bottom: 4px;
+              font-size: 12px;
+              color: grey;
+            }
+          }
         }
       }
 
@@ -1269,6 +1531,11 @@ $total-width: $courseware-width + $courseware-left-margin + $courseware-right-ma
     }
   }
 }
+
+.mt10 {
+  margin: 10px 0 0 !important;
+  background-color: #eee;
+}
 </style>
 
 <style lang="scss">

+ 6 - 2
src/components/ExplanatoryNoteDialog.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-dialog title="编辑注释" :visible.sync="visible" width="680px" @close="dialogClose()">
+  <el-dialog :title="'编辑'+titleText" :visible.sync="visible" width="680px" @close="dialogClose()" :close-on-click-modal="false">
     <RichText
       v-model="richData.note"
       toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright"
@@ -33,6 +33,10 @@ export default {
       type: Object,
       default: () => ({}),
     },
+    titleText:{
+      type:String,
+      default:'注释'
+    }
   },
   data() {
     return {
@@ -58,7 +62,7 @@ export default {
       this.visible = false;
     },
     deleteNote() {
-      this.$confirm('确定要删除此条注释吗?', '提示', {
+      this.$confirm('确定要删除此条'+this.titleText+'吗?', '提示', {
         confirmButtonText: '确定',
         cancelButtonText: '取消',
         type: 'warning',

+ 322 - 1
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -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,281 @@ 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 endContainer = range.endContainer;
+        const startOffset = range.startOffset;
+        const endOffset = range.endOffset;
+
+        // 找到选择开始的元素在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;
+    },
+    scrollToDataId(dataId, 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 +780,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>