RichTextPreview.vue 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. <!-- eslint-disable vue/no-v-html -->
  2. <template>
  3. <div :class="['describe-preview']" :style="[getAreaStyle(), getComponentStyle()]">
  4. <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
  5. <div class="main">
  6. <div ref="leftDiv" :style="{ width: data.note_list?.length > 0 ? '' : '100%' }">
  7. <PinyinText
  8. v-if="isEnable(data.property.view_pinyin)"
  9. :is-enable-voice="data.property.is_enable_voice"
  10. :audio-file-id="data.audio_file_id"
  11. :unified-attrib="data.unified_attrib"
  12. :paragraph-list="data.paragraph_list"
  13. :rich-text-list="data.rich_text_list"
  14. :rich-text-note-list="data.note_list"
  15. :pinyin-position="data.property.pinyin_position"
  16. :pinyin-overall-position="data.property.pinyin_overall_position"
  17. :pinyin-size="data?.unified_attrib?.pinyin_size"
  18. :font-size="data?.unified_attrib?.font_size"
  19. :font-family="data?.unified_attrib?.font"
  20. :pinyin-padding="data.property.pinyin_padding"
  21. :is-preview="isPreview"
  22. />
  23. <div v-else>
  24. <AudioPlay
  25. v-if="isEnable(data.property.is_enable_voice)"
  26. :file-id="data.audio_file_id"
  27. :theme-color="data.unified_attrib && data.unified_attrib.topic_color ? data.unified_attrib.topic_color : ''"
  28. />
  29. <span
  30. class="rich-text"
  31. @mouseover="handleRichFillClick"
  32. @mouseleave="onLeave"
  33. v-html="convertText(sanitizeHTML(data.content))"
  34. ></span>
  35. </div>
  36. </div>
  37. <div v-show="showLang" class="lang">
  38. {{ data.multilingual?.find((item) => item.type === getLang())?.translation }}
  39. </div>
  40. </div>
  41. <el-popover
  42. ref="notePopover"
  43. :popper-options="{ boundariesElement: 'viewport' }"
  44. placement="bottom-start"
  45. trigger="manual"
  46. >
  47. <div class="popover-content" v-html="sanitizeHTML(selectedNote)"></div>
  48. </el-popover>
  49. </div>
  50. </template>
  51. <script>
  52. import { getRichTextData } from '@/views/book/courseware/data/richText';
  53. import PreviewMixin from '../common/PreviewMixin';
  54. import { isEnable } from '@/views/book/courseware/data/common';
  55. import PinyinText from '@/components/PinyinText.vue';
  56. import AudioPlay from '../character_base/components/AudioPlay.vue';
  57. import { getRandomNumber } from '@/utils';
  58. export default {
  59. name: 'RichTextPreview',
  60. components: { PinyinText, AudioPlay },
  61. mixins: [PreviewMixin],
  62. data() {
  63. return {
  64. isEnable,
  65. data: getRichTextData(),
  66. isPreview: true,
  67. divHeight: 'auto',
  68. observer: null,
  69. selectedNote: '',
  70. };
  71. },
  72. mounted() {
  73. this.observer = new ResizeObserver(() => {
  74. this.updateHeight();
  75. });
  76. this.observer.observe(this.$refs.leftDiv.closest('.describe-preview'));
  77. },
  78. beforeDestroy() {
  79. this.observer.disconnect();
  80. },
  81. methods: {
  82. handleRichFillClick(event) {
  83. const targetSpan = event.target.closest('.rich-fill');
  84. if (!targetSpan) {
  85. return;
  86. }
  87. const annotationId = targetSpan.getAttribute('data-annotation-id');
  88. if (!annotationId) return;
  89. const noteItem = this.data.note_list.find((item) => item.id === annotationId || item.annota_id === annotationId);
  90. if (noteItem) {
  91. this.selectedNote = noteItem.note || noteItem.dataStr || '';
  92. // 关键:将 popover 的参考元素移动到被点击的文字位置
  93. // 这样 popover 就会自动围绕这个文字显示
  94. this.$nextTick(() => {
  95. const refEl = this.$refs.notePopover;
  96. refEl.referenceElm = targetSpan;
  97. refEl.showPopper = true;
  98. // 强制重新计算定位
  99. if (refEl.updatePopper) {
  100. refEl.updatePopper();
  101. }
  102. });
  103. }
  104. },
  105. onLeave() {
  106. this.$refs.notePopover.showPopper = false;
  107. },
  108. updateHeight() {
  109. this.$nextTick(() => {
  110. const target = this.$refs.leftDiv;
  111. const parent = target.closest('.describe-preview');
  112. if (!parent) return;
  113. setTimeout(() => {
  114. this.divHeight = parent.offsetHeight - 16;
  115. }, 800);
  116. });
  117. },
  118. /**
  119. * 获取无文本内容的数据结构,用于保存为个人模板时的样式模板
  120. */
  121. getNoTextContentData() {
  122. let noTextContentData = JSON.parse(JSON.stringify(this.data));
  123. const resetFieldMap = {
  124. paragraph_list: [],
  125. rich_text_list: [],
  126. content: '',
  127. audio_file_id: '',
  128. note_list: [],
  129. analysis_list: [],
  130. answer_list: [],
  131. };
  132. Object.assign(noTextContentData, resetFieldMap);
  133. if (noTextContentData.answer) {
  134. noTextContentData.answer.answer_list = [];
  135. noTextContentData.answer.reference_answer = '';
  136. }
  137. return noTextContentData;
  138. },
  139. },
  140. };
  141. </script>
  142. <style lang="scss" scoped>
  143. @use '@/styles/mixin.scss' as *;
  144. .describe-preview {
  145. @include preview-base;
  146. &.middle {
  147. margin-top: calc($title-content-spacing - $component-spacing);
  148. margin-bottom: calc($title-content-spacing - $component-spacing);
  149. }
  150. &.top {
  151. margin-bottom: calc($title-content-spacing - $component-spacing);
  152. }
  153. &.bottom {
  154. margin-top: calc($title-content-spacing - $component-spacing);
  155. }
  156. :deep .el-dialog {
  157. position: fixed;
  158. margin: 0 !important;
  159. transition: all 0.2s;
  160. .el-dialog__header {
  161. padding: 0 !important;
  162. .el-dialog__headerbtn {
  163. top: 6px !important;
  164. right: 6px !important;
  165. }
  166. }
  167. .el-dialog__body {
  168. padding: 10px !important;
  169. }
  170. }
  171. }
  172. </style>