feat: open external link and local markdown file (#790)

* feat: open external link and local markdown file

* image view

* browse image by CmdOrCtrl + click

* use esc to close image viewer

* change image viewer z-index to prevent math render show on top of it

* support windows and linux
This commit is contained in:
Ran Luo 2019-03-24 20:20:44 +08:00 committed by Felix Häusler
parent eff619df34
commit c0c8ea4b15
15 changed files with 189 additions and 9 deletions

View File

@ -170,6 +170,7 @@
"vega": "^5.2.0", "vega": "^5.2.0",
"vega-embed": "^4.0.0-rc1", "vega-embed": "^4.0.0-rc1",
"vega-lite": "^3.0.0-rc15", "vega-lite": "^3.0.0-rc15",
"view-image": "^0.0.1",
"vue": "^2.6.8", "vue": "^2.6.8",
"vue-electron": "^1.0.6", "vue-electron": "^1.0.6",
"vuex": "^3.1.0" "vuex": "^3.1.0"

View File

@ -2,9 +2,9 @@ import fs from 'fs'
// import chokidar from 'chokidar' // import chokidar from 'chokidar'
import path from 'path' import path from 'path'
import { promisify } from 'util' import { promisify } from 'util'
import { BrowserWindow, dialog, ipcMain } from 'electron' import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import appWindow from '../window' import appWindow from '../window'
import { EXTENSION_HASN, EXTENSIONS, PANDOC_EXTENSIONS } from '../config' import { EXTENSION_HASN, EXTENSIONS, PANDOC_EXTENSIONS, URL_REG } from '../config'
import { loadMarkdownFile, writeFile, writeMarkdownFile } from '../utils/filesystem' import { loadMarkdownFile, writeFile, writeMarkdownFile } from '../utils/filesystem'
import appMenu from '../menu' import appMenu from '../menu'
import { getPath, isMarkdownFile, log, isFile, isDirectory, getRecommendTitle } from '../utils' import { getPath, isMarkdownFile, log, isFile, isDirectory, getRecommendTitle } from '../utils'
@ -282,6 +282,23 @@ ipcMain.on('AGANI::ask-for-open-project-in-sidebar', e => {
} }
}) })
ipcMain.on('AGANI::format-link-click', (e, { data, dirname }) => {
if (URL_REG.test(data.href)) {
return shell.openExternal(data.href)
}
let pathname = null
if (path.isAbsolute(data.href) && isMarkdownFile(data.href)) {
pathname = data.href
}
if (!path.isAbsolute(data.href) && isMarkdownFile(path.join(dirname, data.href))) {
pathname = path.join(dirname, data.href)
}
if (pathname) {
const win = BrowserWindow.fromWebContents(e.sender)
return openFileOrFolder(win, pathname)
}
})
export const exportFile = (win, type) => { export const exportFile = (win, type) => {
win.webContents.send('AGANI::export', { type }) win.webContents.send('AGANI::export', { type })
} }

View File

@ -77,3 +77,5 @@ export const LF_LINE_ENDING_REG = /(?:[^\r]\n)|(?:^\n$)/
export const CRLF_LINE_ENDING_REG = /\r\n/ export const CRLF_LINE_ENDING_REG = /\r\n/
export const GITHUB_REPO_URL = 'https://github.com/marktext/marktext' export const GITHUB_REPO_URL = 'https://github.com/marktext/marktext'
// copy from muya
export const URL_REG = /^http(s)?:\/\/([a-z0-9\-._~]+\.[a-z]{2,}|[0-9.]+|localhost|\[[a-f0-9.:]+\])(:[0-9]{1,5})?(\/[\S]+)?/i

View File

