diff --git a/.electron-vue/build.js b/.electron-vue/build.js index 7912a199..7cb7f26c 100644 --- a/.electron-vue/build.js +++ b/.electron-vue/build.js @@ -13,7 +13,6 @@ const Multispinner = require('multispinner') const mainConfig = require('./webpack.main.config') const rendererConfig = require('./webpack.renderer.config') -const webConfig = require('./webpack.web.config') const doneLog = chalk.bgGreen.white(' DONE ') + ' ' const errorLog = chalk.bgRed.white(' ERROR ') + ' ' @@ -103,20 +102,6 @@ function pack (config) { }) } -function web () { - del.sync(['dist/web/*', '!.gitkeep']) - webpack(webConfig, (err, stats) => { - if (err || stats.hasErrors()) console.log(err) - - console.log(stats.toString({ - chunks: false, - colors: true - })) - - process.exit() - }) -} - function greeting () { const cols = process.stdout.columns let text = '' diff --git a/.electron-vue/dev-runner.js b/.electron-vue/dev-runner.js index 474e0934..63684c14 100644 --- a/.electron-vue/dev-runner.js +++ b/.electron-vue/dev-runner.js @@ -43,9 +43,9 @@ function startRenderer () { rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer) const compiler = webpack(rendererConfig) - hotMiddleware = webpackHotMiddleware(compiler, { - log: false, - heartbeat: 2500 + hotMiddleware = webpackHotMiddleware(compiler, { + log: false, + heartbeat: 2500 }) compiler.plugin('compilation', compilation => { diff --git a/.electron-vue/webpack.main.config.js b/.electron-vue/webpack.main.config.js index 6907ed30..f1e2f1c1 100644 --- a/.electron-vue/webpack.main.config.js +++ b/.electron-vue/webpack.main.config.js @@ -57,6 +57,9 @@ const mainConfig = { new webpack.DefinePlugin(getEnvironmentDefinitions()) ], resolve: { + alias: { + 'common': path.join(__dirname, '../src/common') + }, extensions: ['.js', '.json', '.node'] }, target: 'electron-main' diff --git a/.electron-vue/webpack.renderer.config.js b/.electron-vue/webpack.renderer.config.js index d58652b1..ae3fc444 100644 --- a/.electron-vue/webpack.renderer.config.js +++ b/.electron-vue/webpack.renderer.config.js @@ -164,6 +164,7 @@ const rendererConfig = { resolve: { alias: { '@': path.join(__dirname, '../src/renderer'), + 'common': path.join(__dirname, '../src/common'), 'muya': path.join(__dirname, '../src/muya'), 'vue$': 'vue/dist/vue.esm.js' }, diff --git a/.electron-vue/webpack.web.config.js b/.electron-vue/webpack.web.config.js deleted file mode 100644 index 9a782902..00000000 --- a/.electron-vue/webpack.web.config.js +++ /dev/null @@ -1,175 +0,0 @@ -'use strict' - -process.env.BABEL_ENV = 'web' - -const path = require('path') -const webpack = require('webpack') - -const CopyWebpackPlugin = require('copy-webpack-plugin') -const MiniCssExtractPlugin = require("mini-css-extract-plugin") -const HtmlWebpackPlugin = require('html-webpack-plugin') -const VueLoaderPlugin = require('vue-loader/lib/plugin') -const SpritePlugin = require('svg-sprite-loader/plugin') -const postcssPresetEnv = require('postcss-preset-env') - -const proMode = process.env.NODE_ENV === 'production' - -const webConfig = { - mode: 'development', - devtool: '#cheap-module-eval-source-map', - entry: { - web: path.join(__dirname, '../src/renderer/main.js') - }, - module: { - rules: [ - { - test: /\.(js|vue)$/, - enforce: 'pre', - exclude: /node_modules/, - use: { - loader: 'eslint-loader', - options: { - formatter: require('eslint-friendly-formatter') - } - } - }, - { - test: /(katex|github\-markdown|prism[\-a-z]*)\.css$/, - use: [ - 'to-string-loader', - 'css-loader' - ] - }, - { - test: /\.css$/, - exclude: /(katex|github\-markdown|prism[\-a-z]*)\.css$/, - use: [ - proMode ? MiniCssExtractPlugin.loader : 'style-loader', - { loader: 'css-loader', options: { importLoaders: 1 } }, - { - loader: 'postcss-loader', options: { - ident: 'postcss', - plugins: () => [ - postcssPresetEnv({ - stage: 0 - }) - ] - } - } - ] - }, - { - test: /\.html$/, - use: 'vue-html-loader' - }, - { - test: /\.js$/, - use: 'babel-loader', - include: [ path.resolve(__dirname, '../src/renderer') ], - exclude: /node_modules/ - }, - { - test: /\.vue$/, - use: { - loader: 'vue-loader', - options: { - sourceMap: true - } - } - }, - { - test: /\.svg$/, - use: [ - { - loader: 'svg-sprite-loader', - options: { - extract: true, - publicPath: '/static/' - } - }, - 'svgo-loader' - ] - }, - { - test: /\.(png|jpe?g|gif)(\?.*)?$/, - use: { - loader: 'url-loader', - query: { - limit: 10000, - name: 'imgs/[name].[ext]' - } - } - }, - { - test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, - use: { - loader: 'url-loader', - query: { - limit: 100000, - name: 'fonts/[name].[ext]' - } - } - } - ] - }, - plugins: [ - new SpritePlugin(), - new HtmlWebpackPlugin({ - filename: 'index.html', - template: path.resolve(__dirname, '../src/index.ejs'), - minify: { - collapseWhitespace: true, - removeAttributeQuotes: true, - removeComments: true - }, - nodeModules: false - }), - new webpack.DefinePlugin({ - 'process.env.IS_WEB': 'true' - }), - new webpack.HotModuleReplacementPlugin(), - new webpack.NoEmitOnErrorsPlugin(), - new VueLoaderPlugin() - ], - output: { - filename: '[name].js', - path: path.join(__dirname, '../dist/web') - }, - resolve: { - alias: { - '@': path.join(__dirname, '../src/renderer'), - 'vue$': 'vue/dist/vue.esm.js' - }, - extensions: ['.js', '.vue', '.json', '.css'] - }, - target: 'web' -} - -/** - * Adjust webConfig for production settings - */ -if (proMode) { - webConfig.devtool = '#nosources-source-map' - webConfig.mode ='production' - - webConfig.plugins.push( - new MiniCssExtractPlugin({ - // Options similar to the same options in webpackOptions.output - // both options are optional - filename: '[name].[hash].css', - chunkFilename: '[id].[hash].css' - }), - new CopyWebpackPlugin([ - { - from: path.join(__dirname, '../static'), - to: path.join(__dirname, '../dist/web/static'), - ignore: ['.*'] - } - ]), - new webpack.LoaderOptionsPlugin({ - minimize: true - }) - ) -} - -module.exports = webConfig diff --git a/.eslintrc.js b/.eslintrc.js index 36f201fe..4dced88d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,9 @@ module.exports = { extends: [ 'standard', 'eslint:recommended', - 'plugin:vue/base' // 'plugin:vue/essential' + 'plugin:vue/base', + 'plugin:import/errors', + 'plugin:import/warnings' ], globals: { __static: true @@ -33,5 +35,18 @@ module.exports = { 'no-console': 0, // allow debugger during development 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 + }, + settings: { + 'import/resolver': { + alias: { + map: [ + ['common', './src/common'], + // Normally only valid for renderer/ + ['@', './src/renderer'], + ['muya', './src/muya'] + ], + extensions: ['.js', '.vue', '.json', '.css', '.node'] + } + } } } diff --git a/doc/wip/ipc.md b/doc/wip/ipc.md new file mode 100644 index 00000000..b0e96967 --- /dev/null +++ b/doc/wip/ipc.md @@ -0,0 +1,41 @@ +# Inter-Process Communication (IPC) + +[Electron](https://electronjs.org/docs/api/ipc-main) provides `ipcMain` and `ipcRenderer` to communicate asynchronously between the main process and renderer processes. The event name/channel must be prefixed with `mt::` (previously `AGANI::`) if used between main process and renderer processes. The default argument list will be `(event, ...args)`. The event name/channel is not prefixed when using `ipcMain` to emit events to the main process directly and emitted events don't have an `event` parameter. The parameter list will only be `(...args)`! When simulate a renderer event you must specify a [event](https://electronjs.org/docs/api/ipc-main#event-object) parameter (`null` or `undefined` may lead to unexpected exceptions). + +## Examples + +Listening to a renderer event in the main process: + +```js +import { ipcMain } from 'electron' + +// Listen for renderer events +ipcMain.on('mt::some-event-name', (event, arg1, arg2) => { + // ... + + // Send a direct response to the renderer process + event.sender.send('mt::some-event-name-response', 'pong') +}) + +// Listen for main events +ipcMain.on('some-event-name', (arg1, arg2) => { + // ... +}) + + +ipcMain.emit('some-event-name', 'arg 1', 'arg 2') +// ipcMain.emit('mt::some-event-name-response', undefined, 'arg 1', 'arg 2') // crash because event is used +``` + +Listening to a main event in the renderer process: + +```js +import { ipcRenderer } from 'electron' + +// Listen for main events +ipcRenderer.on('mt::some-event-name-response', (event, arg1, arg2) => { + // ... +}) + +ipcRenderer.send('mt::some-event-name-response', 'arg 1', 'arg 2') +``` diff --git a/doc/wip/renderer/editor.md b/doc/wip/renderer/editor.md index b04df491..edc7b8f9 100644 --- a/doc/wip/renderer/editor.md +++ b/doc/wip/renderer/editor.md @@ -16,8 +16,8 @@ interface IMarkdownDocumentRaw // Full path (may be empty?) pathname: string, - // Indicates whether the document is UTF8 or UTF8-DOM encoded. - isUtf8BomEncoded: boolean, + // Document encoding + encoding: string, // "lf" or "crlf" lineEnding: string, // Convert document ("lf") to `lineEnding` when saving @@ -25,9 +25,6 @@ interface IMarkdownDocumentRaw // Whether the document has mixed line endings (lf and crlf) and was converted to lf. isMixedLineEndings: boolean - - // TODO(refactor:renderer/editor): Remove this entry! This should be loaded separately if needed. - textDirection: boolean } ``` @@ -45,8 +42,20 @@ interface IMarkdownDocument // Full path (may be empty?) pathname: string, - // Indicates whether the document is UTF8 or UTF8-DOM encoded. - isUtf8BomEncoded: boolean, + // Document encoding + encoding: string, + // "lf" or "crlf" + lineEnding: string, + // Convert document ("lf") to `lineEnding` when saving + adjustLineEndingOnSave: boolean +} +``` + +```typescript +interface IMarkdownDocumentOptions +{ + // Document encoding + encoding: string, // "lf" or "crlf" lineEnding: string, // Convert document ("lf") to `lineEnding` when saving @@ -65,10 +74,9 @@ interface IDocumentState pathname: string, filename: string, markdown: string, - isUtf8BomEncoded: boolean, + encoding: string, lineEnding: string, adjustLineEndingOnSave: boolean, - textDirection: string, history: { stack: Array, index: number diff --git a/package.json b/package.json index 105b7787..e43bdc3b 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ }, "dependencies": { "@hfelix/electron-localshortcut": "^3.1.1", + "arg": "^4.1.0", "axios": "^0.18.0", "chokidar": "^2.1.5", "codemirror": "^5.46.0", @@ -174,6 +175,7 @@ "dompurify": "^1.0.10", "dragula": "^3.7.2", "electron-is-accelerator": "^0.1.2", + "electron-log": "^3.0.5", "electron-window-state": "^5.0.3", "element-resize-detector": "^1.2.0", "element-ui": "^2.8.2", @@ -233,6 +235,7 @@ "eslint": "^5.16.0", "eslint-config-standard": "^12.0.0", "eslint-friendly-formatter": "^4.0.1", + "eslint-import-resolver-alias": "^1.1.2", "eslint-loader": "^2.1.2", "eslint-plugin-html": "^4.0.6", "eslint-plugin-import": "^2.16.0", diff --git a/src/common/envPaths.js b/src/common/envPaths.js new file mode 100644 index 00000000..44557283 --- /dev/null +++ b/src/common/envPaths.js @@ -0,0 +1,50 @@ +import path from 'path' + +class EnvPaths { + + /** + * @param {string} userDataPath The user data path. + * @returns + */ + constructor (userDataPath) { + const currentDate = new Date() + if (!userDataPath) { + throw new Error('"userDataPath" is not set.') + } + + this._electronUserDataPath = userDataPath // path.join(userDataPath, 'electronUserData') + this._userDataPath = userDataPath + 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') + + // TODO(sessions): enable this... + // this._globalStorage = path.join(this._userDataPath, 'globalStorage') + // this._preferencesPath = path.join(this._userDataPath, 'preferences') + // this._sessionsPath = path.join(this._userDataPath, 'sessions') + } + + get electronUserDataPath () { + // This path is identical to app.getPath('userData') but userDataPath must not necessarily be the same path. + return this._electronUserDataPath + } + + get userDataPath () { + return this._userDataPath + } + + get logPath () { + return this._logPath + } + + get preferencesPath () { + return this._preferencesPath + } + + get preferencesFilePath () { + return this._preferencesFilePath + } +} + +export default EnvPaths diff --git a/src/main/actions/theme.js b/src/main/actions/theme.js deleted file mode 100644 index f1f58e81..00000000 --- a/src/main/actions/theme.js +++ /dev/null @@ -1,13 +0,0 @@ -import { log } from '../utils' -import userPreference from '../preference' -import appWindow from '../window' - -export const selectTheme = (theme, themeCSS) => { - userPreference.setItem('theme', theme) - .then(() => { - for (const { win } of appWindow.windows.values()) { - win.webContents.send('AGANI::user-preference', { theme }) - } - }) - .catch(log) -} diff --git a/src/main/app.js b/src/main/app.js deleted file mode 100644 index 62df583b..00000000 --- a/src/main/app.js +++ /dev/null @@ -1,131 +0,0 @@ -import { app, systemPreferences } from 'electron' -import appWindow from './window' -import { isOsx } from './config' -import { dockMenu } from './menus' -import { isDirectory, isMarkdownFileOrLink, getMenuItemById, normalizeAndResolvePath } from './utils' -import { watchers } from './utils/imagePathAutoComplement' -import { selectTheme } from './actions/theme' -import preference from './preference' - -class App { - constructor () { - this.openFilesCache = [] - } - - init () { - // Enable these features to use `backdrop-filter` css rules! - if (isOsx) { - app.commandLine.appendSwitch('enable-experimental-web-platform-features', 'true') - } - - app.on('open-file', this.openFile) - - app.on('ready', this.ready) - - app.on('window-all-closed', () => { - app.removeListener('open-file', this.openFile) - // close all the image path watcher - for (const watcher of watchers.values()) { - watcher.close() - } - if (!isOsx) { - appWindow.clear() - app.quit() - } - }) - - app.on('activate', () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (appWindow.windows.size === 0) { - this.ready() - } - }) - - // Prevent to load webview and opening links or new windows via HTML/JS. - app.on('web-contents-created', (event, contents) => { - contents.on('will-attach-webview', (event, webPreferences, params) => { - console.warn('Prevented webview creation.') - event.preventDefault() - }) - contents.on('will-navigate', event => { - console.warn('Prevented opening a link.') - event.preventDefault() - }) - contents.on('new-window', (event, url) => { - console.warn('Prevented opening a new window.') - event.preventDefault() - }) - }) - } - - ready = () => { - if (!isOsx && process.argv.length >= 2) { - for (const arg of process.argv) { - if (arg.startsWith('--')) { - continue - } else if (isDirectory(arg) || isMarkdownFileOrLink(arg)) { - // Normalize and resolve the path or link target. - const resolved = normalizeAndResolvePath(arg) - if (resolved) { - // TODO: Allow to open multiple files. - this.openFilesCache = [ resolved ] - break - } else { - console.error(`[ERROR] Cannot resolve "${arg}".`) - } - } - } - } - - // Set dock on macOS - if (process.platform === 'darwin') { - app.dock.setMenu(dockMenu) - - // Listen for system theme change and change Mark Text own `dark` and `light`. - // In macOS 10.14 Mojave, Apple introduced a new system-wide dark mode for - // all macOS computers. - systemPreferences.subscribeNotification( - 'AppleInterfaceThemeChangedNotification', - () => { - const { theme } = preference.getAll() - let setedTheme = null - if (systemPreferences.isDarkMode() && theme !== 'dark') { - selectTheme('dark') - setedTheme = 'dark' - } - if (!systemPreferences.isDarkMode() && theme === 'dark') { - selectTheme('light') - setedTheme = 'light' - } - if (setedTheme) { - const themeMenu = getMenuItemById('themeMenu') - const menuItem = themeMenu.submenu.items.filter(item => (item.id === setedTheme))[0] - if (menuItem) { - menuItem.checked = true - } - } - } - ) - } - - if (this.openFilesCache.length) { - this.openFilesCache.forEach(path => appWindow.createWindow(path)) - this.openFilesCache.length = 0 // empty the open file path cache - } else { - appWindow.createWindow() - } - } - - openFile = (event, path) => { - const { openFilesCache } = this - event.preventDefault() - if (app.isReady()) { - appWindow.createWindow(path) - } else { - openFilesCache.push(path) - } - } -} - -export default App diff --git a/src/main/app/accessor.js b/src/main/app/accessor.js new file mode 100644 index 00000000..bd1039c5 --- /dev/null +++ b/src/main/app/accessor.js @@ -0,0 +1,23 @@ +import WindowManager from '../app/windowManager' +import Preference from '../preferences' +import Keybindings from '../keyboard/shortcutHandler' +import AppMenu from '../menu' + +class Accessor { + + /** + * @param {AppEnvironment} appEnvironment The application environment instance. + */ + constructor(appEnvironment) { + const userDataPath = appEnvironment.paths.userDataPath + + this.env = appEnvironment + this.paths = appEnvironment.paths // export paths to make it better accessible + this.preferences = new Preference(this.paths) + this.keybindings = new Keybindings(userDataPath) + this.menu = new AppMenu(this.preferences, this.keybindings, userDataPath) + this.windowManager = new WindowManager(this.menu, this.preferences) + } +} + +export default Accessor diff --git a/src/main/app/env.js b/src/main/app/env.js new file mode 100644 index 00000000..e1f490a8 --- /dev/null +++ b/src/main/app/env.js @@ -0,0 +1,91 @@ +import path from 'path' +import AppPaths, { ensureAppDirectoriesSync } from './paths' + +let envId = 0 + +const patchEnvPath = () => { + if (process.platform === 'darwin') { + process.env.PATH += (process.env.PATH.endsWith(path.delimiter) ? '' : path.delimiter) + '/Library/TeX/texbin' + } +} + +export class AppEnvironment { + + constructor (options) { + this._id = envId++ + this._appPaths = new AppPaths(options.userDataPath) + this._debug = !!options.debug + this._verbose = !!options.verbose + this._safeMode = !!options.safeMode + } + + /** + * Returns an unique identifier that can be used with IPC to identify messages from this environment. + * + * @returns {number} Returns an unique identifier. + */ + get id() { + return this._id + } + + /** + * @returns {AppPaths} + */ + get paths () { + return this._appPaths + } + + /** + * @returns {boolean} + */ + get debug () { + return this._debug + } + + /** + * @returns {boolean} + */ + get verbose () { + return this._verbose + } + + /** + * @returns {boolean} + */ + get safeMode () { + return this._safeMode + } +} + +/** + * Create a (global) application environment instance and bootstraps the application. + * + * @param {arg.Result} args The parsed application arguments. + * @returns {AppEnvironment} The current (global) environment. + */ +const setupEnvironment = args => { + patchEnvPath() + + const debug = args['--debug'] || !!process.env.MARKTEXT_DEBUG || process.env.NODE_ENV !== 'production' + const verbose = args['--verbose'] || 0 + const safeMode = args['--safe'] + const userDataPath = args['--user-data-dir'] // or null (= default user data path) + + const appEnvironment = new AppEnvironment({ + debug, + verbose, + safeMode, + userDataPath + }) + + ensureAppDirectoriesSync(appEnvironment.paths) + + // Keep this for easier access. + global.MARKTEXT_DEBUG = debug + global.MARKTEXT_DEBUG_VERBOSE = verbose + global.MARKTEXT_SAFE_MODE = safeMode + + return appEnvironment +} + +export default setupEnvironment diff --git a/src/main/app/index.js b/src/main/app/index.js new file mode 100644 index 00000000..3c29f7db --- /dev/null +++ b/src/main/app/index.js @@ -0,0 +1,248 @@ +import { app, ipcMain, systemPreferences } from 'electron' +import { isOsx } from '../config' +import { isDirectory, isMarkdownFileOrLink, normalizeAndResolvePath } from '../filesystem' +import { getMenuItemById } from '../menu' +import { selectTheme } from '../menu/actions/theme' +import { dockMenu } from '../menu/templates' +import { watchers } from '../utils/imagePathAutoComplement' +import EditorWindow from '../windows/editor' + +class App { + + /** + * @param {Accessor} accessor The application accessor for application instances. + * @param {arg.Result} args Parsed application arguments. + */ + constructor (accessor, args) { + this._accessor = accessor + this._args = args || {_: []} + this._openFilesCache = [] + this._openFilesTimer = null + this._windowManager = this._accessor.windowManager + + this._listenForIpcMain() + } + + /** + * The entry point into the application. + */ + init () { + // Enable these features to use `backdrop-filter` css rules! + if (isOsx) { + app.commandLine.appendSwitch('enable-experimental-web-platform-features', 'true') + } + + app.on('open-file', this.openFile) // macOS only + + app.on('ready', this.ready) + + app.on('window-all-closed', () => { + // Close all the image path watcher + for (const watcher of watchers.values()) { + watcher.close() + } + this._windowManager.closeWatcher() + if (!isOsx) { + app.quit() + } + }) + + app.on('activate', () => { // macOS only + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (this._windowManager.windowCount === 0) { + this.ready() + } + }) + + // Prevent to load webview and opening links or new windows via HTML/JS. + app.on('web-contents-created', (event, contents) => { + contents.on('will-attach-webview', (event, webPreferences, params) => { + console.warn('Prevented webview creation.') + event.preventDefault() + }) + contents.on('will-navigate', event => { + console.warn('Prevented opening a link.') + event.preventDefault() + }) + contents.on('new-window', (event, url) => { + console.warn('Prevented opening a new window.') + event.preventDefault() + }) + }) + } + + ready = () => { + const { _args: args } = this + if (!isOsx && args._.length) { + for (const pathname of args._) { + // Ignore all unknown flags + if (pathname.startsWith('--')) { + continue + } + + const info = this.normalizePath(pathname) + if (info) { + this._openFilesCache.push(info) + } + } + } + + if (process.platform === 'darwin') { + app.dock.setMenu(dockMenu) + + // Listen for system theme change and change Mark Text own `dark` and `light`. + // In macOS 10.14 Mojave, Apple introduced a new system-wide dark mode for + // all macOS computers. + systemPreferences.subscribeNotification( + 'AppleInterfaceThemeChangedNotification', + () => { + const preferences = this._accessor.preferences + const { theme } = preferences.getAll() + let setedTheme = null + if (systemPreferences.isDarkMode() && theme !== 'dark') { + selectTheme('dark') + setedTheme = 'dark' + } + if (!systemPreferences.isDarkMode() && theme === 'dark') { + selectTheme('light') + setedTheme = 'light' + } + if (setedTheme) { + const themeMenu = getMenuItemById('themeMenu') + const menuItem = themeMenu.submenu.items.filter(item => (item.id === setedTheme))[0] + if (menuItem) { + menuItem.checked = true + } + } + } + ) + } + + if (this._openFilesCache.length) { + this.openFileCache() + } else { + this.createEditorWindow() + } + } + + openFile = (event, pathname) => { + event.preventDefault() + const info = this.normalizePath(pathname) + if (info) { + this._openFilesCache.push(info) + + if (app.isReady()) { + // It might come more files + if (this._openFilesTimer) { + clearTimeout(this._openFilesTimer) + } + this._openFilesTimer = setTimeout(() => { + this._openFilesTimer = null + this.openFileCache() + }, 100) + } + } + } + + openFileCache = () => { + // TODO: Allow to open multiple files in the same window. + this._openFilesCache.forEach(fileInfo => this.createEditorWindow(fileInfo.path)) + this._openFilesCache.length = 0 // empty the open file path cache + } + + normalizePath = pathname => { + const isDir = isDirectory(pathname) + if (isDir || isMarkdownFileOrLink(pathname)) { + // Normalize and resolve the path or link target. + const resolved = normalizeAndResolvePath(pathname) + if (resolved) { + return { isDir, path: resolved } + } else { + console.error(`[ERROR] Cannot resolve "${pathname}".`) + } + } + return null + } + + // --- private -------------------------------- + + /** + * Creates a new editor window. + * + * @param {string} [pathname] Path to a file, directory or link. + * @param {string} [markdown] Markdown content. + * @param {*} [options] BrowserWindow options. + */ + createEditorWindow (pathname = null, markdown = '', options = {}) { + const editor = new EditorWindow(this._accessor) + editor.createWindow(pathname, markdown, options) + this._windowManager.add(editor) + if (this._windowManager.windowCount === 1) { + this._accessor.menu.setActiveWindow(editor.id) + } + } + + // TODO(sessions): ... + // // Make Mark Text a single instance application. + // _makeSingleInstance() { + // if (process.mas) return + // + // app.requestSingleInstanceLock() + // + // app.on('second-instance', (event, argv, workingDirectory) => { + // // // TODO: Get active/last active window and open process arvg etc + // // if (currentWindow) { + // // if (currentWindow.isMinimized()) currentWindow.restore() + // // currentWindow.focus() + // // } + // }) + // } + + _listenForIpcMain () { + ipcMain.on('app-create-editor-window', () => { + this.createEditorWindow() + }) + + ipcMain.on('app-create-settings-window', () => { + const { paths } = this._accessor + this.createEditorWindow(paths.preferencesFilePath) + }) + + // ipcMain.on('app-open-file', filePath => { + // const windowId = this._windowManager.getActiveWindow() + // ipcMain.emit('app-open-file-by-id', windowId, filePath) + // }) + + ipcMain.on('app-open-file-by-id', (windowId, filePath) => { + const { openFilesInNewWindow } = this._accessor.preferences.getAll() + if (openFilesInNewWindow) { + this.createEditorWindow(filePath) + } else { + const editor = this._windowManager.get(windowId) + if (editor && !editor.quitting) { + editor.openTab(filePath, true) + } + } + }) + + ipcMain.on('app-open-markdown-by-id', (windowId, data) => { + const { openFilesInNewWindow } = this._accessor.preferences.getAll() + if (openFilesInNewWindow) { + this.createEditorWindow(undefined, data) + } else { + const editor = this._windowManager.get(windowId) + if (editor && !editor.quitting) { + editor.openUntitledTab(true, data) + } + } + }) + + ipcMain.on('app-open-directory-by-id', (windowId, pathname) => { + // TODO: Open the directory in an existing window if prefered. + this.createEditorWindow(pathname) + }) + } +} + +export default App diff --git a/src/main/app/paths.js b/src/main/app/paths.js new file mode 100644 index 00000000..e71b422d --- /dev/null +++ b/src/main/app/paths.js @@ -0,0 +1,37 @@ +import { app } from 'electron' +import EnvPaths from 'common/envPaths' +import { ensureDirSync } from '../filesystem' + +class AppPaths extends EnvPaths { + + /** + * Configure and sets all application paths. + * + * @param {[string]} userDataPath The user data path or null. + * @returns + */ + constructor (userDataPath='') { + if (!userDataPath) { + // Use default user data path. + userDataPath = app.getPath('userData') + } + + // Initialize environment paths + super(userDataPath) + + // Changing the user data directory is only allowed during application bootstrap. + app.setPath('userData', this._electronUserDataPath) + } +} + +export const ensureAppDirectoriesSync = paths => { + ensureDirSync(paths.userDataPath) + ensureDirSync(paths.logPath) + // TODO(sessions): enable this... + // ensureDirSync(paths.electronUserDataPath) + // ensureDirSync(paths.globalStorage) + // ensureDirSync(paths.preferencesPath) + // ensureDirSync(paths.sessionsPath) +} + +export default AppPaths diff --git a/src/main/app/windowManager.js b/src/main/app/windowManager.js new file mode 100644 index 00000000..572297c5 --- /dev/null +++ b/src/main/app/windowManager.js @@ -0,0 +1,284 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import EventEmitter from 'events' +import log from 'electron-log' +import Watcher from '../filesystem/watcher' + +/** + * A Mark Text window. + * @typedef {EditorWindow} IApplicationWindow + * @property {number | null} id Identifier (= browserWindow.id) or null during initialization. + * @property {Electron.BrowserWindow} browserWindow The browse window. + * @property {WindowType} type The window type. + */ + +// Currently it makes no sense because we have only one (editor) window but we +// will add more windows like settings and worker windows. +export const WindowType = { + EDITOR: 0 +} + +class WindowActivityList { + constructor() { + // Oldest Newest + // , ... , + this._buf = [] + } + + getNewest () { + const { _buf } = this + if (_buf.length) { + return _buf[_buf.length - 1] + } + return null + } + + setNewest (id) { + // I think we do not need a linked list for only a few windows. + const { _buf } = this + const index = _buf.indexOf(id) + if (index !== -1) { + const lastIndex = _buf.length - 1 + if (index === lastIndex) { + return + } + _buf.splice(index, 1) + } + _buf.push(id) + } + + delete (id) { + const { _buf } = this + const index = _buf.indexOf(id); + if (index !== -1) { + _buf.splice(index, 1); + } + } +} + +class WindowManager extends EventEmitter { + + /** + * + * @param {AppMenu} appMenu The application menu instance. + * @param {Preference} preferences The preference instance. + */ + constructor(appMenu, preferences) { + super() + + this._appMenu = appMenu + + this._activeWindowId = null + this._windows = new Map() + this._windowActivity = new WindowActivityList() + + // TODO(need::refactor): We should move watcher and search into another process/thread(?) + this._watcher = new Watcher(preferences) + + this._listenForIpcMain() + } + + /** + * Add the given window to the window list. + * + * @param {IApplicationWindow} window The application window. We take ownership! + */ + add (window) { + this._windows.set(window.id, window) + if (this.windowCount === 1) { + this.setActiveWindow(window.id) + } + + const { browserWindow } = window + window.on('window-focus', () => { + this.setActiveWindow(browserWindow.id) + }) + } + + /** + * Return the application window by id. + * + * @param {string} windowId The window id. + * @returns {IApplicationWindow} The application window or undefined. + */ + get (windowId) { + return this._windows.get(windowId) + } + + /** + * Return the BrowserWindow by id. + * + * @param {string} windowId The window id. + * @returns {Electron.BrowserWindow} The window or undefined. + */ + getBrowserWindow (windowId) { + const window = this.get(windowId) + if (window) { + return window.browserWindow + } + return undefined + } + + /** + * Remove the given window by id. + * + * NOTE: All window event listeners are removed! + * + * @param {string} windowId The window id. + * @returns {IApplicationWindow} Returns the application window. We no longer take ownership. + */ + remove (windowId) { + const { _windows } = this + const window = this.get(windowId) + if (window) { + window.removeAllListeners() + + this._windowActivity.delete(windowId) + let nextWindowId = this._windowActivity.getNewest() + this.setActiveWindow(nextWindowId) + + _windows.delete(windowId) + } + return window + } + + setActiveWindow (windowId) { + if (this._activeWindowId !== windowId) { + this._activeWindowId = windowId + this._windowActivity.setNewest(windowId) + if (windowId != null) { + // windowId is null when all windows are closed (e.g. when gracefully closed). + this._appMenu.setActiveWindow(windowId) + } + this.emit('activeWindowChanged', windowId) + } + } + + /** + * Returns the active window id or null if no window is registred. + * @returns {number|null} + */ + getActiveWindow () { + return this._activeWindowId + } + + get windows () { + return this._windows + } + + get windowCount () { + return this._windows.size + } + + // --- helper --------------------------------- + + closeWatcher () { + this._watcher.clear() + } + + /** + * Closes the browser window and associated application window without asking to save documents. + * + * @param {Electron.BrowserWindow} browserWindow The browser window. + */ + forceClose (browserWindow) { + if (!browserWindow) { + return false + } + + const { id } = browserWindow + const { _appMenu, _windows } = this + + // Free watchers used by this window + this._watcher.unWatchWin(browserWindow) + + // Application clearup and remove listeners + _appMenu.removeWindowMenu(id) + const window = this.remove(id) + + // Destroy window wrapper and browser window + if (window) { + window.destroy() + } else { + log.error('Something went wrong: Cannot find associated application window!') + browserWindow.destroy() + } + + // Quit application on macOS if not windows are opened. + if (_windows.size === 0) { + app.quit() + } + return true + } + + /** + * Closes the application window and associated browser window without asking to save documents. + * + * @param {number} windowId The application window or browser window id. + */ + forceCloseById (windowId) { + const browserWindow = this.getBrowserWindow(windowId) + if (browserWindow) { + return this.forceClose(browserWindow) + } + return false + } + + // --- events --------------------------------- + + _listenForIpcMain () { + // listen for file watch from renderer process eg + // 1. click file in folder. + // 2. new tab and save it. + // 3. close tab(s) need unwatch. + ipcMain.on('AGANI::file-watch', (e, { pathname, watch }) => { + const win = BrowserWindow.fromWebContents(e.sender) + if (watch) { + // listen for file `change` and `unlink` + this._watcher.watch(win, pathname, 'file') + } else { + // unlisten for file `change` and `unlink` + this._watcher.unWatch(win, pathname, 'file') + } + }) + + // Force close a BrowserWindow + ipcMain.on('AGANI::close-window', e => { + const win = BrowserWindow.fromWebContents(e.sender) + this.forceClose(win) + }) + + // --- local events --------------- + + ipcMain.on('watcher-watch-file', (win, filePath) => { + this._watcher.watch(win, filePath, 'file') + }) + ipcMain.on('watcher-watch-directory', (win, pathname) => { + this._watcher.watch(win, pathname, 'dir') + }) + ipcMain.on('watcher-unwatch-file', (win, filePath) => { + this._watcher.unWatch(win, filePath, 'file') + }) + ipcMain.on('watcher-unwatch-directory', (win, pathname) => { + this._watcher.unWatch(win, pathname, 'dir') + }) + + // Force close a window by id. + ipcMain.on('window-close-by-id', id => { + this.forceCloseById(id) + }) + + ipcMain.on('window-toggle-always-on-top', win => { + const flag = !win.isAlwaysOnTop() + win.setAlwaysOnTop(flag) + this._appMenu.updateAlwaysOnTopMenu(flag) + }) + + ipcMain.on('broadcast-preferences-changed', prefs => { + for (const { browserWindow } of this._windows.values()) { + browserWindow.webContents.send('AGANI::user-preference', prefs) + } + }) + } +} + +export default WindowManager diff --git a/src/main/cli.js b/src/main/cli.js deleted file mode 100644 index a936499f..00000000 --- a/src/main/cli.js +++ /dev/null @@ -1,33 +0,0 @@ -import { dumpKeyboardInformation } from './keyboardUtils' - -for (const arg of process.argv) { - if (arg === '--dump-keyboard-layout') { - console.log(dumpKeyboardInformation()) - process.exit(0) - } else if (arg === '--debug') { - global.MARKTEXT_DEBUG = true - continue - } else if (arg === '--safe') { - global.MARKTEXT_SAFE_MODE = true - continue - } else if (arg === '--version') { - console.log(`Mark Text: ${global.MARKTEXT_VERSION_STRING} -Node.js: ${process.versions.node} -Electron: ${process.versions.electron} -Chromium: ${process.versions.chrome} - `) - process.exit(0) - } else if (arg === '--help') { - console.log(`Usage: marktext [commands] [path] - -Available commands: - - --debug Enable debug mode - --safe Disable plugins and other user configuration - --dump-keyboard-layout Dump keyboard information - --version Print version information - --help Print this help message - `) - process.exit(0) - } -} diff --git a/src/main/cli/index.js b/src/main/cli/index.js new file mode 100644 index 00000000..56b6a01a --- /dev/null +++ b/src/main/cli/index.js @@ -0,0 +1,63 @@ +import path from 'path' +import os from 'os' +import { isDirectory } from '../filesystem' +import parseArgs from './parser' +import { dumpKeyboardInformation } from '../keyboard' +import { getPath } from '../utils' + +const write = s => process.stdout.write(s) +const writeLine = s => write(s + '\n') + +const cli = () => { + let argv = process.argv.slice(1) + if (process.env.NODE_ENV === 'development') { + // Don't pass electron development arguments to Mark Text and change user data path. + argv = [ '--user-data-dir', path.join(getPath('appData'), 'marktext-dev') ] + } + + const args = parseArgs(argv, true) + if (args['--help']) { + write(`Usage: marktext [commands] [path ...] + + Available commands: + + --debug Enable debug mode + --safe Disable plugins and other user configuration + --dump-keyboard-layout Dump keyboard information + --user-data-dir Change the user data directory + -v, --verbose Be verbose + --version Print version information + -h, --help Print this help message +`) + process.exit(0) + } + + if (args['--version']) { + writeLine(`Mark Text: ${global.MARKTEXT_VERSION_STRING}`) + writeLine(`Node.js: ${process.versions.node}`) + writeLine(`Electron: ${process.versions.electron}`) + writeLine(`Chromium: ${process.versions.chrome}`) + writeLine(`OS: ${os.type()} ${os.arch()} ${os.release()}`) + process.exit(0) + } + + if (args['--dump-keyboard-layout']) { + writeLine(dumpKeyboardInformation()) + process.exit(0) + } + + // Check for portable mode and ensure the user data path is absolute. We assume + // that the path is writable if not this lead to an application crash. + if (!args['--user-data-dir']) { + const portablePath = path.resolve('marktext-user-data') + if (isDirectory(portablePath)) { + args['--user-data-dir'] = portablePath + } + } else { + args['--user-data-dir'] = path.resolve(args['--user-data-dir']) + } + + return args +} + +export default cli diff --git a/src/main/cli/parser.js b/src/main/cli/parser.js new file mode 100644 index 00000000..f4762cad --- /dev/null +++ b/src/main/cli/parser.js @@ -0,0 +1,31 @@ +import arg from 'arg' + +/** + * Parse the given arguments or the default program arguments. + * + * @param {string[]} argv Arguments if null the default program arguments are used. + * @param {boolean} permissive If set to false an exception is throw about unknown flags. + * @returns {arg.Result} Parsed arguments + */ +const parseArgs = (argv=null, permissive=true) => { + if (argv == null) { + argv = process.argv.slice(1) + } + const spec = { + '--debug': Boolean, + '--safe': Boolean, + '--dump-keyboard-layout': Boolean, + + '--user-data-dir': String, + + // Misc + '--help': Boolean, + '-h': '--help', + '--verbose': arg.COUNT, + '-v': '--verbose', + '--version': Boolean + } + return arg(spec, { argv, permissive }) +} + +export default parseArgs diff --git a/src/main/config.js b/src/main/config.js index 0d4d400c..f04e841a 100644 --- a/src/main/config.js +++ b/src/main/config.js @@ -1,11 +1,8 @@ -import path from 'path' - export const isOsx = process.platform === 'darwin' export const isWindows = process.platform === 'win32' export const isLinux = process.platform === 'linux' export const defaultWinOptions = { - icon: path.join(__static, 'logo-96px.png'), minWidth: 450, minHeight: 220, webPreferences: { diff --git a/src/main/exceptionHandler.js b/src/main/exceptionHandler.js index bec01113..fed4abcf 100644 --- a/src/main/exceptionHandler.js +++ b/src/main/exceptionHandler.js @@ -7,7 +7,8 @@ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import { app, clipboard, crashReporter, dialog, ipcMain } from 'electron' -import { log } from './utils' +import os from 'os' +import log from 'electron-log' import { createAndOpenGitHubIssueUrl } from './utils/createGitHubIssue' const EXIT_ON_ERROR = !!process.env.MARKTEXT_EXIT_ON_ERROR @@ -15,28 +16,17 @@ const SHOW_ERROR_DIALOG = !process.env.MARKTEXT_ERROR_INTERACTION const ERROR_MSG_MAIN = 'An unexpected error occurred in the main process' const ERROR_MSG_RENDERER = 'An unexpected error occurred in the renderer process' -// main process error handler -process.on('uncaughtException', error => { - handleError(ERROR_MSG_MAIN, error, 'main') -}) +let logger = s => console.error(s) -// renderer process error handler -ipcMain.on('AGANI::handle-renderer-error', (e, error) => { - handleError(ERROR_MSG_RENDERER, error, 'renderer') -}) - -// start crashReporter to save core dumps to temporary folder -crashReporter.start({ - companyName: 'marktext', - productName: 'marktext', - submitURL: 'http://0.0.0.0/', - uploadToServer: false -}) +const getOSInformation = () => { + return `${os.type()} ${os.arch()} ${os.release()} (${os.platform()})` +} const bundleException = (error, type) => { const { message, stack } = error return { - version: app.getVersion(), + version: global.MARKTEXT_VERSION_STRING || app.getVersion(), + os: getOSInformation(), type, date: new Date().toGMTString(), message, @@ -47,10 +37,11 @@ const bundleException = (error, type) => { const handleError = (title, error, type) => { const { message, stack } = error - // log error - const info = bundleException(error, type) - console.error(info) - log(JSON.stringify(info, null, 2)) + // Write error into file + if (type === 'main') { + const info = bundleException(error, type) + logger(JSON.stringify(info, null, 2)) + } if (EXIT_ON_ERROR) { console.log('Mark Text was terminated due to an unexpected error (MARKTEXT_EXIT_ON_ERROR variable was set)!') @@ -98,7 +89,7 @@ ${title}. ### Version Mark Text: ${global.MARKTEXT_VERSION_STRING} -Operating system: ${process.platform}`) +Operating system: ${getOSInformation()}`) break } } @@ -108,3 +99,30 @@ Operating system: ${process.platform}`) process.exit(1) } } + +const setupExceptionHandler = () => { + // main process error handler + process.on('uncaughtException', error => { + handleError(ERROR_MSG_MAIN, error, 'main') + }) + + // renderer process error handler + ipcMain.on('AGANI::handle-renderer-error', (e, error) => { + handleError(ERROR_MSG_RENDERER, error, 'renderer') + }) + + // start crashReporter to save core dumps to temporary folder + crashReporter.start({ + companyName: 'marktext', + productName: 'marktext', + submitURL: 'http://0.0.0.0/', + uploadToServer: false + }) +} + +export const initExceptionLogger = () => { + // replace placeholder logger + logger = log.error +} + +export default setupExceptionHandler diff --git a/src/main/filesystem/index.js b/src/main/filesystem/index.js new file mode 100644 index 00000000..5f24d1aa --- /dev/null +++ b/src/main/filesystem/index.js @@ -0,0 +1,114 @@ +import fs from 'fs-extra' +import path from 'path' +import { hasMarkdownExtension } from '../utils' + +/** + * Ensure that a directory exist. + * + * @param {string} dirPath The directory path. + */ +export const ensureDirSync = dirPath => { + try { + fs.ensureDirSync(dirPath) + } catch (e) { + if (e.code !== 'EEXIST') { + throw e + } + } +} + +/** + * Returns true if the path is a directory with read access. + * + * @param {string} dirPath The directory path. + */ +export const isDirectory = dirPath => { + try { + return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory() + } catch (_) { + return false + } +} + +/** + * Returns true if the path is a file with read access. + * + * @param {string} filepath The file path. + */ +export const isFile = filepath => { + try { + return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile() + } catch (_) { + return false + } +} + +/** + * Returns true if the path is a symbolic link with read access. + * + * @param {string} filepath The link path. + */ +export const isSymbolicLink = filepath => { + try { + return fs.existsSync(filepath) && fs.lstatSync(filepath).isSymbolicLink() + } catch (_) { + return false + } +} + +/** + * Returns true if the path is a markdown file. + * + * @param {string} filepath The path or link path. + */ +export const isMarkdownFile = filepath => { + return isFile(filepath) && hasMarkdownExtension(filepath) +} + +/** + * Returns true if the path is a markdown file or symbolic link to a markdown file. + * + * @param {string} filepath The path or link path. + */ +export const isMarkdownFileOrLink = filepath => { + if (!isFile(filepath)) return false + if (hasMarkdownExtension(filepath)) { + return true + } + + // Symbolic link to a markdown file + if (isSymbolicLink(filepath)) { + const targetPath = fs.readlinkSync(filepath) + return isFile(targetPath) && hasMarkdownExtension(targetPath) + } + return false +} + +/** + * Normalize the path into an absolute path and resolves the link target if needed. + * + * @param {string} pathname The path or link path. + * @returns {string} Returns the absolute path and resolved link. If the link target + * cannot be resolved, an empty string is returned. + */ +export const normalizeAndResolvePath = pathname => { + if (isSymbolicLink(pathname)) { + const absPath = path.dirname(pathname) + const targetPath = path.resolve(absPath, fs.readlinkSync(pathname)) + if (isFile(targetPath) || isDirectory(targetPath)) { + return path.resolve(targetPath) + } + console.error(`Cannot resolve link target "${pathname}" (${targetPath}).`) + return '' + } + return path.resolve(pathname) +} + +export const writeFile = (pathname, content, extension) => { + if (!pathname) { + return Promise.reject('[ERROR] Cannot save file without path.') + } + pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` + + return fs.outputFile(pathname, content, 'utf-8') +} diff --git a/src/main/utils/filesystem.js b/src/main/filesystem/markdown.js similarity index 53% rename from src/main/utils/filesystem.js rename to src/main/filesystem/markdown.js index d0569ee0..46eaf8ed 100644 --- a/src/main/utils/filesystem.js +++ b/src/main/filesystem/markdown.js @@ -1,20 +1,8 @@ -import fse from 'fs-extra' +import fs from 'fs-extra' import path from 'path' -import { LINE_ENDING_REG, LF_LINE_ENDING_REG, CRLF_LINE_ENDING_REG, isWindows } from '../config' -import userPreference from '../preference' - -export const getOsLineEndingName = () => { - const { endOfLine } = userPreference.getAll() - if (endOfLine === 'lf') { - return 'lf' - } - return endOfLine === 'crlf' || isWindows ? 'crlf' : 'lf' -} - -export const getDefaultTextDirection = () => { - const { textDirection } = userPreference.getAll() - return textDirection -} +import log from 'electron-log' +import { LINE_ENDING_REG, LF_LINE_ENDING_REG, CRLF_LINE_ENDING_REG } from '../config' +import { writeFile } from '../filesystem' const getLineEnding = lineEnding => { if (lineEnding === 'lf') { @@ -22,27 +10,28 @@ const getLineEnding = lineEnding => { } else if (lineEnding === 'crlf') { return '\r\n' } - return getOsLineEndingName() === 'crlf' ? '\r\n' : '\n' + + // This should not happend but use fallback value. + log.error(`Invalid end of line character: expected "lf" or "crlf" but got "${lineEnding}".`) + return '\n' } const convertLineEndings = (text, lineEnding) => { return text.replace(LINE_ENDING_REG, getLineEnding(lineEnding)) } -export const writeFile = (pathname, content, extension) => { - if (!pathname) { - return Promise.reject('[ERROR] Cannot save file without path.') - } - pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` - - return fse.outputFile(pathname, content, 'utf-8') -} - -export const writeMarkdownFile = (pathname, content, options, win) => { - const { adjustLineEndingOnSave, isUtf8BomEncoded, lineEnding } = options +/** + * Write the content into a file. + * + * @param {string} pathname The path to the file. + * @param {string} content The buffer to save. + * @param {IMarkdownDocumentOptions} options The markdown document options + */ +export const writeMarkdownFile = (pathname, content, options) => { + const { adjustLineEndingOnSave, encoding, lineEnding } = options const extension = path.extname(pathname) || '.md' - if (isUtf8BomEncoded) { + if (encoding === 'utf8bom') { content = '\uFEFF' + content } @@ -50,29 +39,35 @@ export const writeMarkdownFile = (pathname, content, options, win) => { content = convertLineEndings(content, lineEnding) } + // TODO(@fxha): "safeSaveDocuments" using temporary file and rename syscall. return writeFile(pathname, content, extension) } /** * Reads the contents of a markdown file. * - * @param {String} The path to the markdown file. + * @param {string} pathname The path to the markdown file. + * @param {string} preferedEOL The prefered EOL. * @returns {IMarkdownDocumentRaw} Returns a raw markdown document. */ -export const loadMarkdownFile = async pathname => { - let markdown = await fse.readFile(path.resolve(pathname), 'utf-8') +export const loadMarkdownFile = async (pathname, preferedEOL) => { + let markdown = await fs.readFile(path.resolve(pathname), 'utf-8') + // Check UTF-8 BOM (EF BB BF) encoding const isUtf8BomEncoded = markdown.length >= 1 && markdown.charCodeAt(0) === 0xFEFF if (isUtf8BomEncoded) { - markdown = markdown.slice(1) + markdown.splice(0, 1) } + // TODO(@fxha): Check for more file encodings and whether the file is binary but for now expect UTF-8. + const encoding = isUtf8BomEncoded ? 'utf8bom' : 'utf8' + // Detect line ending const isLf = LF_LINE_ENDING_REG.test(markdown) const isCrlf = CRLF_LINE_ENDING_REG.test(markdown) const isMixedLineEndings = isLf && isCrlf const isUnknownEnding = !isLf && !isCrlf - let lineEnding = getOsLineEndingName() + let lineEnding = preferedEOL if (isLf && !isCrlf) { lineEnding = 'lf' } else if (isCrlf && !isLf) { @@ -87,10 +82,6 @@ export const loadMarkdownFile = async pathname => { } const filename = path.basename(pathname) - - // TODO(refactor:renderer/editor): Remove this entry! This should be loaded separately if needed. - const textDirection = getDefaultTextDirection() - return { // document information markdown, @@ -98,14 +89,11 @@ export const loadMarkdownFile = async pathname => { pathname, // options - isUtf8BomEncoded, + encoding, lineEnding, adjustLineEndingOnSave, // raw file information - isMixedLineEndings, - - // TODO(refactor:renderer/editor): see above - textDirection + isMixedLineEndings } } diff --git a/src/main/watcher.js b/src/main/filesystem/watcher.js similarity index 78% rename from src/main/watcher.js rename to src/main/filesystem/watcher.js index 745c8324..c4259534 100644 --- a/src/main/watcher.js +++ b/src/main/filesystem/watcher.js @@ -1,17 +1,22 @@ import path from 'path' import fs from 'fs' +import log from 'electron-log' import { promisify } from 'util' import chokidar from 'chokidar' -import { getUniqueId, log, hasMarkdownExtension } from './utils' -import { loadMarkdownFile } from './utils/filesystem' -import { isLinux } from './config' +import { getUniqueId, hasMarkdownExtension } from '../utils' +import { loadMarkdownFile } from '../filesystem/markdown' +import { isLinux } from '../config' + +// TODO(need::refactor): +// - Refactor this file +// - Outsource watcher/search features into worker (per window) and use something like "file-matcher" for searching on disk. const EVENT_NAME = { dir: 'AGANI::update-object-tree', file: 'AGANI::update-file' } -const add = async (win, pathname) => { +const add = async (win, pathname, endOfLine) => { const stats = await promisify(fs.stat)(pathname) const birthTime = stats.birthtime const isMarkdown = hasMarkdownExtension(pathname) @@ -24,7 +29,7 @@ const add = async (win, pathname) => { isMarkdown } if (isMarkdown) { - const data = await loadMarkdownFile(pathname) + const data = await loadMarkdownFile(pathname, endOfLine) file.data = data } @@ -42,11 +47,11 @@ const unlink = (win, pathname, type) => { }) } -const change = async (win, pathname, type) => { +const change = async (win, pathname, type, endOfLine) => { const isMarkdown = hasMarkdownExtension(pathname) if (isMarkdown) { - const data = await loadMarkdownFile(pathname) + const data = await loadMarkdownFile(pathname, endOfLine) const file = { pathname, data @@ -85,9 +90,15 @@ const unlinkDir = (win, pathname) => { } class Watcher { - constructor () { + + /** + * @param {Preference} preferences The preference instance. + */ + constructor (preferences) { + this._preferences = preferences this.watchers = {} } + // return a unwatch function watch (win, watchPath, type = 'dir'/* file or dir */) { const id = getUniqueId() @@ -98,13 +109,13 @@ class Watcher { }) watcher - .on('add', pathname => add(win, pathname)) - .on('change', pathname => change(win, pathname, type)) + .on('add', pathname => add(win, pathname, this._preferences.getPreferedEOL())) + .on('change', pathname => change(win, pathname, type, this._preferences.getPreferedEOL())) .on('unlink', pathname => unlink(win, pathname, type)) .on('addDir', pathname => addDir(win, pathname)) .on('unlinkDir', pathname => unlinkDir(win, pathname)) .on('raw', (event, path, details) => { - if (global.MARKTEXT_DEBUG_VERBOSE) { + if (global.MARKTEXT_DEBUG_VERBOSE >= 3) { console.log(event, path, details) } @@ -119,7 +130,7 @@ class Watcher { .on('error', error => { const msg = `Watcher error: ${error}` console.log(msg) - log(msg) + log.error(msg) }) this.watchers[id] = { @@ -173,6 +184,7 @@ class Watcher { clear () { Object.keys(this.watchers).forEach(id => this.watchers[id].watcher.close()) + this.watchers = {} } } diff --git a/src/main/globalSetting.js b/src/main/globalSetting.js index dd9fcf6f..0184b5f1 100644 --- a/src/main/globalSetting.js +++ b/src/main/globalSetting.js @@ -1,8 +1,6 @@ -// Set `__static` path to static files in production -if (process.env.NODE_ENV !== 'development') { - global.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\') -} +import path from 'path' -global.MARKTEXT_DEBUG = process.env.MARKTEXT_DEBUG || process.env.NODE_ENV !== 'production' -global.MARKTEXT_DEBUG_VERBOSE = global.MARKTEXT_DEBUG && process.env.MARKTEXT_DEBUG_VERBOSE -global.MARKTEXT_SAFE_MODE = false +// Set `__static` path to static files in production. +if (process.env.NODE_ENV !== 'development') { + global.__static = path.join(__dirname, '/static').replace(/\\/g, '\\\\') +} diff --git a/src/main/index.dev.js b/src/main/index.dev.js index 10215637..ff08a5be 100644 --- a/src/main/index.dev.js +++ b/src/main/index.dev.js @@ -22,5 +22,7 @@ require('electron').app.on('ready', () => { }) }) +/* eslint-enable */ + // Require `main` process to boot app require('./index') diff --git a/src/main/index.js b/src/main/index.js index cef609e2..d69a4a23 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,17 +1,44 @@ import './globalSetting' -import './cli' -import './exceptionHandler' -import { checkSystem } from './utils/checkSystem' +import path from 'path' +import cli from './cli' +import setupExceptionHandler, { initExceptionLogger } from './exceptionHandler' +import log from 'electron-log' import App from './app' +import Accessor from './app/accessor' +import setupEnvironment from './app/env' +import { getLogLevel } from './utils' + +const initializeLogger = appEnvironment => { + log.transports.console.level = process.env.NODE_ENV === 'development' + log.transports.rendererConsole = null + log.transports.file.file = path.join(appEnvironment.paths.logPath, 'main.log') + log.transports.file.level = getLogLevel() + log.transports.file.sync = false + log.transports.file.init() + initExceptionLogger() +} + +// ----------------------------------------------- // NOTE: We only support Linux, macOS and Windows but not BSD nor SunOS. if (!/^(darwin|win32|linux)$/i.test(process.platform)) { - console.error(`Operating system "${process.platform}" is not supported! Please open an issue at "https://github.com/marktext/marktext".`) + process.stdout.write(`Operating system "${process.platform}" is not supported! Please open an issue at "https://github.com/marktext/marktext".\n`) process.exit(1) } -checkSystem() +setupExceptionHandler() -const app = new App() +const args = cli() +const appEnvironment = setupEnvironment(args) +initializeLogger(appEnvironment) +// Mark Text environment is configured successfully. You can now access paths, use the logger etc. +// Create other instances that need access to the modules from above. +const accessor = new Accessor(appEnvironment) + +// ----------------------------------------------- +// Be careful when changing code before this line! +// NOTE: Do not create classes or other code before this line! + +const app = new App(accessor, args) app.init() diff --git a/src/main/keyboardUtils.js b/src/main/keyboard/index.js similarity index 63% rename from src/main/keyboardUtils.js rename to src/main/keyboard/index.js index 976dc3c1..0cb70c33 100644 --- a/src/main/keyboardUtils.js +++ b/src/main/keyboard/index.js @@ -1,3 +1,4 @@ +// import EventEmitter from 'events' import { getCurrentKeyboardLayout, getCurrentKeyboardLanguage, getCurrentKeymap } from 'keyboard-layout' export const getKeyboardLanguage = () => { @@ -32,3 +33,24 @@ export const getVirtualLetters = () => { } return vkeys } + +// class KeyboardLayoutMonitor { +// +// constructor() { +// this._eventEmitter = new EventEmitter() +// this._subscription = null +// } +// +// onDidChangeCurrentKeyboardLayout (callback) { +// if (!this._subscription) { +// this._subscription = onDidChangeCurrentKeyboardLayout(layout => { +// this._eventEmitter.emit('onDidChangeCurrentKeyboardLayout', layout) +// }) +// } +// this._eventEmitter.on('onDidChangeCurrentKeyboardLayout', callback) +// } +// +// } +// +// // TODO(@fxha): Reload ShortcutHandler on change +// export const keyboardLayoutMonitor = new KeyboardLayoutMonitor() diff --git a/src/main/shortcutHandler.js b/src/main/keyboard/shortcutHandler.js similarity index 78% rename from src/main/shortcutHandler.js rename to src/main/keyboard/shortcutHandler.js index 8829485a..77237a61 100644 --- a/src/main/shortcutHandler.js +++ b/src/main/keyboard/shortcutHandler.js @@ -1,10 +1,12 @@ import { Menu } from 'electron' +import fs from 'fs-extra' import path from 'path' +import log from 'electron-log' import isAccelerator from 'electron-is-accelerator' import electronLocalshortcut from '@hfelix/electron-localshortcut' -import { isOsx } from './config' -import { getKeyboardLanguage, getVirtualLetters } from './keyboardUtils' -import { isFile, getPath, log, readJson } from './utils' +import { isOsx } from '../config' +import { getKeyboardLanguage, getVirtualLetters } from '../keyboard' +import { isFile } from '../filesystem' // Problematic key bindings: // Aidou: Ctrl+/ -> dead key @@ -12,8 +14,12 @@ import { isFile, getPath, log, readJson } from './utils' // Upgrade Heading: Ctrl+= -> points to Ctrl+Plus which is ok; Ctrl+Plus is broken class Keybindings { - constructor () { - this.configPath = path.join(getPath('userData'), 'keybindings.json') + + /** + * @param {string} userDataPath The user data path. + */ + constructor (userDataPath) { + this.configPath = path.join(userDataPath, 'keybindings.json') this.keys = new Map([ // marktext - macOS only @@ -101,13 +107,50 @@ class Keybindings { // fix non-US keyboards this.mnemonics = new Map() - this.fixLayout() + this._fixLayout() // load user-defined keybindings - this.loadLocalKeybindings() + this._loadLocalKeybindings() } - fixLayout () { + getAccelerator (id) { + const name = this.keys.get(id) + if (!name) { + return '' + } + return name + } + + registerKeyHandlers (win, acceleratorMap) { + for (const item of acceleratorMap) { + let { accelerator } = item + + // Fix broken shortcuts because of dead keys or non-US keyboard problems. We bind the + // shortcut to another accelerator because of key mapping issues. E.g: 'Alt+/' is not + // available on a German keyboard, because you have to press 'Shift+7' to produce '/'. + // In this case we can remap the accelerator to 'Alt+7' or 'Ctrl+Shift+7'. + const acceleratorFix = this.mnemonics.get(accelerator) + if (acceleratorFix) { + accelerator = acceleratorFix + } + + // Regisiter shortcuts on the BrowserWindow instead of using Chromium's native menu. + // This makes it possible to receive key down events before Chromium/Electron and we + // can handle reserved Chromium shortcuts. Afterwards prevent the default action of + // the event so the native menu is not triggered. + electronLocalshortcut.register(win, accelerator, () => { + if (global.MARKTEXT_DEBUG && process.env.MARKTEXT_DEBUG_KEYBOARD) { + console.log(`You pressed ${accelerator}`) + } + callMenuCallback(item, win) + return true // prevent default action + }) + } + } + + // --- private -------------------------------- + + _fixLayout () { // fix wrong virtual key mapping on non-QWERTY layouts electronLocalshortcut.updateVirtualKeys(getVirtualLetters()) @@ -121,10 +164,10 @@ class Keybindings { case 'fi': case 'no': case 'se': - this.fixInlineCode() + this._fixInlineCode() if (!isOsx) { - this.fixAidou() + this._fixAidou() } break @@ -136,7 +179,7 @@ class Keybindings { case 'pl': case 'pt': if (!isOsx) { - this.fixAidou() + this._fixAidou() } break @@ -144,29 +187,29 @@ class Keybindings { case 'bg': if (!isOsx) { this.mnemonics.set('CmdOrCtrl+/', 'CmdOrCtrl+8') - this.fixInlineCode() + this._fixInlineCode() } break } } - fixAidou () { + _fixAidou () { this.mnemonics.set('CmdOrCtrl+/', 'CmdOrCtrl+7') } // fix dead backquote key on layouts like German - fixInlineCode () { + _fixInlineCode () { this.keys.set('formatInlineCode', 'CmdOrCtrl+Shift+B') } - loadLocalKeybindings () { + _loadLocalKeybindings () { if (global.MARKTEXT_SAFE_MODE || !isFile(this.configPath)) { return } - const json = readJson(this.configPath, true) + const json = fs.readJsonSync(this.configPath, { throws: false }) if (!json || typeof json !== 'object') { - log('Invalid keybindings.json configuration.') + log.warn('Invalid keybindings.json configuration.') return } @@ -205,10 +248,10 @@ class Keybindings { // check for duplicate shortcuts for (const [userKey, userValue] of userAccelerators) { for (const [key, value] of accelerators) { - if (this.isEqualAccelerator(value, userValue)) { + if (this._isEqualAccelerator(value, userValue)) { const err = `Invalid keybindings.json configuration: Duplicate key ${userKey} - ${key}` console.log(err) - log(err) + log.error(err) return } } @@ -219,15 +262,7 @@ class Keybindings { this.keys = accelerators } - getAccelerator (id) { - const name = this.keys.get(id) - if (!name) { - return '' - } - return name - } - - isEqualAccelerator (a, b) { + _isEqualAccelerator (a, b) { a = a.toLowerCase().replace('cmdorctrl', 'ctrl').replace('command', 'ctrl') b = b.toLowerCase().replace('cmdorctrl', 'ctrl').replace('command', 'ctrl') const i1 = a.indexOf('+') @@ -245,8 +280,6 @@ class Keybindings { } } -const keybindings = new Keybindings() - export const parseMenu = menuTemplate => { const { submenu, accelerator, click, id, visible } = menuTemplate let items = [] @@ -268,33 +301,6 @@ export const parseMenu = menuTemplate => { return items.length === 0 ? null : items } -export const registerKeyHandler = (win, acceleratorMap) => { - for (const item of acceleratorMap) { - let { accelerator } = item - - // Fix broken shortcuts because of dead keys or non-US keyboard problems. We bind the - // shortcut to another accelerator because of key mapping issues. E.g: 'Alt+/' is not - // available on a German keyboard, because you have to press 'Shift+7' to produce '/'. - // In this case we can remap the accelerator to 'Alt+7' or 'Ctrl+Shift+7'. - const acceleratorFix = keybindings.mnemonics.get(accelerator) - if (acceleratorFix) { - accelerator = acceleratorFix - } - - // Regisiter shortcuts on the BrowserWindow instead of using Chromium's native menu. - // This makes it possible to receive key down events before Chromium/Electron and we - // can handle reserved Chromium shortcuts. Afterwards prevent the default action of - // the event so the native menu is not triggered. - electronLocalshortcut.register(win, accelerator, () => { - if (global.MARKTEXT_DEBUG && process.env.MARKTEXT_DEBUG_KEYBOARD) { - console.log(`You pressed ${accelerator}`) - } - callMenuCallback(item, win) - return true // prevent default action - }) - } -} - const callMenuCallback = (menuInfo, win) => { const { click, id } = menuInfo if (click) { @@ -312,4 +318,4 @@ const callMenuCallback = (menuInfo, win) => { } } -export default keybindings +export default Keybindings diff --git a/src/main/actions/edit.js b/src/main/menu/actions/edit.js similarity index 74% rename from src/main/actions/edit.js rename to src/main/menu/actions/edit.js index c76c7c8e..a1f4d196 100644 --- a/src/main/actions/edit.js +++ b/src/main/menu/actions/edit.js @@ -1,11 +1,12 @@ import path from 'path' import { dialog, ipcMain, BrowserWindow } from 'electron' -import { IMAGE_EXTENSIONS } from '../config' -import { searchFilesAndDir } from '../utils/imagePathAutoComplement' -import appMenu from '../menu' -import { log } from '../utils' +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: [{ @@ -25,7 +26,7 @@ ipcMain.on('AGANI::ask-for-insert-image', (e, type) => { ipcMain.on('AGANI::ask-for-image-auto-path', (e, { pathname, src }) => { const win = BrowserWindow.fromWebContents(e.sender) - if (src.endsWith('/') || src.endsWith('.')) { + if (src.endsWith('/') || src.endsWith('\\') || src.endsWith('.')) { return win.webContents.send('AGANI::image-auto-path', []) } const fullPath = path.isAbsolute(src) ? src : path.join(path.dirname(pathname), src) @@ -35,15 +36,11 @@ ipcMain.on('AGANI::ask-for-image-auto-path', (e, { pathname, src }) => { .then(files => { win.webContents.send('AGANI::image-auto-path', files) }) - .catch(log) + .catch(log.error) }) ipcMain.on('AGANI::update-line-ending-menu', (e, lineEnding) => { - appMenu.updateLineEndingnMenu(lineEnding) -}) - -ipcMain.on('AGANI::update-text-direction-menu', (e, textDirection) => { - appMenu.updateTextDirectionMenu(textDirection) + updateLineEndingMenu(lineEnding) }) export const edit = (win, type) => { @@ -61,7 +58,3 @@ export const insertImage = (win, type) => { win.webContents.send('AGANI::INSERT_IMAGE', { type }) } } - -export const textDirection = (win, textDirection) => { - win.webContents.send('AGANI::set-text-direction', { textDirection }) -} diff --git a/src/main/actions/file.js b/src/main/menu/actions/file.js similarity index 75% rename from src/main/actions/file.js rename to src/main/menu/actions/file.js index 9d06895b..1286bdfa 100644 --- a/src/main/actions/file.js +++ b/src/main/menu/actions/file.js @@ -1,26 +1,30 @@ import fs from 'fs' -// import chokidar from 'chokidar' import path from 'path' import { promisify } from 'util' import { BrowserWindow, dialog, ipcMain, shell } from 'electron' -import appWindow from '../window' -import { EXTENSION_HASN, EXTENSIONS, PANDOC_EXTENSIONS, URL_REG } from '../config' -import { writeFile, writeMarkdownFile } from '../utils/filesystem' -import appMenu from '../menu' -import { getPath, isMarkdownFile, isMarkdownFileOrLink, normalizeAndResolvePath, log, isFile, isDirectory, getRecommendTitle } from '../utils' -import userPreference from '../preference' -import pandoc from '../utils/pandoc' +import log from 'electron-log' +import { EXTENSION_HASN, EXTENSIONS, PANDOC_EXTENSIONS, URL_REG } from '../../config' +import { isDirectory, isFile, isMarkdownFile, isMarkdownFileOrLink, normalizeAndResolvePath, writeFile } from '../../filesystem' +import { writeMarkdownFile } from '../../filesystem/markdown' +import { getPath, getRecommendTitleFromMarkdownString } from '../../utils' +import pandoc from '../../utils/pandoc' -// handle the response from render process. +// TODO: +// - use async dialog version to not block the main process. +// - catch "fs." exceptions. Otherwise the main process crashes... + +// Handle the export response from renderer process. const handleResponseForExport = async (e, { type, content, pathname, markdown }) => { const win = BrowserWindow.fromWebContents(e.sender) const extension = EXTENSION_HASN[type] const dirname = pathname ? path.dirname(pathname) : getPath('documents') - let nakedFilename = getRecommendTitle(markdown) + let nakedFilename = getRecommendTitleFromMarkdownString(markdown) if (!nakedFilename) { nakedFilename = pathname ? path.basename(pathname, '.md') : 'Untitled' } - const defaultPath = `${dirname}/${nakedFilename}${extension}` + const defaultPath = path.join(dirname, `${nakedFilename}${extension}`) + + // TODO(need::refactor): use async dialog version const filePath = dialog.showSaveDialog(win, { defaultPath }) @@ -37,7 +41,7 @@ const handleResponseForExport = async (e, { type, content, pathname, markdown }) win.webContents.send('AGANI::export-success', { type, filePath }) } } catch (err) { - log(err) + log.error(err) const ERROR_MSG = err.message || `Error happened when export ${filePath}` win.webContents.send('AGANI::show-notification', { title: 'Export File Error', @@ -71,27 +75,29 @@ const handleResponseForPrint = e => { const handleResponseForSave = (e, { id, markdown, pathname, options }) => { const win = BrowserWindow.fromWebContents(e.sender) - let recommendFilename = getRecommendTitle(markdown) + let recommendFilename = getRecommendTitleFromMarkdownString(markdown) if (!recommendFilename) { recommendFilename = 'Untitled' } // If the file doesn't exist on disk add it to the recently used documents later. const alreadyExistOnDisk = !!pathname + + // TODO(need::refactor): use async dialog version pathname = pathname || dialog.showSaveDialog(win, { - defaultPath: getPath('documents') + `/${recommendFilename}.md` + defaultPath: path.join(getPath('documents'), `${recommendFilename}.md`) }) if (pathname && typeof pathname === 'string') { if (!alreadyExistOnDisk) { - appMenu.addRecentlyUsedDocument(pathname) + ipcMain.emit('menu-clear-recently-used') } return writeMarkdownFile(pathname, markdown, options, win) .then(() => { if (!alreadyExistOnDisk) { // it's a new created file, need watch - appWindow.watcher.watch(win, pathname, 'file') + ipcMain.emit('watcher-watch-file', win, pathname) } const filename = path.basename(pathname) win.webContents.send('AGANI::set-pathname', { id, pathname, filename }) @@ -126,6 +132,7 @@ const showUnsavedFilesMessage = (win, files) => { }) }) } + const noticePandocNotFound = win => { return win.webContents.send('AGANI::pandoc-not-exists', { title: 'Import Warning', @@ -135,14 +142,13 @@ const noticePandocNotFound = win => { }) } -const pandocFile = async pathname => { +const openPandocFile = async (windowId, pathname) => { try { const converter = pandoc(pathname, 'markdown') const data = await converter() - // TODO: allow to open data also in a new tab instead window. - appWindow.createWindow(undefined, data) + ipcMain.emit('app-open-markdown-by-id', windowId, data) } catch (err) { - log(err) + log.error(err) } } @@ -151,70 +157,75 @@ const removePrintServiceFromWindow = win => { win.webContents.send('AGANI::print-service-clearup') } -ipcMain.on('AGANI::save-all', (e, unSavedFiles) => { - Promise.all(unSavedFiles.map(file => handleResponseForSave(e, file))) - .catch(log) +// --- events ----------------------------------- + +ipcMain.on('AGANI::save-all', (e, unsavedFiles) => { + Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file))) + .catch(log.error) }) -ipcMain.on('AGANI::save-close', async (e, unSavedFiles, isSingle) => { +ipcMain.on('AGANI::save-close', async (e, unsavedFiles, isSingle) => { const win = BrowserWindow.fromWebContents(e.sender) - const { needSave } = await showUnsavedFilesMessage(win, unSavedFiles) + const { needSave } = await showUnsavedFilesMessage(win, unsavedFiles) const EVENT = isSingle ? 'AGANI::save-single-response' : 'AGANI::save-all-response' if (needSave) { - Promise.all(unSavedFiles.map(file => handleResponseForSave(e, file))) + Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file))) .then(arr => { const data = arr.filter(id => id) win.send(EVENT, { err: null, data }) }) .catch(err => { win.send(EVENT, { err, data: null }) - log(err) + log.error(err.error) }) } else { - const data = unSavedFiles.map(f => f.id) + const data = unsavedFiles.map(f => f.id) win.send(EVENT, { err: null, data }) } }) ipcMain.on('AGANI::response-file-save-as', (e, { id, markdown, pathname, options }) => { const win = BrowserWindow.fromWebContents(e.sender) - let recommendFilename = getRecommendTitle(markdown) + let recommendFilename = getRecommendTitleFromMarkdownString(markdown) if (!recommendFilename) { recommendFilename = 'Untitled' } + + // TODO(need::refactor): use async dialog version const filePath = dialog.showSaveDialog(win, { defaultPath: pathname || getPath('documents') + `/${recommendFilename}.md` }) + if (filePath) { writeMarkdownFile(filePath, markdown, options, win) .then(() => { // need watch file after `save as` if (pathname !== filePath) { - appWindow.watcher.watch(win, filePath, 'file') - // unWatch the old file. - appWindow.watcher.unWatch(win, pathname, 'file') + // unwatch the old file + ipcMain.emit('watcher-unwatch-file', win, pathname) + ipcMain.emit('watcher-watch-file', win, filePath) } const filename = path.basename(filePath) win.webContents.send('AGANI::set-pathname', { id, pathname: filePath, filename }) }) - .catch(log) + .catch(log.error) } }) -ipcMain.on('AGANI::response-close-confirm', async (e, unSavedFiles) => { +ipcMain.on('AGANI::response-close-confirm', async (e, unsavedFiles) => { const win = BrowserWindow.fromWebContents(e.sender) - const { needSave } = await showUnsavedFilesMessage(win, unSavedFiles) + const { needSave } = await showUnsavedFilesMessage(win, unsavedFiles) if (needSave) { - Promise.all(unSavedFiles.map(file => handleResponseForSave(e, file))) + Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file))) .then(() => { - appWindow.forceClose(win) + ipcMain.emit('window-close-by-id', win.id) }) .catch(err => { console.log(err) - log(err) + log.error(err) }) } else { - appWindow.forceClose(win) + ipcMain.emit('window-close-by-id', win.id) } }) @@ -224,11 +235,6 @@ ipcMain.on('AGANI::response-export', handleResponseForExport) ipcMain.on('AGANI::response-print', handleResponseForPrint) -ipcMain.on('AGANI::close-window', e => { - const win = BrowserWindow.fromWebContents(e.sender) - appWindow.forceClose(win) -}) - ipcMain.on('AGANI::window::drop', async (e, fileList) => { const win = BrowserWindow.fromWebContents(e.sender) for (const file of fileList) { @@ -242,7 +248,7 @@ ipcMain.on('AGANI::window::drop', async (e, fileList) => { if (!existsPandoc) { noticePandocNotFound(win) } else { - pandocFile(file) + openPandocFile(win.id, file) } break } @@ -263,7 +269,7 @@ ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => { type: 'warning', buttons: ['Replace', 'Cancel'], defaultId: 1, - message: `The file ${path.basename(newPathname)} is already exists. Do you want to replace it?`, + message: `The file "${path.basename(newPathname)}" already exists. Do you want to replace it?`, cancelId: 1, noLink: true }, index => { @@ -281,6 +287,8 @@ ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => { ipcMain.on('AGANI::response-file-move-to', (e, { id, pathname }) => { const win = BrowserWindow.fromWebContents(e.sender) + + // TODO(need::refactor): use async dialog version let newPath = dialog.showSaveDialog(win, { buttonLabel: 'Move to', nameFieldLabel: 'Filename:', @@ -293,11 +301,13 @@ ipcMain.on('AGANI::response-file-move-to', (e, { id, pathname }) => { ipcMain.on('AGANI::ask-for-open-project-in-sidebar', e => { const win = BrowserWindow.fromWebContents(e.sender) + + // TODO(need::refactor): use async dialog version const pathname = dialog.showOpenDialog(win, { properties: ['openDirectory', 'createDirectory'] }) if (pathname && pathname[0]) { - appWindow.openFolder(win, pathname[0]) + ipcMain.emit('app-open-directory-by-id', win.id, pathname[0]) } }) @@ -318,6 +328,8 @@ ipcMain.on('AGANI::format-link-click', (e, { data, dirname }) => { } }) +// --- menu ------------------------------------- + export const exportFile = (win, type) => { win.webContents.send('AGANI::export', { type }) } @@ -328,6 +340,8 @@ export const importFile = async win => { if (!existsPandoc) { return noticePandocNotFound(win) } + + // TODO(need::refactor): use async dialog version const filename = dialog.showOpenDialog(win, { properties: [ 'openFile' ], filters: [{ @@ -337,7 +351,7 @@ export const importFile = async win => { }) if (filename && filename[0]) { - pandocFile(filename[0]) + openPandocFile(win.id, filename[0]) } } @@ -345,32 +359,8 @@ export const print = win => { win.webContents.send('AGANI::print') } -export const openFileOrFolder = (win, pathname) => { - const resolvedPath = normalizeAndResolvePath(pathname) - if (isFile(resolvedPath)) { - const { openFilesInNewWindow } = userPreference.getAll() - if (openFilesInNewWindow) { - appWindow.createWindow(resolvedPath) - } else { - appWindow.newTab(win, pathname) - } - } else if (isDirectory(resolvedPath)) { - appWindow.createWindow(resolvedPath) - } else { - console.error(`[ERROR] Cannot open unknown file: "${resolvedPath}"`) - } -} - -export const openFolder = win => { - const dirList = dialog.showOpenDialog(win, { - properties: ['openDirectory', 'createDirectory'] - }) - if (dirList && dirList[0]) { - openFileOrFolder(win, dirList[0]) - } -} - export const openFile = win => { + // TODO(need::refactor): use async dialog version const fileList = dialog.showOpenDialog(win, { properties: ['openFile'], filters: [{ @@ -383,18 +373,33 @@ export const openFile = win => { } } -export const newFile = () => { - appWindow.createWindow() +export const openFolder = win => { + // TODO(need::refactor): use async dialog version + const dirList = dialog.showOpenDialog(win, { + properties: ['openDirectory', 'createDirectory'] + }) + if (dirList && dirList[0]) { + openFileOrFolder(win, dirList[0]) + } } -/** - * Creates a new tab. - * - * @param {BrowserWindow} win Browser window - * @param {IMarkdownDocumentRaw} [rawDocument] Optional markdown document. If null a blank tab is created. - */ -export const newTab = (win, rawDocument = null) => { - win.webContents.send('AGANI::new-tab', rawDocument) +export const openFileOrFolder = (win, pathname) => { + const resolvedPath = normalizeAndResolvePath(pathname) + if (isFile(resolvedPath)) { + ipcMain.emit('app-open-file-by-id', win.id, resolvedPath) + } else if (isDirectory(resolvedPath)) { + ipcMain.emit('app-open-directory-by-id', win.id, resolvedPath) + } else { + console.error(`[ERROR] Cannot open unknown file: "${resolvedPath}"`) + } +} + +export const newBlankTab = win => { + win.webContents.send('mt::new-untitled-tab') +} + +export const newEditorWindow = () => { + ipcMain.emit('app-create-editor-window') } export const closeTab = win => { @@ -411,13 +416,7 @@ export const saveAs = win => { export const autoSave = (menuItem, browserWindow) => { const { checked } = menuItem - userPreference.setItem('autoSave', checked) - .then(() => { - for (const { win } of appWindow.windows.values()) { - win.webContents.send('AGANI::user-preference', { autoSave: checked }) - } - }) - .catch(log) + ipcMain.emit('mt::set-user-preference', { autoSave: checked }) } export const moveTo = win => { @@ -429,5 +428,5 @@ export const rename = win => { } export const clearRecentlyUsed = () => { - appMenu.clearRecentlyUsedDocuments() + ipcMain.emit('menu-clear-recently-used') } diff --git a/src/main/actions/format.js b/src/main/menu/actions/format.js similarity index 94% rename from src/main/actions/format.js rename to src/main/menu/actions/format.js index e0b59425..07d90c21 100644 --- a/src/main/actions/format.js +++ b/src/main/menu/actions/format.js @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import { getMenuItemById } from '../utils' +import { getMenuItemById } from '../../menu' const MENU_ID_FORMAT_MAP = { 'strongMenuItem': 'strong', diff --git a/src/main/actions/help.js b/src/main/menu/actions/help.js similarity index 100% rename from src/main/actions/help.js rename to src/main/menu/actions/help.js diff --git a/src/main/actions/marktext.js b/src/main/menu/actions/marktext.js similarity index 91% rename from src/main/actions/marktext.js rename to src/main/menu/actions/marktext.js index 2547176b..43ac4015 100644 --- a/src/main/actions/marktext.js +++ b/src/main/menu/actions/marktext.js @@ -1,7 +1,5 @@ import { autoUpdater } from 'electron-updater' import { ipcMain } from 'electron' -import appWindow from '../window' -import userPreference from '../preference' let updater let win @@ -47,7 +45,7 @@ autoUpdater.on('update-downloaded', () => { }) export const userSetting = (menuItem, browserWindow) => { - appWindow.createWindow(userPreference.userDataPath) + ipcMain.emit('app-create-settings-window') } export const checkUpdates = (menuItem, browserWindow) => { diff --git a/src/main/actions/paragraph.js b/src/main/menu/actions/paragraph.js similarity index 98% rename from src/main/actions/paragraph.js rename to src/main/menu/actions/paragraph.js index 15ad6ba5..10d59dc9 100644 --- a/src/main/actions/paragraph.js +++ b/src/main/menu/actions/paragraph.js @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import { getMenuItemById } from '../utils' +import { getMenuItemById } from '../../menu' const DISABLE_LABELS = [ // paragraph menu items diff --git a/src/main/menu/actions/theme.js b/src/main/menu/actions/theme.js new file mode 100644 index 00000000..cc36e4bd --- /dev/null +++ b/src/main/menu/actions/theme.js @@ -0,0 +1,5 @@ +import { ipcMain } from 'electron' + +export const selectTheme = theme => { + ipcMain.emit('mt::set-user-preference', undefined, { theme }) +} diff --git a/src/main/actions/view.js b/src/main/menu/actions/view.js similarity index 97% rename from src/main/actions/view.js rename to src/main/menu/actions/view.js index 868a6773..1261efbf 100644 --- a/src/main/actions/view.js +++ b/src/main/menu/actions/view.js @@ -1,5 +1,5 @@ import { ipcMain, BrowserWindow } from 'electron' -import { getMenuItemById } from '../utils' +import { getMenuItemById } from '../../menu' const sourceCodeModeMenuItemId = 'sourceCodeModeMenuItem' const typewriterModeMenuItemId = 'typewriterModeMenuItem' diff --git a/src/main/menu/actions/window.js b/src/main/menu/actions/window.js new file mode 100644 index 00000000..1dbf6531 --- /dev/null +++ b/src/main/menu/actions/window.js @@ -0,0 +1,7 @@ +import { ipcMain } from 'electron' + +export const toggleAlwaysOnTop = win => { + if (win) { + ipcMain.emit('window-toggle-always-on-top', win) + } +} diff --git a/src/main/menu.js b/src/main/menu/index.js similarity index 68% rename from src/main/menu.js rename to src/main/menu/index.js index c6bbdab8..0af57f2d 100644 --- a/src/main/menu.js +++ b/src/main/menu/index.js @@ -1,20 +1,33 @@ import fs from 'fs' import path from 'path' import { app, ipcMain, Menu } from 'electron' -import configureMenu from './menus' -import { isDirectory, isFile, ensureDir, getPath, log } from './utils' -import { parseMenu, registerKeyHandler } from './shortcutHandler' +import log from 'electron-log' +import { ensureDirSync, isDirectory, isFile } from '../filesystem' +import { parseMenu } from '../keyboard/shortcutHandler' +import configureMenu from '../menu/templates' class AppMenu { - constructor () { - const FILE_NAME = 'recently-used-documents.json' + /** + * @param {Preference} preferences The preferences instances. + * @param {Keybindings} keybindings The keybindings instances. + * @param {string} userDataPath The user data path. + */ + constructor (preferences, keybindings, userDataPath) { + const FILE_NAME = 'recently-used-documents.json' this.MAX_RECENTLY_USED_DOCUMENTS = 12 - this.RECENTS_PATH = path.join(getPath('userData'), FILE_NAME) + + this._preferences = preferences + this._keybindings = keybindings + this._userDataPath = userDataPath + + this.RECENTS_PATH = path.join(userDataPath, FILE_NAME) this.isOsxOrWindows = /darwin|win32/.test(process.platform) this.isOsx = process.platform === 'darwin' this.activeWindowId = -1 this.windowMenus = new Map() + + this._listenForIpcMain() } addRecentlyUsedDocument (filePath) { @@ -41,7 +54,7 @@ class AppMenu { this.updateAppMenu(recentDocuments) if (needSave) { - ensureDir(getPath('userData')) + ensureDirSync(this._userDataPath) const json = JSON.stringify(recentDocuments, null, 2) fs.writeFileSync(RECENTS_PATH, json, 'utf-8') } @@ -62,7 +75,7 @@ class AppMenu { } return recentDocuments } catch (err) { - log(err) + log.error(err) return [] } } @@ -75,11 +88,11 @@ class AppMenu { const recentDocuments = [] this.updateAppMenu(recentDocuments) const json = JSON.stringify(recentDocuments, null, 2) - ensureDir(getPath('userData')) + ensureDirSync(this._userDataPath) fs.writeFileSync(RECENTS_PATH, json, 'utf-8') } - addWindowMenuWithListener (window) { + addEditorMenu (window) { const { windowMenus } = this windowMenus.set(window.id, this.buildDefaultMenu(true)) @@ -100,11 +113,11 @@ class AppMenu { typewriterModeMenuItem.enabled = false focusModeMenuItem.enabled = false } - registerKeyHandler(window, shortcutMap) + this._keybindings.registerKeyHandlers(window, shortcutMap) } removeWindowMenu (windowId) { - // NOTE: Shortcut handler is automatically unregistered + // NOTE: Shortcut handler is automatically unregistered when window is closed. const { activeWindowId } = this this.windowMenus.delete(windowId) if (activeWindowId === windowId) { @@ -115,16 +128,18 @@ class AppMenu { getWindowMenuById (windowId) { const { menu } = this.windowMenus.get(windowId) if (!menu) { - log(`getWindowMenuById: Cannot find window menu for id ${windowId}.`) + log.error(`getWindowMenuById: Cannot find window menu for id ${windowId}.`) throw new Error(`Cannot find window menu for id ${windowId}.`) } return menu } setActiveWindow (windowId) { - // change application menu to the current window menu - Menu.setApplicationMenu(this.getWindowMenuById(windowId)) - this.activeWindowId = windowId + if (this.activeWindowId !== windowId) { + // Change application menu to the current window menu. + Menu.setApplicationMenu(this.getWindowMenuById(windowId)) + this.activeWindowId = windowId + } } buildDefaultMenu (createShortcutMap, recentUsedDocuments) { @@ -132,7 +147,7 @@ class AppMenu { recentUsedDocuments = this.getRecentlyUsedDocuments() } - const menuTemplate = configureMenu(recentUsedDocuments) + const menuTemplate = configureMenu(this._keybindings, this._preferences, recentUsedDocuments) const menu = Menu.buildFromTemplate(menuTemplate) let shortcutMap = null @@ -178,26 +193,24 @@ class AppMenu { }) } - updateLineEndingnMenu (lineEnding) { - const menus = Menu.getApplicationMenu() - const crlfMenu = menus.getMenuItemById('crlfLineEndingMenuEntry') - const lfMenu = menus.getMenuItemById('lfLineEndingMenuEntry') - if (lineEnding === 'crlf') { - crlfMenu.checked = true - } else { - lfMenu.checked = true - } + updateLineEndingMenu (lineEnding) { + updateLineEndingMenu(lineEnding) } - updateTextDirectionMenu (textDirection) { + updateAlwaysOnTopMenu (flag) { const menus = Menu.getApplicationMenu() - const ltrMenu = menus.getMenuItemById('textDirectionLTRMenuEntry') - const rtlMenu = menus.getMenuItemById('textDirectionRTLMenuEntry') - if (textDirection === 'ltr') { - ltrMenu.checked = true - } else { - rtlMenu.checked = true - } + const menu = menus.getMenuItemById('alwaysOnTopMenuItem') + menu.checked = flag + } + + _listenForIpcMain () { + ipcMain.on('mt::add-recently-used-document', (e, pathname) => { + this.addRecentlyUsedDocument(pathname) + }) + + ipcMain.on('menu-clear-recently-used', () => { + this.clearRecentlyUsedDocuments() + }) } } @@ -219,10 +232,31 @@ const updateMenuItemSafe = (oldMenus, newMenus, id, defaultValue) => { newItem.checked = checked } -const appMenu = new AppMenu() +// ---------------------------------------------- -ipcMain.on('AGANI::add-recently-used-document', (e, pathname) => { - appMenu.addRecentlyUsedDocument(pathname) -}) +// HACKY: We have one application menu per window and switch the menu when +// switching windows, so we can access and change the menu items via Electron. -export default appMenu +/** + * Return the menu from the application menu. + * + * @param {string} menuId Menu ID + * @returns {Electron.Menu} Returns the menu or null. + */ +export const getMenuItemById = menuId => { + const menus = Menu.getApplicationMenu() + return menus.getMenuItemById(menuId) +} + +export const updateLineEndingMenu = lineEnding => { + const menus = Menu.getApplicationMenu() + const crlfMenu = menus.getMenuItemById('crlfLineEndingMenuEntry') + const lfMenu = menus.getMenuItemById('lfLineEndingMenuEntry') + if (lineEnding === 'crlf') { + crlfMenu.checked = true + } else { + lfMenu.checked = true + } +} + +export default AppMenu diff --git a/src/main/menus/dock.js b/src/main/menu/templates/dock.js similarity index 100% rename from src/main/menus/dock.js rename to src/main/menu/templates/dock.js diff --git a/src/main/menu/templates/edit.js b/src/main/menu/templates/edit.js new file mode 100755 index 00000000..a74d3402 --- /dev/null +++ b/src/main/menu/templates/edit.js @@ -0,0 +1,156 @@ +import * as actions from '../actions/edit' + +export default function (keybindings, userPreference) { + const { aidou } = userPreference.getAll() + return { + label: 'Edit', + submenu: [{ + label: 'Undo', + accelerator: keybindings.getAccelerator('editUndo'), + click: (menuItem, browserWindow) => { + actions.edit(browserWindow, 'undo') + } + }, { + label: 'Redo', + accelerator: keybindings.getAccelerator('editRedo'), + click: (menuItem, browserWindow) => { + actions.edit(browserWindow, 'redo') + } + }, { + type: 'separator' + }, { + 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: 'Copy As Markdown', + accelerator: keybindings.getAccelerator('editCopyAsMarkdown'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'copyAsMarkdown') + } + }, { + label: 'Copy As HTML', + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'copyAsHtml') + } + }, { + label: 'Paste As Plain Text', + accelerator: keybindings.getAccelerator('editCopyAsPlaintext'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'pasteAsPlainText') + } + }, { + type: 'separator' + }, { + label: 'Select All', + accelerator: keybindings.getAccelerator('editSelectAll'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'selectAll') + } + }, { + type: 'separator' + }, { + label: 'Duplicate', + accelerator: keybindings.getAccelerator('editDuplicate'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'duplicate') + } + }, { + label: 'Create Paragraph', + accelerator: keybindings.getAccelerator('editCreateParagraph'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'createParagraph') + } + }, { + label: 'Delete Paragraph', + accelerator: keybindings.getAccelerator('editDeleteParagraph'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'deleteParagraph') + } + }, { + type: 'separator' + }, { + label: 'Find', + accelerator: keybindings.getAccelerator('editFind'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'find') + } + }, { + label: 'Find Next', + accelerator: keybindings.getAccelerator('editFindNext'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'fineNext') + } + }, { + label: 'Find Previous', + accelerator: keybindings.getAccelerator('editFindPrevious'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'findPrev') + } + }, { + label: 'Replace', + accelerator: keybindings.getAccelerator('editReplace'), + click (menuItem, browserWindow) { + actions.edit(browserWindow, 'replace') + } + }, { + type: 'separator' + }, { + label: 'Aidou', + visible: aidou, + accelerator: keybindings.getAccelerator('editAidou'), + click (menuItem, browserWindow) { + 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') + } + }] + }, { + type: 'separator' + }, { + label: 'Line Ending', + submenu: [{ + id: 'crlfLineEndingMenuEntry', + label: 'Carriage return and line feed (CRLF)', + type: 'radio', + click (menuItem, browserWindow) { + actions.lineEnding(browserWindow, 'crlf') + } + }, { + id: 'lfLineEndingMenuEntry', + label: 'Line feed (LF)', + type: 'radio', + click (menuItem, browserWindow) { + actions.lineEnding(browserWindow, 'lf') + } + }] + }, { + type: 'separator' + }] + } +} diff --git a/src/main/menus/file.js b/src/main/menu/templates/file.js similarity index 95% rename from src/main/menus/file.js rename to src/main/menu/templates/file.js index 8ad4f87e..a45a1d44 100755 --- a/src/main/menus/file.js +++ b/src/main/menu/templates/file.js @@ -2,10 +2,8 @@ import { app } from 'electron' import * as actions from '../actions/file' import { userSetting } from '../actions/marktext' import { showTabBar } from '../actions/view' -import userPreference from '../preference' -import keybindings from '../shortcutHandler' -export default function (recentlyUsedFiles) { +export default function (keybindings, userPreference, recentlyUsedFiles) { const { autoSave } = userPreference.getAll() const notOsx = process.platform !== 'darwin' let fileMenu = { @@ -14,14 +12,14 @@ export default function (recentlyUsedFiles) { label: 'New Tab', accelerator: keybindings.getAccelerator('fileNewFile'), click (menuItem, browserWindow) { - actions.newTab(browserWindow) + actions.newBlankTab(browserWindow) showTabBar(browserWindow) } }, { label: 'New Window', accelerator: keybindings.getAccelerator('fileNewTab'), click (menuItem, browserWindow) { - actions.newFile() + actions.newEditorWindow() } }, { type: 'separator' diff --git a/src/main/menu/templates/format.js b/src/main/menu/templates/format.js new file mode 100644 index 00000000..2a04946f --- /dev/null +++ b/src/main/menu/templates/format.js @@ -0,0 +1,101 @@ +import * as actions from '../actions/format' + +export default function (keybindings) { + return { + id: 'formatMenuItem', + label: 'Format', + submenu: [{ + id: 'strongMenuItem', + label: 'Strong', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatStrong'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'strong') + } + }, { + id: 'emphasisMenuItem', + label: 'Emphasis', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatEmphasis'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'em') + } + }, { + id: 'underlineMenuItem', + label: 'Underline', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatUnderline'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'u') + } + }, { + type: 'separator' + }, { + id: 'superscriptMenuItem', + label: 'Superscript', + type: 'checkbox', + click (menuItem, browserWindow) { + actions.format(browserWindow, 'sup') + } + }, { + id: 'subscriptMenuItem', + label: 'Subscript', + type: 'checkbox', + click (menuItem, browserWindow) { + actions.format(browserWindow, 'sub') + } + }, { + type: 'separator' + }, { + id: 'inlineCodeMenuItem', + label: 'Inline Code', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatInlineCode'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'inline_code') + } + }, { + id: 'inlineMathMenuItem', + label: 'Inline Math', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatInlineMath'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'inline_math') + } + }, { + type: 'separator' + }, { + id: 'strikeMenuItem', + label: 'Strike', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatStrike'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'del') + } + }, { + id: 'hyperlinkMenuItem', + label: 'Hyperlink', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatHyperlink'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'link') + } + }, { + id: 'imageMenuItem', + label: 'Image', + type: 'checkbox', + accelerator: keybindings.getAccelerator('formatImage'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'image') + } + }, { + type: 'separator' + }, { + label: 'Clear Format', + accelerator: keybindings.getAccelerator('formatClearFormat'), + click (menuItem, browserWindow) { + actions.format(browserWindow, 'clear') + } + }] + } +} diff --git a/src/main/menu/templates/help.js b/src/main/menu/templates/help.js new file mode 100755 index 00000000..3f6d0e0e --- /dev/null +++ b/src/main/menu/templates/help.js @@ -0,0 +1,76 @@ +import path from 'path' +import { shell } from 'electron' +import * as actions from '../actions/help' +import { checkUpdates } from '../actions/marktext' +import { isFile } from '../../filesystem' + +export default function () { + const helpMenu = { + label: 'Help', + role: 'help', + submenu: [{ + label: 'Learn More', + click () { + shell.openExternal('https://marktext.app') + } + }, { + label: 'Source Code on GitHub', + click () { + shell.openExternal('https://github.com/marktext/marktext') + } + }, { + label: 'Changelog', + click () { + shell.openExternal('https://github.com/marktext/marktext/blob/master/.github/CHANGELOG.md') + } + }, { + label: 'Markdown syntax', + click () { + shell.openExternal('https://spec.commonmark.org/0.29/') + } + }, { + type: 'separator' + }, { + label: 'Feedback via Twitter', + click (item, win) { + actions.showTweetDialog(win, 'twitter') + } + }, { + label: 'Report Issue or Feature request', + click () { + shell.openExternal('https://github.com/marktext/marktext/issues') + } + }, { + type: 'separator' + }, { + label: 'Follow @Jocs on Github', + click () { + shell.openExternal('https://github.com/Jocs') + } + }] + } + + if (isFile(path.join(process.resourcesPath, 'app-update.yml')) && + (process.platform === 'win32' || !!process.env.APPIMAGE)) { + helpMenu.submenu.push({ + type: 'separator' + }, { + label: 'Check for updates...', + click (menuItem, browserWindow) { + checkUpdates(menuItem, browserWindow) + } + }) + } + + if (process.platform !== 'darwin') { + helpMenu.submenu.push({ + type: 'separator' + }, { + label: 'About Mark Text', + click (menuItem, browserWindow) { + actions.showAboutDialog(browserWindow) + } + }) + } + return helpMenu +} diff --git a/src/main/menu/templates/index.js b/src/main/menu/templates/index.js new file mode 100644 index 00000000..370ff884 --- /dev/null +++ b/src/main/menu/templates/index.js @@ -0,0 +1,32 @@ +import edit from './edit' +import file from './file' +import help from './help' +import marktext from './marktext' +import view from './view' +import window from './window' +import paragraph from './paragraph' +import format from './format' +import theme from './theme' + +export dockMenu from './dock' + +/** + * Create the application menu for the editor window. + * + * @param {Keybindings} keybindings The keybindings instance. + * @param {Preference} preferences The preference instance. + * @param {string[]} recentlyUsedFiles The recently used files. + */ +export default function (keybindings, preferences, recentlyUsedFiles) { + return [ + ...(process.platform === 'darwin' ? [ marktext(keybindings) ] : []), + file(keybindings, preferences, recentlyUsedFiles), + edit(keybindings, preferences), + paragraph(keybindings), + format(keybindings), + window(keybindings), + theme(preferences), + view(keybindings), + help() + ] +} diff --git a/src/main/menu/templates/marktext.js b/src/main/menu/templates/marktext.js new file mode 100755 index 00000000..c394f575 --- /dev/null +++ b/src/main/menu/templates/marktext.js @@ -0,0 +1,51 @@ +import { app } from 'electron' +import { showAboutDialog } from '../actions/help' +import * as actions from '../actions/marktext' + +export default function (keybindings) { + return { + label: 'Mark Text', + submenu: [{ + label: 'About Mark Text', + click (menuItem, browserWindow) { + showAboutDialog(browserWindow) + } + }, { + label: 'Check for updates...', + click (menuItem, browserWindow) { + actions.checkUpdates(menuItem, browserWindow) + } + }, { + label: 'Preferences', + accelerator: keybindings.getAccelerator('filePreferences'), + click (menuItem, browserWindow) { + actions.userSetting(menuItem, browserWindow) + } + }, { + type: 'separator' + }, { + label: 'Services', + role: 'services', + submenu: [] + }, { + type: 'separator' + }, { + label: 'Hide Mark Text', + accelerator: keybindings.getAccelerator('mtHide'), + role: 'hide' + }, { + label: 'Hide Others', + accelerator: keybindings.getAccelerator('mtHideOthers'), + role: 'hideothers' + }, { + label: 'Show All', + role: 'unhide' + }, { + type: 'separator' + }, { + label: 'Quit Mark Text', + accelerator: keybindings.getAccelerator('fileQuit'), + click: app.quit + }] + } +} diff --git a/src/main/menu/templates/paragraph.js b/src/main/menu/templates/paragraph.js new file mode 100644 index 00000000..052be9ec --- /dev/null +++ b/src/main/menu/templates/paragraph.js @@ -0,0 +1,177 @@ +import * as actions from '../actions/paragraph' + +export default function (keybindings) { + return { + id: 'paragraphMenuEntry', + label: 'Paragraph', + submenu: [{ + id: 'heading1MenuItem', + label: 'Heading 1', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHeading1'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'heading 1') + } + }, { + id: 'heading2MenuItem', + label: 'Heading 2', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHeading2'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'heading 2') + } + }, { + id: 'heading3MenuItem', + label: 'Heading 3', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHeading3'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'heading 3') + } + }, { + id: 'heading4MenuItem', + label: 'Heading 4', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHeading4'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'heading 4') + } + }, { + id: 'heading5MenuItem', + label: 'Heading 5', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHeading5'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'heading 5') + } + }, { + id: 'heading6MenuItem', + label: 'Heading 6', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHeading6'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'heading 6') + } + }, { + type: 'separator' + }, { + id: 'upgradeHeadingMenuItem', + label: 'Upgrade Heading', + accelerator: keybindings.getAccelerator('paragraphUpgradeHeading'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'upgrade heading') + } + }, { + id: 'degradeHeadingMenuItem', + label: 'Degrade Heading', + accelerator: keybindings.getAccelerator('paragraphDegradeHeading'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'degrade heading') + } + }, { + type: 'separator' + }, { + id: 'tableMenuItem', + label: 'Table', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphTable'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'table') + } + }, { + id: 'codeFencesMenuItem', + label: 'Code Fences', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphCodeFence'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'pre') + } + }, { + id: 'quoteBlockMenuItem', + label: 'Quote Block', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphQuoteBlock'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'blockquote') + } + }, { + id: 'mathBlockMenuItem', + label: 'Math Block', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphMathBlock'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'mathblock') + } + }, { + id: 'htmlBlockMenuItem', + label: 'Html Block', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHtmlBlock'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'html') + } + }, { + type: 'separator' + }, { + id: 'orderListMenuItem', + label: 'Order List', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphOrderList'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'ol-order') + } + }, { + id: 'bulletListMenuItem', + label: 'Bullet List', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphBulletList'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'ul-bullet') + } + }, { + id: 'taskListMenuItem', + label: 'Task List', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphTaskList'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'ul-task') + } + }, { + type: 'separator' + }, { + id: 'looseListItemMenuItem', + label: 'Loose List Item', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphLooseListItem'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'loose-list-item') + } + }, { + type: 'separator' + }, { + id: 'paragraphMenuItem', + label: 'Paragraph', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphParagraph'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'paragraph') + } + }, { + id: 'horizontalLineMenuItem', + label: 'Horizontal Line', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphHorizontalLine'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'hr') + } + }, { + id: 'frontMatterMenuItem', + label: 'YAML Front Matter', + type: 'checkbox', + accelerator: keybindings.getAccelerator('paragraphYAMLFrontMatter'), + click (menuItem, browserWindow) { + actions.paragraph(browserWindow, 'front-matter') + } + }] + } +} diff --git a/src/main/menu/templates/theme.js b/src/main/menu/templates/theme.js new file mode 100644 index 00000000..312d206c --- /dev/null +++ b/src/main/menu/templates/theme.js @@ -0,0 +1,58 @@ +import * as actions from '../actions/theme' + +export default function (userPreference) { + const { theme } = userPreference.getAll() + return { + label: 'Theme', + id: 'themeMenu', + submenu: [{ + label: 'Cadmium Light', + type: 'radio', + id: 'light', + checked: theme === 'light', + click (menuItem, browserWindow) { + actions.selectTheme('light') + } + }, { + label: 'Dark', + type: 'radio', + id: 'dark', + checked: theme === 'dark', + click (menuItem, browserWindow) { + actions.selectTheme('dark') + } + }, { + label: 'Graphite Light', + type: 'radio', + id: 'graphite', + checked: theme === 'graphite', + click (menuItem, browserWindow) { + actions.selectTheme('graphite') + } + }, { + label: 'Material Dark', + type: 'radio', + id: 'material-dark', + checked: theme === 'material-dark', + click (menuItem, browserWindow) { + actions.selectTheme('material-dark') + } + }, { + label: 'One Dark', + type: 'radio', + id: 'one-dark', + checked: theme === 'one-dark', + click (menuItem, browserWindow) { + actions.selectTheme('one-dark') + } + }, { + label: 'Ulysses Light', + type: 'radio', + id: 'ulysses', + checked: theme === 'ulysses', + click (menuItem, browserWindow) { + actions.selectTheme('ulysses') + } + }] + } +} diff --git a/src/main/menu/templates/view.js b/src/main/menu/templates/view.js new file mode 100755 index 00000000..02ca1ed0 --- /dev/null +++ b/src/main/menu/templates/view.js @@ -0,0 +1,131 @@ +import * as actions from '../actions/view' +import { isOsx } from '../../config' + +export default function (keybindings) { + let viewMenu = { + label: 'View', + submenu: [{ + label: 'Toggle Full Screen', + accelerator: keybindings.getAccelerator('viewToggleFullScreen'), + click (item, focusedWindow) { + if (focusedWindow) { + focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) + } + } + }, { + type: 'separator' + }, { + label: 'Font...', + accelerator: keybindings.getAccelerator('viewChangeFont'), + click (item, browserWindow) { + actions.changeFont(browserWindow) + } + }, { + type: 'separator' + }, { + id: 'sourceCodeModeMenuItem', + label: 'Source Code Mode', + accelerator: keybindings.getAccelerator('viewSourceCodeMode'), + type: 'checkbox', + checked: false, + click (item, browserWindow, event) { + // if we call this function, the checked state is not set + if (!event) { + item.checked = !item.checked + } + actions.typeMode(browserWindow, 'sourceCode', item) + } + }, { + id: 'typewriterModeMenuItem', + label: 'Typewriter Mode', + accelerator: keybindings.getAccelerator('viewTypewriterMode'), + type: 'checkbox', + checked: false, + click (item, browserWindow, event) { + // if we call this function, the checked state is not set + if (!event) { + item.checked = !item.checked + } + actions.typeMode(browserWindow, 'typewriter', item) + } + }, { + id: 'focusModeMenuItem', + label: 'Focus Mode', + accelerator: keybindings.getAccelerator('viewFocusMode'), + type: 'checkbox', + checked: false, + click (item, browserWindow, event) { + // if we call this function, the checked state is not set + if (!event) { + item.checked = !item.checked + } + actions.typeMode(browserWindow, 'focus', item) + } + }, { + type: 'separator' + }, { + label: 'Toggle Side Bar', + id: 'sideBarMenuItem', + accelerator: keybindings.getAccelerator('viewToggleSideBar'), + type: 'checkbox', + checked: false, + click (item, browserWindow, event) { + // if we call this function, the checked state is not set + if (!event) { + item.checked = !item.checked + } + + actions.layout(item, browserWindow, 'showSideBar') + } + }, { + label: 'Toggle Tab Bar', + id: 'tabBarMenuItem', + accelerator: keybindings.getAccelerator('viewToggleTabBar'), + type: 'checkbox', + checked: false, + click (item, browserWindow, event) { + // if we call this function, the checked state is not set + if (!event) { + item.checked = !item.checked + } + + actions.layout(item, browserWindow, 'showTabBar') + } + }, { + type: 'separator' + }] + } + + if (global.MARKTEXT_DEBUG) { + // add devtool when development + viewMenu.submenu.push({ + label: 'Toggle Developer Tools', + accelerator: keybindings.getAccelerator('viewDevToggleDeveloperTools'), + click (item, focusedWindow) { + if (focusedWindow) { + focusedWindow.webContents.toggleDevTools() + } + } + }) + // add reload when development + viewMenu.submenu.push({ + label: 'Reload', + accelerator: keybindings.getAccelerator('viewDevReload'), + click (item, focusedWindow) { + if (focusedWindow) { + focusedWindow.reload() + } + } + }) + } + + if (isOsx) { + viewMenu.submenu.push({ + type: 'separator' + }, { + label: 'Bring All to Front', + role: 'front' + }) + } + return viewMenu +} diff --git a/src/main/menu/templates/window.js b/src/main/menu/templates/window.js new file mode 100755 index 00000000..58717331 --- /dev/null +++ b/src/main/menu/templates/window.js @@ -0,0 +1,26 @@ +import { toggleAlwaysOnTop } from '../actions/window' + +export default function (keybindings) { + return { + label: 'Window', + role: 'window', + submenu: [{ + label: 'Minimize', + accelerator: keybindings.getAccelerator('windowMinimize'), + role: 'minimize' + }, { + id: 'alwaysOnTopMenuItem', + label: 'Always on Top', + type: 'checkbox', + click (menuItem, browserWindow) { + toggleAlwaysOnTop(browserWindow) + } + }, { + type: 'separator' + }, { + label: 'Close Window', + accelerator: keybindings.getAccelerator('windowCloseWindow'), + role: 'close' + }] + } +} diff --git a/src/main/menus/edit.js b/src/main/menus/edit.js deleted file mode 100755 index ecad5ba4..00000000 --- a/src/main/menus/edit.js +++ /dev/null @@ -1,174 +0,0 @@ -import * as actions from '../actions/edit' -import userPreference from '../preference' -import keybindings from '../shortcutHandler' - -const { aidou } = userPreference.getAll() - -export default { - label: 'Edit', - submenu: [{ - label: 'Undo', - accelerator: keybindings.getAccelerator('editUndo'), - click: (menuItem, browserWindow) => { - actions.edit(browserWindow, 'undo') - } - }, { - label: 'Redo', - accelerator: keybindings.getAccelerator('editRedo'), - click: (menuItem, browserWindow) => { - actions.edit(browserWindow, 'redo') - } - }, { - type: 'separator' - }, { - 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: 'Copy As Markdown', - accelerator: keybindings.getAccelerator('editCopyAsMarkdown'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'copyAsMarkdown') - } - }, { - label: 'Copy As HTML', - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'copyAsHtml') - } - }, { - label: 'Paste As Plain Text', - accelerator: keybindings.getAccelerator('editCopyAsPlaintext'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'pasteAsPlainText') - } - }, { - type: 'separator' - }, { - label: 'Select All', - accelerator: keybindings.getAccelerator('editSelectAll'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'selectAll') - } - }, { - type: 'separator' - }, { - label: 'Duplicate', - accelerator: keybindings.getAccelerator('editDuplicate'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'duplicate') - } - }, { - label: 'Create Paragraph', - accelerator: keybindings.getAccelerator('editCreateParagraph'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'createParagraph') - } - }, { - label: 'Delete Paragraph', - accelerator: keybindings.getAccelerator('editDeleteParagraph'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'deleteParagraph') - } - }, { - type: 'separator' - }, { - label: 'Find', - accelerator: keybindings.getAccelerator('editFind'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'find') - } - }, { - label: 'Find Next', - accelerator: keybindings.getAccelerator('editFindNext'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'fineNext') - } - }, { - label: 'Find Previous', - accelerator: keybindings.getAccelerator('editFindPrevious'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'findPrev') - } - }, { - label: 'Replace', - accelerator: keybindings.getAccelerator('editReplace'), - click (menuItem, browserWindow) { - actions.edit(browserWindow, 'replace') - } - }, { - type: 'separator' - }, { - label: 'Aidou', - visible: aidou, - accelerator: keybindings.getAccelerator('editAidou'), - click (menuItem, browserWindow) { - 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') - } - }] - }, { - type: 'separator' - }, { - label: 'Line Ending', - submenu: [{ - id: 'crlfLineEndingMenuEntry', - label: 'Carriage return and line feed (CRLF)', - type: 'radio', - click (menuItem, browserWindow) { - actions.lineEnding(browserWindow, 'crlf') - } - }, { - id: 'lfLineEndingMenuEntry', - label: 'Line feed (LF)', - type: 'radio', - click (menuItem, browserWindow) { - actions.lineEnding(browserWindow, 'lf') - } - }] - }, { - type: 'separator' - }, { - label: 'Text Direction', - submenu: [{ - id: 'textDirectionLTRMenuEntry', - label: 'Left To Right', - type: 'radio', - click (menuItem, browserWindow) { - actions.textDirection(browserWindow, 'ltr') - } - }, { - id: 'textDirectionRTLMenuEntry', - label: 'Right To Left', - type: 'radio', - click (menuItem, browserWindow) { - actions.textDirection(browserWindow, 'rtl') - } - }] - }] -} diff --git a/src/main/menus/format.js b/src/main/menus/format.js deleted file mode 100644 index e1c9b6c0..00000000 --- a/src/main/menus/format.js +++ /dev/null @@ -1,100 +0,0 @@ -import * as actions from '../actions/format' -import keybindings from '../shortcutHandler' - -export default { - id: 'formatMenuItem', - label: 'Format', - submenu: [{ - id: 'strongMenuItem', - label: 'Strong', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatStrong'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'strong') - } - }, { - id: 'emphasisMenuItem', - label: 'Emphasis', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatEmphasis'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'em') - } - }, { - id: 'underlineMenuItem', - label: 'Underline', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatUnderline'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'u') - } - }, { - type: 'separator' - }, { - id: 'superscriptMenuItem', - label: 'Superscript', - type: 'checkbox', - click (menuItem, browserWindow) { - actions.format(browserWindow, 'sup') - } - }, { - id: 'subscriptMenuItem', - label: 'Subscript', - type: 'checkbox', - click (menuItem, browserWindow) { - actions.format(browserWindow, 'sub') - } - }, { - type: 'separator' - }, { - id: 'inlineCodeMenuItem', - label: 'Inline Code', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatInlineCode'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'inline_code') - } - }, { - id: 'inlineMathMenuItem', - label: 'Inline Math', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatInlineMath'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'inline_math') - } - }, { - type: 'separator' - }, { - id: 'strikeMenuItem', - label: 'Strike', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatStrike'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'del') - } - }, { - id: 'hyperlinkMenuItem', - label: 'Hyperlink', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatHyperlink'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'link') - } - }, { - id: 'imageMenuItem', - label: 'Image', - type: 'checkbox', - accelerator: keybindings.getAccelerator('formatImage'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'image') - } - }, { - type: 'separator' - }, { - label: 'Clear Format', - accelerator: keybindings.getAccelerator('formatClearFormat'), - click (menuItem, browserWindow) { - actions.format(browserWindow, 'clear') - } - }] -} diff --git a/src/main/menus/help.js b/src/main/menus/help.js deleted file mode 100755 index c39596a3..00000000 --- a/src/main/menus/help.js +++ /dev/null @@ -1,75 +0,0 @@ -import path from 'path' -import { shell } from 'electron' -import * as actions from '../actions/help' -import { checkUpdates } from '../actions/marktext' -import { isFile } from '../utils' - -const helpMenu = { - label: 'Help', - role: 'help', - submenu: [{ - label: 'Learn More', - click () { - shell.openExternal('https://marktext.app') - } - }, { - label: 'Source Code on GitHub', - click () { - shell.openExternal('https://github.com/marktext/marktext') - } - }, { - label: 'Changelog', - click () { - shell.openExternal('https://github.com/marktext/marktext/blob/master/.github/CHANGELOG.md') - } - }, { - label: 'Markdown syntax', - click () { - shell.openExternal('https://spec.commonmark.org/0.29/') - } - }, { - type: 'separator' - }, { - label: 'Feedback via Twitter', - click (item, win) { - actions.showTweetDialog(win, 'twitter') - } - }, { - label: 'Report Issue or Feature request', - click () { - shell.openExternal('https://github.com/marktext/marktext/issues') - } - }, { - type: 'separator' - }, { - label: 'Follow @Jocs on Github', - click () { - shell.openExternal('https://github.com/Jocs') - } - }] -} - -if (isFile(path.join(process.resourcesPath, 'app-update.yml')) && - (process.platform === 'win32' || !!process.env.APPIMAGE)) { - helpMenu.submenu.push({ - type: 'separator' - }, { - label: 'Check for updates...', - click (menuItem, browserWindow) { - checkUpdates(menuItem, browserWindow) - } - }) -} - -if (process.platform !== 'darwin') { - helpMenu.submenu.push({ - type: 'separator' - }, { - label: 'About Mark Text', - click (menuItem, browserWindow) { - actions.showAboutDialog(browserWindow) - } - }) -} - -export default helpMenu diff --git a/src/main/menus/index.js b/src/main/menus/index.js deleted file mode 100644 index efe5c033..00000000 --- a/src/main/menus/index.js +++ /dev/null @@ -1,25 +0,0 @@ -import edit from './edit' -import file from './file' -import help from './help' -import marktext from './marktext' -import view from './view' -import windowMenu from './windowMenu' -import paragraph from './paragraph' -import format from './format' -import theme from './theme' - -export dockMenu from './dock' - -export default function (recentlyUsedFiles) { - return [ - ...(process.platform === 'darwin' ? [marktext] : []), - file(recentlyUsedFiles), - edit, - paragraph, - format, - windowMenu, - theme, - view, - help - ] -} diff --git a/src/main/menus/marktext.js b/src/main/menus/marktext.js deleted file mode 100755 index 6c1f66a8..00000000 --- a/src/main/menus/marktext.js +++ /dev/null @@ -1,50 +0,0 @@ -import { app } from 'electron' -import { showAboutDialog } from '../actions/help' -import * as actions from '../actions/marktext' -import keybindings from '../shortcutHandler' - -export default { - label: 'Mark Text', - submenu: [{ - label: 'About Mark Text', - click (menuItem, browserWindow) { - showAboutDialog(browserWindow) - } - }, { - label: 'Check for updates...', - click (menuItem, browserWindow) { - actions.checkUpdates(menuItem, browserWindow) - } - }, { - label: 'Preferences', - accelerator: keybindings.getAccelerator('filePreferences'), - click (menuItem, browserWindow) { - actions.userSetting(menuItem, browserWindow) - } - }, { - type: 'separator' - }, { - label: 'Services', - role: 'services', - submenu: [] - }, { - type: 'separator' - }, { - label: 'Hide Mark Text', - accelerator: keybindings.getAccelerator('mtHide'), - role: 'hide' - }, { - label: 'Hide Others', - accelerator: keybindings.getAccelerator('mtHideOthers'), - role: 'hideothers' - }, { - label: 'Show All', - role: 'unhide' - }, { - type: 'separator' - }, { - label: 'Quit Mark Text', - accelerator: keybindings.getAccelerator('fileQuit'), - click: app.quit - }] -} diff --git a/src/main/menus/paragraph.js b/src/main/menus/paragraph.js deleted file mode 100644 index 207105ca..00000000 --- a/src/main/menus/paragraph.js +++ /dev/null @@ -1,176 +0,0 @@ -import * as actions from '../actions/paragraph' -import keybindings from '../shortcutHandler' - -export default { - id: 'paragraphMenuEntry', - label: 'Paragraph', - submenu: [{ - id: 'heading1MenuItem', - label: 'Heading 1', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHeading1'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'heading 1') - } - }, { - id: 'heading2MenuItem', - label: 'Heading 2', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHeading2'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'heading 2') - } - }, { - id: 'heading3MenuItem', - label: 'Heading 3', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHeading3'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'heading 3') - } - }, { - id: 'heading4MenuItem', - label: 'Heading 4', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHeading4'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'heading 4') - } - }, { - id: 'heading5MenuItem', - label: 'Heading 5', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHeading5'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'heading 5') - } - }, { - id: 'heading6MenuItem', - label: 'Heading 6', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHeading6'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'heading 6') - } - }, { - type: 'separator' - }, { - id: 'upgradeHeadingMenuItem', - label: 'Upgrade Heading', - accelerator: keybindings.getAccelerator('paragraphUpgradeHeading'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'upgrade heading') - } - }, { - id: 'degradeHeadingMenuItem', - label: 'Degrade Heading', - accelerator: keybindings.getAccelerator('paragraphDegradeHeading'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'degrade heading') - } - }, { - type: 'separator' - }, { - id: 'tableMenuItem', - label: 'Table', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphTable'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'table') - } - }, { - id: 'codeFencesMenuItem', - label: 'Code Fences', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphCodeFence'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'pre') - } - }, { - id: 'quoteBlockMenuItem', - label: 'Quote Block', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphQuoteBlock'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'blockquote') - } - }, { - id: 'mathBlockMenuItem', - label: 'Math Block', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphMathBlock'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'mathblock') - } - }, { - id: 'htmlBlockMenuItem', - label: 'Html Block', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHtmlBlock'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'html') - } - }, { - type: 'separator' - }, { - id: 'orderListMenuItem', - label: 'Order List', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphOrderList'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'ol-order') - } - }, { - id: 'bulletListMenuItem', - label: 'Bullet List', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphBulletList'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'ul-bullet') - } - }, { - id: 'taskListMenuItem', - label: 'Task List', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphTaskList'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'ul-task') - } - }, { - type: 'separator' - }, { - id: 'looseListItemMenuItem', - label: 'Loose List Item', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphLooseListItem'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'loose-list-item') - } - }, { - type: 'separator' - }, { - id: 'paragraphMenuItem', - label: 'Paragraph', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphParagraph'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'paragraph') - } - }, { - id: 'horizontalLineMenuItem', - label: 'Horizontal Line', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphHorizontalLine'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'hr') - } - }, { - id: 'frontMatterMenuItem', - label: 'YAML Front Matter', - type: 'checkbox', - accelerator: keybindings.getAccelerator('paragraphYAMLFrontMatter'), - click (menuItem, browserWindow) { - actions.paragraph(browserWindow, 'front-matter') - } - }] -} diff --git a/src/main/menus/theme.js b/src/main/menus/theme.js deleted file mode 100644 index 633f7656..00000000 --- a/src/main/menus/theme.js +++ /dev/null @@ -1,58 +0,0 @@ -import * as actions from '../actions/theme' -import userPreference from '../preference' - -const { theme } = userPreference.getAll() - -export default { - label: 'Theme', - id: 'themeMenu', - submenu: [{ - label: 'Cadmium Light', - type: 'radio', - id: 'light', - checked: theme === 'light', - click (menuItem, browserWindow) { - actions.selectTheme('light') - } - }, { - label: 'Dark', - type: 'radio', - id: 'dark', - checked: theme === 'dark', - click (menuItem, browserWindow) { - actions.selectTheme('dark') - } - }, { - label: 'Graphite Light', - type: 'radio', - id: 'graphite', - checked: theme === 'graphite', - click (menuItem, browserWindow) { - actions.selectTheme('graphite') - } - }, { - label: 'Material Dark', - type: 'radio', - id: 'material-dark', - checked: theme === 'material-dark', - click (menuItem, browserWindow) { - actions.selectTheme('material-dark') - } - }, { - label: 'One Dark', - type: 'radio', - id: 'one-dark', - checked: theme === 'one-dark', - click (menuItem, browserWindow) { - actions.selectTheme('one-dark') - } - }, { - label: 'Ulysses Light', - type: 'radio', - id: 'ulysses', - checked: theme === 'ulysses', - click (menuItem, browserWindow) { - actions.selectTheme('ulysses') - } - }] -} diff --git a/src/main/menus/view.js b/src/main/menus/view.js deleted file mode 100755 index 52a6f355..00000000 --- a/src/main/menus/view.js +++ /dev/null @@ -1,131 +0,0 @@ -import * as actions from '../actions/view' -import { isOsx } from '../config' -import keybindings from '../shortcutHandler' - -let viewMenu = { - label: 'View', - submenu: [{ - label: 'Toggle Full Screen', - accelerator: keybindings.getAccelerator('viewToggleFullScreen'), - click (item, focusedWindow) { - if (focusedWindow) { - focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) - } - } - }, { - type: 'separator' - }, { - label: 'Font...', - accelerator: keybindings.getAccelerator('viewChangeFont'), - click (item, browserWindow) { - actions.changeFont(browserWindow) - } - }, { - type: 'separator' - }, { - id: 'sourceCodeModeMenuItem', - label: 'Source Code Mode', - accelerator: keybindings.getAccelerator('viewSourceCodeMode'), - type: 'checkbox', - checked: false, - click (item, browserWindow, event) { - // if we call this function, the checked state is not set - if (!event) { - item.checked = !item.checked - } - actions.typeMode(browserWindow, 'sourceCode', item) - } - }, { - id: 'typewriterModeMenuItem', - label: 'Typewriter Mode', - accelerator: keybindings.getAccelerator('viewTypewriterMode'), - type: 'checkbox', - checked: false, - click (item, browserWindow, event) { - // if we call this function, the checked state is not set - if (!event) { - item.checked = !item.checked - } - actions.typeMode(browserWindow, 'typewriter', item) - } - }, { - id: 'focusModeMenuItem', - label: 'Focus Mode', - accelerator: keybindings.getAccelerator('viewFocusMode'), - type: 'checkbox', - checked: false, - click (item, browserWindow, event) { - // if we call this function, the checked state is not set - if (!event) { - item.checked = !item.checked - } - actions.typeMode(browserWindow, 'focus', item) - } - }, { - type: 'separator' - }, { - label: 'Toggle Side Bar', - id: 'sideBarMenuItem', - accelerator: keybindings.getAccelerator('viewToggleSideBar'), - type: 'checkbox', - checked: false, - click (item, browserWindow, event) { - // if we call this function, the checked state is not set - if (!event) { - item.checked = !item.checked - } - - actions.layout(item, browserWindow, 'showSideBar') - } - }, { - label: 'Toggle Tab Bar', - id: 'tabBarMenuItem', - accelerator: keybindings.getAccelerator('viewToggleTabBar'), - type: 'checkbox', - checked: false, - click (item, browserWindow, event) { - // if we call this function, the checked state is not set - if (!event) { - item.checked = !item.checked - } - - actions.layout(item, browserWindow, 'showTabBar') - } - }, { - type: 'separator' - }] -} - -if (global.MARKTEXT_DEBUG) { - // add devtool when development - viewMenu.submenu.push({ - label: 'Toggle Developer Tools', - accelerator: keybindings.getAccelerator('viewDevToggleDeveloperTools'), - click (item, focusedWindow) { - if (focusedWindow) { - focusedWindow.webContents.toggleDevTools() - } - } - }) - // add reload when development - viewMenu.submenu.push({ - label: 'Reload', - accelerator: keybindings.getAccelerator('viewDevReload'), - click (item, focusedWindow) { - if (focusedWindow) { - focusedWindow.reload() - } - } - }) -} - -if (isOsx) { - viewMenu.submenu.push({ - type: 'separator' - }, { - label: 'Bring All to Front', - role: 'front' - }) -} - -export default viewMenu diff --git a/src/main/menus/windowMenu.js b/src/main/menus/windowMenu.js deleted file mode 100755 index 50290b20..00000000 --- a/src/main/menus/windowMenu.js +++ /dev/null @@ -1,15 +0,0 @@ -import keybindings from '../shortcutHandler' - -export default { - label: 'Window', - role: 'window', - submenu: [{ - label: 'Minimize', - accelerator: keybindings.getAccelerator('windowMinimize'), - role: 'minimize' - }, { - label: 'Close Window', - accelerator: keybindings.getAccelerator('windowCloseWindow'), - role: 'close' - }] -} diff --git a/src/main/preference.js b/src/main/preferences/index.js similarity index 58% rename from src/main/preference.js rename to src/main/preferences/index.js index ff4b21eb..71a16235 100644 --- a/src/main/preference.js +++ b/src/main/preferences/index.js @@ -1,10 +1,12 @@ import fs from 'fs' import path from 'path' -import { ipcMain, BrowserWindow, systemPreferences } from 'electron' -import { isOsx, isWindows } from './config' -import appWindow from './window' -import { getPath, hasSameKeys, log, ensureDir } from './utils' -import { getStringRegKey, winHKEY } from './platform/win32/registry.js' +import EventEmitter from 'events' +import { BrowserWindow, ipcMain, systemPreferences } from 'electron' +import log from 'electron-log' +import { isOsx, isWindows } from '../config' +import { ensureDirSync } from '../filesystem' +import { hasSameKeys } from '../utils' +import { getStringRegKey, winHKEY } from '../platform/win32/registry.js' const isDarkSystemMode = () => { if (isOsx) { @@ -17,37 +19,41 @@ const isDarkSystemMode = () => { return false } -class Preference { - constructor () { - const FILE_NAME = 'preference.md' - const staticPath = path.join(__static, FILE_NAME) - const userDataPath = path.join(getPath('userData'), FILE_NAME) +class Preference extends EventEmitter { + + /** + * @param {AppPaths} userDataPath The path instance. + */ + constructor (paths) { + super() + + const { userDataPath, preferencesFilePath } = paths + this._userDataPath = userDataPath this.cache = null - this.staticPath = staticPath - this.userDataPath = userDataPath - + this.staticPath = path.join(__static, 'preference.md') + this.settingsPath = preferencesFilePath this.init() } init () { - const { userDataPath, staticPath } = this + const { settingsPath, staticPath } = this const defaultSettings = this.loadJson(staticPath) let userSetting = null // Try to load settings or write default settings if file doesn't exists. - if (!fs.existsSync(userDataPath) || !this.loadJson(userDataPath)) { - ensureDir(getPath('userData')) + if (!fs.existsSync(settingsPath) || !this.loadJson(settingsPath)) { + ensureDirSync(this._userDataPath) const content = fs.readFileSync(staticPath, 'utf-8') - fs.writeFileSync(userDataPath, content, 'utf-8') + fs.writeFileSync(settingsPath, content, 'utf-8') - userSetting = this.loadJson(userDataPath) + userSetting = this.loadJson(settingsPath) if (isDarkSystemMode()) { userSetting.theme = 'dark' } this.validateSettings(userSetting) } else { - userSetting = this.loadJson(userDataPath) + userSetting = this.loadJson(settingsPath) // Update outdated settings const requiresUpdate = !hasSameKeys(defaultSettings, userSetting) @@ -66,7 +72,7 @@ class Preference { } this.validateSettings(userSetting) this.writeJson(userSetting, false) - .catch(log) + .catch(log.error) } else { this.validateSettings(userSetting) } @@ -77,7 +83,11 @@ class Preference { userSetting = defaultSettings this.validateSettings(userSetting) } + this.cache = userSetting + this.emit('loaded', userSetting) + + this._listenForIpcMain() } getAll () { @@ -87,6 +97,28 @@ class Preference { setItem (key, value) { const preUserSetting = this.getAll() const newUserSetting = this.cache = Object.assign({}, preUserSetting, { [key]: value }) + this.emit('entry-changed', key, value) + return this.writeJson(newUserSetting) + } + + /** + * 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 + } + + const preUserSetting = this.getAll() + const newUserSetting = this.cache = Object.assign({}, preUserSetting, settings) + + Object.keys(settings).map(key => { + this.emit('entry-changed', key, settings[key]) + }) + return this.writeJson(newUserSetting) } @@ -97,13 +129,13 @@ class Preference { const userSetting = JSON_REG.exec(content.replace(/(?:\r\n|\n)/g, ''))[1] return JSON.parse(userSetting) } catch (err) { - log(err) + log.error(err) return null } } writeJson (json, async = true) { - const { userDataPath } = this + const { settingsPath } = this return new Promise((resolve, reject) => { const content = fs.readFileSync(this.staticPath, 'utf-8') const tokens = content.split('```') @@ -113,17 +145,25 @@ class Preference { '\n```' + tokens[2] if (async) { - fs.writeFile(userDataPath, newContent, 'utf-8', err => { + fs.writeFile(settingsPath, newContent, 'utf-8', err => { if (err) reject(err) else resolve(json) }) } else { - fs.writeFileSync(userDataPath, newContent, 'utf-8') + fs.writeFileSync(settingsPath, newContent, 'utf-8') resolve(json) } }) } + getPreferedEOL () { + const { endOfLine } = this.getAll() + if (endOfLine === 'lf') { + return 'lf' + } + return endOfLine === 'crlf' || isWindows ? 'crlf' : 'lf' + } + /** * workaround for issue #265 * expects: settings != null @@ -131,7 +171,7 @@ class Preference { */ validateSettings (settings) { if (!settings) { - log('Broken settings detected: invalid settings object.') + log.warn('Broken settings detected: invalid settings object.') return } @@ -141,6 +181,17 @@ class Preference { settings.theme = 'light' } + if (!settings.codeFontFamily || typeof settings.codeFontFamily !== 'string' || settings.codeFontFamily.length > 60) { + settings.codeFontFamily = 'DejaVu Sans Mono' + } + if (!settings.codeFontSize || typeof settings.codeFontSize !== 'string' || settings.codeFontFamily.length > 10) { + settings.codeFontSize = '14px' + } + + if (!settings.endOfLine || !/^(?:lf|crlf)$/.test(settings.endOfLine)) { + settings.endOfLine = isWindows ? 'crlf' : 'lf' + } + if (!settings.bulletListMarker || (settings.bulletListMarker && !/^(?:\+|-|\*)$/.test(settings.bulletListMarker))) { brokenSettings = true @@ -170,7 +221,7 @@ class Preference { } if (brokenSettings) { - log('Broken settings detected; fallback to default value(s).') + log.warn('Broken settings detected; fallback to default value(s).') } // Currently no CSD is available on Linux and Windows (GH#690) @@ -179,25 +230,19 @@ class Preference { settings.titleBarStyle = 'custom' } } + + _listenForIpcMain () { + ipcMain.on('mt::ask-for-user-preference', e => { + const win = BrowserWindow.fromWebContents(e.sender) + win.webContents.send('AGANI::user-preference', this.getAll()) + }) + + ipcMain.on('mt::set-user-preference', (e, settings) => { + this.setItems(settings).then(() => { + ipcMain.emit('broadcast-preferences-changed', settings) + }).catch(log.error) + }) + } } -const preference = new Preference() - -ipcMain.on('AGANI::ask-for-user-preference', e => { - const win = BrowserWindow.fromWebContents(e.sender) - win.webContents.send('AGANI::user-preference', preference.getAll()) -}) - -ipcMain.on('AGANI::set-user-preference', (e, pre) => { - Object.keys(pre).map(key => { - preference.setItem(key, pre[key]) - .then(() => { - for (const { win } of appWindow.windows.values()) { - win.webContents.send('AGANI::user-preference', { [key]: pre[key] }) - } - }) - .catch(log) - }) -}) - -export default preference +export default Preference diff --git a/src/main/utils/checkSystem.js b/src/main/utils/checkSystem.js deleted file mode 100644 index 5c5c1512..00000000 --- a/src/main/utils/checkSystem.js +++ /dev/null @@ -1,36 +0,0 @@ -import path from 'path' - -const additionalPaths = ({ - 'win32': [], - 'linux': [ - '/usr/bin' - ], - 'darwin': [ - '/usr/local/bin', - '/Library/TeX/texbin' - ] -})[process.platform] || [] - -export const checkSystem = () => { - if (additionalPaths.length > 0) { - // First integrate the additional paths that we need. - const nPATH = process.env.PATH.split(path.delimiter) - - for (const x of additionalPaths) { - // Check for both trailing and non-trailing slashes (to not add any - // directory more than once) - const y = (x[x.length - 1] === '/') ? x.substr(0, x.length - 1) : x + '/' - if (!nPATH.includes(x) && !nPATH.includes(y)) { - nPATH.push(x) - } - } - - process.env.PATH = nPATH.join(path.delimiter) - } - - if (path.dirname('pandoc').length > 0) { - if (process.env.PATH.indexOf(path.dirname('pandoc')) === -1) { - process.env.PATH += path.delimiter + path.dirname('pandoc') - } - } -} diff --git a/src/main/utils/imagePathAutoComplement.js b/src/main/utils/imagePathAutoComplement.js index 2de6fc18..2a316817 100644 --- a/src/main/utils/imagePathAutoComplement.js +++ b/src/main/utils/imagePathAutoComplement.js @@ -1,8 +1,11 @@ import fs from 'fs' import path from 'path' import { filter } from 'fuzzaldrin' -import { isDirectory, isFile, log } from './index' +import log from 'electron-log' import { IMAGE_EXTENSIONS, BLACK_LIST } from '../config' +import { isDirectory, isFile } from '../filesystem' + +// TODO(need::refactor): Refactor this file. Just return an array of directories and files without caching and watching? // TODO: rebuild cache @jocs const IMAGE_PATH = new Map() @@ -42,7 +45,7 @@ const filesHandler = (files, directory, key) => { const rebuild = (directory) => { fs.readdir(directory, (err, files) => { - if (err) log(err) + if (err) log.error(err) else { filesHandler(files, directory) } @@ -67,8 +70,9 @@ export const searchFilesAndDir = (directory, key) => { } else { return new Promise((resolve, reject) => { fs.readdir(directory, (err, files) => { - if (err) reject(err) - else { + if (err) { + reject(err) + } else { result = filesHandler(files, directory, key) watchDirectory(directory) resolve(result) diff --git a/src/main/utils/index.js b/src/main/utils/index.js index 0bb16f0f..9579000b 100644 --- a/src/main/utils/index.js +++ b/src/main/utils/index.js @@ -1,7 +1,4 @@ -import fs from 'fs' -import path from 'path' -import fse from 'fs-extra' -import { app, Menu } from 'electron' +import { app } from 'electron' import { EXTENSIONS } from '../config' const ID_PREFIX = 'mt-' @@ -11,18 +8,8 @@ export const getUniqueId = () => { return `${ID_PREFIX}${id++}` } -// creates a directory if it doesn't already exist. -export const ensureDir = dirPath => { - try { - fse.ensureDirSync(dirPath) - } catch (e) { - if (e.code !== 'EEXIST') { - throw e - } - } -} - -export const getRecommendTitle = markdown => { +// TODO: We should map all heading into the MarkdownDocument. +export const getRecommendTitleFromMarkdownString = markdown => { const tokens = markdown.match(/#{1,6} {1,}(.+)(?:\n|$)/g) if (!tokens) return '' let headers = tokens.map(t => { @@ -35,24 +22,26 @@ export const getRecommendTitle = markdown => { return headers.sort((a, b) => a.level - b.level)[0].content } -export const getPath = directory => { - return app.getPath(directory) +/** + * Returns a special directory path for the requested name. + * + * NOTE: Do not use "userData" to get the user data path, instead use AppPaths! + * + * @param {string} name The special directory name. + * @returns {string} The resolved special directory path. + */ +export const getPath = name => { + if (name === 'userData') { + throw new Error('Do not use "getPath" for user data path!') + } + return app.getPath(name) } -export const getMenuItemById = menuId => { - const menus = Menu.getApplicationMenu() - return menus.getMenuItemById(menuId) -} - -export const log = data => { - if (typeof data !== 'string') data = (data.stack || data).toString() - const LOG_DATA_PATH = path.join(getPath('userData'), 'error.log') - const LOG_TIME = new Date().toLocaleString() - ensureDir(getPath('userData')) - fs.appendFileSync(LOG_DATA_PATH, `\n${LOG_TIME}\n${data}\n`) -} - -// returns true if the filename matches one of the markdown extensions +/** + * Returns true if the filename matches one of the markdown extensions. + * + * @param {string} filename Path or filename + */ export const hasMarkdownExtension = filename => { if (!filename || typeof filename !== 'string') return false return EXTENSIONS.some(ext => filename.endsWith(`.${ext}`)) @@ -64,99 +53,14 @@ export const hasSameKeys = (a, b) => { return JSON.stringify(aKeys) === JSON.stringify(bKeys) } -/** - * Returns true if the path is a directory with read access. - * - * @param {string} dirPath The directory path. - */ -export const isDirectory = dirPath => { - try { - return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory() - } catch (e) { - return false - } -} - -/** - * Returns true if the path is a file with read access. - * - * @param {string} filepath The file path. - */ -export const isFile = filepath => { - try { - return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile() - } catch (e) { - return false - } -} - -/** - * Returns true if the path is a symbolic link with read access. - * - * @param {string} filepath The link path. - */ -export const isSymbolicLink = filepath => { - try { - return fs.existsSync(filepath) && fs.lstatSync(filepath).isSymbolicLink() - } catch (e) { - return false - } -} - -/** - * Returns true if the path is a markdown file. - * - * @param {string} filepath The path or link path. - */ -export const isMarkdownFile = filepath => { - return isFile(filepath) && hasMarkdownExtension(filepath) -} - -/** - * Returns true if the path is a markdown file or symbolic link to a markdown file. - * - * @param {string} filepath The path or link path. - */ -export const isMarkdownFileOrLink = filepath => { - if (!isFile(filepath)) return false - if (hasMarkdownExtension(filepath)) { - return true - } - - // Symbolic link to a markdown file - if (isSymbolicLink(filepath)) { - const targetPath = fs.readlinkSync(filepath) - return isFile(targetPath) && hasMarkdownExtension(targetPath) - } - return false -} - -/** - * Normalize the path into an absolute path and resolves the link target if needed. - * - * @param {string} pathname The path or link path. - * @returns {string} Returns the absolute path and resolved link. If the link target - * cannot be resolved, an empty string is returned. - */ -export const normalizeAndResolvePath = pathname => { - if (isSymbolicLink(pathname)) { - const absPath = path.dirname(pathname) - const targetPath = path.resolve(absPath, fs.readlinkSync(pathname)) - if (isFile(targetPath) || isDirectory(targetPath)) { - return path.resolve(targetPath) - } - console.error(`Cannot resolve link target "${pathname}" (${targetPath}).`) - return '' - } - return path.resolve(pathname) -} - -export const readJson = (filePath, printError) => { - try { - const content = fs.readFileSync(filePath, 'utf-8') - return JSON.parse(content) - } catch (e) { - if (printError) console.log(e) - return null +export const getLogLevel = () => { + if (!global.MARKTEXT_DEBUG_VERBOSE || typeof global.MARKTEXT_DEBUG_VERBOSE !== 'number' || + global.MARKTEXT_DEBUG_VERBOSE <= 0) { + return process.env.NODE_ENV === 'development' ? 'debug' : 'info' + } else if (global.MARKTEXT_DEBUG_VERBOSE === 1) { + return 'verbose' + } else if (global.MARKTEXT_DEBUG_VERBOSE === 2) { + return 'debug' } + return 'silly' // >= 3 } diff --git a/src/main/window.js b/src/main/window.js deleted file mode 100644 index 0358d8c5..00000000 --- a/src/main/window.js +++ /dev/null @@ -1,258 +0,0 @@ -import { app, BrowserWindow, screen, ipcMain } from 'electron' -import windowStateKeeper from 'electron-window-state' -import { getOsLineEndingName, loadMarkdownFile, getDefaultTextDirection } from './utils/filesystem' -import appMenu from './menu' -import Watcher from './watcher' -import { isMarkdownFile, isDirectory, normalizeAndResolvePath, log } from './utils' -import { TITLE_BAR_HEIGHT, defaultWinOptions, isLinux } from './config' -import userPreference from './preference' -import { newTab } from './actions/file' - -class AppWindow { - constructor () { - this.focusedWindowId = -1 - this.windows = new Map() - this.watcher = new Watcher() - this.listen() - } - - listen () { - // listen for file watch from renderer process eg - // 1. click file in folder. - // 2. new tab and save it. - // 3. close tab(s) need unwatch. - ipcMain.on('AGANI::file-watch', (e, { pathname, watch }) => { - const win = BrowserWindow.fromWebContents(e.sender) - if (watch) { - // listen for file `change` and `unlink` - this.watcher.watch(win, pathname, 'file') - } else { - // unlisten for file `change` and `unlink` - this.watcher.unWatch(win, pathname, 'file') - } - }) - } - - ensureWindowPosition (mainWindowState) { - // "workArea" doesn't work on Linux - const { bounds, workArea } = screen.getPrimaryDisplay() - const screenArea = isLinux ? bounds : workArea - - let { x, y, width, height } = mainWindowState - let center = false - if (x === undefined || y === undefined) { - center = true - - // First app start; check whether window size is larger than screen size - if (screenArea.width < width) width = screenArea.width - if (screenArea.height < height) height = screenArea.height - } else { - center = !screen.getAllDisplays().map(display => - x >= display.bounds.x && x <= display.bounds.x + display.bounds.width && - y >= display.bounds.y && y <= display.bounds.y + display.bounds.height) - .some(display => display) - } - if (center) { - // win.center() doesn't work on Linux - x = Math.max(0, Math.ceil(screenArea.x + (screenArea.width - width) / 2)) - y = Math.max(0, Math.ceil(screenArea.y + (screenArea.height - height) / 2)) - } - - return { - x, - y, - width, - height - } - } - - /** - * Creates a new editor window. - * - * @param {string} [pathname] Path to a file, directory or link. - * @param {string} [markdown] Markdown content. - * @param {*} [options] BrowserWindow options. - */ - createWindow (pathname = null, markdown = '', options = {}) { - // Ensure path is normalized - if (pathname) { - pathname = normalizeAndResolvePath(pathname) - } - - const { windows } = this - const mainWindowState = windowStateKeeper({ - defaultWidth: 1200, - defaultHeight: 800 - }) - - const { x, y, width, height } = this.ensureWindowPosition(mainWindowState) - const winOpt = Object.assign({ x, y, width, height }, defaultWinOptions, options) - - // Enable native or custom window - const { titleBarStyle } = userPreference.getAll() - if (titleBarStyle === 'custom') { - winOpt.titleBarStyle = '' - } else if (titleBarStyle === 'native') { - winOpt.frame = true - winOpt.titleBarStyle = '' - } - - const win = new BrowserWindow(winOpt) - windows.set(win.id, { win }) - - // create a menu for the current window - appMenu.addWindowMenuWithListener(win) - if (windows.size === 1) { - appMenu.setActiveWindow(win.id) - } - - win.once('ready-to-show', async () => { - mainWindowState.manage(win) - win.show() - - // open single markdown file - if (pathname && isMarkdownFile(pathname)) { - appMenu.addRecentlyUsedDocument(pathname) - try { - this.openFile(win, pathname) - } catch (err) { - log(err) - } - // open directory / folder - } else if (pathname && isDirectory(pathname)) { - appMenu.addRecentlyUsedDocument(pathname) - this.openFolder(win, pathname) - // open a window but do not open a file or directory - } else { - const lineEnding = getOsLineEndingName() - const textDirection = getDefaultTextDirection() - win.webContents.send('AGANI::open-blank-window', { - lineEnding, - markdown - }) - appMenu.updateLineEndingnMenu(lineEnding) - appMenu.updateTextDirectionMenu(textDirection) - } - }) - - win.on('focus', () => { - win.webContents.send('AGANI::window-active-status', { status: true }) - - if (win.id !== this.focusedWindowId) { - this.focusedWindowId = win.id - win.webContents.send('AGANI::req-update-line-ending-menu') - win.webContents.send('AGANI::request-for-view-layout') - win.webContents.send('AGANI::req-update-text-direction-menu') - - // update application menu - appMenu.setActiveWindow(win.id) - } - }) - - win.on('blur', () => { - win.webContents.send('AGANI::window-active-status', { status: false }) - }) - - win.on('close', event => { // before closed - event.preventDefault() - win.webContents.send('AGANI::ask-for-close') - }) - - // set renderer arguments - const { codeFontFamily, codeFontSize, theme } = userPreference.getAll() - // wow, this can be accessesed in renderer process. - win.stylePrefs = { - codeFontFamily, - codeFontSize, - theme - } - - const winURL = process.env.NODE_ENV === 'development' - ? `http://localhost:9091` - : `file://${__dirname}/index.html` - - win.loadURL(winURL) - win.setSheetOffset(TITLE_BAR_HEIGHT) - - return win - } - - openFile = async (win, filePath) => { - const data = await loadMarkdownFile(filePath) - const { - markdown, - filename, - pathname, - isUtf8BomEncoded, - lineEnding, - adjustLineEndingOnSave, - isMixedLineEndings, - textDirection - } = data - - appMenu.updateLineEndingnMenu(lineEnding) - appMenu.updateTextDirectionMenu(textDirection) - win.webContents.send('AGANI::open-single-file', { - markdown, - filename, - pathname, - options: { - isUtf8BomEncoded, - lineEnding, - adjustLineEndingOnSave - } - }) - // listen for file `change` and `unlink` - this.watcher.watch(win, filePath, 'file') - // Notify user about mixed endings - if (isMixedLineEndings) { - win.webContents.send('AGANI::show-notification', { - title: 'Mixed Line Endings', - type: 'error', - message: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, - time: 20000 - }) - } - } - - newTab (win, filePath) { - this.watcher.watch(win, filePath, 'file') - loadMarkdownFile(filePath).then(rawDocument => { - appMenu.addRecentlyUsedDocument(filePath) - newTab(win, rawDocument) - }).catch(err => { - // TODO: Handle error --> create a end-user error handler. - console.error('[ERROR] Cannot open file or directory.') - log(err) - }) - } - - openFolder (win, pathname) { - this.watcher.watch(win, pathname, 'dir') - try { - win.webContents.send('AGANI::open-project', pathname) - } catch (err) { - log(err) - } - } - - forceClose (win) { - if (!win) return - const { windows } = this - if (windows.has(win.id)) { - this.watcher.unWatchWin(win) - windows.delete(win.id) - } - appMenu.removeWindowMenu(win.id) - win.destroy() // if use win.close(), it will cause a endless loop. - if (windows.size === 0) { - app.quit() - } - } - - clear () { - this.watcher.clear() - } -} - -export default new AppWindow() diff --git a/src/main/windows/editor.js b/src/main/windows/editor.js new file mode 100644 index 00000000..5545c039 --- /dev/null +++ b/src/main/windows/editor.js @@ -0,0 +1,242 @@ +import path from 'path' +import EventEmitter from 'events' +import { BrowserWindow, ipcMain } from 'electron' +import log from 'electron-log' +import windowStateKeeper from 'electron-window-state' +import { WindowType } from '../app/windowManager' +import { TITLE_BAR_HEIGHT, defaultWinOptions, isLinux } from '../config' +import { isDirectory, isMarkdownFile, normalizeAndResolvePath } from '../filesystem' +import { loadMarkdownFile } from '../filesystem/markdown' +import { ensureWindowPosition } from './utils' + +class EditorWindow extends EventEmitter { + + /** + * @param {Accessor} accessor The application accessor for application instances. + */ + constructor (accessor) { + super() + + this._accessor = accessor + + this.id = null + this.browserWindow = null + this.type = WindowType.EDITOR + this.quitting = false + } + + /** + * Creates a new editor window. + * + * @param {string} [pathname] Path to a file, directory or link. + * @param {string} [markdown] Markdown content. + * @param {*} [options] BrowserWindow options. + */ + createWindow (pathname = null, markdown = '', options = {}) { + const { menu: appMenu, env, preferences } = this._accessor + + // Ensure path is normalized + if (pathname) { + pathname = normalizeAndResolvePath(pathname) + } + + const mainWindowState = windowStateKeeper({ + defaultWidth: 1200, + defaultHeight: 800 + }) + + const { x, y, width, height } = ensureWindowPosition(mainWindowState) + const winOptions = Object.assign({ x, y, width, height }, defaultWinOptions, options) + if (isLinux) { + winOptions.icon = path.join(__static, 'logo-96px.png') + } + + // Enable native or custom/frameless window and titlebar + const { titleBarStyle } = preferences.getAll() + if (titleBarStyle === 'custom') { + winOptions.titleBarStyle = '' + } else if (titleBarStyle === 'native') { + winOptions.frame = true + winOptions.titleBarStyle = '' + } + + let win = this.browserWindow = new BrowserWindow(winOptions) + this.id = win.id + + // Create a menu for the current window + appMenu.addEditorMenu(win) + + win.once('ready-to-show', async () => { + mainWindowState.manage(win) + win.show() + + this.emit('window-ready-to-show') + + if (pathname && isMarkdownFile(pathname)) { + // Open single markdown file + appMenu.addRecentlyUsedDocument(pathname) + this._openFile(pathname) + } else if (pathname && isDirectory(pathname)) { + // Open directory / folder + appMenu.addRecentlyUsedDocument(pathname) + this.openFolder(pathname) + } else { + // Open a blank window + const lineEnding = preferences.getPreferedEOL() + win.webContents.send('mt::bootstrap-blank-window', { + lineEnding, + markdown + }) + appMenu.updateLineEndingMenu(lineEnding) + } + }) + + win.on('focus', () => { + this.emit('window-focus') + win.webContents.send('AGANI::window-active-status', { status: true }) + }) + + // Lost focus + win.on('blur', () => { + this.emit('window-blur') + win.webContents.send('AGANI::window-active-status', { status: false }) + }) + + // Before closed. We cancel the action and ask the editor further instructions. + win.on('close', event => { + this.emit('window-close') + + event.preventDefault() + win.webContents.send('AGANI::ask-for-close') + + // TODO: Close all watchers etc. Should we do this manually or listen to 'quit' event? + }) + + // The window is now destroyed. + win.on('closed', () => { + this.emit('window-closed') + + // Free window reference + win = null + }) + + win.loadURL(this._buildUrlWithSettings(this.id, env, preferences)) + win.setSheetOffset(TITLE_BAR_HEIGHT) + + return win + } + + openTab (filePath, selectTab=true) { + if (this.quitting) return + + const { browserWindow } = this + const { menu: appMenu, preferences } = this._accessor + + // Listen for file changed. + ipcMain.emit('watcher-watch-file', browserWindow, filePath) + + loadMarkdownFile(filePath, preferences.getPreferedEOL()).then(rawDocument => { + appMenu.addRecentlyUsedDocument(filePath) + browserWindow.webContents.send('AGANI::new-tab', rawDocument, selectTab) + }).catch(err => { + // TODO: Handle error --> create a end-user error handler. + console.error('[ERROR] Cannot open file or directory.') + log.error(err) + }) + } + + openUntitledTab (selectTab=true, markdownString='') { + if (this.quitting) return + + const { browserWindow } = this + browserWindow.webContents.send('mt::new-untitled-tab', selectTab, markdownString) + } + + openFolder (pathname) { + if (this.quitting) return + + const { browserWindow } = this + ipcMain.emit('watcher-watch-directory', browserWindow, pathname) + browserWindow.webContents.send('AGANI::open-project', pathname) + } + + destroy () { + this.quitting = true + this.emit('bye') + + this.removeAllListeners() + this.browserWindow.destroy() + this.browserWindow = null + this.id = null + } + + // --- private --------------------------------- + + // Only called once during window bootstrapping. + _openFile = async filePath => { + const { browserWindow } = this + const { menu: appMenu, preferences } = this._accessor + + const data = await loadMarkdownFile(filePath, preferences.getPreferedEOL()) + const { + markdown, + filename, + pathname, + encoding, + lineEnding, + adjustLineEndingOnSave, + isMixedLineEndings + } = data + + appMenu.updateLineEndingMenu(lineEnding) + browserWindow.webContents.send('mt::bootstrap-window', { + markdown, + filename, + pathname, + options: { + encoding, + lineEnding, + adjustLineEndingOnSave + } + }) + + // Listen for file changed. + ipcMain.emit('watcher-watch-file', browserWindow, filePath) + + // Notify user about mixed endings + if (isMixedLineEndings) { + browserWindow.webContents.send('AGANI::show-notification', { + title: 'Mixed Line Endings', + type: 'error', + message: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, + time: 20000 + }) + } + } + + _buildUrlWithSettings (windowId, env, userPreference) { + // NOTE: Only send absolutely necessary values. Theme and titlebar settings + // are sended because we delay load the preferences. + const { debug, paths } = env + const { codeFontFamily, codeFontSize, theme, titleBarStyle } = userPreference.getAll() + + const baseUrl = process.env.NODE_ENV === 'development' + ? `http://localhost:9091` + : `file://${__dirname}/index.html` + + const url = new URL(baseUrl) + url.searchParams.set('udp', paths.userDataPath) + url.searchParams.set('debug', debug ? '1' : '0') + url.searchParams.set('wid', windowId) + + // Settings + url.searchParams.set('cff', codeFontFamily) + url.searchParams.set('cfs', codeFontSize) + url.searchParams.set('theme', theme) + url.searchParams.set('tbs', titleBarStyle) + + return url.toString() + } +} + +export default EditorWindow diff --git a/src/main/windows/utils.js b/src/main/windows/utils.js new file mode 100644 index 00000000..09467bac --- /dev/null +++ b/src/main/windows/utils.js @@ -0,0 +1,34 @@ +import { screen } from 'electron' +import { isLinux } from '../config' + +export const ensureWindowPosition = windowState => { + // "workArea" doesn't work on Linux + const { bounds, workArea } = screen.getPrimaryDisplay() + const screenArea = isLinux ? bounds : workArea + + let { x, y, width, height } = windowState + let center = false + if (x === undefined || y === undefined) { + center = true + + // First app start; check whether window size is larger than screen size + if (screenArea.width < width) width = screenArea.width + if (screenArea.height < height) height = screenArea.height + } else { + center = !screen.getAllDisplays().map(display => + x >= display.bounds.x && x <= display.bounds.x + display.bounds.width && + y >= display.bounds.y && y <= display.bounds.y + display.bounds.height) + .some(display => display) + } + if (center) { + // win.center() doesn't work on Linux + x = Math.max(0, Math.ceil(screenArea.x + (screenArea.width - width) / 2)) + y = Math.max(0, Math.ceil(screenArea.y + (screenArea.height - height) / 2)) + } + return { + x, + y, + width, + height + } +} diff --git a/src/muya/lib/contentState/history.js b/src/muya/lib/contentState/history.js index 2cc86093..8c980fb3 100644 --- a/src/muya/lib/contentState/history.js +++ b/src/muya/lib/contentState/history.js @@ -1,7 +1,7 @@ import { deepCopy } from '../utils' import { UNDO_DEPTH } from '../config' -export class History { +class History { constructor (contentState) { this.stack = [] this.index = -1 diff --git a/src/renderer/app.vue b/src/renderer/app.vue index 9fa4fda9..a78cde87 100644 --- a/src/renderer/app.vue +++ b/src/renderer/app.vue @@ -38,7 +38,6 @@