PinyinText.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062
  1. <!-- eslint-disable vue/no-v-html -->
  2. <template>
  3. <div class="pinyin-area" :style="{ 'text-align': pinyinOverallPosition, padding: pinyinPadding }">
  4. <AudioPlay
  5. v-if="isEnable(isEnableVoice)"
  6. :file-id="audioFileId"
  7. :theme-color="unifiedAttrib && unifiedAttrib.topic_color ? unifiedAttrib.topic_color : ''"
  8. />
  9. <!-- 新规则:使用 richTextList -->
  10. <div v-if="richTextList && richTextList.length > 0" class="rich-text-container" :style="bodyStyles">
  11. <template v-for="(block, index) in parsedBlocks">
  12. <!-- 文字块:包含 word_list -->
  13. <span
  14. v-if="block.type === 'text'"
  15. :key="'text-' + index"
  16. class="pinyin-sentence"
  17. :style="[
  18. { 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' },
  19. block.sentenceStyle
  20. ]"
  21. >
  22. <span v-for="(word, wIndex) in block.word_list" :key="wIndex" class="pinyin-text" :style="block.styleObj">
  23. <span
  24. :title="isPreview ? '' : '点击校对'"
  25. :style="{
  26. cursor: isPreview ? '' : 'pointer',
  27. }"
  28. @click="!isPreview && handleWordClick(word, block.paragraphIndex, block.oldIndex, wIndex)"
  29. @mouseover="isPreview && handlePreviewHover(word, $event)"
  30. @mouseleave="onLeave"
  31. >
  32. <span
  33. v-if="word.pinyin_list && word.pinyin_list.length < Array.from(word.text).length"
  34. :style="{
  35. flexDirection: pinyinPosition === 'top' ? 'column' : 'column-reverse',
  36. 'align-items': getWordAlignItems(word, block),
  37. }"
  38. >
  39. <span v-if="shouldShowWordPinyin(word)" class="pinyin" :style="getPinyinStyle(word, block)">
  40. {{ getPinyinText(word) }}
  41. </span>
  42. <span class="py-char" :style="getCharStyle(word, block)">{{ convertText(word.text) }}</span>
  43. </span>
  44. <template v-else>
  45. <span v-for="(char, cIndex) in Array.from(word.text)" :key="cIndex">
  46. <span
  47. :style="{
  48. flexDirection: pinyinPosition === 'top' ? 'column' : 'column-reverse',
  49. 'align-items': getWordAlignItems(word, block),
  50. }"
  51. >
  52. <span v-if="shouldShowWordPinyin(word)" class="pinyin" :style="getPinyinStyle(word, block)">
  53. {{ getCharPinyin(word, cIndex) }}
  54. </span>
  55. <span class="py-char" :style="getCharStyle(word, block, cIndex)">{{ convertText(char) }}</span>
  56. </span>
  57. </span>
  58. </template>
  59. </span>
  60. </span>
  61. </span>
  62. <!-- 图片块 -->
  63. <span
  64. v-else-if="block.type === 'image'"
  65. :key="'image-' + index"
  66. :style="block.containerStyle"
  67. class="image-container"
  68. >
  69. <img
  70. :src="block.src"
  71. :alt="block.alt"
  72. :width="block.width"
  73. :height="block.height"
  74. :style="block.imageStyle"
  75. class="inline-image"
  76. />
  77. </span>
  78. <!-- 视频块 -->
  79. <span
  80. v-else-if="block.type === 'video'"
  81. :key="'video-' + index"
  82. :style="block.containerStyle"
  83. class="video-container"
  84. >
  85. <video
  86. :poster="block.poster"
  87. :width="block.width"
  88. :height="block.height"
  89. :style="block.mediaStyle"
  90. controls
  91. class="inline-video"
  92. >
  93. <source :src="block.src" :type="block.videoType" />
  94. 您的浏览器不支持视频播放
  95. </video>
  96. </span>
  97. <!-- 音频块 -->
  98. <span
  99. v-else-if="block.type === 'audio'"
  100. :key="'audio-' + index"
  101. :style="block.containerStyle"
  102. class="audio-container"
  103. >
  104. <audio :src="block.src" :style="block.mediaStyle" controls class="inline-audio">
  105. 您的浏览器不支持音频播放
  106. </audio>
  107. </span>
  108. <!-- 数学公式块(保留 MathJax 原始结构) -->
  109. <MathHtml
  110. v-else-if="block.type === 'math'"
  111. :key="'math-' + index"
  112. :html="block.html"
  113. :style-obj="block.containerStyle"
  114. :is-display="block.isDisplay"
  115. />
  116. <!-- 换行符 -->
  117. <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
  118. </template>
  119. </div>
  120. <!-- 老规则:使用 paragraphList -->
  121. <template v-else-if="paragraphList && paragraphList.length > 0">
  122. <div v-for="(paragraph, pIndex) in paragraphList" :key="pIndex" class="pinyin-paragraph" :style="bodyStyles">
  123. <div
  124. v-for="(sentence, sIndex) in paragraph"
  125. :key="sIndex"
  126. class="pinyin-sentence"
  127. :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }"
  128. >
  129. <span v-for="(word, wIndex) in sentence" :key="wIndex" class="pinyin-text">
  130. <span
  131. :title="isPreview ? '' : '点击校对'"
  132. :style="{
  133. cursor: isPreview ? '' : 'pointer',
  134. 'align-items': wIndex == 0 ? 'flex-start' : 'center',
  135. }"
  136. @click="!isPreview && handleWordClick(word, block.paragraphIndex, block.oldIndex, wIndex)"
  137. @mouseover="isPreview && handlePreviewHover(word, $event)"
  138. @mouseleave="onLeave"
  139. >
  140. <span
  141. v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)"
  142. class="pinyin"
  143. :style="{ 'font-size': pinyinSize }"
  144. >
  145. {{ getPinyinText(word) }}</span
  146. >
  147. <span class="py-char" :style="textStyle(word)">{{ convertText(word.text) }}</span>
  148. <span
  149. v-if="pinyinPosition !== 'top' && hasPinyinInParagraph(sIndex)"
  150. class="pinyin"
  151. :style="{ 'font-size': pinyinSize }"
  152. >
  153. {{ getPinyinText(word) }}</span
  154. >
  155. </span>
  156. </span>
  157. </div>
  158. </div>
  159. </template>
  160. <CorrectPinyin
  161. :visible.sync="visible"
  162. :new-version="newVersion"
  163. :select-content="selectContent"
  164. :component-type="componentType"
  165. @fillTonePinyin="fillTonePinyin"
  166. />
  167. <!-- 气泡弹窗 -->
  168. <el-popover
  169. ref="notePopover"
  170. :popper-options="{ boundariesElement: 'viewport' }"
  171. placement="bottom-start"
  172. trigger="manual"
  173. >
  174. <div class="popover-content" v-html="sanitizeHTML(note)"></div>
  175. </el-popover>
  176. </div>
  177. </template>
  178. <script>
  179. import CorrectPinyin from '@/views/book/courseware/create/components/base/common/CorrectPinyin.vue';
  180. import { sanitizeHTML } from '@/utils/common';
  181. import { isEnable } from '@/views/book/courseware/data/common';
  182. import AudioPlay from '@/views/book/courseware/preview/components/character_base/components/AudioPlay.vue';
  183. // MathHtml 组件用于渲染数学公式的 HTML 内容,保留 MathJax 的原始结构
  184. const MathHtml = {
  185. name: 'MathHtml',
  186. functional: true,
  187. props: {
  188. html: {
  189. type: String,
  190. default: '',
  191. },
  192. styleObj: {
  193. type: Object,
  194. default: () => ({}),
  195. },
  196. isDisplay: {
  197. type: Boolean,
  198. default: false,
  199. },
  200. },
  201. render(h, context) {
  202. return h('span', {
  203. class: {
  204. 'math-container': true,
  205. 'math-display': context.props.isDisplay,
  206. },
  207. style: context.props.styleObj,
  208. domProps: {
  209. innerHTML: sanitizeHTML(context.props.html || ''),
  210. },
  211. });
  212. },
  213. };
  214. export default {
  215. name: 'PinyinText',
  216. components: {
  217. CorrectPinyin,
  218. AudioPlay,
  219. MathHtml,
  220. },
  221. inject: ['convertText'],
  222. props: {
  223. isEnableVoice: {
  224. type: String,
  225. default: 'false',
  226. },
  227. audioFileId: {
  228. type: String,
  229. default: '',
  230. },
  231. unifiedAttrib: {
  232. type: Object,
  233. default: () => ({}),
  234. },
  235. paragraphList: {
  236. type: Array,
  237. default: () => [],
  238. },
  239. richTextList: {
  240. type: Array,
  241. default: () => [],
  242. },
  243. richTextNoteList: {
  244. type: Array,
  245. default: () => [],
  246. },
  247. pinyinPosition: {
  248. type: String,
  249. required: true,
  250. },
  251. fontFamily: {
  252. type: String,
  253. default: '',
  254. },
  255. fontSize: {
  256. type: String,
  257. default: '12pt',
  258. },
  259. pinyinSize: {
  260. type: String,
  261. default: '12pt',
  262. },
  263. pinyinOverallPosition: {
  264. type: String,
  265. default: 'left',
  266. },
  267. isPreview: {
  268. type: Boolean,
  269. default: false,
  270. },
  271. componentType: {
  272. type: String,
  273. default: '',
  274. },
  275. pinyinPadding: {
  276. type: String,
  277. default: '0px 0px 0px 0px',
  278. },
  279. bodyStyles: {
  280. type: Object,
  281. default: () => ({}),
  282. },
  283. isShowPinyin: {
  284. type: Boolean,
  285. default: true,
  286. },
  287. },
  288. data() {
  289. return {
  290. isEnable,
  291. sanitizeHTML,
  292. visible: false,
  293. selectContent: {},
  294. paragraph_index: 0,
  295. sentence_index: 0,
  296. word_index: 0,
  297. note: '',
  298. isAllSetting: false,
  299. };
  300. },
  301. computed: {
  302. newVersion() {
  303. return this.richTextList && this.richTextList.length > 0;
  304. },
  305. styleWatch() {
  306. return {
  307. fontSize: this.fontSize,
  308. fontFamily: this.fontFamily,
  309. };
  310. },
  311. // 预处理 richTextList,合并分散的视频标签
  312. normalizedRichTextList() {
  313. if (!this.richTextList || this.richTextList.length === 0) return [];
  314. const result = [];
  315. let i = 0;
  316. while (i < this.richTextList.length) {
  317. const item = this.richTextList[i];
  318. // 检查是否是视频开始标签(且不包含结束标签)
  319. if (
  320. item.text &&
  321. typeof item.text === 'string' &&
  322. item.text.includes('<video') &&
  323. !item.text.includes('</video>')
  324. ) {
  325. let combinedText = item.text;
  326. let j = i + 1;
  327. // 向后查找 source 和结束标签,直到遇到 </video> 或超出范围
  328. while (j < this.richTextList.length) {
  329. const nextItem = this.richTextList[j];
  330. if (nextItem.text) {
  331. combinedText += `${nextItem.text}`;
  332. }
  333. // 如果找到了结束标签,合并完成,跳出内部循环
  334. if (nextItem.text && nextItem.text.includes('</video>')) {
  335. j += 1; // 跳过结束标签
  336. break;
  337. }
  338. j += 1;
  339. }
  340. // 将合并后的内容作为一个新项加入结果
  341. result.push({
  342. ...item,
  343. text: combinedText,
  344. is_merged_video: true, // 标记一下,可选
  345. });
  346. // 更新主循环索引,跳过已合并的项
  347. i = j;
  348. } else {
  349. // 其他项直接加入
  350. result.push(item);
  351. i += 1;
  352. }
  353. }
  354. return result;
  355. },
  356. // 解析 richTextList 为可渲染的块
  357. parsedBlocks() {
  358. const listToParse = this.normalizedRichTextList;
  359. if (!listToParse || listToParse.length === 0) return [];
  360. const blocks = [];
  361. let textBlockIndex = 0;
  362. let oldIndex = -1;
  363. let paragraphIndex = 0;
  364. const tagStack = [];
  365. let mathContext = null;
  366. for (const item of listToParse) {
  367. oldIndex += 1;
  368. if (mathContext) {
  369. mathContext.html += this.getRawItemText(item);
  370. if (item.is_style === 'true' || item.is_style === true) {
  371. this.updateHtmlTagStack(mathContext.stack, item.text || '');
  372. if (mathContext.stack.length === 0) {
  373. blocks.push(this.parseMathBlock(mathContext.html, tagStack));
  374. mathContext = null;
  375. }
  376. }
  377. continue;
  378. }
  379. if (this.isMathRootTag(item)) {
  380. const rootTag = this.extractTagName(item.text || '');
  381. mathContext = {
  382. html: this.getRawItemText(item),
  383. stack: rootTag ? [rootTag] : ['span'],
  384. };
  385. continue;
  386. }
  387. if (item.text && typeof item.text === 'string' && item.text.includes('<img')) {
  388. blocks.push(this.parseImageBlock(item, tagStack));
  389. } else if (item.text && typeof item.text === 'string' && item.text.includes('<video')) {
  390. // item.text 是完整的 video 标签串
  391. blocks.push(this.parseVideoBlock(item, tagStack));
  392. } else if (item.text && typeof item.text === 'string' && item.text.includes('<audio')) {
  393. blocks.push(this.parseAudioBlock(item, tagStack));
  394. } else if (item.is_style === 'true' || item.is_style === true) {
  395. if (item.text.includes('<br')) {
  396. blocks.push({ type: 'newline' });
  397. paragraphIndex += 1;
  398. } else {
  399. this.handleStyleTag(item, tagStack);
  400. }
  401. } else if (item.text === '\n') {
  402. blocks.push({ type: 'newline' });
  403. paragraphIndex += 1;
  404. } else if (item.word_list && item.word_list.length > 0) {
  405. const hasEmphasisDot = this.checkHasEmphasisDot(tagStack);
  406. blocks.push(this.createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot));
  407. textBlockIndex += 1;
  408. }
  409. }
  410. if (mathContext) {
  411. blocks.push(this.parseMathBlock(mathContext.html, tagStack));
  412. }
  413. return blocks;
  414. },
  415. },
  416. watch: {
  417. styleWatch: {
  418. handler() {
  419. this.isAllSetting = true;
  420. },
  421. immediate: true,
  422. deep: true,
  423. },
  424. },
  425. methods: {
  426. // 统一处理点击事件
  427. handleWordClick(item, pIndex, sIndex, wIndex) {
  428. if (item) {
  429. this.visible = true;
  430. this.selectContent = item;
  431. this.paragraph_index = pIndex;
  432. this.sentence_index = sIndex;
  433. this.word_index = wIndex;
  434. }
  435. },
  436. // 预览模式:悬停触发气泡
  437. handlePreviewHover(item, event) {
  438. if (!item) return;
  439. let noteContent = '';
  440. // 查找注释内容
  441. if (item.annota_id && this.richTextNoteList) {
  442. const noteItem = this.richTextNoteList.find((p) => p.id === item.annota_id);
  443. if (noteItem) {
  444. noteContent = noteItem.dataStr || noteItem.note || '';
  445. }
  446. } else if (item.note) {
  447. noteContent = item.note;
  448. }
  449. if (noteContent) {
  450. this.note = noteContent;
  451. const refEl = this.$refs.notePopover;
  452. refEl.referenceElm = event.target;
  453. refEl.showPopper = true;
  454. // 强制重新计算定位
  455. if (refEl.updatePopper) {
  456. refEl.updatePopper();
  457. }
  458. }
  459. },
  460. onLeave() {
  461. this.$refs.notePopover.showPopper = false;
  462. },
  463. // 检查标签栈中是否有着重点样式
  464. checkHasEmphasisDot(tagStack) {
  465. return tagStack.some((tagItem) => {
  466. if (!tagItem.className) return false;
  467. return tagItem.className.includes('rich-text-emphasis-dot');
  468. });
  469. },
  470. // 解析图片块
  471. parseImageBlock(item, tagStack) {
  472. const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
  473. if (!imgMatch) return null;
  474. const attrs = imgMatch[1];
  475. const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
  476. const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
  477. const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
  478. const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
  479. const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
  480. const containerStyleObj = {};
  481. tagStack.forEach((tagItem) => {
  482. if (tagItem.style) {
  483. this.mergeStyleString(containerStyleObj, tagItem.style);
  484. }
  485. });
  486. const imageStyleObj = {};
  487. if (styleMatch) {
  488. this.mergeStyleString(imageStyleObj, styleMatch[1]);
  489. }
  490. return {
  491. type: 'image',
  492. src: srcMatch ? srcMatch[1] : '',
  493. alt: altMatch ? altMatch[1] : '',
  494. width: widthMatch ? widthMatch[1] : null,
  495. height: heightMatch ? heightMatch[1] : null,
  496. containerStyle: containerStyleObj,
  497. imageStyle: imageStyleObj,
  498. };
  499. },
  500. // 解析视频块
  501. parseVideoBlock(item, tagStack) {
  502. const videoMatch = item.text.match(/<video\s+([^>]*)>/i);
  503. if (!videoMatch) return null;
  504. const attrs = videoMatch[1];
  505. const posterMatch = attrs.match(/poster=["']([^"']*)["']/i);
  506. const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
  507. const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
  508. const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
  509. // 直接从完整的文本中匹配 source
  510. const sourceMatch = item.text.match(/<source\s+([^>]*)\/?\s*>/i);
  511. let src = '';
  512. let videoType = '';
  513. if (sourceMatch) {
  514. const sourceAttrs = sourceMatch[1];
  515. const srcMatch = sourceAttrs.match(/src\s*=\s*["']?([^"'\s>]+)["']?/i);
  516. const typeMatch = sourceAttrs.match(/type\s*=\s*["']?([^"'\s>]+)["']?/i);
  517. src = srcMatch ? srcMatch[1] : '';
  518. videoType = typeMatch ? typeMatch[1] : '';
  519. }
  520. const containerStyleObj = {};
  521. tagStack.forEach((tagItem) => {
  522. if (tagItem.style) {
  523. this.mergeStyleString(containerStyleObj, tagItem.style);
  524. }
  525. });
  526. const mediaStyleObj = {};
  527. if (styleMatch) {
  528. this.mergeStyleString(mediaStyleObj, styleMatch[1]);
  529. }
  530. return {
  531. type: 'video',
  532. poster: posterMatch ? posterMatch[1] : '',
  533. width: widthMatch ? widthMatch[1] : null,
  534. height: heightMatch ? heightMatch[1] : null,
  535. src,
  536. videoType,
  537. containerStyle: containerStyleObj,
  538. mediaStyle: mediaStyleObj,
  539. };
  540. },
  541. // 解析音频块
  542. parseAudioBlock(item, tagStack) {
  543. const audioMatch = item.text.match(/<audio\s+([^>]*)>/i);
  544. if (!audioMatch) return null;
  545. const attrs = audioMatch[1];
  546. const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
  547. const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
  548. const containerStyleObj = {};
  549. tagStack.forEach((tagItem) => {
  550. if (tagItem.style) {
  551. this.mergeStyleString(containerStyleObj, tagItem.style);
  552. }
  553. });
  554. const mediaStyleObj = {};
  555. if (styleMatch) {
  556. this.mergeStyleString(mediaStyleObj, styleMatch[1]);
  557. }
  558. return {
  559. type: 'audio',
  560. src: srcMatch ? srcMatch[1] : '',
  561. containerStyle: containerStyleObj,
  562. mediaStyle: mediaStyleObj,
  563. };
  564. },
  565. // 合并样式字符串到对象
  566. mergeStyleString(styleObj, styleStr) {
  567. styleStr.split(';').forEach((rule) => {
  568. if (rule.trim()) {
  569. const [prop, value] = rule.split(':').map((s) => s.trim());
  570. if (prop && value) {
  571. const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
  572. styleObj[camelProp] = value;
  573. }
  574. }
  575. });
  576. },
  577. // 处理样式标签
  578. handleStyleTag(item, tagStack) {
  579. const tagText = item.text || '';
  580. const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
  581. if (closeTagMatch) {
  582. const tagName = closeTagMatch[1];
  583. this.removeTagFromStack(tagStack, tagName);
  584. } else {
  585. const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
  586. if (openTagMatch) {
  587. const tagName = openTagMatch[1];
  588. const attrs = openTagMatch[2];
  589. const { style, className } = this.extractTagInfo(attrs, tagName);
  590. tagStack.push({ tag: tagName, style, className });
  591. }
  592. }
  593. },
  594. // 提取标签信息(样式和类名)
  595. extractTagInfo(attrs, tagName) {
  596. const styleMatch = attrs.match(/style=["']([^"']*)["']/);
  597. let style = styleMatch ? styleMatch[1] : null;
  598. const classMatch = attrs.match(/class=["']([^"']*)["']/);
  599. const className = classMatch ? classMatch[1] : '';
  600. if (!style) {
  601. style = this.getDefaultTagStyle(tagName);
  602. }
  603. return { style, className };
  604. },
  605. // 获取标签默认样式
  606. getDefaultTagStyle(tagName) {
  607. const tagStyleMap = {
  608. strong: 'font-weight: bold',
  609. b: 'font-weight: bold',
  610. em: 'font-style: italic',
  611. i: 'font-style: italic',
  612. u: 'text-decoration: underline',
  613. s: 'text-decoration: line-through',
  614. del: 'text-decoration: line-through',
  615. sub: 'vertical-align: sub',
  616. sup: 'vertical-align: super',
  617. };
  618. return tagStyleMap[tagName] || null;
  619. },
  620. // 创建文本块
  621. createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot) {
  622. const combinedStyle = tagStack
  623. .filter((tagItem) => tagItem.style)
  624. .map((tagItem) => tagItem.style)
  625. .join(';');
  626. const styleObj = combinedStyle ? this.parseStyleToObject(combinedStyle) : null;
  627. // 提取 paddingLeft 用于段落缩进,从 styleObj 中移除
  628. const sentenceStyle = {};
  629. if (styleObj && styleObj.paddingLeft) {
  630. sentenceStyle.paddingLeft = styleObj.paddingLeft;
  631. delete styleObj.paddingLeft;
  632. }
  633. return {
  634. type: 'text',
  635. word_list: item.word_list,
  636. index: textBlockIndex,
  637. oldIndex,
  638. paragraphIndex,
  639. styleObj,
  640. sentenceStyle,
  641. rawStyleText: combinedStyle,
  642. hasEmphasisDot,
  643. };
  644. },
  645. // 解析样式字符串为对象
  646. parseStyleToObject(styleStr) {
  647. const styleObj = {};
  648. if (!styleStr) return styleObj;
  649. this.mergeStyleString(styleObj, styleStr);
  650. return styleObj;
  651. },
  652. parseMathBlock(html, tagStack) {
  653. const containerStyleObj = {};
  654. tagStack.forEach((tagItem) => {
  655. if (tagItem.style) {
  656. this.mergeStyleString(containerStyleObj, tagItem.style);
  657. }
  658. });
  659. const isDisplay =
  660. /<mjx-container[^>]*\bdisplay=["']true["']/i.test(html) ||
  661. /<mjx-assistive-mml[^>]*\bdisplay=["']block["']/i.test(html);
  662. return {
  663. type: 'math',
  664. html,
  665. containerStyle: containerStyleObj,
  666. isDisplay,
  667. };
  668. },
  669. isMathRootTag(item) {
  670. if (!item || !(item.is_style === 'true' || item.is_style === true) || !item.text) return false;
  671. return /class=["'][^"']*\bmathjax-container\b[^"']*\beditor-math\b[^"']*["']/i.test(item.text);
  672. },
  673. getRawItemText(item) {
  674. if (!item) return '';
  675. return item.text || '';
  676. },
  677. extractTagName(tagText) {
  678. const match = (tagText || '').match(/^<\/?\s*([a-zA-Z][\w-]*)/);
  679. return match ? match[1].toLowerCase() : '';
  680. },
  681. updateHtmlTagStack(tagStack, tagText) {
  682. if (!tagText || !tagStack) return;
  683. const tagName = this.extractTagName(tagText);
  684. if (!tagName) return;
  685. if (/^<\//.test(tagText)) {
  686. for (let i = tagStack.length - 1; i >= 0; i--) {
  687. if (tagStack[i] === tagName) {
  688. tagStack.splice(i, 1);
  689. break;
  690. }
  691. }
  692. } else if (!/\/>\s*$/.test(tagText)) {
  693. tagStack.push(tagName);
  694. }
  695. },
  696. getBlockColor(block) {
  697. const colorFromObject = block?.styleObj?.color;
  698. if (colorFromObject) return colorFromObject;
  699. const styleText = block?.rawStyleText || '';
  700. const colorMatch = styleText.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
  701. return colorMatch ? colorMatch[1].trim() : '';
  702. },
  703. // 获取单个字符的样式(包括着重点)
  704. getCharStyle(word, block) {
  705. const baseStyle = { ...word.activeTextStyle };
  706. baseStyle['font-size'] = baseStyle.fontSize;
  707. baseStyle['font-family'] = baseStyle.fontFamily;
  708. const blockColor = this.getBlockColor(block);
  709. if (blockColor) {
  710. baseStyle.color = `${blockColor} !important`;
  711. }
  712. if (this.isAllSetting) {
  713. baseStyle['font-size'] = this.fontSize;
  714. baseStyle['font-family'] = this.fontFamily;
  715. this.isAllSetting = false;
  716. }
  717. // 如果该文字块有着重点标记,应用到字符上
  718. if (block.hasEmphasisDot) {
  719. const letterSpacing = block.styleObj?.letterSpacing || '0';
  720. const letterSpacingValue = parseFloat(letterSpacing) || 0;
  721. baseStyle['border-bottom'] = 'none';
  722. baseStyle['background-image'] = 'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)';
  723. baseStyle['background-repeat'] = 'repeat-x';
  724. baseStyle['background-position'] = `${letterSpacingValue * -0.5}em 100%`;
  725. baseStyle['background-size'] = `${1 + letterSpacingValue}em 0.3em`;
  726. baseStyle['padding-bottom'] = '0.3em';
  727. baseStyle['display'] = 'inline';
  728. }
  729. return baseStyle;
  730. },
  731. // 兼容历史数据
  732. getPinyinText(item) {
  733. return this.checkShowPinyin(item.showPinyin) ? item.pinyin.replace(/\s+/g, '') : '\u200B';
  734. },
  735. shouldShowWordPinyin(item) {
  736. return this.checkShowPinyin(item && item.showPinyin);
  737. },
  738. // 拼音与汉字一一对应
  739. getCharPinyin(word, charIndex) {
  740. if (!this.checkShowPinyin(word.showPinyin)) {
  741. return '\u200B';
  742. }
  743. // 优先使用新的 pinyin_list 字段(与字符一一对应)
  744. if (word.pinyin_list && Array.isArray(word.pinyin_list)) {
  745. return word.pinyin_list[charIndex] || '\u200B';
  746. }
  747. // 兼容旧数据:使用 pinyin 字段
  748. const pinyinList = word.pinyin ? word.pinyin.trim().split(/\s+/) : [];
  749. return pinyinList[charIndex] || '\u200B';
  750. },
  751. // 如果汉字有字间距,拼音与汉字左对齐,如果没有则居中对齐
  752. getWordAlignItems(word, block) {
  753. const letterSpacing = block.styleObj?.letterSpacing;
  754. if (letterSpacing && letterSpacing !== '0' && letterSpacing !== '0px') {
  755. return 'flex-start';
  756. }
  757. return 'center';
  758. },
  759. // 拼音固定为拼音字体,跟随汉字的字号、颜色、粗细的样式
  760. getPinyinStyle(item, block) {
  761. const styles = {};
  762. // 固定使用 League 字体
  763. styles['font-family'] = 'League';
  764. // 只继承字号和颜色
  765. if (item.activeTextStyle) {
  766. if (item.activeTextStyle.fontSize) {
  767. styles['font-size'] = item.activeTextStyle.fontSize;
  768. }
  769. if (item.activeTextStyle.color) {
  770. styles['color'] = item.activeTextStyle.color;
  771. }
  772. }
  773. const blockColor = this.getBlockColor(block);
  774. if (blockColor) {
  775. styles['color'] = `${blockColor} !important`;
  776. }
  777. // 如果设置了全局字号,优先使用全局字号
  778. if (this.isAllSetting && this.fontSize) {
  779. styles['font-size'] = this.fontSize;
  780. this.isAllSetting = false;
  781. }
  782. // 优先取文字单独的字号
  783. let _fontSize = block.styleObj && block.styleObj.fontSize ? block.styleObj.fontSize : null;
  784. // 如果没有,取继承的字号
  785. if (!_fontSize && item.activeTextStyle && item.activeTextStyle.fontSize) {
  786. _fontSize = item.activeTextStyle.fontSize;
  787. }
  788. // 如果没有,取组件的字号
  789. if (!_fontSize && this.bodyStyles.fontSize) _fontSize = this.bodyStyles.fontSize;
  790. // 如果没有,取全局字号
  791. if (!_fontSize && this.fontSize) _fontSize = this.fontSize;
  792. if (_fontSize) {
  793. // 取出字号里面的数字,做计算处理之后,自动加上原来的单位
  794. const fontSizeMatch = _fontSize.match(/^([\d.]+)(\D*)$/);
  795. if (fontSizeMatch) {
  796. const fontSizeValue = parseFloat(fontSizeMatch[1]);
  797. const fontSizeUnit = fontSizeMatch[2] || 'px';
  798. // 根据字号大小确定比例:16号及以下75%,16号以上60%
  799. const ratio = fontSizeValue <= 16 ? 0.75 : 0.6;
  800. const calculatedSize = Math.round(fontSizeValue * ratio);
  801. // 保留原始单位
  802. styles['font-size'] = `${calculatedSize}${fontSizeUnit}`;
  803. }
  804. }
  805. // 明确重置不应该继承的样式
  806. styles['text-decoration'] = 'none';
  807. styles['font-style'] = 'normal';
  808. styles['letter-spacing'] = '0';
  809. return styles;
  810. },
  811. removeTagFromStack(tagStack, tagName) {
  812. for (let i = tagStack.length - 1; i >= 0; i--) {
  813. if (tagStack[i].tag === tagName) {
  814. tagStack.splice(i, 1);
  815. break;
  816. }
  817. }
  818. },
  819. textStyle(item) {
  820. const styles = { ...item.activeTextStyle };
  821. styles['font-size'] = styles.fontSize;
  822. styles['font-family'] = styles.fontFamily;
  823. if (this.isAllSetting) styles['font-size'] = this.fontSize;
  824. if (this.isAllSetting) styles['font-family'] = this.fontFamily;
  825. this.isAllSetting = false;
  826. return styles;
  827. },
  828. // 回填校对后的拼音
  829. fillTonePinyin(dataContent) {
  830. this.$emit('fillCorrectPinyin', {
  831. selectContent: dataContent,
  832. i: this.paragraph_index,
  833. j: this.sentence_index,
  834. k: this.word_index,
  835. newVersion: this.newVersion,
  836. });
  837. },
  838. // 如段落有拼音,则返回true ,否则不显示拼音行
  839. hasPinyinInParagraph(paragraphIndex) {
  840. if (!this.isShowPinyin) {
  841. return false;
  842. }
  843. const paragraph = this.paragraphList[paragraphIndex];
  844. return paragraph.some((sentence) => sentence.some((item) => this.checkShowPinyin(item.showPinyin)));
  845. },
  846. // 兼容历史数据,没有 showPinyin 字段的,则默认为 true
  847. checkShowPinyin(showPinyin) {
  848. if (!this.isShowPinyin) {
  849. return false;
  850. }
  851. if (showPinyin === undefined || showPinyin === null) {
  852. return true;
  853. }
  854. return this.isEnable(showPinyin);
  855. },
  856. },
  857. };
  858. </script>
  859. <style lang="scss" scoped>
  860. .pinyin-area {
  861. // 新规则的容器样式
  862. .rich-text-container {
  863. p {
  864. display: inline-flex;
  865. flex-wrap: wrap;
  866. margin: 0.5em 0;
  867. }
  868. :deep(.mathjax-container.editor-math > svg[aria-hidden='true']) {
  869. display: none !important;
  870. }
  871. .math-container.math-display {
  872. display: block !important;
  873. width: 100%;
  874. text-align: center;
  875. }
  876. .math-container.math-display :deep(mjx-container[display='true']) {
  877. display: block !important;
  878. margin: 0 auto;
  879. text-align: center;
  880. }
  881. span {
  882. display: inline-flex;
  883. }
  884. .pinyin-sentence {
  885. display: inline;
  886. white-space: normal;
  887. }
  888. .image-container {
  889. display: inline-block;
  890. .inline-image {
  891. display: inline-block;
  892. max-width: 100%;
  893. height: auto;
  894. }
  895. }
  896. .video-container {
  897. display: inline-block;
  898. margin: 26px 5px 26px 0;
  899. .inline-video {
  900. display: inline-block;
  901. max-width: 100%;
  902. height: auto;
  903. }
  904. }
  905. .audio-container {
  906. display: block;
  907. .inline-audio {
  908. display: inline-block;
  909. width: 100%;
  910. max-width: 500px;
  911. }
  912. }
  913. }
  914. .pinyin-paragraph {
  915. .pinyin-sentence {
  916. display: inline;
  917. white-space: normal;
  918. }
  919. }
  920. .pinyin-text {
  921. display: inline-flex;
  922. padding: 0 2px;
  923. text-wrap: pretty;
  924. hanging-punctuation: allow-end;
  925. > span {
  926. display: inline-flex;
  927. }
  928. .py-char {
  929. ruby-align: center;
  930. line-height: 1em;
  931. }
  932. .pinyin {
  933. display: inline-block;
  934. min-height: 12px;
  935. font-family: 'League';
  936. line-height: 1em;
  937. text-decoration: none !important;
  938. // 防止继承父元素的删除线
  939. text-decoration-skip-ink: none;
  940. }
  941. }
  942. :deep(.annotation-popover) {
  943. z-index: 9999 !important; /* 确保气泡在最上层 */
  944. min-width: 200px;
  945. max-width: 400px;
  946. padding: 12px;
  947. border-radius: 8px;
  948. box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
  949. }
  950. }
  951. </style>
  952. <style lang="scss">
  953. .pinyin-area + .pinyin-area {
  954. margin-top: 4px;
  955. }
  956. </style>