Refactor inline image to support paste/drop image (#1028)

* feat: image setting

* opti: inline image

* add imageSelectAction

* remove axios from muya

* update image selector

* finish image selector ui

* add load success style

* delete image by click delete icon

* opti structure of image html

* handle arrow key

* enter to edit

* image preview by press space

* handle backspace when the previous element is image wrapper

* update codes for change another PC

* emable select all in input

* handle arrow and backspace key

* create a new paragraph after the last paragraph if its not empty

* handle backspace when the previous element is image wrapper

* handle enter event in image selector

* rewrite auto show image selector

* modify image folder

* copy file to folder

* select image

* handle paste image

* picgo

* guess image path from clipboard

* drag and drop image to Mark Text

* add github uploader

* remove unused codes

* remove unused codes

* rewrite image path auto complete

* support `path` imageInsertAction

* doc: add image uploader doc

* remove debug codes

* set init value in image uploader page

* fix typo

* remove unused codes

* drag web image to Mark Text

* add save notification

* opti uploading process

* fix did not close image selector bug

* check image content type when drag web link image

* fix: unable to preview relative path image.

* emit change event after paste/drop image

* add url map in image selector

* feat: screenshot and auto insert the screenshot image

* update error handler

* feat: use the native screencapture command line on macOs system

* opti: drop image

* fix: handle enter error when cursor is after a image

* fix: hasOwnProperty error

* remove debug codes

* fix: backspace when the previous ele is image

* fix: CI error and optimize some codes

* use hash of file path to generate the copied filename

* change default imageInsertAction to `path`

* fix: typo

* remove some unused codes and opti get image file name

* fix some bugs and opti codes

* update image edit icon

* romove screen capture on Linux and Windows

* fix: conflict

* fix error that can not insert image after the existed image or before existed image
This commit is contained in:
Ran Luo 2019-05-26 23:55:13 +08:00 committed by GitHub
parent 5b1cd85d95
commit c239e99f1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 3148 additions and 622 deletions

View File

@ -8,13 +8,14 @@ const getLicenses = (rootDir, callback) => {
production: true,
development: false,
direct: true,
excludePackages: 'xmldom@0.1.27', // xmldom@0.1.27 is under MIT License, but license-checker show it's under LGPL License.
json: true,
onlyAllow: 'Unlicense;WTFPL;ISC;MIT;BSD;ISC;Apache-2.0;MIT*;Apache*;BSD*',
customPath: {
"licenses": "",
"licenseText": "none"
licenses: '',
licenseText: 'none'
}
}, function(err, packages) {
}, function (err, packages) {
callback(err, packages, checker)
})
}

View File

