|
|
@@ -0,0 +1,773 @@
|
|
|
+<template>
|
|
|
+ <div class="tinymce-container">
|
|
|
+ <Editor
|
|
|
+ v-model="editorContent"
|
|
|
+ :init="editorInit"
|
|
|
+ :disabled="disabled"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 插入公式弹窗 -->
|
|
|
+ <div v-if="latexDialogVisible" class="fm-mask" @click.self="latexDialogVisible = false">
|
|
|
+ <div class="fm-dialog">
|
|
|
+ <!-- 标题栏 -->
|
|
|
+ <div class="fm-header">
|
|
|
+ <span>插入公式</span>
|
|
|
+ <span class="fm-close" @click="latexDialogVisible = false">×</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 工具栏 -->
|
|
|
+ <div class="fm-toolbar">
|
|
|
+ <!-- 预设下拉 -->
|
|
|
+ <div class="fm-preset-wrap">
|
|
|
+ <div class="fm-preset-btn" @click="presetOpen = !presetOpen">
|
|
|
+ <span class="fm-preset-icon">f<sub>(x)</sub></span>
|
|
|
+ <span class="fm-preset-label">预设</span>
|
|
|
+ <span class="fm-preset-arrow">▾</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="presetOpen" class="fm-preset-dropdown">
|
|
|
+ <div v-for="p in presets" :key="p.label" class="fm-preset-item"
|
|
|
+ @click="applyPreset(p.value)">{{ p.label }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="fm-divider"></div>
|
|
|
+
|
|
|
+ <!-- 常用符号面板 -->
|
|
|
+ <div class="fm-symbol-grid">
|
|
|
+ <button v-for="s in symbolList" :key="s.label" type="button"
|
|
|
+ class="fm-sym-btn" :title="s.tip" @click="insertAtCursor(s.value)">
|
|
|
+ {{ s.label }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="fm-divider"></div>
|
|
|
+
|
|
|
+ <!-- 分类按钮组 -->
|
|
|
+ <div class="fm-category-row">
|
|
|
+ <div v-for="cat in categories" :key="cat.id" class="fm-cat-wrap">
|
|
|
+ <button type="button" class="fm-cat-btn"
|
|
|
+ :class="{active: activeCat === cat.id}"
|
|
|
+ @click="activeCat = activeCat === cat.id ? null : cat.id">
|
|
|
+ <span class="fm-cat-icon" v-html="cat.icon"></span>
|
|
|
+ <span class="fm-cat-label">{{ cat.label }}</span>
|
|
|
+ <span class="fm-cat-arrow">▾</span>
|
|
|
+ </button>
|
|
|
+ <div v-if="activeCat === cat.id" class="fm-cat-dropdown">
|
|
|
+ <button v-for="item in cat.items" :key="item.label" type="button"
|
|
|
+ class="fm-cat-item" :title="item.tip"
|
|
|
+ @click="insertAtCursor(item.value); activeCat = null">
|
|
|
+ <span v-html="item.label"></span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 输入区 -->
|
|
|
+ <div class="fm-body">
|
|
|
+ <textarea
|
|
|
+ ref="fmInput"
|
|
|
+ v-model="latexInput"
|
|
|
+ class="fm-input"
|
|
|
+ placeholder="在此处键入公式"
|
|
|
+ spellcheck="false"
|
|
|
+ @input="onInputChange"
|
|
|
+ />
|
|
|
+ <!-- 实时预览 -->
|
|
|
+ <div class="fm-preview" ref="fmPreview">
|
|
|
+ <span v-if="latexInput">$${{ latexInput }}$$</span>
|
|
|
+ <span v-else class="fm-preview-empty">公式预览</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部 -->
|
|
|
+ <div class="fm-footer">
|
|
|
+ <button class="fm-btn-close" type="button" @click="latexDialogVisible = false">关闭</button>
|
|
|
+ <button class="fm-btn-save" type="button" @click="insertLatex">保存</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import Editor from '@tinymce/tinymce-vue'
|
|
|
+import tinymce from 'tinymce/tinymce'
|
|
|
+
|
|
|
+// 计算静态资源根路径,兼容 /bigWeb/ 或 / 等任意子目录部署
|
|
|
+// publicPath="./" 时,index.html 放在子目录下,tinymce 资源也在同级
|
|
|
+function getStaticBase() {
|
|
|
+ const base = document.baseURI || window.location.href
|
|
|
+ return base.replace(/#.*$/, '').replace(/\/[^/]*$/, '') + '/'
|
|
|
+}
|
|
|
+const staticBase = getStaticBase()
|
|
|
+
|
|
|
+import 'tinymce/themes/silver'
|
|
|
+import 'tinymce/icons/default'
|
|
|
+import 'tinymce/models/dom'
|
|
|
+import 'tinymce/plugins/advlist'
|
|
|
+import 'tinymce/plugins/autolink'
|
|
|
+import 'tinymce/plugins/autoresize'
|
|
|
+import 'tinymce/plugins/autosave'
|
|
|
+import 'tinymce/plugins/charmap'
|
|
|
+import 'tinymce/plugins/code'
|
|
|
+import 'tinymce/plugins/codesample'
|
|
|
+import 'tinymce/plugins/directionality'
|
|
|
+import 'tinymce/plugins/emoticons'
|
|
|
+import 'tinymce/plugins/fullscreen'
|
|
|
+import 'tinymce/plugins/help'
|
|
|
+import 'tinymce/plugins/image'
|
|
|
+import 'tinymce/plugins/importcss'
|
|
|
+import 'tinymce/plugins/insertdatetime'
|
|
|
+import 'tinymce/plugins/link'
|
|
|
+import 'tinymce/plugins/lists'
|
|
|
+import 'tinymce/plugins/media'
|
|
|
+import 'tinymce/plugins/nonbreaking'
|
|
|
+import 'tinymce/plugins/pagebreak'
|
|
|
+import 'tinymce/plugins/preview'
|
|
|
+import 'tinymce/plugins/quickbars'
|
|
|
+import 'tinymce/plugins/save'
|
|
|
+import 'tinymce/plugins/searchreplace'
|
|
|
+import 'tinymce/plugins/table'
|
|
|
+import 'tinymce/plugins/template'
|
|
|
+import 'tinymce/plugins/visualblocks'
|
|
|
+import 'tinymce/plugins/visualchars'
|
|
|
+import 'tinymce/plugins/wordcount'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'TinymceContainer',
|
|
|
+ components: { Editor },
|
|
|
+ props: {
|
|
|
+ value: { type: String, default: '' },
|
|
|
+ disabled: { type: Boolean, default: false },
|
|
|
+ height: { type: [Number, String], default: 400 },
|
|
|
+ // 图片/附件上传接口地址
|
|
|
+ uploadUrl: { type: String, default: '' },
|
|
|
+ // 上传请求头,如 { Authorization: 'Bearer xxx' }
|
|
|
+ uploadHeaders: { type: Object, default: () => ({}) }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ latexDialogVisible: false,
|
|
|
+ latexInput: '',
|
|
|
+ presetOpen: false,
|
|
|
+ activeCat: null,
|
|
|
+ // 预设模板
|
|
|
+ presets: [
|
|
|
+ { label: '二次方程', value: 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}' },
|
|
|
+ { label: '勾股定理', value: 'a^2 + b^2 = c^2' },
|
|
|
+ { label: '欧拉公式', value: 'e^{i\\pi} + 1 = 0' },
|
|
|
+ { label: '正态分布', value: 'f(x)=\\frac{1}{\\sigma\\sqrt{2\\pi}}e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}' },
|
|
|
+ { label: '泰勒展开', value: 'f(x) = \\sum_{n=0}^{\\infty} \\frac{f^{(n)}(a)}{n!}(x-a)^n' },
|
|
|
+ { label: '矩阵行列式', value: '\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix} = ad - bc' },
|
|
|
+ ],
|
|
|
+ // 常用符号
|
|
|
+ symbolList: [
|
|
|
+ { label: '±', value: '\\pm ', tip: '正负号' },
|
|
|
+ { label: '∞', value: '\\infty ', tip: '无穷' },
|
|
|
+ { label: '=', value: '= ', tip: '等号' },
|
|
|
+ { label: '~', value: '\\sim ', tip: '相似' },
|
|
|
+ { label: '×', value: '\\times ', tip: '乘号' },
|
|
|
+ { label: '÷', value: '\\div ', tip: '除号' },
|
|
|
+ { label: '!', value: '! ', tip: '阶乘' },
|
|
|
+ { label: '<', value: '< ', tip: '小于' },
|
|
|
+ { label: '≪', value: '\\ll ', tip: '远小于' },
|
|
|
+ { label: '>', value: '> ', tip: '大于' },
|
|
|
+ { label: '≫', value: '\\gg ', tip: '远大于' },
|
|
|
+ { label: '≤', value: '\\leq ', tip: '小于等于' },
|
|
|
+ { label: '≥', value: '\\geq ', tip: '大于等于' },
|
|
|
+ { label: '∓', value: '\\mp ', tip: '负正号' },
|
|
|
+ { label: '≅', value: '\\cong ', tip: '全等' },
|
|
|
+ { label: '≡', value: '\\equiv ', tip: '恒等' },
|
|
|
+ { label: '∝', value: '\\propto ', tip: '正比于' },
|
|
|
+ { label: '≈', value: '\\approx ', tip: '约等于' },
|
|
|
+ ],
|
|
|
+ // 分类按钮组
|
|
|
+ categories: [
|
|
|
+ {
|
|
|
+ id: 'fraction', label: '分数', icon: '<i style="font-style:normal">x/y</i>',
|
|
|
+ items: [
|
|
|
+ { label: 'x/y', value: '\\frac{x}{y}', tip: '分数' },
|
|
|
+ { label: 'x/y (斜)', value: '{}^{x}/{}_{y}', tip: '斜分数' },
|
|
|
+ { label: 'x·y⁻¹', value: 'x \\cdot y^{-1}', tip: '倒数形式' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'power', label: '上下标', icon: '<i style="font-style:normal">e<sup>x</sup></i>',
|
|
|
+ items: [
|
|
|
+ { label: 'x²', value: 'x^{2}', tip: '上标' },
|
|
|
+ { label: 'xₙ', value: 'x_{n}', tip: '下标' },
|
|
|
+ { label: 'xₙ²', value: 'x_{n}^{2}', tip: '上下标' },
|
|
|
+ { label: 'eˣ', value: 'e^{x}', tip: 'e的幂次' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'root', label: '根式', icon: '<i style="font-style:normal">ⁿ√x</i>',
|
|
|
+ items: [
|
|
|
+ { label: '√x', value: '\\sqrt{x}', tip: '平方根' },
|
|
|
+ { label: 'ⁿ√x', value: '\\sqrt[n]{x}', tip: 'n次根' },
|
|
|
+ { label: '∛x', value: '\\sqrt[3]{x}', tip: '立方根' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'integral', label: '积分', icon: '<i style="font-style:normal">∫</i>',
|
|
|
+ items: [
|
|
|
+ { label: '∫', value: '\\int_{a}^{b} f(x)\\,dx', tip: '定积分' },
|
|
|
+ { label: '∬', value: '\\iint_{D} f(x,y)\\,dx\\,dy', tip: '二重积分' },
|
|
|
+ { label: '∭', value: '\\iiint_{V} f\\,dV', tip: '三重积分' },
|
|
|
+ { label: '∮', value: '\\oint_{C} f\\,ds', tip: '曲线积分' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'large', label: '大型运算符', icon: '<i style="font-style:normal">Σ</i>',
|
|
|
+ items: [
|
|
|
+ { label: 'Σ 求和', value: '\\sum_{i=1}^{n} x_i', tip: '求和' },
|
|
|
+ { label: 'Π 连乘', value: '\\prod_{i=1}^{n} x_i', tip: '连乘积' },
|
|
|
+ { label: 'lim 极限', value: '\\lim_{x \\to 0} f(x)', tip: '极限' },
|
|
|
+ { label: 'max', value: '\\max_{x \\in D}', tip: '最大值' },
|
|
|
+ { label: 'min', value: '\\min_{x \\in D}', tip: '最小值' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'bracket', label: '括号', icon: '<i style="font-style:normal">{()}</i>',
|
|
|
+ items: [
|
|
|
+ { label: '()', value: '\\left( x \\right)', tip: '圆括号' },
|
|
|
+ { label: '[]', value: '\\left[ x \\right]', tip: '方括号' },
|
|
|
+ { label: '{}', value: '\\left\\{ x \\right\\}', tip: '花括号' },
|
|
|
+ { label: '||', value: '\\left| x \\right|', tip: '绝对值' },
|
|
|
+ { label: '‖‖', value: '\\left\\| x \\right\\|', tip: '范数' },
|
|
|
+ { label: '矩阵()', value: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}', tip: '圆括号矩阵' },
|
|
|
+ { label: '矩阵[]', value: '\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}', tip: '方括号矩阵' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'func', label: '函数', icon: '<i style="font-style:normal">sinθ</i>',
|
|
|
+ items: [
|
|
|
+ { label: 'sin', value: '\\sin\\theta', tip: '正弦' },
|
|
|
+ { label: 'cos', value: '\\cos\\theta', tip: '余弦' },
|
|
|
+ { label: 'tan', value: '\\tan\\theta', tip: '正切' },
|
|
|
+ { label: 'ln', value: '\\ln x', tip: '自然对数' },
|
|
|
+ { label: 'log', value: '\\log_{a} x', tip: '对数' },
|
|
|
+ { label: 'lg', value: '\\lg x', tip: '常用对数' },
|
|
|
+ { label: '∂', value: '\\frac{\\partial f}{\\partial x}', tip: '偏导数' },
|
|
|
+ { label: "f'", value: "f'(x)", tip: '导数' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ editorInit: {
|
|
|
+ language_url: staticBase + 'tinymce/langs/zh_CN.js',
|
|
|
+ language: 'zh_CN',
|
|
|
+ skin_url: staticBase + 'tinymce/skins/ui/oxide',
|
|
|
+ content_css: staticBase + 'tinymce/skins/content/default/content.css',
|
|
|
+ // 编辑区样式:公式 span 不可编辑,光标正常显示
|
|
|
+ content_style: `
|
|
|
+ .math-formula {
|
|
|
+ display: inline-block;
|
|
|
+ cursor: default;
|
|
|
+ user-select: none;
|
|
|
+ background: #f0f4ff;
|
|
|
+ border-radius: 3px;
|
|
|
+ padding: 1px 4px;
|
|
|
+ font-size: 0.95em;
|
|
|
+ }
|
|
|
+ `,
|
|
|
+ // 禁止 TinyMCE 把 data URI 图片自动上传(公式以 span 文本形式存储,不产生 data URI)
|
|
|
+ automatic_uploads: false,
|
|
|
+ images_reuse_filename: true,
|
|
|
+ // 指定 emoticons 数据文件路径,避免动态加载时 404
|
|
|
+ emoticons_database_url: staticBase + 'tinymce/plugins/emoticons/js/emojis.js',
|
|
|
+ height: this.height,
|
|
|
+ menubar: false,
|
|
|
+ plugins: 'advlist autolink autoresize autosave charmap code codesample directionality emoticons fullscreen help image importcss insertdatetime link lists media nonbreaking pagebreak preview quickbars save searchreplace table template visualblocks visualchars wordcount',
|
|
|
+ toolbar:
|
|
|
+ 'table | codesample | latex | code | preview | fullscreen | image | media | attachment | ' +
|
|
|
+ 'fontsizeselect | forecolor | ' +
|
|
|
+ 'bold italic | ' +
|
|
|
+ 'alignleft aligncenter alignright alignjustify | ' +
|
|
|
+ 'bullist numlist | removeformat',
|
|
|
+ toolbar_mode: 'wrap',
|
|
|
+ quickbars_selection_toolbar: 'bold italic underline | quicklink h2 h3 blockquote',
|
|
|
+ quickbars_insert_toolbar: false,
|
|
|
+ font_formats:
|
|
|
+ '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;' +
|
|
|
+ '宋体=SimSun,serif;黑体=SimHei,sans-serif;楷体=KaiTi,serif;' +
|
|
|
+ '仿宋=FangSong,serif;Arial=arial,helvetica,sans-serif;' +
|
|
|
+ 'Times New Roman=times new roman,times,serif;',
|
|
|
+ fontsize_formats: '12px 14px 16px 18px 20px 24px 28px 32px 36px',
|
|
|
+ codesample_languages: [
|
|
|
+ { text: 'HTML/XML', value: 'markup' },
|
|
|
+ { text: 'JavaScript', value: 'javascript' },
|
|
|
+ { text: 'CSS', value: 'css' },
|
|
|
+ { text: 'Python', value: 'python' },
|
|
|
+ { text: 'Java', value: 'java' },
|
|
|
+ { text: 'C', value: 'c' },
|
|
|
+ { text: 'C++', value: 'cpp' },
|
|
|
+ { text: 'SQL', value: 'sql' },
|
|
|
+ ],
|
|
|
+ paste_data_images: true,
|
|
|
+ autosave_interval: '30s',
|
|
|
+ autosave_prefix: 'tinymce-autosave-{path}{query}-{id}-',
|
|
|
+ autosave_restore_when_empty: false,
|
|
|
+ images_upload_handler: (blobInfo) =>
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
+ if (this.uploadUrl) {
|
|
|
+ const xhr = new XMLHttpRequest()
|
|
|
+ xhr.open('POST', this.uploadUrl)
|
|
|
+ Object.keys(this.uploadHeaders).forEach(k => xhr.setRequestHeader(k, this.uploadHeaders[k]))
|
|
|
+ xhr.onload = () => {
|
|
|
+ if (xhr.status < 200 || xhr.status >= 300) { reject('上传失败: ' + xhr.status); return }
|
|
|
+ try {
|
|
|
+ const res = JSON.parse(xhr.responseText)
|
|
|
+ // 兼容 { url } / { data: { url } } / { data: url }
|
|
|
+ const url = res.url || (res.data && (res.data.url || res.data)) || ''
|
|
|
+ if (url) resolve(url)
|
|
|
+ else reject('接口未返回图片地址')
|
|
|
+ } catch (e) { reject('接口响应解析失败') }
|
|
|
+ }
|
|
|
+ xhr.onerror = () => reject('网络错误')
|
|
|
+ const fd = new FormData()
|
|
|
+ fd.append('file', blobInfo.blob(), blobInfo.filename())
|
|
|
+ xhr.send(fd)
|
|
|
+ } else {
|
|
|
+ const reader = new FileReader()
|
|
|
+ reader.onload = () => resolve(reader.result)
|
|
|
+ reader.onerror = () => reject('图片读取失败')
|
|
|
+ reader.readAsDataURL(blobInfo.blob())
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ setup: (editor) => {
|
|
|
+ editor.ui.registry.addButton('latex', {
|
|
|
+ text: '公式',
|
|
|
+ tooltip: '插入公式',
|
|
|
+ icon: 'insert-character',
|
|
|
+ onAction: () => {
|
|
|
+ this.latexInput = ''
|
|
|
+ this.presetOpen = false
|
|
|
+ this.activeCat = null
|
|
|
+ this.latexDialogVisible = true
|
|
|
+ this._editor = editor
|
|
|
+ }
|
|
|
+ })
|
|
|
+ editor.ui.registry.addButton('attachment', {
|
|
|
+ text: '',
|
|
|
+ tooltip: '插入图片',
|
|
|
+ icon: 'image',
|
|
|
+ onAction: () => {
|
|
|
+ const input = document.createElement('input')
|
|
|
+ input.type = 'file'
|
|
|
+ input.accept = 'image/jpeg,image/png,image/jpg'
|
|
|
+ input.onchange = (e) => {
|
|
|
+ const file = e.target.files[0]
|
|
|
+ if (!file) return
|
|
|
+ // 类型校验
|
|
|
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg']
|
|
|
+ if (!allowedTypes.includes(file.type)) {
|
|
|
+ alert('仅支持 jpg / jpeg / png 格式的图片')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 大小校验:30MB
|
|
|
+ if (file.size > 30 * 1024 * 1024) {
|
|
|
+ alert('图片大小不能超过 30MB')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (this.uploadUrl) {
|
|
|
+ const xhr = new XMLHttpRequest()
|
|
|
+ xhr.open('POST', this.uploadUrl)
|
|
|
+ Object.keys(this.uploadHeaders).forEach(k => xhr.setRequestHeader(k, this.uploadHeaders[k]))
|
|
|
+ xhr.onload = () => {
|
|
|
+ if (xhr.status < 200 || xhr.status >= 300) {
|
|
|
+ alert('上传失败: ' + xhr.status)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = JSON.parse(xhr.responseText)
|
|
|
+ const url = res.url || (res.data && (res.data.url || res.data)) || ''
|
|
|
+ if (url) editor.insertContent(`<img src="${url}" alt="${file.name}" style="max-width:100%;" /> `)
|
|
|
+ else alert('接口未返回图片地址')
|
|
|
+ } catch (err) { alert('接口响应解析失败') }
|
|
|
+ }
|
|
|
+ xhr.onerror = () => alert('网络错误,上传失败')
|
|
|
+ const fd = new FormData()
|
|
|
+ fd.append('file', file)
|
|
|
+ xhr.send(fd)
|
|
|
+ } else {
|
|
|
+ // 未配置接口时转 base64 预览
|
|
|
+ const reader = new FileReader()
|
|
|
+ reader.onload = (ev) => {
|
|
|
+ editor.insertContent(`<img src="${ev.target.result}" alt="${file.name}" style="max-width:100%;" /> `)
|
|
|
+ }
|
|
|
+ reader.readAsDataURL(file)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ input.click()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ editor.on('init', () => {
|
|
|
+ this._editor = editor
|
|
|
+ })
|
|
|
+ },
|
|
|
+ branding: false,
|
|
|
+ promotion: false,
|
|
|
+ statusbar: true,
|
|
|
+ resize: true,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ editorContent: {
|
|
|
+ get() { return this.value },
|
|
|
+ set(val) { this.$emit('input', val) }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ tinymce.init({})
|
|
|
+ document.addEventListener('click', this.closeDropdowns)
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ document.removeEventListener('click', this.closeDropdowns)
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ closeDropdowns(e) {
|
|
|
+ if (!this.$el.contains(e.target)) {
|
|
|
+ this.presetOpen = false
|
|
|
+ this.activeCat = null
|
|
|
+ }
|
|
|
+ },
|
|
|
+ applyPreset(value) {
|
|
|
+ this.latexInput = value
|
|
|
+ this.presetOpen = false
|
|
|
+ this.$nextTick(() => this.renderPreview())
|
|
|
+ },
|
|
|
+ // 在光标处插入文本(textarea)
|
|
|
+ insertAtCursor(text) {
|
|
|
+ const el = this.$refs.fmInput
|
|
|
+ if (!el) { this.latexInput += text; return }
|
|
|
+ const start = el.selectionStart
|
|
|
+ const end = el.selectionEnd
|
|
|
+ this.latexInput = this.latexInput.slice(0, start) + text + this.latexInput.slice(end)
|
|
|
+ this.$nextTick(() => {
|
|
|
+ el.focus()
|
|
|
+ const pos = start + text.length
|
|
|
+ el.setSelectionRange(pos, pos)
|
|
|
+ this.renderPreview()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ onInputChange() {
|
|
|
+ this.renderPreview()
|
|
|
+ },
|
|
|
+ renderPreview() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const el = this.$refs.fmPreview
|
|
|
+ if (!el || !window.MathJax) return
|
|
|
+ window.MathJax.typesetClear([el])
|
|
|
+ window.MathJax.typesetPromise([el]).catch(() => {})
|
|
|
+ })
|
|
|
+ },
|
|
|
+ typeset(editor) {
|
|
|
+ try {
|
|
|
+ const body = editor.getBody()
|
|
|
+ if (window.MathJax && window.MathJax.typesetPromise && body) {
|
|
|
+ window.MathJax.typesetClear([body])
|
|
|
+ window.MathJax.typesetPromise([body]).catch(() => {})
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ },
|
|
|
+ insertLatex() {
|
|
|
+ if (!this.latexInput.trim()) return
|
|
|
+ const editor = this._editor
|
|
|
+ if (!editor) return
|
|
|
+ const latex = this.latexInput.trim()
|
|
|
+ this.latexDialogVisible = false
|
|
|
+ // 用主页面已加载的 MathJax 3 将 LaTeX 转成 SVG,以 <img> 插入编辑区
|
|
|
+ // 避免 iframe 内独立加载 MathJax 的时序问题
|
|
|
+ const doInsert = () => {
|
|
|
+ const mj = window.MathJax
|
|
|
+ if (mj && mj.tex2svgPromise) {
|
|
|
+ mj.tex2svgPromise(latex, { display: true }).then(node => {
|
|
|
+ const svg = node.querySelector('svg')
|
|
|
+ if (!svg) { this._insertLatexFallback(editor, latex); return }
|
|
|
+ // 给 SVG 加固定宽高,避免尺寸为 0
|
|
|
+ if (!svg.getAttribute('width') || svg.getAttribute('width') === '0') {
|
|
|
+ svg.setAttribute('width', '200')
|
|
|
+ }
|
|
|
+ svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
|
+ const svgStr = svg.outerHTML
|
|
|
+ const dataUri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)))
|
|
|
+ editor.insertContent(
|
|
|
+ `<img class="math-formula-img" src="${dataUri}" data-latex="${encodeURIComponent(latex)}" alt="${latex}" style="vertical-align:middle;max-height:3em;display:inline-block;" /> `
|
|
|
+ )
|
|
|
+ }).catch(() => this._insertLatexFallback(editor, latex))
|
|
|
+ } else if (mj && mj.startup && mj.startup.promise) {
|
|
|
+ // MathJax 还未完成初始化,等待后重试
|
|
|
+ mj.startup.promise.then(() => this.insertLatex())
|
|
|
+ } else {
|
|
|
+ this._insertLatexFallback(editor, latex)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ doInsert()
|
|
|
+ },
|
|
|
+ _insertLatexFallback(editor, latex) {
|
|
|
+ // MathJax 不可用时退回文本形式
|
|
|
+ editor.insertContent(
|
|
|
+ `<span class="math-formula" contenteditable="false" data-latex="${encodeURIComponent(latex)}">\\(${latex}\\)</span> `
|
|
|
+ )
|
|
|
+ },
|
|
|
+ _typesetWhenReady(editor, attempts) {
|
|
|
+ attempts = attempts || 0
|
|
|
+ if (attempts > 40) return
|
|
|
+ try {
|
|
|
+ const iframeWin = this._iframeWin || editor.getWin()
|
|
|
+ if (iframeWin.MathJax && iframeWin.MathJax.typesetPromise) {
|
|
|
+ iframeWin.MathJax.typesetPromise([editor.getBody()]).catch(() => {})
|
|
|
+ } else {
|
|
|
+ setTimeout(() => this._typesetWhenReady(editor, attempts + 1), 100)
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.tinymce-container {
|
|
|
+ width: 100%;
|
|
|
+ line-height: normal;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== 弹窗遮罩 ===== */
|
|
|
+.fm-mask {
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ background: rgba(0,0,0,0.5);
|
|
|
+ z-index: 99999;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.fm-dialog {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 6px;
|
|
|
+ box-shadow: 0 6px 30px rgba(0,0,0,0.2);
|
|
|
+ width: 720px;
|
|
|
+ max-width: 96vw;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ max-height: 90vh;
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== 标题栏 ===== */
|
|
|
+.fm-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 18px;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #333;
|
|
|
+ border-bottom: 1px solid #e4e4e4;
|
|
|
+ background: #f7f7f7;
|
|
|
+ border-radius: 6px 6px 0 0;
|
|
|
+}
|
|
|
+.fm-close {
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 22px;
|
|
|
+ color: #aaa;
|
|
|
+ line-height: 1;
|
|
|
+ padding: 0 2px;
|
|
|
+}
|
|
|
+.fm-close:hover { color: #333; }
|
|
|
+
|
|
|
+/* ===== 工具栏 ===== */
|
|
|
+.fm-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-bottom: 1px solid #e4e4e4;
|
|
|
+ background: #fafafa;
|
|
|
+}
|
|
|
+.fm-divider {
|
|
|
+ width: 1px;
|
|
|
+ height: 28px;
|
|
|
+ background: #ddd;
|
|
|
+ margin: 0 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 预设 */
|
|
|
+.fm-preset-wrap { position: relative; }
|
|
|
+.fm-preset-btn {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 4px 10px;
|
|
|
+ border: 1px solid #ccc;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #e8f4e8;
|
|
|
+ cursor: pointer;
|
|
|
+ min-width: 48px;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+.fm-preset-btn:hover { background: #d0ebd0; }
|
|
|
+.fm-preset-icon { font-size: 14px; font-weight: bold; color: #2a7a2a; line-height: 1.2; }
|
|
|
+.fm-preset-label { font-size: 11px; color: #2a7a2a; }
|
|
|
+.fm-preset-arrow { font-size: 10px; color: #2a7a2a; }
|
|
|
+.fm-preset-dropdown {
|
|
|
+ position: absolute;
|
|
|
+ top: calc(100% + 4px);
|
|
|
+ left: 0;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
|
|
+ z-index: 10;
|
|
|
+ min-width: 160px;
|
|
|
+}
|
|
|
+.fm-preset-item {
|
|
|
+ padding: 7px 14px;
|
|
|
+ font-size: 13px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #333;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.fm-preset-item:hover { background: #f0f7ff; color: #0183FB; }
|
|
|
+
|
|
|
+/* 符号格子 */
|
|
|
+.fm-symbol-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(9, 28px);
|
|
|
+ grid-template-rows: repeat(2, 26px);
|
|
|
+ gap: 2px;
|
|
|
+}
|
|
|
+.fm-sym-btn {
|
|
|
+ width: 28px;
|
|
|
+ height: 26px;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 3px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #333;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+.fm-sym-btn:hover { background: #e8f0fe; border-color: #0183FB; color: #0183FB; }
|
|
|
+
|
|
|
+/* 分类按钮组 */
|
|
|
+.fm-category-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.fm-cat-wrap { position: relative; }
|
|
|
+.fm-cat-btn {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 3px 8px;
|
|
|
+ border: 1px solid #ccc;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ min-width: 44px;
|
|
|
+ user-select: none;
|
|
|
+ gap: 1px;
|
|
|
+}
|
|
|
+.fm-cat-btn:hover, .fm-cat-btn.active { background: #e8f0fe; border-color: #0183FB; }
|
|
|
+.fm-cat-icon { font-size: 14px; color: #333; line-height: 1; }
|
|
|
+.fm-cat-label { font-size: 11px; color: #555; }
|
|
|
+.fm-cat-arrow { font-size: 9px; color: #888; }
|
|
|
+.fm-cat-dropdown {
|
|
|
+ position: absolute;
|
|
|
+ top: calc(100% + 4px);
|
|
|
+ left: 0;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
|
|
+ z-index: 10;
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 8px;
|
|
|
+ min-width: 160px;
|
|
|
+}
|
|
|
+.fm-cat-item {
|
|
|
+ padding: 5px 10px;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #333;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.fm-cat-item:hover { background: #e8f0fe; border-color: #0183FB; color: #0183FB; }
|
|
|
+
|
|
|
+/* ===== 输入区 ===== */
|
|
|
+.fm-body {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ gap: 0;
|
|
|
+ min-height: 200px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.fm-input {
|
|
|
+ flex: 1;
|
|
|
+ border: none;
|
|
|
+ border-right: 1px solid #e4e4e4;
|
|
|
+ outline: none;
|
|
|
+ padding: 20px;
|
|
|
+ font-size: 16px;
|
|
|
+ font-family: 'Courier New', monospace;
|
|
|
+ resize: none;
|
|
|
+ color: #333;
|
|
|
+ background: #fff;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+.fm-input::placeholder {
|
|
|
+ color: #bbb;
|
|
|
+ font-family: inherit;
|
|
|
+}
|
|
|
+.fm-preview {
|
|
|
+ flex: 1;
|
|
|
+ padding: 20px;
|
|
|
+ background: #fafafa;
|
|
|
+ font-size: 18px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ min-height: 120px;
|
|
|
+ overflow: auto;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+.fm-preview-empty { color: #ccc; font-size: 14px; font-family: sans-serif; }
|
|
|
+
|
|
|
+/* ===== 底部 ===== */
|
|
|
+.fm-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 12px 18px;
|
|
|
+ border-top: 1px solid #e4e4e4;
|
|
|
+ background: #f7f7f7;
|
|
|
+ border-radius: 0 0 6px 6px;
|
|
|
+}
|
|
|
+.fm-btn-close, .fm-btn-save {
|
|
|
+ padding: 7px 28px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 14px;
|
|
|
+ cursor: pointer;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ outline: none;
|
|
|
+}
|
|
|
+.fm-btn-close { background: #fff; color: #555; }
|
|
|
+.fm-btn-close:hover { background: #f0f0f0; }
|
|
|
+.fm-btn-save { background: #0183FB; border-color: #0183FB; color: #fff; }
|
|
|
+.fm-btn-save:hover { background: #0072e0; }
|
|
|
+</style>
|