mirror of
https://github.com/blossom-editor/blossom
synced 2024-11-17 22:48:03 +08:00
fix: 修改文章渲染保存逻辑
This commit is contained in:
parent
93b84abd99
commit
c673aac932
@ -53,14 +53,48 @@
|
||||
<div v-if="!curArticle" class="ep-placeholder">
|
||||
<ArticleIndexPlaceholder></ArticleIndexPlaceholder>
|
||||
</div>
|
||||
<div class="operator" ref="EditorOperatorRef">
|
||||
<el-tooltip
|
||||
:content="'同步滚动:' + (editorOperator.sycnScroll ? '开启' : '关闭')"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div
|
||||
class="iconbl bl-scroll"
|
||||
:style="{ color: editorOperator.sycnScroll ? 'var(--el-color-primary-light-3)' : '' }"
|
||||
@click="handleSyncScroll"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往顶部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleonline-line" @click="scrollTop"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往底部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleunderline-line" @click="scrollBottom"></div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="gutter-holder" ref="GutterHolderRef"></div>
|
||||
<div class="editor-codemirror" ref="EditorRef" @click.right="handleEditorClickRight"></div>
|
||||
<div class="resize-divider" ref="ResizeDividerRef"></div>
|
||||
<div class="preview-marked bl-preview" ref="PreviewRef" v-html="articleHtml"></div>
|
||||
<el-backtop target=".editor-codemirror" :right="20" :bottom="90"><div class="iconbl bl-a-doubleonline-line backtop"></div></el-backtop>
|
||||
<el-backtop target=".editor-codemirror" :right="20" :bottom="45" :visibility-height="0" @click="scrollBottom">
|
||||
<div class="iconbl bl-a-doubleunderline-line backbottom"></div>
|
||||
</el-backtop>
|
||||
</div>
|
||||
|
||||
<!-- status -->
|
||||
@ -169,7 +203,7 @@ import hotkeys from 'hotkeys-js'
|
||||
import Notify from '@renderer/scripts/notify'
|
||||
import { useDraggable } from '@renderer/scripts/draggable'
|
||||
import type { shortcutFunc } from '@renderer/scripts/shortcut-register'
|
||||
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo } from '@renderer/views/doc/doc'
|
||||
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo, isArticle } from '@renderer/views/doc/doc'
|
||||
import { TempTextareaKey, ArticleReference, DocEditorStyle, parseTocAsync } from './scripts/article'
|
||||
import type { Toc } from './scripts/article'
|
||||
import { beforeUpload, onError, picCacheWrapper, picCacheRefresh, uploadForm, uploadDate } from '@renderer/views/picture/scripts/picture'
|
||||
@ -177,21 +211,14 @@ import { useResize } from './scripts/editor-preview-resize'
|
||||
// codemirror
|
||||
import { CmWrapper } from './scripts/codemirror'
|
||||
// marked
|
||||
import marked, {
|
||||
renderBlockquote,
|
||||
renderCode,
|
||||
renderCodespan,
|
||||
renderHeading,
|
||||
renderImage,
|
||||
renderTable,
|
||||
tokenizerCodespan,
|
||||
renderLink
|
||||
} from './scripts/markedjs'
|
||||
import marked, { renderBlockquote, renderCode, renderCodespan, renderHeading, renderImage, renderTable, renderLink } from './scripts/markedjs'
|
||||
import { EPScroll } from './scripts/editor-preview-scroll'
|
||||
import { useArticleHtmlEvent } from './scripts/article-html-event'
|
||||
import { shallowRef } from 'vue'
|
||||
import { keymaps } from './scripts/editor-tools'
|
||||
|
||||
//#region -- mounted
|
||||
|
||||
const PictureViewerInfo = defineAsyncComponent(() => import('@renderer/views/picture/PictureViewerInfo.vue'))
|
||||
// const EditorTools = defineAsyncComponent(() => import('./EditorTools.vue'))
|
||||
const EditorStatus = defineAsyncComponent(() => import('./EditorStatus.vue'))
|
||||
@ -225,7 +252,6 @@ onDeactivated(() => {
|
||||
unbindKeys()
|
||||
})
|
||||
|
||||
//#region ----------------------------------------< panin store >--------------------------------------
|
||||
const userStore = useUserStore()
|
||||
const serverStore = useServerStore()
|
||||
const { editorStyle } = useConfigStore()
|
||||
@ -238,14 +264,19 @@ watch(
|
||||
setNewState('')
|
||||
}
|
||||
)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 公共参数和页面动态样式 >--------------------------------------
|
||||
const GutterHolderRef = ref() // editor gutter holder
|
||||
const EditorRef = ref() // editor
|
||||
const ResizeDividerRef = ref() // editor&preview resize dom
|
||||
const EditorOperatorRef = ref()
|
||||
const PreviewRef = ref() // html 预览
|
||||
|
||||
const editorOperator = ref({
|
||||
syncParse: true,
|
||||
sycnScroll: true
|
||||
})
|
||||
/**
|
||||
* 文档列表的展开和收起
|
||||
*/
|
||||
@ -268,7 +299,8 @@ const changeEditorPreviewStyle = () => {
|
||||
GutterHolderRef.value.style.width = '0px'
|
||||
EditorRef.value.style.width = '0px'
|
||||
PreviewRef.value.style.width = '100%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 20px'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
if (editorFullScreen) {
|
||||
@ -276,12 +308,15 @@ const changeEditorPreviewStyle = () => {
|
||||
EditorRef.value.style.width = 'calc(100% - 6px)'
|
||||
PreviewRef.value.style.width = '0'
|
||||
PreviewRef.value.style.padding = '0'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
GutterHolderRef.value.style.width = '50px'
|
||||
EditorRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 20px'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'block'
|
||||
EditorOperatorRef.value.style.left = 'calc(50% - 0.5px)'
|
||||
}
|
||||
/**
|
||||
* 临时文本框
|
||||
@ -322,6 +357,7 @@ const exitView = () => {
|
||||
autoSave()
|
||||
}
|
||||
|
||||
useResize(EditorRef, PreviewRef, ResizeDividerRef, EditorOperatorRef)
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 图片管理 >--------------------------------------
|
||||
@ -356,6 +392,41 @@ const uploadFile = (file: File) => {
|
||||
cmw.insertBlockCommand(`\n![${file.name}](${url})\n`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传回调
|
||||
* @param event DragEvent | ClipboardEvent
|
||||
*/
|
||||
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
|
||||
if (!isArticle(curArticle.value)) return
|
||||
|
||||
/**
|
||||
* 拖拽上传
|
||||
*/
|
||||
if (event instanceof DragEvent) {
|
||||
let data: DataTransfer | null = event.dataTransfer
|
||||
if (data && data.files.length && data.files.length > 0) {
|
||||
for (const file of data.files) {
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黏贴上传
|
||||
*/
|
||||
if (event instanceof ClipboardEvent) {
|
||||
if (!event.clipboardData) return
|
||||
if (event.clipboardData.items.length === 0) return
|
||||
for (let i = 0; i < event.clipboardData.items.length; i++) {
|
||||
const file: File | null = event.clipboardData.items[i].getAsFile()
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< html 事件监听 >----------------------------
|
||||
@ -403,7 +474,7 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
// 如果选中的是文章, 则查询文章详情, 用于在编辑器中显示以及注入
|
||||
if (doc.type == 3) {
|
||||
// 重复点击同一个, 不会多次查询
|
||||
if (curIsArticle() && curArticle.value!.id == doc.id) {
|
||||
if (isArticle(curArticle.value) && curArticle.value!.id == doc.id) {
|
||||
return
|
||||
}
|
||||
editorLoadingTimeout = setTimeout(() => (editorLoading.value = true), 100)
|
||||
@ -424,9 +495,7 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (editorLoadingTimeout) {
|
||||
clearTimeout(editorLoadingTimeout)
|
||||
}
|
||||
if (editorLoadingTimeout) clearTimeout(editorLoadingTimeout)
|
||||
editorLoading.value = false
|
||||
articleChanged = false
|
||||
})
|
||||
@ -441,22 +510,23 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
* @param auto 是否为自动保存, 如果是自动保存, 则不弹出保存成功的提示框, 避免在非用户主动操作下弹框
|
||||
*/
|
||||
const saveCurArticleContent = async (auto: boolean = false) => {
|
||||
if (!curIsArticle()) {
|
||||
if (!isArticle(curArticle.value)) {
|
||||
return
|
||||
}
|
||||
const saveCallback = () => {
|
||||
if (!auto) {
|
||||
ElMessage.success({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
|
||||
ElMessage.info({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
|
||||
}
|
||||
}
|
||||
// 如果文档发生变动才保存
|
||||
if (!articleChanged) {
|
||||
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;')
|
||||
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;padding-top:2px')
|
||||
saveCallback()
|
||||
return
|
||||
}
|
||||
// 如果文档正在解析中, 则等待解析完成
|
||||
while (articleParseing) {
|
||||
console.info('%c检测到正在解析, 等待解析完成', 'background:#AD7736;color:#fff;padding-top:2px')
|
||||
await sleep(100)
|
||||
}
|
||||
articleChanged = false
|
||||
@ -507,21 +577,6 @@ const distoryAutoSaveInterval = () => {
|
||||
const autoSave = () => {
|
||||
saveCurArticleContent(true)
|
||||
}
|
||||
/**
|
||||
* 判断当前选中的是否是文章
|
||||
*/
|
||||
const curIsArticle = (): boolean => {
|
||||
if (isNull(curArticle)) {
|
||||
return false
|
||||
}
|
||||
if (isNull(curArticle.value)) {
|
||||
return false
|
||||
}
|
||||
if (isNull(curArticle.value!.type) || curArticle.value!.type != 3) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@ -529,42 +584,7 @@ const curIsArticle = (): boolean => {
|
||||
let cmw: CmWrapper // codemirror editor wrapper
|
||||
|
||||
/**
|
||||
* 文件上传回调
|
||||
* @param event DragEvent | ClipboardEvent
|
||||
*/
|
||||
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
|
||||
if (!curIsArticle()) return
|
||||
|
||||
/**
|
||||
* 拖拽上传
|
||||
*/
|
||||
if (event instanceof DragEvent) {
|
||||
let data: DataTransfer | null = event.dataTransfer
|
||||
if (data && data.files.length && data.files.length > 0) {
|
||||
for (const file of data.files) {
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黏贴上传
|
||||
*/
|
||||
if (event instanceof ClipboardEvent) {
|
||||
if (!event.clipboardData) return
|
||||
if (event.clipboardData.items.length === 0) return
|
||||
for (let i = 0; i < event.clipboardData.items.length; i++) {
|
||||
const file: File | null = event.clipboardData.items[i].getAsFile()
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
* 初始化编辑器, 创建编辑器封装器, 并在编辑器底部增加一个空白页
|
||||
*/
|
||||
const initEditor = (_doc?: string) => {
|
||||
cmw = new CmWrapper(
|
||||
@ -573,7 +593,7 @@ const initEditor = (_doc?: string) => {
|
||||
CmWrapper.newState(
|
||||
() => {
|
||||
articleParseing = true
|
||||
debounce(parse, 300)
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback
|
||||
@ -593,7 +613,8 @@ const setNewState = (md: string): void => {
|
||||
() => {
|
||||
articleChanged = true
|
||||
articleParseing = true
|
||||
debounce(parse, 300)
|
||||
allwaysBottom()
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback,
|
||||
@ -603,6 +624,9 @@ const setNewState = (md: string): void => {
|
||||
parse()
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器底部增加空白占位元素, 点击占位元素会时会聚焦在编辑器
|
||||
*/
|
||||
const appendEditorHolder = () => {
|
||||
// 创建元素
|
||||
let editorHeightHolder = document.createElement('div')
|
||||
@ -616,11 +640,27 @@ const appendEditorHolder = () => {
|
||||
EditorRef.value.appendChild(editorHeightHolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动条永远置底
|
||||
*/
|
||||
const allwaysBottom = async () => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 100) {
|
||||
scrollWrapper.toBottom()
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< marked/preview >-------------------------------
|
||||
const renderInterval = ref(0) // 解析用时
|
||||
const articleHtml = shallowRef('') // 解析后的 html 内容
|
||||
const articleHtml = ref('') // 解析后的 html 内容
|
||||
const renderAsync = ref({
|
||||
need: 0,
|
||||
done: 0
|
||||
})
|
||||
let immediateParse = false // 是否立即渲染, 文档初次加载时立即渲染, 内容变更时防抖渲染
|
||||
/**
|
||||
* 自定义渲染
|
||||
@ -636,7 +676,7 @@ const renderer = {
|
||||
return renderCodespan(src)
|
||||
},
|
||||
code(code: string, language: string | undefined, _isEscaped: boolean): string {
|
||||
return renderCode(code, language, _isEscaped)
|
||||
return renderCode(code, language, _isEscaped, renderAsync.value)
|
||||
},
|
||||
heading(text: string, level: number, raw: string): string {
|
||||
return renderHeading(text, level, raw)
|
||||
@ -652,16 +692,7 @@ const renderer = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义解析
|
||||
*/
|
||||
const tokenizer = {
|
||||
codespan(src: string): any {
|
||||
return tokenizerCodespan(src)
|
||||
}
|
||||
}
|
||||
|
||||
marked.use({ tokenizer: tokenizer, renderer: renderer })
|
||||
marked.use({ renderer: renderer })
|
||||
|
||||
/**
|
||||
* 解析 markdown 为 html, 并将 html 赋值给 articleHtml
|
||||
@ -671,45 +702,49 @@ const parse = () => {
|
||||
immediateParse = false
|
||||
let mdContent = cmw.getDocString()
|
||||
clearTocAndImg()
|
||||
marked.parse(mdContent, { async: true }).then((content: string) => {
|
||||
articleHtml.value = content
|
||||
renderInterval.value = Date.now() - begin
|
||||
articleParseing = false
|
||||
nextTick(() => {
|
||||
parseToc()
|
||||
|
||||
let previewHeightHolder = document.createElement('div')
|
||||
previewHeightHolder.style.height = '60vh'
|
||||
PreviewRef.value.appendChild(previewHeightHolder)
|
||||
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 150) {
|
||||
EditorRef.value.scrollTop = 99999999
|
||||
PreviewRef.value.scrollTop = 99999999
|
||||
}
|
||||
renderAsync.value = {
|
||||
need: 0,
|
||||
done: 0
|
||||
}
|
||||
marked
|
||||
.parse(mdContent, { async: true })
|
||||
.then((content: string) => {
|
||||
articleHtml.value = content
|
||||
renderInterval.value = Date.now() - begin
|
||||
articleParseing = false
|
||||
})
|
||||
.then(() => {
|
||||
nextTick(() => {
|
||||
parseToc()
|
||||
}).then(() => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 150) {
|
||||
setTimeout(() => {
|
||||
PreviewRef.value.scrollTop = PreviewRef.value.scrollHeight
|
||||
}, 7)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖, 防止频繁渲染造成的卡顿
|
||||
*/
|
||||
let debounceTimeout: NodeJS.Timeout | undefined
|
||||
function debounce(fn: () => void, time = 500) {
|
||||
function debounceParse(parseFn: () => void, time = 500) {
|
||||
if (debounceTimeout != undefined) {
|
||||
clearTimeout(debounceTimeout)
|
||||
}
|
||||
if (immediateParse) {
|
||||
fn()
|
||||
parseFn()
|
||||
} else {
|
||||
debounceTimeout = setTimeout(fn, time)
|
||||
debounceTimeout = setTimeout(parseFn, time)
|
||||
}
|
||||
}
|
||||
|
||||
useResize(EditorRef, PreviewRef, ResizeDividerRef)
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< TOC >------------------------------------------
|
||||
@ -746,61 +781,19 @@ let scrollWrapper: EPScroll
|
||||
const initScroll = async () => {
|
||||
scrollWrapper = new EPScroll(EditorRef.value, PreviewRef.value, cmw)
|
||||
}
|
||||
|
||||
const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
scrollWrapper.sycnScroll(event, source, lineno, colno, error)
|
||||
}
|
||||
|
||||
const scrollTopReset = () => {
|
||||
if (scrollWrapper) {
|
||||
scrollWrapper.scrollTopReset()
|
||||
}
|
||||
const scrollTopReset = () => scrollWrapper.scrollTopReset()
|
||||
const scrollTopLast = () => scrollWrapper.scrollTopLast()
|
||||
const addListenerScroll = () => EditorRef.value.addEventListener('scroll', scroll)
|
||||
const removeListenerScroll = () => EditorRef.value.removeEventListener('scroll', scroll)
|
||||
const scrollTop = () => scrollWrapper.toTop()
|
||||
const scrollBottom = () => scrollWrapper.toBottom()
|
||||
const handleSyncScroll = () => {
|
||||
editorOperator.value.sycnScroll = scrollWrapper.open()
|
||||
}
|
||||
|
||||
const scrollTopLast = () => {
|
||||
if (scrollWrapper) {
|
||||
scrollWrapper.scrollTopLast()
|
||||
}
|
||||
}
|
||||
|
||||
const addListenerScroll = () => {
|
||||
EditorRef.value.addEventListener('scroll', scroll)
|
||||
}
|
||||
|
||||
const removeListenerScroll = () => {
|
||||
EditorRef.value.removeEventListener('scroll', scroll)
|
||||
}
|
||||
|
||||
const scrollBottom = () => {
|
||||
;(EditorRef.value as Element).scrollTop = 9999999999
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 双屏滚动2 >----------------------------------------
|
||||
// let CmEditorRef
|
||||
// let scrollWrapper: EPScroll
|
||||
// const initScroll = () => {
|
||||
// CmEditorRef = document.getElementsByClassName('cm-scroller')[0]
|
||||
// scrollWrapper = new EPScroll(CmEditorRef, PreviewRef.value, cmw)
|
||||
// }
|
||||
|
||||
// const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
// scrollWrapper.sycnScroll(event, source, lineno, colno, error)
|
||||
// }
|
||||
|
||||
// const scrollTopLast = () => {
|
||||
// if (scrollWrapper) {
|
||||
// scrollWrapper.scrollTopLast()
|
||||
// }
|
||||
// }
|
||||
|
||||
// const addListenerScroll = () => {
|
||||
// CmEditorRef.addEventListener('scroll', scroll)
|
||||
// }
|
||||
|
||||
// const removeListenerScroll = () => {
|
||||
// CmEditorRef.removeEventListener('scroll', scroll)
|
||||
// }
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 编辑器右键 >----------------------------------------
|
||||
|
1053
blossom-editor/src/renderer/src/views/article/ArticleIndexNew.vue
Normal file
1053
blossom-editor/src/renderer/src/views/article/ArticleIndexNew.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,944 @@
|
||||
<template>
|
||||
<div class="index-article-root">
|
||||
<!-- folder menu -->
|
||||
<div class="doc-container" :style="{ width: docEditorStyle.docs }" v-show="docsExpand">
|
||||
<div class="doc-tree-menu-container" :style="tempTextareaStyle.docTree">
|
||||
<ArticleTreeDocs @click-doc="clickCurDoc" ref="ArticleTreeDocsRef"></ArticleTreeDocs>
|
||||
</div>
|
||||
|
||||
<div class="doc-temp-textarea">
|
||||
<bl-row just="space-between" height="28px" class="doc-temp-textarea-workbench">
|
||||
<bl-row><img src="@renderer/assets/imgs/note/cd.png" />临时内容(可从便签快速设置)</bl-row>
|
||||
<div class="iconbl bl-subtract-line" @click="tempTextareaExpand = !tempTextareaExpand"></div>
|
||||
</bl-row>
|
||||
<bl-row class="doc-temp-textarea-input" :style="tempTextareaStyle.tempTextarea">
|
||||
<el-input v-model="tempTextarea" type="textarea" resize="none" @input="tempInput"></el-input>
|
||||
</bl-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- editor -->
|
||||
<div class="editor-container" :style="{ width: docEditorStyle.editor }" v-loading="editorLoading" element-loading-text="正在读取文章内容...">
|
||||
<div class="editor-tools">
|
||||
<EditorTools
|
||||
@save="saveCurArticleContent()"
|
||||
@preview-full-screen="alt_3()"
|
||||
@editor-full-screen="alt_4()"
|
||||
@bold="cmw.commandBold()"
|
||||
@italic="cmw.commandItalic()"
|
||||
@strike="cmw.commandStrike()"
|
||||
@sub="cmw.commandSub()"
|
||||
@sup="cmw.commandSup()"
|
||||
@separator="cmw.commandSeparator()"
|
||||
@blockquote="cmw.commandQuote()"
|
||||
@blockquote-block="cmw.commandQuoteBlack()"
|
||||
@blockquote-green="cmw.commandQuoteGreen()"
|
||||
@blockquote-yellow="cmw.commandQuoteYellow()"
|
||||
@blockquote-red="cmw.commandQuoteRed()"
|
||||
@blockquote-blue="cmw.commandQuoteBlue()"
|
||||
@blockquote-purple="cmw.commandQuotePurple()"
|
||||
@code="cmw.commandCode()"
|
||||
@pre="cmw.commandPre()"
|
||||
@checkbox="cmw.commandCheckBox()"
|
||||
@unordered="cmw.commandUnordered()"
|
||||
@ordered="cmw.commandOrdered()"
|
||||
@table="cmw.commandTable()"
|
||||
@image="cmw.commandImg()"
|
||||
@link="cmw.commandLink()">
|
||||
</EditorTools>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器与预览 -->
|
||||
<div class="editor-preview" :style="editorStyle">
|
||||
<div v-if="!curArticle" class="ep-placeholder">
|
||||
<ArticleIndexPlaceholder></ArticleIndexPlaceholder>
|
||||
</div>
|
||||
<div class="operator" ref="EditorOperatorRef">
|
||||
<el-tooltip popper-class="is-small" effect="light" placement="top" transition="none" :show-after="500" :hide-after="0" :show-arrow="false">
|
||||
<template #content>
|
||||
当编辑超大文档时
|
||||
<bl-row>
|
||||
可关闭同步预览
|
||||
<div class="iconbl bl-eye-line" style="transform: rotate(90deg)"></div>
|
||||
与同步滚动<span class="iconbl bl-scroll"></span>提升性能
|
||||
</bl-row>
|
||||
</template>
|
||||
<div class="iconbl bl-admonish-line"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="'同步滚动:' + (editorOperator.sycnScroll ? '开启' : '关闭')"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div
|
||||
class="iconbl bl-scroll"
|
||||
:style="{ color: editorOperator.sycnScroll ? 'var(--el-color-primary-light-3)' : '' }"
|
||||
@click="handleSyncScroll"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往顶部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleonline-line" @click="scrollTop"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往底部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleunderline-line" @click="scrollBottom"></div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="gutter-holder" ref="GutterHolderRef"></div>
|
||||
<div class="editor-codemirror" ref="EditorRef" @click.right="handleEditorClickRight"></div>
|
||||
<div class="resize-divider" ref="ResizeDividerRef"></div>
|
||||
<div class="preview-marked bl-preview" ref="PreviewRef" v-html="articleHtml"></div>
|
||||
</div>
|
||||
|
||||
<!-- status -->
|
||||
<div class="editor-status">
|
||||
<EditorStatus :render-interval="renderInterval"></EditorStatus>
|
||||
</div>
|
||||
|
||||
<!-- toc -->
|
||||
<div :class="['bl-preview-toc-absolute', tocsExpand ? 'is-expand-open' : 'is-expand-close']" ref="TocRef">
|
||||
<div class="toc-title" ref="TocTitleRef">
|
||||
目录
|
||||
<span v-show="tocsExpand" style="font-size: 10px">({{ keymaps.hideToc }} 可隐藏)</span>
|
||||
</div>
|
||||
<div class="toc-content" v-show="tocsExpand">
|
||||
<div v-for="toc in articleToc" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)" v-html="toc.content"></div>
|
||||
</div>
|
||||
<div class="img-title">
|
||||
引用图片
|
||||
<el-tooltip effect="light" placement="right" :hide-after="0">
|
||||
<template #content> 重复上传图片后<br />如果图片无变化可刷新缓存 </template>
|
||||
<span class="iconbl bl-refresh-line" @click="refreshCache"></span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="img-content">
|
||||
<div class="img-wrapper" v-for="image in articleImg" :key="image.targetUrl" @click="showPicInfo(image.targetUrl)">
|
||||
<img :src="picCacheWrapper(image.targetUrl)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PictureViewerInfo ref="PictureViewerInfoRef" @saved="refreshCache"></PictureViewerInfo>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="editorRightMenu.show"
|
||||
class="editor-right-menu"
|
||||
:style="{ left: editorRightMenu.clientX + 'px', top: editorRightMenu.clientY + 'px' }">
|
||||
<div class="menu-content">
|
||||
<div v-if="isElectron()" class="menu-item" @click="rightMenuCopy"><span class="iconbl bl-copy-line"></span>复制</div>
|
||||
<div v-if="isElectron()" class="menu-item" @click="rightMenuPaste"><span class="iconbl bl-a-texteditorpastetext-line"></span>黏贴</div>
|
||||
<div class="menu-item">
|
||||
<el-upload
|
||||
name="file"
|
||||
:action="serverStore.serverUrl + uploadFileApiUrl"
|
||||
:data="(f: UploadRawFile) => uploadDate(f, curArticle!.pid)"
|
||||
:headers="{ Authorization: 'Bearer ' + userStore.auth.token }"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="onUploadSeccess"
|
||||
:on-error="onError">
|
||||
<bl-row><span class="iconbl bl-image--line"></span>插入图片</bl-row>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="menu-item" @click="upper"><span class="iconbl bl-daxie"></span>大写</div>
|
||||
<div class="menu-item" @click="lower"><span class="iconbl bl-xiaoxie"></span>小写</div>
|
||||
<div class="menu-item" @click="formatTable"><span class="iconbl bl-transcript-line"></span>格式化选中表格</div>
|
||||
<div class="menu-item" @click="openExtenal('https://katex.org/#demo')">
|
||||
<span class="iconbl bl-a-texteditorsuperscript-line"></span>Katex 在线校验
|
||||
</div>
|
||||
<div class="menu-item" @click="openExtenal('https://mermaid.live/edit')">
|
||||
<span class="iconbl bl-a-statisticalviewpiechart3-line"></span>Mermaid 在线校验
|
||||
</div>
|
||||
<div class="menu-item" @click="openExtenal('https://www.emojiall.com/zh-hans')">
|
||||
<span style="margin-right: 4px; padding: 2px 0">😉</span>Emoji网站
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="articleReferenceView.show" ref="ArticleViewRef" class="article-view-absolute bl-preview" :style="articleReferenceView.style">
|
||||
<div class="content-view bl-preview" v-html="articleReferenceView.html" :style="editorStyle"></div>
|
||||
<bl-row class="workbench" just="space-between">
|
||||
<div class="btns">
|
||||
<div @click="openArticleWindow(articleReferenceView.articleId)">新窗口打开</div>
|
||||
</div>
|
||||
<div class="infos">{{ articleReferenceView.name }}</div>
|
||||
</bl-row>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// vue
|
||||
import { ref, computed, provide, onMounted, onBeforeUnmount, onActivated, onDeactivated, defineAsyncComponent, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UploadProps, UploadRawFile } from 'element-plus'
|
||||
import { useUserStore } from '@renderer/stores/user'
|
||||
import { useServerStore } from '@renderer/stores/server'
|
||||
import { useConfigStore } from '@renderer/stores/config'
|
||||
import { articleInfoApi, articleUpdContentApi, uploadFileApiUrl } from '@renderer/api/blossom'
|
||||
// utils
|
||||
import { Local } from '@renderer/assets/utils/storage'
|
||||
import { isBlank, isNull } from '@renderer/assets/utils/obj'
|
||||
import { sleep, isElectron } from '@renderer/assets/utils/util'
|
||||
import { openExtenal, writeText, readText, openNewArticleWindow } from '@renderer/assets/utils/electron'
|
||||
import { formartMarkdownTable } from '@renderer/assets/utils/format-table'
|
||||
// component
|
||||
import ArticleTreeDocs from './ArticleTreeDocs.vue'
|
||||
import ArticleIndexPlaceholder from './ArticleIndexPlaceholder.vue'
|
||||
import EditorTools from './EditorTools.vue'
|
||||
// ts
|
||||
import hotkeys from 'hotkeys-js'
|
||||
import Notify from '@renderer/scripts/notify'
|
||||
import { useDraggable } from '@renderer/scripts/draggable'
|
||||
import type { shortcutFunc } from '@renderer/scripts/shortcut-register'
|
||||
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo, isArticle } from '@renderer/views/doc/doc'
|
||||
import { TempTextareaKey, ArticleReference, DocEditorStyle, parseTocAsync } from './scripts/article'
|
||||
import type { Toc } from './scripts/article'
|
||||
import { beforeUpload, onError, picCacheWrapper, picCacheRefresh, uploadForm, uploadDate } from '@renderer/views/picture/scripts/picture'
|
||||
import { useResize } from './scripts/editor-preview-resize'
|
||||
// codemirror
|
||||
import { CmWrapper } from './scripts/codemirror'
|
||||
// marked
|
||||
import marked, { renderBlockquote, renderCode, renderCodespan, renderHeading, renderImage, renderTable, renderLink } from './scripts/markedjs'
|
||||
import { EPScroll } from './scripts/editor-preview-scroll'
|
||||
import { useArticleHtmlEvent } from './scripts/article-html-event'
|
||||
import { shallowRef } from 'vue'
|
||||
import { keymaps } from './scripts/editor-tools'
|
||||
|
||||
//#region -- mounted
|
||||
|
||||
const PictureViewerInfo = defineAsyncComponent(() => import('@renderer/views/picture/PictureViewerInfo.vue'))
|
||||
// const EditorTools = defineAsyncComponent(() => import('./EditorTools.vue'))
|
||||
const EditorStatus = defineAsyncComponent(() => import('./EditorStatus.vue'))
|
||||
let isMounted = false
|
||||
|
||||
onMounted(() => {
|
||||
initEditor()
|
||||
initScroll()
|
||||
addListenerScroll()
|
||||
initAutoSaveInterval()
|
||||
if (!isMounted) {
|
||||
enterView()
|
||||
bindKeys()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unbindKeys()
|
||||
removeListenerEditorRightMenu()
|
||||
removeListenerScroll()
|
||||
distoryAutoSaveInterval()
|
||||
})
|
||||
onActivated(() => {
|
||||
if (isMounted) {
|
||||
enterView()
|
||||
bindKeys()
|
||||
}
|
||||
isMounted = true
|
||||
})
|
||||
onDeactivated(() => {
|
||||
exitView()
|
||||
unbindKeys()
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const serverStore = useServerStore()
|
||||
const { editorStyle } = useConfigStore()
|
||||
|
||||
watch(
|
||||
() => userStore.userinfo.id,
|
||||
(_newId: string, _oldId: string) => {
|
||||
curDoc.value = undefined
|
||||
curArticle.value = undefined
|
||||
setNewState('')
|
||||
}
|
||||
)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 公共参数和页面动态样式 >--------------------------------------
|
||||
const GutterHolderRef = ref() // editor gutter holder
|
||||
const EditorRef = ref() // editor
|
||||
const ResizeDividerRef = ref() // editor&preview resize dom
|
||||
const EditorOperatorRef = ref()
|
||||
const PreviewRef = ref() // html 预览
|
||||
const editorOperator = ref({
|
||||
syncParse: true,
|
||||
sycnScroll: true
|
||||
})
|
||||
/**
|
||||
* 文档列表的展开和收起
|
||||
*/
|
||||
const docsExpand = ref<boolean>(true)
|
||||
const tocsExpand = ref<boolean>(true)
|
||||
const docEditorStyle = computed<DocEditorStyle>(() => {
|
||||
if (!docsExpand.value) {
|
||||
return { docs: '0px', editor: '100%' }
|
||||
}
|
||||
return { docs: '250px', editor: 'calc(100% - 250px)' }
|
||||
})
|
||||
|
||||
/**
|
||||
* 编辑器和预览的展开收起
|
||||
*/
|
||||
let previewFullScreen = false // 是否全屏展开预览
|
||||
let editorFullScreen = false // 是否全屏展开编辑
|
||||
const changeEditorPreviewStyle = () => {
|
||||
if (previewFullScreen) {
|
||||
GutterHolderRef.value.style.width = '0px'
|
||||
EditorRef.value.style.width = '0px'
|
||||
PreviewRef.value.style.width = '100%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
if (editorFullScreen) {
|
||||
GutterHolderRef.value.style.width = '50px'
|
||||
EditorRef.value.style.width = 'calc(100% - 6px)'
|
||||
PreviewRef.value.style.width = '0'
|
||||
PreviewRef.value.style.padding = '0'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
GutterHolderRef.value.style.width = '50px'
|
||||
EditorRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'block'
|
||||
EditorOperatorRef.value.style.left = 'calc(50% - 0.5px)'
|
||||
}
|
||||
/**
|
||||
* 临时文本框
|
||||
*/
|
||||
const tempTextarea = ref('')
|
||||
const tempTextareaExpand = ref(true)
|
||||
const tempTextareaStyle = computed<any>(() => {
|
||||
if (tempTextareaExpand.value) {
|
||||
return {
|
||||
docTree: { height: 'calc(100% - 178px)' },
|
||||
tempTextarea: { height: '150px', padding: '10px' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
docTree: { height: 'calc(100% - 28px)' },
|
||||
tempTextarea: { height: '0', padding: '' }
|
||||
}
|
||||
})
|
||||
const initTempTextarea = () => {
|
||||
tempTextarea.value = Local.get('editor_temp_textarea_value')
|
||||
}
|
||||
const tempInput = (value: string) => {
|
||||
Local.set(TempTextareaKey, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入页面时, 保存文章
|
||||
*/
|
||||
const enterView = () => {
|
||||
autoSave()
|
||||
initTempTextarea()
|
||||
scrollTopLast()
|
||||
}
|
||||
/**
|
||||
* 退出页面时, 保存文章
|
||||
*/
|
||||
const exitView = () => {
|
||||
autoSave()
|
||||
}
|
||||
|
||||
useResize(EditorRef, PreviewRef, ResizeDividerRef, EditorOperatorRef)
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 图片管理 >--------------------------------------
|
||||
const PictureViewerInfoRef = ref()
|
||||
const showPicInfo = (url: string) => {
|
||||
PictureViewerInfoRef.value.showPicInfo(url)
|
||||
}
|
||||
const refreshCache = () => {
|
||||
picCacheRefresh()
|
||||
parse()
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单的上传回调
|
||||
* @param resp
|
||||
* @param file
|
||||
*/
|
||||
const onUploadSeccess: UploadProps['onSuccess'] = (resp, file) => {
|
||||
if (resp.code === '20000') {
|
||||
cmw.insertBlockCommand(`\n![${file.name}](${resp.data})\n`)
|
||||
} else {
|
||||
Notify.error(resp.msg, '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽和黏贴上传
|
||||
* @param file 文件
|
||||
*/
|
||||
const uploadFile = (file: File) => {
|
||||
uploadForm(file, curArticle.value!.pid, (url: string) => {
|
||||
cmw.insertBlockCommand(`\n![${file.name}](${url})\n`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传回调
|
||||
* @param event DragEvent | ClipboardEvent
|
||||
*/
|
||||
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
|
||||
if (!isArticle(curArticle.value)) return
|
||||
|
||||
/**
|
||||
* 拖拽上传
|
||||
*/
|
||||
if (event instanceof DragEvent) {
|
||||
let data: DataTransfer | null = event.dataTransfer
|
||||
if (data && data.files.length && data.files.length > 0) {
|
||||
for (const file of data.files) {
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黏贴上传
|
||||
*/
|
||||
if (event instanceof ClipboardEvent) {
|
||||
if (!event.clipboardData) return
|
||||
if (event.clipboardData.items.length === 0) return
|
||||
for (let i = 0; i < event.clipboardData.items.length; i++) {
|
||||
const file: File | null = event.clipboardData.items[i].getAsFile()
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< html 事件监听 >----------------------------
|
||||
const ArticleViewRef = ref()
|
||||
const { articleReferenceView } = useArticleHtmlEvent(ArticleViewRef)
|
||||
|
||||
const openArticleWindow = (id: string) => {
|
||||
openNewArticleWindow('article_window_' + id, id)
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 文档列表与当前文章 >----------------------------
|
||||
const editorLoading = ref(false) // eidtor loading
|
||||
const ArticleTreeDocsRef = ref()
|
||||
const curDoc = ref<DocInfo>() // 当前选中的文档, 包含文件夹和文章, 如果选中是文件夹, 则不会重置编辑器中的文章
|
||||
const curArticle = ref<DocInfo>() // 当前选中的文章, 用于在编辑器中展示
|
||||
// 自定保存间隔, 5分钟不编辑则自动保存
|
||||
const authSaveMs = 5 * 60 * 1000
|
||||
// 非绑定数据
|
||||
// 文章是否在解析时, 为 true 则正在解析, 为 false 则解析完成
|
||||
let articleParseing = false
|
||||
// 编辑器内容是否有变更, 防止在没有变更时频繁保存导致请求接口和版本号的无意义变更, 如果为 true, 则文章允许保存, 为 false 时跳过保存
|
||||
let articleChanged = false
|
||||
// 上次保存时间
|
||||
let lastSaveTime: number = new Date().getTime()
|
||||
// 自动保存定时器
|
||||
let autoSaveInterval: NodeJS.Timeout
|
||||
// 文章加载延迟遮罩
|
||||
let editorLoadingTimeout: NodeJS.Timeout
|
||||
|
||||
provide(provideKeyDocInfo, curDoc)
|
||||
provide(provideKeyCurArticleInfo, curArticle)
|
||||
|
||||
/**
|
||||
* 点击 doc title 的回调, 用于选中某个文档
|
||||
* 选中分为两种
|
||||
* 1:选中的是文件夹
|
||||
* 2:选中的是文章, 则查询文章内容, 变
|
||||
*
|
||||
* @param tree
|
||||
*/
|
||||
const clickCurDoc = async (tree: DocTree) => {
|
||||
let doc: DocInfo = treeToInfo(tree)
|
||||
curDoc.value = doc
|
||||
// 如果选中的是文章, 则查询文章详情, 用于在编辑器中显示以及注入
|
||||
if (doc.type == 3) {
|
||||
// 重复点击同一个, 不会多次查询
|
||||
if (isArticle(curArticle.value) && curArticle.value!.id == doc.id) {
|
||||
return
|
||||
}
|
||||
editorLoadingTimeout = setTimeout(() => (editorLoading.value = true), 100)
|
||||
await saveCurArticleContent(true)
|
||||
clearTocAndImg()
|
||||
await articleInfoApi({ id: doc.id, showToc: false, showMarkdown: true, showHtml: false })
|
||||
.then((resp) => {
|
||||
if (isNull(resp.data)) {
|
||||
return
|
||||
}
|
||||
curArticle.value = resp.data
|
||||
// 初次加载时立即渲染
|
||||
immediateParse = true
|
||||
if (isBlank(resp.data.markdown)) {
|
||||
setNewState('')
|
||||
} else {
|
||||
setNewState(resp.data.markdown)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (editorLoadingTimeout) clearTimeout(editorLoadingTimeout)
|
||||
editorLoading.value = false
|
||||
articleChanged = false
|
||||
})
|
||||
nextTick(() => {
|
||||
scrollTopReset()
|
||||
})
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 保存文章的正文, 并更新编辑器状态栏中的版本, 字数, 修改时间等信息.
|
||||
*
|
||||
* @param auto 是否为自动保存, 如果是自动保存, 则不弹出保存成功的提示框, 避免在非用户主动操作下弹框
|
||||
*/
|
||||
const saveCurArticleContent = async (auto: boolean = false) => {
|
||||
if (!isArticle(curArticle.value)) {
|
||||
return
|
||||
}
|
||||
const saveCallback = () => {
|
||||
if (!auto) {
|
||||
ElMessage.success({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
|
||||
}
|
||||
}
|
||||
// 如果文档发生变动才保存
|
||||
if (!articleChanged) {
|
||||
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;')
|
||||
saveCallback()
|
||||
return
|
||||
}
|
||||
// 如果文档正在解析中, 则等待解析完成
|
||||
while (articleParseing) {
|
||||
console.log('检测到正在解析, 等待解析完成')
|
||||
await sleep(100)
|
||||
}
|
||||
articleChanged = false
|
||||
let data = {
|
||||
id: curArticle.value!.id,
|
||||
name: curArticle.value!.name,
|
||||
markdown: cmw.getDocString(),
|
||||
html: PreviewRef.value.innerHTML,
|
||||
references: articleImg.value.concat(articleLink.value)
|
||||
}
|
||||
await articleUpdContentApi(data)
|
||||
.then((resp) => {
|
||||
lastSaveTime = new Date().getTime()
|
||||
curArticle.value!.words = resp.data.words as number
|
||||
curArticle.value!.updTime = resp.data.updTime as string
|
||||
if (curArticle.value!.version != undefined) {
|
||||
curArticle.value!.version = curArticle.value!.version + 1
|
||||
} else {
|
||||
curArticle.value!.version = 1
|
||||
}
|
||||
saveCallback()
|
||||
})
|
||||
.catch(() => {
|
||||
articleChanged = true
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 初始化自动保存定时器
|
||||
* 如果 authSaveMs 时间没有保存, 则自动保存.
|
||||
*/
|
||||
const initAutoSaveInterval = () => {
|
||||
autoSaveInterval = setInterval(() => {
|
||||
let current = new Date().getTime()
|
||||
if (current - lastSaveTime > authSaveMs) {
|
||||
autoSave()
|
||||
}
|
||||
}, 30 * 1000)
|
||||
}
|
||||
/**
|
||||
* 销毁自动保存定时器
|
||||
*/
|
||||
const distoryAutoSaveInterval = () => {
|
||||
clearInterval(autoSaveInterval)
|
||||
}
|
||||
/**
|
||||
* 自动保存, 该种方式不会有保存成功的提示
|
||||
*/
|
||||
const autoSave = () => {
|
||||
saveCurArticleContent(true)
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< codemirror/editor >----------------------------
|
||||
let cmw: CmWrapper // codemirror editor wrapper
|
||||
|
||||
/**
|
||||
* 初始化编辑器, 创建编辑器封装器, 并在编辑器底部增加一个空白页
|
||||
*/
|
||||
const initEditor = (_doc?: string) => {
|
||||
cmw = new CmWrapper(
|
||||
CmWrapper.newEditor(
|
||||
// 创建 state
|
||||
CmWrapper.newState(
|
||||
() => {
|
||||
articleParseing = true
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback
|
||||
),
|
||||
EditorRef.value
|
||||
)
|
||||
)
|
||||
appendEditorHolder()
|
||||
}
|
||||
/**
|
||||
* 将 markdown 原文设置到编辑器中, 并且会重置编辑器状态
|
||||
* @param md markdown
|
||||
*/
|
||||
const setNewState = (md: string): void => {
|
||||
cmw.setState(
|
||||
CmWrapper.newState(
|
||||
() => {
|
||||
articleChanged = true
|
||||
articleParseing = true
|
||||
allwaysBottom()
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback,
|
||||
md
|
||||
)
|
||||
)
|
||||
parse()
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器底部增加空白占位元素, 点击占位元素会时会聚焦在编辑器
|
||||
*/
|
||||
const appendEditorHolder = () => {
|
||||
// 创建元素
|
||||
let editorHeightHolder = document.createElement('div')
|
||||
editorHeightHolder.style.height = '65vh'
|
||||
editorHeightHolder.style.position = 'relative'
|
||||
editorHeightHolder.addEventListener('click', () => {
|
||||
let length = cmw.getDocLength()
|
||||
cmw.editor.focus()
|
||||
cmw.insert(length, length, '', length, length)
|
||||
})
|
||||
EditorRef.value.appendChild(editorHeightHolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动条永远置底
|
||||
*/
|
||||
const allwaysBottom = async () => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 100) {
|
||||
scrollWrapper.toBottom()
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< marked/preview >-------------------------------
|
||||
const renderInterval = ref(0) // 解析用时
|
||||
const articleHtml = ref('') // 解析后的 html 内容
|
||||
const renderAsync = ref({
|
||||
need: 0,
|
||||
done: 0
|
||||
})
|
||||
let immediateParse = false // 是否立即渲染, 文档初次加载时立即渲染, 内容变更时防抖渲染
|
||||
/**
|
||||
* 自定义渲染
|
||||
*/
|
||||
const renderer = {
|
||||
table(header: string, body: string): string {
|
||||
return renderTable(header, body)
|
||||
},
|
||||
blockquote(quote: string): string {
|
||||
return renderBlockquote(quote)
|
||||
},
|
||||
codespan(src: string): string {
|
||||
return renderCodespan(src)
|
||||
},
|
||||
code(code: string, language: string | undefined, _isEscaped: boolean): string {
|
||||
return renderCode(code, language, _isEscaped, renderAsync.value)
|
||||
},
|
||||
heading(text: string, level: number, raw: string): string {
|
||||
return renderHeading(text, level, raw)
|
||||
},
|
||||
image(href: string | null, _title: string | null, text: string): string {
|
||||
articleImg.value.push({ targetId: '0', targetName: text, targetUrl: href as string, type: 10 })
|
||||
return renderImage(href, _title, text)
|
||||
},
|
||||
link(href: string, title: string | null | undefined, text: string): string {
|
||||
let { link, ref } = renderLink(href, title, text, ArticleTreeDocsRef.value.getDocTreeData())
|
||||
articleLink.value.push(ref)
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
marked.use({ renderer: renderer })
|
||||
|
||||
/**
|
||||
* 解析 markdown 为 html, 并将 html 赋值给 articleHtml
|
||||
*/
|
||||
const parse = () => {
|
||||
const begin = Date.now()
|
||||
immediateParse = false
|
||||
let mdContent = cmw.getDocString()
|
||||
clearTocAndImg()
|
||||
renderAsync.value = {
|
||||
need: 0,
|
||||
done: 0
|
||||
}
|
||||
marked
|
||||
.parse(mdContent, { async: true })
|
||||
.then((content: string) => {
|
||||
articleHtml.value = content
|
||||
renderInterval.value = Date.now() - begin
|
||||
articleParseing = false
|
||||
})
|
||||
.then(() => {
|
||||
nextTick(() => {
|
||||
parseToc()
|
||||
}).then(() => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 150) {
|
||||
setTimeout(() => {
|
||||
PreviewRef.value.scrollTop = PreviewRef.value.scrollHeight
|
||||
}, 7)
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(renderAsync.value)
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖, 防止频繁渲染造成的卡顿
|
||||
*/
|
||||
let debounceTimeout: NodeJS.Timeout | undefined
|
||||
function debounceParse(parseFn: () => void, time = 500) {
|
||||
if (debounceTimeout != undefined) {
|
||||
clearTimeout(debounceTimeout)
|
||||
}
|
||||
if (immediateParse) {
|
||||
parseFn()
|
||||
} else {
|
||||
debounceTimeout = setTimeout(parseFn, time)
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< TOC >------------------------------------------
|
||||
const articleToc = shallowRef<Toc[]>([])
|
||||
const articleImg = shallowRef<ArticleReference[]>([]) // 文章对图片引用
|
||||
const articleLink = shallowRef<ArticleReference[]>([]) // 文章对链接的引用
|
||||
const TocRef = ref()
|
||||
const TocTitleRef = ref()
|
||||
/**
|
||||
* 跳转至指定ID位置,ID为 标题级别-标题内容
|
||||
* @param level 标题级别
|
||||
* @param content 标题内容
|
||||
*/
|
||||
const toScroll = (id: string) => {
|
||||
let elm: HTMLElement = document.getElementById(id) as HTMLElement
|
||||
;(elm.parentNode as Element).scrollTop = elm.offsetTop
|
||||
}
|
||||
// 清空当前目录内容
|
||||
const clearTocAndImg = () => {
|
||||
articleImg.value = []
|
||||
articleLink.value = []
|
||||
}
|
||||
|
||||
const parseToc = async () => {
|
||||
parseTocAsync(PreviewRef.value).then((tocs) => (articleToc.value = tocs))
|
||||
}
|
||||
|
||||
useDraggable(TocRef, TocTitleRef)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 双屏滚动 >----------------------------------------
|
||||
let scrollWrapper: EPScroll
|
||||
const initScroll = async () => {
|
||||
scrollWrapper = new EPScroll(EditorRef.value, PreviewRef.value, cmw)
|
||||
}
|
||||
const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
scrollWrapper.sycnScroll(event, source, lineno, colno, error)
|
||||
}
|
||||
const scrollTopReset = () => scrollWrapper.scrollTopReset()
|
||||
const scrollTopLast = () => scrollWrapper.scrollTopLast()
|
||||
const addListenerScroll = () => EditorRef.value.addEventListener('scroll', scroll)
|
||||
const removeListenerScroll = () => EditorRef.value.removeEventListener('scroll', scroll)
|
||||
const scrollTop = () => scrollWrapper.toTop()
|
||||
const scrollBottom = () => scrollWrapper.toBottom()
|
||||
const handleSyncScroll = () => {
|
||||
editorOperator.value.sycnScroll = scrollWrapper.open()
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 编辑器右键 >----------------------------------------
|
||||
const editorRightMenu = ref<RightMenu>({ show: false, clientX: 0, clientY: 0 })
|
||||
const rightMenuHeight = isElectron() ? 270 : 220
|
||||
|
||||
const handleEditorClickRight = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
editorRightMenu.value = { show: false, clientX: 0, clientY: 0 }
|
||||
let y = event.clientY
|
||||
if (document.body.clientHeight - event.clientY < rightMenuHeight) {
|
||||
y = event.clientY - rightMenuHeight
|
||||
}
|
||||
editorRightMenu.value = { show: true, clientX: event.clientX, clientY: y }
|
||||
setTimeout(() => {
|
||||
document.body.addEventListener('click', closeEditorRightMenu)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const closeEditorRightMenu = () => {
|
||||
removeListenerEditorRightMenu()
|
||||
editorRightMenu.value.show = false
|
||||
}
|
||||
|
||||
const removeListenerEditorRightMenu = () => {
|
||||
document.body.removeEventListener('click', closeEditorRightMenu)
|
||||
}
|
||||
|
||||
/** 复制当前选中内容 */
|
||||
const rightMenuCopy = () => {
|
||||
writeText(cmw.getSelectionRangesText())
|
||||
}
|
||||
/** 右键黏贴功能 */
|
||||
const rightMenuPaste = () => {
|
||||
cmw.insertBlockCommand(readText())
|
||||
}
|
||||
|
||||
/** 转大写功能 */
|
||||
const upper = () => {
|
||||
cmw.toUpper()
|
||||
}
|
||||
|
||||
/** 转小写功能 */
|
||||
const lower = () => {
|
||||
cmw.toLower()
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键格式化表格功能
|
||||
*/
|
||||
const formatTable = () => {
|
||||
let ranges = cmw.getSlelctionRangesArr()
|
||||
if (ranges.length < 1) {
|
||||
Notify.error('未选中内容')
|
||||
return
|
||||
}
|
||||
if (ranges.length > 1) {
|
||||
Notify.error('选中内容过多')
|
||||
return
|
||||
}
|
||||
let text = cmw.sliceDoc(ranges[0].from, ranges[0].to)
|
||||
if (isBlank(text)) {
|
||||
return
|
||||
}
|
||||
cmw.insertBlockCommand(formartMarkdownTable(text))
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 快捷键注册 >-------------------------------------
|
||||
const alt_1: shortcutFunc = (): void => {
|
||||
docsExpand.value = !docsExpand.value
|
||||
}
|
||||
const alt_2: shortcutFunc = (): void => {
|
||||
tocsExpand.value = !tocsExpand.value
|
||||
}
|
||||
// 全屏预览
|
||||
const alt_3: shortcutFunc = (): void => {
|
||||
previewFullScreen = !previewFullScreen
|
||||
if (previewFullScreen) {
|
||||
editorFullScreen = false
|
||||
}
|
||||
changeEditorPreviewStyle()
|
||||
}
|
||||
// 全屏编辑
|
||||
const alt_4: shortcutFunc = (): void => {
|
||||
editorFullScreen = !editorFullScreen
|
||||
if (previewFullScreen) {
|
||||
previewFullScreen = false
|
||||
}
|
||||
changeEditorPreviewStyle()
|
||||
}
|
||||
|
||||
hotkeys.filter = function (_event) {
|
||||
return true
|
||||
}
|
||||
|
||||
const bindKeys = () => {
|
||||
hotkeys('alt+1, command+1', () => {
|
||||
alt_1()
|
||||
return false
|
||||
})
|
||||
hotkeys('alt+2, command+2', () => {
|
||||
alt_2()
|
||||
return false
|
||||
})
|
||||
hotkeys('alt+3, command+3', () => {
|
||||
alt_3()
|
||||
return false
|
||||
})
|
||||
hotkeys('alt+4, command+4', () => {
|
||||
alt_4()
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const unbindKeys = () => {
|
||||
hotkeys.unbind('alt+1, command+1')
|
||||
hotkeys.unbind('alt+2, command+2')
|
||||
hotkeys.unbind('alt+3, command+3')
|
||||
hotkeys.unbind('alt+4, command+4')
|
||||
}
|
||||
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import './styles/article-index.scss';
|
||||
@import './styles/article-view-absolute.scss';
|
||||
@import './styles/editor-right-menu.scss';
|
||||
@import './styles/bl-preview-toc.scss';
|
||||
@import './styles/article-backtop.scss';
|
||||
@import '@renderer/assets/styles/bl-loading-spinner.scss';
|
||||
</style>
|
@ -10,9 +10,9 @@ import type { Ref } from 'vue'
|
||||
export const useResize = (
|
||||
editorRef: Ref<HTMLElement | undefined>,
|
||||
previewRef: Ref<HTMLElement | undefined>,
|
||||
resizeDividerRef: Ref<HTMLElement | undefined>
|
||||
resizeDividerRef: Ref<HTMLElement | undefined>,
|
||||
operatorRef: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
|
||||
const onMousedown = (_e: MouseEvent) => {
|
||||
const targetRect = editorRef.value!.getBoundingClientRect()
|
||||
// editor 距离应用左侧的距离
|
||||
@ -24,6 +24,7 @@ export const useResize = (
|
||||
const onMousemove = (e: MouseEvent) => {
|
||||
const x = Math.max(0, e.clientX - targetLeft)
|
||||
editorRef.value!.style.width = `${x}px`
|
||||
operatorRef.value!.style.left = `${x + 1}px`
|
||||
previewRef.value!.style.width = `calc(100% - ${x}px - 3px)`
|
||||
}
|
||||
|
||||
|
@ -33,7 +33,7 @@ const markmapOptions = deriveOptions({
|
||||
duration: 0, // 展开缩起动画
|
||||
maxWidth: 160, // 每个节点最大宽度
|
||||
zoom: true, // 缩放
|
||||
pan: false // 拖动
|
||||
pan: true // 拖动
|
||||
})
|
||||
|
||||
/**
|
||||
@ -86,22 +86,6 @@ let hljsConfig = {
|
||||
marked.use(markedHighlight(hljsConfig))
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< tokenizer >--------------------------------------
|
||||
export const tokenizerCodespan = (src: string): any => {
|
||||
const match = src.match(singleDollar)
|
||||
if (match) {
|
||||
let result = {
|
||||
type: 'codespan',
|
||||
raw: match[0],
|
||||
text: match[0]
|
||||
}
|
||||
return result
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< renderer >--------------------------------------
|
||||
const domParser = new DOMParser()
|
||||
/**
|
||||
@ -196,7 +180,7 @@ export const renderBlockquote = (quote: string) => {
|
||||
* @param language 语言
|
||||
* @param isEscaped
|
||||
*/
|
||||
export const renderCode = (code: string, language: string | undefined, _isEscaped: boolean) => {
|
||||
export const renderCode = (code: string, language: string | undefined, _isEscaped: boolean, asyncStat: { need: number; done: number }) => {
|
||||
if (language == undefined) language = 'text'
|
||||
|
||||
/** ==========================================================================================
|
||||
@ -204,6 +188,7 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
* ```mermaid${grammar}h300
|
||||
* ========================================================================================== */
|
||||
if (language.startsWith('mermaid') && isNotBlank(code)) {
|
||||
asyncStat.need++
|
||||
const eleid = 'mermaid-' + Date.now() + '-' + randomInt(1, 10000)
|
||||
const escape = escape2Html(code) as string
|
||||
|
||||
@ -224,10 +209,20 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
.then((syntax) => {
|
||||
let canSyntax: boolean | void = syntax
|
||||
if (canSyntax) {
|
||||
mermaid.render(eleid + '-svg', escape).then((resp) => {
|
||||
mermaid.render(eleid + '-svg', escape).then(async (resp) => {
|
||||
const { svg } = resp
|
||||
let element = document.getElementById(eleid)
|
||||
element!.innerHTML = svg
|
||||
let retry = 0
|
||||
while (!element || element == null) {
|
||||
if (retry > 30) break
|
||||
await sleep(5)
|
||||
element = document.querySelector(`#${eleid}`)
|
||||
retry++
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = svg
|
||||
}
|
||||
asyncStat.done++
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -240,6 +235,7 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
</p>`
|
||||
let element = document.getElementById(eleid)
|
||||
if (element) element!.innerHTML = html
|
||||
asyncStat.done++
|
||||
})
|
||||
return `<p class="mermaid-container" style="height:${height}" id="${eleid}"></p>`
|
||||
}
|
||||
@ -264,6 +260,7 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
* ```markmap${grammar}h300
|
||||
* ========================================================================================== */
|
||||
if (language.startsWith('markmap')) {
|
||||
asyncStat.need++
|
||||
let height = '300px'
|
||||
let tags: string[] = language.split(grammar)
|
||||
if (tags.length >= 2) {
|
||||
@ -279,22 +276,32 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
const eleid = 'markmap-' + Date.now() + '-' + randomInt(1, 10000)
|
||||
const escape = escape2Html(code) as string
|
||||
const { root } = transformer.transform(escape)
|
||||
new Promise<SVGElement>(async (resolve, reject) => {
|
||||
let svgEl: SVGElement | null = document.querySelector(`#${eleid}`)
|
||||
|
||||
// let svg: SVGElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
// svg.id = eleid
|
||||
// svg.setAttributeNS(null, 'width', '100%')
|
||||
// svg.setAttributeNS(null, 'height', '500px')
|
||||
// svg.setAttributeNS(null, 'viewBox', '0 0 100 500')
|
||||
// svg.classList.add('markmap')
|
||||
// svg.style.width = '500px'
|
||||
// svg.style.height = height
|
||||
// let markmap = Markmap.create(svg, markmapOptions, root)
|
||||
// console.log(svg.outerHTML)
|
||||
// return `<p class="markmap-container">${svg.outerHTML} </p>`
|
||||
|
||||
new Promise<SVGElement>(async (_resolve, _reject) => {
|
||||
let svg: SVGElement | null = document.querySelector(`#${eleid}`)
|
||||
let retry = 0
|
||||
while (!svgEl || svgEl == null) {
|
||||
if (retry > 10) break
|
||||
await sleep(10)
|
||||
svgEl = document.querySelector(`#${eleid}`)
|
||||
while (!svg || svg == null) {
|
||||
if (retry > 30) break
|
||||
await sleep(5)
|
||||
svg = document.querySelector(`#${eleid}`)
|
||||
retry++
|
||||
}
|
||||
if (svgEl) {
|
||||
resolve(svgEl)
|
||||
} else {
|
||||
reject()
|
||||
if (svg) {
|
||||
Markmap.create(svg, markmapOptions, root)
|
||||
}
|
||||
}).then((svgEl: SVGElement) => {
|
||||
Markmap.create(svgEl, markmapOptions, root)
|
||||
asyncStat.done++
|
||||
})
|
||||
return `<p class="markmap-container"><svg id=${eleid} xmlns="http://www.w3.org/2000/svg" style="width:100%;height:${height}"></svg></p>`
|
||||
}
|
||||
@ -484,21 +491,14 @@ const simpleRenderer = {
|
||||
}
|
||||
let lineNumbers = result + '</ol>'
|
||||
return `<pre><code class="hljs language-${language}"></code>${lineNumbers}<div class="pre-copy">${language}</div></pre>`
|
||||
// return `<pre><code class="hljs language-${language}">${code}</code></pre>`
|
||||
},
|
||||
codespan(src: string): string {
|
||||
return renderCodespan(src)
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizer = {
|
||||
codespan(src: string): any {
|
||||
return tokenizerCodespan(src)
|
||||
}
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
simpleMarked.use({ tokenizer: tokenizer, renderer: simpleRenderer })
|
||||
simpleMarked.use({ renderer: simpleRenderer })
|
||||
|
||||
//#endregion
|
||||
|
||||
|
@ -130,11 +130,10 @@
|
||||
}
|
||||
|
||||
.operator {
|
||||
width: 20px;
|
||||
height: 140px;
|
||||
padding-top: 5px;
|
||||
@include themeBorder(2px, #d8d8d841, #ffffff12);
|
||||
// border: 2px solid var(--el-border-color);
|
||||
width: 20px;
|
||||
height: 75px;
|
||||
padding-top: 5px;
|
||||
background-color: var(--bl-html-color);
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
|
Loading…
Reference in New Issue
Block a user