@ -169,6 +169,7 @@ const rendererConfig = {
},
resolve: {
alias: {
'main': path.join(__dirname, '../src/main'),
'@': path.join(__dirname, '../src/renderer'),
'common': path.join(__dirname, '../src/common'),
'muya': path.join(__dirname, '../src/muya'),

View File

@ -81,8 +81,8 @@ Before you can get started developing, you need set up your build environment:
- libx11 (dev)
- libxkbfile (dev)
On Debian-based Linux: `sudo apt-get install libx11-dev libxkbfile-dev`
On Red Hat-based Linux: `sudo dnf install libx11-devel libxkbfile-devel`
On Debian-based Linux: `sudo apt-get install libx11-dev libxkbfile-dev libsecret-1-dev`
On Red Hat-based Linux: `sudo dnf install libx11-devel libxkbfile-devel libsecret-devel`
**Let's build:**

View File

@ -31,6 +31,8 @@ addons:
# atom/keyboard-layout
- libx11-dev
- libxkbfile-dev
# atom/node-keytar
- libsecret-1-dev
before_install:
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo apt-get -qq update ; fi

View File

@ -0,0 +1,23 @@
#### Image Uploader Configration
##### SM.MS
No need to config, it's free uploading service, thanks!
##### GitHub
1. Step 1, Create a GitHub [repo](https://github.com/new).
![5ce17b03726c384991](https://i.loli.net/2019/05/19/5ce17b03726c384991.png)
2. Step 2, Create a GitHub token in [Settings/Developer settings.](https://github.com/settings/tokens)
![5ce17bd849d5589341](https://i.loli.net/2019/05/19/5ce17bd849d5589341.png)
3. Config in Mark Text Preferences window. click `CmdOrCtrl + ,` to open Mark Text Preferences window.
![5ce17cb97b0f111638](https://i.loli.net/2019/05/19/5ce17cb97b0f111638.png)
4. Input you `token`, `owner name` and `repo name` whick you just created. Click `Save` and `Set As default Uploader`.
5. Paste an image into Mark Text and open you created repo to see the uploaded image.

View File

@ -57,6 +57,7 @@ Here is an example:
| `editFindPrevious` | Continue the search and find the previous match |
| `editReplace` | Replace the information with a replacement |
| `editAidou` | Show Aidou dialog |
| `editScreenshot` | Get the screenshot |
**Paragraph menu:**

View File

@ -28,7 +28,7 @@
"postinstall": "npm run rebuild && npm run lint:fix",
"build:muya": "cd src/muya && webpack --progress --colors --config webpack.config.js",
"release:muya": "npm run build:muya && cd src/muya && npm publish",
"rebuild": "electron-rebuild -f -o keyboard-layout,vscode-windows-registry",
"rebuild": "electron-rebuild -f -o keytar,keyboard-layout,vscode-windows-registry",
"gen-third-party": "node tools/generateThirdPartyLicense.js",
"validate-licenses": "node tools/validateLicenses.js"
},
@ -165,6 +165,7 @@
},
"dependencies": {
"@hfelix/electron-localshortcut": "^3.1.1",
"@octokit/rest": "^16.26.0",
"arg": "^4.1.0",
"axios": "^0.18.0",
"chokidar": "^3.0.0",
@ -188,7 +189,9 @@
"html-tags": "^3.0.0",
"katex": "^0.10.2",
"keyboard-layout": "^2.0.15",
"keytar": "^4.7.0",
"mermaid": "^8.0.0",
"plist": "^3.0.1",
"popper.js": "^1.15.0",
"prismjs": "^1.16.0",
"snabbdom": "^0.7.3",

View File

@ -17,7 +17,9 @@ class EnvPaths {
this._logPath = path.join(this._userDataPath, 'logs', `${currentDate.getFullYear()}${currentDate.getMonth() + 1}`)
this._preferencesPath = userDataPath // path.join(this._userDataPath, 'preferences')
this._preferencesFilePath = path.join(this._preferencesPath, 'preference.md')
this._dataCenterPath = userDataPath
this._preferencesFilePath = path.join(this._preferencesPath, 'preference.json')
// TODO(sessions): enable this...
// this._globalStorage = path.join(this._userDataPath, 'globalStorage')
@ -42,6 +44,10 @@ class EnvPaths {
return this._preferencesPath
}
get dataCenterPath () {
return this._dataCenterPath
}
get preferencesFilePath () {
return this._preferencesFilePath
}

View File

@ -1,5 +1,6 @@
import WindowManager from '../app/windowManager'
import Preference from '../preferences'
import DataCenter from '../dataCenter'
import Keybindings from '../keyboard/shortcutHandler'
import AppMenu from '../menu'
@ -14,6 +15,7 @@ class Accessor {
this.env = appEnvironment
this.paths = appEnvironment.paths // export paths to make it better accessible
this.preferences = new Preference(this.paths)
this.dataCenter = new DataCenter(this.paths)
this.keybindings = new Keybindings(userDataPath)
this.menu = new AppMenu(this.preferences, this.keybindings, userDataPath)
this.windowManager = new WindowManager(this.menu, this.preferences)

View File

@ -1,4 +1,9 @@
import { app, ipcMain, systemPreferences } from 'electron'
import path from 'path'
import fse from 'fs-extra'
import log from 'electron-log'
import { exec } from 'child_process'
import { app, ipcMain, systemPreferences, clipboard } from 'electron'
import dayjs from 'dayjs'
import { isLinux, isOsx } from '../config'
import { isDirectory, isMarkdownFileOrLink, normalizeAndResolvePath } from '../filesystem'
import { getMenuItemById } from '../menu'
@ -8,6 +13,7 @@ import { watchers } from '../utils/imagePathAutoComplement'
import EditorWindow from '../windows/editor'
import SettingWindow from '../windows/setting'
import { WindowType } from './windowManager'
// import ShortcutCapture from 'shortcut-capture'
class App {
@ -21,6 +27,8 @@ class App {
this._openFilesCache = []
this._openFilesTimer = null
this._windowManager = this._accessor.windowManager
// this.launchScreenshotWin = null // The window which call the screenshot.
// this.shortcutCapture = null
this._listenForIpcMain()
}
@ -74,6 +82,12 @@ class App {
})
}
get screenshotFileName () {
const screenshotFolderPath = this._accessor.dataCenter.getItem('screenshotFolderPath')
const fileName = `${dayjs().format('YYYY-MM-DD-HH-mm-ss')}-screenshot.png`
return path.join(screenshotFolderPath, fileName)
}
ready = () => {
const { _args: args } = this
if (!isOsx && args._.length) {
@ -126,6 +140,27 @@ class App {
} else {
this.createEditorWindow()
}
// this.shortcutCapture = new ShortcutCapture()
// if (process.env.NODE_ENV === 'development') {
// this.shortcutCapture.dirname = path.resolve(path.join(__dirname, '../../../node_modules/shortcut-capture'))
// }
// this.shortcutCapture.on('capture', async ({ dataURL }) => {
// const { screenshotFileName } = this
// const image = nativeImage.createFromDataURL(dataURL)
// const bufferImage = image.toPNG()
// if (this.launchScreenshotWin) {
// this.launchScreenshotWin.webContents.send('mt::screenshot-captured')
// this.launchScreenshotWin = null
// }
// try {
// // write screenshot image into screenshot folder.
// await fse.writeFile(screenshotFileName, bufferImage)
// } catch (err) {
// log.error(err)
// }
// })
}
openFile = (event, pathname) => {
@ -217,6 +252,34 @@ class App {
this.createEditorWindow()
})
ipcMain.on('screen-capture', win => {
if (isOsx) {
// Use macOs `screencapture` command line when in macOs system.
const { screenshotFileName } = this
exec(`screencapture -i -c`, async (err) => {
if (err) {
log.error(err)
return
}
try {
// Write screenshot image into screenshot folder.
const image = clipboard.readImage()
const bufferImage = image.toPNG()
await fse.writeFile(screenshotFileName, bufferImage)
} catch (err) {
log.error(err)
}
win.webContents.send('mt::screenshot-captured')
})
} else {
// Do nothing, maybe we'll add screenCapture later on Linux and Windows.
// if (this.shortcutCapture) {
// this.launchScreenshotWin = win
// this.shortcutCapture.shortcutCapture()
// }
}
})
ipcMain.on('app-create-settings-window', () => {
const settingWins = this._windowManager.windowsOfType(WindowType.SETTING)
if (settingWins.length >= 1) {

View File

@ -312,6 +312,12 @@ class WindowManager extends EventEmitter {
}
}
})
ipcMain.on('broadcast-user-data-changed', userData => {
for (const { browserWindow } of this._windows.values()) {
browserWindow.webContents.send('AGANI::user-preference', userData)
}
})
}
}

View File

@ -0,0 +1,202 @@
import fs from 'fs'
import path from 'path'
import EventEmitter from 'events'
import { BrowserWindow, ipcMain, dialog } from 'electron'
import keytar from 'keytar'
import schema from './schema'
import Store from 'electron-store'
import log from 'electron-log'
import { ensureDirSync } from '../filesystem'
import { IMAGE_EXTENSIONS } from '../config'
const DATA_CENTER_NAME = 'dataCenter'
class DataCenter extends EventEmitter {
constructor (paths) {
super()
const { dataCenterPath, userDataPath } = paths
this.dataCenterPath = dataCenterPath
this.userDataPath = userDataPath
this.serviceName = 'marktext'
this.encryptKeys = ['githubToken']
this.hasDataCenterFile = fs.existsSync(path.join(this.dataCenterPath, `./${DATA_CENTER_NAME}.json`))
this.store = new Store({
schema,
name: DATA_CENTER_NAME
})
this.init()
}
init () {
const defaltData = {
imageFolderPath: path.join(this.userDataPath, 'images/'),
screenshotFolderPath: path.join(this.userDataPath, 'screenshot/'),
webImages: [],
cloudImages: [],
currentUploader: 'smms',
imageBed: {
github: {
owner: '',
repo: ''
}
}
}
if (!this.hasDataCenterFile) {
this.store.set(defaltData)
const imageFolderPath = this.store.get('imageFolderPath')
const screenshotFolderPath = this.store.get('screenshotFolderPath')
ensureDirSync(imageFolderPath)
ensureDirSync(screenshotFolderPath)
}
this._listenForIpcMain()
}
async getAll () {
const { serviceName, encryptKeys } = this
const data = this.store.store
try {
const encryptData = await Promise.all(encryptKeys.map(key => {
return keytar.getPassword(serviceName, key)
}))
const encryptObj = encryptKeys.reduce((acc, k, i) => {
return {
...acc,
[k]: encryptData[i]
}
}, {})
return Object.assign(data, encryptObj)
} catch (err) {
log.error(err)
return data
}
}
addImage (key, url) {
const items = this.store.get(key)
const alreadyHas = items.some(item => item.url === url)
let item
if (alreadyHas) {
item = items.find(item => item.url === url)
item.timeStamp = +new Date()
} else {
item = {
url,
timeStamp: +new Date()
}
items.push(item)
}
ipcMain.emit('broadcast-web-image-added', { type: key, item })
return this.store.set(key, items)
}
removeImage (type, url) {
const items = this.store.get(type)
const index = items.indexOf(url)
const item = items[index]
if (index === -1) return
items.splice(index, 1)
ipcMain.emit('broadcast-web-image-removed', { type, item })
return this.store.set(type, items)
}
/**
*
* @param {string} key
* return a promise
*/
getItem (key) {
const { encryptKeys, serviceName } = this
if (encryptKeys.includes(key)) {
return keytar.getPassword(serviceName, key)
} else {
const value = this.store.get(key)
return Promise.resolve(value)
}
}
async setItem (key, value) {
const { encryptKeys, serviceName } = this
if (
key === 'imageFolderPath' ||
key === 'screenshotFolderPath'
) {
ensureDirSync(value)
}
ipcMain.emit('broadcast-user-data-changed', { [key]: value })
if (encryptKeys.includes(key)) {
try {
return await keytar.setPassword(serviceName, key, value)
} catch (err) {
log.error(err)
}
} else {
return this.store.set(key, value)
}
}
/**
* Change multiple setting entries.
*
* @param {Object.<string, *>} settings A settings object or subset object with key/value entries.
*/
setItems (settings) {
if (!settings) {
log.error('Cannot change settings without entires: object is undefined or null.')
return
}
Object.keys(settings).map(key => {
this.setItem(key, settings[key])
})
}
_listenForIpcMain () {
// local main events
ipcMain.on('set-image-folder-path', newPath => {
this.setItem('imageFolderPath', newPath)
})
// events from renderer process
ipcMain.on('mt::ask-for-user-data', async e => {
const win = BrowserWindow.fromWebContents(e.sender)
const userData = await this.getAll()
win.webContents.send('AGANI::user-preference', userData)
})
ipcMain.on('mt::ask-for-modify-image-folder-path', e => {
const win = BrowserWindow.fromWebContents(e.sender)
const folder = dialog.showOpenDialog(win, {
properties: [ 'openDirectory', 'createDirectory' ]
})
if (folder && folder[0]) {
this.setItem('imageFolderPath', folder[0])
}
})
ipcMain.on('mt::set-user-data', (e, userData) =>{
this.setItems(userData)
})
ipcMain.on('mt::ask-for-image-path', e => {
const win = BrowserWindow.fromWebContents(e.sender)
const files = dialog.showOpenDialog(win, {
properties: [ 'openFile' ],
filters: [{
name: 'Images',
extensions: IMAGE_EXTENSIONS
}]
})
if (files && files[0]) {
e.returnValue = files[0]
} else {
e.returnValue = ''
}
})
}
}
export default DataCenter

View File

@ -0,0 +1,26 @@
{
"imageFolderPath": {
"description": "The image folder to store local images.",
"type": "string"
},
"screenshotFolderPath": {
"description": "The place to store screen capture images.",
"type": "string"
},
"webImages": {
"description": "Images from web which you used in Mark Text",
"type": "array"
},
"cloudImages": {
"description": "Images which you upload to cloud",
"type": "array"
},
"currentUploader": {
"description": "The current image uploader",
"type": "string"
},
"imageBed": {
"description": "The image bed configration",
"type": "object"
}
}

View File

@ -1,6 +1,7 @@
import fs from 'fs-extra'
import path from 'path'
import { hasMarkdownExtension } from '../utils'
import { IMAGE_EXTENSIONS } from '../config'
/**
* Ensure that a directory exist.
@ -64,6 +65,18 @@ export const isSymbolicLink = filepath => {
export const isMarkdownFile = filepath => {
return isFile(filepath) && hasMarkdownExtension(filepath)
}
/**
* Returns ture if the path is an image file.
*
* @param {string} filepath The path
*/
export const isImageFile = filepath => {
const extname = path.extname(filepath)
return isFile(filepath) && IMAGE_EXTENSIONS.some(ext => {
const EXT_REG = new RegExp(ext, 'i')
return EXT_REG.test(extname)
})
}
/**
* Returns true if the path is a markdown file or symbolic link to a markdown file.

View File

@ -55,6 +55,7 @@ class Keybindings {
['editFindPrevious', 'CmdOrCtrl+Shift+U'],
['editReplace', 'CmdOrCtrl+Alt+F'],
['editAidou', 'CmdOrCtrl+/'],
['editScreenshot', 'CmdOrCtrl+Alt+A'],
// paragraph menu
['paragraphHeading1', 'CmdOrCtrl+1'],

View File

@ -1,42 +1,25 @@
import path from 'path'
import { dialog, ipcMain, BrowserWindow } from 'electron'
import { ipcMain, BrowserWindow } from 'electron'
import log from 'electron-log'
import { IMAGE_EXTENSIONS } from '../../config'
import { updateLineEndingMenu } from '../../menu'
import { searchFilesAndDir } from '../../utils/imagePathAutoComplement'
const getAndSendImagePath = (win, type) => {
// TODO(need::refactor): use async dialog version
const filename = dialog.showOpenDialog(win, {
properties: [ 'openFile' ],
filters: [{
name: 'Images',
extensions: IMAGE_EXTENSIONS
}]
})
if (filename && filename[0]) {
win.webContents.send('AGANI::INSERT_IMAGE', { filename: filename[0], type })
}
}
ipcMain.on('AGANI::ask-for-insert-image', (e, type) => {
const win = BrowserWindow.fromWebContents(e.sender)
getAndSendImagePath(win, type)
})
ipcMain.on('AGANI::ask-for-image-auto-path', (e, { pathname, src }) => {
ipcMain.on('mt::ask-for-image-auto-path', (e, { pathname, src, id }) => {
const win = BrowserWindow.fromWebContents(e.sender)
if (src.endsWith('/') || src.endsWith('\\') || src.endsWith('.')) {
return win.webContents.send('AGANI::image-auto-path', [])
return win.webContents.send(`mt::response-of-image-path-${id}`, [])
}
const fullPath = path.isAbsolute(src) ? src : path.join(path.dirname(pathname), src)
const dir = path.dirname(fullPath)
const searchKey = path.basename(fullPath)
searchFilesAndDir(dir, searchKey)
.then(files => {
win.webContents.send('AGANI::image-auto-path', files)
return win.webContents.send(`mt::response-of-image-path-${id}`, files)
})
.catch(err => {
log.error(err)
return win.webContents.send(`mt::response-of-image-path-${id}`, [])
})
.catch(log.error)
})
ipcMain.on('AGANI::update-line-ending-menu', (e, lineEnding) => {
@ -47,14 +30,10 @@ export const edit = (win, type) => {
win.webContents.send('AGANI::edit', { type })
}
export const screenshot = (win, type) => {
ipcMain.emit('screen-capture', win)
}
export const lineEnding = (win, lineEnding) => {
win.webContents.send('AGANI::set-line-ending', { lineEnding, ignoreSaveStatus: false })
}
export const insertImage = (win, type) => {
if (type === 'absolute' || type === 'relative') {
getAndSendImagePath(win, type)
} else {
win.webContents.send('AGANI::INSERT_IMAGE', { type })
}
}

View File

@ -1,4 +1,5 @@
import * as actions from '../actions/edit'
import { isOsx } from '../../config'
export default function (keybindings, userPreference) {
const { aidou } = userPreference.getAll()
@ -114,23 +115,13 @@ export default function (keybindings, userPreference) {
actions.edit(browserWindow, 'aidou')
}
}, {
label: 'Insert Image',
submenu: [{
label: 'Absolute Path',
click (menuItem, browserWindow) {
actions.insertImage(browserWindow, 'absolute')
}
}, {
label: 'Relative Path',
click (menuItem, browserWindow) {
actions.insertImage(browserWindow, 'relative')
}
}, {
label: 'Upload to Cloud (EXP)',
click (menuItem, browserWindow) {
actions.insertImage(browserWindow, 'upload')
}
}]
label: 'Screenshot',
id: 'screenshot',
visible: isOsx,
accelerator: keybindings.getAccelerator('editScreenshot'),
click (menuItem, browserWindow) {
actions.screenshot(browserWindow, 'screenshot')
}
}, {
type: 'separator'
}, {

View File

@ -1,4 +1,5 @@
import edit from './edit'
import prefEdit from './prefEdit'
import file from './file'
import help from './help'
import marktext from './marktext'
@ -18,6 +19,7 @@ export dockMenu from './dock'
export const configSettingMenu = (keybindings) => {
return [
...(process.platform === 'darwin' ? [ marktext(keybindings) ] : []),
prefEdit(keybindings),
help()
]
}

View File

@ -0,0 +1,24 @@
export default function (keybindings) {
return {
label: 'Edit',
submenu: [{
label: 'Cut',
accelerator: keybindings.getAccelerator('editCut'),
role: 'cut'
}, {
label: 'Copy',
accelerator: keybindings.getAccelerator('editCopy'),
role: 'copy'
}, {
label: 'Paste',
accelerator: keybindings.getAccelerator('editPaste'),
role: 'paste'
}, {
type: 'separator'
}, {
label: 'Select All',
accelerator: keybindings.getAccelerator('editSelectAll'),
role: 'selectAll'
}]
}
}

View File

@ -62,20 +62,24 @@ class Preference extends EventEmitter {
if (!this.hasPreferencesFile) {
this.store.set(defaultSettings)
} else {
let userSetting = this.getAll()
// Because `this.getAll()` will return a plainObject, so we can not use `hasOwnProperty` method
// const plainObject = () => Object.create(null)
const userSetting = this.getAll()
// Update outdated settings
const requiresUpdate = !hasSameKeys(defaultSettings, userSetting)
const userSettingKeys = Object.keys(userSetting)
const defaultSettingKeys = Object.keys(defaultSettings)
if (requiresUpdate) {
// remove outdated settings
for (const key in userSetting) {
if (userSetting.hasOwnProperty(key) && !defaultSettings.hasOwnProperty(key)) {
for (const key of userSettingKeys) {
if (!defaultSettingKeys.includes(key)) {
delete userSetting[key]
}
}
// add new setting options
for (const key in defaultSettings) {
if (defaultSettings.hasOwnProperty(key) && !userSetting.hasOwnProperty(key)) {
if (!userSettingKeys.includes(key)) {
userSetting[key] = defaultSettings[key]
}
}

View File

@ -115,14 +115,6 @@
"description": "Editor--Hide hint for quickly creating paragraphs",
"type": "boolean"
},
"imageDropAction": {
"description": "Editor--The default behavior after paste or drag the image to Mark Text",
"enum": [
"upload",
"folder",
"path"
]
},
"preferLooseListItem": {
"description": "Markdown--The preferred list type",
"type": "boolean"
@ -167,5 +159,14 @@
"theme": {
"description": "Theme--Select the theme used in Mark Text",
"type": "string"
},
"imageInsertAction": {
"description": "Image--The default behavior after insert image from local folder",
"enum": [
"upload",
"folder",
"path"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -22,6 +22,12 @@ pre {
outline: none;
}
#mu-dragover-ghost {
height: 3px;
position: absolute;
background: var(--highlightColor);
}
div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-paragraph-content:first-of-type:empty::after {
content: 'Type @ to insert';
color: var(--editorColor10);
@ -741,6 +747,175 @@ span.ag-warn.ag-emoji-marked-text {
vertical-align: middle;
}
.ag-inline-image {
display: flex;
position: relative;
background: var(--codeBlockBgColor);
border-radius: 3px;
overflow: hidden;
margin: 0 auto;
justify-content: space-around;
}
.ag-inline-image .ag-image-container {
position: relative;
overflow: hidden;
}
.ag-inline-image.ag-image-success {
background: transparent;
}
.ag-inline-image a.ag-image-icon-turninto,
.ag-inline-image a.ag-image-icon-delete {
opacity: 0;
display: none;
width: 30px;
height: 30px;
position: absolute;
top: 15px;
background: rgba(50, 54, 58, .5);
border-radius: 10px;
cursor: pointer;
}
.ag-inline-image a.ag-image-icon-turninto i.icon,
.ag-inline-image a.ag-image-icon-delete i.icon {
color: #ccc;
transition: color .25s ease-in-out;
}
.ag-inline-image a.ag-image-icon-turninto i.icon:hover,
.ag-inline-image a.ag-image-icon-delete i.icon:hover {
color: #fff;
}
.ag-inline-image a.ag-image-icon-turninto {
right: 60px;
}
.ag-inline-image a.ag-image-icon-delete {
right: 15px;
}
.ag-inline-image a.ag-image-icon-success,
.ag-inline-image a.ag-image-icon-fail,
.ag-inline-image a.ag-image-icon-close {
display: none;
width: 20px;
height: 20px;
position: absolute;
top: 15px;
}
.ag-inline-image a.ag-image-icon-success,
.ag-inline-image a.ag-image-icon-fail {
left: 15px;
}
.ag-inline-image i.icon {
display: inline-block;
position: relative;
overflow: hidden;
width: 20px;
height: 20px;
color: var(--iconColor);
}
.ag-inline-image i.icon > i[class^=icon-] {
width: 100%;
height: 100%;
display: inline-block;
filter: drop-shadow(20px 0 currentColor);
position: absolute;
left: -20px;
}
.ag-inline-image.ag-image-fail,
.ag-inline-image.ag-image-loading,
.ag-inline-image.ag-empty-image {
width: 100%;
height: 50px;
cursor: pointer;
}
.ag-inline-image.ag-image-loading::before,
.ag-inline-image.ag-image-fail::before,
.ag-inline-image.ag-empty-image::before {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(100% - 50px);
display: block;
position: absolute;
top: 15px;
left: 50px;
color: var(--editorColor);
font-size: 14px;
}
.ag-inline-image.ag-empty-image::before {
content: 'Click to add an image';
}
.ag-inline-image.ag-image-fail::before {
content: 'Load image failed';
}
.ag-inline-image.ag-image-loading::before {
content: 'Loading image...';
}
.ag-inline-image.ag-image-loading a.ag-image-icon-success,
.ag-inline-image.ag-empty-image a.ag-image-icon-success {
display: block;
}
.ag-inline-image.ag-image-fail a.ag-image-icon-fail {
display: block;
}
.ag-inline-image.ag-image-success:hover a.ag-image-icon-turninto,
.ag-inline-image.ag-image-success:hover a.ag-image-icon-delete {
opacity: 1;
display: flex;
align-items: center;
justify-content: space-around;
z-index: 1;
}
.ag-inline-image.ag-empty-image:hover a.ag-image-icon-close,
.ag-inline-image.ag-image-fail:hover a.ag-image-icon-close {
opacity: .5;
display: block;
align-items: center;
justify-content: space-around;
z-index: 1;
right: 15px;
}
.ag-inline-image.ag-image-success.ag-inline-image-selected .ag-image-container img {
filter: brightness(80%);
}
.ag-inline-image.ag-image-uploading .ag-image-container img {
opacity: .3;
}
.ag-inline-image.ag-image-uploading .ag-image-container a.ag-image-icon-turninto,
.ag-inline-image.ag-image-uploading .ag-image-container a.ag-image-icon-delete {
display: none;
}
.ag-inline-image.ag-image-uploading .ag-image-container::before {
content: 'Loading ...';
top: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%) translateY(-50%);
color: var(--iconColor);
}
.ag-image-marked-text ~ img {
display: block;
margin: 0 auto;
@ -749,8 +924,9 @@ span.ag-warn.ag-emoji-marked-text {
.ag-image-marked-text::before {
background-size: cover;
content: '';
width: 1em;
height: 1em;
width: 1.2em;
height: 1.2em;
margin-right: 5px;
display: inline-block;
vertical-align: sub;
}

View File

@ -47,6 +47,7 @@ export const emptyElementNames = ['br', 'col', 'colgroup', 'hr', 'img', 'input',
export const EVENT_KEYS = generateKeyHash([
'Enter',
'Backspace',
'Space',
'Delete',
'ArrowUp',
'ArrowDown',
@ -95,8 +96,16 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([
'AG_HTML_PREVIEW',
'AG_HTML_TAG',
'AG_IMAGE_FAIL',
'AG_IMAGE_LOADING',
'AG_EMPTY_IMAGE',
'AG_IMAGE_MARKED_TEXT',
'AG_IMAGE_SRC',
'AG_IMAGE_CONTAINER',
'AG_INLINE_IMAGE',
'AG_IMAGE_SUCCESS',
'AG_IMAGE_UPLOADING',
'AG_INLINE_IMAGE_SELECTED',
'AG_INLINE_IMAGE_IS_EDIT',
'AG_INDENT_CODE',
'AG_INLINE_RULE',
'AG_LANGUAGE',
@ -240,7 +249,14 @@ export const MUYA_DEFAULT_OPTION = {
sequenceTheme: 'hand', // hand or simple
mermaidTheme: 'default', // dark / forest / default
vegaTheme: 'latimes', // excel / ggplot2 / quartz / vox / fivethirtyeight / dark / latimes
hideQuickInsertHint: false
hideQuickInsertHint: false,
// transform the image to local folder, cloud or just return the local path
imageAction: null,
// Call Electron open dialog or input element type is file.
imagePathPicker: null,
clipboardFilePath: () => {},
// image path auto completed when you input in image selector.
imagePathAutoComplete: () => []
}
// export const DIAGRAM_TEMPLATE = {
@ -249,5 +265,8 @@ export const MUYA_DEFAULT_OPTION = {
export const isInElectron = window && window.process && window.process.type === 'renderer'
export const isOsx = window && window.navigator && /Mac/.test(window.navigator.platform)
export const isWin = window && window.navigator.userAgent && /win32|wow32|win64|wow64/i.test(window.navigator.userAgent)
// 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
// The smallest transparent gif base64 image.
export const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

View File

@ -53,6 +53,36 @@ const arrowCtrl = ContentState => {
return null
}
ContentState.prototype.docArrowHandler = function (event) {
const { selectedImage } = this
if (selectedImage) {
const { key, token } = selectedImage
const { start, end } = token.range
event.preventDefault()
event.stopPropagation()
const block = this.getBlock(key)
switch (event.key) {
case EVENT_KEYS.ArrowUp:
case EVENT_KEYS.ArrowLeft: {
this.cursor = {
start: { key, offset: start },
end: { key, offset: start }
}
break
}
case EVENT_KEYS.ArrowDown:
case EVENT_KEYS.ArrowRight: {
this.cursor = {
start: { key, offset: end },
end: { key, offset: end }
}
break
}
}
return this.singleRender(block)
}
}
ContentState.prototype.arrowHandler = function (event) {
const node = selection.getSelectionStart()
const paragraph = findNearestParagraph(node)

View File

@ -1,6 +1,7 @@
import selection from '../selection'
import { findNearestParagraph, findOutMostParagraph } from '../selection/dom'
import { tokenizer, generator } from '../parser/'
import { getImageInfo } from '../utils/getImageInfo'
const backspaceCtrl = ContentState => {
ContentState.prototype.checkBackspaceCase = function () {
@ -100,6 +101,14 @@ const backspaceCtrl = ContentState => {
}
}
ContentState.prototype.docBackspaceHandler = function (event) {
// handle delete selected image
if (this.selectedImage) {
event.preventDefault()
return this.deleteImage(this.selectedImage)
}
}
ContentState.prototype.backspaceHandler = function (event) {
const { start, end } = selection.getCursorRange()
@ -211,14 +220,50 @@ const backspaceCtrl = ContentState => {
}
const node = selection.getSelectionStart()
const preEleSibling = node && node.nodeType === 1 ? node.previousElementSibling : null
const paragraph = findNearestParagraph(node)
const id = paragraph.id
let block = this.getBlock(id)
let parent = this.getBlock(block.parent)
const preBlock = this.findPreBlockInLocation(block)
const { left } = selection.getCaretOffsets(paragraph)
const { left, right } = selection.getCaretOffsets(paragraph)
const inlineDegrade = this.checkBackspaceCase()
// Handle backspace when the previous is an inline image.
if (preEleSibling && preEleSibling.classList.contains('ag-inline-image')) {
if (selection.getCaretOffsets(node).left === 0) {
event.preventDefault()
event.stopPropagation()
const imageInfo = getImageInfo(preEleSibling)
return this.selectImage(imageInfo)
}
if (selection.getCaretOffsets(node).left === 1 && right === 0) {
event.stopPropagation()
event.preventDefault()
const key = startBlock.key
const text = startBlock.text
startBlock.text = text.substring(0, start.offset - 1) + text.substring(start.offset)
const offset = start.offset - 1
this.cursor = {
start: { key, offset },
end: { key, offset }
}
return this.singleRender(startBlock)
}
}
// handle backspace when cursor at the end of inline image.
if (node.classList.contains('ag-image-container')) {
const imageWrapper = node.parentNode
const imageInfo = getImageInfo(imageWrapper)
if (start.offset === imageInfo.token.range.end) {
event.preventDefault()
event.stopPropagation()
return this.selectImage(imageInfo)
}
}
const tableHasContent = table => {
const tHead = table.children[0]
const tBody = table.children[1]

View File

@ -1,10 +1,30 @@
import selection from '../selection'
import { isMuyaEditorElement } from '../selection/dom'
import { HAS_TEXT_BLOCK_REG } from '../config'
const clickCtrl = ContentState => {
ContentState.prototype.clickHandler = function (event) {
const { eventCenter } = this.muya
const { target } = event
if (isMuyaEditorElement(target)) {
const lastBlock = this.getLastBlock()
const archor = this.findOutMostBlock(lastBlock)
const archorParagraph = document.querySelector(`#${archor.key}`)
const rect = archorParagraph.getBoundingClientRect()
// If click below the last paragraph
// and the last paragraph is not empty, create a new empty paragraph
if (/\S/.test(lastBlock.text) && event.clientY > rect.top + rect.height) {
const paragraphBlock = this.createBlockP()
this.insertAfter(paragraphBlock, archor)
const key = paragraphBlock.key
const offset = 0
this.cursor = {
start: { key, offset },
end: { key, offset }
}
return this.partialRender()
}
}
// handle front menu click
const { start: oldStart, end: oldEnd } = this.cursor
if (oldStart && oldEnd) {

View File

@ -0,0 +1,180 @@
import { findNearestParagraph, findOutMostParagraph } from '../selection/dom'
import { verticalPositionInRect, getUniqueId, getImageInfo as getImageSrc, checkImageContentType } from '../utils'
import { getImageInfo } from '../utils/getImageInfo'
import { URL_REG, IMAGE_EXT_REG } from '../config'
const GHOST_ID = 'mu-dragover-ghost'
const GHOST_HEIGHT = 3
const dragDropCtrl = ContentState => {
ContentState.prototype.hideGhost = function () {
this.dropAnchor = null
const ghost = document.querySelector(`#${GHOST_ID}`)
ghost && ghost.remove()
}
/**
* create the ghost element.
*/
ContentState.prototype.createGhost = function (event) {
const target = event.target
let ghost = null
const nearestParagraph = findNearestParagraph(target)
const outmostParagraph = findOutMostParagraph(target)
if (!outmostParagraph) {
return this.hideGhost()
}
const block = this.getBlock(nearestParagraph.id)
let anchor = this.getAnchor(block)
// dragover preview container
if (!anchor && outmostParagraph) {
anchor = this.getBlock(outmostParagraph.id)
}
if (anchor) {
const anchorParagraph = this.muya.container.querySelector(`#${anchor.key}`)
const rect = anchorParagraph.getBoundingClientRect()
const position = verticalPositionInRect(event, rect)
this.dropAnchor = {
position,
anchor
}
// create ghost
ghost = document.querySelector(`#${GHOST_ID}`)
if (!ghost) {
ghost = document.createElement('div')
ghost.id = GHOST_ID
document.body.appendChild(ghost)
}
Object.assign(ghost.style, {
width: `${rect.width}px`,
left: `${rect.left}px`,
top: position === 'up' ? `${rect.top - GHOST_HEIGHT}px` : `${rect.top + rect.height}px`
})
}
}
ContentState.prototype.dragoverHandler = function (event) {
// Cancel to allow tab drag&drop.
if (!event.dataTransfer.types.length) {
return event.dataTransfer.dropEffect = 'none'
}
if (event.dataTransfer.types.includes('text/uri-list')) {
const items = Array.from(event.dataTransfer.items)
const hasUriItem = items.some(i => i.type === 'text/uri-list')
const hasTextItem = items.some(i => i.type === 'text/plain')
const hasHtmlItem = items.some(i => i.type === 'text/html')
if (hasUriItem && hasHtmlItem && !hasTextItem) {
this.createGhost(event)
event.dataTransfer.dropEffect = 'copy'
}
}
if (event.dataTransfer.types.indexOf('Files') >= 0) {
if (event.dataTransfer.items.length === 1 && event.dataTransfer.items[0].type.indexOf('image') > -1) {
event.preventDefault()
this.createGhost(event)
event.dataTransfer.dropEffect = 'copy'
}
} else {
event.stopPropagation()
event.dataTransfer.dropEffect = 'none'
}
}
ContentState.prototype.dragleaveHandler = function (event) {
return this.hideGhost()
}
ContentState.prototype.dropHandler = async function (event) {
event.preventDefault()
const { dropAnchor } = this
this.hideGhost()
// handle drag/drop web link image.
if (event.dataTransfer.items.length) {
for (const item of event.dataTransfer.items) {
if (item.kind === 'string' && item.type === 'text/uri-list') {
item.getAsString(async str => {
if (URL_REG.test(str) && dropAnchor) {
let isImage = false
if (IMAGE_EXT_REG.test(str)) {
isImage = true
}
if (!isImage) {
isImage = await checkImageContentType(str)
}
if (!isImage) return
const text = `![](${str})`
const imageBlock = this.createBlockP(text)
const { anchor, position } = dropAnchor
if (position === 'up') {
this.insertBefore(imageBlock, anchor)
} else {
this.insertAfter(imageBlock, anchor)
}
const key = imageBlock.children[0].key
const offset = 0
this.cursor = {
start: { key, offset },
end: { key, offset }
}
this.render()
this.muya.eventCenter.dispatch('stateChange')
}
})
}
}
}
if (event.dataTransfer.files) {
const fileList = []
for (const file of event.dataTransfer.files) {
fileList.push(file)
}
const image = fileList.find(file => /image/.test(file.type))
if (image && dropAnchor) {
const { name, path } = image
const id = `loading-${getUniqueId()}`
const text = `![${id}](${path})`
const imageBlock = this.createBlockP(text)
const { anchor, position } = dropAnchor
if (position === 'up') {
this.insertBefore(imageBlock, anchor)
} else {
this.insertAfter(imageBlock, anchor)
}
const key = imageBlock.children[0].key
const offset = 0
this.cursor = {
start: { key, offset },
end: { key, offset }
}
this.render()
const nSrc = await this.muya.options.imageAction(path)
const { src } = getImageSrc(path)
if (src) {
this.stateRender.urlMap.set(nSrc, src)
}
const imageWrapper = this.muya.container.querySelector(`span[data-id=${id}]`)
if (imageWrapper) {
const imageInfo = getImageInfo(imageWrapper)
this.replaceImage(imageInfo, {
alt: name,
src: nSrc
})
}
}
this.muya.eventCenter.dispatch('stateChange')
}
}
}
export default dragDropCtrl

View File

@ -144,12 +144,41 @@ const enterCtrl = ContentState => {
return this.partialRender()
}
ContentState.prototype.docEnterHandler = function (event) {
const { eventCenter } = this.muya
const { selectedImage } = this
// Show image selector when you press Enter key and there is already one image selected.
if (selectedImage) {
event.preventDefault()
event.stopPropagation()
const { imageId, ...imageInfo } = selectedImage
const imageWrapper = document.querySelector(`#${imageId}`)
const rect = imageWrapper.getBoundingClientRect()
const reference = {
getBoundingClientRect () {
rect.height = 0 // Put image selector bellow the top border of image.
return rect
}
}
eventCenter.dispatch('muya-image-selector', {
reference,
imageInfo,
cb: () => {}
})
this.selectedImage = null
return
}
}
ContentState.prototype.enterHandler = function (event) {
const { start, end } = selection.getCursorRange()
if (!start || !end) {
return event.preventDefault()
}
let block = this.getBlock(start.key)
const { text } = block
const endBlock = this.getBlock(end.key)
let parent = this.getParent(block)
@ -161,7 +190,6 @@ const enterCtrl = ContentState => {
this.updateCodeLanguage(block, block.text.trim())
return
}
// handle select multiple blocks
if (start.key !== end.key) {
const key = start.key
@ -320,7 +348,8 @@ const enterCtrl = ContentState => {
block = parent
parent = this.getParent(block)
}
const { left, right } = selection.getCaretOffsets(paragraph)
const left = start.offset
const right = text.length - left
const type = block.type
let newBlock

View File

@ -1,6 +1,7 @@
import selection from '../selection'
import { tokenizer, generator } from '../parser/'
import { FORMAT_MARKER_MAP, FORMAT_TYPES, URL_REG } from '../config'
import { FORMAT_MARKER_MAP, FORMAT_TYPES } from '../config'
import { getImageInfo } from '../utils/getImageInfo'
const getOffset = (offset, { range: { start, end }, type, tag, anchor, alt }) => {
const dis = offset - start
@ -221,73 +222,6 @@ const formatCtrl = ContentState => {
block.text = generator(tokens)
}
ContentState.prototype.insertImage = function (url) {
const title = /\/?([^./]+)\.[a-z]+$/.exec(url)[1] || ''
const { start, end } = this.cursor
const { formats } = this.selectionFormats({ start, end })
const { key, offset: startOffset } = start
const { offset: endOffset } = end
const block = this.getBlock(key)
const { text } = block
const imageFormat = formats.filter(f => f.type === 'image')
// Only encode URLs but not local paths or data URLs
let imgUrl
if (URL_REG.test(url)) {
imgUrl = encodeURI(url)
} else {
imgUrl = url
}
if (imageFormat.length === 1) {
// Replace already existing image
let imageTitle = title
// Extract title from image if there isn't an image source already (GH#562). E.g: ![old-title]()
if (imageFormat[0].alt && !imageFormat[0].src) {
imageTitle = imageFormat[0].alt
}
const { start, end } = imageFormat[0].range
block.text = text.substring(0, start) +
`![${imageTitle}](${imgUrl})` +
text.substring(end)
this.cursor = {
start: { key, offset: start + 2 },
end: { key, offset: start + 2 + imageTitle.length }
}
} else if (key !== end.key) {
// Replace multi-line text
const endBlock = this.getBlock(end.key)
const { text } = endBlock
endBlock.text = text.substring(0, endOffset) + `![${title}](${imgUrl})` + text.substring(endOffset)
const offset = endOffset + 2
this.cursor = {
start: { key: end.key, offset },
end: { key: end.key, offset: offset + title.length }
}
} else {
// Replace single-line text
const imageTitle = startOffset !== endOffset ? text.substring(startOffset, endOffset) : title
block.text = text.substring(0, start.offset) +
`![${imageTitle}](${imgUrl})` +
text.substring(end.offset)
this.cursor = {
start: {
key,
offset: startOffset + 2
},
end: {
key,
offset: startOffset + 2 + imageTitle.length
}
}
}
this.partialRender()
}
ContentState.prototype.format = function (type) {
const { start, end } = selection.getCursorRange()
if (!start || !end) {
@ -331,6 +265,23 @@ const formatCtrl = ContentState => {
end.offset += end.delata
startBlock.text = generator(tokens)
addFormat(type, startBlock, { start, end })
if (type === 'image') {
// Show image selector when create a inline image by menu/shortcut/or just input `![]()`
requestAnimationFrame(() => {
const startNode = selection.getSelectionStart()
if (startNode) {
const imageWrapper = startNode.closest('.ag-inline-image')
if (imageWrapper && imageWrapper.classList.contains('ag-empty-image')) {
const imageInfo = getImageInfo(imageWrapper)
this.muya.eventCenter.dispatch('muya-image-selector', {
reference: imageWrapper,
imageInfo,
cb: () => {}
})
}
}
})
}
}
this.cursor = { start, end }
this.partialRender()

View File

@ -0,0 +1,138 @@
import { URL_REG } from '../config'
const imageCtrl = ContentState => {
/**
* insert inline image at the cursor position.
*/
ContentState.prototype.insertImage = function ({ alt = '', src = '', title = '' }) {
const match = /(?:\/|\\)?([^./\\]+)\.[a-z]+$/.exec(src)
if (!alt) {
alt = match && match[1] ? match[1] : ''
}
const { start, end } = this.cursor
const { formats } = this.selectionFormats({ start, end })
const { key, offset: startOffset } = start
const { offset: endOffset } = end
const block = this.getBlock(key)
if (
block.type === 'span' &&
(
block.functionType === 'codeLine' ||
block.functionType === 'languageInput' ||
block.functionType === 'thematicBreakLine'
)
) {
// You can not insert image into code block or language input...
return
}
const { text } = block
const imageFormat = formats.filter(f => f.type === 'image')
// Only encode URLs but not local paths or data URLs
let imgUrl
if (URL_REG.test(src)) {
imgUrl = encodeURI(src)
} else {
imgUrl = src
}
let srcAndTitle = imgUrl
if (srcAndTitle && title) {
srcAndTitle += ` "${title}"`
}
if (
imageFormat.length === 1 &&
imageFormat[0].range.start !== startOffset &&
imageFormat[0].range.end !== endOffset
) {
// Replace already existing image
let imageAlt = alt
// Extract alt from image if there isn't an image source already (GH#562). E.g: ![old-alt]()
if (imageFormat[0].alt && !imageFormat[0].src) {
imageAlt = imageFormat[0].alt
}
const { start, end } = imageFormat[0].range
block.text = text.substring(0, start) +
`![${imageAlt}](${srcAndTitle})` +
text.substring(end)
this.cursor = {
start: { key, offset: start + 2 },
end: { key, offset: start + 2 + imageAlt.length }
}
} else if (key !== end.key) {
// Replace multi-line text
const endBlock = this.getBlock(end.key)
const { text } = endBlock
endBlock.text = text.substring(0, endOffset) + `![${alt}](${srcAndTitle})` + text.substring(endOffset)
const offset = endOffset + 2
this.cursor = {
start: { key: end.key, offset },
end: { key: end.key, offset: offset + alt.length }
}
} else {
// Replace single-line text
const imageAlt = startOffset !== endOffset ? text.substring(startOffset, endOffset) : alt
block.text = text.substring(0, start.offset) +
`![${imageAlt}](${srcAndTitle})` +
text.substring(end.offset)
this.cursor = {
start: {
key,
offset: startOffset + 2
},
end: {
key,
offset: startOffset + 2 + imageAlt.length
}
}
}
this.partialRender()
}
ContentState.prototype.replaceImage = function ({ key, token }, { alt = '', src = '', title = '' }) {
const block = this.getBlock(key)
const { start, end } = token.range
const oldText = block.text
let imageText = '!['
if (alt) {
imageText += alt
}
imageText += ']('
if (src) {
imageText += src
}
if (title) {
imageText += ` "${title}"`
}
imageText += ')'
block.text = oldText.substring(0, start) + imageText + oldText.substring(end)
return this.singleRender(block)
}
ContentState.prototype.deleteImage = function ({ key, token }) {
const block = this.getBlock(key)
const oldText = block.text
const { start, end } = token.range
block.text = oldText.substring(0, start) + oldText.substring(end)
this.cursor = {
start: { key, offset: start },
end: { key, offset: start }
}
return this.singleRender(block)
}
ContentState.prototype.selectImage = function (imageInfo) {
this.selectedImage = imageInfo
const block = this.getBlock(imageInfo.key)
return this.singleRender(block, false)
}
}
export default imageCtrl

View File

@ -1,98 +0,0 @@
import selection from '../selection'
import { findNearestParagraph } from '../selection/dom'
import { CLASS_OR_ID } from '../config'
import { getParagraphReference } from '../utils'
const imagePathCtrl = ContentState => {
ContentState.prototype.getImageTextNode = function () {
const node = selection.getSelectionStart()
const getNode = node => {
const parentNode = node && node.parentNode
if (node && node.classList && node.classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) {
return node
} else if (parentNode) {
return getNode(parentNode)
}
return null
}
return getNode(node)
}
ContentState.prototype.showAutoImagePath = function (list) {
const { eventCenter } = this.muya
const node = this.getImageTextNode()
if (!node) {
return eventCenter.dispatch('muya-image-picker', { list: [] })
}
const cb = item => {
const { text } = item
const { start: { key, offset } } = this.cursor
const block = this.getBlock(key)
const { text: oldText } = block
let chop = ''
block.text = oldText.substring(0, offset).replace(/(\/)([^/]+)$/, (m, p1, p2) => {
chop = p2
return p1
}) + text + oldText.substring(offset)
this.cursor = {
start: { key, offset: offset + (text.length - chop.length) },
end: { key, offset: offset + (text.length - chop.length) }
}
this.partialRender()
}
const paragraph = findNearestParagraph(node)
const reference = getParagraphReference(node, paragraph.id)
eventCenter.dispatch('muya-image-picker', { reference, list, cb })
}
ContentState.prototype.listenForPathChange = function () {
const { eventCenter } = this.muya
eventCenter.subscribe('image-path', src => {
const node = this.getImageTextNode()
if (!node) {
return eventCenter.dispatch('muya-image-picker', { list: [] })
}
if (src === '') {
const cb = item => {
const type = item.label
eventCenter.dispatch('insert-image', type)
}
const list = [{
text: 'Absolute Path',
iconClass: 'icon-folder',
label: 'absolute'
}, {
text: 'Relative Path',
iconClass: 'icon-folder',
label: 'relative'
}, {
text: 'Upload Image',
iconClass: 'icon-upload',
label: 'upload'
}]
const paragraph = findNearestParagraph(node)
const reference = getParagraphReference(node, paragraph.id)
eventCenter.dispatch('muya-image-picker', {
reference,
list,
cb
})
} else if (src && typeof src === 'string' && src.length) {
eventCenter.dispatch('muya-image-picker', {
list: []
})
eventCenter.dispatch('image-path-autocomplement', src)
}
})
}
}
export default imagePathCtrl

View File

@ -18,12 +18,13 @@ import tabCtrl from './tabCtrl'
import formatCtrl from './formatCtrl'
import searchCtrl from './searchCtrl'
import containerCtrl from './containerCtrl'
import imagePathCtrl from './imagePathCtrl'
import htmlBlockCtrl from './htmlBlock'
import clickCtrl from './clickCtrl'
import inputCtrl from './inputCtrl'
import tocCtrl from './tocCtrl'
import emojiCtrl from './emojiCtrl'
import imageCtrl from './imageCtrl'
import dragDropCtrl from './dragDropCtrl'
import importMarkdown from '../utils/importMarkdown'
import Cursor from '../selection/cursor'
@ -43,12 +44,13 @@ const prototypes = [
formatCtrl,
searchCtrl,
containerCtrl,
imagePathCtrl,
htmlBlockCtrl,
clickCtrl,
inputCtrl,
tocCtrl,
emojiCtrl,
imageCtrl,
dragDropCtrl,
importMarkdown
]
@ -67,6 +69,8 @@ class ContentState {
this.currentCursor = null
// you'll select the outmost block of current cursor when you click the front icon.
this.selectedBlock = null
this._selectedImage = null
this.dropAnchor = null
this.prevCursor = null
this.historyTimer = null
this.history = new History(this)
@ -76,6 +80,22 @@ class ContentState {
this.init()
}
set selectedImage (image) {
const oldSelectedImage = this._selectedImage
// if there is no selected image, remove selected status of current selected image.
if (!image && oldSelectedImage) {
const selectedImages = this.muya.container.querySelectorAll('.ag-inline-image-selected')
for (const img of selectedImages) {
img.classList.remove('ag-inline-image-selected')
}
}
this._selectedImage = image
}
get selectedImage () {
return this._selectedImage
}
set cursor (cursor) {
if (!(cursor instanceof Cursor)) {
cursor = new Cursor(cursor)
@ -149,20 +169,29 @@ class ContentState {
this.renderRange = [ startOutMostBlock.preSibling, endOutMostBlock.nextSibling ]
}
postRender () {
// do nothing.
}
render (isRenderCursor = true) {
const { blocks, cursor, searchMatches: { matches, index }, selectedBlock } = this
const { blocks, searchMatches: { matches, index } } = this
const activeBlocks = this.getActiveBlocks()
matches.forEach((m, i) => {
m.active = i === index
})
this.setNextRenderRange()
this.stateRender.collectLabels(blocks)
this.stateRender.render(blocks, cursor, activeBlocks, matches, selectedBlock)
if (isRenderCursor) this.setCursor()
this.stateRender.render(blocks, activeBlocks, matches)
if (isRenderCursor) {
this.setCursor()
} else {
this.muya.blur()
}
this.postRender()
}
partialRender (isRenderCursor = true) {
const { blocks, cursor, searchMatches: { matches, index }, selectedBlock } = this
const { blocks, searchMatches: { matches, index } } = this
const activeBlocks = this.getActiveBlocks()
const [ startKey, endKey ] = this.renderRange
matches.forEach((m, i) => {
@ -174,8 +203,30 @@ class ContentState {
this.setNextRenderRange()
this.stateRender.collectLabels(blocks)
this.stateRender.partialRender(needRenderBlocks, cursor, activeBlocks, matches, startKey, endKey, selectedBlock)
if (isRenderCursor) this.setCursor()
this.stateRender.partialRender(needRenderBlocks, activeBlocks, matches, startKey, endKey)
if (isRenderCursor) {
this.setCursor()
} else {
this.muya.blur()
}
this.postRender()
}
singleRender (block, isRenderCursor = true) {
const { blocks, searchMatches: { matches, index } } = this
const activeBlocks = this.getActiveBlocks()
matches.forEach((m, i) => {
m.active = i === index
})
this.setNextRenderRange()
this.stateRender.collectLabels(blocks)
this.stateRender.singleRender(block, activeBlocks, matches)
if (isRenderCursor) {
this.setCursor()
} else {
this.muya.blur()
}
this.postRender()
}
/**

View File

@ -1,5 +1,7 @@
import { sanitize } from '../utils'
import { PARAGRAPH_TYPES, PREVIEW_DOMPURIFY_CONFIG, HAS_TEXT_BLOCK_REG } from '../config'
import { PARAGRAPH_TYPES, PREVIEW_DOMPURIFY_CONFIG, HAS_TEXT_BLOCK_REG, IMAGE_EXT_REG } from '../config'
import { sanitize, getUniqueId, getImageInfo as getImageSrc } from '../utils'
import { getImageInfo } from '../utils/getImageInfo'
const LIST_REG = /ul|ol/
const LINE_BREAKS_REG = /\n/
@ -77,11 +79,113 @@ const pasteCtrl = ContentState => {
return tempWrapper.innerHTML
}
ContentState.prototype.pasteImage = async function (event) {
// Try to guess the clipboard file path.
const imagePath = this.muya.options.clipboardFilePath()
if (imagePath && typeof imagePath === 'string' && IMAGE_EXT_REG.test(imagePath)) {
const id = `loading-${getUniqueId()}`
if (this.selectedImage) {
this.replaceImage(this.selectedImage, {
alt: id,
src: imagePath,
})
} else {
this.insertImage({
alt: id,
src: imagePath
})
}
const nSrc = await this.muya.options.imageAction(imagePath)
const { src } = getImageSrc(imagePath)
if (src) {
this.stateRender.urlMap.set(nSrc, src)
}
const imageWrapper = this.muya.container.querySelector(`span[data-id=${id}]`)
if (imageWrapper) {
const imageInfo = getImageInfo(imageWrapper)
this.replaceImage(imageInfo, {
src: nSrc
})
}
return imagePath
}
const items = event.clipboardData && event.clipboardData.items
let file = null
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
file = items[i].getAsFile()
break
}
}
}
// handle paste to create inline image
if (file) {
const id = `loading-${getUniqueId()}`
if (this.selectedImage) {
this.replaceImage(this.selectedImage, {
alt: id,
src: ''
})
} else {
this.insertImage({
alt: id,
src: ''
})
}
const reader = new FileReader()
reader.onload = event => {
const base64 = event.target.result
const imageWrapper = this.muya.container.querySelector(`span[data-id=${id}]`)
const imageContainer = this.muya.container.querySelector(`span[data-id=${id}] .ag-image-container`)
this.stateRender.urlMap.set(id, base64)
if (imageContainer) {
imageWrapper.classList.remove('ag-empty-image')
imageWrapper.classList.add('ag-image-success')
const image = document.createElement('img')
image.src = base64
imageContainer.appendChild(image)
}
}
reader.readAsDataURL(file)
const nSrc = await this.muya.options.imageAction(file)
const base64 = this.stateRender.urlMap.get(id)
if (base64) {
this.stateRender.urlMap.set(nSrc, base64)
this.stateRender.urlMap.delete(id)
}
const imageWrapper = this.muya.container.querySelector(`span[data-id=${id}]`)
if (imageWrapper) {
const imageInfo = getImageInfo(imageWrapper)
this.replaceImage(imageInfo, {
src: nSrc
})
}
return file
}
return null
}
ContentState.prototype.docPasteHandler = async function (event) {
const file = await this.pasteImage(event)
if (file) {
return event.preventDefault()
}
}
// handle `normal` and `pasteAsPlainText` paste
ContentState.prototype.pasteHandler = function (event, type) {
ContentState.prototype.pasteHandler = async function (event, type = 'normal', rawText, rawHtml) {
event.preventDefault()
const text = event.clipboardData.getData('text/plain')
let html = event.clipboardData.getData('text/html')
event.stopPropagation()
const text = rawText || event.clipboardData.getData('text/plain')
let html = rawHtml || event.clipboardData.getData('text/html')
html = this.standardizeHTML(html)
const copyType = this.checkCopyType(html, text)
const { start, end } = this.cursor
@ -94,6 +198,11 @@ const pasteCtrl = ContentState => {
return this.pasteHandler(event, type)
}
const file = await this.pasteImage(event)
if (file) {
return
}
const appendHtml = (text) => {
startBlock.text = startBlock.text.substring(0, start.offset) + text + startBlock.text.substring(start.offset)
const { key } = start

View File

@ -1,4 +1,5 @@
import { operateClassName } from '../utils/domManipulate'
import { getImageInfo } from '../utils/getImageInfo'
import { CLASS_OR_ID } from '../config'
import selection from '../selection'
@ -43,6 +44,7 @@ class ClickEvent {
const { target } = event
// handler table click
const toolItem = getToolItem(target)
contentState.selectedImage = null
if (toolItem) {
event.preventDefault()
event.stopPropagation()
@ -56,6 +58,9 @@ class ClickEvent {
const markedImageText = target.previousElementSibling
const mathRender = target.closest(`.${CLASS_OR_ID['AG_MATH_RENDER']}`)
const rubyRender = target.closest(`.${CLASS_OR_ID['AG_RUBY_RENDER']}`)
const imageWrapper = target.closest(`.${CLASS_OR_ID['AG_INLINE_IMAGE']}`)
const imageTurnInto = target.closest('.ag-image-icon-turninto')
const imageDelete = target.closest('.ag-image-icon-delete') || target.closest('.ag-image-icon-close')
const mathText = mathRender && mathRender.previousElementSibling
const rubyText = rubyRender && rubyRender.previousElementSibling
if (markedImageText && markedImageText.classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) {
@ -70,6 +75,51 @@ class ClickEvent {
} else if (rubyText) {
selectionText(rubyText)
}
// Handle delete inline iamge by click delete icon.
if (imageDelete && imageWrapper) {
const imageInfo = getImageInfo(imageWrapper)
event.preventDefault()
event.stopPropagation()
// hide image selector if needed.
eventCenter.dispatch('muya-image-selector', { reference: null })
return contentState.deleteImage(imageInfo)
}
// Handle image click, to select the current image
if (target.tagName === 'IMG' && imageWrapper) {
// Handle select image
const imageInfo = getImageInfo(imageWrapper)
event.preventDefault()
return contentState.selectImage(imageInfo)
}
// Handle click imagewrapper when it's empty or image load failed.
if (
(imageTurnInto && imageWrapper) ||
(imageWrapper &&
(
imageWrapper.classList.contains('ag-empty-image') ||
imageWrapper.classList.contains('ag-image-fail')
))
) {
const rect = imageWrapper.getBoundingClientRect()
const reference = {
getBoundingClientRect () {
if (imageTurnInto) {
rect.height = 0
}
return rect
}
}
const imageInfo = getImageInfo(imageWrapper)
eventCenter.dispatch('muya-image-selector', {
reference,
imageInfo,
cb: () => {}
})
event.preventDefault()
return event.stopPropagation()
}
if (target.closest('div.ag-container-preview') || target.closest('div.ag-html-preview')) {
return event.stopPropagation()
}

View File

@ -8,6 +8,9 @@ class Clipboard {
listen () {
const { container, eventCenter, contentState } = this.muya
const docPasteHandler = event => {
contentState.docPasteHandler(event)
}
const copyCutHandler = event => {
contentState.copyHandler(event, this._copyType)
if (event.type === 'cut') {
@ -22,6 +25,7 @@ class Clipboard {
this._pasteType = 'normal'
}
eventCenter.attachDOMEvent(document, 'paste', docPasteHandler)
eventCenter.attachDOMEvent(container, 'paste', pasteHandler)
eventCenter.attachDOMEvent(container, 'cut', copyCutHandler)
eventCenter.attachDOMEvent(container, 'copy', copyCutHandler)

View File

@ -0,0 +1,39 @@
class DragDrop {
constructor (muya) {
this.muya = muya
this.dragOverBinding()
this.dropBinding()
this.dragendBinding()
}
dragOverBinding () {
const { container, eventCenter, contentState } = this.muya
const dragoverHandler = event => {
contentState.dragoverHandler(event)
}
eventCenter.attachDOMEvent(container, 'dragover', dragoverHandler)
}
dropBinding () {
const { container, eventCenter, contentState } = this.muya
const dropHandler = event => {
contentState.dropHandler(event)
}
eventCenter.attachDOMEvent(container, 'drop', dropHandler)
}
dragendBinding () {
const { eventCenter, contentState } = this.muya
const dragleaveHandler = event => {
contentState.dragleaveHandler(event)
}
eventCenter.attachDOMEvent(window, 'dragleave', dragleaveHandler)
}
}
export default DragDrop

View File

@ -1,7 +1,7 @@
import { EVENT_KEYS } from '../config'
import selection from '../selection'
import { findNearestParagraph } from '../selection/dom'
import { getParagraphReference } from '../utils'
import { getParagraphReference, getImageInfo } from '../utils'
import { checkEditEmoji } from '../ui/emojis'
class Keyboard {
@ -90,6 +90,31 @@ class Keyboard {
keydownBinding () {
const { container, eventCenter, contentState } = this.muya
const docHandler = event => {
switch (event.code) {
case EVENT_KEYS.Enter:
return contentState.docEnterHandler(event)
case EVENT_KEYS.Space: {
if (contentState.selectedImage) {
const { src } = getImageInfo(contentState.selectedImage.token.src)
if (src) {
eventCenter.dispatch('preview-image', {
data: src
})
}
}
break
}
case EVENT_KEYS.Backspace: {
return contentState.docBackspaceHandler(event)
}
case EVENT_KEYS.ArrowUp: // fallthrough
case EVENT_KEYS.ArrowDown: // fallthrough
case EVENT_KEYS.ArrowLeft: // fallthrough
case EVENT_KEYS.ArrowRight: // fallthrough
return contentState.docArrowHandler(event)
}
}
const handler = event => {
if (event.metaKey || event.ctrlKey) {
@ -145,6 +170,7 @@ class Keyboard {
}
eventCenter.attachDOMEvent(container, 'keydown', handler)
eventCenter.attachDOMEvent(document, 'keydown', docHandler)
}
inputBinding () {
@ -180,7 +206,7 @@ class Keyboard {
const node = selection.getSelectionStart()
const paragraph = findNearestParagraph(node)
const emojiNode = checkEditEmoji(node)
contentState.selectedImage = null
if (
paragraph &&
emojiNode &&
@ -224,12 +250,6 @@ class Keyboard {
}
}
// hide image-path float box
const imageTextNode = contentState.getImageTextNode()
if (!imageTextNode) {
eventCenter.dispatch('muya-image-picker', { list: [] })
}
const block = contentState.getBlock(anchor.key)
if (anchor.key === focus.key && anchor.offset !== focus.offset && block.functionType !== 'codeLine') {
const reference = contentState.getPositionReference()

View File

@ -2,6 +2,7 @@ import ContentState from './contentState'
import EventCenter from './eventHandler/event'
import Clipboard from './eventHandler/clipboard'
import Keyboard from './eventHandler/keyboard'
import DragDrop from './eventHandler/dragDrop'
import ClickEvent from './eventHandler/clickEvent'
import { CLASS_OR_ID, MUYA_DEFAULT_OPTION } from './config'
import { wordCount } from './utils'
@ -33,6 +34,7 @@ class Muya {
this.clipboard = new Clipboard(this)
this.clickEvent = new ClickEvent(this)
this.keyboard = new Keyboard(this)
this.dragdrop = new DragDrop(this)
this.init()
}
@ -40,7 +42,6 @@ class Muya {
const { container, contentState, eventCenter } = this
contentState.stateRender.setContainer(container.children[0])
eventCenter.subscribe('stateChange', this.dispatchChange)
contentState.listenForPathChange()
const { markdown } = this
const { focusMode } = this.options
this.setMarkdown(markdown)
@ -232,20 +233,12 @@ class Muya {
this.container.blur()
}
showAutoImagePath (files) {
const list = files.map(f => {
const iconClass = f.type === 'directory' ? 'icon-folder' : 'icon-image'
return Object.assign(f, { iconClass, text: f.file + (f.type === 'directory' ? '/' : '') })
})
this.contentState.showAutoImagePath(list)
}
format (type) {
this.contentState.format(type)
}
insertImage (url) {
this.contentState.insertImage(url)
insertImage (imageInfo) {
this.contentState.insertImage(imageInfo)
}
search (value, opt) {
@ -288,7 +281,13 @@ class Muya {
}
selectAll () {
this.contentState.selectAll()
if (this.hasFocus()) {
this.contentState.selectAll()
}
const activeElement = document.activeElement
if (activeElement.nodeName === 'INPUT') {
activeElement.select()
}
}
copyAsMarkdown () {

View File

@ -197,9 +197,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => {
start: pos,
end: pos + imageTo[0].length
},
// An image description has inline elements as its contents.
// When an image is rendered to HTML, this is standardly used as the images alt attribute.
alt: imageTo[2].replace(/[`*{}[\]()#+\-.!_>~:|<>$]/g, ''),
alt: imageTo[2],
backlash: {
first: imageTo[3],
second: imageTo[5]

View File

@ -20,6 +20,7 @@ class StateRender {
this.diagramCache = new Map()
this.tokenCache = new Map()
this.labels = new Map()
this.urlMap = new Map()
this.container = null
}
@ -77,7 +78,8 @@ class StateRender {
return active ? CLASS_OR_ID['AG_HIGHLIGHT'] : CLASS_OR_ID['AG_SELECTION']
}
getSelector (block, cursor, activeBlocks, selectedBlock) {
getSelector (block, activeBlocks) {
const { cursor, selectedBlock } = this.muya.contentState
const type = block.type === 'hr' ? 'p' : block.type
const isActive = activeBlocks.some(b => b.key === block.key) || block.key === cursor.start.key
@ -142,11 +144,10 @@ class StateRender {
}
}
render (blocks, cursor, activeBlocks, matches, selectedBlock) {
render (blocks, activeBlocks, matches) {
const selector = `div#${CLASS_OR_ID['AG_EDITOR_ID']}`
const children = blocks.map(block => {
return this.renderBlock(block, cursor, activeBlocks, selectedBlock, matches, true)
return this.renderBlock(block, activeBlocks, matches, true)
})
const newVdom = h(selector, children)
@ -160,11 +161,11 @@ class StateRender {
}
// Only render the blocks which you updated
partialRender (blocks, cursor, activeBlocks, matches, startKey, endKey, selectedBlock) {
partialRender (blocks, activeBlocks, matches, startKey, endKey) {
const cursorOutMostBlock = activeBlocks[activeBlocks.length - 1]
// If cursor is not in render blocks, need to render cursor block independently
const needRenderCursorBlock = blocks.indexOf(cursorOutMostBlock) === -1
const newVnode = h('section', blocks.map(block => this.renderBlock(block, cursor, activeBlocks, selectedBlock, matches)))
const newVnode = h('section', blocks.map(block => this.renderBlock(block, activeBlocks, matches)))
const html = toHTML(newVnode).replace(/^<section>([\s\S]+?)<\/section>$/, '$1')
const needToRemoved = []
@ -193,7 +194,7 @@ class StateRender {
const cursorDom = document.querySelector(`#${key}`)
if (cursorDom) {
const oldCursorVnode = toVNode(cursorDom)
const newCursorVnode = this.renderBlock(cursorOutMostBlock, cursor, activeBlocks, selectedBlock, matches)
const newCursorVnode = this.renderBlock(cursorOutMostBlock, activeBlocks, matches)
patch(oldCursorVnode, newCursorVnode)
}
}
@ -202,6 +203,24 @@ class StateRender {
this.renderDiagram()
this.codeCache.clear()
}
/**
* Only render one block.
*
* @param {object} block
* @param {array} activeBlocks
* @param {array} matches
*/
singleRender (block, activeBlocks, matches) {
const selector = `#${block.key}`
const newVdom = this.renderBlock(block, activeBlocks, matches, true)
const rootDom = document.querySelector(selector)
const oldVdom = toVNode(rootDom)
patch(oldVdom, newVdom)
this.renderMermaid()
this.renderDiagram()
this.codeCache.clear()
}
}
mixins(StateRender, renderInlines, renderBlock)

View File

@ -1,10 +1,10 @@
/**
* [renderBlock render one block, no matter it is a container block or text block]
*/
export default function renderBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) {
export default function renderBlock (block, activeBlocks, matches, useCache = false) {
const method = Array.isArray(block.children) && block.children.length > 0
? 'renderContainerBlock'
: 'renderLeafBlock'
return this[method](block, cursor, activeBlocks, selectedBlock, matches, useCache)
return this[method](block, activeBlocks, matches, useCache)
}

View File

@ -15,8 +15,8 @@ const PRE_BLOCK_HASH = {
'vega-lite': `.${CLASS_OR_ID['AG_VEGA_LITE']}`
}
export default function renderContainerBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) {
let selector = this.getSelector(block, cursor, activeBlocks, selectedBlock)
export default function renderContainerBlock (block, activeBlocks, matches, useCache = false) {
let selector = this.getSelector(block, activeBlocks)
const {
type,
headingStyle,
@ -28,7 +28,7 @@ export default function renderContainerBlock (block, cursor, activeBlocks, selec
isLooseListItem,
lang
} = block
const children = block.children.map(child => this.renderBlock(child, cursor, activeBlocks, selectedBlock, matches, useCache))
const children = block.children.map(child => this.renderBlock(child, activeBlocks, matches, useCache))
const data = {
attrs: {},
dataset: {}

View File

@ -49,9 +49,10 @@ const hasReferenceToken = tokens => {
return result
}
export default function renderLeafBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) {
export default function renderLeafBlock (block, activeBlocks, matches, useCache = false) {
const { loadMathMap } = this
let selector = this.getSelector(block, cursor, activeBlocks, selectedBlock)
const { cursor } = this.muya.contentState
let selector = this.getSelector(block, activeBlocks)
// highlight search key in block
const highlights = matches.filter(m => m.key === block.key)
const {

View File

@ -1,79 +1,138 @@
import { CLASS_OR_ID, IMAGE_EXT_REG, isInElectron } from '../../../config'
import { CLASS_OR_ID } from '../../../config'
import { getImageInfo } from '../../../utils'
import ImageIcon from '../../../assets/pngicon/image/2.png'
import ImageFailIcon from '../../../assets/pngicon/image_fail/2.png'
import ImageEditIcon from '../../../assets/pngicon/imageEdit/2.png'
import DeleteIcon from '../../../assets/pngicon/delete/delete@2x.png'
const renderIcon = (h, className, icon) => {
const selector = `a.${className}`
const iconVnode = h('i.icon', h(`i.icon-inner`, {
style: {
background: `url(${icon}) no-repeat`,
'background-size': '100%'
}
}, ''))
return h(selector, {
attrs: {
contenteditable: 'false'
}
}, iconVnode)
}
// I dont want operate dom directly, is there any better method? need help!
export default function image (h, cursor, block, token, outerClass) {
const { eventCenter } = this
const { start: cursorStart, end: cursorEnd } = cursor
const { start, end } = token.range
if (
cursorStart.key === cursorEnd.key &&
cursorStart.offset === cursorEnd.offset &&
cursorStart.offset === end - 1 &&
!IMAGE_EXT_REG.test(token.src) &&
isInElectron
) {
eventCenter.dispatch('image-path', token.src)
const imageInfo = getImageInfo(token.src + encodeURI(token.backlash.second))
const { selectedImage } = this.muya.contentState
const data = {
dataset: {
raw: token.raw
},
attrs: {
contenteditable: 'true'
}
}
const className = this.getClassName(outerClass, block, token, cursor)
const imageClass = CLASS_OR_ID['AG_IMAGE_MARKED_TEXT']
const titleContent = this.highlight(h, block, start, start + 2 + token.alt.length, token)
const srcContent = this.highlight(
h, block,
start + 2 + token.alt.length + token.backlash.first.length + 2,
start + 2 + token.alt.length + token.backlash.first.length + 2 + token.srcAndTitle.length,
token
)
const secondBracketContent = this.highlight(
h, block,
start + 2 + token.alt.length + token.backlash.first.length,
start + 2 + token.alt.length + token.backlash.first.length + 2,
token
)
const lastBracketContent = this.highlight(h, block, end - 1, end, token)
const firstBacklashStart = start + 2 + token.alt.length
const secondBacklashStart = end - 1 - token.backlash.second.length
let id
let isSuccess
let selector
const imageInfo = getImageInfo(token.src + encodeURI(token.backlash.second))
const { src } = imageInfo
let { src } = imageInfo
const alt = token.alt + encodeURI(token.backlash.first)
const { title } = token
if (src) {
({ id, isSuccess } = this.loadImageAsync(imageInfo, alt, className))
({ id, isSuccess } = this.loadImageAsync(imageInfo, alt))
}
let wrapperSelector = id
? `span#${id}.${CLASS_OR_ID['AG_INLINE_IMAGE']}`
: `span.${CLASS_OR_ID['AG_INLINE_IMAGE']}`
selector = id ? `span#${id}.${imageClass}.${CLASS_OR_ID['AG_REMOVE']}` : `span.${imageClass}.${CLASS_OR_ID['AG_REMOVE']}`
if (isSuccess) {
if (className === CLASS_OR_ID['AG_HIDE']) {
selector += `.${className}`
}
} else {
selector += `.${CLASS_OR_ID['AG_IMAGE_FAIL']}`
}
const children = [
...titleContent,
...this.backlashInToken(h, token.backlash.first, className, firstBacklashStart, token),
...secondBracketContent,
h(`span.${CLASS_OR_ID['AG_IMAGE_SRC']}`, srcContent),
...this.backlashInToken(h, token.backlash.second, className, secondBacklashStart, token),
...lastBracketContent
const imageIcons = [
renderIcon(h, 'ag-image-icon-success', ImageIcon),
renderIcon(h, 'ag-image-icon-fail', ImageFailIcon),
renderIcon(h, 'ag-image-icon-close', DeleteIcon)
]
const toolIcons = [
renderIcon(h, 'ag-image-icon-turninto', ImageEditIcon),
renderIcon(h, 'ag-image-icon-delete', DeleteIcon)
]
const renderImageContainer = (...args) => {
return h(`span.${CLASS_OR_ID['AG_IMAGE_CONTAINER']}`, {
attrs: {
contenteditable: 'true'
}
}, args)
}
return isSuccess
? [
h(selector, children),
h('img', { props: { alt, src, title } })
// the src image is still loading, so use the url Map base64.
if (this.urlMap.has(src)) {
// fix: it will generate a new id if the image is not loaded.
const { selectedImage } = this.muya.contentState
if (selectedImage && selectedImage.token.src === src && selectedImage.imageId !== id) {
selectedImage.imageId = id
}
src = this.urlMap.get(src)
isSuccess = true
}
if (alt.startsWith('loading-')) {
wrapperSelector += `.${CLASS_OR_ID['AG_IMAGE_UPLOADING']}`
Object.assign(data.dataset, {
id: alt
})
if (this.urlMap.has(alt)) {
src = this.urlMap.get(alt)
isSuccess = true
}
}
if (src) {
// image is loading...
if (typeof isSuccess === 'undefined') {
wrapperSelector += `.${CLASS_OR_ID['AG_IMAGE_LOADING']}`
} else if (isSuccess === true) {
wrapperSelector += `.${CLASS_OR_ID['AG_IMAGE_SUCCESS']}`
} else {
wrapperSelector += `.${CLASS_OR_ID['AG_IMAGE_FAIL']}`
}
if (selectedImage) {
const { key, token: selectToken } = selectedImage
if (
key === block.key &&
selectToken.range.start === token.range.start &&
selectToken.range.end === token.range.end
) {
wrapperSelector += `.${CLASS_OR_ID['AG_INLINE_IMAGE_SELECTED']}`
}
}
return isSuccess
? [
h(wrapperSelector, data, [
...imageIcons,
renderImageContainer(
...toolIcons,
// An image description has inline elements as its contents.
// When an image is rendered to HTML, this is standardly used as the images alt attribute.
h('img', { props: { alt: alt.replace(/[`*{}[\]()#+\-.!_>~:|<>$]/g, ''), src, title } })
)
])
]
: [
h(wrapperSelector, data, [
...imageIcons,
renderImageContainer(
...toolIcons
)
])
]
} else {
wrapperSelector += `.${CLASS_OR_ID['AG_EMPTY_IMAGE']}`
return [
h(wrapperSelector, data, [
...imageIcons,
renderImageContainer(
...toolIcons
)
])
]
: [h(selector, children)]
}
}

View File

@ -11,16 +11,31 @@ export default function loadImageAsync (imageInfo, alt, className, imageClass) {
id = getUniqueId()
loadImage(src, isUnknownType)
.then(url => {
const imageWrapper = document.querySelector(`#${id}`)
const imageText = document.querySelector(`#${id}`)
const img = document.createElement('img')
img.src = url
if (alt) img.alt = alt
if (alt) img.alt = alt.replace(/[`*{}[\]()#+\-.!_>~:|<>$]/g, '')
if (imageClass) {
img.classList.add(imageClass)
}
if (imageWrapper) {
insertAfter(img, imageWrapper)
operateClassName(imageWrapper, 'add', className)
if (imageText) {
if (imageText.classList.contains('ag-inline-image')) {
const imageContainer = imageText.querySelector('.ag-image-container')
const oldImage = imageContainer.querySelector('img')
if (oldImage) {
oldImage.remove()
}
imageContainer.appendChild(img)
imageText.classList.remove('ag-image-loading')
imageText.classList.add('ag-image-success')
} else {
insertAfter(img, imageText)
operateClassName(imageText, 'add', className)
}
}
if (this.urlMap.has(src)) {
this.urlMap.delete(src)
}
this.loadImageMap.set(src, {
id,
@ -28,9 +43,17 @@ export default function loadImageAsync (imageInfo, alt, className, imageClass) {
})
})
.catch(() => {
const imageWrapper = document.querySelector(`#${id}`)
if (imageWrapper) {
operateClassName(imageWrapper, 'add', CLASS_OR_ID['AG_IMAGE_FAIL'])
const imageText = document.querySelector(`#${id}`)
if (imageText) {
operateClassName(imageText, 'remove', CLASS_OR_ID['AG_IMAGE_LOADING'])
operateClassName(imageText, 'add', CLASS_OR_ID['AG_IMAGE_FAIL'])
const image = imageText.querySelector('img')
if (image) {
image.remove()
}
}
if (this.urlMap.has(src)) {
this.urlMap.delete(src)
}
this.loadImageMap.set(src, {
id,

View File

@ -1,5 +1,7 @@
// render token of text type to vdom.
export default function text (h, cursor, block, token) {
const { start, end } = token.range
return this.highlight(h, block, start, end, token)
return [
h('span.ag-plain-text', this.highlight(h, block, start, end, token))
]
}

View File

@ -23,7 +23,7 @@ export const inlineRules = {
'reference_link': /^\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/,
'reference_image': /^\!\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/,
'tail_header': /^(\s{1,}#{1,})(\s*)$/,
'html_tag': /^(<!--[\s\S]*?-->|(<([a-zA-Z]{1}[a-zA-Z\d-]*) *[_\.\-/:a-zA-Z\d='";\? *]* *(?:\/)?>)(?:([\s\S]*?)(<\/\3 *>))?)/, // row html
'html_tag': /^(<!--[\s\S]*?-->|(<([a-zA-Z]{1}[a-zA-Z\d-]*) *[_\.\-/:a-zA-Z\d='";\? *]* *(?:\/)?>)(?:([\s\S]*?)(<\/\3 *>))?)/, // raw html
'html_escape': new RegExp(`^(${escapeCharacters.join('|')})`, 'i'),
'soft_line_break': /^(\n)(?!\n)/,
'hard_line_break': /^( {2,})(\n)(?!\n)/,

View File

@ -77,13 +77,24 @@ export const getAttributes = html => {
export const parseSrcAndTitle = (text = '') => {
const parts = text.split(/\s+/)
const src = parts[0]
const rawTitle = text.substring(src.length).trim()
if (parts.length === 1) {
return {
src: text.trim(),
title: ''
}
}
const rawTitle = parts.pop()
let src = ''
const TITLE_REG = /^('|")(.*?)\1$/ // we only support use `'` and `"` to indicate a title now.
let title = ''
if (rawTitle && TITLE_REG.test(rawTitle)) {
title = rawTitle.replace(TITLE_REG, '$2')
}
if (title) {
src = text.substring(0, text.length - rawTitle.length).trim()
} else {
src = text.trim()
}
return { src, title }
}

View File

@ -16,6 +16,23 @@ export const getTextContent = (node, blackList) => {
}
if (node.nodeType === 3) {
text += node.textContent
} else if (node.nodeType === 1 && node.classList.contains('ag-inline-image')) {
// handle inline image
const raw = node.getAttribute('data-raw')
const imageContainer = node.querySelector('.ag-image-container')
const hasImg = imageContainer.querySelector('img')
const childNodes = imageContainer.childNodes
if (childNodes.length && hasImg) {
for (const child of childNodes) {
if (child.nodeType === 1 && child.nodeName === 'IMG') {
text += raw
} else if (child.nodeType === 3) {
text += child.textContent
}
}
} else {
text += raw
}
} else {
const childNodes = node.childNodes
for (const n of childNodes) {
@ -25,6 +42,23 @@ export const getTextContent = (node, blackList) => {
return text
}
export const getOffsetOfParagraph = (node, paragraph) => {
let offset = 0
let preSibling = node
if (node === paragraph) return offset
do {
preSibling = preSibling.previousSibling
if (preSibling) {
offset += getTextContent(preSibling, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
} while (preSibling)
return (node === paragraph || node.parentNode === paragraph)
? offset
: offset + getOffsetOfParagraph(node.parentNode, paragraph)
}
export const findNearestParagraph = node => {
if (!node) {
return null
@ -39,7 +73,7 @@ export const findNearestParagraph = node => {
export const findOutMostParagraph = node => {
do {
let parentNode = node.parentNode
if (isAganippeEditorElement(parentNode) && isAganippeParagraph(node)) return node
if (isMuyaEditorElement(parentNode) && isAganippeParagraph(node)) return node
node = parentNode
} while (node)
}
@ -53,7 +87,7 @@ export const isBlockContainer = element => {
blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1
}
export const isAganippeEditorElement = element => {
export const isMuyaEditorElement = element => {
return element && element.id === CLASS_OR_ID['AG_EDITOR_ID']
}
@ -68,7 +102,7 @@ export const traverseUp = (current, testElementFunction) => {
return current
}
// do not traverse upwards past the nearest containing editor
if (isAganippeEditorElement(current)) {
if (isMuyaEditorElement(current)) {
return false
}
}
@ -100,7 +134,7 @@ export const getFirstSelectableLeafNode = element => {
export const getClosestBlockContainer = node => {
return traverseUp(node, node => {
return isBlockContainer(node) || isAganippeEditorElement(node)
return isBlockContainer(node) || isMuyaEditorElement(node)
})
}

View File

@ -11,7 +11,8 @@ import {
getClosestBlockContainer,
getCursorPositionWithinMarkedText,
findNearestParagraph,
getTextContent
getTextContent,
getOffsetOfParagraph
} from './dom'
const filterOnlyParentElements = node => {
@ -424,11 +425,48 @@ class Selection {
let count = 0
for (i = 0; i < len; i++) {
const child = childNodes[i]
const textLength = getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
if (child.classList && child.classList.contains(CLASS_OR_ID['AG_FRONT_ICON'])) continue
if (count + getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length >= offset) {
return getNodeAndOffset(child, offset - count)
if (count + textLength >= offset) {
if (
child.classList && child.classList.contains('ag-inline-image')
) {
const imageContainer = child.querySelector('.ag-image-container')
const hasImg = imageContainer.querySelector('img')
if (!hasImg) {
return {
node: child,
offset: 0
}
}
if (count + textLength === offset) {
if (child.nextElementSibling) {
return {
node: child.nextElementSibling,
offset: 0
}
} else {
return {
node: imageContainer,
offset: 3
}
}
} else if (count === offset && count === 0) {
return {
node: imageContainer,
offset: 2
}
} else {
return {
node: child,
offset: 0
}
}
} else {
return getNodeAndOffset(child, offset - count)
}
} else {
count += getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
count += textLength
}
}
return { node, offset }
@ -436,10 +474,12 @@ class Selection {
let { node: anchorNode, offset: anchorOffset } = getNodeAndOffset(anchorParagraph, anchor.offset)
let { node: focusNode, offset: focusOffset } = getNodeAndOffset(focusParagraph, focus.offset)
if (anchorNode.nodeType === 3 || anchorNode.nodeType === 1 && !anchorNode.classList.contains('ag-image-container')) {
anchorOffset = Math.min(anchorOffset, anchorNode.textContent.length)
focusOffset = Math.min(focusOffset, focusNode.textContent.length)
}
anchorOffset = Math.min(anchorOffset, anchorNode.textContent.length)
focusOffset = Math.min(focusOffset, focusNode.textContent.length)
// First set the anchor node and anchor offeet, make it collapsed
// First set the anchor node and anchor offset, make it collapsed
this.select(anchorNode, anchorOffset)
// Secondly, set the focus node and focus offset.
this.setFocus(focusNode, focusOffset)
@ -457,7 +497,6 @@ class Selection {
getCursorRange () {
let { anchorNode, anchorOffset, focusNode, focusOffset } = this.doc.getSelection()
const isAnchorValid = this.isValidCursorNode(anchorNode)
const isFocusValid = this.isValidCursorNode(focusNode)
let needFix = false
@ -492,26 +531,44 @@ class Selection {
const anchorParagraph = findNearestParagraph(anchorNode)
const focusParagraph = findNearestParagraph(focusNode)
const getOffsetOfParagraph = (node, paragraph) => {
let offset = 0
let preSibling = node
if (node === paragraph) return offset
do {
preSibling = preSibling.previousSibling
if (preSibling) {
offset += getTextContent(preSibling, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
} while (preSibling)
return (node === paragraph || node.parentNode === paragraph)
? offset
: offset + getOffsetOfParagraph(node.parentNode, paragraph)
let aOffset = getOffsetOfParagraph(anchorNode, anchorParagraph) + anchorOffset
let fOffset = getOffsetOfParagraph(focusNode, focusParagraph) + focusOffset
// fix input after image.
if (
anchorNode === focusNode &&
anchorOffset === focusOffset &&
anchorNode.parentNode.classList.contains('ag-image-container') &&
anchorNode.previousElementSibling &&
anchorNode.previousElementSibling.nodeName === 'IMG'
) {
const imageWrapper = anchorNode.parentNode.parentNode
const preElement = imageWrapper.previousElementSibling
aOffset = 0
if (preElement) {
aOffset += getOffsetOfParagraph(preElement, anchorParagraph)
aOffset += getTextContent(preElement, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
aOffset += getTextContent(imageWrapper, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
fOffset = aOffset
}
const aOffset = getOffsetOfParagraph(anchorNode, anchorParagraph) + anchorOffset
const fOffset = getOffsetOfParagraph(focusNode, focusParagraph) + focusOffset
if (
anchorNode === focusNode &&
anchorNode.nodeType === 1 &&
anchorNode.classList.contains('ag-image-container')
) {
const imageWrapper = anchorNode.parentNode
const preElement = imageWrapper.previousElementSibling
aOffset = 0
if (preElement) {
aOffset += getOffsetOfParagraph(preElement, anchorParagraph)
aOffset += getTextContent(preElement, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
if (anchorOffset === 3) {
aOffset += getTextContent(imageWrapper, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
fOffset = aOffset
}
const anchor = { key: anchorParagraph.id, offset: aOffset }
const focus = { key: focusParagraph.id, offset: fOffset }

View File

@ -8,7 +8,6 @@
right: -1000px;
border-radius: 8px;
box-shadow: var(--floatShadow);
border: 1px solid var(--floatBorderColor);
background-color: var(--floatBgColor);
transition: opacity .25s ease-in-out;
transform-origin: top;

View File

@ -0,0 +1,7 @@
.ag-image-picker-wrapper {
z-index: 100000;
}
.ag-image-picker-wrapper .ag-list-picker {
width: 450px;
}

View File

@ -4,6 +4,8 @@ import FolderIcon from '../../assets/icons/folder.svg'
import ImageIcon from '../../assets/icons/image.svg'
import UploadIcon from '../../assets/icons/upload.svg'
import './index.css'
const iconhash = {
'icon-image': ImageIcon,
'icon-folder': FolderIcon,
@ -18,6 +20,7 @@ class ImagePathPicker extends BaseScrollFloat {
this.renderArray = []
this.oldVnode = null
this.activeItem = null
this.floatBox.classList.add('ag-image-picker-wrapper')
this.listen()
}

View File

@ -0,0 +1,128 @@
.ag-image-selector-wrapper {
border-radius: 3px;
}
.ag-image-selector {
width: 500px;
height: 190px;
}
.ag-image-selector ul.header {
margin: 0;
padding: 0;
height: 40px;
border-bottom: 1px solid var(--editorColor10);
padding-left: 40px;
display: flex;
}
.ag-image-selector ul.header li {
font-size: 14px;
color: var(--editorColor);
height: 100%;
display: flex;
align-items: center;
justify-content: space-around;
position: relative;
}
.ag-image-selector ul.header li span {
display: inline-block;
padding: 0 8px;
width: 100%;
height: 30px;
line-height: 30px;
cursor: pointer;
border-radius: 5px;
}
.ag-image-selector ul.header li span:hover {
background: var(--editorColor04);
}
.ag-image-selector ul.header li.active::before {
content: '';
width: 100%;
height: 3px;
display: block;
background: var(--themeColor);
bottom: 0;
left: 0;
position: absolute;
}
.ag-image-selector .input-container {
width: 100%;
height: 30px;
margin: 20px 0 15px 0;
padding: 0 25px;
box-sizing: border-box;
display: flex;
}
.ag-image-selector .input-container input {
height: 30px;
border-radius: 15px;
outline: none;
padding: 0 15px;
border: none;
font-size: 14px;
background: var(--inputBgColor);
color: var(--editorColor);
margin-right: 10px;
}
.ag-image-selector .input-container input:last-of-type {
margin-right: 0;
}
.ag-image-selector .input-container input.src {
flex: 1;
}
.ag-image-selector .input-container input.title,
.ag-image-selector .input-container input.alt {
flex: 0;
max-width: 80px;
}
.ag-image-selector span.role-button {
display: block;
width: 280px;
height: 32px;
border-radius: 8px;
color: var(--editorColor80);
line-height: 28px;
font-size: 16px;
text-align: center;
border: 1px solid var(--buttonBorderColor);
box-shadow: 0 2px 4px 0 var(--buttonShadowColor);
background: var(--buttonBgColor);
cursor: pointer;
margin: 0 auto;
margin-bottom: 16px;
box-sizing: border-box;
user-select: none;
}
.ag-image-selector span.role-button.select {
margin-top: 35px;
}
.ag-image-selector span.role-button:hover {
background: var(--buttonHover);
}
.ag-image-selector span.role-button:active {
background: var(--buttonActive);
}
.ag-image-selector span.description {
text-align: center;
display: block;
color: var(--editorColor30);
}
.ag-image-selector span.description a {
cursor: pointer;
color: #4ca4f5;
}

View File

@ -0,0 +1,338 @@
import BaseFloat from '../baseFloat'
import { patch, h } from '../../parser/render/snabbdom'
import { EVENT_KEYS, URL_REG } from '../../config'
import { getUniqueId, getImageInfo as getImageSrc } from '../../utils'
import { getImageInfo } from '../../utils/getImageInfo'
import './index.css'
class ImageSelector extends BaseFloat {
static pluginName = 'imageSelector'
constructor (muya) {
const name = 'ag-image-selector'
const options = {
placement: 'bottom-center',
modifiers: {
offset: {
offset: '0, 0'
}
},
showArrow: false
}
super(muya, name, options)
this.renderArray = []
this.oldVnode = null
this.imageInfo = null
this.tab = 'link' // select or link
this.isFullMode = false // is show title and alt input
this.state = {
alt: '',
src: '',
title: ''
}
const imageSelectorContainer = this.imageSelectorContainer = document.createElement('div')
this.container.appendChild(imageSelectorContainer)
this.floatBox.classList.add('ag-image-selector-wrapper')
this.listen()
}
listen () {
super.listen()
const { eventCenter } = this.muya
eventCenter.subscribe('muya-image-selector', ({ reference, cb, imageInfo }) => {
if (reference) {
let { alt, backlash, src, title } = imageInfo.token
alt += encodeURI(backlash.first)
Object.assign(this.state, { alt, title, src })
this.imageInfo = imageInfo
this.show(reference, cb)
this.render()
// Auto focus and select all content of the `src.input` element.
const input = this.imageSelectorContainer.querySelector('input.src')
if (input) {
input.focus()
input.select()
}
} else {
this.hide()
}
})
}
tabClick (event, tab) {
const { value } = tab
this.tab = value
return this.render()
}
toggleMode () {
this.isFullMode = !this.isFullMode
return this.render()
}
inputHandler (event, type) {
const value = event.target.value
this.state[type] = value
}
handleKeyDown (event) {
if (event.key === EVENT_KEYS.Enter) {
event.stopPropagation()
this.handleLinkButtonClick()
}
}
srcInputKeyDown (event) {
const { imagePathPicker } = this.muya
if (!imagePathPicker.status) {
if (event.key === EVENT_KEYS.Enter) {
event.stopPropagation()
this.handleLinkButtonClick()
}
return
}
switch (event.key) {
case EVENT_KEYS.ArrowUp:
event.preventDefault()
imagePathPicker.step('previous')
break
case EVENT_KEYS.ArrowDown:
case EVENT_KEYS.Tab:
event.preventDefault()
imagePathPicker.step('next')
break
case EVENT_KEYS.Enter:
event.preventDefault()
imagePathPicker.selectItem(imagePathPicker.activeItem)
break
default:
break
}
}
async handleKeyUp (event) {
const { key } = event
if (
key === EVENT_KEYS.ArrowUp ||
key === EVENT_KEYS.ArrowDown ||
key === EVENT_KEYS.Tab ||
key === EVENT_KEYS.Enter
) {
return
}
const value = event.target.value
const { eventCenter } = this.muya
const reference = this.imageSelectorContainer.querySelector('input.src')
const cb = item => {
const { text } = item
const newValue = value.replace(/(\/)([^/]+)$/, (m, p1, p2) => {
return p1
}) + text
const len = newValue.length
reference.value = newValue
this.state.src = newValue
reference.focus()
reference.setSelectionRange(
len,
len
)
}
let list
if (!value) {
list = []
} else {
list = await this.muya.options.imagePathAutoComplete(value)
}
eventCenter.dispatch('muya-image-picker', { reference, list, cb })
}
handleLinkButtonClick () {
return this.replaceImageAsync(this.state)
}
async replaceImageAsync ({ alt, src, title }) {
if (!this.muya.options.imageAction || URL_REG.test(src)) {
const { alt: oldAlt, src: oldSrc, title: oldTitle } = this.imageInfo.token
if (alt !== oldAlt || src !== oldSrc || title !== oldTitle) {
this.muya.contentState.replaceImage(this.imageInfo, { alt, src, title })
}
this.hide()
} else {
if (src) {
const id = `loading-${getUniqueId()}`
this.muya.contentState.replaceImage(this.imageInfo, {
alt: id,
src,
title
})
this.hide()
const nSrc = await this.muya.options.imageAction(src)
const { src: localPath } = getImageSrc(src)
if (localPath) {
this.muya.contentState.stateRender.urlMap.set(nSrc, localPath)
}
const imageWrapper = this.muya.container.querySelector(`span[data-id=${id}]`)
if (imageWrapper) {
const imageInfo = getImageInfo(imageWrapper)
this.muya.contentState.replaceImage(imageInfo, {
alt,
src: nSrc,
title
})
}
} else {
this.hide()
}
}
this.muya.eventCenter.dispatch('stateChange')
}
async handleSelectButtonClick () {
if (!this.muya.options.imagePathPicker) {
console.warn('You need to add a imagePathPicker option')
return
}
const path = await this.muya.options.imagePathPicker()
const { alt, title } = this.state
return this.replaceImageAsync({
alt,
title,
src: path
})
}
renderHeader () {
const tabs = [{
label: 'Select',
value: 'select'
}, {
label: 'Embed link',
value: 'link'
}]
const children = tabs.map(tab => {
const itemSelector = this.tab === tab.value ? 'li.active' : 'li'
return h(itemSelector, h('span', {
on: {
click: event => {
this.tabClick(event, tab)
}
}
}, tab.label))
})
return h('ul.header', children)
}
renderBody () {
const { tab, state, isFullMode } = this
const { alt, title, src } = state
let bodyContent = null
if (tab === 'select') {
bodyContent = [
h('span.role-button.select', {
on: {
click: event => {
this.handleSelectButtonClick()
}
}
}, 'Choose an Image'),
h('span.description', 'Choose image from you computer.')
]
} else {
const altInput = h('input.alt', {
props: {
placeholder: 'Alt text',
value: alt
},
on: {
input: event => {
this.inputHandler(event, 'alt')
},
paste: event => {
this.inputHandler(event, 'alt')
},
keydown: event => {
this.handleKeyDown(event)
}
}
})
const srcInput = h('input.src', {
props: {
placeholder: 'Image link or local path',
value: src
},
on: {
input: event => {
this.inputHandler(event, 'src')
},
paste: event => {
this.inputHandler(event, 'src')
},
keydown: event => {
this.srcInputKeyDown(event)
},
keyup: event => {
this.handleKeyUp(event)
}
}
})
const titleInput = h('input.title', {
props: {
placeholder: 'Image title',
value: title
},
on: {
input: event => {
this.inputHandler(event, 'title')
},
paste: event => {
this.inputHandler(event, 'title')
},
keydown: event => {
this.handleKeyDown(event)
}
}
})
const inputWrapper = isFullMode
? h('div.input-container', [altInput, srcInput, titleInput])
: h('div.input-container', [srcInput])
const embedButton = h('span.role-button.link', {
on: {
click: event => {
this.handleLinkButtonClick()
}
}
}, 'Embed Image')
const bottomDes = h('span.description', [
h('span', 'Paste web image or local image path, '),
h('a', {
on: {
click: event => {
this.toggleMode()
}
}
}, `${isFullMode ? 'simple mode' : 'full mode'}`)
])
bodyContent = [inputWrapper, embedButton, bottomDes]
}
return h('div.image-select-body', bodyContent)
}
render () {
const { oldVnode, imageSelectorContainer } = this
const selector = 'div'
const vnode = h(selector, [this.renderHeader(), this.renderBody()])
if (oldVnode) {
patch(oldVnode, vnode)
} else {
patch(imageSelectorContainer, vnode)
}
this.oldVnode = vnode
}
}
export default ImageSelector

View File

@ -1,21 +0,0 @@
import selection from '../selection'
import { CLASS_OR_ID, IMAGE_EXT_REG } from '../config'
export const checkEditImage = () => {
const { start, end } = selection.getCursorRange()
if (!start || !end) {
return false
}
if (start.key === end.key && start.offset === end.offset) {
const node = selection.getSelectionStart()
const { right } = selection.getCaretOffsets(node)
const classList = node && node.classList
if (classList && (classList.contains(CLASS_OR_ID['AG_IMAGE_SRC'])) && right === 0) {
return IMAGE_EXT_REG.test(node.textContent) ? false : 'image-path'
}
if (classList && (classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) && right === 1) {
return 'image'
}
}
return false
}

View File

@ -0,0 +1,19 @@
import { findNearestParagraph, getOffsetOfParagraph } from '../selection/dom'
import { tokenizer } from '../parser'
export const getImageInfo = image => {
const paragraph = findNearestParagraph(image)
const raw = image.getAttribute('data-raw')
const offset = getOffsetOfParagraph(image, paragraph)
const tokens = tokenizer(raw)
const token = tokens[0]
token.range = {
start: offset,
end: offset + raw.length
}
return {
key: paragraph.id,
token,
imageId: image.id
}
}

View File

@ -1,6 +1,4 @@
// DOTO: Don't use Node API in editor folder, remove `path` @jocs
// todo@jocs: remove the use of `axios` in muya
import axios from 'axios'
import createDOMPurify from 'dompurify'
import { isInElectron, URL_REG } from '../config'
@ -135,7 +133,7 @@ export const deepCopy = object => {
return obj
}
export const loadImage = async (url, detectContentType) => {
export const loadImage = async (url, detectContentType = false) => {
if (detectContentType) {
const isImage = await checkImageContentType(url)
if (!isImage) throw new Error('not an image')
@ -152,17 +150,35 @@ export const loadImage = async (url, detectContentType) => {
})
}
export const checkImageContentType = async url => {
try {
const res = await axios.head(url)
const contentType = res.headers['content-type']
if (res.status === 200 && /^image\/(?:jpeg|png|gif|svg\+xml|webp)$/.test(contentType)) {
return true
export const checkImageContentType = url => {
const req = new XMLHttpRequest()
let settle
const promise = new Promise((resolve, reject) => {
settle = resolve
})
const handler = () => {
if (req.readyState === XMLHttpRequest.DONE) {
if (req.status === 200) {
const contentType = req.getResponseHeader('Content-Type')
if (/^image\/(?:jpeg|png|gif|svg\+xml|webp)$/.test(contentType)) {
settle(true)
} else {
settle(false)
}
} else {
settle(false)
}
}
return false
} catch (err) {
return false
}
const handleError = () => {
settle(false)
}
req.open('HEAD', url)
req.onreadystatechange = handler
req.onerror = handleError
req.send()
return promise
}
/**
@ -302,3 +318,9 @@ export const getParagraphReference = (ele, id) => {
id
}
}
export const verticalPositionInRect = (event, rect) => {
const { clientY } = event
const { top, height } = rect
return (clientY - top) > (height / 2) ? 'down' : 'up'
}

View File

@ -481,6 +481,16 @@ kbd {
background-size: cover;
}
.ag-inline-image.ag-image-success .ag-image-marked-text::before {
background: url(../lib/assets/icons/image_dark.png);
background-size: cover;
}
.ag-inline-image.ag-image-success .ag-image-marked-text.ag-image-fail::before {
background-image: url(../lib/assets/icons/image_dark_fail.png);
background-size: cover;
}
body.dark .ag-image-marked-text::before {
background: url(../lib/assets/icons/image_dark.png);
background-size: cover;

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 class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M985.6128 84.4288 42.3936 84.4288c-22.0544 0-39.9232 17.8688-39.9232 39.9488l0 728.5888c0 22.0416 17.8688 39.9232 39.9232 39.9232l943.2192 0c22.0288 0 39.9104-17.8816 39.9104-39.9232L1025.5232 124.3648C1025.5232 102.2976 1007.6416 84.4288 985.6128 84.4288zM962.5088 833.5872 64.7808 833.5872 64.7808 663.3216l228.992-229.5552 302.7584 302.7456L769.536 541.8752l192.9728 194.6496L962.5088 833.5872zM962.5088 640.0256l-187.136-191.296L596.5312 640.0256 293.7728 347.2384 64.7808 566.8352 64.7808 150.4128l897.7408 0L962.5216 640.0256zM762.88 326.464m-49.8944 0a3.898 3.898 0 1 0 99.7888 0 3.898 3.898 0 1 0-99.7888 0Z" /></svg>

After

Width:  |  Height:  |  Size: 893 B

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 class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M873.984 610.816c-7.168-7.168-14.848-10.752-25.6-10.752s-18.432 3.584-25.6 10.752l-102.4 102.4c-14.848 14.848-14.848 36.352 0 51.2 14.848 14.848 36.352 14.848 51.2 0l40.448-40.448v226.816c0 18.432 14.848 36.352 36.352 36.352 22.016 0 36.352-14.848 36.352-36.352v-226.816l44.032 44.032c14.848 14.848 36.352 14.848 51.2 0 14.848-14.848 14.848-36.352 0-51.2l-105.984-105.984zM394.752 336.384c18.432-40.448 7.168-87.552-22.016-120.832-32.768-32.768-83.968-40.448-124.416-25.6-40.448 18.432-66.048 61.952-66.048 105.984 0 58.368 47.616 105.984 105.984 105.984 44.544 0.512 88.576-25.088 106.496-65.536zM256 296.448c0-10.752 3.584-18.432 10.752-25.6 3.584-10.752 14.848-14.848 25.6-14.848 18.432 0 36.352 14.848 36.352 32.768 0 18.432-14.848 36.352-32.768 40.448-21.504 0-39.936-14.848-39.936-32.768z m0 0" /><path d="M950.784 36.352H73.216C32.768 36.352 0 69.632 0 109.568v731.648c0 40.448 32.768 73.216 73.216 73.216h585.216c19.968 0 36.352-16.384 36.352-36.352 0-19.968-16.384-36.352-36.352-36.352H109.568c-22.016 0-36.352-18.432-36.352-36.352V146.432c0-18.432 14.848-36.352 36.352-36.352h804.352c22.016 0 36.352 18.432 36.352 36.352v438.784c0 19.968 16.384 36.352 36.352 36.352 19.968 0 36.352-16.384 36.352-36.352V109.568c1.024-39.936-31.744-73.216-72.192-73.216z m0 0" /><path d="M135.168 742.4c14.848 14.848 36.352 14.848 51.2 0l153.6-153.6 128 128c3.584 3.584 3.584 3.584 7.168 3.584 14.848 7.168 29.184 7.168 44.032-3.584l361.984-361.984c14.848-14.848 14.848-36.352 0-51.2-14.848-14.848-36.352-14.848-51.2 0L493.568 640l-128-131.584-3.584-3.584c-14.848-10.752-32.768-10.752-47.616 3.584L131.584 691.2c-10.752 14.848-10.752 36.352 3.584 51.2z m0 0" /></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -29,6 +29,12 @@
--iconColor: #333;
--codeBgColor: #d8d8d869;
--codeBlockBgColor: rgba(0, 0, 0, 0.03);
--inputBgColor: rgba(0, 0, 0, .06);
--buttonBgColor: #ffffff;
--buttonBorderColor: rgba(0, 0, 0, 0.2);
--buttonShadow: rgba(0, 0, 0, 0.12);
--buttonHover: #f2f2f2;
--buttonActive: #e5e5e5;
/*marktext*/
--sideBarColor: rgba(0, 0, 0, .6);
@ -110,6 +116,23 @@ body {
background-color: var(--floatBgColor);
}
input.el-input__inner {
background: var(--inputBgColor);
}
.el-tabs__nav-wrap::after {
background: var(--editorColor04);
}
div.el-tabs__item {
color: var(--editorColor);
}
div.el-tab-pane {
color: var(--editorColor);
font-size: 14px;
}
.ag-dialog-table .dialog-title svg {
width: 1.5em;
height: 1.5em;

View File

@ -24,6 +24,12 @@
--iconColor: rgba(255, 255, 255, .8);
--codeBgColor: #424344;
--codeBlockBgColor: #424344;
--inputBgColor: rgba(0, 0, 0, .1);
--buttonBgColor: #58606a;
--buttonBorderColor: rgba(0, 0, 0, 0.2);
--buttonShadow: rgba(0, 0, 0, 0.12);
--buttonHover: #4a5058;
--buttonActive: #434a53;
/*marktext*/
--sideBarColor: rgba(255, 255, 255, .6);

View File

@ -23,6 +23,12 @@
--iconColor: rgba(135, 135, 135, .8);
--codeBgColor: #d8d8d869;
--codeBlockBgColor: rgba(104, 134, 170, .04);
--inputBgColor: rgba(0, 0, 0, .06);
--buttonBgColor: #ffffff;
--buttonBorderColor: rgba(0, 0, 0, 0.2);
--buttonShadow: rgba(0, 0, 0, 0.12);
--buttonHover: #f2f2f2;
--buttonActive: #e5e5e5;
--sideBarColor: rgba(188, 193, 197, .8);
--sideBarTitleColor: rgba(255, 255, 255, 1);

View File

@ -24,6 +24,12 @@
--iconColor: rgba(255, 255, 255, .8);
--codeBgColor: #d8d8d869;
--codeBlockBgColor: #3f454c;
--inputBgColor: rgba(0, 0, 0, .1);
--buttonBgColor: #58606a;
--buttonBorderColor: rgba(0, 0, 0, 0.2);
--buttonShadow: rgba(0, 0, 0, 0.12);
--buttonHover: #4a5058;
--buttonActive: #434a53;
/*marktext*/
--sideBarColor: rgba(255, 255, 255, .6);

View File

@ -24,6 +24,12 @@
--iconColor: rgba(255, 255, 255, .8);
--codeBgColor: #3a3f4b;
--codeBlockBgColor: #3a3f4b;
--inputBgColor: rgba(0, 0, 0, .1);
--buttonBgColor: #58606a;
--buttonBorderColor: rgba(0, 0, 0, 0.2);
--buttonShadow: rgba(0, 0, 0, 0.12);
--buttonHover: #4a5058;
--buttonActive: #434a53;
/*marktext*/
--sideBarColor: #9da5b4;
@ -37,7 +43,7 @@
--floatBorderColor: #181a1f;
--floatShadow: rgba(0, 0, 0, 0.3);
--maskColor: rgba(0, 0, 0, .7);
--editorAreaWidth: 700px;
--editorAreaWidth: 750px;
}
::-webkit-scrollbar {

View File

@ -23,6 +23,12 @@
--iconColor: rgba(101, 101, 101, .8);
--codeBgColor: #d8d8d869;
--codeBlockBgColor: rgba(12, 139, 186, .04);
--inputBgColor: rgba(0, 0, 0, .06);
--buttonBgColor: #ffffff;
--buttonBorderColor: rgba(0, 0, 0, 0.2);
--buttonShadow: rgba(0, 0, 0, 0.12);
--buttonHover: #f2f2f2;
--buttonActive: #e5e5e5;
--sideBarColor: rgba(101, 101, 101, .6);
--sideBarTitleColor: rgba(101, 101, 101, 1);

View File

@ -81,15 +81,19 @@
import CodePicker from 'muya/lib/ui/codePicker'
import EmojiPicker from 'muya/lib/ui/emojiPicker'
import ImagePathPicker from 'muya/lib/ui/imagePicker'
import ImageSelector from 'muya/lib/ui/imageSelector'
import FormatPicker from 'muya/lib/ui/formatPicker'
import FrontMenu from 'muya/lib/ui/frontMenu'
import bus from '../../bus'
import Search from '../search.vue'
import { animatedScrollTo } from '../../util'
import { addCommonStyle } from '../../util/theme'
import { guessClipboardFilePath } from '../../util/guessClipBoardFilePath'
import { showContextMenu } from '../../contextMenu/editor'
import Printer from '@/services/printService'
import { DEFAULT_EDITOR_FONT_FAMILY } from '@/config'
import { moveImageToFolder, uploadImage } from '@/util/fileSystem'
import notice from '@/services/notification'
import 'muya/themes/default.css'
import '@/assets/themes/codemirror/one-dark.css'
@ -116,6 +120,7 @@
},
computed: {
...mapState({
'preferences': state => state.preferences,
'preferLooseListItem': state => state.preferences.preferLooseListItem,
'autoPairBracket': state => state.preferences.autoPairBracket,
'autoPairMarkdownSyntax': state => state.preferences.autoPairMarkdownSyntax,
@ -132,7 +137,11 @@
'darkColor': state => state.preferences.darkColor,
'editorFontFamily': state => state.preferences.editorFontFamily,
'hideQuickInsertHint': state => state.preferences.hideQuickInsertHint,
'imageInsertAction': state => state.preferences.imageInsertAction,
'imageFolderPath': state => state.preferences.imageFolderPath,
'theme': state => state.preferences.theme,
'currentFile': state => state.editor.currentFile,
// edit modes
'typewriter': state => state.preferences.typewriter,
'focus': state => state.preferences.focus,
@ -291,6 +300,7 @@
Muya.use(CodePicker)
Muya.use(EmojiPicker)
Muya.use(ImagePathPicker)
Muya.use(ImageSelector)
Muya.use(FormatPicker)
Muya.use(FrontMenu)
@ -305,7 +315,11 @@
orderListDelimiter,
tabSize,
listIndentation,
hideQuickInsertHint
hideQuickInsertHint,
imageAction: this.imageAction.bind(this),
imagePathPicker: this.imagePathPicker.bind(this),
clipboardFilePath: guessClipboardFilePath,
imagePathAutoComplete: this.imagePathAutoComplete.bind(this)
}
if (/dark/i.test(theme)) {
Object.assign(options, {
@ -341,7 +355,6 @@
bus.$on('image-uploaded', this.handleUploadedImage)
bus.$on('file-changed', this.handleMarkdownChange)
bus.$on('editor-blur', this.blurEditor)
bus.$on('image-auto-path', this.handleImagePath)
bus.$on('copyAsMarkdown', this.handleCopyPaste)
bus.$on('copyAsHtml', this.handleCopyPaste)
bus.$on('pasteAsPlainText', this.handleCopyPaste)
@ -353,19 +366,7 @@
bus.$on('scroll-to-header', this.scrollToHeader)
bus.$on('copy-block', this.handleCopyBlock)
bus.$on('print', this.handlePrint)
// when cursor is in `![](cursor)` will emit `insert-image`
this.editor.on('insert-image', type => {
if (type === 'absolute' || type === 'relative') {
this.$store.dispatch('ASK_FOR_INSERT_IMAGE', type)
} else if (type === 'upload') {
bus.$emit('upload-image')
}
})
this.editor.on('image-path-autocomplement', src => {
this.$store.dispatch('ASK_FOR_IMAGE_AUTO_PATH', src)
})
bus.$on('screenshot-captured', this.handleScreenShot)
this.editor.on('change', changes => {
// WORKAROUND: "id: 'muya'"
@ -391,6 +392,19 @@
}
})
this.editor.on('preview-image', ({ data }) => {
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 => {
const { y } = changes.cursorCoords
if (this.typewriter) {
@ -412,17 +426,52 @@
})
},
methods: {
async imagePathAutoComplete (src) {
const files = await this.$store.dispatch('ASK_FOR_IMAGE_AUTO_PATH', src)
return files.map(f => {
const iconClass = f.type === 'directory' ? 'icon-folder' : 'icon-image'
return Object.assign(f, { iconClass, text: f.file + (f.type === 'directory' ? '/' : '') })
})
},
async imageAction (image) {
const { imageInsertAction, imageFolderPath, preferences } = this
const { pathname } = this.currentFile
switch (imageInsertAction) {
case 'upload': {
try {
const result = await uploadImage(pathname, image, preferences)
return result
} catch (err) {
notice.notify({
title: 'Upload Image',
type: 'info',
message: err
})
return await moveImageToFolder(pathname, image, imageFolderPath)
}
}
case 'folder': {
return await moveImageToFolder(pathname, image, imageFolderPath)
}
case 'path': {
if (typeof image === 'string') {
return image
} else {
// Move image to image folder if it's Blob object.
return await moveImageToFolder(pathname, image, imageFolderPath)
}
}
}
},
imagePathPicker () {
return this.$store.dispatch('ASK_FOR_IMAGE_PATH')
},
keyup (event) {
if (event.key === 'Escape') {
this.setImageViewerVisible(false)
}
},
handleImagePath (files) {
const { editor } = this
editor && editor.showAutoImagePath(files)
},
setImageViewerVisible (status) {
this.imageViewerVisible = status
},
@ -452,9 +501,9 @@
}
},
handleSelect (url) {
handleSelect (src) {
if (!this.sourceCode) {
this.editor && this.editor.insertImage(url)
this.editor && this.editor.insertImage({ src })
}
},
@ -618,6 +667,12 @@
handleCopyBlock (name) {
this.editor.copy(name)
},
handleScreenShot () {
if (this.editor) {
document.execCommand('paste')
}
}
},
beforeDestroy () {
@ -636,7 +691,6 @@
bus.$off('image-uploaded', this.handleUploadedImage)
bus.$off('file-changed', this.handleMarkdownChange)
bus.$off('editor-blur', this.blurEditor)
bus.$off('image-auto-path', this.handleImagePath)
bus.$off('copyAsMarkdown', this.handleCopyPaste)
bus.$off('copyAsHtml', this.handleCopyPaste)
bus.$off('pasteAsPlainText', this.handleCopyPaste)
@ -648,6 +702,7 @@
bus.$off('scroll-to-header', this.scrollToHeader)
bus.$off('copy-block', this.handleCopyBlock)
bus.$off('print', this.handlePrint)
bus.$off('screenshot-captured', this.handleScreenShot)
document.removeEventListener('keyup', this.keyup)

View File

@ -1,106 +0,0 @@
<template>
<div class="aidou">
<el-dialog
:visible.sync="showUpload"
:show-close="false"
:modal="true"
custom-class="ag-dialog-table"
width="400px"
>
<el-upload
ref="uploader"
class="upload-image"
drag
action="https://sm.ms/api/upload"
name="smfile"
:multiple="false"
:limit="1"
:on-success="handleResponse"
:before-upload="handleBeforeUpload"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">Drag image here, or <em>Click</em></div>
<div class="el-upload__tip" slot="tip" :class="{ 'error': error }">{{ message }}</div>
</el-upload>
</el-dialog>
</div>
</template>
<script>
import bus from '../../bus'
const msg = 'jpg | png | gif | jpeg only, max size 5M'
export default {
data () {
return {
showUpload: false,
message: msg,
error: false
}
},
created () {
this.$nextTick(() => {
bus.$on('upload-image', this.handleUpload)
})
},
methods: {
handleBeforeUpload (file) {
const MAX_SIZE = 5 * 1024 * 1024
if (!/png|jpg|jpeg|gif/.test(file.type)) {
this.message = 'jpg | png | gif | jpeg only'
this.error = true
return false
}
if (file.size > MAX_SIZE) {
this.message = 'Upload image limit to 5M'
this.error = true
return false
}
this.message = msg
this.error = false
},
handleUpload () {
if (!this.showUpload) {
this.showUpload = true
bus.$emit('editor-blur')
}
},
handleResponse (res) {
if (res.code === 'success') {
// handle success
const { url, delete: deletionUrl } = res.data
this.showUpload = false
bus.$emit('image-uploaded', url, deletionUrl)
} else if (res.code === 'error') {
// handle error
this.message = res.msg
this.error = true
}
this.$refs.uploader.clearFiles()
}
}
}
</script>
<style>
.el-upload__tip {
text-align: center;
color: var(--sideBarColor);
}
.el-upload__tip.error {
color: #E6A23C;
}
.el-upload-dragger {
background: var(--itemBgColor) !important;
& .el-upload__text {
color: var(--sideBarColor);
& em {
color: var(--themeColor);
}
}
}
.el-upload-dragger:hover {
border-color: var(--themeColor);
}
</style>

View File

@ -1,7 +1,5 @@
import path from 'path'
export const isLinux = process.platform === 'linux'
import { isLinux } from './util'
export const PATH_SEPARATOR = path.sep
export const THEME_STYLE_ID = 'ag-theme'

View File

@ -25,7 +25,11 @@ import {
Switch,
Select,
Option,
Radio
Radio,
RadioGroup,
Tabs,
TabPane,
Input
} from 'element-ui'
import services from './services'
import routes from './router'
@ -72,6 +76,10 @@ Vue.use(Switch)
Vue.use(Select)
Vue.use(Option)
Vue.use(Radio)
Vue.use(RadioGroup)
Vue.use(Tabs)
Vue.use(TabPane)
Vue.use(Input)
Vue.use(VueRouter)

View File

@ -27,7 +27,6 @@
:platform="platform"
></editor-with-tabs>
<aidou></aidou>
<upload-image></upload-image>
<about-dialog></about-dialog>
<rename></rename>
<tweet></tweet>
@ -43,7 +42,6 @@
import TitleBar from '@/components/titleBar'
import SideBar from '@/components/sideBar'
import Aidou from '@/components/aidou/aidou'
import UploadImage from '@/components/uploadImage'
import AboutDialog from '@/components/about'
import Rename from '@/components/rename'
import Tweet from '@/components/tweet'
@ -60,7 +58,6 @@
EditorWithTabs,
TitleBar,
SideBar,
UploadImage,
AboutDialog,
Rename,
Tweet,
@ -116,7 +113,6 @@
dispatch('LISTEN_FOR_LAYOUT')
dispatch('LISTEN_FOR_REQUEST_LAYOUT')
// module: listenForMain
dispatch('LISTEN_FOR_IMAGE_PATH')
dispatch('LISTEN_FOR_EDIT')
dispatch('LISTEN_FOR_VIEW')
dispatch('LISTEN_FOR_ABOUT_DIALOG')
@ -128,6 +124,7 @@
// module: autoUpdates
dispatch('LISTEN_FOR_UPDATE')
// module: editor
dispatch('LISTEN_SCREEN_SHOT')
dispatch('ASK_FOR_USER_PREFERENCE')
dispatch('ASK_FOR_MODE')
dispatch('LISTEN_FOR_CLOSE')
@ -138,7 +135,6 @@
dispatch('LISTEN_FOR_BOOTSTRAP_WINDOW')
dispatch('LISTEN_FOR_SAVE_CLOSE')
dispatch('LISTEN_FOR_EXPORT_PRINT')
dispatch('LISTEN_FOR_INSERT_IMAGE')
dispatch('LISTEN_FOR_RENAME')
dispatch('LINTEN_FOR_SET_LINE_ENDING')
dispatch('LISTEN_FOR_NEW_TAB')
@ -154,11 +150,11 @@
// Cancel to allow tab drag&drop.
if (!e.dataTransfer.types.length) return
e.preventDefault()
if (e.dataTransfer.types.indexOf('Files') >= 0) {
if (e.dataTransfer.items.length === 1 && /png|jpg|jpeg|gif/.test(e.dataTransfer.items[0].type)) {
bus.$emit('upload-image')
if (e.dataTransfer.items.length === 1 && e.dataTransfer.items[0].type.indexOf('image') > -1) {
// Do nothing, because we already drag/drop image in muya.
} else {
e.preventDefault()
if (this.timer) {
clearTimeout(this.timer)
}
@ -167,6 +163,7 @@
}, 300)
bus.$emit('importDialog', true)
}
e.dataTransfer.dropEffect = 'copy'
} else {
e.stopPropagation()

View File

@ -75,13 +75,6 @@
:bool="hideQuickInsertHint"
:onChange="value => onSelectChange('hideQuickInsertHint', value)"
></bool>
<separator></separator>
<section class="image-ctrl ag-underdevelop">
<div>The default behavior after paste or drag the image to Mark Text</div>
<el-radio v-model="imageDropAction" label="upload">Upload image to cloud</el-radio>
<el-radio v-model="imageDropAction" label="folder">Move image to sepcial folder</el-radio>
<el-radio v-model="imageDropAction" label="path">Insert absolute or relative path of image</el-radio>
</section>
</div>
</template>
@ -124,8 +117,7 @@ export default {
textDirection: state => state.preferences.textDirection,
codeFontSize: state => state.preferences.codeFontSize,
codeFontFamily: state => state.preferences.codeFontFamily,
hideQuickInsertHint: state => state.preferences.hideQuickInsertHint,
imageDropAction: state => state.preferences.imageDropAction
hideQuickInsertHint: state => state.preferences.hideQuickInsertHint
})
},
methods: {

View File

@ -0,0 +1,98 @@
<template>
<div class="pref-image">
<h4>Image</h4>
<section class="image-ctrl">
<div>The default behavior after insert image from local folder.
<el-tooltip class='item' effect='dark' content='Mark Text can not get image path from paste event in Linux system.' placement='top-start'>
<i class="el-icon-info"></i>
</el-tooltip>
</div>
<el-radio-group v-model="imageInsertAction">
<el-radio label="upload">Upload image to cloud by image uploader</el-radio>
<el-radio label="folder">Move image to sepcial folder</el-radio>
<el-radio label="path">Insert absolute or relative path of image</el-radio>
</el-radio-group>
</section>
<separator></separator>
<section class="image-folder">
<div class="description">The local image folder.</div>
<div class="path">{{imageFolderPath}}</div>
<div class="button-group">
<el-button size="mini" @click="modifyImageFolderPath">Modify</el-button>
<el-button size="mini" @click="openImageFolder">Open Folder</el-button>
</div>
</section>
</div>
</template>
<script>
import Separator from '../common/separator'
import { shell } from 'electron'
export default {
components: {
Separator
},
data () {
return {
}
},
computed: {
imageInsertAction: {
get: function () {
return this.$store.state.preferences.imageInsertAction
},
set: function (value) {
const type = 'imageInsertAction'
this.$store.dispatch('SET_SINGLE_PREFERENCE', { type, value })
}
},
imageFolderPath: {
get: function () {
return this.$store.state.preferences.imageFolderPath
}
}
},
methods: {
openImageFolder () {
shell.openItem(this.imageFolderPath)
},
modifyImageFolderPath () {
return this.$store.dispatch('SET_IMAGE_FOLDER_PATH')
}
}
}
</script>
<style>
.pref-image {
& h4 {
text-transform: uppercase;
margin: 0;
font-weight: 100;
}
& .image-ctrl {
font-size: 14px;
margin: 20px 0;
color: var(--editorColor);
& label {
display: block;
margin: 20px 0;
}
}
& .image-folder {
& div.description {
font-size: 14px;
color: var(--editorColor);
}
& div.path {
font-size: 14px;
color: var(--editorColor50);
margin-top: 15px;
margin-bottom: 15px;
}
}
}
</style>

View File

@ -0,0 +1,159 @@
<template>
<div class="pref-image-uploader">
<h4>Image Uploader</h4>
<section class="current-uploader">
<div>The current image uploader is <span class="uploader">{{currentUploader}}</span>.</div>
</section>
<separator></separator>
<section class="configration">
<el-tabs v-model="activeTab">
<el-tab-pane label="SM.MS" name="smms">
<div class="description">Thank you <span class="link" @click="open('https://sm.ms/')">SM.MS</span> for providing free uploading services.</div>
<el-button size="mini" @click="setCurrentUploader('smms')">Set As default Uploader</el-button>
</el-tab-pane>
<el-tab-pane label="Github" name="github">
<div class="form-group">
<div class="label">GitHub token:</div>
<el-input v-model="githubToken" placeholder="Input token" size="mini"></el-input>
</div>
<div class="form-group">
<div class="label">Owner name:</div>
<el-input v-model="github.owner" placeholder="owner" size="mini"></el-input>
</div>
<div class="form-group">
<div class="label">Repo name:</div>
<el-input v-model="github.repo" placeholder="repo" size="mini"></el-input>
</div>
<div class="form-group button-group">
<el-button size="mini" :disabled="githubDisable" @click="save('github')">Save</el-button>
<el-button size="mini" :disabled="githubDisable" @click="setCurrentUploader('github')">Set As default Uploader</el-button>
</div>
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script>
import Separator from '../common/separator'
import { shell } from 'electron'
export default {
components: {
Separator
},
data () {
return {
activeTab: 'smms',
githubToken: '',
github: {
owner: '',
repo: ''
}
}
},
computed: {
currentUploader: {
get: function () {
return this.$store.state.preferences.currentUploader
}
},
imageBed: {
get: function () {
return this.$store.state.preferences.imageBed
}
},
prefGithubToken: {
get: function () {
return this.$store.state.preferences.githubToken
}
},
githubDisable () {
return !this.githubToken || !this.github.owner || !this.github.repo
}
},
watch: {
imageBed: function (value, oldValue) {
if (value !== oldValue) {
this.github = value.github
}
}
},
created () {
this.$nextTick(() => {
this.github = this.imageBed.github
this.githubToken = this.prefGithubToken
})
},
methods: {
handleImageChange (value) {
// console.log(value)
},
open (link) {
shell.openExternal(link)
},
save (type) {
const newImageBedConfig = Object.assign({}, this.imageBed, {[type]: this[type]})
this.$store.dispatch('SET_USER_DATA', {
type: 'imageBed',
value: newImageBedConfig
})
if (type === 'github') {
this.$store.dispatch('SET_USER_DATA', {
type: 'githubToken',
value: this.githubToken
})
}
new Notification('Save Image Uploader', {
body: `The Github configration has been saved.`
})
},
setCurrentUploader (value) {
const type = 'currentUploader'
this.$store.dispatch('SET_USER_DATA', { type, value })
new Notification('Set Image Uploader', {
body: `Set ${value} as the default image uploader successfully.`
})
}
}
}
</script>
<style>
.pref-image-uploader {
& h4 {
text-transform: uppercase;
margin: 0;
font-weight: 100;
}
& .current-uploader {
font-size: 14px;
margin: 20px 0;
color: var(--editorColor);
& .uploader {
color: var(--editorColor80);
font-size: 600;
}
}
& .link {
color: var(--themeColor);
cursor: pointer;
}
& .description {
margin-top: 20px;
margin-bottom: 20px;
}
& .form-group {
margin: 20px 0 0 0;
}
& .label {
margin-bottom: 10px;
}
& .el-input {
max-width: 242px;
}
& .button-group {
margin-top: 30px;
}
}
</style>

View File

@ -2,25 +2,41 @@ import GeneralIcon from '@/assets/icons/pref_general.svg'
import EditorIcon from '@/assets/icons/pref_editor.svg'
import MarkdownIcon from '@/assets/icons/pref_markdown.svg'
import ThemeIcon from '@/assets/icons/pref_theme.svg'
import ImageIcon from '@/assets/icons/pref_image.svg'
import ImageUploaderIcon from '@/assets/icons/pref_image_uploader.svg'
import preferences from '../../../main/preferences/schema'
export const category = [{
name: 'General',
label: 'general',
icon: GeneralIcon,
path: '/preference/general'
}, {
name: 'Editor',
label: 'editor',
icon: EditorIcon,
path: '/preference/editor'
}, {
name: 'Markdown',
label: 'markdown',
icon: MarkdownIcon,
path: '/preference/markdown'
}, {
name: 'Theme',
label: 'theme',
icon: ThemeIcon,
path: '/preference/theme'
}, {
name: 'Image',
label: 'image',
icon: ImageIcon,
path: '/preference/image'
}, {
name: 'Image Uploader',
label: 'imageUploader',
icon: ImageUploaderIcon,
path: '/preference/imageUploader'
}]
export const searchContent = Object.keys(preferences).map(k => {

View File

@ -23,7 +23,7 @@
<section class="category">
<div v-for="c of category" :key="c.name" class="item"
@click="handleCategoryItemClick(c)"
:class="{active: c.name.toLowerCase() === currentCategory}"
:class="{active: c.label === currentCategory}"
>
<svg :viewBox="c.icon.viewBox">
<use :xlink:href="c.icon.url"></use>

View File

@ -4,6 +4,8 @@ import General from '@/prefComponents/general'
import Editor from '@/prefComponents/editor'
import Markdown from '@/prefComponents/markdown'
import Theme from '@/prefComponents/theme'
import Image from '@/prefComponents/image'
import ImageUploader from '@/prefComponents/imageUploader'
const routes = type => ([{
path: '/', redirect: type === 'editor'? '/editor' : '/preference'
@ -21,6 +23,10 @@ const routes = type => ([{
path: 'markdown', component: Markdown, name: 'markdown'
}, {
path: 'theme', component: Theme, name: 'theme'
}, {
path: 'image', component: Image, name: 'image'
}, {
path: 'imageUploader', component: ImageUploader, name: 'imageUploader'
}]
}])

View File

@ -1,7 +1,7 @@
import { clipboard, ipcRenderer, shell } from 'electron'
import path from 'path'
import bus from '../bus'
import { hasKeys } from '../util'
import { hasKeys, getUniqueId } from '../util'
import { isSameFileSync } from '../util/fileSystem'
import listToTree from '../util/listToTree'
import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
@ -216,18 +216,32 @@ const mutations = {
}
const actions = {
// when cursor in `![](cursor)`, insert image popup will be shown! `absolute` or `relative`
ASK_FOR_INSERT_IMAGE ({ commit }, type) {
ipcRenderer.send('AGANI::ask-for-insert-image', type)
},
FORMAT_LINK_CLICK ({ commit }, { data, dirname }) {
ipcRenderer.send('AGANI::format-link-click', { data, dirname })
},
LISTEN_SCREEN_SHOT ({ commit }) {
ipcRenderer.on('mt::screenshot-captured', e => {
bus.$emit('screenshot-captured')
})
},
// image path auto complement
ASK_FOR_IMAGE_AUTO_PATH ({ commit, state }, src) {
const { pathname } = state.currentFile
if (pathname) {
ipcRenderer.send('AGANI::ask-for-image-auto-path', { pathname, src })
let rs
const promise = new Promise((resolve, reject) => {
rs = resolve
})
const id = getUniqueId()
ipcRenderer.once(`mt::response-of-image-path-${id}`, (e, files) => {
rs(files)
})
ipcRenderer.send('mt::ask-for-image-auto-path', { pathname, src, id })
return promise
} else {
return []
}
},
@ -647,22 +661,6 @@ const actions = {
})
},
LISTEN_FOR_INSERT_IMAGE ({ commit, state }) {
ipcRenderer.on('AGANI::INSERT_IMAGE', (e, { filename: imagePath, type }) => {
if (!hasKeys(state.currentFile)) return
if (type === 'absolute' || type === 'relative') {
const { pathname } = state.currentFile
if (type === 'relative' && pathname) {
imagePath = path.relative(path.dirname(pathname), imagePath)
}
bus.$emit('insert-image', imagePath)
} else {
// upload to CM
bus.$emit('upload-image')
}
})
},
LINTEN_FOR_SET_LINE_ENDING ({ commit, state }) {
ipcRenderer.on('AGANI::set-line-ending', (e, { lineEnding, ignoreSaveStatus }) => {
const { lineEnding: oldLineEnding } = state.currentFile
@ -713,6 +711,10 @@ const actions = {
ASK_FILE_WATCH ({ commit }, { pathname, watch }) {
ipcRenderer.send('AGANI::file-watch', { pathname, watch })
},
ASK_FOR_IMAGE_PATH ({ commit }) {
return ipcRenderer.sendSync('mt::ask-for-image-path')
}
}

View File

@ -9,12 +9,6 @@ const getters = {}
const mutations = {}
const actions = {
LISTEN_FOR_IMAGE_PATH ({ commit }) {
ipcRenderer.on('AGANI::image-auto-path', (e, files) => {
bus.$emit('image-auto-path', files)
})
},
LISTEN_FOR_EDIT ({ commit }) {
ipcRenderer.on('AGANI::edit', (e, { type }) => {
bus.$emit(type, type)

View File

@ -23,7 +23,7 @@ const state = {
endOfLine: 'default',
textDirection: 'ltr',
hideQuickInsertHint: false,
imageDropAction: 'folder',
imageInsertAction: 'folder',
preferLooseListItem: true,
bulletListMarker: '-',
@ -36,7 +36,20 @@ const state = {
// edit modes (they are not in preference.md, but still put them here)
typewriter: false, // typewriter mode
focus: false, // focus mode
sourceCode: false // source code mode
sourceCode: false, // source code mode
// user configration
imageFolderPath: '',
webImages: [],
cloudImages: [],
currentUploader: 'smms',
githubToken: '',
imageBed: {
github: {
owner: '',
repo: ''
}
}
}
const getters = {}
@ -57,6 +70,7 @@ const mutations = {
const actions = {
ASK_FOR_USER_PREFERENCE ({ commit, state, rootState }) {
ipcRenderer.send('mt::ask-for-user-preference')
ipcRenderer.send('mt::ask-for-user-data')
ipcRenderer.on('AGANI::user-preference', (e, preference) => {
const { autoSave } = preference
@ -90,6 +104,14 @@ const actions = {
// commit('SET_USER_PREFERENCE', { [type]: value })
// save to electron-store
ipcRenderer.send('mt::set-user-preference', { [type]: value })
},
SET_USER_DATA ({ commit }, { type, value }) {
ipcRenderer.send('mt::set-user-data', { [type]: value })
},
SET_IMAGE_FOLDER_PATH ({ commit }) {
ipcRenderer.send('mt::ask-for-modify-image-folder-path')
}
}

View File

@ -1,5 +1,10 @@
import path from 'path'
import fse from 'fs-extra'
import dayjs from 'dayjs'
import Octokit from '@octokit/rest'
import { isImageFile } from '../../main/filesystem'
import { dataURItoBlob, getContentHash } from './index'
import axios from 'axios'
export const create = (pathname, type) => {
if (type === 'directory') {
@ -43,3 +48,136 @@ export const isSameFileSync = (pathA, pathB, isNormalized=false) => {
}
return false
}
export const moveImageToFolder = async (pathname, image, dir) => {
const isPath = typeof image === 'string'
if (isPath) {
const dirname = path.dirname(pathname)
const imagePath = path.resolve(dirname, image)
const isImage = isImageFile(imagePath)
if (isImage) {
const filename = path.basename(imagePath)
const extname = path.extname(imagePath)
const noHashPath = path.join(dir, filename)
if (noHashPath === imagePath) {
return imagePath
}
const hash = getContentHash(imagePath)
// To avoid name conflict.
const hashFilePath = path.join(dir, `${hash}${extname}`)
await fse.copy(imagePath, hashFilePath)
return hashFilePath
} else {
return Promise.resolve(image)
}
} else {
const imagePath = path.join(dir, `${dayjs().format('YYYY-MM-DD-HH-mm-ss')}-${image.name}`)
const binaryString = await new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = () => {
resolve(fileReader.result)
}
fileReader.readAsBinaryString(image)
})
await fse.writeFile(imagePath, binaryString, 'binary')
return imagePath
}
}
/**
* @jocs todo, rewrite it use class
*/
export const uploadImage = async (pathname, image, preferences) => {
const { currentUploader } = preferences
const { owner, repo } = preferences.imageBed.github
const token = preferences.githubToken
const isPath = typeof image === 'string'
const MAX_SIZE = 5 * 1024 * 1024
let re
let rj
const promise = new Promise((resolve, reject) => {
re = resolve
rj = reject
})
const uploadToSMMS = file => {
const api = 'https://sm.ms/api/upload'
const formData = new window.FormData()
formData.append('smfile', file)
axios.post(api, formData).then((res) => {
re(res.data.data.url)
})
.catch(err => {
rj('Upload failed, the image will be copied to the image folder')
})
}
const uploadByGithub = (content, filename) => {
const octokit = new Octokit({
auth: `token ${token}`
})
const path = dayjs().format('YYYY/MM') + `/${dayjs().format('DD-HH-mm-ss')}-${filename}`
const message = `Upload by Mark Text at ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`
octokit.repos.createFile({
owner,
repo,
path,
message,
content
}).then(result => {
re(result.data.content.download_url)
})
.catch(err => {
rj('Upload failed, the image will be copied to the image folder')
})
}
const notification = () => {
rj('Cannot upload more than 5M image, the image will be copied to the image folder')
}
if (isPath) {
const dirname = path.dirname(pathname)
const imagePath = path.resolve(dirname, image)
const isImage = isImageFile(imagePath)
if (isImage) {
const { size } = await fse.stat(imagePath)
if (size > MAX_SIZE) {
notification()
} else {
const imageFile = await fse.readFile(imagePath)
const blobFile = new Blob([imageFile])
if (currentUploader === 'smms') {
uploadToSMMS(blobFile)
} else {
const base64 = Buffer.from(imageFile).toString('base64')
uploadByGithub(base64, path.basename(imagePath))
}
}
} else {
re(image)
}
} else {
const { size } = image
if (size > MAX_SIZE) {
notification()
} else {
const reader = new FileReader()
reader.onload = async () => {
const blobFile = dataURItoBlob(reader.result, image.name)
if (currentUploader === 'smms') {
uploadToSMMS(blobFile)
} else {
uploadByGithub(reader.result, image.name)
}
}
reader.readAsDataURL(image)
}
}
return promise
}

View File

@ -0,0 +1,26 @@
import { isLinux, isOsx, isWindows } from './index'
import plist from 'plist'
import { remote } from 'electron'
const hasClipboardFiles = () => {
return remote.clipboard.has('NSFilenamesPboardType')
}
const getClipboardFiles = () => {
if (!hasClipboardFiles()) { return [] }
return plist.parse(remote.clipboard.read('NSFilenamesPboardType'))
}
export const guessClipboardFilePath = () => {
if (isLinux) return ''
if (isOsx) {
const result = getClipboardFiles()
return Array.isArray(result) && result.length ? result[0] : ''
} else if (isWindows) {
const rawFilePath = remote.clipboard.read('FileNameW')
const filePath = rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), '')
return filePath && typeof filePath === 'string' ? filePath : ''
} else {
return ''
}
}

View File

@ -1,3 +1,5 @@
import crypto from 'crypto'
// help functions
const easeInOutQuad = function (t, b, c, d) {
t /= d / 2
@ -187,6 +189,14 @@ export const cloneObj = (obj, deepCopy=true) => {
return deepCopy ? JSON.parse(JSON.stringify(obj)) : Object.assign({}, obj)
}
export const getHash = (content, encoding, type) => {
return crypto.createHash(type).update(content, encoding).digest('hex')
}
export const getContentHash = content => {
return getHash(content, 'utf8', 'sha1')
}
export const isOsx = process.platform === 'darwin'
export const isWindows = process.platform === 'win32'
export const isLinux = process.platform === 'linux'

View File

@ -1,5 +1,6 @@
import { isLinux, THEME_STYLE_ID, COMMON_STYLE_ID, DEFAULT_CODE_FONT_FAMILY, oneDarkThemes, railscastsThemes } from '../config'
import { THEME_STYLE_ID, COMMON_STYLE_ID, DEFAULT_CODE_FONT_FAMILY, oneDarkThemes, railscastsThemes } from '../config'
import { dark, graphite, materialDark, oneDark, ulysses } from './themeColor'
import { isLinux } from './index'
import elementStyle from 'element-ui/lib/theme-chalk/index.css'
const ORIGINAL_THEME = '#409EFF'

View File

@ -19,7 +19,7 @@
"endOfLine": "default",
"textDirection": "ltr",
"hideQuickInsertHint": false,
"imageDropAction": "folder",
"imageInsertAction": "path",
"preferLooseListItem": true,
"bulletListMarker": "-",

245
yarn.lock
View File

@ -150,6 +150,56 @@
vow "^0.4.19"
vow-fs "^0.3.6"
"@octokit/endpoint@^5.1.0":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-5.1.2.tgz#45fd879e33a25ee10fa4cffc4d098ee04135afe6"
integrity sha512-bBGGmcRFq1x0jrB29G/9KjYmO3cdHfk3476B2JOHRvLsNw1Pn3l+ZvbiqtcO9qAS4Ti+zFedLB84ziHZRZclQA==
dependencies:
deepmerge "3.2.0"
is-plain-object "^3.0.0"
universal-user-agent "^2.1.0"
url-template "^2.0.8"
"@octokit/request-error@^1.0.1", "@octokit/request-error@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.0.2.tgz#e6dbc5be13be1041ef8eca9225520982add574cf"
integrity sha512-T9swMS/Vc4QlfWrvyeSyp/GjhXtYaBzCcibjGywV4k4D2qVrQKfEMPy8OxMDEj7zkIIdpHwqdpVbKCvnUPqkXw==
dependencies:
deprecation "^2.0.0"
once "^1.4.0"
"@octokit/request@^4.0.1":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@octokit/request/-/request-4.1.0.tgz#e85dc377113baf2fe24433af8feb20e8a32e21b0"
integrity sha512-RvpQAba4i+BNH0z8i0gPRc1ShlHidj4puQjI/Tno6s+Q3/Mzb0XRSHJiOhpeFrZ22V7Mwjq1E7QS27P5CgpWYA==
dependencies:
"@octokit/endpoint" "^5.1.0"
"@octokit/request-error" "^1.0.1"
deprecation "^2.0.0"
is-plain-object "^3.0.0"
node-fetch "^2.3.0"
once "^1.4.0"
universal-user-agent "^2.1.0"
"@octokit/rest@^16.26.0":
version "16.26.0"
resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.26.0.tgz#5c12b28763219045e1c9a15182e8dfaed10004e8"
integrity sha512-NBpzre44ZAQWZhlH+zUYTgqI0pHN+c9rNj4d+pCydGEiKTGc1HKmoTghEUyr9GxazDyoAvmpx9nL0I7QS1Olvg==
dependencies:
"@octokit/request" "^4.0.1"
"@octokit/request-error" "^1.0.2"
atob-lite "^2.0.0"
before-after-hook "^1.4.0"
btoa-lite "^1.0.0"
deprecation "^2.0.0"
lodash.get "^4.4.2"
lodash.set "^4.3.2"
lodash.uniq "^4.5.0"
octokit-pagination-methods "^1.1.0"
once "^1.4.0"
universal-user-agent "^2.0.0"
url-template "^2.0.8"
"@types/clone@~0.1.30":
version "0.1.30"
resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
@ -803,6 +853,11 @@ atoa@1.0.0:
resolved "https://registry.yarnpkg.com/atoa/-/atoa-1.0.0.tgz#0cc0e91a480e738f923ebc103676471779b34a49"
integrity sha1-DMDpGkgOc4+SPrwQNnZHF3mzSkk=
atob-lite@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
atob@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
@ -1627,6 +1682,11 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
before-after-hook@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.4.0.tgz#2b6bf23dca4f32e628fd2747c10a37c74a4b484d"
integrity sha512-l5r9ir56nda3qu14nAXIlyq1MmUSs0meCIaFAh8HwkFwP1F8eToOuS3ah2VAHHcY04jaYD7FpJC5JTXHYRbkzg==
better-assert@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
@ -1890,6 +1950,11 @@ browserslist@^4.4.2, browserslist@^4.5.4:
electron-to-chromium "^1.3.124"
node-releases "^1.1.14"
btoa-lite@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
buffer-alloc-unsafe@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
@ -2259,7 +2324,7 @@ chokidar@^3.0.0:
optionalDependencies:
fsevents "^2.0.6"
chownr@^1.1.1:
chownr@^1.0.1, chownr@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"
integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==
@ -3426,6 +3491,13 @@ decode-uri-component@^0.2.0:
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
decompress-response@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
dependencies:
mimic-response "^1.0.0"
deep-eql@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
@ -3453,6 +3525,11 @@ deepmerge@1.3.2:
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-1.3.2.tgz#1663691629d4dbfe364fa12a2a4f0aa86aa3a050"
integrity sha1-FmNpFinU2/42T6EqKk8KqGqjoFA=
deepmerge@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-3.2.0.tgz#58ef463a57c08d376547f8869fdc5bcee957f44e"
integrity sha512-6+LuZGU7QCNUnAJyX8cIrlzoEgggTM6B7mm+znKOX4t5ltluT9KLjN6g61ECMS0LTsLW7yDpNoxhix5FZcrIow==
deepmerge@^1.2.0:
version "1.5.2"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-1.5.2.tgz#10499d868844cdad4fee0842df8c7f6f0c95a753"
@ -3552,6 +3629,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
deprecation@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.0.0.tgz#dd0427cd920c78bc575ec39dab2f22e7c304fb9d"
integrity sha512-lbQN037mB3VfA2JFuguM5GCJ+zPinMeCrFe+AfSZ6eqrnJA/Fs+EYMnd6Nb2mn9lf2jO9xwEd9o9lic+D4vkcw==
des.js@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"
@ -4619,6 +4701,11 @@ expand-brackets@^2.1.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"
expand-template@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
expand-tilde@^2.0.0, expand-tilde@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
@ -5184,6 +5271,11 @@ git-revision-webpack-plugin@^3.0.3:
resolved "https://registry.yarnpkg.com/git-revision-webpack-plugin/-/git-revision-webpack-plugin-3.0.3.tgz#f909949d7851d1039ed530518f73f5d46594e66f"
integrity sha512-B2ixM0fY7VgR61ZRSXYrh0R57Er7RY+CZb+fja5OFe21Y5o9GgzQanMgdlcBwWZ+LoOVqxBogbDutTTYMXQDWw==
github-from-package@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4=
github-markdown-css@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-3.0.1.tgz#d08db1060d2e182025e0d07d547cfe2afed30205"
@ -6161,6 +6253,13 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
dependencies:
isobject "^3.0.1"
is-plain-object@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.0.tgz#47bfc5da1b5d50d64110806c199359482e75a928"
integrity sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==
dependencies:
isobject "^4.0.0"
is-promise@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
@ -6276,6 +6375,11 @@ isobject@^3.0.0, isobject@^3.0.1:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
isobject@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@ -6597,6 +6701,14 @@ keypress@0.1.x:
resolved "https://registry.yarnpkg.com/keypress/-/keypress-0.1.0.tgz#4a3188d4291b66b4f65edb99f806aa9ae293592a"
integrity sha1-SjGI1CkbZrT2XtuZ+AaqmuKTWSo=
keytar@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/keytar/-/keytar-4.7.0.tgz#992f646ace0862ee72a513a9f6cf7c21ef97078f"
integrity sha512-0hLlRRkhdR0068fVQo21hnIndGvacsh9PtAHGAPMPzxFjJwP8idAkVAcbdb1P5B+gterCBa3+4hxL0NPMDlZtw==
dependencies:
nan "2.13.2"
prebuild-install "5.3.0"
killable@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
@ -6864,6 +6976,11 @@ lodash.forown@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.forown/-/lodash.forown-4.4.0.tgz#85115cf04f73ef966eced52511d3893cc46683af"
integrity sha1-hRFc8E9z75ZuztUlEdOJPMRmg68=
lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
lodash.isarguments@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
@ -6947,6 +7064,11 @@ lodash.restparam@^3.0.0:
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=
lodash.set@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@ -7076,6 +7198,11 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
macos-release@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.2.0.tgz#ab58d55dd4714f0a05ad4b0e90f4370fef5cdea8"
integrity sha512-iV2IDxZaX8dIcM7fG6cI46uNmHUxHE4yN+Z8tKHAW1TBPMZDIKHf/3L+YnOuj/FK9il14UaVdHmiQ1tsi90ltA==
make-dir@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
@ -7300,6 +7427,11 @@ mimic-fn@^2.0.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
mimic-response@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
mini-css-extract-plugin@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.6.0.tgz#a3f13372d6fcde912f3ee4cd039665704801e3b9"
@ -7486,7 +7618,7 @@ mute-stream@0.0.7:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
nan@^2.10.0, nan@^2.12.1:
nan@2.13.2, nan@^2.10.0, nan@^2.12.1:
version "2.13.2"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
@ -7508,6 +7640,11 @@ nanomatch@^1.2.1, nanomatch@^1.2.9:
snapdragon "^0.8.1"
to-regex "^3.0.1"
napi-build-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.1.tgz#1381a0f92c39d66bf19852e7873432fc2123e508"
integrity sha512-boQj1WFgQH3v4clhu3mTNfP+vOBxorDlE8EKiMjUlLG3C4qAESnn9AxIOkFgTR2c9LtzNjPrjS60cT27ZKBhaA==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@ -7661,6 +7798,11 @@ node-releases@^1.1.14:
dependencies:
semver "^5.3.0"
noop-logger@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2"
integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI=
"nopt@2 || 3", nopt@3.x:
version "3.0.6"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
@ -7742,7 +7884,7 @@ npm-run-path@^2.0.0:
dependencies:
path-key "^2.0.0"
"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2:
"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, npmlog@^4.0.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
@ -7873,6 +8015,11 @@ obuf@^1.0.0, obuf@^1.1.2:
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
octokit-pagination-methods@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@ -7960,7 +8107,7 @@ os-browserify@^0.3.0:
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
os-homedir@^1.0.0:
os-homedir@^1.0.0, os-homedir@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
@ -7974,6 +8121,14 @@ os-locale@^3.0.0, os-locale@^3.1.0:
lcid "^2.0.0"
mem "^4.0.0"
os-name@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
dependencies:
macos-release "^2.2.0"
windows-release "^3.1.0"
os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
@ -8787,6 +8942,28 @@ posthtml@^0.9.2:
posthtml-parser "^0.2.0"
posthtml-render "^1.0.5"
prebuild-install@5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.0.tgz#58b4d8344e03590990931ee088dd5401b03004c8"
integrity sha512-aaLVANlj4HgZweKttFNUVNRxDukytuIuxeK2boIMHjagNJCiVKWFsKF4tCE3ql3GbrD2tExPQ7/pwtEJcHNZeg==
dependencies:
detect-libc "^1.0.3"
expand-template "^2.0.3"
github-from-package "0.0.0"
minimist "^1.2.0"
mkdirp "^0.5.1"
napi-build-utils "^1.0.1"
node-abi "^2.7.0"
noop-logger "^0.1.1"
npmlog "^4.0.1"
os-homedir "^1.0.1"
pump "^2.0.1"
rc "^1.2.7"
simple-get "^2.7.0"
tar-fs "^1.13.0"
tunnel-agent "^0.6.0"
which-pm-runs "^1.0.0"
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@ -8898,7 +9075,15 @@ public-encrypt@^4.0.0:
randombytes "^2.0.1"
safe-buffer "^5.1.2"
pump@^2.0.0:
pump@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
pump@^2.0.0, pump@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
@ -9745,6 +9930,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
simple-concat@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.0.tgz#7344cbb8b6e26fb27d66b2fc86f9f6d5997521c6"
integrity sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=
simple-get@^2.7.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-2.8.1.tgz#0e22e91d4575d87620620bc91308d57a77f44b5d"
integrity sha512-lSSHRSw3mQNUGPAYRqo7xy9dhKmxFXIjLjp4KHpf99GEH2VH7C3AM+Qfx6du6jhfUi6Vm7XnbEVEf7Wb6N8jRw==
dependencies:
decompress-response "^3.3.0"
once "^1.3.1"
simple-concat "^1.0.0"
single-line-log@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/single-line-log/-/single-line-log-1.1.2.tgz#c2f83f273a3e1a16edb0995661da0ed5ef033364"
@ -10470,7 +10669,17 @@ tapable@^1.0.0, tapable@^1.1.0:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
tar-stream@^1.5.0:
tar-fs@^1.13.0:
version "1.16.3"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509"
integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==
dependencies:
chownr "^1.0.1"
mkdirp "^0.5.1"
pump "^1.0.0"
tar-stream "^1.1.2"
tar-stream@^1.1.2, tar-stream@^1.5.0:
version "1.6.2"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555"
integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==
@ -10919,6 +11128,13 @@ unique-string@^1.0.0:
dependencies:
crypto-random-string "^1.0.0"
universal-user-agent@^2.0.0, universal-user-agent@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-2.1.0.tgz#5abfbcc036a1ba490cb941f8fd68c46d3669e8e4"
integrity sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==
dependencies:
os-name "^3.0.0"
universalify@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
@ -11028,6 +11244,11 @@ url-slug@2.0.0:
dependencies:
unidecode "0.1.8"
url-template@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE=
url@^0.11.0, url@~0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@ -11905,6 +12126,11 @@ which-module@^2.0.0:
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
which-pm-runs@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb"
integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=
which@1, which@1.3.1, which@^1.1.1, which@^1.2.14, which@^1.2.9:
version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
@ -11939,6 +12165,13 @@ window-size@^1.1.1:
define-property "^1.0.0"
is-number "^3.0.0"
windows-release@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f"
integrity sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==
dependencies:
execa "^1.0.0"
wordwrap@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"