前端升级到 Vue 3.0

This commit is contained in:
purocean
2020-10-26 20:11:29 +08:00
parent cda855e3d9
commit 7f6ed517a0
79 changed files with 9141 additions and 7560 deletions
+1 -1
View File
@@ -6,6 +6,6 @@ dist
vs
out
bin
.yarnrc
# .yarnrc
b.bat
*.pfx
+1
View File
@@ -0,0 +1 @@
registry "https://registry.yarnpkg.com"
+1 -1
View File
@@ -1,3 +1,3 @@
> 1%
last 2 versions
not ie <= 8
not dead
+26 -10
View File
@@ -3,16 +3,32 @@ module.exports = {
env: {
node: true
},
'extends': [
'plugin:vue/essential',
'@vue/standard'
extends: [
'plugin:vue/vue3-essential',
'@vue/standard',
'@vue/typescript/recommended'
],
rules: {
'comma-dangle': 'off',
'no-console': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
parserOptions: {
parser: 'babel-eslint'
}
ecmaVersion: 2020
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/camelcase': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'comma-dangle': 'off',
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)'
],
env: {
mocha: true
}
}
]
}
+2
View File
@@ -2,6 +2,7 @@
node_modules
/dist
# local env files
.env.local
.env.*.local
@@ -10,6 +11,7 @@ node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
+1
View File
@@ -0,0 +1 @@
registry "https://registry.yarnpkg.com"
+6 -6
View File
@@ -1,4 +1,4 @@
# frontend2
# frontend-vue3
## Project setup
```
@@ -7,22 +7,22 @@ yarn install
### Compiles and hot-reloads for development
```
yarn run serve
yarn serve
```
### Compiles and minifies for production
```
yarn run build
yarn build
```
### Run your tests
### Run your unit tests
```
yarn run test
yarn test:unit
```
### Lints and fixes files
```
yarn run lint
yarn lint
```
### Customize configuration
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
presets: [
'@vue/app'
'@vue/cli-plugin-babel/preset'
]
}
+59 -34
View File
@@ -1,52 +1,77 @@
{
"name": "frontend",
"name": "yn-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^2.6.5",
"crypto-js": "^3.1.9-1",
"dayjs": "^1.8.12",
"echarts": "^4.2.1",
"github-markdown-css": "^3.0.1",
"highlight": "^0.2.4",
"highlight.js": "^9.15.6",
"core-js": "^3.6.5",
"crypto-js": "^4.0.0",
"dayjs": "^1.9.3",
"echarts": "^4.9.0",
"github-markdown-css": "^4.0.0",
"highlight.js": "^10.3.1",
"katex": "^0.6.0",
"markdown-it": "^8.4.2",
"markdown-it-attrs": "^2.3.2",
"loadsh": "^0.0.4",
"markdown-it": "^12.0.1",
"markdown-it-attrs": "^3.0.3",
"markdown-it-footnote": "^3.0.2",
"markdown-it-katex": "^2.0.3",
"markdown-it-multimd-table": "^3.1.3",
"markdown-it-multimd-table": "^4.0.3",
"markdown-it-task-lists": "^2.1.1",
"mermaid": "^8.2.6",
"mime-types": "^2.1.24",
"monaco-editor": "^0.17.1",
"mermaid": "^8.8.2",
"mime-types": "^2.1.27",
"mitt": "^2.1.0",
"monaco-editor": "^0.21.2",
"normalize.css": "^8.0.1",
"socket.io-client": "^2.2.0",
"sortablejs": "^1.10.1",
"transliteration": "^2.1.3",
"turndown": "^5.0.3",
"vue": "^2.6.10",
"vue-awesome": "^3.5.4",
"vue-bus": "^1.2.1",
"vue-router": "^3.0.3",
"vuex": "^3.0.1",
"xterm": "^3.12.2"
"socket.io-client": "^2.3.1",
"sortablejs": "^1.12.0",
"transliteration": "^2.1.11",
"turndown": "^7.0.0",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0",
"vuex": "^4.0.0-0",
"xterm": "^4.9.0",
"xterm-addon-fit": "^0.4.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.6.0",
"@vue/cli-plugin-eslint": "^3.6.0",
"@vue/cli-service": "^3.6.0",
"@vue/eslint-config-standard": "^4.0.0",
"babel-eslint": "^10.0.1",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"less": "^3.0.4",
"less-loader": "^4.1.0",
"vue-template-compiler": "^2.5.21"
"@types/chai": "^4.2.11",
"@types/crypto-js": "^4.0.1",
"@types/echarts": "^4.8.3",
"@types/lodash": "^4.14.162",
"@types/markdown-it": "^10.0.2",
"@types/mermaid": "^8.2.1",
"@types/mime-types": "^2.1.0",
"@types/mocha": "^5.2.4",
"@types/socket.io-client": "^1.4.34",
"@types/sortablejs": "^1.10.6",
"@types/turndown": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-unit-mocha": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-standard": "^5.1.2",
"@vue/eslint-config-typescript": "^5.0.2",
"@vue/test-utils": "^2.0.0-0",
"chai": "^4.1.2",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^7.0.0-0",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"typescript": "~3.9.3"
}
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {}
}
}
+2
View File
@@ -2,7 +2,9 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Yank Note</title>
<script>
// 强制 monaco-editor 使用 浏览器模式
+3 -15
View File
@@ -1,22 +1,10 @@
<template>
<div id="app">
<router-view/>
</div>
<router-view />
</template>
<script>
import 'normalize.css/normalize.css'
import Vue from 'vue'
import VueBus from 'vue-bus'
<style lang="scss">
@import url('~normalize.css/normalize.css');
Vue.use(VueBus)
export default {
name: 'App'
}
</script>
<style>
* {
outline: none;
}
+69 -49
View File
@@ -1,7 +1,7 @@
<template>
<div class="context-menu">
<div class="mask" v-if="isShow" @click="hide" @contextmenu.prevent.stop="hide"></div>
<ul class="menu" ref="menu" :style="{visibility: isShow ? 'visible' : 'hidden'}">
<ul class="menu" ref="refMenu" :style="{visibility: isShow ? 'visible' : 'hidden'}">
<template v-for="item in items">
<li v-if="item.type === 'separator'" :key="item.id" :class="item.type || 'normal'"></li>
<li v-else :key="item.id" @click="handleClick(item)" :class="item.type || 'normal'">{{item.label}}</li>
@@ -10,66 +10,86 @@
</div>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { Components } from '../types'
export default defineComponent({
name: 'context-menu',
props: {
},
data () {
return {
items: [], // [{ type: normal | 'separator', id: string, label: string, onClick: item => void }]
isShow: false,
setup () {
const refMenu = ref<HTMLUListElement | null>(null)
const items = ref<Components.ContextMenu.Item[]>([])
const isShow = ref(false)
let mouseX = 0
let mouseY = 0
function hideMenu () {
isShow.value = false
}
},
mounted () {
this.mouseX = 0
this.mouseY = 0
window.addEventListener('blur', this.hide)
window.addEventListener('mousemove', this.recordMousePosition)
},
beforeDestroy () {
window.removeEventListener('blur', this.hide)
window.removeEventListener('mousemove', this.recordMousePosition)
},
methods: {
handleClick (item) {
function hide () {
items.value = []
hideMenu()
}
function handleClick (item: Components.ContextMenu.NormalItem) {
item.onClick(item)
this.hide()
},
recordMousePosition (e) {
this.mouseX = e.clientX
this.mouseY = e.clientY
},
showMenu () {
hide()
}
function recordMousePosition (e: MouseEvent) {
mouseX = e.clientX
mouseY = e.clientY
}
function showMenu () {
if (!refMenu.value) {
return
}
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const menuWidth = this.$refs.menu.offsetWidth
const menuHeight = this.$refs.menu.offsetHeight
const menuWidth = refMenu.value.offsetWidth
const menuHeight = refMenu.value.offsetHeight
const x = this.mouseX + menuWidth > windowWidth ? this.mouseX - menuWidth : this.mouseX
const y = this.mouseY + menuHeight > windowHeight ? this.mouseY - menuHeight : this.mouseY
const x = mouseX + menuWidth > windowWidth ? mouseX - menuWidth : mouseX
const y = mouseY + menuHeight > windowHeight ? mouseY - menuHeight : mouseY
this.$refs.menu.style.left = x + 'px'
this.$refs.menu.style.top = y + 'px'
refMenu.value.style.left = x + 'px'
refMenu.value.style.top = y + 'px'
this.isShow = true
},
hideMenu () {
this.isShow = false
},
show (items) {
this.items = items
this.$nextTick(() => {
this.showMenu()
isShow.value = true
}
function show (menuItems: Components.ContextMenu.Item[]) {
items.value = menuItems
nextTick(() => {
showMenu()
})
},
hide () {
this.items = []
this.hideMenu()
}
onMounted(() => {
window.addEventListener('blur', hide)
window.addEventListener('mousemove', recordMousePosition)
})
onBeforeUnmount(() => {
window.removeEventListener('blur', hide)
window.removeEventListener('mousemove', recordMousePosition)
})
return {
refMenu,
isShow,
items,
hide,
show,
handleClick,
}
},
}
})
</script>
<style scoped>
+193 -161
View File
@@ -1,152 +1,125 @@
<template>
<div class="editor">
<MonacoEditor
ref="editor"
ref="refEditor"
class="editor"
@change="setCurrentValue"
@ready="editorReady"
@scroll-view="syncScrollView"
@paste-img="pasteImg"
@upload-file="uploadFile"
@change-document="val => $store.commit('app/setDocumentInfo', val)"
@change-document="val => $store.commit('setDocumentInfo', val)"
@xterm-run="val => $bus.emit('xterm-run', val)"
@save="saveFile"></MonacoEditor>
</div>
</template>
<script>
import _ from 'lodash'
import { mapState } from 'vuex'
<script lang="ts">
import lodash from 'lodash'
import dayjs from 'dayjs'
import File from '@/lib/file'
import Storage from '@/lib/Storage'
import { encodeMarkdownLink } from '@/lib/utils'
import MonacoEditor from './MonacoEditor'
import { defineComponent, nextTick, onBeforeMount, onMounted, ref, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import { encodeMarkdownLink } from '../useful/utils'
import { useBus } from '../useful/bus'
import { useModal } from '../useful/modal'
import { useToast } from '../useful/toast'
import File from '../useful/file'
import Storage from '../useful/storage'
import MonacoEditor from './MonacoEditor.vue'
const FILE_POSITION_KEY = 'filePosition'
export default {
export default defineComponent({
name: 'editor',
components: { MonacoEditor },
data () {
return {
timer: null
}
},
mounted () {
this.$bus.on('resize', this.$refs.editor.resize)
this.$bus.on('editor-insert-value', this.$refs.editor.insert)
this.$bus.on('editor-replace-value', this.$refs.editor.replaceValue)
this.$bus.on('editor-toggle-wrap', this.$refs.editor.toggleWrap)
this.$bus.on('file-new', this.createFile)
this.restartTimer()
setup (_, { emit }) {
const bus = useBus()
const store = useStore()
const modal = useModal()
const toast = useToast()
this.saveFileOpenPositionDebounce = _.debounce(line => {
this.saveFileOpenPosition(line)
}, 1000)
},
beforeDestroy () {
this.$bus.off('resize', this.$refs.editor.resize)
this.$bus.off('editor-insert-value', this.$refs.editor.insert)
this.$bus.off('editor-replace-value', this.$refs.editor.replaceValue)
this.$bus.off('editor-toggle-wrap', this.$refs.editor.toggleWrap)
this.$bus.off('file-new', this.createFile)
},
methods: {
setCurrentValue ({ uri, value }) {
if (this.toUri(this.currentFile) === uri) {
this.$store.commit('app/setCurrentContent', value)
}
},
saveFileOpenPosition (top) {
if (this.currentFile) {
const map = Storage.get(FILE_POSITION_KEY, {})
map[`${this.currentFile.repo}|${this.currentFile.path}`] = top
Storage.set(FILE_POSITION_KEY, map)
}
},
async createFile ({ file, content }) {
try {
// 加密文件内容
if (File.isEncryptedFile(file)) {
const password = await this.inputPassword('[创建] 请输入密码', file.name)
const encrypted = File.encrypt(content, password)
content = encrypted.content
// 储存这次解密文件密码的 hash,用于下次判断是否输入了相同密码
this.$store.commit('app/setPasswordHash', file, encrypted.passwordHash)
}
let timer: number | null = null
const refEditor = ref<any>(null)
const { currentFile, currentContent, previousContent, previousHash, passwordHash } = toRefs(store.state)
await File.write(file, content, 'new')
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)
this.$bus.emit('file-created', file)
this.$store.commit('app/setCurrentFile', file)
} catch (error) {
this.$toast.show('warning', error.message)
console.error(error)
}
},
async inputPassword (title, filename) {
const password = await this.$modal.input({ title, type: 'password', hint: filename })
async function inputPassword (title: string, filename: string) {
const password = await modal.input({ title, type: 'password', hint: filename })
if (!password) {
throw new Error('未输入密码')
}
return password
},
clearTimer () {
if (this.timer) {
window.clearTimeout(this.timer)
}
},
restartTimer () {
this.clearTimer()
}
if (!(this.currentFile && this.currentFile.repo && this.currentFile.path)) {
return
function saveFileOpenPosition (top: number) {
if (currentFile.value) {
const map = Storage.get(FILE_POSITION_KEY, {})
map[`${currentFile.value.repo}|${currentFile.value.path}`] = top
Storage.set(FILE_POSITION_KEY, map)
}
}
this.timer = window.setTimeout(() => {
if (!this.currentFile || File.isEncryptedFile(this.currentFile)) { // 加密文件不自动保存
return
const saveFileOpenPositionDebounce = lodash.debounce((line: number) => {
saveFileOpenPosition(line)
}, 1000)
function setCurrentValue ({ uri, value }: { uri: string; value: any}) {
if (toUri(currentFile.value) === uri) {
store.commit('setCurrentContent', value)
}
}
function syncScrollView ({ line, top }: { line: number; top: number }) {
emit('scroll-line', line)
saveFileOpenPositionDebounce(top)
}
async function createFile ({ file, content }: {file: any; content: any}) {
try {
// 加密文件内容
if (File.isEncryptedFile(file)) {
const password = await inputPassword('[创建] 请输入密码', file.name)
const encrypted = File.encrypt(content, password)
content = encrypted.content
// 储存这次解密文件密码的 hash,用于下次判断是否输入了相同密码
store.commit('setPasswordHash', { file, passwordHash: encrypted.passwordHash })
}
this.saveFile()
}, 2000)
},
editorReady () {
this.$bus.emit('editor-ready')
if (this.currentFile) {
this.changeFile(this.currentFile)
await File.write(file, content, 'new')
bus.emit('file-created', file)
store.commit('setCurrentFile', file)
} catch (error) {
toast.show('warning', error.message)
console.error(error)
}
},
revealLine (line) {
this.$refs.editor.revealLine(line)
},
revealLineInCenter (line) {
this.$refs.editor.revealLineInCenter(line)
},
switchTodo (line, checked) {
this.$refs.editor.switchTodo(line, checked)
},
syncScrollView ({ line, top }) {
this.$emit('scroll-line', line)
this.saveFileOpenPositionDebounce(top)
},
toUri (file) {
return File.toUri(file)
},
async saveFile (f = null) {
const file = f || this.currentFile
}
function clearTimer () {
if (timer) {
window.clearTimeout(timer)
timer = null
}
}
async function saveFile (f: any = null) {
const file = f || currentFile.value
if (!(file && file.repo && file.path)) {
return
}
if (this.previousContent === this.currentContent) {
if (previousContent.value === currentContent.value) {
return
}
if (!this.currentContent) {
if (!currentContent.value) {
return
}
@@ -155,69 +128,87 @@ export default {
}
try {
let content = this.currentContent
let content = currentContent.value
// 加密文件内容
if (File.isEncryptedFile(file)) {
const password = await this.inputPassword('[保存] 请输入密码', file.name)
const password = await inputPassword('[保存] 请输入密码', file.name)
const encrypted = File.encrypt(content, password)
const oldPasswdHash = this.passwordHash[`${file.repo}|${file.path}`]
const oldPasswdHash = passwordHash.value[`${file.repo}|${file.path}`]
if (oldPasswdHash !== encrypted.passwordHash) {
if (!(await this.$modal.confirm({ title: '提示', content: '密码和上一次输入的密码不一致,是否用新密码保存?' }))) {
if (!(await modal.confirm({ title: '提示', content: '密码和上一次输入的密码不一致,是否用新密码保存?' }))) {
return
}
}
content = encrypted.content
// 储存这次解密文件密码的 hash,用于下次判断是否输入了相同密码
this.$store.commit('app/setPasswordHash', file, encrypted.passwordHash)
store.commit('setPasswordHash', { file, passwordHash: encrypted.passwordHash })
}
const { hash } = await File.write(file, content, this.previousHash)
const { hash } = await File.write(file, content, previousHash.value)
this.$store.commit('app/setPreviousHash', hash)
this.$store.commit('app/setPreviousContent', this.currentContent)
this.$store.commit('app/setSavedAt', new Date())
store.commit('setPreviousHash', hash)
store.commit('setPreviousContent', currentContent.value)
store.commit('setSavedAt', new Date())
} catch (error) {
this.$toast.show('warning', error.message)
toast.show('warning', error.message)
console.error(error)
}
},
async pasteImg (file, asBase64) {
console.log(file, asBase64)
}
function restartTimer () {
clearTimer()
if (!(currentFile.value && currentFile.value.repo && currentFile.value.path)) {
return
}
timer = window.setTimeout(() => {
if (!currentFile.value || File.isEncryptedFile(currentFile.value)) { // 加密文件不自动保存
return
}
saveFile()
}, 2000)
}
async function pasteImg (file: any, asBase64: boolean) {
if (asBase64) {
const uri = await File.toBase64URL(file)
this.$refs.editor.insert(`![图片](${uri})\n`)
getEditor().insert(`![图片](${uri})\n`)
} else {
const { relativePath } = await File.upload(this.currentFile.repo, this.currentFile.path, file)
this.$bus.emit('file-uploaded', relativePath)
this.$refs.editor.insert(`![图片](${encodeMarkdownLink(relativePath)})\n`)
const { relativePath } = await File.upload(currentFile.value.repo, currentFile.value.path, file)
bus.emit('file-uploaded', relativePath)
getEditor().insert(`![图片](${encodeMarkdownLink(relativePath)})\n`)
}
},
async uploadFile (file) {
}
async function uploadFile (file: any) {
const filename = `${dayjs().format('YYYYMMDDHHmmss')}.${file.name}`
const { relativePath } = await File.upload(this.currentFile.repo, this.currentFile.path, file, filename)
this.$bus.emit('file-uploaded', relativePath)
this.$refs.editor.insert(`附件 [${dayjs().format('YYYY-MM-DD HH:mm')}][${file.name} (${(file.size / 1024).toFixed(2)}KiB)](${encodeMarkdownLink(relativePath)}){class=open target=_blank}\n`)
},
async changeFile (current, previous) {
this.clearTimer()
const { relativePath } = await File.upload(currentFile.value.repo, currentFile.value.path, file, filename)
bus.emit('file-uploaded', relativePath)
getEditor().insert(`附件 [${dayjs().format('YYYY-MM-DD HH:mm')}][${file.name} (${(file.size / 1024).toFixed(2)}KiB)](${encodeMarkdownLink(relativePath)}){class=open target=_blank}\n`)
}
async function changeFile (current: any, previous?: any) {
clearTimer()
if (previous && previous.repo && previous.path) {
await this.saveFile(previous)
await saveFile(previous)
}
if (!current) {
this.$store.commit('app/setPreviousContent', '\n')
this.$refs.editor.setModel(this.toUri(current), '\n')
this.$store.commit('app/setCurrentFile', null)
store.commit('setPreviousContent', '\n')
getEditor().setModel(toUri(current), '\n')
store.commit('setCurrentFile', null)
return
}
if (current.content) { // 系统文件
this.$store.commit('app/setPreviousContent', current.content)
this.$store.commit('app/setSavedAt', null)
this.$refs.editor.setModel(this.toUri(current), current.content)
store.commit('setPreviousContent', current.content)
store.commit('setSavedAt', null)
getEditor().setModel(toUri(current), current.content)
return
}
@@ -226,40 +217,81 @@ export default {
// 解密文件内容
if (File.isEncryptedFile(current)) {
const password = await this.inputPassword('[打开] 请输入密码', current.name)
const password = await inputPassword('[打开] 请输入密码', current.name)
const decrypted = File.decrypt(content, password)
content = decrypted.content
// 储存这次解密文件密码的 hash,用于下次判断是否输入了相同密码
this.$store.commit('app/setPasswordHash', { file: current, passwordHash: decrypted.passwordHash })
store.commit('setPasswordHash', { file: current, passwordHash: decrypted.passwordHash })
}
this.$store.commit('app/setPreviousContent', content)
this.$store.commit('app/setPreviousHash', hash)
this.$store.commit('app/setSavedAt', null)
this.$refs.editor.setModel(this.toUri(current), content)
store.commit('setPreviousContent', content)
store.commit('setPreviousHash', hash)
store.commit('setSavedAt', null)
getEditor().setModel(toUri(current), content)
} catch (error) {
this.$store.commit('app/setCurrentFile', null)
this.$toast.show('warning', error.message)
store.commit('setCurrentFile', null)
toast.show('warning', error.message)
console.error(error)
}
// 切换文件时候定位
const top = Storage.get(FILE_POSITION_KEY, {})[`${current.repo}|${current.path}`] || 1
this.$refs.editor.setScrollToTop(top)
// 切换文件时候保留定位
nextTick(() => {
const top = Storage.get(FILE_POSITION_KEY, {})[`${current.repo}|${current.path}`] || 1
getEditor().setScrollToTop(top)
})
}
},
computed: {
...mapState('app', ['currentFile', 'currentContent', 'previousContent', 'previousHash', 'passwordHash'])
},
watch: {
currentFile (current, previous) {
this.changeFile(current, previous)
},
currentContent () {
this.restartTimer()
function editorReady () {
bus.emit('editor-ready')
if (currentFile.value) {
changeFile(currentFile.value)
}
}
watch(currentFile, changeFile)
watch(currentContent, restartTimer)
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()
onMounted(() => {
bus.on('resize', resize)
bus.on('editor-insert-value', insert)
bus.on('editor-replace-value', replaceValue)
bus.on('editor-toggle-wrap', toggleWrap)
bus.on('file-new', createFile as any)
restartTimer()
})
onBeforeMount(() => {
bus.off('resize', resize)
bus.off('editor-insert-value', insert)
bus.off('editor-replace-value', replaceValue)
bus.off('editor-toggle-wrap', toggleWrap)
bus.off('file-new', createFile as any)
})
return {
refEditor,
revealLine,
revealLineInCenter,
switchTodo,
setCurrentValue,
syncScrollView,
pasteImg,
uploadFile,
editorReady,
saveFile,
}
}
}
})
</script>
<style scoped>
+86 -75
View File
@@ -2,108 +2,110 @@
<Tabs :list="tabs" :value="current" @remove="removeTabs" @switch="switchTab" @change-list="setTabs"></Tabs>
</template>
<script>
import { mapState } from 'vuex'
import Tabs from './Tabs'
import File from '@/lib/file'
<script lang="ts">
import { useStore } from 'vuex'
import Tabs from './Tabs.vue'
import File from '../useful/file'
import { defineComponent, onBeforeMount, onBeforeUnmount, ref, toRefs, watch } from 'vue'
import { Components } from '../types'
const blankUri = File.toUri(null)
export default {
export default defineComponent({
name: 'file-tabs',
components: { Tabs },
data () {
return {
list: [],
current: blankUri
}
},
created () {
window.addEventListener('keydown', this.keydownHandler, true)
},
beforeDestroy () {
window.removeEventListener('keydown', this.keydownHandler)
},
methods: {
keydownHandler (e) {
const findTab = offset => {
const tabs = [...this.tabs]
setup () {
const store = useStore()
if (tabs.length < 1) {
const { currentFile, tabs } = toRefs(store.state)
const list = ref<Components.FileTabs.Item[]>([])
const current = ref(blankUri)
function setTabs (list: Components.FileTabs.Item[]) {
store.commit('setTabs', list)
}
function switchFile (file: any) {
store.commit('setCurrentFile', file)
}
function switchTab (item: Components.FileTabs.Item) {
switchFile(item.payload.file)
}
function removeTabs (items: Components.FileTabs.Item[]) {
const keys = items.map(x => x.key)
setTabs(tabs.value.filter((x: any) => keys.indexOf(x.key) === -1))
}
function addTab (item: Components.FileTabs.Item) {
const tab = tabs.value.find((x: any) => item.key === x.key)
// 没有打开此 Tab,新建一个
if (!tab) {
setTabs(tabs.value.concat([item]))
}
current.value = item.key
}
function keydownHandler (e: KeyboardEvent) {
const findTab = (offset: number) => {
const list = [...tabs.value]
if (list.length < 1) {
return null
}
const currentIndex = tabs.findIndex(x => x.key === this.current)
const currentIndex = list.findIndex(x => x.key === current.value)
let index = currentIndex + offset
if (index < 0) {
index = tabs.length - 1
index = list.length - 1
}
if (index >= tabs.length) {
if (index >= list.length) {
index = 0
}
return tabs[index]
return list[index]
}
// 快捷键切换最近文档
if (e.altKey && e.ctrlKey) {
if (e.key === 'ArrowLeft') {
const prev = findTab(-1)
prev && this.switchTab(prev)
prev && switchTab(prev)
} else if (e.key === 'ArrowRight') {
const next = findTab(1)
next && this.switchTab(next)
next && switchTab(next)
}
}
},
setTabs (list) {
this.$store.commit('app/setTabs', list)
},
switchTab (item) {
this.switchFile(item.payload.file)
},
removeTabs (items) {
const keys = items.map(x => x.key)
const tabs = this.tabs.filter(x => keys.indexOf(x.key) === -1)
this.setTabs(tabs)
},
addTab (item) {
const tab = this.tabs.find(x => item.key === x.key)
// 没有打开此 Tab,新建一个
if (!tab) {
this.setTabs(this.tabs.concat([item]))
}
this.current = item.key
},
switchFile (file) {
this.$store.commit('app/setCurrentFile', file)
}
},
computed: {
...mapState('app', ['currentFile', 'tabs'])
},
watch: {
currentFile: {
immediate: true,
handler (file) {
const uri = File.toUri(file)
const item = {
key: uri,
label: file ? file.name : '空白页',
description: file ? file.path : '空白页',
payload: { file },
}
this.addTab(item)
onBeforeMount(() => {
window.addEventListener('keydown', keydownHandler, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keydownHandler)
})
watch(currentFile, file => {
const uri = File.toUri(file)
const item = {
key: uri,
label: file ? file.name : '空白页',
description: file ? file.path : '空白页',
payload: { file },
}
},
tabs (list) {
addTab(item)
}, { immediate: true })
watch(tabs, list => {
if (list.length < 1) {
this.addTab({
addTab({
key: blankUri,
label: '空白页',
description: '空白页',
@@ -111,12 +113,21 @@ export default {
})
}
const tab = list.find(x => x.key === this.current)
const tab = list.find((x: any) => x.key === current.value)
if (!tab) {
const currentFile = list.length > 0 ? list[list.length - 1].payload.file : null
this.switchFile(currentFile)
switchFile(currentFile)
}
})
return {
list,
current,
tabs,
removeTabs,
switchTab,
setTabs,
}
}
}
},
})
</script>
+48 -36
View File
@@ -1,55 +1,67 @@
<template>
<XMask :show="!!show" @close="show = false">
<QuickOpen @choose-file="show" @close="show = false" :with-marked="show && show.withMarked"></QuickOpen>
<XMask :show="show" @close="callback = null">
<QuickOpen @choose-file="callback" @close="callback = false" :with-marked="withMarked"></QuickOpen>
</XMask>
</template>
<script>
import { mapState } from 'vuex'
import XMask from './Mask'
import QuickOpen from './QuickOpen'
import { encodeMarkdownLink } from '@/lib/utils'
<script lang="ts">
import { useStore } from 'vuex'
import { computed, defineComponent, onMounted, onUnmounted, ref, toRef } from 'vue'
import { encodeMarkdownLink } from '../useful/utils'
import { useBus } from '../useful/bus'
import XMask from './Mask.vue'
import QuickOpen from './QuickOpen.vue'
export default {
export default defineComponent({
name: 'x-filter',
components: { QuickOpen, XMask },
data () {
return {
show: false,
}
},
mounted () {
window.addEventListener('keydown', this.keydownHandler, true)
},
beforeDestroy () {
window.removeEventListener('keydown', this.keydownHandler)
},
methods: {
keydownHandler (e) {
setup () {
const store = useStore()
const bus = useBus()
const currentFile = toRef(store.state, 'currentFile')
const callback = ref<Function | null>(null)
const withMarked = ref(true)
function keydownHandler (e: KeyboardEvent) {
if (e.key === 'i' && e.ctrlKey && e.altKey) {
this.show = f => {
if (this.currentFile) {
const relativePath = f.path.replace(this.currentFile.path.substr(0, this.currentFile.path.lastIndexOf('/')), '.')
this.$bus.emit('editor-insert-value', `[${f.name.replace(/\.[^.]+$/, '')}](${encodeMarkdownLink(relativePath)})`)
callback.value = (f: any) => {
const file = currentFile.value
if (file) {
const relativePath = f.path.replace(file.path.substr(0, file.path.lastIndexOf('/')), '.')
bus.emit('editor-insert-value', `[${f.name.replace(/\.[^.]+$/, '')}](${encodeMarkdownLink(relativePath)})`)
}
this.show = false
callback.value = null
}
this.show.withMarked = false
withMarked.value = false
e.preventDefault()
e.stopPropagation()
} else if (e.key === 'p' && e.ctrlKey) {
this.show = f => {
this.$store.commit('app/setCurrentFile', f)
this.show = false
callback.value = (f: any) => {
store.commit('setCurrentFile', f)
callback.value = null
}
this.show.withMarked = true
withMarked.value = true
e.preventDefault()
e.stopPropagation()
}
},
}
onMounted(() => {
window.addEventListener('keydown', keydownHandler, true)
})
onUnmounted(() => {
window.removeEventListener('keydown', keydownHandler)
})
const show = computed(() => !!callback.value)
return {
show,
callback,
withMarked,
}
},
computed: {
...mapState('app', ['currentFile'])
}
}
})
</script>
+97 -65
View File
@@ -30,69 +30,56 @@
</div>
</template>
<script>
import { mapState } from 'vuex'
<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, nextTick, ref, toRefs } from 'vue'
import { useStore } from 'vuex'
import { useBus } from '../useful/bus'
import { $args } from '../useful/global-args'
let resizeOrigin = null
let resizeOrigin: any = null
export default {
export default defineComponent({
name: 'layout',
mounted () {
window.addEventListener('resize', this.emitResize)
window.document.addEventListener('mousemove', this.resizeFrame)
this.$bus.on('toggle-view', this.toggleView)
this.$bus.on('toggle-side', this.toggleSide)
this.$bus.on('toggle-xterm', this.toggleXterm)
},
beforeDestroy () {
window.removeEventListener('resize', this.emitResize)
window.document.removeEventListener('mousemove', this.resizeFrame)
this.$bus.off('toggle-side', this.toggleSide)
this.$bus.off('toggle-view', this.toggleView)
this.$bus.off('toggle-xterm', this.toggleXterm)
},
methods: {
emitResize () {
this.$bus.emit('resize')
},
toggleSide () {
this.$store.commit('app/setShowSide', !this.showSide)
this.$nextTick(() => this.$bus.emit('resize'), 500)
},
toggleView () {
this.$store.commit('app/setShowView', !this.showView)
this.$nextTick(() => this.$bus.emit('resize'), 500)
},
toggleXterm (val) {
const showXterm = typeof val === 'boolean' ? val : !this.showXterm
setup () {
const bus = useBus()
const store = useStore()
this.$store.commit('app/setShowXterm', showXterm)
const { showView, showXterm, showSide } = toRefs(store.state)
this.$nextTick(() => {
this.$nextTick(() => this.$bus.emit('resize'), 500)
const aside = ref(null)
const editor = ref(null)
const terminal = ref(null)
const refs: any = { aside, editor, terminal }
if (showXterm) {
this.$bus.emit('xterm-init')
function toggleSide () {
store.commit('setShowSide', !showSide.value)
nextTick(() => bus.emit('resize'))
}
function toggleView () {
store.commit('setShowView', !showView.value)
nextTick(() => bus.emit('resize'))
}
function toggleXterm (val?: boolean) {
const show = typeof val === 'boolean' ? val : !showXterm.value
store.commit('setShowXterm', show)
nextTick(() => {
nextTick(() => bus.emit('resize'))
if (showXterm.value) {
bus.emit('xterm-init')
}
})
},
initResize (type, ref, min, max, e) {
if (!resizeOrigin && type) {
resizeOrigin = {
min,
max,
type,
ref,
mouseX: e.clientX,
mouseY: e.clientY,
targetWidth: this.$refs[ref].clientWidth,
targetHeight: this.$refs[ref].clientHeight,
targetLeft: this.$refs[ref].clientLeft,
targetTop: this.$refs[ref].clientTop,
}
}
},
resizeFrame (e) {
}
function emitResize () {
bus.emit('resize')
}
function resizeFrame (e: MouseEvent) {
if (e.buttons !== 1) {
resizeOrigin = null
return
@@ -102,25 +89,70 @@ export default {
return
}
const ref = refs[resizeOrigin.ref].value
if (resizeOrigin.type === 'right') {
const offsetX = e.clientX - resizeOrigin.mouseX
const width = (resizeOrigin.targetWidth + offsetX)
this.$refs[resizeOrigin.ref].style.width = Math.min(resizeOrigin.max, Math.max(resizeOrigin.min, width)) + 'px'
ref.style.width = Math.min(resizeOrigin.max, Math.max(resizeOrigin.min, width)) + 'px'
} else if (resizeOrigin.type === 'top') {
const offsetY = -(e.clientY - resizeOrigin.mouseY)
const height = (resizeOrigin.targetHeight + offsetY)
this.$refs[resizeOrigin.ref].style.height = Math.min(resizeOrigin.max, Math.max(resizeOrigin.min, height)) + 'px'
ref.style.height = Math.min(resizeOrigin.max, Math.max(resizeOrigin.min, height)) + 'px'
}
this.$bus.emit('resize')
bus.emit('resize')
}
function initResize (type: any, ref: any, min: any, max: any, e: any) {
const refEl = refs[ref].value
if (!resizeOrigin && type) {
resizeOrigin = {
min,
max,
type,
ref,
mouseX: e.clientX,
mouseY: e.clientY,
targetWidth: refEl.clientWidth,
targetHeight: refEl.clientHeight,
targetLeft: refEl.clientLeft,
targetTop: refEl.clientTop,
}
}
}
onMounted(() => {
bus.on('toggle-view', toggleView)
bus.on('toggle-side', toggleSide)
bus.on('toggle-xterm', toggleXterm)
window.addEventListener('resize', emitResize)
window.document.addEventListener('mousemove', resizeFrame)
})
onBeforeUnmount(() => {
bus.off('toggle-side', toggleSide)
bus.off('toggle-view', toggleView)
bus.off('toggle-xterm', toggleXterm)
window.removeEventListener('resize', emitResize)
window.document.removeEventListener('mousemove', resizeFrame)
})
const showFooter = $args().get('show-status-bar') !== 'false'
return {
initResize,
showSide,
showXterm,
showFooter,
showView,
aside,
editor,
terminal,
}
},
computed: {
...mapState('app', ['showView', 'showXterm', 'showSide']),
showFooter () {
return window.$args().get('show-status-bar') !== 'false'
}
}
}
})
</script>
<style scoped>
+20 -16
View File
@@ -6,8 +6,10 @@
</transition>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted } from 'vue'
export default defineComponent({
name: 'x-mask',
props: {
show: Boolean,
@@ -20,22 +22,24 @@ export default {
default: true,
},
},
mounted () {
window.addEventListener('keydown', this.keydownHandler, true)
},
beforeDestroy () {
window.removeEventListener('keydown', this.keydownHandler)
},
methods: {
keydownHandler (e) {
if (e.key === 'Escape' && this.show) {
this.escClooseable && this.$emit('close')
} else if (e.key === 'Enter' && this.show) {
this.$emit('enter')
setup (props, { emit }) {
function keydownHandler (e: KeyboardEvent) {
if (e.key === 'Escape' && props.show) {
props.escClooseable && emit('close')
} else if (e.key === 'Enter' && props.show) {
emit('key-enter')
}
},
}
onMounted(() => {
window.addEventListener('keydown', keydownHandler, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keydownHandler)
})
},
}
})
</script>
<style scoped>
+78 -54
View File
@@ -1,9 +1,9 @@
<template>
<XMask :show="show" @close="cancel" @enter="ok" :mask-closeable="false" esc-closeable>
<XMask :show="show" @close="cancel" @key-enter="ok" :mask-closeable="false" esc-closeable>
<div class="wrapper" @click.stop>
<h4>{{title}}</h4>
<p v-if="content">{{content}}</p>
<input v-if="type === 'input'" ref="input" :type="inputType" :placeholder="inputHint" v-model="inputValue">
<input v-if="type === 'input'" ref="refInput" :type="inputType" :placeholder="inputHint" v-model="inputValue">
<div class="action">
<button @click="cancel">取消</button>
<button class="primary" @click="ok">确定</button>
@@ -12,77 +12,101 @@
</XMask>
</template>
<script>
import XMask from './Mask'
<script lang="ts">
import { defineComponent, nextTick, ref } from 'vue'
import XMask from './Mask.vue'
import { Components } from '../types'
export default {
type ModalType = '' | 'confirm' | 'input'
export default defineComponent({
name: 'modal-input',
components: { XMask },
data () {
return {
type: '',
show: false,
title: '',
content: '',
inputType: '',
inputValue: '',
inputHint: '',
}
},
methods: {
handle (val) {
this.show = false
this.inputValue = ''
setup () {
const refInput = ref<HTMLInputElement | null>(null)
const type = ref<ModalType>('')
const show = ref(false)
const title = ref('')
const content = ref('')
const inputType = ref('')
const inputValue = ref('')
const inputHint = ref('')
let resolveFun: Function | null = null
function handle (val: any) {
show.value = false
inputValue.value = ''
try {
this.resolve && this.resolve(val)
} catch (error) {
throw error
resolveFun && resolveFun(val)
} finally {
this.resolve = null
resolveFun = null
}
},
cancel () {
this.handle(this.type === 'input' ? null : false)
},
ok () {
this.handle(this.type === 'input' ? this.inputValue : true)
},
confirm ({ title, content }) {
this.type = 'confirm'
this.title = title || '提示'
this.content = content || ''
this.show = true
}
function cancel () {
handle(type.value === 'input' ? null : false)
}
function ok () {
handle(type.value === 'input' ? inputValue.value : true)
}
function confirm (params: Components.Modal.ConfirmModalParams): Promise<boolean> {
type.value = 'confirm'
title.value = params.title || '提示'
content.value = params.content || ''
show.value = true
return new Promise(resolve => {
this.resolve = resolve
resolveFun = resolve
})
},
input ({ type, title, hint, value, content, select = true }) {
this.type = 'input'
this.title = title || '请输入'
this.content = content || ''
this.inputType = type || 'text'
this.inputValue = value || ''
this.inputHint = hint || ''
}
this.show = true
this.$nextTick(() => {
this.$refs.input.focus()
function input (params: Components.Modal.InputModalParams): Promise<string> {
type.value = 'input'
title.value = params.title || '请输入'
content.value = params.content || ''
inputType.value = params.type || 'text'
inputValue.value = params.value || ''
inputHint.value = params.hint || ''
if (select) {
this.$refs.input.select()
if (Array.isArray(select)) {
this.$refs.input.setSelectionRange(...select)
show.value = true
nextTick(() => {
refInput.value!!.focus()
if (params.select) {
refInput.value!!.select()
if (Array.isArray(params.select)) {
refInput.value!!.setSelectionRange(...params.select)
}
}
})
return new Promise(resolve => {
this.resolve = resolve
resolveFun = resolve
})
}
return {
refInput,
ok,
cancel,
confirm,
input,
type,
show,
title,
content,
inputType,
inputValue,
inputHint,
}
},
}
})
</script>
<style scoped>
+266 -227
View File
@@ -1,51 +1,42 @@
<template>
<div id="editor"></div>
<div id="editor" ref="refEditor"></div>
</template>
<script>
<script lang="ts">
import dayjs from 'dayjs'
import TurndownService from 'turndown'
import * as monaco from 'monaco-editor'
import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue'
import { $args } from '../useful/global-args'
import env from '../useful/env'
const models = {}
const models: {[key: string]: monaco.editor.ITextModel} = {}
const keys = {}
const keys: {[key: string]: boolean} = {}
export default {
export default defineComponent({
name: 'monaco-editor',
mounted () {
this.editor = null
if (!window.require) {
const loaderScript = document.createElement('script')
loaderScript.type = 'text/javascript'
loaderScript.src = 'vs/loader.js'
loaderScript.addEventListener('load', this.onGotAmdLoader)
document.body.appendChild(loaderScript)
} else {
this.onGotAmdLoader()
}
setup (_, { emit }) {
let editor: monaco.editor.IStandaloneCodeEditor | null = null
const refEditor = ref<HTMLElement | null>(null)
window.addEventListener('keydown', this.recordKeys, true)
window.addEventListener('keyup', this.recordKeys, true)
},
beforeDestroy () {
window.removeEventListener('paste', this.paste)
window.removeEventListener('keydown', this.recordKeys)
window.removeEventListener('keyup', this.recordKeys)
},
methods: {
getModel (uri, value) {
const getMonaco = () => (window as any).monaco
const getEditor = () => editor!!
const resize = () => editor && editor.layout()
function getModel (uri: string, value: string) {
let model = models[uri]
if (!model) {
model = window.monaco.editor.createModel(value, undefined, window.monaco.Uri.parse(uri))
model.onDidChangeContent(e => {
model = getMonaco().editor.createModel(value, undefined, getMonaco().Uri.parse(uri))
model.onDidChangeContent(() => {
let value = model.getValue()
const eol = model.getEOL()
if (!value.endsWith(eol)) {
value += eol
model.setValue(value)
}
this.$emit('change', { uri, value })
emit('change', { uri, value })
})
}
@@ -56,34 +47,213 @@ export default {
models[uri] = model
return model
},
recordKeys (e) {
}
function setModel (uri: string, value: string) {
const model = getModel(uri, value || '')
getEditor().setModel(model)
}
function addAttachment () {
const input = window.document.createElement('input')
input.type = 'file'
input.onchange = () => {
for (let i = 0; i < input.files!!.length; i++) {
emit('upload-file', input.files!![i])
}
}
input.click()
}
function insert (text: string) {
const selection = getEditor().getSelection()!!
getEditor().executeEdits('', [
{
range: new (getMonaco().Range)(selection.endLineNumber, selection.endColumn, selection.endLineNumber, selection.endColumn),
text,
forceMoveMarkers: true
}
])
}
function keyBind () {
const KM = getMonaco().KeyMod
const KC = getMonaco().KeyCode
getEditor().addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_F, addAttachment)
getEditor().addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_D, () => {
insert(dayjs().format('YYYY-MM-DD'))
})
getEditor().addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_T, () => {
insert(dayjs().format('HH:mm:ss'))
})
getEditor().addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_R, () => {
emit('xterm-run', getEditor().getModel()!!.getValueInRange(getEditor().getSelection()!!))
})
getEditor().addCommand(KM.CtrlCmd | KC.Enter, () => {
insert(getEditor().getModel()!!.getEOL())
})
getEditor().addCommand(KM.Shift | KC.Enter, () => {
// getOneIndent 接口被移除了 https://github.com/microsoft/monaco-editor/issues/1565
const getOneIndent = (editor: monaco.editor.IStandaloneCodeEditor) => {
const options = getEditor().getModel()!!.getOptions()
return options.insertSpaces ? ' '.repeat(options.tabSize) : '\t'
}
insert(getOneIndent(getEditor()))
})
getEditor().addCommand(KM.CtrlCmd | KM.Shift | KC.UpArrow, () => {
getEditor().getAction('editor.action.moveLinesUpAction').run()
})
getEditor().addCommand(KM.CtrlCmd | KM.Shift | KC.DownArrow, () => {
getEditor().getAction('editor.action.moveLinesDownAction').run()
})
getEditor().addCommand(KM.CtrlCmd | KM.Shift | KC.KEY_D, () => {
getEditor().getAction('editor.action.copyLinesDownAction').run()
})
getEditor().addCommand(KM.CtrlCmd | KC.KEY_S, () => {
emit('save')
})
getEditor().addCommand(KM.CtrlCmd | KC.KEY_J, () => {
getEditor().getAction('editor.action.joinLines').run()
})
getEditor().addCommand(KM.chord(KM.CtrlCmd | KC.KEY_K, KM.CtrlCmd | KC.KEY_U), () => {
getEditor().getAction('editor.action.transformToUppercase').run()
})
getEditor().addCommand(KM.chord(KM.CtrlCmd | KC.KEY_K, KM.CtrlCmd | KC.KEY_L), () => {
getEditor().getAction('editor.action.transformToLowercase').run()
})
getMonaco().languages.setLanguageConfiguration('markdown', {
onEnterRules: [
{ beforeText: /^\s*> .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '> ' } },
{ beforeText: /^\s*\+ \[ \] .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '+ [ ] ' } },
{ beforeText: /^\s*- \[ \] .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '- [ ] ' } },
{ beforeText: /^\s*\* \[ \] .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '* [ ] ' } },
{ beforeText: /^\s*\+ \[x\] .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '+ [ ] ' } },
{ beforeText: /^\s*- \[x\] .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '- [ ] ' } },
{ beforeText: /^\s*\* \[x\] .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '* [ ] ' } },
{ beforeText: /^\s*\+ .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '+ ' } },
{ beforeText: /^\s*- .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '- ' } },
{ beforeText: /^\s*\* .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '* ' } },
{ beforeText: /^\s*1. .*$/, action: { indentAction: getMonaco().languages.IndentAction.None, appendText: '1. ' } }
]
})
}
function paste (e: ClipboardEvent) {
if (getEditor().hasTextFocus()) {
const items = e.clipboardData!!.items
if (keys.m || keys.M) { // 粘贴 HTML 转为 markdown
for (let i = 0; i < items.length; i++) {
if (items[i].type.match(/^text\/html$/i)) {
items[i].getAsString(str => {
const md = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '+' }).turndown(str)
insert(md)
})
}
}
e.preventDefault()
e.stopPropagation()
} else {
for (let i = 0; i < items.length; i++) {
const fileType = items[i].type
if (fileType.match(/^image\/(png|jpg|jpeg|gif)$/i)) {
const asBase64 = keys.b || keys.B // Ctrl + v 粘贴的同时 按下了 B 键,就粘贴 base64 图像
emit('paste-img', items[i].getAsFile(), asBase64)
}
}
}
}
}
function replaceLine (line: number, text: string) {
const length = getEditor().getModel()!!.getLineLength(line)
getEditor().executeEdits('', [
{ range: new (getMonaco().Range)(line, 1, line, length + 1), text }
])
}
function revealLineInCenter (line: number) {
getEditor().revealLineInCenter(line)
}
function revealLine (line: number) {
getEditor().revealLine(line)
}
function setScrollToTop (top: number) {
getEditor().setScrollTop(top)
}
function switchTodo (line: number, checked: boolean) {
if (checked) {
const value = getEditor().getModel()!!.getLineContent(line).replace('[ ]', `[x] ~~${dayjs().format('YYYY-MM-DD HH:mm')}~~`)
replaceLine(line, value)
} else {
const value = getEditor().getModel()!!.getLineContent(line).replace(/(\[x\] ~~[\d-: ]+~~|\[x\])/, '[ ]')
replaceLine(line, value)
}
}
function getValue () {
return getEditor().getModel()!!.getValue(getMonaco().editor.DefaultEndOfLine.LF)
}
function replaceValue (oldValue: string, newValue: string) {
getEditor().getModel()!!.setValue(getValue().replace(oldValue, newValue))
}
function getDucumentInfo () {
const selection = getEditor().getSelection()!!
return {
line: selection.positionLineNumber,
column: selection.positionColumn,
lineCount: getEditor().getModel()!!.getLineCount(),
textLength: getValue().length,
selectedLength: getEditor().getModel()!!.getValueInRange(selection).length
}
}
function toggleWrap () {
const isWrapping = getEditor().getOption(monaco.editor.EditorOption.wrappingInfo).isViewportWrapping
getEditor().updateOptions({ wordWrap: (isWrapping ? 'off' : 'on') })
}
function recordKeys (e: KeyboardEvent) {
if (e.type === 'keydown') {
keys[e.key] = true
} else {
keys[e.key] = false
}
},
resize () {
if (this.editor) {
this.editor.layout()
}
},
onGotAmdLoader () {
window.require(['vs/editor/editor.main'], () => {
this.initMonaco()
})
},
initMonaco () {
window.supportedExtensions = window.monaco.languages.getLanguages().flatMap(x => x.extensions)
}
const model = this.getModel('yank-note://system/blank.md', '')
function initMonaco () {
// window.supportedExtensions = getMonaco().languages.getLanguages().flatMap(x => x.extensions)
this.editor = window.monaco.editor.create(window.document.getElementById('editor'), {
const model = getModel('yank-note://system/blank.md', '')
editor = (window as any).monaco.editor.create(refEditor.value, {
value: '',
theme: 'vs-dark',
fontSize: 18,
wordWrap: false,
links: !env.isElectron,
// wordWrapColumn: 40,
// Set this to false to not auto word wrap minified files
wordWrapMinified: true,
@@ -97,197 +267,66 @@ export default {
verticalScrollbarSize: 0
},
model,
readOnly: window.$args().get('readonly') === 'true',
readOnly: $args().get('readonly') === 'true',
})
this.editor.onDidChangeCursorSelection(e => {
this.$emit('change-document', this.getDucumentInfo())
getEditor().onDidChangeCursorSelection(() => {
emit('change-document', getDucumentInfo())
})
this.editor.onDidScrollChange(e => {
const line = this.editor.getVisibleRanges()[0].startLineNumber
const top = this.editor.getScrollTop()
this.$emit('scroll-view', { line, top })
getEditor().onDidScrollChange(() => {
const line = getEditor().getVisibleRanges()[0].startLineNumber
const top = getEditor().getScrollTop()
emit('scroll-view', { line, top })
})
this.keyBind()
window.addEventListener('paste', this.paste, true)
keyBind()
window.addEventListener('paste', paste as any, true)
setTimeout(() => {
this.$emit('ready')
emit('ready')
}, 500)
},
keyBind () {
const KM = window.monaco.KeyMod
const KC = window.monaco.KeyCode
this.editor.addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_F, this.addAttachment)
this.editor.addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_D, () => {
this.insert(dayjs().format('YYYY-MM-DD'))
})
this.editor.addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_T, () => {
this.insert(dayjs().format('HH:mm:ss'))
})
this.editor.addCommand(KM.CtrlCmd | KM.Alt | KC.KEY_R, () => {
this.$emit('xterm-run', this.editor.getModel().getValueInRange(this.editor.getSelection()))
})
this.editor.addCommand(KM.CtrlCmd | KC.Enter, () => {
this.insert(this.editor.getModel().getEOL())
})
this.editor.addCommand(KM.Shift | KC.Enter, () => {
// getOneIndent 接口被移除了 https://github.com/microsoft/monaco-editor/issues/1565
const getOneIndent = editor => {
const options = editor.getModel().getOptions()
return options.insertSpaces ? ' '.repeat(options.tabSize) : '\t'
}
this.insert(getOneIndent(this.editor))
})
this.editor.addCommand(KM.CtrlCmd | KM.Shift | KC.UpArrow, () => {
this.editor.getAction('editor.action.moveLinesUpAction').run()
})
this.editor.addCommand(KM.CtrlCmd | KM.Shift | KC.DownArrow, () => {
this.editor.getAction('editor.action.moveLinesDownAction').run()
})
this.editor.addCommand(KM.CtrlCmd | KM.Shift | KC.KEY_D, () => {
this.editor.getAction('editor.action.copyLinesDownAction').run()
})
this.editor.addCommand(KM.CtrlCmd | KC.KEY_S, () => {
this.$emit('save')
})
this.editor.addCommand(KM.CtrlCmd | KC.KEY_J, () => {
this.editor.getAction('editor.action.joinLines').run()
})
this.editor.addCommand(KM.chord(KM.CtrlCmd | KC.KEY_K, KM.CtrlCmd | KC.KEY_U), () => {
this.editor.getAction('editor.action.transformToUppercase').run()
})
this.editor.addCommand(KM.chord(KM.CtrlCmd | KC.KEY_K, KM.CtrlCmd | KC.KEY_L), () => {
this.editor.getAction('editor.action.transformToLowercase').run()
})
window.monaco.languages.setLanguageConfiguration('markdown', {
onEnterRules: [
{ beforeText: /^\s*> .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '> ' } },
{ beforeText: /^\s*\+ \[ \] .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '+ [ ] ' } },
{ beforeText: /^\s*- \[ \] .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '- [ ] ' } },
{ beforeText: /^\s*\* \[ \] .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '* [ ] ' } },
{ beforeText: /^\s*\+ \[x\] .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '+ [ ] ' } },
{ beforeText: /^\s*- \[x\] .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '- [ ] ' } },
{ beforeText: /^\s*\* \[x\] .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '* [ ] ' } },
{ beforeText: /^\s*\+ .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '+ ' } },
{ beforeText: /^\s*- .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '- ' } },
{ beforeText: /^\s*\* .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '* ' } },
{ beforeText: /^\s*1. .*$/, action: { indentAction: window.monaco.languages.IndentAction.None, appendText: '1. ' } }
]
})
},
paste (e) {
if (this.editor.hasTextFocus()) {
const items = e.clipboardData.items
if (keys['m'] || keys['M']) { // 粘贴 HTML 转为 markdown
for (let i = 0; i < items.length; i++) {
if (items[i].type.match(/^text\/html$/i)) {
items[i].getAsString(str => {
const md = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '+' }).turndown(str)
this.insert(md)
})
}
}
e.preventDefault()
e.stopPropagation()
} else {
for (let i = 0; i < items.length; i++) {
const fileType = items[i].type
if (fileType.match(/^image\/(png|jpg|jpeg|gif)$/i)) {
const asBase64 = keys['b'] || keys['B'] // Ctrl + v 粘贴的同时 按下了 B 键,就粘贴 base64 图像
this.$emit('paste-img', items[i].getAsFile(), asBase64)
}
}
}
}
},
addAttachment () {
const input = window.document.createElement('input')
input.type = 'file'
input.onchange = () => {
for (let i = 0; i < input.files.length; i++) {
this.$emit('upload-file', input.files[i])
}
}
input.click()
},
insert (text) {
const selection = this.editor.getSelection()
this.editor.executeEdits('', [
{
range: new window.monaco.Range(selection.endLineNumber, selection.endColumn, selection.endLineNumber, selection.endColumn),
text,
forceMoveMarkers: true
}
])
},
replaceLine (line, text) {
const length = this.editor.getModel().getLineLength(line)
this.editor.executeEdits('', [
{ range: new window.monaco.Range(line, 1, line, length + 1), text }
])
},
revealLineInCenter (line) {
this.editor.revealLineInCenter(line)
},
setScrollToTop (top) {
this.editor.setScrollTop(top)
},
switchTodo (line, checked) {
if (checked) {
const value = this.editor.getModel().getLineContent(line).replace('[ ]', `[x] ~~${dayjs().format('YYYY-MM-DD HH:mm')}~~`)
this.replaceLine(line, value)
} else {
const value = this.editor.getModel().getLineContent(line).replace(/(\[x\] ~~[\d-: ]+~~|\[x\])/, '[ ]')
this.replaceLine(line, value)
}
},
getValue () {
return this.editor.getModel().getValue(window.monaco.editor.DefaultEndOfLine.LF)
},
setModel (uri, value) {
const model = this.getModel(uri, value || '')
this.editor.setModel(model)
},
replaceValue (oldValue, newValue) {
this.editor.getModel().setValue(this.getValue().replace(oldValue, newValue))
},
getDucumentInfo () {
const selection = this.editor.getSelection()
return {
line: selection.positionLineNumber,
column: selection.positionColumn,
lineCount: this.editor.getModel().getLineCount(),
textLength: this.getValue().length,
selectedLength: this.editor.getModel().getValueInRange(selection).length
}
},
toggleWrap () {
const isWrapping = this.editor.getConfiguration().wrappingInfo.isViewportWrapping
this.editor.updateOptions({ wordWrap: !isWrapping })
}
},
}
function onGotAmdLoader () {
(window as any).require(['vs/editor/editor.main'], initMonaco)
}
onMounted(() => {
if (!window.require) {
const loaderScript = document.createElement('script')
loaderScript.type = 'text/javascript'
loaderScript.src = 'vs/loader.js'
loaderScript.addEventListener('load', onGotAmdLoader)
document.body.appendChild(loaderScript)
} else {
onGotAmdLoader()
}
window.addEventListener('keydown', recordKeys, true)
window.addEventListener('keyup', recordKeys, true)
})
onBeforeUnmount(() => {
window.removeEventListener('paste', paste as any)
window.removeEventListener('keydown', recordKeys)
window.removeEventListener('keyup', recordKeys)
})
return {
refEditor,
resize,
revealLine,
revealLineInCenter,
setScrollToTop,
switchTodo,
replaceValue,
toggleWrap,
setModel,
insert,
}
}
})
</script>
<style scoped>
+270 -225
View File
@@ -1,5 +1,5 @@
<template>
<div ref="view-wrapper" class="view" @scroll="handleScroll">
<div ref="refViewWrapper" class="view" @scroll="handleScroll">
<div class="action-bar" :style="{width: (width - 50) + 'px'}">
<div :style="{background: todoCount ? '#4e4e4e' : 'transparent'}">
<div v-if="todoCount" class="todo-progress">
@@ -13,7 +13,7 @@
<div v-else></div>
<div class="convert">
<iframe width="0" height="0" hidden id="preview_download" name="preview_download"></iframe>
<form ref="convertForm" :action="`/api/convert/${convert.fileName}`" method="post" target="preview_download">
<form ref="refConvertForm" :action="`/api/convert/${convert.fileName}`" method="post" target="preview_download">
<input type="hidden" name="html" :value="convert.html">
<input type="hidden" name="type" :value="convert.type">
<!-- <button type="button" @click="convertFile('pdf')">pdf</button> -->
@@ -23,7 +23,7 @@
</div>
</div>
</div>
<div ref="outline" class="outline">
<div class="outline">
<div style="padding: .5em;"><b>目录</b></div>
<div class="catalog" :style="{maxHeight: (height - 120) + 'px'}">
<div v-for="(head, index) in heads" :key="index" :style="{paddingLeft: `${head.level + 1}em`}" @click="syncScroll(head.sourceLine)">
@@ -33,13 +33,14 @@
</div>
</div>
<div :class="{'scroll-to-top': true, 'hide': scrollTop < 30}" :style="{top: (height - 40) + 'px'}" @click="scrollToTop">TOP</div>
<article ref="view" class="markdown-body" @click.capture="handleClick"></article>
<article ref="refView" class="markdown-body" @click.capture="handleClick"></article>
</div>
</template>
<script>
import { mapState } from 'vuex'
import _ from 'lodash'
<script lang="ts">
import loadsh 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'
@@ -60,20 +61,22 @@ import DrawioPlugin from '../plugins/DrawioPlugin'
import PlantumlPlugin from '../plugins/PlantumlPlugin'
import MermaidPlugin from '../plugins/MermaidPlugin'
import CodePlugin from '../plugins/CodePlugin'
import file from '@/lib/file'
import env from '@/lib/env'
import { encodeMarkdownLink } from '@/lib/utils'
import file from '../useful/file'
import env from '../useful/env'
import { encodeMarkdownLink } from '../useful/utils'
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'
HighlightLineNumber.addStyles()
const markdown = Markdown({
linkify: true,
breaks: true,
html: true,
highlight: (str, lang) => {
highlight: (str: string, lang: string) => {
if (lang && Highlight.getLanguage(lang)) {
try {
return Highlight.highlight(lang, str).value
@@ -93,117 +96,82 @@ const markdown = Markdown({
.use(LinkTargetPlugin)
.use(Footnote)
.use(DrawioPlugin)
.use(MultimdTable, { enableMultilineRows: true })
.use(MultimdTable, { multiline: true })
.use(MarkdownItToc)
.use(MarkdownItECharts)
.use(MermaidPlugin)
.use(CodePlugin)
export default {
export default defineComponent({
name: 'xview',
data () {
return {
width: 1024,
height: 1024,
heads: [],
convert: {},
todoCount: 0,
todoDoneCount: 0,
scrollTop: 0,
setup (_, { emit }) {
const bus = useBus()
const store = useStore()
const toast = useToast()
const { currentContent, currentFile } = toRefs(store.state)
const fileRepo = computed(() => currentFile.value?.repo)
const fileName = computed(() => currentFile.value?.name)
const filePath = computed(() => currentFile.value?.path)
const refViewWrapper = ref<HTMLElement | null>(null)
const refView = ref<HTMLElement | null>(null)
const refConvertForm = ref<HTMLFormElement | null>(null)
const width = ref(1024)
const height = ref(1024)
const heads = ref<{
tag: string;
text: string;
level: number;
sourceLine: number;
}[]>([])
const convert = ref({ fileName: '', html: '', type: '' })
const todoCount = ref(0)
const todoDoneCount = ref(0)
const scrollTop = ref(0)
function resizeHandler () {
width.value = refViewWrapper.value!!.clientWidth
height.value = refViewWrapper.value!!.clientHeight
}
},
mounted () {
this.updatePlantumlDebounce = _.debounce(() => {
this.updatePlantuml()
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)
}
const updatePlantumlDebounce = loadsh.debounce(() => {
updatePlantuml()
}, 3000, { leading: true })
this.runAppletScriptDebounce = _.debounce(() => {
this.runAppletScript()
const runAppletScriptDebounce = loadsh.debounce(() => {
runAppletScript()
}, 1000, { leading: true })
this.render = _.debounce(() => {
// 编辑非 markdown 文件预览直接显示代码
const content = (this.filePath || '').endsWith('.md')
? this.currentContent
: '```' + file.extname(this.fileName || '').replace(/^\./, '') + '\n' + this.currentContent + '```'
this.$refs.view.innerHTML = markdown.render(this.replaceRelativeLink(content))
this.runAppletScriptDebounce()
this.initDrawio()
this.updateOutline()
this.updateTodoCount()
this.updatePlantumlDebounce()
MarkdownItECharts.update()
MermaidPlugin.update()
for (let ele of document.querySelectorAll('code[class^="language-"]')) {
HighlightLineNumber.lineNumbersBlock(ele)
}
// 渲染完成后触发渲染完成事件
this.$nextTick(() => this.$bus.emit('preview-rendered'))
}, 500, { leading: true })
this.render()
setTimeout(() => {
this.updatePlantuml()
}, 100)
window.addEventListener('keydown', this.keydownHandler, true)
this.$bus.on('resize', this.resizeHandler)
this.resizeHandler()
},
beforeDestroy () {
window.removeEventListener('keydown', this.keydownHandler)
this.$bus.off('resize', this.resizeHandler)
},
methods: {
resizeHandler () {
this.width = this.$refs['view-wrapper'].clientWidth
this.height = this.$refs['view-wrapper'].clientHeight
},
async keydownHandler (e) {
// 转换所有外链图片到本地
if (e.key === 'l' && e.ctrlKey && e.altKey) {
e.preventDefault()
e.stopPropagation()
const result = []
const imgList = [...this.$refs.view.querySelectorAll('img')]
for (let i = 0; i < imgList.length; i++) {
this.$toast.show('info', `正在转换外链图片 ${i + 1}/${imgList.length}`)
const img = imgList[i]
const data = await this.transformImgOutLink(img)
if (data) {
result.push(data)
}
}
result.forEach(data => this.$bus.emit('editor-replace-value', data.oldLink, data.replacedLink))
this.$bus.emit('tree-refresh')
}
},
handleScroll (e) {
this.scrollTop = e.target.scrollTop
},
scrollToTop () {
this.$refs['view-wrapper'].scrollTo(0, 0)
this.syncScroll(1)
},
replaceRelativeLink (md) {
if (!this.fileRepo) {
function replaceRelativeLink (md: string) {
if (!fileRepo.value) {
return md
}
if (this.fileRepo === '__help__') {
return md.replace(/\[([^\]]*)\]\((\.\/[^)]*)\)/g, `[$1](api/help/file?path=$2)`)
.replace(/<img([^>]*)src=["']?(\.\/[^\s'"]*)["']?/ig, `<img$1src="api/help/file?path=$2"`)
if (fileRepo.value === '__help__') {
return md.replace(/\[([^\]]*)\]\((\.\/[^)]*)\)/g, '[$1](api/help/file?path=$2)')
.replace(/<img([^>]*)src=["']?(\.\/[^\s'"]*)["']?/ig, '<img$1src="api/help/file?path=$2"')
}
if (!this.filePath) {
if (!filePath.value) {
return md
}
const basePath = file.dirname(this.filePath)
const repo = this.fileRepo
const basePath = file.dirname(filePath.value)
const repo = fileRepo.value
return md.replace(/\[([^\]]*)\]\(\.\/([^)]*)\)/g, (match, alt, path) => {
path = decodeURI(path) // 提前解码一次,有的链接已经预先编码
@@ -227,63 +195,79 @@ export default {
const filePath = `${basePath}/${path}`
return `<img${_}origin-src="${path}" src="api/attachment/${encodeURIComponent(fileName)}?repo=${repo}&path=${encodeURIComponent(filePath)}"`
})
},
updatePlantuml () {
const nodes = this.$refs.view.querySelectorAll('img[data-plantuml-src]')
for (let ele of nodes) {
ele.src = ele.dataset['plantumlSrc']
}
},
runAppletScript () {
const nodes = this.$refs.view.querySelectorAll('.applet[data-code]')
for (const el of nodes) {
AppletPlugin.runScript(el)
}
},
initDrawio () {
if (!this.$refs.view) {
}
function initDrawio () {
if (!refView.value) {
return
}
const nodes = this.$refs.view.querySelectorAll('.drawio[data-file]')
for (const el of nodes) {
let originPath = el.dataset.file
if (originPath) {
originPath = decodeURI(originPath)
const filepath = originPath.startsWith('/') ? originPath : (file.dirname(this.filePath) + '/' + originPath.replace(/^.\//, ''))
DrawioPlugin.load(el, this.fileRepo, filepath)
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)
}
}
},
updateOutline () {
})
}
function updateOutline () {
const tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
const nodes = this.$refs.view.querySelectorAll(tags.join(','))
this.heads = Array.from(nodes).map(node => {
const nodes = refView.value!!.querySelectorAll<HTMLHeadElement>(tags.join(','))
heads.value = Array.from(nodes).map(node => {
return {
tag: node.tagName.toLowerCase(),
text: node.innerText,
level: tags.indexOf(node.tagName.toLowerCase()),
sourceLine: parseInt(node.dataset['sourceLine'])
sourceLine: parseInt(node.dataset.sourceLine || '0')
}
})
},
updateTodoCount () {
this.todoCount = this.$refs.view.querySelectorAll('input[type=checkbox]').length
this.todoDoneCount = this.$refs.view.querySelectorAll('input[type=checkbox][checked]').length
},
async transformImgOutLink (img) {
const transform = ximg => {
}
function updateTodoCount () {
todoCount.value = refView.value!!.querySelectorAll('input[type=checkbox]').length
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(() => {
// 编辑非 markdown 文件预览直接显示代码
const content = (filePath.value || '').endsWith('.md')
? currentContent.value
: '```' + file.extname(fileName.value || '').replace(/^\./, '') + '\n' + currentContent.value + '```'
refView.value!!.innerHTML = markdown.render(replaceRelativeLink(content))
runAppletScriptDebounce()
initDrawio()
updateOutline()
updateTodoCount()
updatePlantumlDebounce()
hightCode()
MarkdownItECharts.update()
MermaidPlugin.update()
// 渲染完成后触发渲染完成事件
nextTick(() => bus.emit('preview-rendered'))
}, 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)
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(this.fileRepo, this.filePath, imgFile)
const imgFile = new File([blob!!], 'file.png')
const { relativePath } = await file.upload(fileRepo.value, filePath.value, imgFile)
resolve(relativePath)
} catch (error) {
reject(error)
@@ -292,14 +276,15 @@ export default {
})
}
let replacedLink = null
let replacedLink = ''
const imgAttrSrc = img.getAttribute('src') || ''
if (img.src.startsWith('data:')) {
replacedLink = await transform(img)
} else if (img.getAttribute('src').startsWith('http://') || img.getAttribute('src').startsWith('https://')) {
} 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(this.fileRepo, this.filePath, imgFile)
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
}
@@ -308,16 +293,55 @@ export default {
}
return null
},
async handleClick (e) {
const target = e.target
}
async function keydownHandler (e: KeyboardEvent) {
// 转换所有外链图片到本地
if (e.key === 'l' && e.ctrlKey && e.altKey) {
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')
}
}
function handleScroll (e: any) {
scrollTop.value = e.target.scrollTop
}
function syncScroll (line: number) {
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) {
const target = e.target as HTMLElement
const preventEvent = () => {
e.preventDefault()
e.stopPropagation()
}
const handleLink = link => {
const handleLink = (link: HTMLAnchorElement) => {
// 系统中打开附件
if (link.classList.contains('open')) {
fetch(link.href.replace('api/attachment', 'api/open'))
@@ -338,21 +362,21 @@ export default {
let path = tmp[0]
if (path.startsWith('.')) { // 将相对路径转换为绝对路径
path = file.dirname(this.filePath || '') + path.replace('.', '')
path = file.dirname(filePath.value || '') + path.replace('.', '')
}
// 打开文件
this.$store.commit('app/setCurrentFile', {
store.commit('setCurrentFile', {
path,
name: file.basename(path),
repo: this.fileRepo,
repo: fileRepo.value,
type: 'file'
})
// 跳转锚点
const hash = tmp.slice(1).join('#')
if (hash) {
this.$bus.once('preview-rendered', () => {
bus.once('preview-rendered', () => {
const el = document.getElementById(hash) ||
document.getElementById(encodeURIComponent(hash))
@@ -368,32 +392,35 @@ export default {
preventEvent()
} else if (href && href.startsWith('#')) { // 处理 TOC 跳转
document.getElementById(href.replace(/^#/, '')).scrollIntoView()
const el = document.getElementById(href.replace(/^#/, ''))
if (el) {
el.scrollIntoView()
}
preventEvent()
}
}
}
if (target.tagName === 'A') {
handleLink(target)
handleLink(target as HTMLAnchorElement)
}
// 复制标题链接
if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].indexOf(target.tagName) > -1 && target.id && e.ctrlKey) {
this.$bus.emit('copy-text', encodeMarkdownLink(this.filePath) + '#' + encodeMarkdownLink(decodeURIComponent(target.id)))
bus.emit('copy-text', encodeMarkdownLink(filePath.value) + '#' + encodeMarkdownLink(decodeURIComponent(target.id)))
return preventEvent()
}
if (target.tagName === 'IMG') {
const img = target
const img = target as HTMLImageElement
if (e.ctrlKey && e.shiftKey) { // 转换外链图片到本地
const data = await this.transformImgOutLink(img)
const data = await transformImgOutLink(img)
if (data) {
this.$bus.emit('tree-refresh')
this.$bus.emit('editor-replace-value', data.oldLink, data.replacedLink)
bus.emit('tree-refresh')
bus.emit('editor-replace-value', { search: data.oldLink, replace: data.replacedLink })
}
} else if (img.parentElement.tagName === 'A') {
handleLink(img.parentElement)
} else if (img.parentElement!!.tagName === 'A') {
handleLink(img.parentElement as HTMLAnchorElement)
} else {
env.openAlwaysOnTopWindow(img.src)
}
@@ -402,40 +429,37 @@ export default {
return
}
if (target.tagName === 'INPUT' && target.parentElement.classList.contains('source-line')) {
this.switchTodo(parseInt(target.parentElement.dataset['sourceLine']), target.checked)
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.classList.contains('source-line') && window.getSelection().toString().length < 1) {
this.syncScroll(parseInt(target.dataset['sourceLine']))
if (target.classList.contains('source-line') && window.getSelection()!!.toString().length < 1) {
syncScroll(parseInt(target.dataset.sourceLine || '0'))
e.stopPropagation()
e.preventDefault()
}
},
syncScroll (line) {
this.$emit('sync-scroll', line)
},
switchTodo (line, checked) {
this.$emit('switch-todo', line, checked)
},
revealLine (line) {
}
function revealLine (line: number) {
if (line <= 1) {
this.$refs['view-wrapper'].scrollTo(0, 0)
refViewWrapper.value!!.scrollTo(0, 0)
return
}
const nodes = this.$refs['view-wrapper'].querySelectorAll('.view .source-line')
for (let ele of nodes) {
if (parseInt(ele.dataset['sourceLine']) >= line) {
ele.scrollIntoView()
const nodes = refViewWrapper.value!!.querySelectorAll<HTMLElement>('.view .source-line')
for (let i = 0; i < nodes.length; i++) {
const el = nodes[i]
if (parseInt(el.dataset.sourceLine || '0') >= line) {
el.scrollIntoView()
break
}
}
},
convertFile (type) {
}
function convertFile (type: string) {
MarkdownItECharts.preparePrint()
let baseUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/')) + '/'
@@ -445,43 +469,64 @@ export default {
baseUrl = baseUrl.replace(/localhost/i, '127.0.0.1')
}
this.convert = {
fileName: (this.fileName || '未命名') + `.${type}`,
html: this.$refs.view.outerHTML.replace(/src="api/g, `src="${baseUrl}api`),
convert.value = {
fileName: (fileName.value || '未命名') + `.${type}`,
html: refView.value!!.outerHTML.replace(/src="api/g, `src="${baseUrl}api`),
type
}
MarkdownItECharts.update()
setTimeout(() => {
this.$refs.convertForm.submit()
refConvertForm.value!!.submit()
}, 300)
},
print () {
}
function print () {
window.print()
}
},
computed: {
...mapState('app', ['currentContent', 'currentFile']),
fileRepo () {
return this.currentFile && this.currentFile.repo
},
fileName () {
return this.currentFile && this.currentFile.name
},
filePath () {
return this.currentFile && this.currentFile.path
},
},
watch: {
currentContent () {
this.render()
},
filePath () {
onMounted(() => {
render()
setTimeout(() => {
this.updatePlantuml()
updatePlantuml()
}, 100)
window.addEventListener('keydown', keydownHandler, true)
bus.on('resize', resizeHandler)
resizeHandler()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keydownHandler)
bus.off('resize', resizeHandler)
})
watch(currentContent, render)
watch(filePath, () => {
setTimeout(() => {
updatePlantuml()
}, 100)
})
return {
refViewWrapper,
refView,
width,
height,
heads,
convert,
todoCount,
todoDoneCount,
scrollTop,
print,
convertFile,
handleScroll,
scrollToTop,
handleClick,
revealLine,
syncScroll,
}
}
}
},
})
</script>
<style scoped>
@@ -495,42 +540,42 @@ export default {
margin-top: 1em;
}
.markdown-body /deep/ a {
.markdown-body ::v-deep(a) {
color: #4c93e2;
}
.markdown-body /deep/ tr {
.markdown-body ::v-deep(tr) {
background: inherit;
}
.markdown-body /deep/ * {
.markdown-body ::v-deep(*) {
border-color: #8b8d90;
background: inherit;
}
.markdown-body /deep/ code {
.markdown-body ::v-deep(code) {
background: #464648;
}
.markdown-body /deep/ table tr:nth-child(2n),
.markdown-body /deep/ pre,
.markdown-body /deep/ pre > code
.markdown-body ::v-deep(table tr:nth-child(2n)),
.markdown-body ::v-deep(pre),
.markdown-body ::v-deep(pre > code)
{
background: #303133;
}
.markdown-body /deep/ div.mermaid,
.markdown-body /deep/ div.drawio-wrapper,
.markdown-body /deep/ input,
.markdown-body /deep/ img
.markdown-body ::v-deep(div.mermaid),
.markdown-body ::v-deep(div.drawio-wrapper),
.markdown-body ::v-deep(input),
.markdown-body ::v-deep(img)
{
transition: all .3s ease-in-out;
filter: brightness(70%);
}
.markdown-body /deep/ div.mermaid:hover,
.markdown-body /deep/ div.drawio-wrapper:hover,
.markdown-body /deep/ img:hover
.markdown-body ::v-deep(div.mermaid:hover),
.markdown-body ::v-deep(div.drawio-wrapper:hover),
.markdown-body ::v-deep(img:hover)
{
filter: none;
}
@@ -669,17 +714,17 @@ button:hover {
right: -60px;
}
.markdown-body /deep/ table.hljs-ln tbody {
.markdown-body ::v-deep(table.hljs-ln tbody) {
display: table;
min-width: 100%;
}
.markdown-body /deep/ fieldset {
.markdown-body ::v-deep(fieldset) {
border-style: solid;
margin: 20px 0;
}
.markdown-body /deep/ legend {
.markdown-body ::v-deep(legend) {
padding: 0 .2em;
}
+209 -176
View File
@@ -3,19 +3,19 @@
<div class="tab">
<div v-for="tab in tabs" :key="tab.key" @click="switchTab(tab.key)" :class="{selected: currentTab === tab.key}">{{tab.label}}</div>
</div>
<input ref="input" v-model="searchText" type="text" class="input" @keydown.tab.prevent @keydown.up.prevent @keydown.down.prevent autofocus>
<ul ref="result" class="result">
<input ref="refInput" v-model="searchText" type="text" class="input" @keydown.tab.prevent @keydown.up.prevent @keydown.down.prevent>
<ul ref="refResult" class="result">
<li v-if="dataList === null">加载中</li>
<template v-else>
<li
v-for="item in dataList"
v-for="(item, i) in dataList"
:key="item.repo + item.path"
:class="{selected: selected === item}"
@click="chooseItem(item)">
<span ref="fileName">
<span :ref="el => refFilename[i] = el">
{{item.name}}
</span>
<span ref="filePath" class="path">
<span :ref="el => refFilepath[i] = el" class="path">
[{{item.repo}}] {{item.path.substr(0, item.path.lastIndexOf('/'))}}
</span>
</li>
@@ -25,85 +25,154 @@
</div>
</template>
<script>
<script lang="ts">
import _ from 'lodash'
import { mapState } from 'vuex'
import file from '@/lib/file'
import fuzzyMatch from '@/lib/fuzzyMatch'
import { computed, defineComponent, nextTick, onMounted, ref, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import file from '../useful/file'
import fuzzyMatch from '../useful/fuzzy-match'
export default {
type TabKey = 'marked' | 'search' | 'file'
export default defineComponent({
name: 'quick-open',
components: {},
props: {
withMarked: {
type: Boolean,
default: true,
},
},
data () {
return {
selected: null,
searchText: '',
currentTab: 'marked',
list: [],
lastFetchTime: 0,
setup (props, { emit }) {
const store = useStore()
const refInput = ref<HTMLInputElement | null>(null)
const refResult = ref<HTMLUListElement | null>(null)
const refFilename = ref<HTMLElement[]>([])
const refFilepath = ref<HTMLElement[]>([])
const { currentRepo, recentOpenTime, tree, markedFiles } = toRefs(store.state)
const selected = ref<any>(null)
const searchText = ref('')
const currentTab = ref<TabKey>('marked')
const list = ref<any>([])
const lastFetchTime = ref(0)
const repo = computed(() => currentRepo.value?.name)
const tabs = computed(() => {
const arr: {key: TabKey; label: string}[] = [
{ key: 'file', label: '快速跳转' },
{ key: 'search', label: '搜索内容' },
]
if (props.withMarked) {
arr.unshift({ key: 'marked', label: '已标记' })
}
return arr
})
const files = computed(() => {
const travelFiles = (tree: any) => {
let tmp: any[] = []
tree.forEach((node: any) => {
if (node.type === 'file' && node.path.endsWith('.md')) {
tmp.push(node)
}
if (Array.isArray(node.children)) {
tmp = tmp.concat(travelFiles(node.children))
}
})
return tmp
}
return travelFiles(tree.value || [])
})
function sortList (list: any) {
if (list === null) {
return
}
const map = (recentOpenTime.value || {})
return list.sort((a: any, b: any) => {
const at = map[`${a.repo}|${a.path}`] || 0
const bt = map[`${b.repo}|${b.path}`] || 0
return bt - at
})
}
},
created () {
this.searchWithDebounce = _.debounce(async (text, call) => {
if (this.repo && text.trim()) {
function filterFiles (files: any[], search: string, fuzzy: boolean) {
if (!fuzzy) {
search = search.toLowerCase()
return files.filter(x => x.path.toLowerCase().indexOf(search) > -1)
}
const tmp: any[] = []
files.forEach(x => {
const result = fuzzyMatch(search, x.path)
if (result.matched) {
tmp.push({ ...x })
}
})
return tmp.sort((a, b) => b.score - a.score)
}
const dataList = computed(() => {
if (!list.value) {
return null
}
// 筛选一下,搜索全文不筛选
const arr = currentTab.value === 'search' ? list.value : filterFiles(list.value, searchText.value.trim(), false)
// 按照最近使用时间排序
return sortList(arr).slice(0, 70)
})
const searchWithDebounce = _.debounce(async (text: string, call: Function) => {
if (repo.value && text.trim()) {
const fetchTime = new Date().getTime()
this.lastFetchTime = fetchTime
const data = await file.search(this.repo, text.trim())
lastFetchTime.value = fetchTime
const data = await file.search(repo.value, text.trim())
// 总是保证最后的搜索结果出现在列表
if (fetchTime >= this.lastFetchTime) {
if (fetchTime >= lastFetchTime.value) {
call(data)
}
} else {
call([])
}
}, 500)
},
async mounted () {
this.$refs.input.focus()
this.updateDataSource()
await this.$store.dispatch('app/fetchMarkedFiles')
this.updateDataSource()
},
methods: {
travelFiles (tree) {
let tmp = []
tree.forEach(node => {
if (node.type === 'file' && node.path.endsWith('.md')) {
tmp.push(node)
}
if (Array.isArray(node.children)) {
tmp = tmp.concat(this.travelFiles(node.children))
}
})
return tmp
},
highlightText (search) {
if (this.$refs.fileName && this.$refs.filePath) {
function highlightText (search: string) {
if (refFilename.value && refFilepath.value) {
search = search.toLowerCase()
const openF = '(#$*B'
const closeF = '#$*B)'
const escape = function (s) {
return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
}
const escape = (s: string) => s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
const openR = new RegExp(escape(openF), 'g')
const closeR = new RegExp(escape(closeF), 'g')
this.$refs.fileName.concat(this.$refs.filePath).forEach(function (it) {
;(refFilename.value || []).concat(refFilepath.value || []).forEach(function (it: any) {
if (!it) {
return
}
let text = ''
it.innerText.split('').forEach(char => {
it.innerText.split('').forEach((char: string) => {
if (search.indexOf(char.toLowerCase()) > -1) {
text += `${openF}${char}${closeF}`
} else {
@@ -116,161 +185,125 @@ export default {
it.innerHTML = it.innerHTML.replace(openR, '<b>').replace(closeR, '</b>')
})
}
},
filterFiles (files, search, fuzzy) {
if (!fuzzy) {
search = search.toLowerCase()
return files.filter(x => x.path.toLowerCase().indexOf(search) > -1)
}
}
const tmp = []
files.forEach(x => {
const result = fuzzyMatch(search, x.path)
if (result.matched) {
tmp.push({ ...x })
}
})
return tmp.sort((a, b) => b.score - a.score)
},
updateDataSource () {
if (this.currentTab === 'file') {
this.list = this.files
} else if (this.currentTab === 'marked') {
this.list = this.markedFiles
} else if (this.currentTab === 'search') {
this.list = null
this.searchWithDebounce(this.searchText.trim(), data => {
if (this.currentTab === 'search') {
this.list = data
function updateDataSource () {
if (currentTab.value === 'file') {
list.value = files.value
} else if (currentTab.value === 'marked') {
list.value = markedFiles.value
} else if (currentTab.value === 'search') {
list.value = null
searchWithDebounce(searchText.value.trim(), (data: any[]) => {
if (currentTab.value === 'search') {
list.value = data
}
})
}
},
updateSelected (item = null) {
if (this.dataList === null) {
}
function updateSelected (item: any = null) {
if (dataList.value === null) {
return
}
if (item) {
this.selected = item
selected.value = item
} else {
this.selected = this.dataList.length > 0 ? this.dataList[0] : null
selected.value = dataList.value.length > 0 ? dataList.value[0] : null
}
this.$nextTick(() => {
const li = this.$refs.result.querySelector('li.selected')
if (li) {
li.scrollIntoViewIfNeeded()
nextTick(() => {
if (refResult.value) {
const li = refResult.value.querySelector<any>('li.selected')
if (li) {
li.scrollIntoViewIfNeeded()
}
}
})
},
selectItem (inc) {
if (!this.dataList || this.dataList.length < 1) {
this.updateSelected()
}
function selectItem (inc: number) {
if (!dataList.value || dataList.value.length < 1) {
updateSelected()
return
}
const currentIndex = this.dataList.findIndex(x => this.selected === x)
const currentIndex = dataList.value.findIndex((x: any) => selected.value === x)
let index = currentIndex + inc
if (index > this.dataList.length - 1) {
if (index > dataList.value.length - 1) {
index = 0
} else if (index < 0) {
index = this.dataList.length - 1
index = dataList.value.length - 1
}
this.updateSelected(this.dataList[index])
},
chooseItem (item = null) {
const file = item || this.selected
updateSelected(dataList.value[index])
}
function chooseItem (item: any = null) {
const file = item || selected.value
if (file) {
this.$emit('choose-file', file)
emit('choose-file', file)
}
},
switchTab (tab) {
}
function switchTab (tab: TabKey| number) {
if (typeof tab === 'string') {
this.currentTab = tab
currentTab.value = tab
return
}
const tabs = this.tabs.map(x => x.key)
const arr = tabs.value.map(x => x.key)
const index = tabs.indexOf(this.currentTab) + tab
this.currentTab = tabs[index > -1 ? index : tabs.length - 1] || tabs[0]
},
sortList (list) {
if (list === null) {
return
const index = arr.indexOf(currentTab.value) + tab
currentTab.value = arr[index > -1 ? index : arr.length - 1] || arr[0]
}
watch(() => props.withMarked, val => {
if (!val && currentTab.value === 'marked') {
currentTab.value = 'file'
}
}, { immediate: true })
const map = (this.recentOpenTime || {})
watch(searchText, () => updateDataSource())
return list.sort((a, b) => {
const at = map[`${a.repo}|${a.path}`] || 0
const bt = map[`${b.repo}|${b.path}`] || 0
watch(dataList, () => {
updateSelected()
if (currentTab.value !== 'search') { // 搜索模式不高亮字符
nextTick(() => highlightText(searchText.value.trim()))
}
})
return bt - at
})
watch(currentTab, () => {
list.value = null
refInput.value!!.focus()
updateDataSource()
})
onMounted(async () => {
refInput.value!!.focus()
updateDataSource()
await store.dispatch('fetchMarkedFiles')
updateDataSource()
})
return {
refInput,
refResult,
refFilename,
refFilepath,
tabs,
currentTab,
searchText,
dataList,
selected,
selectItem,
chooseItem,
switchTab,
}
},
watch: {
withMarked: {
immediate: true,
handler (val) {
if (!val && this.currentTab === 'marked') {
this.currentTab = 'file'
}
}
},
searchText () {
this.updateDataSource()
},
dataList () {
this.updateSelected()
this.$nextTick(() => this.highlightText(this.searchText.trim()))
},
currentTab () {
this.list = null
this.$refs.input.focus()
this.updateDataSource()
}
},
computed: {
...mapState('app', ['currentRepo', 'recentOpenTime', 'tree', 'markedFiles']),
tabs () {
const tabs = [
{ key: 'file', label: '快速跳转' },
{ key: 'search', label: '搜索内容' },
]
if (this.withMarked) {
tabs.unshift({ key: 'marked', label: '已标记' })
}
return tabs
},
files () {
return this.travelFiles(this.tree || [])
},
repo () {
return this.currentRepo && this.currentRepo.name
},
dataList () {
if (!this.list) {
return null
}
// 筛选一下,搜索全文不筛选
const list = this.currentTab === 'search' ? this.list : this.filterFiles(this.list, this.searchText.trim(), false)
// 按照最近使用时间排序
return this.sortList(list).slice(0, 70)
}
}
}
})
</script>
<style scoped>
@@ -333,12 +366,12 @@ export default {
padding-left: .3em;
}
.result li span.path /deep/ b {
.result li span.path ::v-deep b {
color: #ababab;
font-weight: bold;
}
.result li /deep/ b {
.result li ::v-deep(b) {
color: #c6d2ca;
font-weight: normal;
}
+57 -51
View File
@@ -8,68 +8,74 @@
</div>
</template>
<script>
import { mapState } from 'vuex'
import file from '@/lib/file'
<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 {
export default defineComponent({
name: 'repository-switch',
components: {},
props: {
},
data () {
return {
current: null,
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)
}
}
},
created () {
this.$bus.on('switch-repo-by-name', this.chooseRepoByName)
this.$bus.on('editor-ready', this.initRepo)
this.$store.dispatch('app/fetchRepositories')
},
beforeDestroy () {
this.$bus.off('switch-repo-by-name', this.chooseRepoByName)
this.$bus.off('editor-ready', this.initRepo)
},
methods: {
initRepo () {
const initRepoName = window.$args().get('init-repo')
const initFilePath = window.$args().get('init-file')
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) {
this.chooseRepoByName(initRepoName)
chooseRepoByName(initRepoName)
}
if (initFilePath) {
this.$store.commit('app/setCurrentFile', { repo: this.currentRepo.name, name: file.basename(initFilePath), path: initFilePath })
}
},
chooseRepoByName (name) {
if (this.repositories[name]) {
this.choose({ name, path: this.repositories[name] })
}
},
choose (repo) {
if (repo.name !== this.currentRepo.name) {
this.$store.commit('app/setCurrentRepo', repo)
}
},
},
watch: {
repositories (val) {
const keys = Object.keys(val)
if (!this.currentRepo || keys.indexOf(this.currentRepo.name) < 0) {
if (keys.length > 0) {
const name = keys[0]
this.$store.commit('app/setCurrentRepo', { name, path: val[name] })
}
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,
}
},
computed: {
...mapState('app', ['currentRepo', 'repositories']),
}
}
})
</script>
<style scoped>
+60 -41
View File
@@ -19,76 +19,95 @@
</div>
</template>
<script>
import { mapState } from 'vuex'
import RepositorySwitch from './RepositorySwitch'
<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, toRef } from 'vue'
import { useStore } from 'vuex'
import { useBus } from '../useful/bus'
import RepositorySwitch from './RepositorySwitch.vue'
export default {
export default defineComponent({
name: 'status-bar',
components: { RepositorySwitch },
created () {
window.addEventListener('keydown', this.keydownHandler, true)
},
beforeDestroy () {
window.removeEventListener('keydown', this.keydownHandler)
},
methods: {
toggleSide () {
this.$bus.emit('toggle-side')
},
toggleView () {
this.$bus.emit('toggle-view')
},
toggleXterm () {
this.$bus.emit('toggle-xterm')
},
toggleFeature () {
this.$store.dispatch('app/showHelp', 'FEATURES.md')
},
toggleReadme () {
this.$store.dispatch('app/showHelp', 'README.md')
},
toggleWrap () {
this.$bus.emit('editor-toggle-wrap')
},
keydownHandler (e) {
setup () {
const bus = useBus()
const store = useStore()
const documentInfo = toRef(store.state, 'documentInfo')
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 keydownHandler (e: KeyboardEvent) {
if (e.key === 'e' && e.altKey) {
this.toggleSide()
toggleSide()
e.preventDefault()
e.stopPropagation()
}
if (e.key === 'v' && e.altKey) {
this.toggleView()
toggleView()
e.preventDefault()
e.stopPropagation()
}
if (e.key === 'o' && e.altKey) {
this.toggleXterm()
toggleXterm()
e.preventDefault()
e.stopPropagation()
}
if (e.key === 'w' && e.altKey) {
this.toggleWrap()
toggleWrap()
e.preventDefault()
e.stopPropagation()
}
if (e.key === 'h' && e.altKey) {
this.toggleReadme()
toggleReadme()
e.preventDefault()
e.stopPropagation()
}
}
onMounted(() => {
window.addEventListener('keydown', keydownHandler, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keydownHandler)
})
return {
documentInfo,
toggleSide,
toggleWrap,
toggleView,
toggleXterm,
toggleReadme,
toggleFeature,
}
},
watch: {
},
computed: {
...mapState('app', ['documentInfo'])
}
}
})
</script>
<style scoped>
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="svg-icon" :style="{color}" v-html="html" :title="title"></div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'svg-icon',
props: {
name: String,
title: String,
color: String,
},
setup (props) {
const icons: { [key: string]: string } = {
'thumbtack-solid': '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="thumbtack" class="svg-inline--fa fa-thumbtack fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M298.028 214.267L285.793 96H328c13.255 0 24-10.745 24-24V24c0-13.255-10.745-24-24-24H56C42.745 0 32 10.745 32 24v48c0 13.255 10.745 24 24 24h42.207L85.972 214.267C37.465 236.82 0 277.261 0 328c0 13.255 10.745 24 24 24h136v104.007c0 1.242.289 2.467.845 3.578l24 48c2.941 5.882 11.364 5.893 14.311 0l24-48a8.008 8.008 0 0 0 .845-3.578V352h136c13.255 0 24-10.745 24-24-.001-51.183-37.983-91.42-85.973-113.733z"></path></svg>',
'folder-plus-solid': '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="folder-plus" class="svg-inline--fa fa-folder-plus fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M464,128H272L208,64H48A48,48,0,0,0,0,112V400a48,48,0,0,0,48,48H464a48,48,0,0,0,48-48V176A48,48,0,0,0,464,128ZM359.5,296a16,16,0,0,1-16,16h-64v64a16,16,0,0,1-16,16h-16a16,16,0,0,1-16-16V312h-64a16,16,0,0,1-16-16V280a16,16,0,0,1,16-16h64V200a16,16,0,0,1,16-16h16a16,16,0,0,1,16,16v64h64a16,16,0,0,1,16,16Z"></path></svg>',
'times-solid': '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>',
}
const html = ref(icons[props.name!!])
return { html }
}
})
</script>
<style scoped>
.svg-icon {
width: 18px;
height: 18px;
display: inline-block;
text-align: center;
vertical-align: middle;
}
.svg-icon ::v-deep(svg) {
width: 100%;
height: 100%;
display: inline-block;
}
</style>
+77 -56
View File
@@ -1,5 +1,5 @@
<template>
<div ref="tabs" class="tabs">
<div ref="refTabs" class="tabs">
<div
v-for="item in list"
:key="item.key"
@@ -10,72 +10,97 @@
@click="switchTab(item)">
<div class="label">{{item.label}}</div>
<div class="close" @click.prevent.stop="removeTabs([item])">
<y-icon class="close-icon" name="times" title="关闭"></y-icon>
<svg-icon name="times-solid" title="关闭" style="width: 12px; height: 12px;" />
</div>
</div>
</div>
</template>
<script>
<script lang="ts">
import Sortable from 'sortablejs'
import 'vue-awesome/icons/times'
import { defineComponent, onMounted, ref } from 'vue'
import { useContextMenu } from '../useful/context-menu'
import { Components } from '../types'
import SvgIcon from './SvgIcon.vue'
export default {
export default defineComponent({
name: 'tabs',
components: { SvgIcon },
props: {
value: String,
list: Array,
list: {
type: Array as () => Components.Tabs.Item[],
required: true,
},
},
mounted () {
Sortable.create(this.$refs.tabs, {
animation: 250,
ghostClass: 'on-sort',
direction: 'horizontal',
onEnd: ({ oldIndex, newIndex }) => {
this.swapTab(oldIndex, newIndex)
setup (props, { emit }) {
const refTabs = ref<HTMLElement | null>(null)
const contextMenu = useContextMenu()
function switchTab (item: Components.Tabs.Item) {
if (item.key !== props.value) {
emit('input', item.key)
emit('switch', item)
}
})
},
methods: {
showContextMenu (item) {
this.$contextMenu.show([
{ id: 'close', label: '关闭', onClick: () => this.removeTabs([item]) },
{ id: 'close-other', label: '关闭其他', onClick: () => this.removeOther(item) },
{ id: 'close-right', label: '关闭到右侧', onClick: () => this.removeRight(item) },
{ id: 'close-left', label: '关闭到左侧', onClick: () => this.removeLeft(item) },
{ id: 'close-all', label: '全部关闭', onClick: () => this.removeAll(item) },
])
},
switchTab (item) {
if (item.key !== this.value) {
this.$emit('input', item.key)
this.$emit('switch', item)
}
},
removeOther (item) {
this.removeTabs(this.list.filter(x => x.key !== item.key))
},
removeRight (item) {
this.removeTabs(this.list.slice(this.list.findIndex(x => x.key === item.key) + 1))
},
removeLeft (item) {
this.removeTabs(this.list.slice(0, this.list.findIndex(x => x.key === item.key)))
},
removeAll () {
this.removeTabs(this.list)
},
removeTabs (items) {
this.$emit('remove', items)
},
swapTab (oldIndex, newIndex) {
const list = this.list
}
function removeTabs (items: Components.Tabs.Item[]) {
emit('remove', items)
}
function removeOther (item: Components.Tabs.Item) {
removeTabs(props.list.filter(x => x.key !== item.key))
}
function removeRight (item: Components.Tabs.Item) {
removeTabs(props.list.slice(props.list.findIndex(x => x.key === item.key) + 1))
}
function removeLeft (item: Components.Tabs.Item) {
removeTabs(props.list.slice(0, props.list.findIndex(x => x.key === item.key)))
}
function removeAll () {
removeTabs(props.list)
}
function swapTab (oldIndex: number, newIndex: number) {
const list = props.list
const tmp = list[oldIndex]
list[oldIndex] = list[newIndex]
list[newIndex] = tmp
this.$emit('change-list', list)
emit('change-list', list)
}
},
}
function showContextMenu (item: Components.Tabs.Item) {
contextMenu.show([
{ id: 'close', label: '关闭', onClick: () => removeTabs([item]) },
{ id: 'close-other', label: '关闭其他', onClick: () => removeOther(item) },
{ id: 'close-right', label: '关闭到右侧', onClick: () => removeRight(item) },
{ id: 'close-left', label: '关闭到左侧', onClick: () => removeLeft(item) },
{ id: 'close-all', label: '全部关闭', onClick: () => removeAll() },
])
}
onMounted(() => {
Sortable.create(refTabs.value!!, {
animation: 250,
ghostClass: 'on-sort',
direction: 'horizontal',
onEnd: ({ oldIndex, newIndex }: { oldIndex?: number; newIndex?: number }) => {
swapTab(oldIndex || 0, newIndex || 0)
}
})
})
return {
refTabs,
switchTab,
showContextMenu,
removeTabs,
}
}
})
</script>
<style scoped>
@@ -124,14 +149,10 @@ export default {
}
.close:hover {
color: #aaa;
color: rgb(212, 212, 212);
background: #444;
}
.close-icon {
zoom: .6;
}
.tab.on-sort {
background: rgb(112, 112, 112);
}
+127 -112
View File
@@ -1,13 +1,13 @@
<template>
<div class="title-bar" :style="titleBarStyles">
<div v-if="win && !isMaximized" class="resizer"></div>
<div v-if="hasWin && !isMaximized" class="resizer"></div>
<h4 class="title">
<img v-if="win" @dblclick="close" class="logo" src="~@/assets/icon.png" alt="logo">
<img v-if="hasWin" @dblclick="close" class="logo" src="~@/assets/icon.png" alt="logo">
<span>{{statusText}}</span>
</h4>
<div class="action" v-if="win">
<div class="action" v-if="hasWin">
<div title="置顶窗口" :class="{btn: true, pin: true, ontop: isAlwaysOnTop}" @click="toggleAlwaysOnTop">
<y-icon class="pin-icon" name="thumbtack"></y-icon>
<svg-icon color="hsla(0, 0%, 100%, .5)" name="thumbtack-solid"></svg-icon>
</div>
<div title="最小化" class="btn" @click="minimize">
<div class="icon minimize"></div>
@@ -25,141 +25,160 @@
</div>
</template>
<script>
import { mapState } from 'vuex'
import env from '@/lib/env'
import 'vue-awesome/icons/thumbtack'
<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import env from '../useful/env'
import SvgIcon from './SvgIcon.vue'
const isElectron = env.isElectron
export default {
export default defineComponent({
name: 'title-bar',
data () {
return {
win: null,
isMaximized: false,
isAlwaysOnTop: false,
isFocused: false,
}
},
mounted () {
if (!isElectron) {
window.onbeforeunload = () => {
return !this.saved || null
components: { SvgIcon },
setup () {
const store = useStore()
const { currentFile, savedAt, currentContent, previousContent } = toRefs(store.state)
const saved = computed(() => currentContent.value === previousContent.value)
let win: any = null
const hasWin = ref(false)
const isMaximized = ref(false)
const isAlwaysOnTop = ref(false)
const isFocused = ref(false)
function updateWindowStatus () {
if (win) {
isMaximized.value = win.isMaximized()
isAlwaysOnTop.value = win.isAlwaysOnTop()
isFocused.value = win.isFocused()
}
}
if (isElectron && env.require) {
this.win = env.require('electron').remote.getCurrentWindow()
this.updateWindowStatus()
this.win.on('maximize', this.updateWindowStatus)
this.win.on('restore', this.updateWindowStatus)
this.win.on('unmaximize', this.updateWindowStatus)
this.win.on('always-on-top-changed', this.updateWindowStatus)
this.win.on('focus', this.updateWindowStatus)
this.win.on('blur', this.updateWindowStatus)
}
},
beforeDestroy () {
if (!isElectron) {
window.onbeforeunload = null
function toggleAlwaysOnTop () {
win && win.setAlwaysOnTop(!win.isAlwaysOnTop())
}
if (this.win) {
this.win.removeListener('maximize', this.updateWindowStatus)
this.win.removeListener('restore', this.updateWindowStatus)
this.win.removeListener('unmaximize', this.updateWindowStatus)
this.win.removeListener('minimize', this.updateWindowStatus)
this.win.removeListener('always-on-top-changed', this.updateWindowStatus)
this.win.removeListener('focus', this.updateWindowStatus)
this.win.removeListener('blur', this.updateWindowStatus)
function unmaximize () {
win && win.unmaximize()
}
this.win = null
},
computed: {
...mapState('app', ['currentFile', 'savedAt', 'previousContent', 'currentContent']),
titleBarStyles () {
if (isElectron && !this.isFocused) {
return { background: '#818181' }
function minimize () {
win && win.minimize()
}
function maximize () {
// 最大化后取消窗口置顶
win && win.maximize()
win && win.setAlwaysOnTop(false)
}
function close () {
win && win.close()
}
onMounted(() => {
if (!isElectron) {
window.onbeforeunload = () => {
return !saved.value || null
}
}
if (!this.saved) {
return { background: '#ff9800ad' }
if (isElectron && env.require) {
win = env.require('electron').remote.getCurrentWindow()
hasWin.value = true
updateWindowStatus()
win.on('maximize', updateWindowStatus)
win.on('restore', updateWindowStatus)
win.on('unmaximize', updateWindowStatus)
win.on('always-on-top-changed', updateWindowStatus)
win.on('focus', updateWindowStatus)
win.on('blur', updateWindowStatus)
}
})
onBeforeUnmount(() => {
if (!isElectron) {
window.onbeforeunload = null
}
return null
},
saved () {
return this.previousContent === this.currentContent
},
status () {
if (this.savedAt === null && this.currentFile) {
return '加载完毕'
} else if (this.savedAt) {
return '保存于:' + this.savedAt.toLocaleString()
if (win) {
win.removeListener('maximize', updateWindowStatus)
win.removeListener('restore', updateWindowStatus)
win.removeListener('unmaximize', updateWindowStatus)
win.removeListener('minimize', updateWindowStatus)
win.removeListener('always-on-top-changed', updateWindowStatus)
win.removeListener('focus', updateWindowStatus)
win.removeListener('blur', updateWindowStatus)
}
return ''
},
statusText () {
const file = this.currentFile
win = null
hasWin.value = false
})
const statusText = computed(() => {
let status = ''
if (savedAt.value === null && currentFile.value) {
status = '加载完毕'
} else if (savedAt.value) {
status = '保存于:' + savedAt.value.toLocaleString()
}
const file = currentFile.value
if (file) {
if (file.repo === '__help__') {
return file.title
}
if (file.path && file.repo) {
return `${file.path}-${this.status} [${file.repo}]`
return `${file.path}-${status} [${file.repo}]`
} else {
return file.name
}
} else {
return '未打开文件'
}
})
const titleBarStyles = computed(() => {
if (isElectron && !isFocused.value) {
return { background: '#818181' }
}
if (!saved.value) {
return { background: '#ff9800ad' }
}
return null
})
watch(statusText, () => {
document.title = currentFile.value ? (currentFile.value.name || currentFile.value.title || 'Yank Note') : '未打开文件'
}, { immediate: true })
watch(saved, val => {
// 暴露文档保存状态给 Electron 用
(window as any).documentSaved = val
}, { immediate: true })
return {
hasWin,
isMaximized,
isAlwaysOnTop,
isFocused,
toggleAlwaysOnTop,
maximize,
unmaximize,
minimize,
close,
statusText,
titleBarStyles,
}
},
watch: {
statusText: {
immediate: true,
handler (val) {
document.title = this.currentFile ? (this.currentFile.name || this.currentFile.title || 'Yank Note') : '未打开文件'
},
},
saved: {
immediate: true,
handler (val) {
window.documentSaved = val
},
},
},
methods: {
updateWindowStatus () {
if (this.win) {
this.isMaximized = this.win.isMaximized()
this.isAlwaysOnTop = this.win.isAlwaysOnTop()
this.isFocused = this.win.isFocused()
}
},
toggleAlwaysOnTop () {
this.win && this.win.setAlwaysOnTop(!this.win.isAlwaysOnTop())
},
unmaximize () {
this.win && this.win.unmaximize()
},
minimize () {
this.win && this.win.minimize()
},
maximize () {
// 最大化后取消窗口置顶
this.win && this.win.maximize()
this.win && this.win.setAlwaysOnTop(false)
},
close () {
this.win && this.win.close()
},
},
}
})
</script>
<style scoped>
@@ -260,8 +279,4 @@ export default {
.action .btn.pin.ontop {
background-color: hsla(0, 0%, 100%, .3)
}
.action .btn.pin .pin-icon {
fill: hsla(0, 0%, 100%, .5)
}
</style>
+22 -13
View File
@@ -2,24 +2,33 @@
<div v-if="toast" :class="{toast: true, [`toast-${toast.type}`]: true}">{{toast.content}}</div>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { Components } from '../types'
interface ToastData {
type: Components.Toast.ToastType;
content: string;
}
export default defineComponent({
name: 'toast',
data () {
return {
toast: null
}
},
methods: {
show (type, content, timeout = 2000) {
this.toast = { type, content }
setup () {
const toast = ref<ToastData | null>()
function show (type: Components.Toast.ToastType, content: string, timeout = 2000) {
toast.value = { type, content }
setTimeout(() => {
this.toast = null
toast.value = null
}, timeout)
},
}
return { toast, show }
},
}
methods: {
},
})
</script>
<style scoped>
+56 -49
View File
@@ -1,5 +1,5 @@
<template>
<aside class="side" @contextmenu.exact.prevent="showContextMenu" @dblclick="refresh();refreshRepo()" title="双击此处刷新目录树">
<aside class="side" @contextmenu.exact.prevent="showContextMenu" @dblclick="refreshRepo" title="双击此处刷新目录树">
<div class="loading" v-if="tree === null"> 加载中 </div>
<template v-else>
<TreeNode v-for="item in tree" :item="item" :key="item.path" />
@@ -7,65 +7,72 @@
</aside>
</template>
<script>
import { mapState } from 'vuex'
import TreeNode from './TreeNode'
<script lang="ts">
import { defineComponent, onBeforeMount, onBeforeUnmount, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import { useBus } from '../useful/bus'
import { useContextMenu } from '../useful/context-menu'
import TreeNode from './TreeNode.vue'
export default {
export default defineComponent({
name: 'tree',
components: { TreeNode },
data () {
return {
setup () {
const bus = useBus()
const store = useStore()
const contextMenu = useContextMenu()
const { currentRepo, tree } = toRefs(store.state)
function refreshTree () {
store.dispatch('fetchTree', currentRepo.value)
}
},
created () {
this.$bus.on('file-created', this.refresh)
this.$bus.on('file-moved', this.refresh)
this.$bus.on('file-deleted', this.refresh)
this.$bus.on('file-uploaded', this.refresh)
this.$bus.on('file-marked', this.refresh)
this.$bus.on('file-unmarked', this.refresh)
this.$bus.on('tree-refresh', this.refresh)
this.refresh()
},
beforeDestroy () {
this.$bus.off('file-created', this.refresh)
this.$bus.off('file-moved', this.refresh)
this.$bus.off('file-deleted', this.refresh)
this.$bus.off('file-uploaded', this.refresh)
this.$bus.off('file-marked', this.refresh)
this.$bus.off('file-unmarked', this.refresh)
this.$bus.off('tree-refresh', this.refresh)
},
methods: {
showContextMenu () {
this.$contextMenu.show([
function refreshRepo () {
refreshTree()
store.dispatch('fetchRepositories')
}
function showContextMenu () {
contextMenu.show([
{
id: 'refresh',
label: '刷新目录树',
onClick: () => {
this.refresh()
this.refreshRepo()
}
onClick: refreshRepo
}
])
},
refresh () {
this.$store.dispatch('app/fetchTree', this.currentRepo)
},
refreshRepo () {
this.$store.dispatch('app/fetchRepositories')
}
onBeforeMount(() => {
bus.on('file-created', refreshTree)
bus.on('file-moved', refreshTree)
bus.on('file-deleted', refreshTree)
bus.on('file-uploaded', refreshTree)
bus.on('file-marked', refreshTree)
bus.on('file-unmarked', refreshTree)
bus.on('tree-refresh', refreshTree)
refreshTree()
})
onBeforeUnmount(() => {
bus.off('file-created', refreshTree)
bus.off('file-moved', refreshTree)
bus.off('file-deleted', refreshTree)
bus.off('file-uploaded', refreshTree)
bus.off('file-marked', refreshTree)
bus.off('file-unmarked', refreshTree)
bus.off('tree-refresh', refreshTree)
})
watch(currentRepo, refreshTree)
return {
tree,
refreshRepo,
showContextMenu,
}
},
watch: {
currentRepo () {
this.refresh()
}
},
computed: {
...mapState('app', ['currentRepo', 'tree']),
}
}
})
</script>
<style scoped>
+240 -194
View File
@@ -1,6 +1,6 @@
<template>
<div class="tree-node">
<details ref="dir" @keydown.enter.prevent v-if="item.type === 'dir'" class="name" :title="item.name + '\n\n' + dirTitle" :open="item.path === '/'">
<details ref="refDir" @keydown.enter.prevent v-if="item.type === 'dir'" class="name" :title="item.name + '\n\n' + dirTitle" :open="item.path === '/'">
<summary
class="folder"
:style="{background: selected ? '#313131' : 'none'}"
@@ -14,17 +14,14 @@
{{ item.name === '/' ? currentRepoName : item.name }} <span class="count">({{item.children.length}})</span>
</div>
<div class="item-action">
<y-icon class="icon" name="folder-plus" @click.native.exact.stop.prevent="createFile()" title="创建文件"></y-icon>
<!-- <EditIcon class="icon" @click.native.exact.stop.prevent="renameFile" title="重命名/移动(Ctrl + 右键)"></EditIcon>
<ShareIcon class="icon" @click.native.exact.stop.prevent="revealInExplorer" title="系统中打开(Ctrl + 单击)"></ShareIcon>
<TrashIcon class="icon" @click.native.exact.stop.prevent="deleteFile" title="删除(Shift + 右键)"></TrashIcon> -->
<svg-icon class="icon" color="hsla(0, 0%, 100%, .5)" name="folder-plus-solid" @click.exact.stop.prevent="createFile()" title="创建文件"></svg-icon>
</div>
</div>
</summary>
<tree-node v-for="x in item.children" :key="x.path" :item="x"></tree-node>
</details>
<div
ref="file"
ref="refFile"
v-else
:class="{name: true, 'file-name': true, selected}"
:title="item.name + '\n\n' + fileTitle"
@@ -34,139 +31,60 @@
@contextmenu.ctrl.exact.prevent.stop="renameFile"
@contextmenu.shift.exact.prevent.stop="deleteFile">
<div class="item">
<div :class="{'item-label': true, marked: !!item.marked}"> {{ item.name }} </div>
<div :class="{'item-label': true, marked}"> {{ item.name }} </div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import File from '@/lib/file'
import Extensions from '@/lib/extensions'
import DrawioPlugin from '@/plugins/DrawioPlugin'
import 'vue-awesome/icons/folder-plus'
<script lang="ts">
import { computed, defineComponent, nextTick, ref, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import { useBus } from '../useful/bus'
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 SvgIcon from './SvgIcon.vue'
import DrawioPlugin from '../plugins/DrawioPlugin'
export default {
export default defineComponent({
name: 'tree-node',
props: {
item: Object,
},
data () {
return {
dirTitle: [
'"Ctrl + 右键" 重命名目录',
'"Shift + 右键" 删除目录',
'"Ctrl + 单击" 在操作系统中打开目录',
'"Ctrl + Alt + 单击" 在终端中打开'
].join('\n'),
fileTitle: [
`创建于:${this.item.birthtime ? new Date(this.item.birthtime).toLocaleString() : '无'}`,
`更新于:${this.item.mtime ? new Date(this.item.mtime).toLocaleString() : '无'}`,
'',
'"Ctrl + 右键" 重命名文件',
'"Shift + 右键" 删除文件',
'"Ctrl + 单击" 使用系统程序打开文件',
'".c.md" 结尾的文件为加密文件'
].join('\n')
}
},
methods: {
showContextMenu (item) {
const menu = [
{ id: 'rename', label: '重命名 / 移动', onClick: () => this.renameFile() },
{ id: 'delete', label: '删除', onClick: () => this.deleteFile() },
{ type: 'separator' },
{ id: 'openInOS', label: '在系统中打开', onClick: () => this.revealInExplorer() },
{ id: 'openInOS', label: '刷新目录树', onClick: () => this.$bus.emit('tree-refresh') },
]
components: { SvgIcon },
props: ['item'],
setup (props) {
const store = useStore()
const bus = useBus()
const toast = useToast()
const modal = useModal()
const contextMenu = useContextMenu()
if (item.type === 'dir') {
this.$contextMenu.show([
{ id: 'create', label: '创建新文件', onClick: () => this.createFile() }
].concat(menu).concat([
{ id: 'openInTerminal', label: '在终端中打开', onClick: () => this.revealInXterminal() }
]))
} else {
// markdown 文件可以被标记
if (this.item.path.endsWith('.md')) {
const additional = [
{ id: 'mark', label: this.item.marked ? '取消标记' : '标记文件', onClick: () => this.toggleMark() },
]
const refDir = ref<any>(null)
const refFile = ref<any>(null)
const localMarked = ref<boolean | null>(null)
// 非加密文件增加复制重复菜单
if (!File.isEncryptedFile(this.item)) {
additional.push({ id: 'duplicate', label: '重复文件', onClick: () => this.duplicateFile() })
}
watch(() => props.item, () => {
localMarked.value = null
})
this.$contextMenu.show(additional.concat(menu, [{ id: 'create', label: '当前目录创建新文件', onClick: () => this.createFile() }]))
} else {
this.$contextMenu.show(menu)
}
}
},
async duplicateFile () {
let newPath = await this.$modal.input({
title: '重复文件',
hint: '目标路径',
content: '当前路径:' + this.item.path,
value: this.item.path,
// 默认选中文件名
select: [this.item.path.lastIndexOf('/') + 1, this.item.name.lastIndexOf('.') > -1 ? this.item.path.lastIndexOf('.') : this.item.path.length, 'forward']
})
const { currentFile, currentRepo } = toRefs(store.state)
if (!newPath) {
return
}
newPath = newPath.replace(/\/$/, '')
const { content } = await File.read({ path: this.item.path, repo: this.item.repo })
await this.createFile(newPath, content)
},
async toggleMark () {
if (this.item.marked) {
this.item.marked = false
await File.unmark(this.item)
this.$bus.emit('file-unmarked', this.item)
} else {
this.item.marked = true
await File.mark(this.item)
this.$bus.emit('file-marked', this.item)
}
},
select (item) {
if (item.type !== 'dir') {
if (Extensions.supported(item.name)) {
this.$store.commit('app/setCurrentFile', item)
} else {
if (item.path.toLowerCase().endsWith('.drawio')) {
DrawioPlugin.open(item)
} else {
File.openInOS(this.item)
}
}
}
},
revealInExplorer () {
File.openInOS(this.item)
},
revealInXterminal () {
const path = this.currentRepo ? this.currentRepo.path + this.item.path : ''
this.$bus.emit('xterm-run', `--yank-note-run-command-cd-- ${path}`)
},
async createFile (path = null, content = null) {
async function createFile (path: string | null = null, content: string | null = null) {
if (path === null) {
const currentPath = this.item.type === 'dir' ? this.item.path : File.dirname(this.item.path)
const currentPath = props.item.type === 'dir' ? props.item.path : File.dirname(props.item.path)
let filename = await this.$modal.input({
let filename = await modal.input({
title: '创建文件(加密文件以 .c.md 结尾)',
hint: '文件路径',
content: '当前路径:' + currentPath,
value: 'new.md'
value: 'new.md',
select: true
})
console.log('filename', filename)
if (!filename) {
return
}
@@ -186,7 +104,7 @@ export default {
const file = {
type: 'file',
repo: this.item.repo,
repo: props.item.repo,
path,
name: filename,
}
@@ -195,21 +113,17 @@ export default {
content = `# ${filename.replace(/\.md$/i, '')}\n`
}
this.$bus.emit('file-new', { file, content })
},
async renameFile () {
if (this.item.path === '/') {
this.$toast.show('warning', '不能移动根目录')
return
}
bus.emit('file-new', { file, content })
}
let newPath = await this.$modal.input({
title: '移动文件',
hint: '新的路径',
content: '当前路径' + this.item.path,
value: this.item.path,
async function duplicateFile () {
let newPath = await modal.input({
title: '重复文件',
hint: '目标路径',
content: '当前路径:' + props.item.path,
value: props.item.path,
// 默认选中文件名
select: [this.item.path.lastIndexOf('/') + 1, this.item.name.lastIndexOf('.') > -1 ? this.item.path.lastIndexOf('.') : this.item.path.length, 'forward']
select: [props.item.path.lastIndexOf('/') + 1, props.item.name.lastIndexOf('.') > -1 ? props.item.path.lastIndexOf('.') : props.item.path.length, 'forward']
})
if (!newPath) {
@@ -217,97 +131,228 @@ export default {
}
newPath = newPath.replace(/\/$/, '')
const oldPath = this.item.path.replace(/\/$/, '')
const { content } = await File.read({ path: props.item.path, repo: props.item.repo })
await createFile(newPath, content)
}
async function toggleMark () {
if (props.item.marked) {
localMarked.value = false
await File.unmark(props.item)
bus.emit('file-unmarked', props.item)
} else {
localMarked.value = true
await File.mark(props.item)
bus.emit('file-marked', props.item)
}
}
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 {
File.openInOS(props.item)
}
}
}
}
function revealInExplorer () {
File.openInOS(props.item)
}
function revealInXterminal () {
const path = currentRepo.value ? currentRepo.value.path + props.item.path : ''
bus.emit('xterm-run', `--yank-note-run-command-cd-- ${path}`)
}
async function renameFile () {
if (props.item.path === '/') {
toast.show('warning', '不能移动根目录')
return
}
let newPath = await modal.input({
title: '移动文件',
hint: '新的路径',
content: '当前路径:' + props.item.path,
value: props.item.path,
// 默认选中文件名
select: [props.item.path.lastIndexOf('/') + 1, props.item.name.lastIndexOf('.') > -1 ? props.item.path.lastIndexOf('.') : props.item.path.length, 'forward']
})
if (!newPath) {
return
}
newPath = newPath.replace(/\/$/, '')
const oldPath = props.item.path.replace(/\/$/, '')
if (newPath === oldPath) {
return
}
// TODO 文件统一处理
// this.$bus.emit('file-move', {file, newPath})
// bus.emit('file-move', {file, newPath})
const newFile = {
name: File.basename(newPath),
path: newPath,
repo: this.item.repo,
type: this.item.type
repo: props.item.repo,
type: props.item.type
}
await File.move(this.item, newPath)
await File.move(props.item, newPath)
// 重命名当前文件或父目录后,切换到新位置
if (this.currentFile && (File.isSameFile(this.item, this.currentFile) || (this.item.type === 'dir' && File.isBelongTo(this.item.path, this.currentFile.path)))) {
if (currentFile.value && (File.isSameFile(props.item, currentFile.value) || (props.item.type === 'dir' && File.isBelongTo(props.item.path, currentFile.value.path)))) {
if (newFile.type === 'file') {
this.$store.commit('app/setCurrentFile', newFile)
store.commit('setCurrentFile', newFile)
} else {
// TODO 切换到新位置
this.$store.commit('app/setCurrentFile', null)
store.commit('setCurrentFile', null)
}
}
this.$bus.emit('file-moved', newFile)
},
async deleteFile () {
if (this.item.path === '/') {
this.$toast.show('warning', '不能删除根目录')
bus.emit('file-moved', newFile)
}
async function deleteFile () {
if (props.item.path === '/') {
toast.show('warning', '不能删除根目录')
return
}
const confirm = await this.$modal.confirm({ title: '删除文件', content: `确定要删除 [${this.item.path}] 吗?` })
const confirm = await modal.confirm({ title: '删除文件', content: `确定要删除 [${props.item.path}] 吗?` })
if (confirm) {
await File.delete(this.item)
await File.delete(props.item)
// 删除当前文件或父目录后,关闭当前文件
if (this.currentFile && (File.isSameFile(this.item, this.currentFile) || (this.item.type === 'dir' && File.isBelongTo(this.item.path, this.currentFile.path)))) {
this.$store.commit('app/setCurrentFile', null)
if (currentFile.value && (File.isSameFile(props.item, currentFile.value) || (props.item.type === 'dir' && File.isBelongTo(props.item.path, currentFile.value.path)))) {
store.commit('setCurrentFile', null)
}
this.$bus.emit('file-deleted', this.item)
bus.emit('file-deleted', props.item)
}
},
},
computed: {
currentRepoName () {
return this.currentRepo ? this.currentRepo.name : '/'
},
...mapState('app', ['currentFile', 'currentRepo']),
selected () {
if (!this.currentFile) {
}
function showContextMenu (item: any) {
const menu: Components.ContextMenu.Item[] = [
{ id: 'delete', label: '删除', onClick: () => deleteFile() },
{ id: 'rename', label: '重命名 / 移动', onClick: () => renameFile() },
{ type: 'separator' },
{ id: 'openInOS', label: '在系统中打开', onClick: () => revealInExplorer() },
{ id: 'openInOS', label: '刷新目录树', onClick: () => bus.emit('tree-refresh') },
]
if (item.type === 'dir') {
contextMenu.show([
{ id: 'create', label: '创建新文件', onClick: () => createFile() },
...menu,
{ id: 'openInTerminal', label: '在终端中打开', onClick: () => revealInXterminal() }
])
} else {
// markdown 文件可以被标记
if (props.item.path.endsWith('.md')) {
const additional: Components.ContextMenu.Item[] = [
{ id: 'mark', label: props.item.marked ? '取消标记' : '标记文件', onClick: () => toggleMark() },
]
// 非加密文件增加复制重复菜单
if (!File.isEncryptedFile(props.item)) {
additional.push({ id: 'duplicate', label: '重复文件', onClick: () => duplicateFile() })
}
contextMenu.show([
...additional,
...menu,
{ id: 'create', label: '当前目录创建新文件', onClick: () => createFile() }
])
} else {
contextMenu.show(menu)
}
}
}
const currentRepoName = computed(() => currentRepo.value?.name ?? '/')
const selected = computed(() => {
if (!currentFile.value) {
return false
}
if (this.item.type === 'dir') {
return this.currentFile.repo === this.item.repo && this.currentFile.path.startsWith(this.item.path + '/')
if (props.item.type === 'dir') {
return currentFile.value.repo === props.item.repo && currentFile.value.path.startsWith(props.item.path + '/')
}
return this.currentFile.repo === this.item.repo && this.currentFile.path === this.item.path
},
shouldOpen () {
return this.item.type === 'dir' && this.currentFile && this.currentFile.path.startsWith(this.item.path + '/') && this.currentFile.repo === this.item.repo
return currentFile.value.repo === props.item.repo && currentFile.value.path === props.item.path
})
const shouldOpen = computed(() => {
return props.item.type === 'dir' && currentFile.value && currentFile.value.path.startsWith(props.item.path + '/') && currentFile.value.repo === props.item.repo
})
const marked = computed(() => localMarked.value ?? props.item.marked)
watch(selected, val => {
if (val && props.item.type === 'file') {
nextTick(() => {
refFile.value.scrollIntoViewIfNeeded()
})
}
}, { immediate: true })
watch(shouldOpen, val => {
if (val) {
nextTick(() => {
refDir.value.open = true
})
}
}, { immediate: true })
const dirTitle = computed(() => [
'"Ctrl + 右键" 重命名目录',
'"Shift + 右键" 删除目录',
'"Ctrl + 单击" 在操作系统中打开目录',
'"Ctrl + Alt + 单击" 在终端中打开'
].join('\n'))
const fileTitle = computed(() => [
`创建于:${props.item.birthtime ? new Date(props.item.birthtime).toLocaleString() : '无'}`,
`更新于:${props.item.mtime ? new Date(props.item.mtime).toLocaleString() : '无'}`,
'',
'"Ctrl + 右键" 重命名文件',
'"Shift + 右键" 删除文件',
'"Ctrl + 单击" 使用系统程序打开文件',
'".c.md" 结尾的文件为加密文件'
].join('\n'))
return {
refDir,
refFile,
dirTitle,
fileTitle,
currentRepoName,
selected,
marked,
showContextMenu,
duplicateFile,
toggleMark,
select,
revealInExplorer,
revealInXterminal,
createFile,
renameFile,
deleteFile
}
},
watch: {
selected: {
immediate: true,
handler (val) {
if (val && this.item.type === 'file') {
this.$nextTick(() => {
this.$refs.file.scrollIntoViewIfNeeded()
})
}
}
},
shouldOpen: {
immediate: true,
handler (val) {
if (val) {
this.$nextTick(() => {
this.$refs.dir.open = true
})
}
}
}
}
}
})
</script>
<style scoped>
@@ -365,14 +410,15 @@ summary > .item {
padding: 0 2px;
display: flex;
align-items: center;
height: 22px;
height: 14px;
width: 14px;
border-radius: 2px;
fill: #999999;
color: #999999;
}
.item-action .icon:hover {
background: #757575;
fill: #eee;
color: #eee;
}
.item:hover .item-action {
+85 -62
View File
@@ -1,35 +1,45 @@
<template>
<div class="xterm">
<div ref="xterm" style="height: 100%"></div>
<div ref="refXterm" style="height: 100%"></div>
</div>
</template>
<script>
<script lang="ts">
import io from 'socket.io-client'
import { defineComponent, nextTick, onBeforeMount, onBeforeUnmount, ref } from 'vue'
import { useStore } from 'vuex'
import { Terminal } from 'xterm'
import * as fit from 'xterm/lib/addons/fit/fit'
import 'xterm/src/xterm.css'
import { FitAddon } from 'xterm-addon-fit'
import { useBus } from '../useful/bus'
Terminal.applyAddon(fit)
let xterm = null
let socket = null
export default {
export default defineComponent({
name: 'xterm',
created () {
window.runInXterm = this.runInXterm
this.$bus.on('xterm-run', this.handleRunInXterm)
this.$bus.on('xterm-init', this.init)
},
beforeDestroy () {
window.runInXterm = null
this.$bus.off('xterm-init', this.init)
this.$bus.off('xterm-run', this.handleRunInXterm)
this.$bus.off('resize', this.handleRunInXterm)
},
methods: {
init () {
setup () {
const bus = useBus()
const store = useStore()
const refXterm = ref<HTMLElement | null>(null)
let xterm: Terminal | null = null
let socket: SocketIOClient.Socket | null = null
const fitAddon = new FitAddon()
function fitXterm () {
if (store.state.showXterm) {
fitAddon.fit()
}
}
function input (data: string) {
socket!!.emit('input', data)
}
function focus () {
xterm!!.focus()
}
function init () {
if (!xterm) {
xterm = new Terminal({
cols: 80,
@@ -43,20 +53,22 @@ export default {
}
})
xterm.open(this.$refs.xterm)
xterm.fit()
this.$bus.on('resize', this.fitXterm)
xterm.loadAddon(fitAddon)
xterm.open(refXterm.value!!)
fitAddon.fit()
bus.on('resize', fitXterm)
}
if (!socket) {
socket = io({ path: '/ws' })
xterm.on('resize', size => socket.emit('resize', [size.cols, size.rows]))
xterm.on('data', data => this.input(data))
socket.on('output', arrayBuffer => xterm.write(arrayBuffer))
xterm.onResize(size => socket!!.emit('resize', [size.cols, size.rows]))
xterm.onData(input)
socket.on('output', (arrayBuffer: any) => xterm!!.write(arrayBuffer))
socket.on('disconnect', () => {
xterm.clear()
this.$bus.emit('toggle-xterm', false)
xterm!!.clear()
bus.emit('toggle-xterm', false)
})
}
@@ -64,26 +76,15 @@ export default {
socket.connect()
}
this.focus()
},
fitXterm () {
xterm.fit()
},
input (data) {
socket.emit('input', data)
},
focus () {
xterm.focus()
},
handleRunInXterm (code) {
this.runInXterm(null, code, false)
},
runInXterm (language, code, exit = true) {
this.$bus.emit('toggle-xterm', true)
this.$nextTick(() => {
this.init()
focus()
}
const map = {
function runInXterm (language: string, code: string, exit = true) {
bus.emit('toggle-xterm', true)
nextTick(() => {
init()
const map: {[key: string]: {start: string; exit: string; eol: string}} = {
bat: { start: 'cmd.exe', exit: 'exit', eol: '\r\n' },
bash: { start: 'bash', exit: 'exit', eol: '\n' },
php: { start: 'php -a', exit: 'exit', eol: '\n' },
@@ -92,38 +93,60 @@ export default {
js: { start: 'node', exit: '.exit', eol: '\n' }
}
const run = (code, eol) => {
code.split('\n').forEach((x, i) => {
this.input(x.trim())
this.input(eol)
const run = (code: string, eol: string) => {
code.split('\n').forEach(x => {
input(x.trim())
input(eol)
})
}
if (!language) {
if (!language || language === '_') {
run(code, '\n')
} else if (map[language]) {
this.input(map[language].start)
this.input(map[language].eol)
input(map[language].start)
input(map[language].eol)
// 延迟一下等待子进程启动
setTimeout(() => {
run(code, map[language].eol)
if (exit) {
this.input(map[language].exit)
this.input(map[language].eol)
input(map[language].exit)
input(map[language].eol)
}
}, 400)
} else {
xterm.write(`不支持 ${language} 语言\n`)
xterm!!.write(`不支持 ${language} 语言\n`)
}
})
}
}
}
function handleRunInXterm (code?: string) {
runInXterm('_', code || '', false)
}
onBeforeMount(() => {
(window as any).runInXterm = runInXterm
bus.on('xterm-run', handleRunInXterm)
bus.on('xterm-init', init)
})
onBeforeUnmount(() => {
(window as any).runInXterm = null
bus.off('xterm-init', init)
bus.off('xterm-run', handleRunInXterm)
})
return {
refXterm,
}
},
})
</script>
<style scoped>
@import url('~xterm/css/xterm.css');
.xterm {
box-sizing: border-box;
padding: 5px;
-11
View File
@@ -1,11 +0,0 @@
import Menu from '@/components/ContextMenu'
export default {
install (Vue, options) {
const MenuInstance = Vue.extend(Menu)
const instance = new MenuInstance()
document.body.appendChild(instance.$mount().$el)
Vue.prototype.$contextMenu = instance
}
}
-26
View File
@@ -1,26 +0,0 @@
const nodeProcess = window && (window.process || window.nodeProcess)
const nodeModule = window && (window.module || window.nodeModule)
const nodeRequire = window && (window.require || window.nodeRequire)
const isElectron = !!(nodeProcess && nodeProcess.versions && nodeProcess.versions['electron'])
const openAlwaysOnTopWindow = (url, target = '_blank') => {
if (isElectron) {
const opener = window.open(url, target, 'frame=true,alwaysOnTop=true')
// 让弹出的窗口置顶
opener.eval(`require('electron').remote.getCurrentWindow().setAlwaysOnTop(true)`)
return opener
} else {
return window.open(url, target)
}
}
export default {
process: nodeProcess,
module: nodeModule,
require: nodeRequire,
isElectron,
openAlwaysOnTopWindow,
}
-27
View File
@@ -1,27 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import Icon from 'vue-awesome/components/Icon'
import router from './router'
import store from './store/index'
import modal from './modal'
import contextMenu from './contextMenu'
import toast from './toast'
Vue.config.productionTip = false
Vue.use(modal)
Vue.use(contextMenu)
Vue.use(toast)
Vue.component('y-icon', Icon)
window.$args = () => {
return new URLSearchParams(location.search)
}
const appVm = new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
window.appVm = appVm
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import bus from './useful/bus'
import toast from './useful/toast'
import modal from './useful/modal'
import contextmenu from './useful/context-menu'
const app = createApp(App)
app.use(bus)
app.use(store)
app.use(router)
app.use(toast)
app.use(modal)
app.use(contextmenu)
app.mount('#app')
-11
View File
@@ -1,11 +0,0 @@
import ModalUi from '@/components/ModalUi'
export default {
install (Vue, options) {
const ModalUiInstance = Vue.extend(ModalUi)
const instance = new ModalUiInstance()
document.body.appendChild(instance.$mount().$el)
Vue.prototype.$modal = instance
}
}
-73
View File
@@ -1,73 +0,0 @@
<template>
<div>
<Layout>
<TitleBar slot="header"></TitleBar>
<StatusBar slot="footer"></StatusBar>
<Tree slot="left"></Tree>
<Xterm slot="terminal"></Xterm>
<template slot="editor">
<FileTabs />
<Editor ref="editor" @scroll-line="line => $refs.preview.revealLine(line)" />
</template>
<Preview
ref="preview"
slot="preview"
@sync-scroll="line => $refs.editor.revealLineInCenter(line)"
@switch-todo="(line, checked) => $refs.editor.switchTodo(line, checked)" />
</Layout>
<XFilter />
</div>
</template>
<script>
import { mapState } from 'vuex'
import Layout from '@/components/Layout'
import TitleBar from '@/components/TitleBar'
import FileTabs from '@/components/FileTabs'
import StatusBar from '@/components/StatusBar'
import Tree from '@/components/Tree'
import Editor from '@/components/Editor'
import Preview from '@/components/Preview'
import RunPlugin from '../plugins/RunPlugin'
import XFilter from '@/components/Filter'
import Xterm from '@/components/Xterm'
export default {
name: 'x-main',
components: { Layout, TitleBar, StatusBar, Tree, Editor, Preview, XFilter, Xterm, FileTabs },
mounted () {
RunPlugin.clearCache()
this.$bus.on('editor-ready', this.init)
this.$bus.on('copy-text', this.copyText)
},
beforeDestroy () {
this.$bus.off('editor-ready', this.init)
this.$bus.off('copy-text', this.copyText)
},
methods: {
copyText (text) {
const input = document.createElement('input')
input.style.position = 'absolute'
input.style.background = 'red'
input.style.left = '-999999px'
input.style.top = '-999999px'
input.style.zIndex = -1000
input.style.opacity = 0
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
this.$toast.show('info', '已复制')
},
init () {
if (!this.currentFile) {
this.$store.dispatch('app/showHelp', 'README.md')
}
}
},
computed: {
...mapState('app', ['currentFile'])
}
}
</script>
@@ -1,9 +1,10 @@
import Vue from 'vue'
import CryptoJS from 'crypto-js'
import dayjs from 'dayjs'
import Markdown from 'markdown-it'
import { useBus } from '@/useful/bus'
const RunPlugin = (md) => {
const temp = md.renderer.rules.fence.bind(md.renderer.rules)
const RunPlugin = (md: Markdown) => {
const temp = md.renderer.rules.fence!!.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
@@ -32,7 +33,7 @@ const RunPlugin = (md) => {
}
}
RunPlugin.runScript = (el) => {
RunPlugin.runScript = (el: HTMLElement) => {
const appletId = el.id
const appletCode = el.dataset.code
if (!appletCode) {
@@ -111,28 +112,29 @@ RunPlugin.runScript = (el) => {
${appletCode}
`
const appVm = window.appVm
const bus = useBus()
iframe.className = 'applet-iframe'
iframe.frameBorder = '0'
iframe.width = '100%'
iframe.onload = function () {
const resize = () => {
this.height = this.contentDocument.documentElement.scrollHeight + 'px'
appVm.$bus.emit('resize')
iframe.height = iframe.contentDocument!!.documentElement.scrollHeight + 'px'
bus.emit('resize')
}
resize()
const win = iframe.contentWindow as any
// 注入变量
this.contentWindow.resize = resize
this.contentWindow.appletFrame = this
this.contentWindow.appletId = appletId
this.contentWindow.Vue = Vue
this.contentWindow.CryptoJS = CryptoJS
this.contentWindow.dayjs = dayjs
win.resize = resize
win.appletFrame = this
win.appletId = appletId
win.CryptoJS = CryptoJS
win.dayjs = dayjs
// 调用初始化方法
this.contentWindow.init && this.contentWindow.init()
win.init && win.init()
}
el.innerHTML = ''
el.appendChild(iframe)
-12
View File
@@ -1,12 +0,0 @@
export default (md) => {
const Fun = fn => (tokens, idx, options, env, slf) => {
if (tokens[idx].attrIndex('title') < 0) {
tokens[idx].attrPush(['title', 'Ctrl + 单击复制代码'])
tokens[idx].attrPush(['onclick', `event.ctrlKey && window.appVm.$bus.emit('copy-text', this.innerText)`])
}
return (fn)(tokens, idx, options, env, slf)
}
md.renderer.rules.code_inline = Fun(md.renderer.rules.code_inline.bind(md.renderer.rules))
}
+14
View File
@@ -0,0 +1,14 @@
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].attrPush(['title', 'Ctrl + 单击复制代码'])
tokens[idx].attrPush(['onclick', "event.ctrlKey && window.globalBus.emit('copy-text', this.innerText)"])
}
return (fn)(tokens, idx, options, env, slf)
}
md.renderer.rules.code_inline = Fun(md.renderer.rules.code_inline!!.bind(md.renderer.rules))
}
@@ -1,15 +1,17 @@
import file from '@/lib/file'
import env from '@/lib/env'
import Markdown from 'markdown-it'
import file from '@/useful/file'
import env from '@/useful/env'
import { useBus } from '@/useful/bus'
const Plugin = md => {
const renderHtml = ({ file, content }) => {
const Plugin = (md: Markdown) => {
const renderHtml = ({ url, content }: any) => {
const iframe = document.createElement('iframe')
iframe.className = 'drawio'
iframe.frameBorder = '0'
iframe.style.border = '0'
iframe.width = '100%'
iframe.height = '300px'
iframe.dataset['file'] = file || ''
iframe.dataset['content'] = content || ''
iframe.dataset.url = url || ''
iframe.dataset.content = content || ''
return `
<div class="drawio-wrapper" style="position: relative">
@@ -18,24 +20,24 @@ const Plugin = md => {
`
}
const linkTemp = md.renderer.rules.link_open.bind(md.renderer.rules)
const linkTemp = md.renderer.rules.link_open!!.bind(md.renderer.rules)
md.renderer.rules.link_open = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
if (token.attrGet('title') !== '--drawio--') {
if (token.attrGet('link-type') !== 'drawio') {
return linkTemp(tokens, idx, options, env, slf)
}
const file = token.attrGet('href')
const url = token.attrGet('href')
const nextToken = tokens[idx + 1]
if (nextToken && nextToken.type === 'text') {
nextToken.content = ''
}
return renderHtml({ file })
return renderHtml({ url })
}
const fenceTemp = md.renderer.rules.fence.bind(md.renderer.rules)
const fenceTemp = md.renderer.rules.fence!!.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
@@ -49,8 +51,12 @@ const Plugin = md => {
}
}
const buildSrcdoc = async ({ repo, path, content }) => {
if (!content) {
type F = { repo?: string; path?: string; url?: string; content: string }
const buildSrcdoc = async ({ repo, path, content, url }: F) => {
if (url) {
content = await (await fetch(url)).text()
} else if (!content && repo && path) {
content = (await file.read({ repo, path })).content
}
@@ -58,7 +64,7 @@ const buildSrcdoc = async ({ repo, path, content }) => {
const div = document.createElement('div')
div.className = 'mxgraph'
div.dataset['mxgraph'] = JSON.stringify({
div.dataset.mxgraph = JSON.stringify({
highlight: '#00afff',
lightbox: false,
nav: true,
@@ -113,7 +119,7 @@ const buildSrcdoc = async ({ repo, path, content }) => {
`
}
const openInNewWindow = srcdoc => {
const openInNewWindow = (srcdoc: string) => {
const opener = env.openAlwaysOnTopWindow('about:blank')
const frame = document.createElement('iframe')
frame.width = '100%'
@@ -137,18 +143,19 @@ const openInNewWindow = srcdoc => {
}
}
Plugin.load = async (el, repo, path) => {
const content = el.dataset['content']
Plugin.load = async (el: HTMLIFrameElement, url?: string) => {
const bus = useBus()
const content = el.dataset.content || ''
const srcdoc = await buildSrcdoc({ repo, path, content })
const srcdoc = await buildSrcdoc({ content, url })
const resize = () => {
el.contentDocument.body.style.height = 'auto'
el.contentDocument.documentElement.style.height = 'auto'
el.height = el.contentDocument.documentElement.offsetHeight + 'px'
el.contentDocument.body.style.height = el.contentDocument.body.clientHeight + 'px'
el.contentDocument.documentElement.style.height = '100%'
window.appVm.$bus.emit('resize')
el.contentDocument!!.body.style.height = 'auto'
el.contentDocument!!.documentElement.style.height = 'auto'
el.height = el.contentDocument!!.documentElement.offsetHeight + 'px'
el.contentDocument!!.body.style.height = el.contentDocument!!.body.clientHeight + 'px'
el.contentDocument!!.documentElement.style.height = '100%'
bus.emit('resize')
}
el.onload = () => setTimeout(resize, 300)
@@ -168,10 +175,10 @@ Plugin.load = async (el, repo, path) => {
action.style.cssText = 'position: absolute; right: 15px; top: 3px; z-index: 1;'
action.appendChild(button2)
action.appendChild(button1)
el.parentElement.appendChild(action)
el.parentElement!!.appendChild(action)
}
Plugin.open = async file => {
Plugin.open = async (file: F) => {
const srcdoc = await buildSrcdoc(file)
openInNewWindow(srcdoc)
}
@@ -1,14 +1,15 @@
import echarts from 'echarts'
import CryptoJS from 'crypto-js'
import Markdown from 'markdown-it'
const renderHtml = code => {
const renderHtml = (code: string) => {
const id = `echart-${CryptoJS.MD5(code).toString()}-${Math.random().toString(36).substr(2)}`
return `<div class="echarts" id="${id}" data-code="${encodeURIComponent(code)}"></div>`
}
const EChartsPlugin = md => {
const temp = md.renderer.rules.fence.bind(md.renderer.rules)
const EChartsPlugin = (md: Markdown) => {
const temp = md.renderer.rules.fence!!.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
@@ -21,19 +22,20 @@ const EChartsPlugin = md => {
}
}
let charts = {}
let charts: {[key: string]: any} = {}
EChartsPlugin.update = (theme = 'dark', animation = undefined, renderImg = false) => {
EChartsPlugin.update = (theme = 'dark', animation: boolean | undefined = undefined, renderImg = false) => {
Object.values(charts).forEach(x => x.chart.dispose())
charts = {}
for (let ele of document.getElementsByClassName('echarts')) {
for (const ele of document.getElementsByClassName('echarts')) {
const el = ele as HTMLDivElement
try {
const id = ele.getAttribute('id')
const code = ele.dataset['code']
const id = el.getAttribute('id')!!
const code = el.dataset.code
if (code) {
const chart = echarts.init(ele, theme)
const chart = echarts.init(el, theme)
// eslint-disable-next-line
eval(`(${decodeURIComponent(code)})(chart)`)
@@ -45,15 +47,15 @@ EChartsPlugin.update = (theme = 'dark', animation = undefined, renderImg = false
if (renderImg) {
img = new Image()
img.width = ele.clientWidth
img.height = ele.clientHeight
img.src = chart.getDataURL()
img.width = el.clientWidth
img.height = el.clientHeight
img.src = chart.getDataURL({})
}
charts[id] = { chart, img }
}
} catch (error) {
ele.innerText = error
el.innerText = error
}
}
}
@@ -61,12 +63,12 @@ EChartsPlugin.update = (theme = 'dark', animation = undefined, renderImg = false
EChartsPlugin.preparePrint = () => {
EChartsPlugin.update('light', false, true)
for (let ele of document.getElementsByClassName('echarts')) {
for (const ele of document.getElementsByClassName('echarts')) {
while (ele.firstChild) {
ele.firstChild.remove()
}
const id = ele.getAttribute('id')
const id = ele.getAttribute('id')!!
if (charts[id] && charts[id].img) {
ele.appendChild(charts[id].img)
@@ -1,3 +1,5 @@
/* eslint-disable */
// https://github.com/wcoder/highlightjs-line-numbers.js
const TABLE_NAME = 'hljs-ln'
@@ -23,7 +25,7 @@ function addStyles () {
document.getElementsByTagName('head')[0].appendChild(css)
}
function lineNumbersBlock (element, options) {
function lineNumbersBlock (element: any, options?: any) {
if (typeof element !== 'object') return
async(function () {
@@ -31,7 +33,7 @@ function lineNumbersBlock (element, options) {
})
}
function lineNumbersInternal (element, options) {
function lineNumbersInternal (element: any, options: any) {
// define options or set default
options = options || {
singleLine: false
@@ -45,7 +47,7 @@ function lineNumbersInternal (element, options) {
return addLineNumbersBlockFor(element.innerHTML, firstLineIndex)
}
function addLineNumbersBlockFor (inputHtml, firstLineIndex) {
function addLineNumbersBlockFor (inputHtml: any, firstLineIndex: any) {
var lines = getLines(inputHtml)
// if last line contains only carriage return remove it
@@ -88,7 +90,7 @@ function addLineNumbersBlockFor (inputHtml, firstLineIndex) {
* Doing deep passage on child nodes.
* @param {HTMLElement} element
*/
function duplicateMultilineNodes (element) {
function duplicateMultilineNodes (element: any) {
var nodes = element.childNodes
for (var node in nodes) {
if (nodes.hasOwnProperty(node)) {
@@ -108,7 +110,7 @@ function duplicateMultilineNodes (element) {
* Method for fix multi-line elements implementation in highlight.js
* @param {HTMLElement} element
*/
function duplicateMultilineNode (element) {
function duplicateMultilineNode (element: any) {
var className = element.className
if (!/hljs-/.test(className)) return
@@ -123,16 +125,16 @@ function duplicateMultilineNode (element) {
element.innerHTML = result.trim()
}
function getLines (text) {
function getLines (text: string) {
if (text.length === 0) return []
return text.split(BREAK_LINE_REGEXP)
}
function getLinesCount (text) {
function getLinesCount (text: string) {
return (text.trim().match(BREAK_LINE_REGEXP) || []).length
}
function async (func) {
function async (func: Function) {
setTimeout(func, 0)
}
@@ -141,7 +143,7 @@ function async (func) {
* @param {string} format
* @param {array} args
*/
function format (format, args) {
function format (format: string, args: any) {
return format.replace(/\{(\d+)\}/g, function (m, n) {
return args[n] ? args[n] : m
})
-13
View File
@@ -1,13 +0,0 @@
const LinkTargetPlugin = (md) => {
const LinkTargetBlank = (tokens, idx, options, env, slf) => {
if (tokens[idx].attrIndex('target') < 0) {
tokens[idx].attrPush(['target', '_blank'])
}
return slf.renderToken(tokens, idx, options, env, slf)
}
md.renderer.rules.link_open = LinkTargetBlank
}
export default LinkTargetPlugin
+13
View File
@@ -0,0 +1,13 @@
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
-56
View File
@@ -1,56 +0,0 @@
import mermaid from 'mermaid'
const mermaidChart = (code) => {
try {
mermaid.parse(code)
return `<div class="mermaid">${code}</div>`
} catch ({ str, hash }) {
return `<pre>${str}</pre>`
}
}
const MermaidPlugin = (md) => {
md.mermaid = mermaid
mermaid.loadPreferences = (preferenceStore) => {
let mermaidTheme = preferenceStore.get('mermaid-theme')
if (mermaidTheme === undefined) {
mermaidTheme = 'default'
}
let ganttAxisFormat = preferenceStore.get('gantt-axis-format')
if (ganttAxisFormat === undefined) {
ganttAxisFormat = '%Y-%m-%d'
}
mermaid.initialize({
theme: mermaidTheme,
gantt: { axisFormatter: [
[ganttAxisFormat, (d) => {
return d.getDay() === 1
}]
] }
})
return {
'mermaid-theme': mermaidTheme,
'gantt-axis-format': ganttAxisFormat
}
}
const temp = md.renderer.rules.fence.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
const code = token.content.trim()
if (token.info === 'mermaid') {
return mermaidChart(code)
}
const firstLine = code.split(/\n/)[0].trim()
if (firstLine === 'gantt' || firstLine === 'sequenceDiagram' || firstLine.match(/^graph (?:TB|BT|RL|LR|TD);?$/)) {
return mermaidChart(code)
}
return temp(tokens, idx, options, env, slf)
}
}
MermaidPlugin.update = () => {
mermaid.init()
}
export default MermaidPlugin
+35
View File
@@ -0,0 +1,35 @@
import Markdown from 'markdown-it'
import mermaid from 'mermaid'
const mermaidChart = (code: string) => {
try {
mermaid.parse(code)
return `<div class="mermaid">${code}</div>`
} catch ({ str, hash }) {
return `<pre>${str}</pre>`
}
}
const MermaidPlugin = (md: Markdown) => {
mermaid.initialize({})
const temp = md.renderer.rules.fence!!.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
const code = token.content.trim()
if (token.info === 'mermaid') {
return mermaidChart(code)
}
const firstLine = code.split(/\n/)[0].trim()
if (firstLine === 'gantt' || firstLine === 'sequenceDiagram' || firstLine.match(/^graph (?:TB|BT|RL|LR|TD);?$/)) {
return mermaidChart(code)
}
return temp(tokens, idx, options, env, slf)
}
}
MermaidPlugin.update = () => {
mermaid.init('.mermaid')
}
export default MermaidPlugin
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +1,10 @@
import CryptoJS from 'crypto-js'
import Markdown from 'markdown-it'
const cachePrefix = 'run_code_result_'
const RunPlugin = (md) => {
const temp = md.renderer.rules.fence.bind(md.renderer.rules)
const RunPlugin = (md: Markdown) => {
const temp = md.renderer.rules.fence!!.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
@@ -1,13 +1,16 @@
export const injectLineNumbers = (tokens, idx, options, env, slf) => {
import Markdown from 'markdown-it'
import Renderer from 'markdown-it/lib/renderer'
export const injectLineNumbers: Renderer.RenderRule = (tokens, idx, options, _, slf) => {
if (tokens[idx].map) {
const line = tokens[idx].map[0]
const line = tokens[idx].map!![0]
tokens[idx].attrJoin('class', 'source-line')
tokens[idx].attrSet('data-source-line', String(line + 1))
}
return slf.renderToken(tokens, idx, options, env, slf)
return slf.renderToken(tokens, idx, options)
}
const SourceLinePlugin = (md) => {
const SourceLinePlugin = (md: Markdown) => {
md.renderer.rules.paragraph_open = injectLineNumbers
md.renderer.rules.heading_open = injectLineNumbers
md.renderer.rules.list_item_open = injectLineNumbers
@@ -1,8 +1,10 @@
// https://github.com/Oktavilla/markdown-it-table-of-contents
import Markdown from 'markdown-it'
import { injectLineNumbers } from './SourceLinePlugin'
const slugify = s => 'h-' + encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'))
const slugify = (s: string) => 'h-' + encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'))
const defaults = {
level: [2, 3],
@@ -16,14 +18,14 @@ const defaults = {
containerFooterHtml: undefined
}
export default (md, o) => {
export default (md: Markdown, o: any) => {
const options = Object.assign({}, defaults, o)
const tocRegexp = options.markerPattern
let gstate
let gstate: any
function toc (state, silent) {
var token
var match
function toc (state: any, silent: any) {
let token
let match
// Reject if the token does not start with [
if (state.src.charCodeAt(state.pos) !== 0x5B) {
@@ -36,7 +38,7 @@ export default (md, o) => {
// Detect TOC markdown
match = tocRegexp.exec(state.src.substr(state.pos))
match = !match ? [] : match.filter(m => !!m)
match = !match ? [] : match.filter((m: any) => !!m)
if (match.length < 1) {
return false
}
@@ -59,7 +61,7 @@ export default (md, o) => {
token = state.push('toc_close', 'toc', -1)
// Update pos so the parser can continue
var newline = state.src.indexOf('\n', state.pos)
const newline = state.src.indexOf('\n', state.pos)
if (newline !== -1) {
state.pos = newline
} else {
@@ -69,8 +71,59 @@ export default (md, o) => {
return true
}
md.renderer.rules.toc_open = function (tokens, index) {
var tocOpenHtml = `<div class="${options.containerClass}">`
function renderChildrenTokens (pos: any, tokens: any) {
const headings = []
let buffer = ''
let currentLevel
let subHeadings
const size = tokens.length
let i = pos
while (i < size) {
const token = tokens[i]
const heading = tokens[i - 1]
const level = token.tag && parseInt(token.tag.substr(1, 1))
if (token.type !== 'heading_close' || options.level.indexOf(level) === -1 || heading.type !== 'inline') {
i++
continue // Skip if not matching criteria
}
if (!currentLevel) {
currentLevel = level // We init with the first found level
} else {
if (level > currentLevel) {
subHeadings = renderChildrenTokens(i, tokens)
buffer += subHeadings[1]
i = subHeadings[0]
continue
}
if (level < currentLevel) {
// Finishing the sub headings
buffer += '</li>'
headings.push(buffer)
return [i, `<${options.type}>${headings.join('')}</${options.type}>`]
}
if (level === currentLevel) {
// Finishing the sub headings
buffer += '</li>'
headings.push(buffer)
}
}
// 给标题加上 id
const slug = options.slugify(heading.content)
buffer = `<li><a href="#${slug}">`
buffer += typeof options.format === 'function' ? options.format(heading.content) : heading.content
buffer += '</a>'
i++
}
buffer += buffer === '' ? '' : '</li>'
headings.push(buffer)
return [i, `<${options.type}>${headings.join('')}</${options.type}>`]
}
md.renderer.rules.toc_open = function () {
let tocOpenHtml = `<div class="${options.containerClass}">`
if (options.containerHeaderHtml) {
tocOpenHtml += options.containerHeaderHtml
@@ -79,17 +132,17 @@ export default (md, o) => {
return tocOpenHtml
}
md.renderer.rules.toc_close = function (tokens, index) {
var tocFooterHtml = ''
md.renderer.rules.toc_close = function () {
let tocFooterHtml = ''
if (options.containerFooterHtml) {
tocFooterHtml = options.containerFooterHtml
}
return tocFooterHtml + `</div>`
return tocFooterHtml + '</div>'
}
md.renderer.rules.toc_body = function (tokens, index) {
md.renderer.rules.toc_body = function () {
if (options.forceFullToc) {
/*
@@ -106,12 +159,12 @@ export default (md, o) => {
- heading 1
*/
var tocBody = ''
var pos = 0
var tokenLength = gstate && gstate.tokens && gstate.tokens.length
let tocBody = ''
let pos = 0
const tokenLength = gstate && gstate.tokens && gstate.tokens.length
while (pos < tokenLength) {
var tocHierarchy = renderChildrenTokens(pos, gstate.tokens)
const tocHierarchy = renderChildrenTokens(pos, gstate.tokens)
pos = tocHierarchy[0]
tocBody += tocHierarchy[1]
}
@@ -138,60 +191,10 @@ export default (md, o) => {
return injectLineNumbers(tokens, idx, opt, env, slf)
}
function renderChildrenTokens (pos, tokens) {
let headings = []
let buffer = ''
let currentLevel
let subHeadings
let size = tokens.length
let i = pos
while (i < size) {
var token = tokens[i]
var heading = tokens[i - 1]
var level = token.tag && parseInt(token.tag.substr(1, 1))
if (token.type !== 'heading_close' || options.level.indexOf(level) === -1 || heading.type !== 'inline') {
i++
continue // Skip if not matching criteria
}
if (!currentLevel) {
currentLevel = level // We init with the first found level
} else {
if (level > currentLevel) {
subHeadings = renderChildrenTokens(i, tokens)
buffer += subHeadings[1]
i = subHeadings[0]
continue
}
if (level < currentLevel) {
// Finishing the sub headings
buffer += `</li>`
headings.push(buffer)
return [i, `<${options.type}>${headings.join('')}</${options.type}>`]
}
if (level === currentLevel) {
// Finishing the sub headings
buffer += `</li>`
headings.push(buffer)
}
}
// 给标题加上 id
const slug = options.slugify(heading.content)
buffer = `<li><a href="#${slug}">`
buffer += typeof options.format === 'function' ? options.format(heading.content) : heading.content
buffer += `</a>`
i++
}
buffer += buffer === '' ? '' : `</li>`
headings.push(buffer)
return [i, `<${options.type}>${headings.join('')}</${options.type}>`]
}
// Catch all the tokens for iteration later
md.core.ruler.push('grab_state', function (state) {
gstate = state
return true
})
// Insert TOC
-12
View File
@@ -1,12 +0,0 @@
import Vue from 'vue'
import Router from 'vue-router'
import Main from './pages/Main'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{ path: '/', name: 'main', component: Main },
]
})
+17
View File
@@ -0,0 +1,17 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import Main from '@/views/Main.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Main',
component: Main
},
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
+18
View File
@@ -0,0 +1,18 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare global {
interface Window {
documentSaved: boolean;
runInXterm: Function;
}
}
declare module 'markdown-it-task-lists'
declare module 'markdown-it-attrs'
declare module 'markdown-it-multimd-table'
declare module 'markdown-it-footnote'
declare module 'markdown-it-katex'
-16
View File
@@ -1,16 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
}
})
@@ -1,20 +1,25 @@
import Storage from '@/lib/Storage'
import file from '@/lib/file'
import { createStore } from 'vuex'
import Storage from '@/useful/storage'
import file from '@/useful/file'
const getLastOpenFile = repo => {
/**
*
* @param repoName
*/
const getLastOpenFile = (repoName?: string) => {
const currentFile = Storage.get('currentFile')
const recentOpenTime = Storage.get('recentOpenTime', {})
const recentOpenTime = Storage.get('recentOpenTime', {}) as {[key: string]: number}
if (!repo) {
if (!repoName) {
return null
}
if (currentFile && currentFile.repo === repo.name) {
if (currentFile && currentFile.repo === repoName) {
return currentFile
}
const item = Object.entries(recentOpenTime)
.filter(x => x[0].startsWith(repo.name + '|'))
.filter(x => x[0].startsWith(repoName + '|'))
.sort((a, b) => b[1] - a[1])[0]
if (!item) {
@@ -26,11 +31,10 @@ const getLastOpenFile = repo => {
return null
}
return { repo: repo.name, name: file.basename(path), path }
return { repo: repoName, name: file.basename(path), path }
}
export default {
namespaced: true,
export default createStore({
state: {
repositories: {},
tree: null,
@@ -42,7 +46,7 @@ export default {
previousContent: '',
previousHash: '',
currentRepo: Storage.get('currentRepo'),
currentFile: getLastOpenFile(Storage.get('currentRepo')),
currentFile: getLastOpenFile(Storage.get('currentRepo')?.name),
recentOpenTime: Storage.get('recentOpenTime', {}),
tabs: Storage.get('tabs', []),
passwordHash: {},
@@ -102,7 +106,7 @@ export default {
},
setCurrentRepo (state, data) {
state.currentRepo = data
state.currentFile = getLastOpenFile(data)
state.currentFile = getLastOpenFile(data?.name)
state.tree = null
Storage.set('currentRepo', data)
},
@@ -120,7 +124,7 @@ export default {
},
actions: {
async fetchMarkedFiles ({ commit }) {
const files = (await file.markedFiles()).map(x => ({ ...x, name: file.basename(x.path) }))
const files = (await file.markedFiles()).map((x: any) => ({ ...x, name: file.basename(x.path) }))
commit('setMarkedFiles', files)
},
async fetchRepositories ({ commit }) {
@@ -147,4 +151,4 @@ export default {
})
},
}
}
})
-13
View File
@@ -1,13 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
import app from './app'
Vue.use(Vuex)
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
modules: {
app,
}
})
-11
View File
@@ -1,11 +0,0 @@
import Toast from '@/components/Toast'
export default {
install (Vue, options) {
const ToastInstance = Vue.extend(Toast)
const instance = new ToastInstance()
document.body.appendChild(instance.$mount().$el)
Vue.prototype.$toast = instance
}
}
+48
View File
@@ -0,0 +1,48 @@
export namespace Components {
export namespace Modal {
export type ConfirmModalParams = { title?: string; content?: string }
export type InputModalParams = {
type?: string;
title?: string;
content?: string;
value?: string;
hint?: string;
select?: boolean | [number, number, 'forward' | 'backward' | 'none'];
}
}
export namespace Toast {
export type ToastType = 'warning' | 'info'
}
export namespace ContextMenu {
export type SeparatorItem = { type: 'separator' }
export type NormalItem = {
type?: 'normal';
id: string;
label: string;
onClick: (item?: NormalItem) => void;
}
export type Item = SeparatorItem | NormalItem
}
export namespace Tabs {
export interface Item {
key: string;
label: string;
description?: string;
payload: any;
}
}
export namespace FileTabs {
export interface Item extends Tabs.Item {
payload: {
file: any; // TODO 文件类型
};
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import { App } from 'vue'
import mitt, { Emitter, EventType, Handler, WildcardHandler } from 'mitt'
const emitter = mitt()
emitter.on('*', (type, payload) => {
console.log('debug bus >', type, payload)
})
interface XEmitter extends Emitter {
once<T = any>(type: EventType, handler: Handler<T>): void;
once(type: '*', handler: WildcardHandler): void;
}
(emitter as any).once = (type: any, handler: any) => {
const wrappedHandler = (evt: any) => {
handler(evt)
emitter.off(type, wrappedHandler)
}
emitter.on(type, wrappedHandler)
}
export function useBus () {
return emitter as XEmitter
}
export default function install (app: App) {
app.config.globalProperties.$bus = emitter
;(window as any).globalBus = emitter
}
+22
View File
@@ -0,0 +1,22 @@
import { App, ComponentPublicInstance, createApp } from 'vue'
import { Components } from '@/types'
import Modal from '@/components/ContextMenu.vue'
interface Instance extends ComponentPublicInstance {
show: (menuItems: Components.ContextMenu.Item[]) => void;
}
let instance: Instance
export function useContextMenu (): Instance {
return instance
}
export default function install (app: App) {
const toast = createApp(Modal)
const el = document.createElement('div')
document.body.appendChild(el)
instance = toast.mount(el) as Instance
app.config.globalProperties.$modal = instance
}
+23
View File
@@ -0,0 +1,23 @@
import { useToast } from '@/useful/toast'
export function copyText (text?: string) {
if (text === undefined) {
return
}
const toast = useToast()
const input = document.createElement('input')
input.style.position = 'absolute'
input.style.background = 'red'
input.style.left = '-999999px'
input.style.top = '-999999px'
input.style.zIndex = '-1000'
input.style.opacity = '0'
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
toast.show('info', '已复制')
}
@@ -1,6 +1,6 @@
import CryptoJS from 'crypto-js'
const getCryptKey = password => {
const getCryptKey = (password: string) => {
if (!password) {
throw new Error('未输入密码')
}
@@ -8,9 +8,9 @@ const getCryptKey = password => {
return CryptoJS.MD5(password).toString().substr(0, 16)
}
const encrypt = (content, password) => {
let key = getCryptKey(password)
let iv = key
const encrypt = (content: any, password: string) => {
let key: any = getCryptKey(password)
let iv: any = key
const passwordHash = CryptoJS.MD5(key).toString()
key = CryptoJS.enc.Utf8.parse(key)
@@ -25,9 +25,9 @@ const encrypt = (content, password) => {
return { content: encrypted.toString(), passwordHash }
}
const decrypt = (content, password) => {
let key = getCryptKey(password)
let iv = key
const decrypt = (content: any, password: string) => {
let key: any = getCryptKey(password)
let iv: any = key
const passwordHash = CryptoJS.MD5(key).toString()
key = CryptoJS.enc.Utf8.parse(key)
@@ -47,11 +47,11 @@ const decrypt = (content, password) => {
return { content: result, passwordHash }
}
const md5 = content => {
const md5 = (content: any) => {
return CryptoJS.MD5(content).toString()
}
const binMd5 = data => {
const binMd5 = (data: any) => {
return md5(CryptoJS.enc.Latin1.parse(data))
}
+32
View File
@@ -0,0 +1,32 @@
const _window = window as any
interface Process extends NodeJS.Process {
versions?: any;
}
const nodeProcess: Process = window && (window.process || _window.nodeProcess)
const nodeModule = window && (window.module || _window.nodeModule)
const nodeRequire = window && (window.require || _window.nodeRequire)
const isElectron = !!(nodeProcess?.versions?.electron)
const openAlwaysOnTopWindow = (url: string, target = '_blank') => {
if (isElectron) {
const opener: any = window.open(url, target, 'frame=true,alwaysOnTop=true')
// 让弹出的窗口置顶
opener.eval("require('electron').remote.getCurrentWindow().setAlwaysOnTop(true)")
return opener
} else {
return window.open(url, target)
}
}
export default {
process: nodeProcess,
module: nodeModule,
require: nodeRequire,
isElectron,
openAlwaysOnTopWindow,
}
@@ -144,7 +144,7 @@ const extensions = [
'.gql'
]
const supported = name => {
const supported = (name: string) => {
return extensions.includes(file.extname(name.toLowerCase()))
}
@@ -1,8 +1,11 @@
import Crypto from './Crypto'
import Crypto from './crypto'
import { slugify } from 'transliteration'
const fetchHttp = async (...params) => {
const response = await fetch(...params)
// TODO 文件类型
type F = { repo: string; path: string };
const fetchHttp = async (input: RequestInfo, init?: RequestInit) => {
const response = await fetch(input, init)
const result = await response.json()
if (result.status !== 'ok') {
throw new Error(result.message)
@@ -11,31 +14,31 @@ const fetchHttp = async (...params) => {
return result
}
const isBelongTo = (path, sub) => {
const isBelongTo = (path: string, sub: string) => {
return sub.startsWith(path.replace(/\/$/, '') + '/')
}
const isEncryptedFile = file => {
const isEncryptedFile = (file: any) => {
return file && file.path.endsWith('.c.md')
}
const isSameFile = (a, b) => {
const isSameFile = (a?: F | null, b?: F | null) => {
return a && b && a.repo === b.repo && a.path === b.path
}
const dirname = path => {
const dirname = (path: string) => {
return path.substr(0, path.lastIndexOf('/'))
}
const basename = path => {
const basename = (path: string) => {
return path.substr(path.lastIndexOf('/') + 1)
}
const extname = path => {
const extname = (path: string) => {
return path.substr(path.lastIndexOf('.'))
}
const toUri = (file) => {
const toUri = (file?: F | null) => {
if (file && file.repo && file.path) {
return encodeURI(`yank-note://${file.repo}/${file.path.replace(/^\//, '')}`)
} else {
@@ -43,7 +46,7 @@ const toUri = (file) => {
}
}
const decrypt = (content, password) => {
const decrypt = (content: any, password: string) => {
if (!password) {
throw new Error('未输入解密密码')
}
@@ -51,7 +54,7 @@ const decrypt = (content, password) => {
return Crypto.decrypt(content, password)
}
const encrypt = (content, password) => {
const encrypt = (content: any, password: string) => {
if (!password) {
throw new Error('未输入解密密码')
}
@@ -59,15 +62,15 @@ const encrypt = (content, password) => {
return Crypto.encrypt(content, password)
}
const read = async ({ path, repo }) => {
const read = async ({ path, repo }: F) => {
const result = await fetchHttp(`/api/file?path=${encodeURIComponent(path)}&repo=${encodeURIComponent(repo)}`)
const hash = result.data.hash
let content = result.data.content
const content = result.data.content
return { content, hash }
}
const write = async ({ repo, path }, content, oldHash) => {
const write = async ({ repo, path }: F, content: any, oldHash: string) => {
const result = await fetchHttp('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -77,7 +80,7 @@ const write = async ({ repo, path }, content, oldHash) => {
return { hash: result.data }
}
const move = async ({ repo, path }, newPath) => {
const move = async ({ repo, path }: F, newPath: string) => {
return fetchHttp('/api/file', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -85,15 +88,15 @@ const move = async ({ repo, path }, newPath) => {
})
}
const deleteFile = async ({ path, repo }) => {
const deleteFile = async ({ path, repo }: F) => {
return fetchHttp(`/api/file?path=${encodeURIComponent(path)}&repo=${repo}`, { method: 'DELETE' })
}
const mark = async ({ path, repo }) => {
const mark = async ({ path, repo }: F) => {
return fetchHttp(`/api/mark?path=${encodeURIComponent(path)}&repo=${repo}`, { method: 'POST' })
}
const unmark = async ({ path, repo }) => {
const unmark = async ({ path, repo }: F) => {
return fetchHttp(`/api/mark?path=${encodeURIComponent(path)}&repo=${repo}`, { method: 'DELETE' })
}
@@ -102,12 +105,12 @@ const markedFiles = async () => {
return data
}
const fetchTree = async repo => {
const fetchTree = async (repo: string) => {
const result = await fetchHttp(`/api/tree?repo=${repo}`)
return result.data
}
const fetchHelpContent = async (doc) => {
const fetchHelpContent = async (doc: string) => {
const result = await fetchHttp('/api/help?doc=' + doc)
return result.data.content
}
@@ -117,12 +120,12 @@ const fetchRepositories = async () => {
return result.data
}
const search = async (repo, text) => {
const search = async (repo: string, text: string) => {
const result = await fetchHttp(`/api/search?repo=${repo}&search=${encodeURIComponent(text)}`)
return result.data
}
const upload = async (repo, belongPath, uploadFile, name = null) => {
const upload = async (repo: string, belongPath: string, uploadFile: any, name: string | null = null): Promise<{repo: string; path: string; relativePath: string}> => {
return new Promise((resolve, reject) => {
belongPath = belongPath.replace(/\\/g, '/')
@@ -153,11 +156,11 @@ const upload = async (repo, belongPath, uploadFile, name = null) => {
})
}
const openInOS = async ({ repo, path }) => {
const openInOS = async ({ repo, path }: F) => {
return fetchHttp(`/api/open?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}`)
}
const toBase64URL = async file => new Promise((resolve, reject) => {
const toBase64URL = async (file: any) => new Promise((resolve, reject) => {
const fr = new FileReader()
fr.readAsDataURL(file)
fr.onload = () => resolve(fr.result)
@@ -1,6 +1,8 @@
/* eslint-disable */
// https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js
const fuzzyMatch = function (pattern, str) {
const fuzzyMatch = function (pattern: any, str: any) {
// Score consts
var adjacencyBonus = 5 // bonus for adjacent matches
var separatorBonus = 10 // bonus if match occurs after a separator
+3
View File
@@ -0,0 +1,3 @@
export function $args () {
return new URLSearchParams(location.search)
}
+23
View File
@@ -0,0 +1,23 @@
import { App, ComponentPublicInstance, createApp } from 'vue'
import { Components } from '@/types'
import Modal from '@/components/ModalUi.vue'
interface Instance extends ComponentPublicInstance {
confirm: (params: Components.Modal.ConfirmModalParams) => Promise<boolean>;
input: (params: Components.Modal.InputModalParams) => Promise<string>;
}
let instance: Instance
export function useModal (): Instance {
return instance
}
export default function install (app: App) {
const toast = createApp(Modal)
const el = document.createElement('div')
document.body.appendChild(el)
instance = toast.mount(el) as Instance
app.config.globalProperties.$modal = instance
}
+22
View File
@@ -0,0 +1,22 @@
import { useStore } from 'vuex'
import { useBus } from './bus'
import RunPlugin from '@/plugins/RunPlugin'
import { copyText } from './copy-text'
export default function startup () {
const bus = useBus()
const store = useStore()
RunPlugin.clearCache()
bus.on('editor-ready', () => {
const { currentFile } = store.state
if (!currentFile) {
// 当前没打开文件,直接打开 README
store.dispatch('showHelp', 'README.md')
}
})
bus.on('copy-text', copyText)
}
@@ -1,4 +1,4 @@
const get = function (key, defaultValue) {
const get = function (key: string, defaultValue?: any) {
try {
return typeof window.localStorage[key] === 'undefined' ? defaultValue : JSON.parse(window.localStorage[key])
} catch (error) {
@@ -6,7 +6,7 @@ const get = function (key, defaultValue) {
}
}
const set = function (key, value) {
const set = function (key: string, value: any) {
window.localStorage[key] = JSON.stringify(value)
}
+22
View File
@@ -0,0 +1,22 @@
import { App, ComponentPublicInstance, createApp } from 'vue'
import Toast from '@/components/Toast.vue'
import { Components } from '@/types'
interface ToastInstance extends ComponentPublicInstance {
show: (type: Components.Toast.ToastType, content: string, timeout?: number) => void;
}
let instance: ToastInstance
export function useToast (): ToastInstance {
return instance
}
export default function install (app: App) {
const toast = createApp(Toast)
const el = document.createElement('div')
document.body.appendChild(el)
instance = toast.mount(el) as ToastInstance
app.config.globalProperties.$toast = instance
}
@@ -1,4 +1,4 @@
export const encodeMarkdownLink = path => {
export const encodeMarkdownLink = (path: string) => {
return path
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
+66
View File
@@ -0,0 +1,66 @@
<template>
<Layout>
<template v-slot:header>
<TitleBar />
</template>
<template v-slot:footer>
<StatusBar />
</template>
<template v-slot:left>
<Tree />
</template>
<template v-slot:terminal>
<Xterm />
</template>
<template v-slot:editor>
<FileTabs />
<Editor ref="refEditor" @scroll-line="line => refPreview.revealLine(line)" />
</template>
<template v-slot:preview>
<Preview
ref="refPreview"
@sync-scroll="line => refEditor.revealLineInCenter(line)"
@switch-todo="(line, checked) => refEditor.switchTodo(line, checked)" />
</template>
</Layout>
<XFilter />
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import startup from '../useful/startup'
import Layout from '../components/Layout.vue'
import TitleBar from '../components/TitleBar.vue'
import StatusBar from '../components/StatusBar.vue'
import Tree from '../components/Tree.vue'
import Xterm from '../components/Xterm.vue'
import FileTabs from '../components/FileTabs.vue'
import Editor from '../components/Editor.vue'
import Preview from '../components/Preview.vue'
// import RunPlugin from ''../plugins/RunPlugin'
import XFilter from '../components/Filter.vue'
export default defineComponent({
name: 'x-main',
components: {
Layout,
TitleBar,
StatusBar,
Tree,
Xterm,
FileTabs,
Editor,
Preview,
XFilter,
},
setup () {
const refEditor = ref<any>(null)
const refPreview = ref<any>(null)
onMounted(startup)
return { refEditor, refPreview }
}
})
</script>
+13
View File
@@ -0,0 +1,13 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
props: { msg }
})
expect(wrapper.text()).to.include(msg)
})
})
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env",
"mocha",
"chai"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
+6052 -5198
View File
File diff suppressed because it is too large Load Diff
+61 -13
View File
@@ -36,18 +36,65 @@ Yank-Note 很多功能可以直接使用的快捷键操作
支持使用脚注[^1]语法[^2]
## Mermaid 图形解析
```mermaid
graph LR
A[Hard] -->|Text| B(Round)
B --> C{Decision}
C -->|One| D[Result 1]
C -->|Two| E[Result 2]
```
```mermaid
sequenceDiagram
participant Alice
participant Bob
Alice->>John: Hello John, how are you?
loop Healthcheck
John->>John: Fight against hypochondria
end
Note right of John: Rational thoughts <br/>prevail!
John-->>Alice: Great!
John->>Bob: How about you?
Bob-->>John: Jolly good!
Alice->>John: Hello John, how are you?
loop Healthcheck
John->>John: Fight against hypochondria
end
Note right of John: Rational thoughts!
John-->>Alice: Great!
John->>Bob: How about you?
Bob-->>John: Jolly good!
```
```mermaid
gantt
section Section
Completed :done, des1, 2014-01-06,2014-01-08
Active :active, des2, 2014-01-07, 3d
Parallel 1 : des3, after des1, 1d
Parallel 2 : des4, after des1, 1d
Parallel 3 : des5, after des3, 1d
Parallel 4 : des6, after des4, 1d
```
```mermaid
stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]
```
```mermaid
pie
"Dogs" : 386
"Cats" : 85
"Rats" : 15
```
```mermaid
journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 3: Me
```
## Plantuml 图形解析
@@ -253,10 +300,10 @@ xml 代码块 第一行注释需要有 `--drawio--` 文字
```
### 嵌入本地 drawio 文件
链接标题(title需要是 `--drawio--` 字符串。使用链接的形式也不会影响其他 Markdown 解析器解析。
链接属性 `link-type`需要是 `drawio` 字符串。使用链接的形式也不会影响其他 Markdown 解析器解析。
```markdown
[drawio](./test.drawio "--drawio--")
[drawio](./test.drawio){link-type="drawio"}
```
## 编辑器快捷键
@@ -291,7 +338,8 @@ xml 代码块 第一行注释需要有 `--drawio--` 文字
+ `Ctrl + K, Ctrl + U` 转换大写
+ `Ctrl + K, Ctrl + L` 转换小写
+ `Ctrl + Alt + R` 在内置终端里面运行选中内容
+ `Ctrl + B + V` 粘贴 HTML 富文本
+ `Ctrl + M + V` 粘贴 HTML 富文本
+ `Ctrl + B + V` 粘贴图片为 base64 链接
## 元素属性书写
此功能使用如下库实现 [markdown-it-attrs](https://github.com/arve0/markdown-it-attrs)