refactor main source code (#1006)

* refactor main source code

* fix invalid file cache entries during startup
This commit is contained in:
Felix Häusler 2019-05-04 16:14:45 +02:00 committed by Ran Luo
parent b7d51e0d6c
commit 77ff23c2c8
81 changed files with 2835 additions and 2077 deletions

View File

@ -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 = ''

View File

@ -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'

View File

@ -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'
},

View File

@ -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

View File

@ -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']
}
}
}
}

41
doc/wip/ipc.md Normal file
View File

@ -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')
```

View File

@ -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<any>,
index: number

View File

@ -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",

50
src/common/envPaths.js Normal file
View File

@ -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

View File

@ -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)
}

View File

@ -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

23
src/main/app/accessor.js Normal file
View File

@ -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

91
src/main/app/env.js Normal file
View File

@ -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

248
src/main/app/index.js Normal file
View File

@ -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

37
src/main/app/paths.js Normal file
View File

@ -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

View File

@ -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
// <number>, ... , <number>
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

View File

@ -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)
}
}

63
src/main/cli/index.js Normal file
View File

@ -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

31
src/main/cli/parser.js Normal file
View File

@ -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

View File

@ -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: {

View File

@ -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

View File

@ -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')
}

View File

@ -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
}
}

View File

@ -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 = {}
}
}

View File

@ -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, '\\\\')
}

View File

@ -22,5 +22,7 @@ require('electron').app.on('ready', () => {
})
})
/* eslint-enable */
// Require `main` process to boot app
require('./index')

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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 })
}

View File

@ -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')
}

View File

@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import { getMenuItemById } from '../utils'
import { getMenuItemById } from '../../menu'
const MENU_ID_FORMAT_MAP = {
'strongMenuItem': 'strong',

View File

@ -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) => {

View File

@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import { getMenuItemById } from '../utils'
import { getMenuItemById } from '../../menu'
const DISABLE_LABELS = [
// paragraph menu items

View File

@ -0,0 +1,5 @@
import { ipcMain } from 'electron'
export const selectTheme = theme => {
ipcMain.emit('mt::set-user-preference', undefined, { theme })
}

View File

@ -1,5 +1,5 @@
import { ipcMain, BrowserWindow } from 'electron'
import { getMenuItemById } from '../utils'
import { getMenuItemById } from '../../menu'
const sourceCodeModeMenuItemId = 'sourceCodeModeMenuItem'
const typewriterModeMenuItemId = 'typewriterModeMenuItem'

View File

@ -0,0 +1,7 @@
import { ipcMain } from 'electron'
export const toggleAlwaysOnTop = win => {
if (win) {
ipcMain.emit('window-toggle-always-on-top', win)
}
}

View File

@ -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

156
src/main/menu/templates/edit.js Executable file
View File

@ -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'
}]
}
}

View File

@ -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'

View File

@ -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')
}
}]
}
}

76
src/main/menu/templates/help.js Executable file
View File

@ -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
}

View File

@ -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()
]
}

View File

@ -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
}]
}
}

View File

@ -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')
}
}]
}
}

View File

@ -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')
}
}]
}
}

131
src/main/menu/templates/view.js Executable file
View File

@ -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
}

View File

@ -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'
}]
}
}

View File

@ -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')
}
}]
}]
}

View File

@ -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')
}
}]
}

View File

@ -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

View File

@ -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
]
}

View File

@ -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
}]
}

View File

@ -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')
}
}]
}

View File

@ -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')
}
}]
}

View File

@ -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

View File

@ -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'
}]
}

View File

@ -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.<string, *>} settings A settings object or subset object with key/value entries.
*/
setItems (settings) {
if (!settings) {
log.error('Cannot change settings without entires: object is undefined or null.')
return
}
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

View File

@ -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')
}
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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()

242
src/main/windows/editor.js Normal file
View File

@ -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

34
src/main/windows/utils.js Normal file
View File

@ -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
}
}

View File

@ -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

View File

@ -38,7 +38,6 @@
</template>
<script>
import { remote } from 'electron'
import { addStyles, addThemeStyle } from '@/util/theme'
import Recent from '@/components/recent'
import EditorWithTabs from '@/components/editorWithTabs'
@ -78,7 +77,8 @@
...mapState({
'showTabBar': state => state.layout.showTabBar,
'sourceCode': state => state.preferences.sourceCode,
'theme': state => state.preferences.theme
'theme': state => state.preferences.theme,
'textDirection': state => state.preferences.textDirection
}),
...mapState({
'projectTree': state => state.project.projectTree,
@ -87,8 +87,7 @@
'isSaved': state => state.editor.currentFile.isSaved,
'markdown': state => state.editor.currentFile.markdown,
'cursor': state => state.editor.currentFile.cursor,
'wordCount': state => state.editor.currentFile.wordCount,
'textDirection': state => state.editor.currentFile.textDirection
'wordCount': state => state.editor.currentFile.wordCount
}),
...mapState([
'windowActive', 'platform', 'init'
@ -105,7 +104,13 @@
}
},
created () {
const { dispatch } = this.$store
const { commit, dispatch } = this.$store
// Apply initial state (theme and titleBarStyle) and delay load other values.
if (global.marktext.initialState) {
commit('SET_USER_PREFERENCE', global.marktext.initialState)
}
// store/index.js
dispatch('LINTEN_WIN_STATUS')
// module: tweet
@ -133,8 +138,7 @@
dispatch('LISTEN_FOR_MOVE_TO')
dispatch('LISTEN_FOR_SAVE')
dispatch('LISTEN_FOR_SET_PATHNAME')
dispatch('LISTEN_FOR_OPEN_SINGLE_FILE')
dispatch('LISTEN_FOR_OPEN_BLANK_WINDOW')
dispatch('LISTEN_FOR_BOOTSTRAP_WINDOW')
dispatch('LISTEN_FOR_SAVE_CLOSE')
dispatch('LISTEN_FOR_EXPORT_PRINT')
dispatch('LISTEN_FOR_INSERT_IMAGE')
@ -144,9 +148,7 @@
dispatch('LISTEN_FOR_CLOSE_TAB')
dispatch('LINTEN_FOR_PRINT_SERVICE_CLEARUP')
dispatch('LINTEN_FOR_EXPORT_SUCCESS')
dispatch('LISTEN_FOR_SET_TEXT_DIRECTION')
dispatch('LISTEN_FOR_FILE_CHANGE')
dispatch('LISTEN_FOR_TEXT_DIRECTION_MENU')
// module: notification
dispatch('LISTEN_FOR_NOTIFICATION')
@ -176,8 +178,7 @@
}, false)
this.$nextTick(() => {
const win = remote.getCurrentWindow()
const style = win.stylePrefs || DEFAULT_STYLE
const style = global.marktext.initialState || DEFAULT_STYLE
addStyles(style)
})
}

79
src/renderer/bootstrap.js vendored Normal file
View File

@ -0,0 +1,79 @@
import path from 'path'
import { crashReporter, ipcRenderer } from 'electron'
import log from 'electron-log'
import EnvPaths from "common/envPaths";
let exceptionLogger = s => console.error(s)
const configureLogger = () => {
const { debug, paths, windowId } = global.marktext.env
log.transports.console.level = process.env.NODE_ENV === 'development' // mirror to window console
log.transports.mainConsole = null
log.transports.file.file = path.join(paths.logPath, `editor-${windowId}.log`)
log.transports.file.level = debug ? 'debug' : 'info'
log.transports.file.sync = false
log.transports.file.init()
exceptionLogger = log.error
}
const parseUrlArgs = () => {
const params = new URLSearchParams(window.location.search)
const codeFontFamily = params.get('cff')
const codeFontSize = params.get('cfs')
const debug = params.get('debug') === '1'
const theme = params.get('theme')
const titleBarStyle = params.get('tbs')
const userDataPath = params.get('udp')
const windowId = params.get('wid')
return {
debug,
userDataPath,
windowId,
initialState: {
codeFontFamily,
codeFontSize,
theme,
titleBarStyle
}
}
}
const bootstrapRenderer = () => {
// Start crash reporter to save core dumps for the renderer process
crashReporter.start({
companyName: 'marktext',
productName: 'marktext',
submitURL: 'http://0.0.0.0/',
uploadToServer: false
})
// Register renderer exception handler
window.addEventListener('error', event => {
const { message, name, stack } = event.error
const copy = {
message,
name,
stack
}
exceptionLogger(event.error)
// Pass exception to main process exception handler to show a error dialog.
ipcRenderer.send('AGANI::handle-renderer-error', copy)
})
const { debug, initialState, userDataPath, windowId } = parseUrlArgs()
const marktext = {
initialState,
env: {
debug,
paths: new EnvPaths(userDataPath),
windowId
}
}
global.marktext = marktext
configureLogger()
}
export default bootstrapRenderer

View File

@ -64,7 +64,7 @@
},
methods: {
newFile () {
this.$store.dispatch('NEW_BLANK_FILE')
this.$store.dispatch('NEW_UNTITLED_TAB')
},
handleTabScroll (event) {
// Use mouse wheel value first but prioritize X value more (e.g. touchpad input).

View File

@ -23,7 +23,7 @@
},
methods: {
newFile () {
this.$store.dispatch('NEW_BLANK_FILE')
this.$store.dispatch('NEW_UNTITLED_TAB')
}
}
}

View File

@ -56,6 +56,9 @@
this.searchResult = []
return
}
// TODO(need::refactor): See TODO in src/main/filesystem/watcher.js.
this.searchResult = this.fileList.filter(f => f.data.markdown.indexOf(keyword) >= 0)
}
}

View File

@ -1,8 +1,10 @@
import Vue from 'vue'
import VueElectron from 'vue-electron'
import axios from 'axios'
import { crashReporter, ipcRenderer } from 'electron'
import sourceMapSupport from 'source-map-support'
import lang from 'element-ui/lib/locale/lang/en'
import locale from 'element-ui/lib/locale'
import bootstrapRenderer from './bootstrap'
import App from './app'
import store from './store'
import './assets/symbolIcon'
@ -25,33 +27,20 @@ import services from './services'
import './assets/styles/index.css'
import './assets/styles/printService.css'
// -----------------------------------------------
// Decode source map in production - must be registered first
import sourceMapSupport from 'source-map-support'
sourceMapSupport.install({
environment: 'node',
handleUncaughtExceptions: false,
hookRequire: false
})
// Start crash reporter to save core dumps for the renderer process
crashReporter.start({
companyName: 'marktext',
productName: 'marktext',
submitURL: 'http://0.0.0.0/',
uploadToServer: false
})
global.marktext = {}
bootstrapRenderer()
// Register renderer error handler
window.addEventListener('error', event => {
const { message, name, stack } = event.error
const copy = {
message,
name,
stack
}
// pass error to error handler
ipcRenderer.send('AGANI::handle-renderer-error', copy)
})
// -----------------------------------------------
// Be careful when changing code before this line!
// Configure Vue
locale.use(lang)
@ -69,7 +58,7 @@ Vue.use(Col)
Vue.use(Row)
Vue.use(Tree)
if (!process.env.IS_WEB) Vue.use(require('vue-electron'))
Vue.use(VueElectron)
Vue.http = Vue.prototype.$http = axios
Vue.config.productionTip = false

View File

@ -35,7 +35,7 @@ export const fileMixins = {
watch: true
})
ipcRenderer.send("AGANI::add-recently-used-document", pathname)
ipcRenderer.send('mt::add-recently-used-document', pathname)
if (isMixedLineEndings && !isOpened) {
this.$notify({

View File

@ -11,7 +11,6 @@ const state = {
lineEnding: 'lf',
currentFile: {},
tabs: [],
textDirection: 'ltr',
toc: []
}
@ -79,8 +78,8 @@ const mutations = {
LOAD_CHANGE (state, change) {
const { tabs, currentFile } = state
const { data, pathname } = change
const { isMixedLineEndings, lineEnding, adjustLineEndingOnSave, isUtf8BomEncoded, markdown, textDirection, filename } = data
const options = { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave, textDirection }
const { isMixedLineEndings, lineEnding, adjustLineEndingOnSave, encoding, markdown, filename } = data
const options = { encoding, lineEnding, adjustLineEndingOnSave }
const newFileState = getSingleFileState({ markdown, filename, pathname, options })
if (isMixedLineEndings) {
notice.notify({
@ -142,9 +141,9 @@ const mutations = {
state.currentFile.markdown = markdown
}
},
SET_IS_UTF8_BOM_ENCODED (state, isUtf8BomEncoded) {
SET_DOCUMENT_ENCODING (state, encoding) {
if (hasKeys(state.currentFile)) {
state.currentFile.isUtf8BomEncoded = isUtf8BomEncoded
state.currentFile.encoding = encoding
}
},
SET_LINE_ENDING (state, lineEnding) {
@ -209,13 +208,10 @@ const mutations = {
}
})
},
// TODO: Remove "SET_GLOBAL_LINE_ENDING" because nowhere used.
SET_GLOBAL_LINE_ENDING (state, ending) {
state.lineEnding = ending
},
SET_TEXT_DIRECTION (state, textDirection) {
if (hasKeys(state.currentFile)) {
state.currentFile.textDirection = textDirection
}
}
}
@ -277,19 +273,6 @@ const actions = {
}
},
LISTEN_FOR_TEXT_DIRECTION_MENU ({ commit, state, dispatch }) {
ipcRenderer.on('AGANI::req-update-text-direction-menu', e => {
dispatch('UPDATE_TEXT_DIRECTION_MENU')
})
},
UPDATE_TEXT_DIRECTION_MENU ({ commit, state }) {
const { textDirection } = state.currentFile
if (textDirection) {
ipcRenderer.send('AGANI::update-text-direction-menu', textDirection)
}
},
CLOSE_SINGLE_FILE ({ commit, state }, file) {
const { id, pathname, filename, markdown } = file
const options = getOptionsFromState(file)
@ -415,16 +398,15 @@ const actions = {
UPDATE_CURRENT_FILE ({ commit, state, dispatch }, currentFile) {
commit('SET_CURRENT_FILE', currentFile)
dispatch('UPDATE_TEXT_DIRECTION_MENU', state)
const { tabs } = state
if (!tabs.some(file => file.id === currentFile.id)) {
commit('ADD_FILE_TO_TABS', currentFile)
}
},
// This event is only used when loading a file during window creation.
LISTEN_FOR_OPEN_SINGLE_FILE ({ commit, state, dispatch }) {
ipcRenderer.on('AGANI::open-single-file', (e, { markdown, filename, pathname, options }) => {
// This events are only used during window creation.
LISTEN_FOR_BOOTSTRAP_WINDOW ({ commit, state, dispatch }) {
ipcRenderer.on('mt::bootstrap-window', (e, { markdown, filename, pathname, options }) => {
const fileState = getSingleFileState({ markdown, filename, pathname, options })
const { id } = fileState
const { lineEnding } = options
@ -439,19 +421,42 @@ const actions = {
})
dispatch('SET_LAYOUT_MENU_ITEM')
})
ipcRenderer.on('mt::bootstrap-blank-window', (e, { lineEnding, markdown: source }) => {
const { tabs } = state
const fileState = getBlankFileState(tabs, lineEnding, source)
const { id, markdown } = fileState
commit('SET_GLOBAL_LINE_ENDING', lineEnding)
dispatch('INIT_STATUS', true)
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
commit('SET_LAYOUT', {
rightColumn: 'files',
showSideBar: false,
showTabBar: false
})
dispatch('SET_LAYOUT_MENU_ITEM')
})
},
// Open a new tab, optionally with content.
LISTEN_FOR_NEW_TAB ({ dispatch }) {
ipcRenderer.on('AGANI::new-tab', (e, markdownDocument) => {
ipcRenderer.on('AGANI::new-tab', (e, markdownDocument, selectTab=true) => {
// TODO: allow to add a tab without selecting it
if (markdownDocument) {
// Create tab with content.
dispatch('NEW_TAB_WITH_CONTENT', markdownDocument)
} else {
// Create an empty tab
dispatch('NEW_BLANK_FILE')
// Fallback: create a blank tab
dispatch('NEW_UNTITLED_TAB')
}
})
ipcRenderer.on('mt::new-untitled-tab', (e, selectTab=true, markdownString='', ) => {
// TODO: allow to add a tab without selecting it
// Create a blank tab
dispatch('NEW_UNTITLED_TAB', markdownString)
})
},
LISTEN_FOR_CLOSE_TAB ({ commit, state, dispatch }) {
@ -467,10 +472,11 @@ const actions = {
})
},
NEW_BLANK_FILE ({ commit, state, dispatch }) {
// Create a new untitled tab optional with markdown string.
NEW_UNTITLED_TAB ({ commit, state, dispatch }, markdownString) {
dispatch('SHOW_TAB_VIEW', false)
const { tabs, lineEnding } = state
const fileState = getBlankFileState(tabs, lineEnding)
const fileState = getBlankFileState(tabs, lineEnding, markdownString)
const { id, markdown } = fileState
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
@ -485,7 +491,7 @@ const actions = {
NEW_TAB_WITH_CONTENT ({ commit, state, dispatch }, markdownDocument) {
if (!markdownDocument) {
console.warn('Cannot create a file tab without a markdown document!')
dispatch('NEW_BLANK_FILE')
dispatch('NEW_UNTITLED_TAB')
return
}
@ -526,24 +532,6 @@ const actions = {
}
},
LISTEN_FOR_OPEN_BLANK_WINDOW ({ commit, state, dispatch }) {
ipcRenderer.on('AGANI::open-blank-window', (e, { lineEnding, markdown: source }) => {
const { tabs } = state
const fileState = getBlankFileState(tabs, lineEnding, source)
const { id, markdown } = fileState
commit('SET_GLOBAL_LINE_ENDING', lineEnding)
dispatch('INIT_STATUS', true)
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
commit('SET_LAYOUT', {
rightColumn: 'files',
showSideBar: false,
showTabBar: false
})
dispatch('SET_LAYOUT_MENU_ITEM')
})
},
// Content change from realtime preview editor and source code editor
// WORKAROUND: id is "muya" if changes come from muya and not source code editor! So we don't have to apply the workaround.
LISTEN_FOR_CONTENT_CHANGE ({ commit, state, rootState }, { id, markdown, wordCount, cursor, history, toc }) {
@ -688,15 +676,6 @@ const actions = {
})
},
LISTEN_FOR_SET_TEXT_DIRECTION ({ commit, state }) {
ipcRenderer.on('AGANI::set-text-direction', (e, { textDirection }) => {
const { textDirection: oldTextDirection } = state.currentFile
if (textDirection !== oldTextDirection) {
commit('SET_TEXT_DIRECTION', textDirection)
}
})
},
LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) {
ipcRenderer.on('AGANI::update-file', (e, { type, change }) => {
// TODO: Set `isSaved` to false.

View File

@ -12,10 +12,9 @@ export const defaultFileState = {
pathname: '',
filename: 'Untitled-1',
markdown: '',
isUtf8BomEncoded: false,
encoding: 'utf8', // Currently just "utf8" or "utf8bom"
lineEnding: 'lf', // lf or crlf
adjustLineEndingOnSave: false, // convert editor buffer (LF) to CRLF when saving
textDirection: 'ltr',
history: {
stack: [],
index: -1
@ -35,8 +34,8 @@ export const defaultFileState = {
}
export const getOptionsFromState = file => {
const { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave } = file
return { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave }
const { encoding, lineEnding, adjustLineEndingOnSave } = file
return { encoding, lineEnding, adjustLineEndingOnSave }
}
export const getFileStateFromData = data => {
@ -45,10 +44,9 @@ export const getFileStateFromData = data => {
markdown,
filename,
pathname,
isUtf8BomEncoded,
encoding,
lineEnding,
adjustLineEndingOnSave,
textDirection
adjustLineEndingOnSave
} = data
const id = getUniqueId()
@ -59,10 +57,9 @@ export const getFileStateFromData = data => {
markdown,
filename,
pathname,
isUtf8BomEncoded,
encoding,
lineEnding,
adjustLineEndingOnSave,
textDirection
adjustLineEndingOnSave
})
}
@ -78,6 +75,11 @@ export const getBlankFileState = (tabs, lineEnding = 'lf', markdown = '') => {
const id = getUniqueId()
// We may pass muarkdown=null as parameter.
if (markdown == null) {
markdown = ''
}
return Object.assign(fileState, {
lineEnding,
adjustLineEndingOnSave: lineEnding.toLowerCase() === 'crlf',
@ -91,7 +93,7 @@ export const getSingleFileState = ({ id = getUniqueId(), markdown, filename, pat
// TODO(refactor:renderer/editor): Replace this function with `createDocumentState`.
const fileState = JSON.parse(JSON.stringify(defaultFileState))
const { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave, textDirection = 'ltr' } = options
const { encoding, lineEnding, adjustLineEndingOnSave = 'ltr' } = options
assertLineEnding(adjustLineEndingOnSave, lineEnding)
@ -100,9 +102,8 @@ export const getSingleFileState = ({ id = getUniqueId(), markdown, filename, pat
markdown,
filename,
pathname,
isUtf8BomEncoded,
encoding,
lineEnding,
textDirection,
adjustLineEndingOnSave
})
}
@ -120,7 +121,7 @@ export const createDocumentState = (markdownDocument, id = getUniqueId()) => {
markdown,
filename,
pathname,
isUtf8BomEncoded,
encoding,
lineEnding,
adjustLineEndingOnSave,
} = markdownDocument
@ -132,7 +133,7 @@ export const createDocumentState = (markdownDocument, id = getUniqueId()) => {
markdown,
filename,
pathname,
isUtf8BomEncoded,
encoding,
lineEnding,
adjustLineEndingOnSave
})

View File

@ -19,7 +19,8 @@ const state = {
platform: process.platform, // platform of system `darwin` | `win32` | `linux`
appVersion: process.versions.MARKTEXT_VERSION_STRING, // Mark Text version string
windowActive: true, // weather current window is active or focused
init: process.env.NODE_ENV === 'development' // weather Mark Text is inited
// TODO: "init" is nowhere used
init: process.env.NODE_ENV === 'development' // whether Mark Text is inited
}
const getters = {}

View File

@ -9,6 +9,7 @@ const state = {
codeFontFamily: 'DejaVu Sans Mono',
codeFontSize: '14px',
lineHeight: 1.6,
textDirection: 'ltr',
lightColor: '#303133', // color in light theme
darkColor: 'rgb(217, 217, 217)', // color in dark theme
autoSave: false,
@ -45,7 +46,8 @@ const mutations = {
const actions = {
ASK_FOR_USER_PREFERENCE ({ commit, state, rootState }) {
ipcRenderer.send('AGANI::ask-for-user-preference')
ipcRenderer.send('mt::ask-for-user-preference')
ipcRenderer.on('AGANI::user-preference', (e, preference) => {
const { autoSave } = preference
commit('SET_USER_PREFERENCE', preference)
@ -77,7 +79,7 @@ const actions = {
CHANGE_FONT ({ commit }, { type, value }) {
commit('SET_USER_PREFERENCE', { [type]: value })
// save to preference.md
ipcRenderer.send('AGANI::set-user-preference', { [type]: value })
ipcRenderer.send('mt::set-user-preference', { [type]: value })
}
}

View File

@ -108,7 +108,7 @@ const actions = {
LISTEN_FOR_LOAD_PROJECT ({ commit, dispatch }) {
ipcRenderer.on('AGANI::open-project', (e, pathname) => {
// Initialize editor and show empty/new tab
dispatch('NEW_BLANK_FILE')
dispatch('NEW_UNTITLED_TAB')
dispatch('INIT_STATUS', true)
commit('SET_PROJECT_TREE', pathname)

View File

@ -597,6 +597,11 @@ are-we-there-yet@~1.1.2:
delegates "^1.0.0"
readable-stream "^2.0.6"
arg@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0"
integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==
argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@ -3895,6 +3900,11 @@ electron-localshortcut@^3.1.0:
keyboardevent-from-electron-accelerator "^1.1.0"
keyboardevents-areequal "^0.2.1"
electron-log@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/electron-log/-/electron-log-3.0.5.tgz#9bdd307f1f1aec85c0873babd6bdfffb1a661436"
integrity sha512-wWOPNBVGh7NJx/OLXdgtuYrqxoG9ZWO+kZYloIHi66B5W9EgXZw71jdZ1ddeWNg4CkABypa8tkrRGqCKF+9tYg==
electron-osx-sign@0.4.11:
version "0.4.11"
resolved "https://registry.yarnpkg.com/electron-osx-sign/-/electron-osx-sign-0.4.11.tgz#8377732fe7b207969f264b67582ee47029ce092f"
@ -4215,6 +4225,11 @@ eslint-friendly-formatter@^4.0.1:
strip-ansi "^4.0.0"
text-table "^0.2.0"
eslint-import-resolver-alias@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz#297062890e31e4d6651eb5eba9534e1f6e68fc97"
integrity sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==
eslint-import-resolver-node@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a"