RichTextPreview.vue 6.2 KB

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