From c239e99f1b891e2f74a8cd62642704f2a76d584d Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 26 May 2019 23:55:13 +0800 Subject: [PATCH] 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 --- .electron-vue/thirdPartyChecker.js | 7 +- .electron-vue/webpack.renderer.config.js | 1 + .github/CONTRIBUTING.md | 4 +- .travis.yml | 2 + doc/Image Uploader Configration.md | 23 ++ doc/KEYBINDINGS.md | 1 + package.json | 5 +- src/common/envPaths.js | 8 +- src/main/app/accessor.js | 2 + src/main/app/index.js | 65 +++- src/main/app/windowManager.js | 6 + src/main/dataCenter/index.js | 202 +++++++++++ src/main/dataCenter/schema.json | 26 ++ src/main/filesystem/index.js | 13 + src/main/keyboard/shortcutHandler.js | 1 + src/main/menu/actions/edit.js | 45 +-- src/main/menu/templates/edit.js | 25 +- src/main/menu/templates/index.js | 2 + src/main/menu/templates/prefEdit.js | 24 ++ src/main/preferences/index.js | 14 +- src/main/preferences/schema.json | 17 +- src/muya/lib/assets/pngicon/image/1.png | Bin 0 -> 371 bytes src/muya/lib/assets/pngicon/image/2.png | Bin 0 -> 698 bytes src/muya/lib/assets/pngicon/image/3.png | Bin 0 -> 992 bytes src/muya/lib/assets/pngicon/imageEdit/1.png | Bin 0 -> 457 bytes src/muya/lib/assets/pngicon/imageEdit/2.png | Bin 0 -> 924 bytes src/muya/lib/assets/pngicon/imageEdit/3.png | Bin 0 -> 1387 bytes src/muya/lib/assets/pngicon/image_fail/1.png | Bin 0 -> 591 bytes src/muya/lib/assets/pngicon/image_fail/2.png | Bin 0 -> 1324 bytes src/muya/lib/assets/pngicon/image_fail/3.png | Bin 0 -> 2015 bytes src/muya/lib/assets/styles/index.css | 180 +++++++++- src/muya/lib/config/index.js | 21 +- src/muya/lib/contentState/arrowCtrl.js | 30 ++ src/muya/lib/contentState/backspaceCtrl.js | 47 ++- src/muya/lib/contentState/clickCtrl.js | 20 ++ src/muya/lib/contentState/dragDropCtrl.js | 180 ++++++++++ src/muya/lib/contentState/enterCtrl.js | 33 +- src/muya/lib/contentState/formatCtrl.js | 87 +---- src/muya/lib/contentState/imageCtrl.js | 138 +++++++ src/muya/lib/contentState/imagePathCtrl.js | 98 ----- src/muya/lib/contentState/index.js | 67 +++- src/muya/lib/contentState/pasteCtrl.js | 119 +++++- src/muya/lib/eventHandler/clickEvent.js | 50 +++ src/muya/lib/eventHandler/clipboard.js | 4 + src/muya/lib/eventHandler/dragDrop.js | 39 ++ src/muya/lib/eventHandler/keyboard.js | 36 +- src/muya/lib/index.js | 23 +- src/muya/lib/parser/index.js | 4 +- src/muya/lib/parser/render/index.js | 33 +- .../parser/render/renderBlock/renderBlock.js | 4 +- .../renderBlock/renderContainerBlock.js | 6 +- .../render/renderBlock/renderLeafBlock.js | 5 +- .../lib/parser/render/renderInlines/image.js | 185 ++++++---- .../render/renderInlines/loadImageAsync.js | 39 +- .../lib/parser/render/renderInlines/text.js | 4 +- src/muya/lib/parser/rules.js | 2 +- src/muya/lib/parser/utils.js | 15 +- src/muya/lib/selection/dom.js | 42 ++- src/muya/lib/selection/index.js | 109 ++++-- src/muya/lib/ui/baseFloat/index.css | 1 - src/muya/lib/ui/imagePicker/index.css | 7 + src/muya/lib/ui/imagePicker/index.js | 3 + src/muya/lib/ui/imageSelector/index.css | 128 +++++++ src/muya/lib/ui/imageSelector/index.js | 338 ++++++++++++++++++ src/muya/lib/utils/checkEditImage.js | 21 -- src/muya/lib/utils/getImageInfo.js | 19 + src/muya/lib/utils/index.js | 46 ++- src/muya/themes/default.css | 10 + src/renderer/assets/icons/pref_image.svg | 1 + .../assets/icons/pref_image_uploader.svg | 1 + src/renderer/assets/styles/index.css | 23 ++ src/renderer/assets/themes/dark.theme.css | 6 + src/renderer/assets/themes/graphite.theme.css | 6 + .../assets/themes/material-dark.theme.css | 6 + src/renderer/assets/themes/one-dark.theme.css | 8 +- src/renderer/assets/themes/ulysses.theme.css | 6 + .../components/editorWithTabs/editor.vue | 101 ++++-- src/renderer/components/uploadImage/index.vue | 106 ------ src/renderer/config.js | 4 +- src/renderer/main.js | 10 +- src/renderer/pages/app.vue | 13 +- src/renderer/prefComponents/editor/index.vue | 10 +- src/renderer/prefComponents/image/config.js | 0 src/renderer/prefComponents/image/index.vue | 98 +++++ .../prefComponents/imageUploader/index.vue | 159 ++++++++ src/renderer/prefComponents/sideBar/config.js | 16 + src/renderer/prefComponents/sideBar/index.vue | 2 +- src/renderer/router/index.js | 6 + src/renderer/store/editor.js | 46 +-- src/renderer/store/listenForMain.js | 6 - src/renderer/store/preferences.js | 26 +- src/renderer/util/fileSystem.js | 138 +++++++ src/renderer/util/guessClipBoardFilePath.js | 26 ++ src/renderer/util/index.js | 10 + src/renderer/util/theme.js | 3 +- static/preference.json | 2 +- yarn.lock | 245 ++++++++++++- 97 files changed, 3148 insertions(+), 622 deletions(-) create mode 100644 doc/Image Uploader Configration.md create mode 100644 src/main/dataCenter/index.js create mode 100644 src/main/dataCenter/schema.json create mode 100644 src/main/menu/templates/prefEdit.js create mode 100755 src/muya/lib/assets/pngicon/image/1.png create mode 100755 src/muya/lib/assets/pngicon/image/2.png create mode 100755 src/muya/lib/assets/pngicon/image/3.png create mode 100755 src/muya/lib/assets/pngicon/imageEdit/1.png create mode 100755 src/muya/lib/assets/pngicon/imageEdit/2.png create mode 100755 src/muya/lib/assets/pngicon/imageEdit/3.png create mode 100755 src/muya/lib/assets/pngicon/image_fail/1.png create mode 100755 src/muya/lib/assets/pngicon/image_fail/2.png create mode 100755 src/muya/lib/assets/pngicon/image_fail/3.png create mode 100644 src/muya/lib/contentState/dragDropCtrl.js create mode 100644 src/muya/lib/contentState/imageCtrl.js delete mode 100644 src/muya/lib/contentState/imagePathCtrl.js create mode 100644 src/muya/lib/eventHandler/dragDrop.js create mode 100644 src/muya/lib/ui/imagePicker/index.css create mode 100644 src/muya/lib/ui/imageSelector/index.css create mode 100644 src/muya/lib/ui/imageSelector/index.js delete mode 100644 src/muya/lib/utils/checkEditImage.js create mode 100644 src/muya/lib/utils/getImageInfo.js create mode 100644 src/renderer/assets/icons/pref_image.svg create mode 100644 src/renderer/assets/icons/pref_image_uploader.svg delete mode 100644 src/renderer/components/uploadImage/index.vue create mode 100644 src/renderer/prefComponents/image/config.js create mode 100644 src/renderer/prefComponents/image/index.vue create mode 100644 src/renderer/prefComponents/imageUploader/index.vue create mode 100644 src/renderer/util/guessClipBoardFilePath.js diff --git a/.electron-vue/thirdPartyChecker.js b/.electron-vue/thirdPartyChecker.js index 63746403..8ccfaf45 100644 --- a/.electron-vue/thirdPartyChecker.js +++ b/.electron-vue/thirdPartyChecker.js @@ -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) }) } diff --git a/.electron-vue/webpack.renderer.config.js b/.electron-vue/webpack.renderer.config.js index d4613346..e0c34a95 100644 --- a/.electron-vue/webpack.renderer.config.js +++ b/.electron-vue/webpack.renderer.config.js @@ -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'), diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2635946c..1544b4e7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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:** diff --git a/.travis.yml b/.travis.yml index 5809b5b6..25ed2d0a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/doc/Image Uploader Configration.md b/doc/Image Uploader Configration.md new file mode 100644 index 00000000..b7100222 --- /dev/null +++ b/doc/Image Uploader Configration.md @@ -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. diff --git a/doc/KEYBINDINGS.md b/doc/KEYBINDINGS.md index 628f1be7..9f7250d5 100644 --- a/doc/KEYBINDINGS.md +++ b/doc/KEYBINDINGS.md @@ -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:** diff --git a/package.json b/package.json index 3af542ee..e5ff57fb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/envPaths.js b/src/common/envPaths.js index 44557283..f0f989b0 100644 --- a/src/common/envPaths.js +++ b/src/common/envPaths.js @@ -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 } diff --git a/src/main/app/accessor.js b/src/main/app/accessor.js index bd1039c5..834d91bb 100644 --- a/src/main/app/accessor.js +++ b/src/main/app/accessor.js @@ -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) diff --git a/src/main/app/index.js b/src/main/app/index.js index 8c168ed4..75659bc0 100644 --- a/src/main/app/index.js +++ b/src/main/app/index.js @@ -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) { diff --git a/src/main/app/windowManager.js b/src/main/app/windowManager.js index e2a78dda..f28486c5 100644 --- a/src/main/app/windowManager.js +++ b/src/main/app/windowManager.js @@ -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) + } + }) } } diff --git a/src/main/dataCenter/index.js b/src/main/dataCenter/index.js new file mode 100644 index 00000000..8bbdff5a --- /dev/null +++ b/src/main/dataCenter/index.js @@ -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.} 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 diff --git a/src/main/dataCenter/schema.json b/src/main/dataCenter/schema.json new file mode 100644 index 00000000..74158ba5 --- /dev/null +++ b/src/main/dataCenter/schema.json @@ -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" + } +} diff --git a/src/main/filesystem/index.js b/src/main/filesystem/index.js index 5f24d1aa..1f6ad46c 100644 --- a/src/main/filesystem/index.js +++ b/src/main/filesystem/index.js @@ -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. diff --git a/src/main/keyboard/shortcutHandler.js b/src/main/keyboard/shortcutHandler.js index 77237a61..9254fae8 100644 --- a/src/main/keyboard/shortcutHandler.js +++ b/src/main/keyboard/shortcutHandler.js @@ -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'], diff --git a/src/main/menu/actions/edit.js b/src/main/menu/actions/edit.js index a1f4d196..9aa660f2 100644 --- a/src/main/menu/actions/edit.js +++ b/src/main/menu/actions/edit.js @@ -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 }) - } -} diff --git a/src/main/menu/templates/edit.js b/src/main/menu/templates/edit.js index fb60fa30..7b2daf5b 100755 --- a/src/main/menu/templates/edit.js +++ b/src/main/menu/templates/edit.js @@ -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' }, { diff --git a/src/main/menu/templates/index.js b/src/main/menu/templates/index.js index d9fa4962..20b04e2a 100644 --- a/src/main/menu/templates/index.js +++ b/src/main/menu/templates/index.js @@ -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() ] } diff --git a/src/main/menu/templates/prefEdit.js b/src/main/menu/templates/prefEdit.js new file mode 100644 index 00000000..b18dab47 --- /dev/null +++ b/src/main/menu/templates/prefEdit.js @@ -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' + }] + } +} diff --git a/src/main/preferences/index.js b/src/main/preferences/index.js index 1416120b..4d168b83 100644 --- a/src/main/preferences/index.js +++ b/src/main/preferences/index.js @@ -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] } } diff --git a/src/main/preferences/schema.json b/src/main/preferences/schema.json index 9f53ac96..0cac86c1 100644 --- a/src/main/preferences/schema.json +++ b/src/main/preferences/schema.json @@ -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" + ] } } diff --git a/src/muya/lib/assets/pngicon/image/1.png b/src/muya/lib/assets/pngicon/image/1.png new file mode 100755 index 0000000000000000000000000000000000000000..672df478f739f2abae3602b64eb361b8454d9514 GIT binary patch literal 371 zcmV-(0gV2MP)Px$ElET{R5%f1U>I?L5$0n919R0Me}DNh{Ac8Yi4jFJ{{O_m%(nT$g_E~I>X~4g z$TAG18R!78UZ_EE17R*>WcIn+5psV=*pV8_5Px%bV)=(R9Fe^nB7VnK@`X5%zl`pmTC*7P{dqUi_koQU~j!qd*|X>W+yBhTeh`xX zatCq;{%;3BhhM2o-MPFvdCEgx4hbLB<=Bc*0%t&>S@4G5GWPCNuB!kd-1)ELdCs9I zC#N|F;1?zT-U}I$%A$Xq%NPzB&=Ay?c@#igRyirFxlY@a#pOL0S>K_Qz8@MaY-8@p z+2uqShLh11JMy_n=dw1|QvhWVgxlF>YeIZ&Y@$Ls9X=(*QX48uEGu=L6XUv=c--dL z2xwF6Cbf)!xaZ`;x|p0}Yi`_w*bunqy35*a5<--}P+Lsb>KFmBTd^UIPcMX~$OaBc zjZ1v(a{j_32TW>umJV{)bygi9&3>RW0R&2}T;E@51e>2d$N%t80pjys!6_~`zHEF& zuproI_Tyrz4n_~6KZ*mxFSoWn9f_{><6>%nQQ>dphxeD{2he_9Oa%~J*qoc4epRhj z+oE&gxK|gAcM`E^4`c3uyIK!w>(2rfn~r;N?D3Okhm&$#qBRGxQ@sIrQ?J+7;{?TH zy|`!vFzVEwCGXZ!ov}R;z%=fqqZ0#C!oNqIy2#kamI+9A+~i^+KnmnwbmYOZu2Xd| zx|qqu8v_E++T6_a>$pzU0qJ6KE-DXpkH#wty0+CkzbFr@CAby4r6S5rZ zv=u=7MuMLqvIIjd)I@DrS9$qvwGP{(Bjb&bw{HU{B~FMhpJa26%No6V?-%{MD@mHo gCR1K3cR+ODH#l$C{TT`u8vpPx&nn^@KRA>e5nonyJK^Vqo_D_;3E!d(KM64i!wibyZV!lHUDn0r^JbCg~(43Z;jD#@3Wgw@Fh4D=mn%Dh0Jl+a%36Z)wV=?#yO(lg+G5E}5NoX6O06&+fZn zGYF}V%7Ds%%7Ds%%7Ds%%0NgNz+USRLh$g7+sR6$lGF$~T|+45H7CrthETbN(Rb4{ z^95@@k1-~^^|`>QRH}Pxd-pD?k&C>ONE6fWT3>hKMK+t=W@(}ca{qv{k%9#bY7A%w z?e0_vF@fPSj*PQTItqQD-Pz}8GlAirb&QKtlc${t3X5J@3=EeskHag@n;S+<7#QwZo-nUC zZ*CYdVPGgwJYimO-rO)^!oX0CdBVKnyt!e-gn{AFL#wgtZ)@m3d^Clj-Pam^9!nS= zJy)*Z{O37bu2!XZ4)ZeM2N=$WsgtQ4w3AxzjK?+zfVUZiI9FCl2 zf`TLAu&(1-9iw$YHrL@&1Jf6fRoyfvOH0;kQ;%g_2?RT(1;!fs6VnporNy@^z+~I= z>#cBEN?@$vZ}Hxqv8AQC&rSnd;j(nVSSKIP_I9W3Oy-9ZwpA`m1*~hx8oGRGB9qDN z9*39ETGF`N+_krnLP9S5sv z?F^AjVA#S3yV@cJeYg{kxBZY=wCV}^-}YM8xzxAHfXaZ%fXaZ38TboZFG7xc@7wtR O0000Px$gGod|R5%gUlRYm4Q51&P$J!MM5t1!7v4uiNh>u32p;C%J;s=OQhlWH!MWs>^ z6%v(16syoEhzJsi2=Q$^?>KienYmM}Mx5lCbMJfJb9QEDZYJ~B5d^^m?7PtU3PkE##{qSpm_gD!7F*x zHL>o6PovT4iHxBZRzUF#dc*~7hzA%{6S)N*LD7~NWTrZbYoR3wp2%aS>yFn=zajU- zf$jIxhSg)(Fq7_NBx+=zg>9?9SUxA0qP}pA*Z`Ymx+f1OObF;5;2!ggwRc#a!WJm* zU?^o+fm&H6;uMNLpMG^!4&oO2d?xfFXo%tj3O-+`QFM>c<1n3lrPsDr$ zX`#cPeymakPSSJ%Z=erW$4zg15puqs|EYZg>jgFMI6xU500000NkvXXu0mjfPzk}c literal 0 HcmV?d00001 diff --git a/src/muya/lib/assets/pngicon/imageEdit/2.png b/src/muya/lib/assets/pngicon/imageEdit/2.png new file mode 100755 index 0000000000000000000000000000000000000000..d86bbb76fdcf04f069085ad2b63e796cee591ca0 GIT binary patch literal 924 zcmV;N17rM&P)Px&R!KxbR9Fe^m`P|9Q4oeRSF3&1M_O+z6I|S>QhC%VaWF8OPUXt`ockmYpWpG;SSIe23MQ zioL|2SMnw)+N~P#xutEAPWBqzd?mIWtOXa0_0lv+;a;QoXJRYk%gDSO95eF%U6T~c zfWt;nX|6qPE*^>HYw|H`HE~{1#;Dj)M$4qZR;v*4d`<4Q@(YSlq!OF}K6}k#vsHk& zGUQE}x)UjPn@qwE%b ztyP5hb(Py;<)w-tGznZ%g5tgaEh54wm+mCcCcG-Q5PmxtauP?F%j+@RAl8CwK$~2` zIj$mpHMpV^@#UWVdk$c110R8F{jN^xISTdQhN~3KA2QCggoxLHJ5ioH^x~`lpWGnx zVToH>-Wa3?Aoq~Aegfvr9}BWR13cA4{2xYaSIkR2A~Fr!HN<3R8q)}o*8<)EpY2Jb zH7WX?0r%G$OK;0bP71(m z2G3&!XS~dOauQ#!!&wi0IXW_x$4QsYP4pqn2ZKP{c>=*NayXlS{5oBe=C&j@qZ1&> zbI#hiv&5@)ev&Uu0BOcGX?AlG*Ib9V&sB=%@2SgC~t%M7?tUHy4xbje56VQsCs)g;W{89KlS_A(ZSe>60 zu%g8SsB(hYule}$U|toX_98{9i;?L8 y%YpoVHv+P19L6627r_B`ZisQhCU)Sz+krm>ks(;+fNWy`0000Px)B}qge5np?gE~fr_M}Qj(x3y;$G{yp_TS_Fz6lB&1LJP(3JJ zUb;}!gPgpOpeha_^a5v}#N5Jk53-l7juP#ESsxnDtj%Y zI>om+iYECrN&%_*A13R-O7H==3N&flqRF@f)@zw3`X3o5WtfD)y_x9n5 z;zO`OU@s^la-QzvlL&t8Mu2HP998>}z+fP+lqEk{Mr`l;CW3E=!u3STQL~qt!15W~ z1FXaSKJ1gg2B66@xL((cIsJ*;I$hTG#zlf{-~g~>H^zaI5u-epKeC*$J)LIn8nKta zL4{+9Yfls%0f!rb(|mR^xg#4;)?i=i;25u=dpx@*JIA)#u_sl@yC}ZEJ`x;BqbTE6 z^!;4X(ccBWQ}&7aGI%7v7u_>rRODpdKgyb5qrh<`XsX`K#JuZb9|7c~rODqI9!!&F z{+#BR=vSn^(ME{Jf>TKVZFmoZ*G&??@!*_BguW9@1TPsrV)`ERjaH}-L3l4X11xz9 zW6&3d_)i6Y7^Ht1HZlDI%=38gg@EvW@H4PvZ7^}N0n%B(_xr7E4w6%kwz6mr-TNS% z49;hxIOcD3Zzwo{^HK1>gIiOb#c*H2aPDmoJ^Y$utI3y)yz zfN&c4Jrl$=ecqr5HW5I2Xzfsu#+N6==wNbd1BBB-rJKdG7DuR!c>O@7Qr*XYqL){Gn3;U25yFRoe0!*ge3Q5=l-KfyZy@+g3z+D? zVQjN_f^87CgG)&O*C2P$?)EDTVuMK?d3YO9BVSb~nEF-8!0qF~w zAo;1Beil~4pWh4LFd%1NOXLRDqpi&I6kbF8trGbn`niRP9UI}3w(_tNFI9ic#MxFl zD8p5dUq~b6#8v+r>#Z7 zaQd{BujpCT&=R{~`0CzP23?+*1;aH1{H1Xw`lxMXP_g2yB^OEr&m2(KwlXxw8~!m0 zhGPM!)K)%KD8Yi8f=@3LAU{mWvr!HM);JHl{7BXxxa2CzrTk}dv&&d_9fh3(wGwGL zqqdd47sxuY9rh&&^t+7zVqV#;f%~GkTLpJSiO`ee63ayt`$`E{bOa{Y&G!fS-j2E!WUl6u| tO}vf8za_0J(7FPxE6}Px%2}wjjR5%gkl1pn7Q547Txszs`CX!WDKFr&_-O`9@vdavyrlbfK;PH;Boo_qf1cmHQN zg#33Oh>7$0u>-4qYl0vo#NFQ_Q1WANz$mV(E2=p+_l@hRXzCY62{@{em|9(TjnJj?jVGzJQ>R1?KEl&2}$h z5ab9!Pg7$>(|q!VgG_UNk^c%A+S8#z=}RN7-LV|+GC{J7jaM6rWv^GO55KkpA`p4d zp7V1T5CERXwbVVwZC=3uGYY`lJ-KYLQYpJ~b1#=2K!uX(U^F@FE!VEH)kp!*`SWKd zN_^2}n8;<9;j$e%GI}(rYp?80XGzhptqAD8e(~%Y*M<%cj_iA6d&{TTn0>=`DAC*7 ddib9ZZ2&^J!3j&r?2iBd002ovPDHLkV1f!95f}gf literal 0 HcmV?d00001 diff --git a/src/muya/lib/assets/pngicon/image_fail/2.png b/src/muya/lib/assets/pngicon/image_fail/2.png new file mode 100755 index 0000000000000000000000000000000000000000..6d6ad730ae2f2ec76297762c731d74f5f64dbd29 GIT binary patch literal 1324 zcmV+{1=IS8P)Px(=1D|BR9Fe^SM5(*RTw|#zO+Djn-GFown-#Gd1(h_kZv<}$!0_CF>FM$#^FkV%S$OR6lKhzL#^YbqotSL<8z3Yo!#E{QdT~g z&@bnCp7WgN`So|6=bUp9I{Fn*po`-)_HH5S7<15rw>>Ad;)+lPQ}Z@I%3= zv3tql@!AOrZYvi0L?P%nLCB#QO|O>OR##RQx7i#Vt5A8D zIfApb_Dt)dDBPY4Ox$(3Tzuca&G!gV8p4sk-qPkt07$o0>y&Ao7x-^yCP#fvw_BK5 zSZY-es-!s}_bF{IL2J;~9L6NhaonB4+^lKpyB3bx6h*l}2u>&uZ7dy~U=DvXJ2f`Z z+1V)$58P}6pK{d?a_6bCIwk;AC)AOGgE|>jbr^@MY<3inlZ%XPKOzxK;*H>hKLFcm z`e8(R9~PAvElk*A?E;lK)~;t`=M!X%6^`czD7dDkCiCq4;)h5fhTv!%Lx2g%(lpQ# z5lyIXZhCCi;c#UyJz8y75I(uzFopnW&1r+_*|a`X2XH%_nAJNYN%!r|%{l9vi&qgG zUwXdDY=$*R8-muHx~*!HK>B1cckP%%vWUcMfeGJ|)9K7zUH-kDAY77ikV`|p^bK6u2?6(p~-NI4I^kBFo~KhLJtLZ+UYFZ zi0pJ66dVnmQGghuEsH6)JK*;}ayT3>ZHG2G;P5@B{SWEr83PD4_ms~OyCv!FA9lO5 z_{nCpgAi1Z4j4V(3;`sYIbyyi==ZN$&z>#Wk~cb_4z;%e+#=`}=E!e)K$6*B7$9ux z`mww$k0g2jw%gx$S>B9(OcZ2c6Vi@0aoqL!z{H4P7VfZfM)C^*gnRXgxhUV`^ZEX< z*&N58#==)&TXWc?9Kyh^<;y@|e1Y0$#>Rf-5P8V@q>>K|5R!!Qvr9ZvUhkUSe*WZk zJO)Pz&SH~+&*MBx$rrRPSv#LCcv(G_d?0|p+@B@I1s=bDaKmbCDBF^^K7z-RnN7rD z^z*3`rMo_?oyzI3m^MJl%8AmVZm-uHuBvmC%S8SFYEEQ9V9brSUU{c?aB%Rs*D>p} zUlt8-5;iE@ZYu@3zrR0LX{)c@iJ{9d zyZ2h4HCbROP3Y8T5mHM67QCrEQUc^TZlUdROK(?Kmt1xF%o!!Fv_P;ua6HC8CF|5w zN!d9g7Lx*miMh&(vOf4`-KnW*aKv}wZ?l1Er@F2*Ur*JktbZyw6^napr~zK9sr?jc zZrSy2%MAdhz+?6N0X~q_aA}Qj>xAcm(r4_Vgky#mOt)MuP51u;cLBh?$u2ld87_ba z+V}|!st&JL*Iyz?Ij8D}J;L`(B*#?rxg>qJ&gRmsLHdCM3kvB2lyyD~nQkjIV^9y= iVdOiCdBi}v4Ezh$0_HnQVPx+nMp)JRA>e5TI*93M-ZRc-Q#$85CIXjjEYti!Q*irh!~5?!xWW56BV@*CGf@n zA>a6vKf<3NoD4T^{;p1h8G>|`1pvR-!YZuj<(54T@t zrhBHRf88@ZyVHwM{E1s2Zh^Q3;ueToAZ~%U1?I^DINUsh5Nx(=Dfan%#T-FPM1&H; zjhRnA=I}`Js^yLLix;ONnKvZh^73-ii*e6xB$73e2%GOd6zrHW-E^$mW_ty5S65f_ zZnrz}+O=zwO1f}L4Djqi2FoGxA~h+ifq|{BuNPc*9vm2a_Ix+!u7pdGv8lM!;`92d zlm@;}IxOR*>89g0n{5L0pq@?Wy*qG#h++=31%vQwZ&zCf)Po;I55t}f?z|A%)U(4Y zvonq{u$7gSM(Wvb8CcNK@Anre>8c1A0+iCA_eRI)Nt$WhF}6o5Dk>5mJ{dVA60}OH zQz+d9X-8>2Of)7~;BF>jqboZ*^~Lb;>kJQ>$s>MoE0v+RO{DVG)~RRz$;wDOWwSMV zV63I8DtYALlS71{Wx=vS>8=PkScs%e5;)A+x#UzE||u(7L{$IXsZJ ze(mYz=4LO{7_?qc|6AV_J6(W^pPV+e896L@tRo`&fc4Q@ss;?vIMig}_@TxEkHcJ7_3mBgsb^`h zMX0Fg<0TVQfjUHRDzu}0R7YtzdLSCh1ujZo@2?(%St=SWv z$r{>7)+A77kZQnG28N?2w{Y|S&f&LUH62Mjhgx0T_A31oeFI*pOh^eR>FVZA11as+|1 z^*jFqTUfYdwKyF(gb>jkEX=9Z!%W|(N}k8pd%HVYn2>ynW$ld5dw}j)1Ec9(zBj;_ zLswiq_ErX{prGUFwWI^>%JadjwxUY*3Tz!r+RwY z?lG`=g`3MokyNs_TuFgFOEND0$!`Disg%zf(C^Z=mU_$pQ^8&%Z#>y+w?ANtKs~9wTQw(PO{$gej0$Q2a$8fXY}~ri4g* zwcpXvTJMy-PM#YAUd}Za4H6NroFl5J=;~qmp)+Ml6dHSa-hhP+KyJR}%TQpYkvLMl z1*4#OFY5-FM%4*}DY3D=y?q!IryuZKeu1@$AYxSl5*&$@`UTUS?d^ZVwQVpxlD0m8 zF?pS2G&MSI-W-8R0c_Lx3tt69Q89Y~83a>G){nuFI1;6y51%C1BJC^f7d2qIBwuBw zr8L;>wowoa8N8o=xwr=fkXbsBv<{JY)ynLK+qW;jmeSSKwR$-!z;sEzOg5x9w6wI0 zfwC1egXg?EuumjJ@!l(vNE|6}Ene0R7^Bg}X^GUcZ_dlZhYuS@&R#fxL~*@>!n5zh z{mza{bjiM`~|MOj&O(!k?qH6p?v$_ppMX(`#7Be9%JIhue4n;cz^7BOHr_vkzB3F_IobftUZ zK@B2kg`8l>3q>1=<>boI)Oa~Ez>u!YU3ZebW z06RT1UQ6%V7BSj_Eh*RxQ{p)#u%LHXz&SZf#(M9L|Hg@=kYJoie>c!Ab*YEpAV9&M xg=Orpw|0gCtH*qC8F355EfBXr+yb$*z`p}P^xw9Y&JzFt002ovPDHLkV1no_ 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; } diff --git a/src/muya/lib/config/index.js b/src/muya/lib/config/index.js index 25872d7f..9be7a42f 100644 --- a/src/muya/lib/config/index.js +++ b/src/muya/lib/config/index.js @@ -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' diff --git a/src/muya/lib/contentState/arrowCtrl.js b/src/muya/lib/contentState/arrowCtrl.js index 725cf05a..60f47869 100644 --- a/src/muya/lib/contentState/arrowCtrl.js +++ b/src/muya/lib/contentState/arrowCtrl.js @@ -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) diff --git a/src/muya/lib/contentState/backspaceCtrl.js b/src/muya/lib/contentState/backspaceCtrl.js index ca45bad7..1d5936cf 100644 --- a/src/muya/lib/contentState/backspaceCtrl.js +++ b/src/muya/lib/contentState/backspaceCtrl.js @@ -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] diff --git a/src/muya/lib/contentState/clickCtrl.js b/src/muya/lib/contentState/clickCtrl.js index 8365d8c0..75043952 100644 --- a/src/muya/lib/contentState/clickCtrl.js +++ b/src/muya/lib/contentState/clickCtrl.js @@ -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) { diff --git a/src/muya/lib/contentState/dragDropCtrl.js b/src/muya/lib/contentState/dragDropCtrl.js new file mode 100644 index 00000000..badf1594 --- /dev/null +++ b/src/muya/lib/contentState/dragDropCtrl.js @@ -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 diff --git a/src/muya/lib/contentState/enterCtrl.js b/src/muya/lib/contentState/enterCtrl.js index 59b18608..1663fb74 100644 --- a/src/muya/lib/contentState/enterCtrl.js +++ b/src/muya/lib/contentState/enterCtrl.js @@ -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 diff --git a/src/muya/lib/contentState/formatCtrl.js b/src/muya/lib/contentState/formatCtrl.js index 20962a72..f31648f4 100644 --- a/src/muya/lib/contentState/formatCtrl.js +++ b/src/muya/lib/contentState/formatCtrl.js @@ -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() diff --git a/src/muya/lib/contentState/imageCtrl.js b/src/muya/lib/contentState/imageCtrl.js new file mode 100644 index 00000000..9f2f0e97 --- /dev/null +++ b/src/muya/lib/contentState/imageCtrl.js @@ -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 diff --git a/src/muya/lib/contentState/imagePathCtrl.js b/src/muya/lib/contentState/imagePathCtrl.js deleted file mode 100644 index 0f30ad8b..00000000 --- a/src/muya/lib/contentState/imagePathCtrl.js +++ /dev/null @@ -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 diff --git a/src/muya/lib/contentState/index.js b/src/muya/lib/contentState/index.js index eb237fce..89ff87ab 100644 --- a/src/muya/lib/contentState/index.js +++ b/src/muya/lib/contentState/index.js @@ -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() } /** diff --git a/src/muya/lib/contentState/pasteCtrl.js b/src/muya/lib/contentState/pasteCtrl.js index b0b0a6f2..b5e3224e 100644 --- a/src/muya/lib/contentState/pasteCtrl.js +++ b/src/muya/lib/contentState/pasteCtrl.js @@ -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 diff --git a/src/muya/lib/eventHandler/clickEvent.js b/src/muya/lib/eventHandler/clickEvent.js index 7b5e0f2b..7b819136 100644 --- a/src/muya/lib/eventHandler/clickEvent.js +++ b/src/muya/lib/eventHandler/clickEvent.js @@ -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() } diff --git a/src/muya/lib/eventHandler/clipboard.js b/src/muya/lib/eventHandler/clipboard.js index c2617d83..1a7f57a6 100644 --- a/src/muya/lib/eventHandler/clipboard.js +++ b/src/muya/lib/eventHandler/clipboard.js @@ -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) diff --git a/src/muya/lib/eventHandler/dragDrop.js b/src/muya/lib/eventHandler/dragDrop.js new file mode 100644 index 00000000..cdb273cc --- /dev/null +++ b/src/muya/lib/eventHandler/dragDrop.js @@ -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 diff --git a/src/muya/lib/eventHandler/keyboard.js b/src/muya/lib/eventHandler/keyboard.js index a2d50cb1..c7d8da20 100644 --- a/src/muya/lib/eventHandler/keyboard.js +++ b/src/muya/lib/eventHandler/keyboard.js @@ -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() diff --git a/src/muya/lib/index.js b/src/muya/lib/index.js index 2d04fe33..c800fab9 100644 --- a/src/muya/lib/index.js +++ b/src/muya/lib/index.js @@ -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 () { diff --git a/src/muya/lib/parser/index.js b/src/muya/lib/parser/index.js index 66e18ba0..66b51a9c 100644 --- a/src/muya/lib/parser/index.js +++ b/src/muya/lib/parser/index.js @@ -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 image’s alt attribute. - alt: imageTo[2].replace(/[`*{}[\]()#+\-.!_>~:|<>$]/g, ''), + alt: imageTo[2], backlash: { first: imageTo[3], second: imageTo[5] diff --git a/src/muya/lib/parser/render/index.js b/src/muya/lib/parser/render/index.js index 67dd9e43..b4a2250e 100644 --- a/src/muya/lib/parser/render/index.js +++ b/src/muya/lib/parser/render/index.js @@ -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(/^
([\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) diff --git a/src/muya/lib/parser/render/renderBlock/renderBlock.js b/src/muya/lib/parser/render/renderBlock/renderBlock.js index 3ecd4c4c..8fdab50a 100644 --- a/src/muya/lib/parser/render/renderBlock/renderBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderBlock.js @@ -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) } diff --git a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js index 916895a2..4533be87 100644 --- a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js @@ -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: {} diff --git a/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js b/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js index b25ebdbd..a3f63fb1 100644 --- a/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js @@ -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 { diff --git a/src/muya/lib/parser/render/renderInlines/image.js b/src/muya/lib/parser/render/renderInlines/image.js index 4666c8dd..16a304f7 100644 --- a/src/muya/lib/parser/render/renderInlines/image.js +++ b/src/muya/lib/parser/render/renderInlines/image.js @@ -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 image’s 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)] + } } diff --git a/src/muya/lib/parser/render/renderInlines/loadImageAsync.js b/src/muya/lib/parser/render/renderInlines/loadImageAsync.js index a5477ed2..65609029 100644 --- a/src/muya/lib/parser/render/renderInlines/loadImageAsync.js +++ b/src/muya/lib/parser/render/renderInlines/loadImageAsync.js @@ -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, diff --git a/src/muya/lib/parser/render/renderInlines/text.js b/src/muya/lib/parser/render/renderInlines/text.js index d5443d55..519c291a 100644 --- a/src/muya/lib/parser/render/renderInlines/text.js +++ b/src/muya/lib/parser/render/renderInlines/text.js @@ -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)) + ] } diff --git a/src/muya/lib/parser/rules.js b/src/muya/lib/parser/rules.js index 9129fa00..8dfedabf 100644 --- a/src/muya/lib/parser/rules.js +++ b/src/muya/lib/parser/rules.js @@ -23,7 +23,7 @@ export const inlineRules = { 'reference_link': /^\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/, 'reference_image': /^\!\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/, 'tail_header': /^(\s{1,}#{1,})(\s*)$/, - 'html_tag': /^(|(<([a-zA-Z]{1}[a-zA-Z\d-]*) *[_\.\-/:a-zA-Z\d='";\? *]* *(?:\/)?>)(?:([\s\S]*?)(<\/\3 *>))?)/, // row html + 'html_tag': /^(|(<([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)/, diff --git a/src/muya/lib/parser/utils.js b/src/muya/lib/parser/utils.js index 81ef07a7..3deae48b 100644 --- a/src/muya/lib/parser/utils.js +++ b/src/muya/lib/parser/utils.js @@ -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 } } diff --git a/src/muya/lib/selection/dom.js b/src/muya/lib/selection/dom.js index 9e702899..bc431e03 100644 --- a/src/muya/lib/selection/dom.js +++ b/src/muya/lib/selection/dom.js @@ -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) }) } diff --git a/src/muya/lib/selection/index.js b/src/muya/lib/selection/index.js index 258aa8bc..7eeff918 100644 --- a/src/muya/lib/selection/index.js +++ b/src/muya/lib/selection/index.js @@ -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 } diff --git a/src/muya/lib/ui/baseFloat/index.css b/src/muya/lib/ui/baseFloat/index.css index 91141fba..53f2a444 100644 --- a/src/muya/lib/ui/baseFloat/index.css +++ b/src/muya/lib/ui/baseFloat/index.css @@ -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; diff --git a/src/muya/lib/ui/imagePicker/index.css b/src/muya/lib/ui/imagePicker/index.css new file mode 100644 index 00000000..474fcd60 --- /dev/null +++ b/src/muya/lib/ui/imagePicker/index.css @@ -0,0 +1,7 @@ +.ag-image-picker-wrapper { + z-index: 100000; +} + +.ag-image-picker-wrapper .ag-list-picker { + width: 450px; +} diff --git a/src/muya/lib/ui/imagePicker/index.js b/src/muya/lib/ui/imagePicker/index.js index 18ad7b2b..2a6e02e4 100644 --- a/src/muya/lib/ui/imagePicker/index.js +++ b/src/muya/lib/ui/imagePicker/index.js @@ -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() } diff --git a/src/muya/lib/ui/imageSelector/index.css b/src/muya/lib/ui/imageSelector/index.css new file mode 100644 index 00000000..7d269ef7 --- /dev/null +++ b/src/muya/lib/ui/imageSelector/index.css @@ -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; +} diff --git a/src/muya/lib/ui/imageSelector/index.js b/src/muya/lib/ui/imageSelector/index.js new file mode 100644 index 00000000..cf977022 --- /dev/null +++ b/src/muya/lib/ui/imageSelector/index.js @@ -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 diff --git a/src/muya/lib/utils/checkEditImage.js b/src/muya/lib/utils/checkEditImage.js deleted file mode 100644 index 9def7021..00000000 --- a/src/muya/lib/utils/checkEditImage.js +++ /dev/null @@ -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 -} diff --git a/src/muya/lib/utils/getImageInfo.js b/src/muya/lib/utils/getImageInfo.js new file mode 100644 index 00000000..ffc47d9e --- /dev/null +++ b/src/muya/lib/utils/getImageInfo.js @@ -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 + } +} diff --git a/src/muya/lib/utils/index.js b/src/muya/lib/utils/index.js index 604d9f11..9bc9ea99 100644 --- a/src/muya/lib/utils/index.js +++ b/src/muya/lib/utils/index.js @@ -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' +} diff --git a/src/muya/themes/default.css b/src/muya/themes/default.css index f2670119..1b7b66b3 100644 --- a/src/muya/themes/default.css +++ b/src/muya/themes/default.css @@ -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; diff --git a/src/renderer/assets/icons/pref_image.svg b/src/renderer/assets/icons/pref_image.svg new file mode 100644 index 00000000..eaa3c002 --- /dev/null +++ b/src/renderer/assets/icons/pref_image.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/assets/icons/pref_image_uploader.svg b/src/renderer/assets/icons/pref_image_uploader.svg new file mode 100644 index 00000000..c1811f38 --- /dev/null +++ b/src/renderer/assets/icons/pref_image_uploader.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/assets/styles/index.css b/src/renderer/assets/styles/index.css index be64dfd0..20819b7d 100644 --- a/src/renderer/assets/styles/index.css +++ b/src/renderer/assets/styles/index.css @@ -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; diff --git a/src/renderer/assets/themes/dark.theme.css b/src/renderer/assets/themes/dark.theme.css index 8a79996b..d1bef0fe 100644 --- a/src/renderer/assets/themes/dark.theme.css +++ b/src/renderer/assets/themes/dark.theme.css @@ -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); diff --git a/src/renderer/assets/themes/graphite.theme.css b/src/renderer/assets/themes/graphite.theme.css index 8b78cedb..156b2a2e 100644 --- a/src/renderer/assets/themes/graphite.theme.css +++ b/src/renderer/assets/themes/graphite.theme.css @@ -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); diff --git a/src/renderer/assets/themes/material-dark.theme.css b/src/renderer/assets/themes/material-dark.theme.css index 370f1b28..db212bb4 100644 --- a/src/renderer/assets/themes/material-dark.theme.css +++ b/src/renderer/assets/themes/material-dark.theme.css @@ -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); diff --git a/src/renderer/assets/themes/one-dark.theme.css b/src/renderer/assets/themes/one-dark.theme.css index b961c200..cb6ba6d9 100644 --- a/src/renderer/assets/themes/one-dark.theme.css +++ b/src/renderer/assets/themes/one-dark.theme.css @@ -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 { diff --git a/src/renderer/assets/themes/ulysses.theme.css b/src/renderer/assets/themes/ulysses.theme.css index 424428ab..a81d4c8d 100644 --- a/src/renderer/assets/themes/ulysses.theme.css +++ b/src/renderer/assets/themes/ulysses.theme.css @@ -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); diff --git a/src/renderer/components/editorWithTabs/editor.vue b/src/renderer/components/editorWithTabs/editor.vue index daca8fc2..36f6a90d 100644 --- a/src/renderer/components/editorWithTabs/editor.vue +++ b/src/renderer/components/editorWithTabs/editor.vue @@ -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) diff --git a/src/renderer/components/uploadImage/index.vue b/src/renderer/components/uploadImage/index.vue deleted file mode 100644 index 7c523906..00000000 --- a/src/renderer/components/uploadImage/index.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - - - diff --git a/src/renderer/config.js b/src/renderer/config.js index 89778053..f310c6e0 100644 --- a/src/renderer/config.js +++ b/src/renderer/config.js @@ -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' diff --git a/src/renderer/main.js b/src/renderer/main.js index 03507820..c8bb5de0 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -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) diff --git a/src/renderer/pages/app.vue b/src/renderer/pages/app.vue index ef6dd6f4..1926c8e0 100644 --- a/src/renderer/pages/app.vue +++ b/src/renderer/pages/app.vue @@ -27,7 +27,6 @@ :platform="platform" > - @@ -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() diff --git a/src/renderer/prefComponents/editor/index.vue b/src/renderer/prefComponents/editor/index.vue index d754b5cc..4d37455e 100644 --- a/src/renderer/prefComponents/editor/index.vue +++ b/src/renderer/prefComponents/editor/index.vue @@ -75,13 +75,6 @@ :bool="hideQuickInsertHint" :onChange="value => onSelectChange('hideQuickInsertHint', value)" > - -
-
The default behavior after paste or drag the image to Mark Text
- Upload image to cloud - Move image to sepcial folder - Insert absolute or relative path of image -
@@ -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: { diff --git a/src/renderer/prefComponents/image/config.js b/src/renderer/prefComponents/image/config.js new file mode 100644 index 00000000..e69de29b diff --git a/src/renderer/prefComponents/image/index.vue b/src/renderer/prefComponents/image/index.vue new file mode 100644 index 00000000..9c8b3904 --- /dev/null +++ b/src/renderer/prefComponents/image/index.vue @@ -0,0 +1,98 @@ + + + + + + + diff --git a/src/renderer/prefComponents/imageUploader/index.vue b/src/renderer/prefComponents/imageUploader/index.vue new file mode 100644 index 00000000..ca21cbf8 --- /dev/null +++ b/src/renderer/prefComponents/imageUploader/index.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/renderer/prefComponents/sideBar/config.js b/src/renderer/prefComponents/sideBar/config.js index b6b7f08a..f6866736 100644 --- a/src/renderer/prefComponents/sideBar/config.js +++ b/src/renderer/prefComponents/sideBar/config.js @@ -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 => { diff --git a/src/renderer/prefComponents/sideBar/index.vue b/src/renderer/prefComponents/sideBar/index.vue index f3273d00..aadae598 100644 --- a/src/renderer/prefComponents/sideBar/index.vue +++ b/src/renderer/prefComponents/sideBar/index.vue @@ -23,7 +23,7 @@
diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js index 5bde3b05..ad8235d6 100644 --- a/src/renderer/router/index.js +++ b/src/renderer/router/index.js @@ -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' }] }]) diff --git a/src/renderer/store/editor.js b/src/renderer/store/editor.js index 761dbc6a..923eaa3b 100644 --- a/src/renderer/store/editor.js +++ b/src/renderer/store/editor.js @@ -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') } } diff --git a/src/renderer/store/listenForMain.js b/src/renderer/store/listenForMain.js index 364fdccb..6719da36 100644 --- a/src/renderer/store/listenForMain.js +++ b/src/renderer/store/listenForMain.js @@ -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) diff --git a/src/renderer/store/preferences.js b/src/renderer/store/preferences.js index 1d92d115..e16a5a96 100644 --- a/src/renderer/store/preferences.js +++ b/src/renderer/store/preferences.js @@ -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') } } diff --git a/src/renderer/util/fileSystem.js b/src/renderer/util/fileSystem.js index 7f086141..b45eec55 100644 --- a/src/renderer/util/fileSystem.js +++ b/src/renderer/util/fileSystem.js @@ -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 +} diff --git a/src/renderer/util/guessClipBoardFilePath.js b/src/renderer/util/guessClipBoardFilePath.js new file mode 100644 index 00000000..65fbfe48 --- /dev/null +++ b/src/renderer/util/guessClipBoardFilePath.js @@ -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 '' + } +} diff --git a/src/renderer/util/index.js b/src/renderer/util/index.js index a881be0a..7ac3167c 100644 --- a/src/renderer/util/index.js +++ b/src/renderer/util/index.js @@ -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' diff --git a/src/renderer/util/theme.js b/src/renderer/util/theme.js index 4eda81c3..5a4b372e 100644 --- a/src/renderer/util/theme.js +++ b/src/renderer/util/theme.js @@ -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' diff --git a/static/preference.json b/static/preference.json index ab637bfc..b7605d10 100644 --- a/static/preference.json +++ b/static/preference.json @@ -19,7 +19,7 @@ "endOfLine": "default", "textDirection": "ltr", "hideQuickInsertHint": false, - "imageDropAction": "folder", + "imageInsertAction": "path", "preferLooseListItem": true, "bulletListMarker": "-", diff --git a/yarn.lock b/yarn.lock index 490b9cf8..906fd6fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"