md-wechat/src/views/CodemirrorEditor.vue
2024-08-27 19:47:02 +08:00

530 lines
14 KiB
Vue

<script setup>
import { onMounted, ref, toRaw } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import CodeMirror from 'codemirror'
import fileApi from '@/utils/file'
import { useStore } from '@/stores'
import EditorHeader from '@/components/CodemirrorEditor/EditorHeader/index.vue'
import InsertFormDialog from '@/components/CodemirrorEditor/InsertFormDialog.vue'
import UploadImgDialog from '@/components/CodemirrorEditor/UploadImgDialog.vue'
import CssEditor from '@/components/CodemirrorEditor/CssEditor.vue'
import RunLoading from '@/components/RunLoading.vue'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { altKey, altSign, ctrlKey, shiftKey, shiftSign } from '@/config'
import {
checkImage,
formatDoc,
toBase64,
} from '@/utils'
import 'codemirror/mode/javascript/javascript'
const store = useStore()
const { output, editor, editorContent, isShowCssEditor } = storeToRefs(store)
const {
editorRefresh,
exportEditorContent2HTML,
exportEditorContent2MD,
formatContent,
importMarkdownContent,
resetStyleConfirm,
toggleShowInsertFormDialog,
toggleShowUploadImgDialog,
} = store
const isImgLoading = ref(false)
const timeout = ref(0)
const preview = ref(null)
// 使浏览区与编辑区滚动条建立同步联系
function leftAndRightScroll() {
const scrollCB = (text) => {
let source, target
clearTimeout(timeout.value)
if (text === `preview`) {
source = preview.value.$el
target = document.querySelector(`.CodeMirror-scroll`)
editor.value.off(`scroll`, editorScrollCB)
timeout.value = setTimeout(() => {
editor.value.on(`scroll`, editorScrollCB)
}, 300)
}
else if (text === `editor`) {
source = document.querySelector(`.CodeMirror-scroll`)
target = preview.value.$el
target.removeEventListener(`scroll`, previewScrollCB, false)
timeout.value = setTimeout(() => {
target.addEventListener(`scroll`, previewScrollCB, false)
}, 300)
}
const percentage
= source.scrollTop / (source.scrollHeight - source.offsetHeight)
const height = percentage * (target.scrollHeight - target.offsetHeight)
target.scrollTo(0, height)
}
function editorScrollCB() {
scrollCB(`editor`)
}
function previewScrollCB() {
scrollCB(`preview`)
}
preview.value.$el.addEventListener(`scroll`, previewScrollCB, false)
editor.value.on(`scroll`, editorScrollCB)
}
onMounted(() => {
setTimeout(() => {
leftAndRightScroll()
}, 300)
})
// 更新编辑器
function onEditorRefresh() {
editorRefresh()
}
const backLight = ref(false)
const isCoping = ref(false)
function startCopy() {
isCoping.value = true
backLight.value = true
}
// 拷贝结束
function endCopy() {
backLight.value = false
setTimeout(() => {
isCoping.value = false
}, 800)
}
function beforeUpload(file) {
// validate image
const checkResult = checkImage(file)
if (!checkResult.ok) {
ElMessage.error(checkResult.msg)
return false
}
// check image host
const imgHost = localStorage.getItem(`imgHost`) || `default`
localStorage.setItem(`imgHost`, imgHost)
const config = localStorage.getItem(`${imgHost}Config`)
const isValidHost = imgHost === `default` || config
if (!isValidHost) {
ElMessage.error(`请先配置 ${imgHost} 图床参数`)
return false
}
return true
}
// 图片上传结束
function uploaded(imageUrl) {
if (!imageUrl) {
ElMessage.error(`上传图片未知异常`)
return
}
toggleShowUploadImgDialog(false)
// 上传成功,获取光标
const cursor = editor.value.getCursor()
const markdownImage = `![](${imageUrl})`
// 将 Markdown 形式的 URL 插入编辑框光标所在位置
toRaw(store.editor).replaceSelection(`\n${markdownImage}\n`, cursor)
ElMessage.success(`图片上传成功`)
}
function uploadImage(file, cb) {
isImgLoading.value = true
toBase64(file)
.then(base64Content => fileApi.fileUpload(base64Content, file))
.then((url) => {
console.log(url)
if (cb) {
cb(url)
}
else {
uploaded(url)
}
})
.catch((err) => {
ElMessage.error(err.message)
})
.finally(() => {
isImgLoading.value = false
})
}
const changeTimer = ref(0)
// 初始化编辑器
function initEditor() {
const editorDom = document.querySelector(`#editor`)
if (!editorDom.value) {
editorDom.value = editorContent.value
}
editor.value = CodeMirror.fromTextArea(editorDom, {
mode: `text/x-markdown`,
theme: `xq-light`,
lineNumbers: false,
lineWrapping: true,
styleActiveLine: true,
autoCloseBrackets: true,
extraKeys: {
[`${shiftKey}-${altKey}-F`]: function autoFormat(editor) {
const doc = formatDoc(editor.getValue(0))
editor.setValue(doc)
},
[`${ctrlKey}-B`]: function bold(editor) {
const selected = editor.getSelection()
editor.replaceSelection(`**${selected}**`)
},
[`${ctrlKey}-I`]: function italic(editor) {
const selected = editor.getSelection()
editor.replaceSelection(`*${selected}*`)
},
[`${ctrlKey}-D`]: function del(editor) {
const selected = editor.getSelection()
editor.replaceSelection(`~~${selected}~~`)
},
[`${ctrlKey}-K`]: function italic(editor) {
const selected = editor.getSelection()
editor.replaceSelection(`[${selected}]()`)
},
[`${ctrlKey}-E`]: function code(editor) {
const selected = editor.getSelection()
editor.replaceSelection(`\`${selected}\``)
},
// 预备弃用
[`${ctrlKey}-L`]: function code(editor) {
const selected = editor.getSelection()
editor.replaceSelection(`\`${selected}\``)
},
},
})
editor.value.on(`change`, (e) => {
clearTimeout(changeTimer.value)
changeTimer.value = setTimeout(() => {
onEditorRefresh()
editorContent.value = e.getValue()
}, 300)
})
// 粘贴上传图片并插入
editor.value.on(`paste`, (cm, e) => {
if (!(e.clipboardData && e.clipboardData.items) || isImgLoading.value) {
return
}
for (let i = 0, len = e.clipboardData.items.length; i < len; ++i) {
const item = e.clipboardData.items[i]
if (item.kind === `file`) {
// 校验图床参数
const pasteFile = item.getAsFile()
const isValid = beforeUpload(pasteFile)
if (!isValid) {
continue
}
uploadImage(pasteFile)
}
}
})
}
const container = ref(null)
// 工具函数,添加格式
function addFormat(cmd) {
editor.value.options.extraKeys[cmd](editor.value)
}
const codeMirrorWrapper = ref(null)
// 转换 markdown 中的本地图片为线上图片
// todo 处理事件覆盖
function mdLocalToRemote() {
const dom = codeMirrorWrapper.value.$el
// 上传 md 中的图片
const uploadMdImg = async ({ md, list }) => {
const mdImgList = [
...(md.str.matchAll(/!\[(.*?)\]\((.*?)\)/g) || []),
].filter((item) => {
return item // 获取所有相对地址的图片
})
const root = md.path.match(/.+?\//)[0]
const resList = await Promise.all(
mdImgList.map((item) => {
return new Promise((resolve) => {
let [, , matchStr] = item
matchStr = matchStr.replace(/^.\//, ``) // 处理 ./img/ 为 img/ 统一相对路径风格
const { file }
= list.find(f => f.path === `${root}${matchStr}`) || {}
uploadImage(file, (url) => {
resolve({ matchStr, url })
})
})
}),
)
resList.forEach((item) => {
md.str = md.str
.replace(`](./${item.matchStr})`, `](${item.url})`)
.replace(`](${item.matchStr})`, `](${item.url})`)
})
editor.value.setValue(md.str)
console.log(`resList`, resList, md.str)
}
dom.ondragover = evt => evt.preventDefault()
dom.ondrop = async (evt) => {
evt.preventDefault()
for (const item of evt.dataTransfer.items) {
item.getAsFileSystemHandle().then(async (handle) => {
if (handle.kind === `directory`) {
const list = await showFileStructure(handle)
const md = await getMd({ list })
uploadMdImg({ md, list })
}
else {
const file = await handle.getFile()
console.log(`file`, file)
}
})
}
}
// 从文件列表中查找一个 md 文件并解析
async function getMd({ list }) {
return new Promise((resolve) => {
const { path, file } = list.find(item => item.path.match(/\.md$/))
const reader = new FileReader()
reader.readAsText(file, `UTF-8`)
reader.onload = (evt) => {
resolve({
str: evt.target.result,
file,
path,
})
}
})
}
// 转换文件系统句柄中的文件为文件列表
async function showFileStructure(root) {
const result = []
let cwd = ``
try {
const dirs = [root]
for (const dir of dirs) {
cwd += `${dir.name}/`
for await (const [, handle] of dir) {
if (handle.kind === `file`) {
result.push({
path: cwd + handle.name,
file: await handle.getFile(),
})
}
else {
result.push({
path: `${cwd + handle.name}/`,
})
dirs.push(handle)
}
}
}
}
catch (err) {
console.error(err)
}
return result
}
}
onMounted(() => {
initEditor()
onEditorRefresh()
mdLocalToRemote()
})
</script>
<template>
<div ref="container" class="container">
<el-container>
<el-header class="editor__header">
<EditorHeader
@add-format="addFormat"
@format-content="formatContent"
@start-copy="startCopy"
@end-copy="endCopy"
/>
</el-header>
<el-main class="container-main">
<el-row class="container-main-section">
<el-col
ref="codeMirrorWrapper"
:span="isShowCssEditor ? 8 : 12"
class="codeMirror-wrapper"
:class="{
'order-1': !store.isEditOnLeft,
}"
>
<ContextMenu>
<ContextMenuTrigger>
<textarea
id="editor"
type="textarea"
placeholder="Your markdown text here."
/>
</ContextMenuTrigger>
<ContextMenuContent class="w-64">
<ContextMenuItem inset @click="toggleShowUploadImgDialog()">
上传图片
</ContextMenuItem>
<ContextMenuItem inset @click="toggleShowInsertFormDialog()">
插入表格
</ContextMenuItem>
<ContextMenuItem inset @click="resetStyleConfirm()">
恢复默认样式
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem inset @click="importMarkdownContent()">
导入 .md 文档
</ContextMenuItem>
<ContextMenuItem inset @click="exportEditorContent2MD()">
导出 .md 文档
</ContextMenuItem>
<ContextMenuItem inset @click="exportEditorContent2HTML()">
导出 .html
</ContextMenuItem>
<ContextMenuItem inset @click="formatContent()">
格式化
<ContextMenuShortcut>{{ altSign }} + {{ shiftSign }} + F</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</el-col>
<el-col
id="preview"
ref="preview"
:span="isShowCssEditor ? 8 : 12"
class="preview-wrapper"
>
<div id="output-wrapper" :class="{ output_night: !backLight }">
<div class="preview">
<section id="output" v-html="output" />
<div v-if="isCoping" class="loading-mask">
<div class="loading-mask-box">
<div class="loading__img" />
<span>正在生成</span>
</div>
</div>
</div>
</div>
</el-col>
<CssEditor />
</el-row>
</el-main>
</el-container>
<UploadImgDialog
@before-upload="beforeUpload"
@upload-image="uploadImage"
@uploaded="uploaded"
/>
<InsertFormDialog />
<RunLoading />
</div>
</template>
<style lang="less" scoped>
@import url('../assets/less/app.less');
</style>
<style lang="less" scoped>
.container {
height: 100%;
min-width: 100%;
padding: 0;
}
.container-main {
padding: 20px;
padding-top: 0;
}
.container-main-section {
height: 100%;
}
#output-wrapper {
position: relative;
user-select: text;
height: 100%;
}
.loading-mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
text-align: center;
color: var(--el-text-color-regular);
background-color: var(--el-bg-color);
.loading-mask-box {
position: sticky;
top: 50%;
transform: translateY(-50%);
.loading__img {
width: 75px;
height: 75px;
background: url('../assets/images/favicon.png') no-repeat;
margin: 1em auto;
background-size: cover;
}
}
}
:deep(.preview-table) {
border-spacing: 0;
}
.codeMirror-wrapper,
.preview-wrapper {
height: 100%;
}
.codeMirror-wrapper {
overflow-x: auto;
}
</style>