Merge branch 'release/2.6.0'

This commit is contained in:
purocean
2021-03-04 15:24:30 +08:00
40 changed files with 1145 additions and 700 deletions
+7 -2
View File
@@ -77,12 +77,17 @@
## 更新日志
[最新发布](https://github.com/purocean/yn/releases)
### [v2.5.5](https://github.com/purocean/yn/releases/tag/v2.5.5) 2021-02-03
1. 调整预览文字选择颜色
### [v2.6.0](https://github.com/purocean/yn/releases/tag/v2.6.0) 2021-03-04
1. 内部功能插件化,增强拓展性
2. 微调界面样式
3. 修复复制代码快捷键不正确问题
<details>
<summary>展开查看更多版本记录</summary>
### [v2.5.5](https://github.com/purocean/yn/releases/tag/v2.5.5) 2021-02-03
1. 调整预览文字选择颜色
### [v2.5.4](https://github.com/purocean/yn/releases/tag/v2.5.4) 2021-01-31
1. 调整 macOS 上应用边框样式
2. macOS 打包增加签名公证
+1 -1
View File
@@ -16,7 +16,7 @@
"github-markdown-css": "^4.0.0",
"highlight.js": "^10.4.1",
"katex": "^0.6.0",
"loadsh": "^0.0.4",
"lodash": "^4.17.21",
"markdown-it": "^12.0.1",
"markdown-it-attrs": "^3.0.3",
"markdown-it-footnote": "^3.0.2",
+3 -3
View File
@@ -109,7 +109,7 @@ export default defineComponent({
left: -99999px;
top: -99999px;
visibility: hidden;
background: #696969;
background: #3d4042;
border: 1px #333 solid;
border-left: 0;
border-top: 0;
@@ -120,8 +120,8 @@ export default defineComponent({
}
.menu > li.separator {
border-top: 1px #888888 solid;
border-bottom: 1px #646464 solid;
border-top: 1px #666 solid;
border-bottom: 1px #444 solid;
margin: 3px 0;
}
+17 -83
View File
@@ -45,7 +45,6 @@ export default defineComponent({
const getEditor = () => refEditor.value
const revealLine = (line: number) => getEditor().revealLine(line)
const revealLineInCenter = (line: number) => getEditor().revealLineInCenter(line)
const switchTodo = (line: number, checked: boolean) => getEditor().switchTodo(line, checked)
const toUri = (file: any) => File.toUri(file)
async function inputPassword (title: string, filename: string) {
@@ -253,100 +252,35 @@ export default defineComponent({
const resize = () => nextTick(() => getEditor().resize())
const insert = (text?: string) => getEditor().insert(text)
const replaceValue = (value?: { search: string; replace: string }) => {
if (value) {
getEditor().replaceValue(value.search, value.replace)
}
}
const toggleWrap = () => getEditor().toggleWrap()
const editTableCell = async (params?: { start: number; end: number; cellIndex: number }) => {
if (!params) {
return
const replaceLine = (params?: { line: number; value: string }) => {
if (params) {
getEditor().replaceLine(params.line, params.value)
}
const { start, end, cellIndex } = params
if (end - start !== 1) {
toast.show('warning', '暂只支持编辑单行文本')
return
}
const escapedSplit = (str: string) => {
const result = []
const max = str.length
let pos = 0
let ch = str.charCodeAt(pos)
let isEscaped = false
let lastPos = 0
let current = ''
while (pos < max) {
if (ch === 0x7c/* | */) {
if (!isEscaped) {
// pipe separating cells, '|'
result.push(current + str.substring(lastPos, pos))
current = ''
lastPos = pos + 1
} else {
// escaped pipe, '\|'
current += str.substring(lastPos, pos - 1)
lastPos = pos
}
}
isEscaped = (ch === 0x5c/* \ */)
pos++
ch = str.charCodeAt(pos)
}
result.push(current + str.substring(lastPos))
if (result.length && result[0] === '') result.shift()
if (result.length && result[result.length - 1] === '') result.pop()
return result
}
const text = getEditor().getLineContent(start).trim()
const columns = escapedSplit(text)
const cellText = columns[cellIndex]
if (typeof cellText !== 'string') {
toast.show('warning', '编辑错误')
return
}
let value = await modal.input({
title: '编辑单元格',
type: 'textarea',
value: cellText,
modalWidth: '600px',
hint: '单元格内容',
})
if (typeof value !== 'string') {
toast.show('warning', '取消编辑')
return
}
if (!value.startsWith(' ') && cellIndex > 0) value = ' ' + value
if (!value.endsWith(' ') && cellIndex < columns.length - 1) value += ' '
columns[cellIndex] = value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
let content = columns.join('|').trim()
if (text.startsWith('|')) content = '| ' + content
if (text.endsWith('|')) content += ' |'
getEditor().replaceLine(start, content)
}
const getLine = (params?: {line: number; callback?: (value: string) => void}) => {
if (params) {
const { line, callback } = params
callback && callback(getEditor().getLineContent(line))
}
}
const toggleWrap = () => getEditor().toggleWrap()
onMounted(() => {
bus.on('resize', resize)
bus.on('editor-insert-value', insert)
bus.on('editor-replace-value', replaceValue)
bus.on('editor-get-line', getLine)
bus.on('editor-replace-line', replaceLine)
bus.on('editor-toggle-wrap', toggleWrap)
bus.on('editor-edit-table-cell', editTableCell)
bus.on('file-new', createFile as any)
restartTimer()
})
@@ -355,16 +289,16 @@ export default defineComponent({
bus.off('resize', resize)
bus.off('editor-insert-value', insert)
bus.off('editor-replace-value', replaceValue)
bus.off('editor-get-line', getLine)
bus.off('editor-replace-line', replaceLine)
bus.off('editor-toggle-wrap', toggleWrap)
bus.off('file-new', createFile as any)
bus.off('editor-edit-table-cell', editTableCell)
})
return {
refEditor,
revealLine,
revealLineInCenter,
switchTodo,
setCurrentValue,
syncScrollView,
pasteImg,
+2 -2
View File
@@ -188,7 +188,7 @@ export default defineComponent({
right: 0;
top: 0;
width: 4px;
cursor: w-resize;
cursor: ew-resize;
}
.sash-top {
@@ -198,7 +198,7 @@ export default defineComponent({
left: 0;
top: 0;
height: 4px;
cursor: s-resize;
cursor: ns-resize;
}
.right {
-11
View File
@@ -204,16 +204,6 @@ export default defineComponent({
return getEditor().getModel()!!.getLineContent(line)
}
function switchTodo (line: number, checked: boolean) {
if (checked) {
const value = getLineContent(line).replace('[ ]', `[x] ~~${dayjs().format('YYYY-MM-DD HH:mm')}~~`)
replaceLine(line, value)
} else {
const value = getLineContent(line).replace(/(\[x\] ~~[\d-: ]+~~|\[x\])/, '[ ]')
replaceLine(line, value)
}
}
function getValue () {
return getEditor().getModel()!!.getValue(getMonaco().editor.DefaultEndOfLine.LF)
}
@@ -323,7 +313,6 @@ export default defineComponent({
revealLine,
revealLineInCenter,
setScrollToTop,
switchTodo,
replaceValue,
replaceLine,
toggleWrap,
+19 -280
View File
@@ -38,10 +38,9 @@
</template>
<script lang="ts">
import loadsh from 'lodash'
import lodash from 'lodash'
import { useStore } from 'vuex'
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue'
import mime from 'mime-types'
import Markdown from 'markdown-it'
import TaskLists from 'markdown-it-task-lists'
import katex from 'markdown-it-katex'
@@ -50,30 +49,13 @@ import MultimdTable from 'markdown-it-multimd-table'
import Footnote from 'markdown-it-footnote'
import Highlight from 'highlight.js'
import HighlightLineNumber from '../plugins/HightLightNumberPlugin'
import MarkdownItToc from '../plugins/TocPlugin'
import MarkdownItECharts from '../plugins/EChartsPlugin'
import RunPlugin from '../plugins/RunPlugin'
import AppletPlugin from '../plugins/AppletPlugin'
import SourceLinePlugin from '../plugins/SourceLinePlugin'
import LinkTargetPlugin from '../plugins/LinkTargetPlugin'
import DrawioPlugin from '../plugins/DrawioPlugin'
import PlantumlPlugin from '../plugins/PlantumlPlugin'
import MermaidPlugin from '../plugins/MermaidPlugin'
import CodePlugin from '../plugins/CodePlugin'
import MindMapPlugin from '../plugins/MindMapPlugin'
import file from '../useful/file'
import env from '../useful/env'
import { encodeMarkdownLink } from '../useful/utils'
import { getMarkdownItPlugins, triggerHook } from '../useful/plugin'
import { useBus } from '../useful/bus'
import 'github-markdown-css/github-markdown.css'
import 'highlight.js/styles/atom-one-dark.css'
import 'katex/dist/katex.min.css'
import { useBus } from '../useful/bus'
import { useToast } from '../useful/toast'
import { hasCtrlCmd, isAction } from '../useful/shortcut'
HighlightLineNumber.addStyles()
const markdown = Markdown({
linkify: true,
@@ -90,28 +72,20 @@ const markdown = Markdown({
}
})
.use(TaskLists, { enabled: true })
.use(PlantumlPlugin)
.use(RunPlugin)
.use(AppletPlugin)
.use(katex)
.use(SourceLinePlugin)
.use(MarkdownItAttrs)
.use(LinkTargetPlugin)
.use(Footnote)
.use(DrawioPlugin)
.use(MultimdTable, { multiline: true })
.use(MarkdownItToc)
.use(MarkdownItECharts)
.use(MermaidPlugin)
.use(CodePlugin)
.use(MindMapPlugin)
getMarkdownItPlugins().forEach(({ plugin, params }) => {
markdown.use(plugin, params)
})
export default defineComponent({
name: 'xview',
setup (_, { emit }) {
const bus = useBus()
const store = useStore()
const toast = useToast()
const { currentContent, currentFile, autoPreview } = toRefs(store.state)
const fileRepo = computed(() => currentFile.value?.repo)
@@ -122,6 +96,8 @@ export default defineComponent({
const refView = ref<HTMLElement | null>(null)
const refConvertForm = ref<HTMLFormElement | null>(null)
const getViewDom = () => refView.value
const width = ref(1024)
const height = ref(1024)
const heads = ref<{
@@ -140,35 +116,6 @@ export default defineComponent({
height.value = refViewWrapper.value!!.clientHeight
}
function updatePlantuml () {
const nodes = refView.value!!.querySelectorAll<HTMLImageElement>('img[data-plantuml-src]')
nodes.forEach(el => {
el.src = el.dataset.plantumlSrc || ''
})
}
function runAppletScript () {
const nodes = refView.value!!.querySelectorAll<HTMLElement>('.applet[data-code]')
nodes.forEach(AppletPlugin.runScript)
}
function renderMindMap () {
const nodes = refView.value!!.querySelectorAll<HTMLElement>('.mindmap-list[data-mindmap-source]')
nodes.forEach(MindMapPlugin.render)
}
const updatePlantumlDebounce = loadsh.debounce(() => {
updatePlantuml()
}, 3000, { leading: true })
const runAppletScriptDebounce = loadsh.debounce(() => {
runAppletScript()
}, 1000, { leading: true })
const renderMindmapDebounce = loadsh.debounce(() => {
renderMindMap()
}, 1000, { leading: true })
function replaceRelativeLink (md: string) {
if (!fileRepo.value) {
return md
@@ -210,22 +157,6 @@ export default defineComponent({
})
}
function initDrawio () {
if (!refView.value) {
return
}
const nodes = refView.value!!.querySelectorAll<HTMLIFrameElement>('.drawio[data-url]')
nodes.forEach(el => {
const url = el.dataset.url
if (url) {
DrawioPlugin.load(el, url)
} else {
DrawioPlugin.load(el)
}
})
}
function updateOutline () {
const tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
const nodes = refView.value!!.querySelectorAll<HTMLHeadElement>(tags.join(','))
@@ -244,14 +175,7 @@ export default defineComponent({
todoDoneCount.value = refView.value!!.querySelectorAll('input[type=checkbox][checked]').length
}
function hightCode () {
const nodes = refView.value!!.querySelectorAll<HTMLImageElement>('code[class^="language-"]')
nodes.forEach(el => {
HighlightLineNumber.lineNumbersBlock(el)
})
}
const render = loadsh.debounce(() => {
const render = lodash.debounce(() => {
// 编辑非 markdown 文件预览直接显示代码
const content = (filePath.value || '').endsWith('.md')
? currentContent.value
@@ -259,77 +183,15 @@ export default defineComponent({
const source = replaceRelativeLink(content)
refView.value!!.innerHTML = markdown.render(source, { source })
runAppletScriptDebounce()
renderMindmapDebounce()
initDrawio()
updateOutline()
updateTodoCount()
updatePlantumlDebounce()
hightCode()
MarkdownItECharts.update()
MermaidPlugin.update()
// 渲染完成后触发渲染完成事件
nextTick(() => bus.emit('preview-rendered'))
triggerHook('ON_VIEW_RENDER', { getViewDom })
nextTick(() => triggerHook('ON_VIEW_RENDERED', { getViewDom }))
}, 500, { leading: true })
async function transformImgOutLink (img: HTMLImageElement) {
const transform = (ximg: HTMLImageElement): Promise<string> => {
const canvas = document.createElement('canvas')
canvas.width = ximg.naturalWidth
canvas.height = ximg.naturalHeight
canvas.getContext('2d')!!.drawImage(ximg, 0, 0)
return new Promise((resolve, reject) => {
canvas.toBlob(async blob => {
try {
const imgFile = new File([blob!!], 'file.png')
const { relativePath } = await file.upload(fileRepo.value, filePath.value, imgFile)
resolve(relativePath)
} catch (error) {
reject(error)
}
})
})
}
let replacedLink = ''
const imgAttrSrc = img.getAttribute('src') || ''
if (img.src.startsWith('data:')) {
replacedLink = await transform(img)
} else if (imgAttrSrc.startsWith('http://') || imgAttrSrc.startsWith('https://')) {
const res = await window.fetch(`api/proxy?url=${encodeURIComponent(img.src)}&headers=${img.getAttribute('headers') || ''}`)
const blob = await res.blob()
const imgFile = new File([blob!!], 'file.' + mime.extension(res.headers.get('content-type')!!))
const { relativePath } = await file.upload(fileRepo.value, filePath.value, imgFile)
replacedLink = relativePath
}
if (replacedLink) {
return { oldLink: img.src, replacedLink: encodeMarkdownLink(replacedLink) }
}
return null
}
async function keydownHandler (e: KeyboardEvent) {
// 转换所有外链图片到本地
if (isAction(e, 'transform-img-link')) {
e.preventDefault()
e.stopPropagation()
const result = []
const imgList = refView.value!!.querySelectorAll('img')
for (let i = 0; i < imgList.length; i++) {
toast.show('info', `正在转换外链图片 ${i + 1}/${imgList.length}`)
const img = imgList[i]
const data = await transformImgOutLink(img)
if (data) {
result.push(data)
}
}
result.forEach(data => bus.emit('editor-replace-value', { search: data.oldLink, replace: data.replacedLink }))
bus.emit('tree-refresh')
}
triggerHook('ON_VIEW_KEY_DOWN', e, getViewDom())
}
function handleScroll (e: any) {
@@ -340,133 +202,16 @@ export default defineComponent({
emit('sync-scroll', line)
}
function switchTodo (line: number, checked: boolean) {
emit('switch-todo', line, checked)
}
function scrollToTop () {
refViewWrapper.value!!.scrollTo(0, 0)
syncScroll(1)
}
async function handleClick (e: MouseEvent) {
triggerHook('ON_VIEW_ELEMENT_CLICK', e)
const target = e.target as HTMLElement
const preventEvent = () => {
e.preventDefault()
e.stopPropagation()
}
const handleLink = (link: HTMLAnchorElement) => {
// 系统中打开附件
if (link.classList.contains('open')) {
fetch(link.href.replace('api/attachment', 'api/open'))
return preventEvent()
}
const href = link.getAttribute('href') || ''
if (/^(http:|https:|ftp:)\/\//i.test(href)) { // 处理外链
// Electron 中打开外链
if (env.isElectron) {
env.require && env.require('opn')(link.href)
preventEvent()
}
} else { // 处理相对链接
if (/(\.md$|\.md#)/.test(href)) { // 处理打开相对 md 文件
const tmp = decodeURI(href).split('#')
let path = tmp[0]
if (path.startsWith('.')) { // 将相对路径转换为绝对路径
path = file.dirname(filePath.value || '') + path.replace('.', '')
}
// 打开文件
store.commit('setCurrentFile', {
path,
name: file.basename(path),
repo: fileRepo.value,
type: 'file'
})
// 跳转锚点
const hash = tmp.slice(1).join('#')
if (hash) {
bus.once('preview-rendered', () => {
const el = document.getElementById(hash) ||
document.getElementById(encodeURIComponent(hash))
if (el) {
// 如果是标题的话,也顺便将编辑器滚动到可视区域
if (hash.startsWith('h-')) {
el.click()
}
el.scrollIntoView()
}
})
}
preventEvent()
} else if (href && href.startsWith('#')) { // 处理 TOC 跳转
const el = document.getElementById(href.replace(/^#/, ''))
if (el) {
el.scrollIntoView()
}
preventEvent()
}
}
}
if (target.tagName === 'A') {
handleLink(target as HTMLAnchorElement)
}
// 复制标题链接
if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].indexOf(target.tagName) > -1 && target.id && hasCtrlCmd(e)) {
bus.emit('copy-text', encodeMarkdownLink(filePath.value) + '#' + encodeMarkdownLink(decodeURIComponent(target.id)))
return preventEvent()
}
// 复制内容
if (target.classList.contains('copy-inner-text')) {
bus.emit('copy-text', target.innerText)
}
if (target.tagName === 'IMG') {
const img = target as HTMLImageElement
if (isAction(e, 'transform-img-link-by-click')) { // 转换外链图片到本地
const data = await transformImgOutLink(img)
if (data) {
bus.emit('tree-refresh')
bus.emit('editor-replace-value', { search: data.oldLink, replace: data.replacedLink })
}
} else if (img.parentElement!!.tagName === 'A') {
handleLink(img.parentElement as HTMLAnchorElement)
} else {
env.openAlwaysOnTopWindow(img.src)
}
e.stopPropagation()
return
}
if (target.tagName === 'INPUT' && target.parentElement!!.classList.contains('source-line')) {
switchTodo(parseInt(target.parentElement!!.dataset.sourceLine || '0'), (target as HTMLInputElement).checked)
e.preventDefault()
e.stopPropagation()
return
}
if (target.tagName === 'TD' && target.classList.contains('yank-td') && hasCtrlCmd(e)) {
const start = parseInt(target.dataset.sourceLine || '0')
const end = parseInt(target.dataset.sourceLineEnd || '0')
const td = target as HTMLTableDataCellElement
const cellIndex = [...td.parentElement!.children as any]
.slice(0, td.cellIndex)
.reduce((prev, current) => prev + current.colSpan, 0)
bus.emit('editor-edit-table-cell', { start, end, cellIndex })
}
if (target.classList.contains('source-line') && window.getSelection()!!.toString().length < 1) {
syncScroll(parseInt(target.dataset.sourceLine || '0'))
e.stopPropagation()
@@ -491,7 +236,7 @@ export default defineComponent({
}
function convertFile (type: string) {
MarkdownItECharts.preparePrint()
triggerHook('ON_VIEW_BEFORE_CONVERT', { getViewDom })
let baseUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/')) + '/'
@@ -505,7 +250,6 @@ export default defineComponent({
html: refView.value!!.outerHTML.replace(/src="api/g, `src="${baseUrl}api`),
type
}
MarkdownItECharts.update()
setTimeout(() => {
refConvertForm.value!!.submit()
}, 300)
@@ -516,11 +260,8 @@ export default defineComponent({
}
onMounted(() => {
render()
setTimeout(() => {
updatePlantuml()
}, 100)
nextTick(render)
triggerHook('ON_VIEW_MOUNTED', { getViewDom })
window.addEventListener('keydown', keydownHandler, true)
bus.on('resize', resizeHandler)
bus.on('view-rerender', render)
@@ -537,9 +278,7 @@ export default defineComponent({
watch(filePath, () => {
// 切换文件后,开启自动预览
store.commit('setAutoPreview', true)
setTimeout(() => {
updatePlantuml()
}, 100)
triggerHook('ON_VIEW_FILE_CHANGE', { getViewDom })
})
return {
@@ -1,125 +0,0 @@
<template>
<div class="repository-switch">
<div class="current" v-if="currentRepo">仓库:{{currentRepo.name}}</div>
<div class="current" v-else>未选择仓库</div>
<ul class="list">
<li v-for="(path, name) in repositories" :key="name" :title="path" @click="choose({name, path})">{{name}}</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent, onBeforeMount, onBeforeUnmount, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import file from '../useful/file'
import { useBus } from '../useful/bus'
import { $args } from '../useful/global-args'
export default defineComponent({
name: 'repository-switch',
setup () {
const store = useStore()
const bus = useBus()
const { currentRepo, repositories } = toRefs(store.state)
function choose (repo: any) {
if (repo.name !== currentRepo.value?.name) {
store.commit('setCurrentRepo', repo)
}
}
function chooseRepoByName (name?: string) {
if (name && repositories.value[name]) {
choose({ name, path: repositories.value[name] })
}
}
function initRepo () {
const initRepoName = $args().get('init-repo')
const initFilePath = $args().get('init-file')
if (initRepoName) {
chooseRepoByName(initRepoName)
}
if (initFilePath) {
store.commit('setCurrentFile', { repo: currentRepo.value.name, name: file.basename(initFilePath), path: initFilePath })
}
}
onBeforeMount(() => {
bus.on('switch-repo-by-name', chooseRepoByName)
bus.on('editor-ready', initRepo)
store.dispatch('fetchRepositories')
})
onBeforeUnmount(() => {
bus.off('switch-repo-by-name', chooseRepoByName)
bus.off('editor-ready', initRepo)
})
watch(repositories, val => {
const keys = Object.keys(val)
if (!currentRepo.value || keys.indexOf(currentRepo.value.name) < 0) {
if (keys.length > 0) {
const name = keys[0]
store.commit('setCurrentRepo', { name, path: val[name] })
}
}
})
return {
currentRepo,
repositories,
choose,
}
},
})
</script>
<style scoped>
.repository-switch {
width: 100px;
cursor: pointer;
user-select: none;
}
.current {
padding: 0 .3em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.repository-switch:hover {
background: #333030;
}
.repository-switch:hover .list {
display: block;
}
.list {
width: 100px;
margin: 0;
list-style: none;
background: #333030;
padding: 4px 0;
display: none;
position: fixed;
bottom: 20px;
box-sizing: border-box;
}
.list li {
padding: 4px .6em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.list li:hover {
background: #252525;
}
</style>
+6 -82
View File
@@ -1,6 +1,6 @@
<template>
<div class="status-bar">
<RepositorySwitch class="left"></RepositorySwitch>
<StatusBarMenu class="left" position="left" />
<div class="right">
<div class="document-info">
<span>行:{{documentInfo.line}}</span>
@@ -9,12 +9,7 @@
<span>字符数:{{documentInfo.textLength}}</span>
<span v-if="documentInfo.selectedLength > 0">已选中:{{documentInfo.selectedLength}}</span>
</div>
<div class="action" @click="toggleSide" :title="getActionLabel('toggle-side')">切换侧栏</div>
<div class="action" @click="toggleWrap" :title="getActionLabel('toggle-wrap')">切换换行</div>
<div class="action" @click="toggleView" :title="getActionLabel('toggle-view')">切换预览</div>
<div class="action" @click="toggleXterm" :title="getActionLabel('toggle-xterm')">切换终端</div>
<div class="action" @click="toggleReadme" :title="getActionLabel('toggle-readme')">README</div>
<div class="action" @click="toggleFeature">特色功能说明</div>
<StatusBarMenu class="right" position="right" />
<div class="action" @click="toggleRender">{{autoPreview ? '同步渲染-已开启' : '同步渲染-已关闭'}}</div>
<svg-icon v-if="!autoPreview" class="action action-icon" name="sync-alt-solid" @click="rerenderView" title="强制重新渲染" />
</div>
@@ -22,45 +17,20 @@
</template>
<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, toRefs } from 'vue'
import { defineComponent, toRefs } from 'vue'
import { useStore } from 'vuex'
import { useBus } from '../useful/bus'
import RepositorySwitch from './RepositorySwitch.vue'
import StatusBarMenu from './StatusBarMenu.vue'
import SvgIcon from '../components/SvgIcon.vue'
import { isAction, getActionLabel } from '../useful/shortcut'
export default defineComponent({
name: 'status-bar',
components: { RepositorySwitch, SvgIcon },
components: { StatusBarMenu, SvgIcon },
setup () {
const bus = useBus()
const store = useStore()
const { documentInfo, autoPreview } = toRefs(store.state)
function toggleSide () {
bus.emit('toggle-side')
}
function toggleView () {
bus.emit('toggle-view')
}
function toggleXterm () {
bus.emit('toggle-xterm')
}
function toggleFeature () {
store.dispatch('showHelp', 'FEATURES.md')
}
function toggleReadme () {
store.dispatch('showHelp', 'README.md')
}
function toggleWrap () {
bus.emit('editor-toggle-wrap')
}
function toggleRender () {
store.commit('setAutoPreview', !autoPreview.value)
}
@@ -69,58 +39,11 @@ export default defineComponent({
bus.emit('view-rerender')
}
function keydownHandler (e: KeyboardEvent) {
if (isAction(e, 'toggle-side')) {
toggleSide()
e.preventDefault()
e.stopPropagation()
}
if (isAction(e, 'toggle-view')) {
toggleView()
e.preventDefault()
e.stopPropagation()
}
if (isAction(e, 'toggle-xterm')) {
toggleXterm()
e.preventDefault()
e.stopPropagation()
}
if (isAction(e, 'toggle-wrap')) {
toggleWrap()
e.preventDefault()
e.stopPropagation()
}
if (isAction(e, 'toggle-readme')) {
toggleReadme()
e.preventDefault()
e.stopPropagation()
}
}
onMounted(() => {
window.addEventListener('keydown', keydownHandler, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keydownHandler)
})
return {
documentInfo,
autoPreview,
toggleSide,
toggleWrap,
toggleView,
toggleXterm,
toggleReadme,
toggleFeature,
toggleRender,
rerenderView,
getActionLabel,
}
},
})
@@ -151,6 +74,7 @@ export default defineComponent({
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 0.3em;
}
.document-info > span {
+105
View File
@@ -0,0 +1,105 @@
<template>
<div class="status-bar-menu-wrapper">
<div class="status-bar-menu" v-for="menu in list" :key="menu.id">
<div class="title">{{menu.title}}</div>
<ul class="list" v-if="showList">
<li v-for="item in menu.list" :key="item.id" :title="item.tips" @click="handleItemClick(item)">{{item.title}}</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onBeforeUnmount, ref } from 'vue'
import { useBus } from '../useful/bus'
import { getStatusBarMenus, StatusBarMenuItem } from '../useful/plugin'
export default defineComponent({
name: 'status-bar-menu',
props: {
position: {
type: String,
default: 'left'
}
},
setup (props) {
const bus = useBus()
const list = ref(getStatusBarMenus('status-bar', props.position))
const showList = ref(true)
const handleItemClick = (item: StatusBarMenuItem) => {
item.onClick && item.onClick(item)
showList.value = false
setTimeout(() => {
showList.value = true
}, 0)
}
const updateMenu = () => {
list.value = getStatusBarMenus('status-bar', props.position)
}
bus.on('status-bar-menu-update', updateMenu)
onBeforeUnmount(() => {
bus.off('status-bar-menu-update', updateMenu)
})
return {
list,
showList,
handleItemClick
}
},
})
</script>
<style scoped>
.status-bar-menu {
cursor: pointer;
user-select: none;
z-index: 9999999999;
position: relative;
overflow-x: hidden;
}
.title {
width: 100%;
padding: 0 .8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
box-sizing: border-box;
width: fit-content;
}
.status-bar-menu:hover {
background: #2f3031;
}
.status-bar-menu:hover .list {
display: block;
}
.list {
width: 100px;
margin: 0;
list-style: none;
background: #37393a;
padding: 4px 0;
display: none;
position: fixed;
bottom: 20px;
box-sizing: border-box;
}
.list li {
padding: 4px .8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.list li:hover {
background: #2f3031;
}
</style>
+4 -6
View File
@@ -38,10 +38,10 @@ import { useToast } from '../useful/toast'
import { useModal } from '../useful/modal'
import { useContextMenu } from '../useful/context-menu'
import File from '../useful/file'
import { Components } from '../types'
import Extensions from '../useful/extensions'
import { triggerHook } from '../useful/plugin'
import { Components } from '../types'
import SvgIcon from './SvgIcon.vue'
import DrawioPlugin from '../plugins/DrawioPlugin'
export default defineComponent({
name: 'tree-node',
@@ -139,14 +139,12 @@ export default defineComponent({
}
}
function select (item: any) {
async function select (item: any) {
if (item.type !== 'dir') {
if (Extensions.supported(item.name)) {
store.commit('setCurrentFile', item)
} else {
if (item.path.toLowerCase().endsWith('.drawio')) {
DrawioPlugin.open(item)
} else {
if (!(await triggerHook('ON_TREE_NODE_SELECT', item))) {
File.openInOS(props.item)
}
}
-15
View File
@@ -1,15 +0,0 @@
import { getKeyLabel } from '@/useful/shortcut'
import Markdown from 'markdown-it'
export default (md: Markdown) => {
const Fun = (fn: Function) => (tokens: any, idx: any, options: any, env: any, slf: any) => {
if (tokens[idx].attrIndex('title') < 0) {
tokens[idx].attrJoin('class', 'copy-inner-text')
tokens[idx].attrPush(['title', getKeyLabel('CtrlCmd') + ' + 单击复制'])
}
return (fn)(tokens, idx, options, env, slf)
}
md.renderer.rules.code_inline = Fun(md.renderer.rules.code_inline!!.bind(md.renderer.rules))
}
-13
View File
@@ -1,13 +0,0 @@
import Markdown from 'markdown-it'
const LinkTargetPlugin = (md: Markdown) => {
md.renderer.rules.link_open = (tokens, idx, options, _, slf) => {
if (tokens[idx].attrIndex('target') < 0) {
tokens[idx].attrPush(['target', '_blank'])
}
return slf.renderToken(tokens, idx, options)
}
}
export default LinkTargetPlugin
@@ -1,7 +1,9 @@
import CryptoJS from 'crypto-js'
import lodash from 'lodash'
import dayjs from 'dayjs'
import Markdown from 'markdown-it'
import { useBus } from '@/useful/bus'
import { Plugin } from '@/useful/plugin'
const RunPlugin = (md: Markdown) => {
const temp = md.renderer.rules.fence!!.bind(md.renderer.rules)
@@ -33,7 +35,7 @@ const RunPlugin = (md: Markdown) => {
}
}
RunPlugin.runScript = (el: HTMLElement) => {
const runScript = (el: HTMLElement) => {
const appletId = el.id
const appletCode = el.dataset.code
if (!appletCode) {
@@ -44,7 +46,7 @@ RunPlugin.runScript = (el: HTMLElement) => {
iframe.srcdoc = `
<style>
::selection {
background: #d3d3d3;
background: #515455;
}
::-webkit-scrollbar {
@@ -140,4 +142,17 @@ RunPlugin.runScript = (el: HTMLElement) => {
el.appendChild(iframe)
}
export default RunPlugin
export default {
name: 'applet',
register: ctx => {
ctx.registerMarkdownItPlugin(RunPlugin)
function runAppletScript ({ getViewDom }: any) {
const refView: HTMLElement = getViewDom()
const nodes = refView.querySelectorAll<HTMLElement>('.applet[data-code]')
nodes.forEach(runScript)
}
ctx.registerHook('ON_VIEW_RENDER', lodash.debounce(runAppletScript, 1000, { leading: true }))
}
} as Plugin
@@ -2,6 +2,8 @@
// https://github.com/wcoder/highlightjs-line-numbers.js
import { Plugin } from '@/useful/plugin'
const TABLE_NAME = 'hljs-ln'
const LINE_NAME = 'hljs-ln-line'
const CODE_BLOCK_NAME = 'hljs-ln-code'
@@ -149,4 +151,14 @@ function format (format: string, args: any) {
})
}
export default { addStyles, lineNumbersBlock }
export default {
name: 'markdown-toc',
register: ctx => {
ctx.registerHook('ON_STARTUP', addStyles)
ctx.registerHook('ON_VIEW_RENDER', ({ getViewDom }) => {
const refView: HTMLElement = getViewDom()
const nodes = refView.querySelectorAll<HTMLImageElement>('code[class^="language-"]')
nodes.forEach(lineNumbersBlock)
})
}
} as Plugin
+42
View File
@@ -0,0 +1,42 @@
import { Plugin, Ctx } from '@/useful/plugin'
import { hasCtrlCmd } from '@/useful/shortcut'
import { encodeMarkdownLink } from '@/useful/utils'
import { useBus } from '@/useful/bus'
import { copyText } from '@/useful/copy-text'
import store from '@/store'
export default {
name: 'copy-text',
register: (ctx: Ctx) => {
ctx.registerHook('ON_STARTUP', () => {
const bus = useBus()
bus.on('copy-text', copyText)
})
ctx.registerHook('ON_VIEW_ELEMENT_CLICK', async (e: MouseEvent) => {
const target = e.target as HTMLElement
const bus = useBus()
const preventEvent = () => {
e.preventDefault()
e.stopPropagation()
return true
}
// 复制标题链接
if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].indexOf(target.tagName) > -1 && target.id && hasCtrlCmd(e)) {
const { currentFile } = store.state
bus.emit('copy-text', encodeMarkdownLink(currentFile.path) + '#' + encodeMarkdownLink(decodeURIComponent(target.id)))
return preventEvent()
}
// 复制内容
if (target.classList.contains('copy-inner-text') && hasCtrlCmd(e)) {
bus.emit('copy-text', target.innerText)
return preventEvent()
}
return false
})
}
} as Plugin
@@ -1,9 +1,10 @@
import Markdown from 'markdown-it'
import { Plugin } from '@/useful/plugin'
import file from '@/useful/file'
import { useBus } from '@/useful/bus'
import { openInNewWindow } from '@/useful/utils'
const Plugin = (md: Markdown) => {
const MarkdownItPlugin = (md: Markdown) => {
const renderHtml = ({ url, content }: any) => {
const iframe = document.createElement('iframe')
iframe.className = 'drawio'
@@ -119,7 +120,7 @@ const buildSrcdoc = async ({ repo, path, content, url }: F) => {
`
}
Plugin.load = async (el: HTMLIFrameElement, url?: string) => {
const load = async (el: HTMLIFrameElement, url?: string) => {
const bus = useBus()
const content = el.dataset.content || ''
@@ -154,9 +155,33 @@ Plugin.load = async (el: HTMLIFrameElement, url?: string) => {
el.parentElement!!.appendChild(action)
}
Plugin.open = async (file: F) => {
const srcdoc = await buildSrcdoc(file)
openInNewWindow(srcdoc)
}
export default {
name: 'drawio',
register: ctx => {
ctx.registerMarkdownItPlugin(MarkdownItPlugin)
export default Plugin
ctx.registerHook('ON_VIEW_RENDER', ({ getViewDom }) => {
const refView: HTMLElement = getViewDom()
const nodes = refView.querySelectorAll<HTMLIFrameElement>('.drawio[data-url]')
nodes.forEach(el => {
const url = el.dataset.url
if (url) {
load(el, url)
} else {
load(el)
}
})
})
ctx.registerHook('ON_TREE_NODE_SELECT', async (item: any) => {
if (item.path.toLowerCase().endsWith('.drawio')) {
const srcdoc = await buildSrcdoc(item)
openInNewWindow(srcdoc)
return true
}
return false
})
}
} as Plugin
@@ -1,6 +1,7 @@
import echarts from 'echarts'
import CryptoJS from 'crypto-js'
import Markdown from 'markdown-it'
import { Plugin } from '@/useful/plugin'
const renderHtml = (code: string) => {
const id = `echart-${CryptoJS.MD5(code).toString()}-${Math.random().toString(36).substr(2)}`
@@ -24,7 +25,7 @@ const EChartsPlugin = (md: Markdown) => {
let charts: {[key: string]: any} = {}
EChartsPlugin.update = (theme = 'dark', animation: boolean | undefined = undefined, renderImg = false) => {
const update = (theme = 'dark', animation: boolean | undefined = undefined, renderImg = false) => {
Object.values(charts).forEach(x => x.chart.dispose())
charts = {}
@@ -60,8 +61,8 @@ EChartsPlugin.update = (theme = 'dark', animation: boolean | undefined = undefin
}
}
EChartsPlugin.preparePrint = () => {
EChartsPlugin.update('light', false, true)
const preparePrint = () => {
update('light', false, true)
for (const ele of document.getElementsByClassName('echarts')) {
while (ele.firstChild) {
@@ -81,7 +82,19 @@ window.addEventListener('resize', () => {
})
window.addEventListener('beforeprint', () => {
EChartsPlugin.update('light', false)
update('light', false)
})
export default EChartsPlugin
export default {
name: 'markdown-toc',
register: ctx => {
ctx.registerMarkdownItPlugin(EChartsPlugin)
ctx.registerHook('ON_VIEW_RENDER', () => {
update()
})
ctx.registerHook('ON_VIEW_BEFORE_CONVERT', () => {
preparePrint() // 转换成图片形式,设置主题
setTimeout(() => update(), 0) // 恢复
})
}
} as Plugin
+20
View File
@@ -0,0 +1,20 @@
import { getKeyLabel } from '@/useful/shortcut'
import { Plugin, Ctx } from '@/useful/plugin'
export default {
name: 'markdown-code',
register: (ctx: Ctx) => {
ctx.registerMarkdownItPlugin(md => {
const Fun = (fn: Function) => (tokens: any, idx: any, options: any, env: any, slf: any) => {
if (tokens[idx].attrIndex('title') < 0) {
tokens[idx].attrJoin('class', 'copy-inner-text')
tokens[idx].attrPush(['title', getKeyLabel('CtrlCmd') + ' + 单击复制'])
}
return (fn)(tokens, idx, options, env, slf)
}
md.renderer.rules.code_inline = Fun(md.renderer.rules.code_inline!!.bind(md.renderer.rules))
})
}
} as Plugin
+104
View File
@@ -0,0 +1,104 @@
import { Plugin, Ctx } from '@/useful/plugin'
import store from '@/store'
import env from '@/useful/env'
import file from '@/useful/file'
const handleLink = (link: HTMLAnchorElement, ctx: Ctx) => {
const { currentFile } = store.state
const { repo: fileRepo, path: filePath } = currentFile
// 系统中打开附件
if (link.classList.contains('open')) {
fetch(link.href.replace('api/attachment', 'api/open'))
return true
}
const href = link.getAttribute('href') || ''
if (/^(http:|https:|ftp:)\/\//i.test(href)) { // 处理外链
// Electron 中打开外链
if (env.isElectron) {
env.require && env.require('opn')(link.href)
return true
}
} else { // 处理相对链接
if (/(\.md$|\.md#)/.test(href)) { // 处理打开相对 md 文件
const tmp = decodeURI(href).split('#')
let path = tmp[0]
if (path.startsWith('.')) { // 将相对路径转换为绝对路径
path = file.dirname(filePath || '') + path.replace('.', '')
}
// 打开文件
store.commit('setCurrentFile', {
path,
name: file.basename(path),
repo: fileRepo,
type: 'file'
})
// 跳转锚点
const hash = tmp.slice(1).join('#')
if (hash) {
ctx.registerHook('ON_VIEW_RENDERED', () => {
const el = document.getElementById(hash) ||
document.getElementById(encodeURIComponent(hash))
if (el) {
// 如果是标题的话,也顺便将编辑器滚动到可视区域
if (hash.startsWith('h-')) {
el.click()
}
el.scrollIntoView()
}
}, true)
}
return true
} else if (href && href.startsWith('#')) { // 处理 TOC 跳转
const el = document.getElementById(href.replace(/^#/, ''))
if (el) {
el.scrollIntoView()
}
return true
}
return false
}
}
export default {
name: 'markdown-link',
register: (ctx: Ctx) => {
ctx.registerHook('ON_VIEW_ELEMENT_CLICK', async (e: MouseEvent) => {
const target = e.target as HTMLElement
const preventEvent = () => {
e.preventDefault()
e.stopPropagation()
return true
}
if (target.tagName === 'A' || target.parentElement?.tagName === 'A') {
if (handleLink(target as HTMLAnchorElement, ctx)) {
return preventEvent()
} else {
return true
}
}
return false
})
ctx.registerMarkdownItPlugin(md => {
md.renderer.rules.link_open = (tokens, idx, options, _, slf) => {
if (tokens[idx].attrIndex('target') < 0) {
tokens[idx].attrPush(['target', '_blank'])
}
return slf.renderToken(tokens, idx, options)
}
})
}
} as Plugin
@@ -1,4 +1,4 @@
import Markdown from 'markdown-it'
import { Plugin } from '@/useful/plugin'
import Renderer from 'markdown-it/lib/renderer'
const buildFunction = (className: string): Renderer.RenderRule => {
@@ -16,12 +16,15 @@ const buildFunction = (className: string): Renderer.RenderRule => {
export const injectLineNumbers = buildFunction('source-line')
const SourceLinePlugin = (md: Markdown) => {
md.renderer.rules.paragraph_open = injectLineNumbers
md.renderer.rules.heading_open = injectLineNumbers
md.renderer.rules.list_item_open = injectLineNumbers
md.renderer.rules.table_open = injectLineNumbers
md.renderer.rules.td_open = buildFunction('yank-td')
}
export default SourceLinePlugin
export default {
name: 'markdown-source-line',
register: ctx => {
ctx.registerMarkdownItPlugin(md => {
md.renderer.rules.paragraph_open = injectLineNumbers
md.renderer.rules.heading_open = injectLineNumbers
md.renderer.rules.list_item_open = injectLineNumbers
md.renderer.rules.table_open = injectLineNumbers
md.renderer.rules.td_open = buildFunction('yank-td')
})
}
} as Plugin
@@ -2,8 +2,9 @@
import Markdown from 'markdown-it'
import { getKeyLabel } from '@/useful/shortcut'
import { Plugin } from '@/useful/plugin'
import { injectLineNumbers } from './SourceLinePlugin'
import { injectLineNumbers } from './markdown-source-line'
const slugify = (s: string) => 'h-' + encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'))
@@ -19,7 +20,7 @@ const defaults = {
containerFooterHtml: undefined
}
export default (md: Markdown, o: any) => {
const MarkdownItPlugin = (md: Markdown, o: any) => {
const options = Object.assign({}, defaults, o)
const tocRegexp = options.markerPattern
let gstate: any
@@ -201,3 +202,10 @@ export default (md: Markdown, o: any) => {
// Insert TOC
md.inline.ruler.after('emphasis', 'toc', toc)
}
export default {
name: 'markdown-toc',
register: ctx => {
ctx.registerMarkdownItPlugin(MarkdownItPlugin)
}
} as Plugin
@@ -1,5 +1,6 @@
import Markdown from 'markdown-it'
import mermaid from 'mermaid'
import { Plugin } from '@/useful/plugin'
const mermaidChart = (code: string) => {
try {
@@ -28,8 +29,10 @@ const MermaidPlugin = (md: Markdown) => {
}
}
MermaidPlugin.update = () => {
mermaid.init('.mermaid')
}
export default MermaidPlugin
export default {
name: 'mermaid',
register: ctx => {
ctx.registerMarkdownItPlugin(MermaidPlugin)
ctx.registerHook('ON_VIEW_RENDER', () => mermaid.init('.mermaid'))
}
} as Plugin
@@ -1,5 +1,6 @@
import Markdown from 'markdown-it'
import lodash from 'lodash'
import Renderer from 'markdown-it/lib/renderer'
import { Plugin } from '@/useful/plugin'
import crypto from '@/useful/crypto'
import { dataURItoBlobLink, openInNewWindow } from '@/useful/utils'
@@ -21,10 +22,6 @@ const renderRule: Renderer.RenderRule = (tokens, idx, options, { source }, slf)
return slf.renderToken(tokens, idx, options)
}
const Plugin = (md: Markdown) => {
md.renderer.rules.bullet_list_open = renderRule
}
const buildSrcdoc = (json: string, btns: string) => {
return `
<html>
@@ -87,7 +84,7 @@ const buildSrcdoc = (json: string, btns: string) => {
`
}
Plugin.render = async (ele: HTMLElement) => {
const render = async (ele: HTMLElement) => {
const code = (ele.dataset.mindmapSource || '').trim()
const div = document.createElement('div')
@@ -189,4 +186,19 @@ Plugin.render = async (ele: HTMLElement) => {
div.appendChild(action)
}
export default Plugin
export default {
name: 'mind-map',
register: ctx => {
ctx.registerMarkdownItPlugin(md => {
md.renderer.rules.bullet_list_open = renderRule
})
function renderMindMap ({ getViewDom }: any) {
const refView: HTMLElement = getViewDom()
const nodes = refView.querySelectorAll<HTMLElement>('.mindmap-list[data-mindmap-source]')
nodes.forEach(render)
}
ctx.registerHook('ON_VIEW_RENDER', lodash.debounce(renderMindMap, 1000, { leading: true }))
}
} as Plugin
@@ -1,7 +1,9 @@
// https://github.com/DeepElement/markdown-it-plantuml-offline
import lodash from 'lodash'
import Markdown from 'markdown-it'
import { Plugin } from '@/useful/plugin'
export default function umlPlugin (md: Markdown, options: any) {
const MarkdownItPlugin = function umlPlugin (md: Markdown, options: any) {
function generateSourceDefault (umlCode: string) {
return 'api/plantuml/png?data=' + encodeURIComponent(umlCode)
}
@@ -136,3 +138,30 @@ export default function umlPlugin (md: Markdown, options: any) {
})
md.renderer.rules.uml_diagram = render
}
export default {
name: 'markdown-plantuml',
register: ctx => {
ctx.registerMarkdownItPlugin(MarkdownItPlugin)
const updatePlantuml = ({ getViewDom }: any) => {
const refView: HTMLElement = getViewDom()
const nodes = refView.querySelectorAll<HTMLImageElement>('img[data-plantuml-src]')
nodes.forEach(el => {
el.src = el.dataset.plantumlSrc || ''
})
}
const quickUpdateFun = (params: any) => {
setTimeout(() => {
ctx.registerHook('ON_VIEW_RENDERED', () => {
updatePlantuml(params)
}, true)
}, 0)
}
ctx.registerHook('ON_VIEW_MOUNTED', quickUpdateFun)
ctx.registerHook('ON_VIEW_FILE_CHANGE', quickUpdateFun)
ctx.registerHook('ON_VIEW_RENDERED', lodash.debounce(updatePlantuml, 3000, { leading: true }))
}
} as Plugin
@@ -1,5 +1,6 @@
import CryptoJS from 'crypto-js'
import Markdown from 'markdown-it'
import { Plugin } from '@/useful/plugin'
const cachePrefix = 'run_code_result_'
@@ -60,7 +61,7 @@ const RunPlugin = (md: Markdown) => {
}
}
RunPlugin.clearCache = () => {
const clearCache = () => {
Object.keys(localStorage).forEach(key => {
if (key.startsWith(cachePrefix)) {
localStorage.removeItem(key)
@@ -68,4 +69,10 @@ RunPlugin.clearCache = () => {
})
}
export default RunPlugin
export default {
name: 'run-code',
register: ctx => {
ctx.registerMarkdownItPlugin(RunPlugin)
ctx.registerHook('ON_STARTUP', clearCache)
}
} as Plugin
+54
View File
@@ -0,0 +1,54 @@
import { Plugin } from '@/useful/plugin'
import { Alt, getActionLabel, isAction } from '@/useful/shortcut'
import store from '@/store'
export default {
name: 'status-bar-view',
register: ctx => {
const actions = {
'toggle-readme': {
title: 'README',
action: () => store.dispatch('showHelp', 'README.md'),
shortcut: [Alt, 'h'],
},
'toggle-features': {
title: '特色功能说明',
action: () => store.dispatch('showHelp', 'FEATURES.md'),
shortcut: null,
},
}
Object.entries(actions).forEach(([key, item]) => {
item.shortcut && ctx.registerShortcutAction(key, item.shortcut)
})
function keydownHandler (e: KeyboardEvent) {
for (const [key, item] of Object.entries(actions)) {
if (isAction(e, key)) {
item.action()
e.preventDefault()
e.stopPropagation()
return
}
}
}
window.addEventListener('keydown', keydownHandler, true)
ctx.updateStatusBarMenu({
id: 'status-bar-help',
location: 'status-bar',
position: 'right',
title: '帮助',
list: Object.entries(actions).map(([key, item]) => {
return {
id: key,
type: 'normal',
title: item.title,
tips: item.shortcut ? getActionLabel(key) : '',
onClick: item.action
}
})
})
}
} as Plugin
@@ -0,0 +1,87 @@
import { Plugin, StatusBarMenu } from '@/useful/plugin'
import store from '@/store'
import { useBus } from '@/useful/bus'
import { $args } from '@/useful/global-args'
import file from '@/useful/file'
export default {
name: 'status-bar-repository-switch',
register: ctx => {
const bus = useBus()
function choose (repo: any) {
const { currentRepo } = store.state
if (repo.name !== currentRepo.name) {
store.commit('setCurrentRepo', repo)
}
}
function chooseRepoByName (name?: string) {
const { repositories } = store.state
if (name && repositories[name]) {
choose({ name, path: repositories[name] })
}
}
function initRepo () {
const { currentRepo } = store.state
const initRepoName = $args().get('init-repo')
const initFilePath = $args().get('init-file')
if (initRepoName) {
chooseRepoByName(initRepoName)
}
if (initFilePath) {
store.commit('setCurrentFile', { repo: currentRepo.name, name: file.basename(initFilePath), path: initFilePath })
}
}
const menu = {
id: 'status-bar-repository-switch',
location: 'status-bar',
position: 'left',
title: '仓库',
} as StatusBarMenu
function updateMenu () {
const { currentRepo, repositories } = store.state
ctx.updateStatusBarMenu({
...menu,
title: currentRepo ? `仓库: ${currentRepo.name}` : '未选择仓库',
list: Object.keys(repositories).map(name => {
const path = repositories[name]
return {
id: name,
type: 'normal',
title: name,
tips: path,
onClick: () => choose({ name, path })
}
})
})
}
ctx.updateStatusBarMenu(menu)
bus.on('switch-repo-by-name', chooseRepoByName)
bus.on('editor-ready', initRepo)
store.dispatch('fetchRepositories')
store.watch(() => store.state.repositories, updateMenu)
store.watch(() => store.state.currentRepo, updateMenu)
store.watch(() => store.state.repositories, val => {
const { currentRepo } = store.state
const keys = Object.keys(val)
if (!currentRepo || keys.indexOf(currentRepo.name) < 0) {
if (keys.length > 0) {
const name = keys[0]
store.commit('setCurrentRepo', { name, path: val[name] })
}
}
})
}
} as Plugin
+64
View File
@@ -0,0 +1,64 @@
import { Plugin } from '@/useful/plugin'
import { useBus } from '@/useful/bus'
import { Alt, getActionLabel, isAction } from '@/useful/shortcut'
export default {
name: 'status-bar-view',
register: ctx => {
const bus = useBus()
const actions = {
'toggle-side': {
title: '切换侧栏',
action: () => bus.emit('toggle-side'),
shortcut: [Alt, 'e'],
},
'toggle-view': {
title: '切换预览',
action: () => bus.emit('toggle-view'),
shortcut: [Alt, 'v'],
},
'toggle-xterm': {
title: '切换终端',
action: () => bus.emit('toggle-xterm'),
shortcut: [Alt, 't'],
},
'toggle-wrap': {
title: '切换换行',
action: () => bus.emit('editor-toggle-wrap'),
shortcut: [Alt, 'w'],
},
}
Object.entries(actions).forEach(([key, item]) => ctx.registerShortcutAction(key, item.shortcut))
function keydownHandler (e: KeyboardEvent) {
for (const [key, item] of Object.entries(actions)) {
if (isAction(e, key)) {
item.action()
e.preventDefault()
e.stopPropagation()
return
}
}
}
window.addEventListener('keydown', keydownHandler, true)
ctx.updateStatusBarMenu({
id: 'status-bar-view',
location: 'status-bar',
position: 'left',
title: '视图',
list: Object.entries(actions).map(([key, item]) => {
return {
id: key,
type: 'normal',
title: item.title,
tips: getActionLabel(key),
onClick: item.action
}
})
})
}
} as Plugin
+36
View File
@@ -0,0 +1,36 @@
import { Plugin, Ctx } from '@/useful/plugin'
import { useBus } from '@/useful/bus'
import dayjs from 'dayjs'
export default {
name: 'switch-todo',
register: (ctx: Ctx) => {
ctx.registerHook('ON_VIEW_ELEMENT_CLICK', async (e: MouseEvent) => {
const target = e.target as HTMLElement
const bus = useBus()
const preventEvent = () => {
e.preventDefault()
e.stopPropagation()
return true
}
function switchTodo (line: number, checked: boolean) {
let lineText = ''
bus.emit('editor-get-line', { line, callback: (val: string) => { lineText = val } })
const value = checked
? lineText.replace('[ ]', `[x] ~~${dayjs().format('YYYY-MM-DD HH:mm')}~~`)
: lineText.replace(/(\[x\] ~~[\d-: ]+~~|\[x\])/, '[ ]')
bus.emit('editor-replace-line', { line, value })
}
if (target.tagName === 'INPUT' && target.parentElement!!.classList.contains('source-line')) {
switchTodo(parseInt(target.parentElement!!.dataset.sourceLine || '0'), (target as HTMLInputElement).checked)
return preventEvent()
}
return false
})
}
} as Plugin
+116
View File
@@ -0,0 +1,116 @@
import { useBus } from '@/useful/bus'
import { useModal } from '@/useful/modal'
import { Plugin, Ctx } from '@/useful/plugin'
import { hasCtrlCmd } from '@/useful/shortcut'
import { useToast } from '@/useful/toast'
const editTableCell = async (start: number, end: number, cellIndex: number) => {
const toast = useToast()
const modal = useModal()
const bus = useBus()
if (end - start !== 1) {
toast.show('warning', '暂只支持编辑单行文本')
return
}
const escapedSplit = (str: string) => {
const result = []
const max = str.length
let pos = 0
let ch = str.charCodeAt(pos)
let isEscaped = false
let lastPos = 0
let current = ''
while (pos < max) {
if (ch === 0x7c/* | */) {
if (!isEscaped) {
// pipe separating cells, '|'
result.push(current + str.substring(lastPos, pos))
current = ''
lastPos = pos + 1
} else {
// escaped pipe, '\|'
current += str.substring(lastPos, pos - 1)
lastPos = pos
}
}
isEscaped = (ch === 0x5c/* \ */)
pos++
ch = str.charCodeAt(pos)
}
result.push(current + str.substring(lastPos))
if (result.length && result[0] === '') result.shift()
if (result.length && result[result.length - 1] === '') result.pop()
return result
}
let text = ''
bus.emit('editor-get-line', { line: start, callback: (val: string) => { text = val.trim() } })
const columns = escapedSplit(text)
const cellText = columns[cellIndex]
if (typeof cellText !== 'string') {
toast.show('warning', '编辑错误')
return
}
let value = await modal.input({
title: '编辑单元格',
type: 'textarea',
value: cellText,
modalWidth: '600px',
hint: '单元格内容',
})
if (typeof value !== 'string') {
toast.show('warning', '取消编辑')
return
}
if (!value.startsWith(' ') && cellIndex > 0) value = ' ' + value
if (!value.endsWith(' ') && cellIndex < columns.length - 1) value += ' '
columns[cellIndex] = value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
let content = columns.join('|').trim()
if (text.startsWith('|')) content = '| ' + content
if (text.endsWith('|')) content += ' |'
bus.emit('editor-replace-line', { line: start, value: content })
}
export default {
name: 'table-cell-edit',
register: (ctx: Ctx) => {
ctx.registerHook('ON_VIEW_ELEMENT_CLICK', async (e: MouseEvent) => {
const target = e.target as HTMLElement
const preventEvent = () => {
e.preventDefault()
e.stopPropagation()
return true
}
if (target.tagName === 'TD' && target.classList.contains('yank-td') && hasCtrlCmd(e)) {
const start = parseInt(target.dataset.sourceLine || '0')
const end = parseInt(target.dataset.sourceLineEnd || '0')
const td = target as HTMLTableDataCellElement
const cellIndex = [...td.parentElement!.children as any]
.slice(0, td.cellIndex)
.reduce((prev, current) => prev + current.colSpan, 0)
editTableCell(start, end, cellIndex)
return preventEvent()
}
return false
})
}
} as Plugin
@@ -0,0 +1,108 @@
import mime from 'mime-types'
import file from '@/useful/file'
import { Plugin, Ctx } from '@/useful/plugin'
import { CtrlCmd, isAction, LeftClick, Shift } from '@/useful/shortcut'
import { encodeMarkdownLink } from '@/useful/utils'
import { useBus } from '@/useful/bus'
import env from '@/useful/env'
import { useToast } from '@/useful/toast'
import store from '@/store'
async function transformImgOutLink (img: HTMLImageElement) {
const { currentFile } = store.state
const { repo, path } = currentFile
const transform = (ximg: HTMLImageElement): Promise<string> => {
const canvas = document.createElement('canvas')
canvas.width = ximg.naturalWidth
canvas.height = ximg.naturalHeight
canvas.getContext('2d')!!.drawImage(ximg, 0, 0)
return new Promise((resolve, reject) => {
canvas.toBlob(async blob => {
try {
const imgFile = new File([blob!!], 'file.png')
const { relativePath } = await file.upload(repo, path, imgFile)
resolve(relativePath)
} catch (error) {
reject(error)
}
})
})
}
let replacedLink = ''
const imgAttrSrc = img.getAttribute('src') || ''
if (img.src.startsWith('data:')) {
replacedLink = await transform(img)
} else if (imgAttrSrc.startsWith('http://') || imgAttrSrc.startsWith('https://')) {
const res = await window.fetch(`api/proxy?url=${encodeURIComponent(img.src)}&headers=${img.getAttribute('headers') || ''}`)
const blob = await res.blob()
const imgFile = new File([blob!!], 'file.' + mime.extension(res.headers.get('content-type')!!))
const { relativePath } = await file.upload(repo, path, imgFile)
replacedLink = relativePath
}
if (replacedLink) {
return { oldLink: img.src, replacedLink: encodeMarkdownLink(replacedLink) }
}
return null
}
const actionKeydown = 'transform-img-link'
const actionClick = 'transform-img-link-by-click'
export default {
name: 'transform-img-out-link',
register: (ctx: Ctx) => {
ctx.registerShortcutAction(actionKeydown, [CtrlCmd, Shift, 'l'])
ctx.registerShortcutAction(actionClick, [CtrlCmd, Shift, LeftClick])
ctx.registerHook('ON_VIEW_KEY_DOWN', async (e: KeyboardEvent, refView: HTMLElement) => {
const toast = useToast()
const bus = useBus()
if (isAction(e, actionKeydown)) {
e.preventDefault()
e.stopPropagation()
const result = []
const imgList = refView.querySelectorAll('img')
for (let i = 0; i < imgList.length; i++) {
toast.show('info', `正在转换外链图片 ${i + 1}/${imgList.length}`)
const img = imgList[i]
const data = await transformImgOutLink(img)
if (data) {
result.push(data)
}
}
result.forEach(data => bus.emit('editor-replace-value', { search: data.oldLink, replace: data.replacedLink }))
bus.emit('tree-refresh')
}
})
ctx.registerHook('ON_VIEW_ELEMENT_CLICK', async (e: MouseEvent) => {
const bus = useBus()
const target = e.target as HTMLElement
if (target.tagName !== 'IMG') {
return false
}
const img = target as HTMLImageElement
if (isAction(e, actionClick)) { // 转换外链图片到本地
const data = await transformImgOutLink(img)
if (data) {
bus.emit('tree-refresh')
bus.emit('editor-replace-value', { search: data.oldLink, replace: data.replacedLink })
}
} else {
env.openAlwaysOnTopWindow(img.src)
}
e.stopPropagation()
e.preventDefault()
return true
})
}
} as Plugin
+1 -1
View File
@@ -36,7 +36,7 @@ const getLastOpenFile = (repoName?: string) => {
export default createStore({
state: {
repositories: {},
repositories: {} as any,
tree: null,
showSide: Storage.get('showSide', true),
showView: Storage.get('showView', true),
+138
View File
@@ -0,0 +1,138 @@
import { addAction } from './shortcut'
import { getLogger } from './utils'
import Markdown from 'markdown-it'
import { useBus } from './bus'
export type HookType = 'ON_STARTUP'
| 'ON_VIEW_ELEMENT_CLICK'
| 'ON_VIEW_KEY_DOWN'
| 'ON_VIEW_RENDER'
| 'ON_VIEW_RENDERED'
| 'ON_VIEW_MOUNTED'
| 'ON_VIEW_FILE_CHANGE'
| 'ON_VIEW_BEFORE_CONVERT'
| 'ON_TREE_NODE_SELECT'
export interface Plugin {
name: string;
register?: (ctx: Ctx) => void;
}
export type HookFun = (...args: any[]) => boolean | void | Promise<boolean | void>
export type StatusBarMenuItem = {
id: string;
type: 'normal';
title: string;
tips?: string;
onClick?: (item: StatusBarMenuItem) => void;
}
export type MenuLocation = 'status-bar'
export interface StatusBarMenu {
id: string;
title: string;
tips?: string;
location: MenuLocation;
position: 'left' | 'right';
onClick?: (menu: StatusBarMenu) => void;
list?: (StatusBarMenuItem | { type: 'separator' })[];
}
const logger = getLogger('plugin')
const bus = useBus()
const plugins: {[name: string]: Plugin} = {}
const hooks: { [key in HookType]?: {fun: HookFun; once: boolean}[] } = {}
const markdownItPlugins: {plugin: any; params: any}[] = []
const statusBarMenus: { [key: string]: StatusBarMenu } = {}
const ctx = {
registerShortcutAction (name: string, keys: (string | number)[]) {
addAction(name, keys)
},
registerHook: (type: HookType, fun: HookFun, once = false) => {
if (Array.isArray(hooks[type])) {
hooks[type] = [...hooks[type]!!, { fun, once }]
} else {
hooks[type] = [{ fun, once }]
}
},
removeHook: (type: HookType, fun: HookFun) => {
if (Array.isArray(hooks[type])) {
hooks[type] = hooks[type]!!.filter(x => x.fun !== fun)
}
},
registerMarkdownItPlugin: (plugin: (md: Markdown, ...args: any) => void, params?: any) => {
markdownItPlugins.push({ plugin, params })
},
updateStatusBarMenu: (menu: StatusBarMenu) => {
statusBarMenus[menu.id] = menu
bus.emit('status-bar-menu-update', menu)
},
removeStatusBarMenu: (id: string) => {
delete statusBarMenus[id]
bus.emit('status-bar-menu-update')
}
}
export type Ctx = typeof ctx;
export const register = (plugin: Plugin) => {
logger.debug('register', plugin)
plugins[plugin.name] = plugin
plugin.register && plugin.register(ctx)
}
export const init = () => {
logger.debug('init')
const localPlugins = [
require('@/plugins/status-bar-repository-switch'),
require('@/plugins/status-bar-view'),
require('@/plugins/status-bar-help'),
require('@/plugins/markdown-source-line'),
require('@/plugins/markdown-toc'),
require('@/plugins/markdown-code'),
require('@/plugins/markdown-link'),
require('@/plugins/code-line-number'),
require('@/plugins/transform-img-out-link'),
require('@/plugins/copy-text'),
require('@/plugins/table-cell-edit'),
require('@/plugins/switch-todo'),
require('@/plugins/run-code'),
require('@/plugins/plantuml'),
require('@/plugins/drawio'),
require('@/plugins/mind-map'),
require('@/plugins/mermaid'),
require('@/plugins/applet'),
require('@/plugins/echarts'),
]
localPlugins.forEach((plugin) => {
if (plugin.__esModule && plugin.default) {
register(plugin.default)
} else {
register(plugin)
}
})
}
export const triggerHook = async (type: HookType, ...args: any[]) => {
logger.debug('triggerHook', type, args)
for (const { fun, once } of (hooks[type] || [])) {
once && ctx.removeHook(type, fun)
if (await fun(...args)) {
return true
}
}
return false
}
export const getMarkdownItPlugins = () => markdownItPlugins
export const getStatusBarMenus = (location: MenuLocation, position: string) =>
Object.values(statusBarMenus).filter(x => x.location === location && x.position === position)
init()
+27 -20
View File
@@ -4,29 +4,26 @@ import env from './env'
const logger = getLogger('shortcut')
const isMacOS = env.isMacOS
const Ctrl = 'Ctrl'
const CtrlCmd = 'CtrlCmd'
const Alt = 'Alt'
const Shift = 'Shift'
const LeftClick = 0
export const Ctrl = 'Ctrl'
export const CtrlCmd = 'CtrlCmd'
export const Alt = 'Alt'
export const Shift = 'Shift'
export const LeftClick = 0
type XKey = typeof Ctrl | typeof CtrlCmd | typeof Alt | typeof Shift
const actions = {
'toggle-side': [Alt, 'e'],
'toggle-wrap': [Alt, 'w'],
'toggle-view': [Alt, 'v'],
'toggle-xterm': [Alt, 't'],
'toggle-readme': [Alt, 'h'],
const defaultActions = {
'insert-document': [CtrlCmd, Alt, 'i'],
'show-quick-open': [CtrlCmd, 'p'],
'transform-img-link-by-click': [CtrlCmd, Shift, LeftClick],
'transform-img-link': [CtrlCmd, Shift, 'l'],
'file-tabs-switch-left': [Ctrl, Alt, 'ArrowLeft'],
'file-tabs-switch-right': [Ctrl, Alt, 'ArrowRight'],
}
type ActionName = keyof typeof actions
const actions: {[key: string]: (string | number)[]} = {
...defaultActions
}
type ActionName = keyof typeof defaultActions
export const hasCtrlCmd = (e: KeyboardEvent | MouseEvent) => isMacOS ? e.metaKey : e.ctrlKey
@@ -43,11 +40,6 @@ export const getKeyLabel = (key: XKey | string | number) => {
}
}
export const getActionLabel = (name: ActionName) => {
const keys: any[] = actions[name]
return keys.map(getKeyLabel).join(' + ')
}
export const matchKeys = (e: KeyboardEvent | MouseEvent, keys: (string | number)[]) => {
for (const key of keys) {
switch (key) {
@@ -80,7 +72,22 @@ export const getCurrentAction = (e: KeyboardEvent | MouseEvent) => {
return (Object.keys(actions) as ActionName[]).find(action => matchKeys(e, actions[action]))
}
export const isAction = (e: KeyboardEvent | MouseEvent, name: ActionName) => {
export function isAction (e: KeyboardEvent | MouseEvent, name: ActionName): boolean
export function isAction (e: KeyboardEvent | MouseEvent, name: string): boolean
export function isAction (e: KeyboardEvent | MouseEvent, name: string) {
logger.debug('isAction', name, e)
return matchKeys(e, actions[name])
}
export function getActionLabel (name: ActionName): string
export function getActionLabel (name: string): string
export function getActionLabel (name: string): string {
const keys: any[] = actions[name]
return keys.map(getKeyLabel).join(' + ')
}
export function addAction (name: ActionName, keys: (string | number)[]): void
export function addAction (name: string, keys: (string | number)[]): void
export function addAction (name: string, keys: (string | number)[]): void {
actions[name] = keys
}
+2 -5
View File
@@ -1,15 +1,14 @@
import { useStore } from 'vuex'
import { useBus } from './bus'
import RunPlugin from '@/plugins/RunPlugin'
import { copyText } from './copy-text'
import env from './env'
import { hasCtrlCmd } from './shortcut'
import { triggerHook } from './plugin'
export default function startup () {
const bus = useBus()
const store = useStore()
RunPlugin.clearCache()
triggerHook('ON_STARTUP')
bus.on('editor-ready', () => {
const { currentFile } = store.state
@@ -20,8 +19,6 @@ export default function startup () {
}
})
bus.on('copy-text', copyText)
// 在 Electron 环境中开启缩放页面功能
if (env.isElectron) {
const webContents = env.require('electron').remote.getCurrentWebContents()
+1 -2
View File
@@ -19,8 +19,7 @@
<template v-slot:preview>
<Preview
ref="refPreview"
@sync-scroll="line => refEditor.revealLineInCenter(line)"
@switch-todo="(line, checked) => refEditor.switchTodo(line, checked)" />
@sync-scroll="line => refEditor.revealLineInCenter(line)" />
</template>
</Layout>
<XFilter />
+5 -5
View File
@@ -6441,11 +6441,6 @@ loader-utils@^2.0.0:
emojis-list "^3.0.0"
json5 "^2.1.2"
loadsh@^0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/loadsh/-/loadsh-0.0.4.tgz#5314babd12bb13315dde024a4ca70758c5489d2d"
integrity sha512-U+wLL8InpfRalWrr+0SuhWgGt10M4OyAk6G8xCYo2rwpiHtxZkWiFpjei0vO463ghW8LPCdhqQxXlMy2qicAEw==
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -6514,6 +6509,11 @@ lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-symbols@2.2.0, log-symbols@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "yank.note",
"version": "2.5.5",
"version": "2.6.0",
"description": "Yank Note 一款面向程序员的 Markdown 笔记应用",
"main": "dist/app.js",
"license": "Apache-2.0",
+5
View File
@@ -1799,6 +1799,11 @@ lodash@^4.17.10:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"