@ -236,3 +236,5 @@ export const MUYA_DEFAULT_OPTION = {
export const isInElectron = window && window.process && window.process.type === 'renderer' export const isInElectron = window && window.process && window.process.type === 'renderer'
export const isOsx = window && window.navigator && /Mac/.test(window.navigator.platform) export const isOsx = window && window.navigator && /Mac/.test(window.navigator.platform)
// http[s] (domain or IPv4 or localhost or IPv6) [port] /not-white-space
export const URL_REG = /^http(s)?:\/\/([a-z0-9\-._~]+\.[a-z]{2,}|[0-9.]+|localhost|\[[a-f0-9.:]+\])(:[0-9]{1,5})?\/[\S]+/i

View File

@ -5,6 +5,59 @@ const clickCtrl = ContentState => {
ContentState.prototype.clickHandler = function (event) { ContentState.prototype.clickHandler = function (event) {
const { eventCenter } = this.muya const { eventCenter } = this.muya
const { start, end } = selection.getCursorRange() const { start, end } = selection.getCursorRange()
// format-click
const node = selection.getSelectionStart()
if (node.classList.contains('ag-inline-rule')) {
let formatType = null
let data = null
switch (node.tagName) {
case 'SPAN': {
if (node.hasAttribute('data-emoji')) {
formatType = 'emoji'
data = node.getAttribute('data-emoji')
} else if (node.classList.contains('ag-math-text')) {
formatType = 'inline_math'
data = node.innerHTML
}
break
}
case 'A': {
formatType = 'link' // auto link or []() link
data = {
text: node.innerHTML,
href: node.getAttribute('href')
}
break
}
case 'STRONG': {
formatType = 'strong'
data = node.innerHTML
break
}
case 'EM': {
formatType = 'em'
data = node.innerHTML
break
}
case 'DEL': {
formatType = 'del'
data = node.innerHTML
break
}
case 'CODE': {
formatType = 'inline_code'
data = node.innerHTML
break
}
}
if (formatType) {
eventCenter.dispatch('format-click', {
event,
formatType,
data,
})
}
}
const block = this.getBlock(start.key) const block = this.getBlock(start.key)
let needRender = false let needRender = false
// is show format float box? // is show format float box?

View File

@ -30,7 +30,12 @@ class ClickEvent {
const mathRender = target.closest(`.${CLASS_OR_ID['AG_MATH_RENDER']}`) const mathRender = target.closest(`.${CLASS_OR_ID['AG_MATH_RENDER']}`)
const mathText = mathRender && mathRender.previousElementSibling const mathText = mathRender && mathRender.previousElementSibling
if (markedImageText && markedImageText.classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) { if (markedImageText && markedImageText.classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) {
selectionText(markedImageText) eventCenter.dispatch('format-click', {
event,
formatType: 'image',
data: event.target.getAttribute('src')
})
selectionText(markedImageText)
} else if (mathText) { } else if (mathText) {
selectionText(mathText) selectionText(mathText)
} }

View File

@ -7,7 +7,7 @@ export default function emoji (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor) const className = this.getClassName(outerClass, block, token, cursor)
const validation = validEmoji(token.content) const validation = validEmoji(token.content)
const finalClass = validation ? className : CLASS_OR_ID['AG_WARN'] const finalClass = validation ? className : CLASS_OR_ID['AG_WARN']
const CONTENT_CLASSNAME = `span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKED_TEXT']}` const CONTENT_CLASSNAME = `span.${finalClass}.${CLASS_OR_ID['AG_INLINE_RULE']}.${CLASS_OR_ID['AG_EMOJI_MARKED_TEXT']}`
let startMarkerCN = `span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKER']}` let startMarkerCN = `span.${finalClass}.${CLASS_OR_ID['AG_EMOJI_MARKER']}`
let endMarkerCN = startMarkerCN let endMarkerCN = startMarkerCN
let content = token.content let content = token.content

View File

@ -39,7 +39,7 @@ export default function displayMath (h, cursor, block, token, outerClass) {
return [ return [
h(`span.${className}.${CLASS_OR_ID['AG_MATH_MARKER']}`, startMarker), h(`span.${className}.${CLASS_OR_ID['AG_MATH_MARKER']}`, startMarker),
h(`span.${className}.${CLASS_OR_ID['AG_MATH']}`, [ h(`span.${className}.${CLASS_OR_ID['AG_MATH']}`, [
h(`span.${CLASS_OR_ID['AG_MATH_TEXT']}`, content), h(`span.${CLASS_OR_ID['AG_INLINE_RULE']}.${CLASS_OR_ID['AG_MATH_TEXT']}`, content),
h(previewSelector, { h(previewSelector, {
attrs: { contenteditable: 'false' } attrs: { contenteditable: 'false' }
}, mathVnode) }, mathVnode)

View File

@ -2,7 +2,7 @@
// todo@jocs: remove the use of `axios` in muya // todo@jocs: remove the use of `axios` in muya
import axios from 'axios' import axios from 'axios'
import createDOMPurify from 'dompurify' import createDOMPurify from 'dompurify'
import { isInElectron } from '../config' import { isInElectron, URL_REG } from '../config'
const ID_PREFIX = 'ag-' const ID_PREFIX = 'ag-'
let id = 0 let id = 0
@ -171,8 +171,6 @@ export const checkImageContentType = async url => {
*/ */
export const getImageInfo = (src, baseUrl = window.DIRNAME) => { export const getImageInfo = (src, baseUrl = window.DIRNAME) => {
const EXT_REG = /\.(jpeg|jpg|png|gif|svg|webp)(?=\?|$)/i const EXT_REG = /\.(jpeg|jpg|png|gif|svg|webp)(?=\?|$)/i
// http[s] (domain or IPv4 or localhost or IPv6) [port] /not-white-space
const URL_REG = /^http(s)?:\/\/([a-z0-9\-._~]+\.[a-z]{2,}|[0-9.]+|localhost|\[[a-f0-9.:]+\])(:[0-9]{1,5})?\/[\S]+/i
// data:[<MIME-type>][;charset=<encoding>][;base64],<data> // data:[<MIME-type>][;charset=<encoding>][;base64],<data>
const DATA_URL_REG = /^data:image\/[\w+-]+(;[\w-]+=[\w-]+|;base64)*,[a-zA-Z0-9+/]+={0,2}$/ const DATA_URL_REG = /^data:image\/[\w+-]+(;[\w-]+=[\w-]+|;base64)*,[a-zA-Z0-9+/]+={0,2}$/

View File

@ -24,6 +24,7 @@
:source-code="sourceCode" :source-code="sourceCode"
:show-tab-bar="showTabBar" :show-tab-bar="showTabBar"
:text-direction="textDirection" :text-direction="textDirection"
:platform="platform"
></editor-with-tabs> ></editor-with-tabs>
<aidou></aidou> <aidou></aidou>
<upload-image></upload-image> <upload-image></upload-image>

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1553334996824" class="icon" style="" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2737" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M521.693867 449.297067L111.4112 39.0144a51.2 51.2 0 1 0-72.430933 72.362667l410.282666 410.3168-410.282666 410.3168a51.2 51.2 0 1 0 72.3968 72.3968l410.3168-410.282667 410.3168 410.282667a51.2 51.2 0 1 0 72.3968-72.362667l-410.282667-410.350933 410.282667-410.282667a51.2 51.2 0 1 0-72.3968-72.3968l-410.282667 410.282667z" p-id="2738"></path></svg>

After

Width:  |  Height:  |  Size: 735 B

View File

@ -10,6 +10,20 @@
ref="editor" ref="editor"
class="editor-component" class="editor-component"
></div> ></div>
<div
class="image-viewer"
v-show="imageViewerVisible"
>
<span class="icon-close" @click="setImageViewerVisible(false)">
<svg :viewBox="CloseIcon.viewBox">
<use :xlink:href="CloseIcon.url"></use>
</svg>
</span>
<div
ref="imageViewer"
>
</div>
</div>
<el-dialog <el-dialog
:visible.sync="dialogTableVisible" :visible.sync="dialogTableVisible"
:show-close="isShowClose" :show-close="isShowClose"
@ -60,6 +74,7 @@
<script> <script>
import { mapState } from 'vuex' import { mapState } from 'vuex'
import ViewImage from 'view-image'
import Muya from 'muya/lib' import Muya from 'muya/lib'
import TablePicker from 'muya/lib/ui/tablePicker' import TablePicker from 'muya/lib/ui/tablePicker'
import QuickInsert from 'muya/lib/ui/quickInsert' import QuickInsert from 'muya/lib/ui/quickInsert'
@ -75,6 +90,8 @@
import { DEFAULT_EDITOR_FONT_FAMILY } from '@/config' import { DEFAULT_EDITOR_FONT_FAMILY } from '@/config'
import 'muya/themes/light.css' import 'muya/themes/light.css'
import CloseIcon from '@/assets/icons/close.svg'
import 'view-image/lib/imgViewer.css'
const STANDAR_Y = 320 const STANDAR_Y = 320
@ -91,7 +108,8 @@
textDirection: { textDirection: {
type: String, type: String,
required: true required: true
} },
platform: String
}, },
computed: { computed: {
...mapState({ ...mapState({
@ -115,12 +133,14 @@
}, },
data () { data () {
this.defaultFontFamily = DEFAULT_EDITOR_FONT_FAMILY this.defaultFontFamily = DEFAULT_EDITOR_FONT_FAMILY
this.CloseIcon = CloseIcon
return { return {
selectionChange: null, selectionChange: null,
editor: null, editor: null,
pathname: '', pathname: '',
isShowClose: false, isShowClose: false,
dialogTableVisible: false, dialogTableVisible: false,
imageViewerVisible: false,
tableChecker: { tableChecker: {
rows: 4, rows: 4,
columns: 3 columns: 3
@ -243,6 +263,25 @@
this.$store.dispatch('LISTEN_FOR_CONTENT_CHANGE', changes) this.$store.dispatch('LISTEN_FOR_CONTENT_CHANGE', changes)
}) })
this.editor.on('format-click', ({ event, formatType, data }) => {
const isOsx = this.platform === 'darwin'
const ctrlOrMeta = (isOsx && event.metaKey) || (!isOsx && event.ctrlKey)
if (formatType === 'link' && ctrlOrMeta) {
this.$store.dispatch('FORMAT_LINK_CLICK', { data, dirname: window.DIRNAME })
} else if (formatType === 'image' && ctrlOrMeta) {
if (this.imageViewer) {
this.imageViewer.destroy()
}
this.imageViewer = new ViewImage(this.$refs.imageViewer, {
url: data,
snapView: true
})
this.setImageViewerVisible(true)
}
})
this.editor.on('selectionChange', changes => { this.editor.on('selectionChange', changes => {
const { y } = changes.cursorCoords const { y } = changes.cursorCoords
if (this.typewriter) { if (this.typewriter) {
@ -260,14 +299,25 @@
this.editor.on('contextmenu', (event, selectionChanges) => { this.editor.on('contextmenu', (event, selectionChanges) => {
showContextMenu(event, selectionChanges) showContextMenu(event, selectionChanges)
}) })
document.addEventListener('keyup', this.keyup)
}) })
}, },
methods: { methods: {
keyup (event) {
if (event.key === 'Escape') {
this.setImageViewerVisible(false)
}
},
handleImagePath (files) { handleImagePath (files) {
const { editor } = this const { editor } = this
editor && editor.showAutoImagePath(files) editor && editor.showAutoImagePath(files)
}, },
setImageViewerVisible (status) {
this.imageViewerVisible = status
},
handleUndo () { handleUndo () {
if (this.editor) { if (this.editor) {
this.editor.undo() this.editor.undo()
@ -453,6 +503,8 @@
bus.$off('copy-block', this.handleCopyBlock) bus.$off('copy-block', this.handleCopyBlock)
bus.$off('print', this.handlePrint) bus.$off('print', this.handlePrint)
document.removeEventListener('keyup', this.keyup)
this.editor.destroy() this.editor.destroy()
this.editor = null this.editor = null
} }
@ -506,4 +558,39 @@
padding-top: calc(50vh - 136px); padding-top: calc(50vh - 136px);
padding-bottom: calc(50vh - 54px); padding-bottom: calc(50vh - 54px);
} }
.image-viewer {
position: fixed;
backdrop-filter: blur(5px);
top: 0;
right: 0;
left: 0;
bottom: 0;
background: rgba(0, 0, 0, .8);
z-index: 11;
& .icon-close {
z-index: 1000;
width: 30px;
height: 30px;
position: absolute;
top: 50px;
left: 50px;
display: block;
& svg {
fill: #efefef;
width: 100%;
height: 100%;
}
}
}
.iv-container {
width: 100%;
height: 100%;
}
.iv-snap-view {
opacity: 1;
bottom: 20px;
right: 20px;
top: auto;
left: auto;
}
</style> </style>

View File

@ -9,6 +9,7 @@
:markdown="markdown" :markdown="markdown"
:cursor="cursor" :cursor="cursor"
:text-direction="textDirection" :text-direction="textDirection"
:platform="platform"
></editor> ></editor>
<source-code <source-code
v-if="sourceCode" v-if="sourceCode"
@ -51,6 +52,10 @@
textDirection: { textDirection: {
type: String, type: String,
required: true required: true
},
platform: {
type: String,
required: true
} }
}, },
components: { components: {

View File

@ -146,6 +146,9 @@ const actions = {
ASK_FOR_INSERT_IMAGE ({ commit }, type) { ASK_FOR_INSERT_IMAGE ({ commit }, type) {
ipcRenderer.send('AGANI::ask-for-insert-image', type) ipcRenderer.send('AGANI::ask-for-insert-image', type)
}, },
FORMAT_LINK_CLICK ({ commit }, { data, dirname }) {
ipcRenderer.send('AGANI::format-link-click', { data, dirname })
},
// image path auto complement // image path auto complement
ASK_FOR_IMAGE_AUTO_PATH ({ commit, state }, src) { ASK_FOR_IMAGE_AUTO_PATH ({ commit, state }, src) {
const { pathname } = state.currentFile const { pathname } = state.currentFile

View File

@ -10856,6 +10856,11 @@ verror@1.10.0:
core-util-is "1.0.2" core-util-is "1.0.2"
extsprintf "^1.2.0" extsprintf "^1.2.0"
view-image@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/view-image/-/view-image-0.0.1.tgz#f97a6c23e881caa173d83b148ef7ee8adbb890e5"
integrity sha512-xo6/w370OxBo9QLMZOXmRa0gSXNr9PfMQv4hFMZa6z8YFoyi77Q7/FRghANnPF9bILkX18fJDsC9fT1KlRzSDA==
vm-browserify@0.0.4: vm-browserify@0.0.4:
version "0.0.4" version "0.0.4"
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73"