PinyinText.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. <template>
  2. <div class="pinyin-area" :style="{ 'text-align': pinyinOverallPosition, padding: pinyinPadding }">
  3. <AudioPlay
  4. v-if="isEnable(isEnableVoice)"
  5. :file-id="audioFileId"
  6. :theme-color="unifiedAttrib && unifiedAttrib.topic_color ? unifiedAttrib.topic_color : ''"
  7. />
  8. <!-- 新规则:使用 richTextList -->
  9. <div v-if="richTextList && richTextList.length > 0" class="rich-text-container">
  10. <template v-for="(block, index) in parsedBlocks">
  11. <!-- 文字块:包含 word_list -->
  12. <span
  13. v-if="block.type === 'text'"
  14. :key="'text-' + index"
  15. class="pinyin-sentence"
  16. :style="[{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }, block.styleObj]"
  17. >
  18. <span v-for="(word, wIndex) in block.word_list" :key="wIndex" class="pinyin-text">
  19. <span
  20. :class="{ active: visible && word_index == wIndex && sentence_index === block.index }"
  21. :title="isPreview ? '' : '点击校对'"
  22. :style="{
  23. cursor: isPreview ? '' : 'pointer',
  24. }"
  25. @click="correctPinyin(word, block.paragraphIndex, block.oldIndex, wIndex)"
  26. >
  27. <span v-for="(char, cIndex) in Array.from(word.text)" :key="cIndex">
  28. <span
  29. :style="{
  30. flexDirection: pinyinPosition === 'top' ? 'column' : 'column-reverse',
  31. 'align-items': getWordAlignItems(word, block),
  32. }"
  33. >
  34. <span class="pinyin" :style="getPinyinStyle(word)"> {{ getCharPinyin(word, cIndex) }}</span>
  35. <span class="py-char" :style="getCharStyle(word, block, cIndex)">{{ convertText(char) }}</span>
  36. </span>
  37. </span>
  38. </span>
  39. </span>
  40. </span>
  41. <!-- 图片块 -->
  42. <div
  43. v-else-if="block.type === 'image'"
  44. :key="'image-' + index"
  45. :style="block.containerStyle"
  46. class="image-container"
  47. >
  48. <img
  49. :src="block.src"
  50. :alt="block.alt"
  51. :width="block.width"
  52. :height="block.height"
  53. :style="block.imageStyle"
  54. class="inline-image"
  55. />
  56. </div>
  57. <!-- 换行符 -->
  58. <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
  59. </template>
  60. </div>
  61. <!-- 老规则:使用 paragraphList -->
  62. <template v-else-if="paragraphList && paragraphList.length > 0">
  63. <div v-for="(paragraph, pIndex) in paragraphList" :key="pIndex" class="pinyin-paragraph">
  64. <div
  65. v-for="(sentence, sIndex) in paragraph"
  66. :key="sIndex"
  67. class="pinyin-sentence"
  68. :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }"
  69. >
  70. <span v-for="(word, wIndex) in sentence" :key="wIndex" class="pinyin-text">
  71. <span
  72. :class="{
  73. active: visible && word_index == wIndex && sentence_index === sIndex && paragraph_index === pIndex,
  74. }"
  75. :title="isPreview ? '' : '点击校对'"
  76. :style="{
  77. cursor: isPreview ? '' : 'pointer',
  78. 'align-items': wIndex == 0 ? 'flex-start' : 'center',
  79. }"
  80. @click="correctPinyin(word, pIndex, sIndex, wIndex)"
  81. >
  82. <span
  83. v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)"
  84. class="pinyin"
  85. :style="{ 'font-size': pinyinSize }"
  86. >
  87. {{ getPinyinText(word) }}</span
  88. >
  89. <span class="py-char" :style="textStyle(word)">{{ convertText(word.text) }}</span>
  90. <span
  91. v-if="pinyinPosition !== 'top' && hasPinyinInParagraph(sIndex)"
  92. class="pinyin"
  93. :style="{ 'font-size': pinyinSize }"
  94. >
  95. {{ getPinyinText(word) }}</span
  96. >
  97. </span>
  98. </span>
  99. </div>
  100. </div>
  101. </template>
  102. <CorrectPinyin
  103. :visible.sync="visible"
  104. :new-version="newVersion"
  105. :select-content="selectContent"
  106. :component-type="componentType"
  107. @fillTonePinyin="fillTonePinyin"
  108. />
  109. <el-dialog
  110. ref="optimizedDialog"
  111. title=""
  112. :visible.sync="noteDialogVisible"
  113. width="680px"
  114. :style="dialogStyle"
  115. :close-on-click-modal="false"
  116. destroy-on-close
  117. @close="noteDialogVisible = false"
  118. >
  119. <span v-html="sanitizeHTML(note)"></span>
  120. </el-dialog>
  121. </div>
  122. </template>
  123. <script>
  124. import CorrectPinyin from '@/views/book/courseware/create/components/base/common/CorrectPinyin.vue';
  125. import { sanitizeHTML } from '@/utils/common';
  126. import { isEnable } from '@/views/book/courseware/data/common';
  127. import AudioPlay from '@/views/book/courseware/preview/components/character_base/components/AudioPlay.vue';
  128. export default {
  129. name: 'PinyinText',
  130. components: {
  131. CorrectPinyin,
  132. AudioPlay,
  133. },
  134. inject: ['convertText'],
  135. props: {
  136. isEnableVoice: {
  137. type: String,
  138. default: 'false',
  139. },
  140. audioFileId: {
  141. type: String,
  142. default: '',
  143. },
  144. unifiedAttrib: {
  145. type: Object,
  146. default: () => ({}),
  147. },
  148. paragraphList: {
  149. type: Array,
  150. default: () => [],
  151. },
  152. richTextList: {
  153. type: Array,
  154. default: () => [],
  155. },
  156. pinyinPosition: {
  157. type: String,
  158. required: true,
  159. },
  160. fontFamily: {
  161. type: String,
  162. default: '',
  163. },
  164. fontSize: {
  165. type: String,
  166. default: '12pt',
  167. },
  168. pinyinSize: {
  169. type: String,
  170. default: '12pt',
  171. },
  172. pinyinOverallPosition: {
  173. type: String,
  174. default: 'left',
  175. },
  176. isPreview: {
  177. type: Boolean,
  178. default: false,
  179. },
  180. componentType: {
  181. type: String,
  182. default: '',
  183. },
  184. pinyinPadding: {
  185. type: String,
  186. default: '0px 0px 0px 0px',
  187. },
  188. },
  189. data() {
  190. return {
  191. isEnable,
  192. sanitizeHTML,
  193. visible: false,
  194. selectContent: {},
  195. paragraph_index: 0,
  196. sentence_index: 0,
  197. word_index: 0,
  198. noteDialogVisible: false,
  199. note: '',
  200. dialogStyle: {
  201. position: 'fixed',
  202. top: '0',
  203. left: '0',
  204. margin: '0',
  205. },
  206. isAllSetting: false,
  207. };
  208. },
  209. computed: {
  210. newVersion() {
  211. return this.richTextList && this.richTextList.length > 0;
  212. },
  213. styleWatch() {
  214. return {
  215. fontSize: this.fontSize,
  216. fontFamily: this.fontFamily,
  217. };
  218. },
  219. // 解析 richTextList 为可渲染的块
  220. parsedBlocks() {
  221. if (!this.richTextList || this.richTextList.length === 0) return [];
  222. const blocks = [];
  223. let textBlockIndex = 0;
  224. let oldIndex = -1;
  225. let paragraphIndex = 0;
  226. const tagStack = [];
  227. for (const item of this.richTextList) {
  228. oldIndex += 1;
  229. if (item.text && typeof item.text === 'string' && item.text.includes('<img')) {
  230. blocks.push(this.parseImageBlock(item, tagStack));
  231. } else if (item.is_style === 'true' || item.is_style === true) {
  232. this.handleStyleTag(item, tagStack);
  233. } else if (item.text === '\n') {
  234. blocks.push({ type: 'newline' });
  235. paragraphIndex += 1;
  236. } else if (item.word_list && item.word_list.length > 0) {
  237. const hasEmphasisDot = this.checkHasEmphasisDot(tagStack);
  238. blocks.push(this.createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot));
  239. textBlockIndex += 1;
  240. }
  241. }
  242. return blocks;
  243. },
  244. },
  245. watch: {
  246. styleWatch: {
  247. handler() {
  248. this.isAllSetting = true;
  249. },
  250. immediate: true,
  251. deep: true,
  252. },
  253. },
  254. methods: {
  255. // 检查标签栈中是否有着重点样式
  256. checkHasEmphasisDot(tagStack) {
  257. return tagStack.some((tagItem) => {
  258. if (!tagItem.className) return false;
  259. return tagItem.className.includes('rich-text-emphasis-dot');
  260. });
  261. },
  262. // 解析图片块
  263. parseImageBlock(item, tagStack) {
  264. const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
  265. if (!imgMatch) return null;
  266. const attrs = imgMatch[1];
  267. const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
  268. const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
  269. const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
  270. const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
  271. const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
  272. const containerStyleObj = {};
  273. tagStack.forEach((tagItem) => {
  274. if (tagItem.style) {
  275. this.mergeStyleString(containerStyleObj, tagItem.style);
  276. }
  277. });
  278. const imageStyleObj = {};
  279. if (styleMatch) {
  280. this.mergeStyleString(imageStyleObj, styleMatch[1]);
  281. }
  282. return {
  283. type: 'image',
  284. src: srcMatch ? srcMatch[1] : '',
  285. alt: altMatch ? altMatch[1] : '',
  286. width: widthMatch ? widthMatch[1] : null,
  287. height: heightMatch ? heightMatch[1] : null,
  288. containerStyle: containerStyleObj,
  289. imageStyle: imageStyleObj,
  290. };
  291. },
  292. // 合并样式字符串到对象
  293. mergeStyleString(styleObj, styleStr) {
  294. styleStr.split(';').forEach((rule) => {
  295. if (rule.trim()) {
  296. const [prop, value] = rule.split(':').map((s) => s.trim());
  297. if (prop && value) {
  298. const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
  299. styleObj[camelProp] = value;
  300. }
  301. }
  302. });
  303. },
  304. // 处理样式标签
  305. handleStyleTag(item, tagStack) {
  306. const tagText = item.text || '';
  307. const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
  308. if (closeTagMatch) {
  309. const tagName = closeTagMatch[1];
  310. this.removeTagFromStack(tagStack, tagName);
  311. } else {
  312. const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
  313. if (openTagMatch) {
  314. const tagName = openTagMatch[1];
  315. const attrs = openTagMatch[2];
  316. const { style, className } = this.extractTagInfo(attrs, tagName);
  317. tagStack.push({ tag: tagName, style, className });
  318. }
  319. }
  320. },
  321. // 提取标签信息(样式和类名)
  322. extractTagInfo(attrs, tagName) {
  323. const styleMatch = attrs.match(/style=["']([^"']*)["']/);
  324. let style = styleMatch ? styleMatch[1] : null;
  325. const classMatch = attrs.match(/class=["']([^"']*)["']/);
  326. const className = classMatch ? classMatch[1] : '';
  327. if (!style) {
  328. style = this.getDefaultTagStyle(tagName);
  329. }
  330. return { style, className };
  331. },
  332. // 获取标签默认样式
  333. getDefaultTagStyle(tagName) {
  334. const tagStyleMap = {
  335. strong: 'font-weight: bold',
  336. b: 'font-weight: bold',
  337. em: 'font-style: italic',
  338. i: 'font-style: italic',
  339. u: 'text-decoration: underline',
  340. s: 'text-decoration: line-through',
  341. del: 'text-decoration: line-through',
  342. sub: 'vertical-align: sub',
  343. sup: 'vertical-align: super',
  344. };
  345. return tagStyleMap[tagName] || null;
  346. },
  347. // 创建文本块
  348. createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot) {
  349. const combinedStyle = tagStack
  350. .filter((tagItem) => tagItem.style)
  351. .map((tagItem) => tagItem.style)
  352. .join(';');
  353. const styleObj = combinedStyle ? this.parseStyleToObject(combinedStyle) : null;
  354. return {
  355. type: 'text',
  356. word_list: item.word_list,
  357. index: textBlockIndex,
  358. oldIndex,
  359. paragraphIndex,
  360. styleObj,
  361. hasEmphasisDot,
  362. };
  363. },
  364. // 解析样式字符串为对象
  365. parseStyleToObject(styleStr) {
  366. const styleObj = {};
  367. if (!styleStr) return styleObj;
  368. this.mergeStyleString(styleObj, styleStr);
  369. return styleObj;
  370. },
  371. // 获取单个字符的样式(包括着重点)
  372. getCharStyle(word, block, charIndex) {
  373. const baseStyle = { ...word.activeTextStyle };
  374. baseStyle['font-size'] = baseStyle.fontSize;
  375. baseStyle['font-family'] = baseStyle.fontFamily;
  376. if (this.isAllSetting) {
  377. baseStyle['font-size'] = this.fontSize;
  378. baseStyle['font-family'] = this.fontFamily;
  379. this.isAllSetting = false;
  380. }
  381. // 如果该文字块有着重点标记,应用到字符上
  382. if (block.hasEmphasisDot) {
  383. const letterSpacing = block.styleObj?.letterSpacing || '0';
  384. const letterSpacingValue = parseFloat(letterSpacing) || 0;
  385. baseStyle['border-bottom'] = 'none';
  386. baseStyle['background-image'] = 'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)';
  387. baseStyle['background-repeat'] = 'repeat-x';
  388. baseStyle['background-position'] = `${letterSpacingValue * -0.5}em 100%`;
  389. baseStyle['background-size'] = `${1 + letterSpacingValue}em 0.3em`;
  390. baseStyle['padding-bottom'] = '0.3em';
  391. baseStyle['display'] = 'inline';
  392. }
  393. return baseStyle;
  394. },
  395. // 兼容历史数据
  396. getPinyinText(item) {
  397. return this.checkShowPinyin(item.showPinyin) ? item.pinyin.replace(/\s+/g, '') : '\u200B';
  398. },
  399. // 拼音与汉字一一对应
  400. getCharPinyin(word, charIndex) {
  401. if (!this.checkShowPinyin(word.showPinyin)) {
  402. return '\u200B';
  403. }
  404. // 优先使用新的 pinyin_list 字段(与字符一一对应)
  405. if (word.pinyin_list && Array.isArray(word.pinyin_list)) {
  406. return word.pinyin_list[charIndex] || '\u200B';
  407. }
  408. // 兼容旧数据:使用 pinyin 字段
  409. const pinyinList = word.pinyin ? word.pinyin.trim().split(/\s+/) : [];
  410. return pinyinList[charIndex] || '\u200B';
  411. },
  412. // 如果汉字有字间距,拼音与汉字左对齐,如果没有则居中对齐
  413. getWordAlignItems(word, block) {
  414. const letterSpacing = block.styleObj?.letterSpacing;
  415. if (letterSpacing && letterSpacing !== '0' && letterSpacing !== '0px') {
  416. return 'flex-start';
  417. }
  418. return 'center';
  419. },
  420. // 拼音固定为拼音字体,跟随汉字的字号、颜色、粗细的样式
  421. getPinyinStyle(item) {
  422. const styles = {};
  423. // 固定使用 League 字体
  424. styles['font-family'] = 'League';
  425. // 只继承字号和颜色
  426. if (item.activeTextStyle) {
  427. if (item.activeTextStyle.fontSize) {
  428. styles['font-size'] = item.activeTextStyle.fontSize;
  429. }
  430. if (item.activeTextStyle.color) {
  431. styles['color'] = item.activeTextStyle.color;
  432. }
  433. }
  434. // 如果设置了全局字号,优先使用全局字号
  435. if (this.isAllSetting && this.fontSize) {
  436. styles['font-size'] = this.fontSize;
  437. this.isAllSetting = false;
  438. }
  439. // 明确重置不应该继承的样式
  440. styles['text-decoration'] = 'none';
  441. styles['font-style'] = 'normal';
  442. styles['letter-spacing'] = '0';
  443. return styles;
  444. },
  445. removeTagFromStack(tagStack, tagName) {
  446. for (let i = tagStack.length - 1; i >= 0; i--) {
  447. if (tagStack[i].tag === tagName) {
  448. tagStack.splice(i, 1);
  449. break;
  450. }
  451. }
  452. },
  453. textStyle(item) {
  454. const styles = { ...item.activeTextStyle };
  455. styles['font-size'] = styles.fontSize;
  456. styles['font-family'] = styles.fontFamily;
  457. if (this.isAllSetting) styles['font-size'] = this.fontSize;
  458. if (this.isAllSetting) styles['font-family'] = this.fontFamily;
  459. this.isAllSetting = false;
  460. return styles;
  461. },
  462. /**
  463. * 校对拼音
  464. */
  465. correctPinyin(item, pIndex, sIndex, wIndex) {
  466. if (this.isPreview) {
  467. if (item.note) {
  468. this.note = item.note;
  469. this.noteDialogVisible = true;
  470. this.$nextTick(() => {
  471. const dialogElement = this.$refs.optimizedDialog;
  472. // 确保对话框 DOM 已渲染
  473. if (!dialogElement) {
  474. return;
  475. }
  476. // 获取对话框内容区域的 DOM 元素
  477. const dialogContent = dialogElement.$el.querySelector('.el-dialog');
  478. if (!dialogContent) {
  479. return;
  480. }
  481. const dialogRect = dialogContent.getBoundingClientRect();
  482. const dialogWidth = dialogRect.width;
  483. const dialogHeight = dialogRect.height;
  484. const padding = 10; // 安全边距
  485. const clickX = event.clientX;
  486. const clickY = event.clientY;
  487. const windowWidth = window.innerWidth;
  488. const windowHeight = window.innerHeight;
  489. // 水平定位 - 中心对齐
  490. let left = clickX - dialogWidth / 2;
  491. // 边界检查
  492. left = Math.max(padding, Math.min(left, windowWidth - dialogWidth - padding));
  493. // 垂直定位 - 点击位置作为下边界中心
  494. let top = clickY - dialogHeight;
  495. // 上方空间不足时,改为向下展开
  496. if (top < padding) {
  497. top = clickY + padding;
  498. // 如果向下展开会超出屏幕,则贴底部显示
  499. if (top + dialogHeight > windowHeight - padding) {
  500. top = windowHeight - dialogHeight - padding;
  501. }
  502. }
  503. this.dialogStyle = {
  504. position: 'fixed',
  505. top: `${top - 20}px`,
  506. left: `${left}px`,
  507. margin: '0',
  508. transform: 'none',
  509. };
  510. });
  511. }
  512. return;
  513. } // 如果是预览模式,不操作
  514. if (item) {
  515. this.visible = true;
  516. this.selectContent = item;
  517. this.paragraph_index = pIndex;
  518. this.sentence_index = sIndex;
  519. this.word_index = wIndex;
  520. }
  521. },
  522. // 回填校对后的拼音
  523. fillTonePinyin(dataContent) {
  524. this.$emit('fillCorrectPinyin', {
  525. selectContent: dataContent,
  526. i: this.paragraph_index,
  527. j: this.sentence_index,
  528. k: this.word_index,
  529. newVersion: this.newVersion,
  530. });
  531. },
  532. // 如段落有拼音,则返回true ,否则不显示拼音行
  533. hasPinyinInParagraph(paragraphIndex) {
  534. const paragraph = this.paragraphList[paragraphIndex];
  535. return paragraph.some((sentence) => sentence.some((item) => this.checkShowPinyin(item.showPinyin)));
  536. },
  537. hasPinyinInParagraphNeVersion(paragraphIndex) {
  538. const paragraphs = this.parsedBlocks.filter((item) => item.paragraphIndex === paragraphIndex);
  539. return paragraphs.flatMap((item) => item.word_list).some((item) => this.checkShowPinyin(item.showPinyin));
  540. },
  541. // 兼容历史数据,没有 showPinyin 字段的,则默认为 true
  542. checkShowPinyin(showPinyin) {
  543. if (showPinyin === undefined || showPinyin === null) {
  544. return true;
  545. }
  546. return this.isEnable(showPinyin);
  547. },
  548. },
  549. };
  550. </script>
  551. <style lang="scss" scoped>
  552. .pinyin-area {
  553. // 新规则的容器样式
  554. .rich-text-container {
  555. p {
  556. display: inline-flex;
  557. flex-wrap: wrap;
  558. margin: 0.5em 0;
  559. }
  560. span {
  561. display: inline-flex;
  562. }
  563. .pinyin-sentence {
  564. display: inline-flex;
  565. flex-wrap: wrap;
  566. }
  567. .image-container {
  568. display: block;
  569. .inline-image {
  570. display: inline-block;
  571. max-width: 100%;
  572. height: auto;
  573. }
  574. }
  575. }
  576. .pinyin-paragraph {
  577. .pinyin-sentence {
  578. display: inline-flex;
  579. flex-wrap: wrap;
  580. }
  581. }
  582. .pinyin-text {
  583. padding: 0 2px;
  584. // font-size: 16px;
  585. text-wrap: pretty;
  586. hanging-punctuation: allow-end;
  587. > span {
  588. display: inline-flex;
  589. // flex-direction: column;
  590. }
  591. .py-char {
  592. ruby-align: center;
  593. line-height: 1em;
  594. }
  595. .pinyin {
  596. display: inline-block;
  597. min-height: 12px;
  598. font-family: 'League';
  599. // font-family: 'PINYIN-B';
  600. // font-size: 12px;
  601. // font-weight: lighter;
  602. // line-height: 12px;
  603. // color: $font-color;
  604. line-height: 1em;
  605. text-decoration: none !important;
  606. // 防止继承父元素的删除线
  607. text-decoration-skip-ink: none;
  608. }
  609. }
  610. .active {
  611. color: rgb(242, 85, 90) !important;
  612. }
  613. }
  614. </style>
  615. <style lang="scss">
  616. .pinyin-area + .pinyin-area {
  617. margin-top: 4px;
  618. }
  619. </style>