| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681 |
- <template>
- <div class="pinyin-area" :style="{ 'text-align': pinyinOverallPosition, padding: pinyinPadding }">
- <AudioPlay
- v-if="isEnable(isEnableVoice)"
- :file-id="audioFileId"
- :theme-color="unifiedAttrib && unifiedAttrib.topic_color ? unifiedAttrib.topic_color : ''"
- />
- <!-- 新规则:使用 richTextList -->
- <div v-if="richTextList && richTextList.length > 0" class="rich-text-container">
- <template v-for="(block, index) in parsedBlocks">
- <!-- 文字块:包含 word_list -->
- <span
- v-if="block.type === 'text'"
- :key="'text-' + index"
- class="pinyin-sentence"
- :style="[{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }, block.styleObj]"
- >
- <span v-for="(word, wIndex) in block.word_list" :key="wIndex" class="pinyin-text">
- <span
- :class="{ active: visible && word_index == wIndex && sentence_index === block.index }"
- :title="isPreview ? '' : '点击校对'"
- :style="{
- cursor: isPreview ? '' : 'pointer',
- }"
- @click="correctPinyin(word, block.paragraphIndex, block.oldIndex, wIndex)"
- >
- <span v-for="(char, cIndex) in Array.from(word.text)" :key="cIndex">
- <span
- :style="{
- flexDirection: pinyinPosition === 'top' ? 'column' : 'column-reverse',
- 'align-items': getWordAlignItems(word, block),
- }"
- >
- <span class="pinyin" :style="getPinyinStyle(word)"> {{ getCharPinyin(word, cIndex) }}</span>
- <span class="py-char" :style="getCharStyle(word, block, cIndex)">{{ convertText(char) }}</span>
- </span>
- </span>
- </span>
- </span>
- </span>
- <!-- 图片块 -->
- <div
- v-else-if="block.type === 'image'"
- :key="'image-' + index"
- :style="block.containerStyle"
- class="image-container"
- >
- <img
- :src="block.src"
- :alt="block.alt"
- :width="block.width"
- :height="block.height"
- :style="block.imageStyle"
- class="inline-image"
- />
- </div>
- <!-- 换行符 -->
- <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
- </template>
- </div>
- <!-- 老规则:使用 paragraphList -->
- <template v-else-if="paragraphList && paragraphList.length > 0">
- <div v-for="(paragraph, pIndex) in paragraphList" :key="pIndex" class="pinyin-paragraph">
- <div
- v-for="(sentence, sIndex) in paragraph"
- :key="sIndex"
- class="pinyin-sentence"
- :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }"
- >
- <span v-for="(word, wIndex) in sentence" :key="wIndex" class="pinyin-text">
- <span
- :class="{
- active: visible && word_index == wIndex && sentence_index === sIndex && paragraph_index === pIndex,
- }"
- :title="isPreview ? '' : '点击校对'"
- :style="{
- cursor: isPreview ? '' : 'pointer',
- 'align-items': wIndex == 0 ? 'flex-start' : 'center',
- }"
- @click="correctPinyin(word, pIndex, sIndex, wIndex)"
- >
- <span
- v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)"
- class="pinyin"
- :style="{ 'font-size': pinyinSize }"
- >
- {{ getPinyinText(word) }}</span
- >
- <span class="py-char" :style="textStyle(word)">{{ convertText(word.text) }}</span>
- <span
- v-if="pinyinPosition !== 'top' && hasPinyinInParagraph(sIndex)"
- class="pinyin"
- :style="{ 'font-size': pinyinSize }"
- >
- {{ getPinyinText(word) }}</span
- >
- </span>
- </span>
- </div>
- </div>
- </template>
- <CorrectPinyin
- :visible.sync="visible"
- :new-version="newVersion"
- :select-content="selectContent"
- :component-type="componentType"
- @fillTonePinyin="fillTonePinyin"
- />
- <el-dialog
- ref="optimizedDialog"
- title=""
- :visible.sync="noteDialogVisible"
- width="680px"
- :style="dialogStyle"
- :close-on-click-modal="false"
- destroy-on-close
- @close="noteDialogVisible = false"
- >
- <span v-html="sanitizeHTML(note)"></span>
- </el-dialog>
- </div>
- </template>
- <script>
- import CorrectPinyin from '@/views/book/courseware/create/components/base/common/CorrectPinyin.vue';
- import { sanitizeHTML } from '@/utils/common';
- import { isEnable } from '@/views/book/courseware/data/common';
- import AudioPlay from '@/views/book/courseware/preview/components/character_base/components/AudioPlay.vue';
- export default {
- name: 'PinyinText',
- components: {
- CorrectPinyin,
- AudioPlay,
- },
- inject: ['convertText'],
- props: {
- isEnableVoice: {
- type: String,
- default: 'false',
- },
- audioFileId: {
- type: String,
- default: '',
- },
- unifiedAttrib: {
- type: Object,
- default: () => ({}),
- },
- paragraphList: {
- type: Array,
- default: () => [],
- },
- richTextList: {
- type: Array,
- default: () => [],
- },
- pinyinPosition: {
- type: String,
- required: true,
- },
- fontFamily: {
- type: String,
- default: '',
- },
- fontSize: {
- type: String,
- default: '12pt',
- },
- pinyinSize: {
- type: String,
- default: '12pt',
- },
- pinyinOverallPosition: {
- type: String,
- default: 'left',
- },
- isPreview: {
- type: Boolean,
- default: false,
- },
- componentType: {
- type: String,
- default: '',
- },
- pinyinPadding: {
- type: String,
- default: '0px 0px 0px 0px',
- },
- },
- data() {
- return {
- isEnable,
- sanitizeHTML,
- visible: false,
- selectContent: {},
- paragraph_index: 0,
- sentence_index: 0,
- word_index: 0,
- noteDialogVisible: false,
- note: '',
- dialogStyle: {
- position: 'fixed',
- top: '0',
- left: '0',
- margin: '0',
- },
- isAllSetting: false,
- };
- },
- computed: {
- newVersion() {
- return this.richTextList && this.richTextList.length > 0;
- },
- styleWatch() {
- return {
- fontSize: this.fontSize,
- fontFamily: this.fontFamily,
- };
- },
- // 解析 richTextList 为可渲染的块
- parsedBlocks() {
- if (!this.richTextList || this.richTextList.length === 0) return [];
- const blocks = [];
- let textBlockIndex = 0;
- let oldIndex = -1;
- let paragraphIndex = 0;
- const tagStack = [];
- for (const item of this.richTextList) {
- oldIndex += 1;
- if (item.text && typeof item.text === 'string' && item.text.includes('<img')) {
- blocks.push(this.parseImageBlock(item, tagStack));
- } else if (item.is_style === 'true' || item.is_style === true) {
- this.handleStyleTag(item, tagStack);
- } else if (item.text === '\n') {
- blocks.push({ type: 'newline' });
- paragraphIndex += 1;
- } else if (item.word_list && item.word_list.length > 0) {
- const hasEmphasisDot = this.checkHasEmphasisDot(tagStack);
- blocks.push(this.createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot));
- textBlockIndex += 1;
- }
- }
- return blocks;
- },
- },
- watch: {
- styleWatch: {
- handler() {
- this.isAllSetting = true;
- },
- immediate: true,
- deep: true,
- },
- },
- methods: {
- // 检查标签栈中是否有着重点样式
- checkHasEmphasisDot(tagStack) {
- return tagStack.some((tagItem) => {
- if (!tagItem.className) return false;
- return tagItem.className.includes('rich-text-emphasis-dot');
- });
- },
- // 解析图片块
- parseImageBlock(item, tagStack) {
- const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
- if (!imgMatch) return null;
- const attrs = imgMatch[1];
- const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
- const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
- const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
- const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
- const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
- const containerStyleObj = {};
- tagStack.forEach((tagItem) => {
- if (tagItem.style) {
- this.mergeStyleString(containerStyleObj, tagItem.style);
- }
- });
- const imageStyleObj = {};
- if (styleMatch) {
- this.mergeStyleString(imageStyleObj, styleMatch[1]);
- }
- return {
- type: 'image',
- src: srcMatch ? srcMatch[1] : '',
- alt: altMatch ? altMatch[1] : '',
- width: widthMatch ? widthMatch[1] : null,
- height: heightMatch ? heightMatch[1] : null,
- containerStyle: containerStyleObj,
- imageStyle: imageStyleObj,
- };
- },
- // 合并样式字符串到对象
- mergeStyleString(styleObj, styleStr) {
- styleStr.split(';').forEach((rule) => {
- if (rule.trim()) {
- const [prop, value] = rule.split(':').map((s) => s.trim());
- if (prop && value) {
- const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
- styleObj[camelProp] = value;
- }
- }
- });
- },
- // 处理样式标签
- handleStyleTag(item, tagStack) {
- const tagText = item.text || '';
- const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
- if (closeTagMatch) {
- const tagName = closeTagMatch[1];
- this.removeTagFromStack(tagStack, tagName);
- } else {
- const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
- if (openTagMatch) {
- const tagName = openTagMatch[1];
- const attrs = openTagMatch[2];
- const { style, className } = this.extractTagInfo(attrs, tagName);
- tagStack.push({ tag: tagName, style, className });
- }
- }
- },
- // 提取标签信息(样式和类名)
- extractTagInfo(attrs, tagName) {
- const styleMatch = attrs.match(/style=["']([^"']*)["']/);
- let style = styleMatch ? styleMatch[1] : null;
- const classMatch = attrs.match(/class=["']([^"']*)["']/);
- const className = classMatch ? classMatch[1] : '';
- if (!style) {
- style = this.getDefaultTagStyle(tagName);
- }
- return { style, className };
- },
- // 获取标签默认样式
- getDefaultTagStyle(tagName) {
- const tagStyleMap = {
- strong: 'font-weight: bold',
- b: 'font-weight: bold',
- em: 'font-style: italic',
- i: 'font-style: italic',
- u: 'text-decoration: underline',
- s: 'text-decoration: line-through',
- del: 'text-decoration: line-through',
- sub: 'vertical-align: sub',
- sup: 'vertical-align: super',
- };
- return tagStyleMap[tagName] || null;
- },
- // 创建文本块
- createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot) {
- const combinedStyle = tagStack
- .filter((tagItem) => tagItem.style)
- .map((tagItem) => tagItem.style)
- .join(';');
- const styleObj = combinedStyle ? this.parseStyleToObject(combinedStyle) : null;
- return {
- type: 'text',
- word_list: item.word_list,
- index: textBlockIndex,
- oldIndex,
- paragraphIndex,
- styleObj,
- hasEmphasisDot,
- };
- },
- // 解析样式字符串为对象
- parseStyleToObject(styleStr) {
- const styleObj = {};
- if (!styleStr) return styleObj;
- this.mergeStyleString(styleObj, styleStr);
- return styleObj;
- },
- // 获取单个字符的样式(包括着重点)
- getCharStyle(word, block, charIndex) {
- const baseStyle = { ...word.activeTextStyle };
- baseStyle['font-size'] = baseStyle.fontSize;
- baseStyle['font-family'] = baseStyle.fontFamily;
- if (this.isAllSetting) {
- baseStyle['font-size'] = this.fontSize;
- baseStyle['font-family'] = this.fontFamily;
- this.isAllSetting = false;
- }
- // 如果该文字块有着重点标记,应用到字符上
- if (block.hasEmphasisDot) {
- const letterSpacing = block.styleObj?.letterSpacing || '0';
- const letterSpacingValue = parseFloat(letterSpacing) || 0;
- baseStyle['border-bottom'] = 'none';
- baseStyle['background-image'] = 'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)';
- baseStyle['background-repeat'] = 'repeat-x';
- baseStyle['background-position'] = `${letterSpacingValue * -0.5}em 100%`;
- baseStyle['background-size'] = `${1 + letterSpacingValue}em 0.3em`;
- baseStyle['padding-bottom'] = '0.3em';
- baseStyle['display'] = 'inline';
- }
- return baseStyle;
- },
- // 兼容历史数据
- getPinyinText(item) {
- return this.checkShowPinyin(item.showPinyin) ? item.pinyin.replace(/\s+/g, '') : '\u200B';
- },
- // 拼音与汉字一一对应
- getCharPinyin(word, charIndex) {
- if (!this.checkShowPinyin(word.showPinyin)) {
- return '\u200B';
- }
- // 优先使用新的 pinyin_list 字段(与字符一一对应)
- if (word.pinyin_list && Array.isArray(word.pinyin_list)) {
- return word.pinyin_list[charIndex] || '\u200B';
- }
- // 兼容旧数据:使用 pinyin 字段
- const pinyinList = word.pinyin ? word.pinyin.trim().split(/\s+/) : [];
- return pinyinList[charIndex] || '\u200B';
- },
- // 如果汉字有字间距,拼音与汉字左对齐,如果没有则居中对齐
- getWordAlignItems(word, block) {
- const letterSpacing = block.styleObj?.letterSpacing;
- if (letterSpacing && letterSpacing !== '0' && letterSpacing !== '0px') {
- return 'flex-start';
- }
- return 'center';
- },
- // 拼音固定为拼音字体,跟随汉字的字号、颜色、粗细的样式
- getPinyinStyle(item) {
- const styles = {};
- // 固定使用 League 字体
- styles['font-family'] = 'League';
- // 只继承字号和颜色
- if (item.activeTextStyle) {
- if (item.activeTextStyle.fontSize) {
- styles['font-size'] = item.activeTextStyle.fontSize;
- }
- if (item.activeTextStyle.color) {
- styles['color'] = item.activeTextStyle.color;
- }
- }
- // 如果设置了全局字号,优先使用全局字号
- if (this.isAllSetting && this.fontSize) {
- styles['font-size'] = this.fontSize;
- this.isAllSetting = false;
- }
- // 明确重置不应该继承的样式
- styles['text-decoration'] = 'none';
- styles['font-style'] = 'normal';
- styles['letter-spacing'] = '0';
- return styles;
- },
- removeTagFromStack(tagStack, tagName) {
- for (let i = tagStack.length - 1; i >= 0; i--) {
- if (tagStack[i].tag === tagName) {
- tagStack.splice(i, 1);
- break;
- }
- }
- },
- textStyle(item) {
- const styles = { ...item.activeTextStyle };
- styles['font-size'] = styles.fontSize;
- styles['font-family'] = styles.fontFamily;
- if (this.isAllSetting) styles['font-size'] = this.fontSize;
- if (this.isAllSetting) styles['font-family'] = this.fontFamily;
- this.isAllSetting = false;
- return styles;
- },
- /**
- * 校对拼音
- */
- correctPinyin(item, pIndex, sIndex, wIndex) {
- if (this.isPreview) {
- if (item.note) {
- this.note = item.note;
- this.noteDialogVisible = true;
- this.$nextTick(() => {
- const dialogElement = this.$refs.optimizedDialog;
- // 确保对话框 DOM 已渲染
- if (!dialogElement) {
- return;
- }
- // 获取对话框内容区域的 DOM 元素
- const dialogContent = dialogElement.$el.querySelector('.el-dialog');
- if (!dialogContent) {
- return;
- }
- const dialogRect = dialogContent.getBoundingClientRect();
- const dialogWidth = dialogRect.width;
- const dialogHeight = dialogRect.height;
- const padding = 10; // 安全边距
- const clickX = event.clientX;
- const clickY = event.clientY;
- const windowWidth = window.innerWidth;
- const windowHeight = window.innerHeight;
- // 水平定位 - 中心对齐
- let left = clickX - dialogWidth / 2;
- // 边界检查
- left = Math.max(padding, Math.min(left, windowWidth - dialogWidth - padding));
- // 垂直定位 - 点击位置作为下边界中心
- let top = clickY - dialogHeight;
- // 上方空间不足时,改为向下展开
- if (top < padding) {
- top = clickY + padding;
- // 如果向下展开会超出屏幕,则贴底部显示
- if (top + dialogHeight > windowHeight - padding) {
- top = windowHeight - dialogHeight - padding;
- }
- }
- this.dialogStyle = {
- position: 'fixed',
- top: `${top - 20}px`,
- left: `${left}px`,
- margin: '0',
- transform: 'none',
- };
- });
- }
- return;
- } // 如果是预览模式,不操作
- if (item) {
- this.visible = true;
- this.selectContent = item;
- this.paragraph_index = pIndex;
- this.sentence_index = sIndex;
- this.word_index = wIndex;
- }
- },
- // 回填校对后的拼音
- fillTonePinyin(dataContent) {
- this.$emit('fillCorrectPinyin', {
- selectContent: dataContent,
- i: this.paragraph_index,
- j: this.sentence_index,
- k: this.word_index,
- newVersion: this.newVersion,
- });
- },
- // 如段落有拼音,则返回true ,否则不显示拼音行
- hasPinyinInParagraph(paragraphIndex) {
- const paragraph = this.paragraphList[paragraphIndex];
- return paragraph.some((sentence) => sentence.some((item) => this.checkShowPinyin(item.showPinyin)));
- },
- hasPinyinInParagraphNeVersion(paragraphIndex) {
- const paragraphs = this.parsedBlocks.filter((item) => item.paragraphIndex === paragraphIndex);
- return paragraphs.flatMap((item) => item.word_list).some((item) => this.checkShowPinyin(item.showPinyin));
- },
- // 兼容历史数据,没有 showPinyin 字段的,则默认为 true
- checkShowPinyin(showPinyin) {
- if (showPinyin === undefined || showPinyin === null) {
- return true;
- }
- return this.isEnable(showPinyin);
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- .pinyin-area {
- // 新规则的容器样式
- .rich-text-container {
- p {
- display: inline-flex;
- flex-wrap: wrap;
- margin: 0.5em 0;
- }
- span {
- display: inline-flex;
- }
- .pinyin-sentence {
- display: inline-flex;
- flex-wrap: wrap;
- }
- .image-container {
- display: block;
- .inline-image {
- display: inline-block;
- max-width: 100%;
- height: auto;
- }
- }
- }
- .pinyin-paragraph {
- .pinyin-sentence {
- display: inline-flex;
- flex-wrap: wrap;
- }
- }
- .pinyin-text {
- padding: 0 2px;
- // font-size: 16px;
- text-wrap: pretty;
- hanging-punctuation: allow-end;
- > span {
- display: inline-flex;
- // flex-direction: column;
- }
- .py-char {
- ruby-align: center;
- line-height: 1em;
- }
- .pinyin {
- display: inline-block;
- min-height: 12px;
- font-family: 'League';
- // font-family: 'PINYIN-B';
- // font-size: 12px;
- // font-weight: lighter;
- // line-height: 12px;
- // color: $font-color;
- line-height: 1em;
- text-decoration: none !important;
- // 防止继承父元素的删除线
- text-decoration-skip-ink: none;
- }
- }
- .active {
- color: rgb(242, 85, 90) !important;
- }
- }
- </style>
- <style lang="scss">
- .pinyin-area + .pinyin-area {
- margin-top: 4px;
- }
- </style>
|