PinyinText.vue 31 KB

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