Find best window to open second-instance files and directories in (#1054)

* Files via command-line are opened in the best window

* Don't show FSW changed notification while saving

* Fixed source-code mode setting and remove focus/typewritter option

* Simplify ignore list

* Fix invalid dialog parameter

* Fix invalid dialog parameter (2)

* Use async message box dialog

* Update documentation

* few changes

* Check timer before calling clearTimeout

* Improve switch style

* Fix style
This commit is contained in:
Felix Häusler 2019-06-09 15:41:58 +02:00 committed by GitHub
parent 164e9a1d87
commit e6e652713a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2502 additions and 5064 deletions

View File

@ -11,16 +11,16 @@ const getEnvironmentDefinitions = function () {
} catch(_) { } catch(_) {
// Ignore error if we build without git. // Ignore error if we build without git.
} }
const isOfficialRelease = !!process.env.MARKTEXT_IS_OFFICIAL_RELEASE
const versionSuffix = isOfficialRelease ? '' : ` (${shortHash})`
const isStableRelease = !!process.env.MARKTEXT_IS_STABLE
const versionSuffix = isStableRelease ? '' : ` (${shortHash})`
return { return {
'global.MARKTEXT_GIT_SHORT_HASH': JSON.stringify(shortHash), 'global.MARKTEXT_GIT_SHORT_HASH': JSON.stringify(shortHash),
'global.MARKTEXT_GIT_HASH': JSON.stringify(fullHash), 'global.MARKTEXT_GIT_HASH': JSON.stringify(fullHash),
'global.MARKTEXT_VERSION': JSON.stringify(version), 'global.MARKTEXT_VERSION': JSON.stringify(version),
'global.MARKTEXT_VERSION_STRING': JSON.stringify(`v${version}${versionSuffix}`), 'global.MARKTEXT_VERSION_STRING': JSON.stringify(`v${version}${versionSuffix}`),
'global.MARKTEXT_IS_OFFICIAL_RELEASE': JSON.stringify(isOfficialRelease) 'global.MARKTEXT_IS_STABLE': JSON.stringify(isStableRelease)
} }
} }

18
.github/CHANGELOG.md vendored
View File

@ -4,18 +4,28 @@
- `preference.md` is deprecated and no longer supported. Please use the GUI or edit `preferences.json` manually. - `preference.md` is deprecated and no longer supported. Please use the GUI or edit `preferences.json` manually.
- Removed portable Windows executable. NSIS installer can now be used to install per-user or machine wide. - Removed portable Windows executable. NSIS installer can now be used to install per-user or machine wide.
- Changed `viewToggleFullScreen` and `windowCloseWindow` key bindings to `windowToggleFullScreen` and `fileCloseWindow`.
- Removed `viewChangeFont` key binding.
- Mark Text is now single-instance application on Linux and Windows too.
**:cactus:Feature** **:cactus:Feature**
- GUI settings (#1028)
- The cursor jump to the end of format or to the next brackets when press `tab`(#976) - The cursor jump to the end of format or to the next brackets when press `tab`(#976)
- Tab drag & drop inside the window - Tab drag & drop inside the window
- Scrollable tabs - Scrollable tabs
- Support to replace the root folder in a window
- Second-instance files and directories via command-line are opened in the best window
- Mark Text can use a default directory that is automatically opened during startup (#711)
- New CLI flags: `--disable-gpu` and `-n,--new-window`
**:butterfly:Optimization** **:butterfly:Optimization**
- Rewrite `select all` when press `CtrlOrCmd + A` (#937) - Rewrite `select all` when press `CtrlOrCmd + A` (#937)
- Set the cursor at the end of `#` in header when press arrow down to jump to the next paragraph.(#978) - Set the cursor at the end of `#` in header when press arrow down to jump to the next paragraph.(#978)
- Improved startup time - Improved startup time
- Replace empty untitled tabs (#830)
- Editor window is shown immediately while loading
**:beetle:Bug fix** **:beetle:Bug fix**
@ -23,6 +33,14 @@
- Fixed some bugs after press `backspace` (#934, #938) - Fixed some bugs after press `backspace` (#934, #938)
- Change `inline math` vertical align to `top` (#977) - Change `inline math` vertical align to `top` (#977)
- Prevent to open the same file twice, instead select the existing tab (#878) - Prevent to open the same file twice, instead select the existing tab (#878)
- Fixed some minor filesystem watcher issues
- Fixed rename filesystem watcher bug which lead to multiple issues because the parent directory was watched after deleting a file on Linux using `rename`
- Fixed incorrect file content after a watched file was edited externally (#1043)
**:warning:Breaking Development Changes:**
- Environment variable `MARKTEXT_IS_OFFICIAL_RELEASE` is now `MARKTEXT_IS_STABLE`
- Renamed npm script `build:dir` to `build:bin`
### 0.14.0 ### 0.14.0

View File

@ -7,11 +7,11 @@ matrix:
include: include:
- os: osx - os: osx
osx_image: xcode9.2 osx_image: xcode9.2
env: CC=clang CXX=clang++ npm_config_clang=1 MARKTEXT_IS_OFFICIAL_RELEASE=1 MARKTEXT_EXIT_ON_ERROR=1 env: CC=clang CXX=clang++ npm_config_clang=1 MARKTEXT_IS_STABLE=1 MARKTEXT_EXIT_ON_ERROR=1
compiler: clang compiler: clang
- os: linux - os: linux
dist: trusty dist: trusty
env: CC=clang CXX=clang++ npm_config_clang=1 MARKTEXT_IS_OFFICIAL_RELEASE=1 MARKTEXT_EXIT_ON_ERROR=1 DISPLAY=:99.0 env: CC=clang CXX=clang++ npm_config_clang=1 MARKTEXT_IS_STABLE=1 MARKTEXT_EXIT_ON_ERROR=1 DISPLAY=:99.0
compiler: clang compiler: clang
cache: cache:

View File

@ -14,7 +14,7 @@ branches:
skip_tags: true skip_tags: true
environment: environment:
MARKTEXT_IS_OFFICIAL_RELEASE: 1 MARKTEXT_IS_STABLE: 1
MARKTEXT_EXIT_ON_ERROR: 1 MARKTEXT_EXIT_ON_ERROR: 1
GH_TOKEN: GH_TOKEN:
secure: Ki5AJWygDYhzMJxl0b0rDx3bhAYmar2aPdwVHiai9IigqsvZpWHLeI3qpTiiaOWL secure: Ki5AJWygDYhzMJxl0b0rDx3bhAYmar2aPdwVHiai9IigqsvZpWHLeI3qpTiiaOWL

15
doc/ENVIRONMENT.md Normal file
View File

@ -0,0 +1,15 @@
# Environment
| Name | Description |
| ---------------------------- | ----------------------------------------------------------- |
| `MARKTEXT_DEBUG` | Enable debug mode. |
| `MARKTEXT_DEBUG_KEYBOARD` | Print more keyboard information when debug mode is enabled. |
| `MARKTEXT_ERROR_INTERACTION` | Never show the error dialog to report bugs. |
## Development
| Name | Description |
| ------------------------------------ | ------------------------------------------------------------ |
| `MARKTEXT_EXIT_ON_ERROR` | Exit on the first error or exception that occurs. |
| `MARKTEXT_DEV_HIDE_BROWSER_ANALYZER` | Don't show the dependency analyzer. |
| `MARKTEXT_IS_STABLE` | **Please don't use this!** Used to identify stable releases. |

View File

@ -1,20 +1,22 @@
# Command Line Interface # Command Line Interface
``` ```
Usage: marktext [commands] [path] Usage: marktext [commands] [path ...]
Available commands: Available commands:
--debug Enable debug mode --debug Enable debug mode
--safe Disable plugins and other user configuration --safe Disable plugins and other user configuration
--dump-keyboard-layout Dump keyboard information --dump-keyboard-layout Dump keyboard information
--version Print version information --user-data-dir Change the user data directory
--help Print this help message --disable-gpu Disable GPU hardware acceleration
-v, --verbose Be verbose
--version Print version information
-h, --help Print this help message
``` ```
`marktext` should point to your installation of Mark Text. The exact location will vary from platform to platform. Since I'm on macOS, I created convenient alias for the version of Mark Text that I have installed. `marktext` should point to your installation of Mark Text. The exact location will vary from platform to platform. On macOS, you can create a convenient alias like:
```sh ```sh
alias marktext="/Applications/Mark\ Text.app/Contents/MacOS/Mark\ Text" alias marktext="/Applications/Mark\ Text.app/Contents/MacOS/Mark\ Text"
``` ```

View File

@ -32,10 +32,27 @@ Here is an example:
**Mark Text menu (macOS only):** **Mark Text menu (macOS only):**
| Id | Description | | Id | Description |
| -------------- | --------------------------------------- | | ----------------- | --------------------------------------- |
| `mtHide` | Hide Mark Text | | `mtHide` | Hide Mark Text |
| `mtHideOthers` | Hide all other windows except Mark Text | | `mtHideOthers` | Hide all other windows except Mark Text |
| `filePreferences` | Open settings window |
| `fileQuit` | Quit Mark Text |
**File menu:**
| Id | Description |
|:----------------- | ----------------------------------------- |
| `fileNewFile` | New file |
| `fileNewTab` | New tab |
| `fileOpenFile` | Open markdown file |
| `fileOpenFolder` | Open folder |
| `fileSave` | Save |
| `fileSaveAs` | Save as... |
| `filePreferences` | Open settings window (Linux/Windows only) |
| `fileCloseTab` | Close tab |
| `fileCloseWindow` | Close window |
| `fileQuit` | Quit Mark Text (Linux/Windows only) |
**Edit menu:** **Edit menu:**
@ -80,7 +97,7 @@ Here is an example:
| `paragraphBulletList` | Insert a unordered list | | `paragraphBulletList` | Insert a unordered list |
| `paragraphTaskList` | Insert a task list | | `paragraphTaskList` | Insert a task list |
| `paragraphLooseListItem` | Convert a list item to a loose list item | | `paragraphLooseListItem` | Convert a list item to a loose list item |
| `paragraphParagraph` | Convert a heading to a paragraph | | `paragraphParagraph` | Convert a heading to a paragraph |
| `paragraphHorizontalLine` | Add a horizontal line | | `paragraphHorizontalLine` | Add a horizontal line |
| `paragraphYAMLFrontMatter` | Insert a YAML frontmatter block | | `paragraphYAMLFrontMatter` | Insert a YAML frontmatter block |
@ -99,17 +116,15 @@ Here is an example:
**Window menu:** **Window menu:**
| Id | Description | | Id | Description |
| ------------------- | ------------------- | | ------------------------ | ---------------------- |
| `windowMinimize` | Minimize the window | | `windowMinimize` | Minimize the window |
| `windowCloseWindow` | Close the window | | `windowToggleFullScreen` | Toggle fullscreen mode |
**View menu:** **View menu:**
| Id | Description | | Id | Description |
| ----------------------------- | ---------------------------------------- | | ----------------------------- | ---------------------------------------- |
| `viewToggleFullScreen` | Toggle fullscreen mode |
| `viewChangeFont` | Open font dialog |
| `viewSourceCodeMode` | Switch to source code mode | | `viewSourceCodeMode` | Switch to source code mode |
| `viewTypewriterMode` | Enable typewriter mode | | `viewTypewriterMode` | Enable typewriter mode |
| `viewFocusMode` | Enable focus mode | | `viewFocusMode` | Enable focus mode |
@ -117,4 +132,3 @@ Here is an example:
| `viewToggleTabBar` | Toggle tabbar | | `viewToggleTabBar` | Toggle tabbar |
| `viewDevToggleDeveloperTools` | Toggle developer tools (debug mode only) | | `viewDevToggleDeveloperTools` | Toggle developer tools (debug mode only) |
| `viewDevReload` | Reload window (debug mode only) | | `viewDevReload` | Reload window (debug mode only) |

View File

@ -2,16 +2,18 @@
#### General #### General
| Key | Type | Default Value | Description | | Key | Type | Default Value | Description |
| -------------------- | ------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| autoSave | Boolean | ture | Automatically save the content being edited. option value: true, false | | autoSave | Boolean | false | Automatically save the content being edited. option value: true, false |
| autoSaveDelay | Number | 3000 | The delay in milliseconds after a changed file is saved automatically? 3000 ~10000 | | autoSaveDelay | Number | 5000 | The delay in milliseconds after a changed file is saved automatically? 3000 ~10000 |
| titleBarStyle | String | csd | The title bar style. the native option will result in a standard gray opaque title bar. `csd` (macOS only), `custom`, `native` | | titleBarStyle | String | custom | The title bar style on Linux and Window: `custom` or `native` |
| openFilesInNewWindow | Boolean | false | true, false | | openFilesInNewWindow | Boolean | false | true, false |
| aidou | Boolean | true | Enable aidou. Optional value: true, false | | openFolderInNewWindow | Boolean | false | true, false |
| fileSortBy | String | modified | Sort files in opened folder by `created` time, modified time and title. | | aidou | Boolean | true | Enable aidou. Optional value: true, false |
| startUp | String | lastState | The action after Mark Text startup, open the last edited content, open the specified folder or blank page, optional value: `lasteState`, `folder`, `blank` | | fileSortBy | String | created | Sort files in opened folder by `created` time, modified time and title. |
| language | String | en | The language Mark Text use. | | startUpAction | String | lastState | The action after Mark Text startup, open the last edited content, open the specified folder or blank page, optional value: `lasteState`, `folder`, `blank` |
| defaultDirectoryToOpen | String | `""` | The path that should be opened if `startUpAction=folder`. |
| language | String | en | The language Mark Text use. |
#### Editor #### Editor
@ -32,17 +34,27 @@
#### Markdown #### Markdown
| Key | Type | Default | Description | | Key | Type | Default | Description |
| ------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| preferLooseListItem | Boolean | true | The preferred list type. | | preferLooseListItem | Boolean | true | The preferred list type. |
| bulletListMarker | String | `-` | The preferred marker used in bullet list, optional value: `-`, `*` `+` | | bulletListMarker | String | `-` | The preferred marker used in bullet list, optional value: `-`, `*` `+` |
| orderListDelimiter | String | `.` | The preferred delimiter used in order list, optional value: `.` `)` | | orderListDelimiter | String | `.` | The preferred delimiter used in order list, optional value: `.` `)` |
| preferHeadingStyle | String | `atx` | The preferred heading style in Mark Text, optional value `atx` `setext`, [more info](https://spec.commonmark.org/0.29/#atx-headings) | | preferHeadingStyle | String | `atx` | The preferred heading style in Mark Text, optional value `atx` `setext`, [more info](https://spec.commonmark.org/0.29/#atx-headings) |
| tabSize | Number | 4 | The number of spaces a tab is equal to | | tabSize | Number | 4 | The number of spaces a tab is equal to |
| listIndentation | String | 1 | The list indentation of sub list items or paragraphs, optional value `dfm`, `tab` or number 1~4 | | listIndentation | String | 1 | The list indentation of sub list items or paragraphs, optional value `dfm`, `tab` or number 1~4 |
#### View
| Key | Type | Default | Description |
| ----------------------------- | ------- | ------- | -------------------------------------------------- |
| sideBarVisibility<sup>*</sup> | Boolean | false | Controls the visibility of the side bar. |
| tabBarVisibility<sup>*</sup> | Boolean | false | Controls the visibility of the tabs. |
| sourceCodeModeEnabled* | Boolean | false | Controls the visibility of the source-code editor. |
\*: These options are default/fallback values that are used if not session is loaded and are overwritten by the menu entries.
#### Theme #### Theme
| Key | Type | Default | Description | | Key | Type | Default | Description |
| ----- | ------ | ------- | -------------------------------------------------------------- | | ----- | ------ | ------- | --------------------------------------------------------------------- |
| theme | String | light | `dark` `graphite` `material-dark` `one-dark` `light` `ulysses` | | theme | String | light | `dark`, `graphite`, `material-dark`, `one-dark`, `light` or `ulysses` |

View File

@ -2,7 +2,7 @@
- Create a release candidate - Create a release candidate
- Create branch `release-v%version%` - Create branch `release-v%version%`
- Set environment variable `MARKTEXT_IS_OFFICIAL_RELEASE` to `1` (default on AppVeyor and Travis CI) - Set environment variable `MARKTEXT_IS_STABLE` to `1` (default on AppVeyor and Travis CI)
- Ensure [changelog](https://github.com/marktext/marktext/blob/master/.github/CHANGELOG.md) is up-to-date - Ensure [changelog](https://github.com/marktext/marktext/blob/master/.github/CHANGELOG.md) is up-to-date
- Bump version in `package.json` and changelog - Bump version in `package.json` and changelog
- Update all `README.md` files - Update all `README.md` files

View File

@ -11,7 +11,7 @@
"release:mac": "node .electron-vue/build.js && electron-builder build --mac --publish always", "release:mac": "node .electron-vue/build.js && electron-builder build --mac --publish always",
"release:win": "node .electron-vue/build.js && electron-builder build --win --publish always", "release:win": "node .electron-vue/build.js && electron-builder build --win --publish always",
"build": "node .electron-vue/build.js && electron-builder", "build": "node .electron-vue/build.js && electron-builder",
"build:dir": "node .electron-vue/build.js && electron-builder --dir", "build:bin": "node .electron-vue/build.js && electron-builder --dir",
"build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js", "build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js",
"build:dev": "node .electron-vue/build.js", "build:dev": "node .electron-vue/build.js",
"dev": "node .electron-vue/dev-runner.js", "dev": "node .electron-vue/dev-runner.js",
@ -189,7 +189,6 @@
"prismjs": "^1.16.0", "prismjs": "^1.16.0",
"snabbdom": "^0.7.3", "snabbdom": "^0.7.3",
"snabbdom-to-html": "^5.1.1", "snabbdom-to-html": "^5.1.1",
"snapsvg": "^0.5.1",
"source-map-support": "^0.5.12", "source-map-support": "^0.5.12",
"turndown": "^5.0.3", "turndown": "^5.0.3",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
@ -226,7 +225,6 @@
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"electron": "^5.0.1", "electron": "^5.0.1",
"electron-builder": "^20.40.2", "electron-builder": "^20.40.2",
"electron-debug": "^3.0.0",
"electron-devtools-installer": "^2.2.4", "electron-devtools-installer": "^2.2.4",
"electron-rebuild": "^1.8.4", "electron-rebuild": "^1.8.4",
"electron-updater": "^4.0.6", "electron-updater": "^4.0.6",

File diff suppressed because it is too large Load Diff

View File

@ -9,3 +9,9 @@ Categories=Office;TextEditor;Utility;
MimeType=text/markdown; MimeType=text/markdown;
Keywords=marktext; Keywords=marktext;
StartupWMClass=marktext StartupWMClass=marktext
Actions=NewWindow;
[Desktop Action NewWindow]
Name=New Window
Exec=marktext --new-window %F
Icon=marktext

View File

@ -5,7 +5,6 @@
<title>Mark Text</title> <title>Mark Text</title>
<style> <style>
html, body { html, body {
background: #ffffff;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@ -18,7 +17,12 @@
<% } %> <% } %>
</head> </head>
<body> <body>
<!-- Initial drag area (for Linux and Windows). -->
<div id="init-drag-region" style="position: absolute;-webkit-app-region: drag;left: 0;right: 0;top: 0;height: 25px;"></div>
<!-- Vue app -->
<div id="app"></div> <div id="app"></div>
<!-- Set `__static` path to static files in production --> <!-- Set `__static` path to static files in production -->
<script> <script>
if (process.env.NODE_ENV !== 'development') window.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\') if (process.env.NODE_ENV !== 'development') window.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\')

View File

@ -1,19 +1,20 @@
import path from 'path' import path from 'path'
import fse from 'fs-extra' import fse from 'fs-extra'
import log from 'electron-log'
import { exec } from 'child_process' import { exec } from 'child_process'
import { app, ipcMain, systemPreferences, clipboard } from 'electron'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import log from 'electron-log'
import { app, BrowserWindow, clipboard, dialog, ipcMain, systemPreferences } from 'electron'
import { isLinux, isOsx } from '../config' import { isLinux, isOsx } from '../config'
import { isDirectory, isMarkdownFileOrLink, normalizeAndResolvePath } from '../filesystem' import parseArgs from '../cli/parser'
import { isChildOfDirectory } from '../filesystem'
import { normalizeMarkdownPath } from '../filesystem/markdown'
import { getMenuItemById } from '../menu' import { getMenuItemById } from '../menu'
import { selectTheme } from '../menu/actions/theme' import { selectTheme } from '../menu/actions/theme'
import { dockMenu } from '../menu/templates' import { dockMenu } from '../menu/templates'
import { watchers } from '../utils/imagePathAutoComplement' import { watchers } from '../utils/imagePathAutoComplement'
import { WindowType } from '../windows/base'
import EditorWindow from '../windows/editor' import EditorWindow from '../windows/editor'
import SettingWindow from '../windows/setting' import SettingWindow from '../windows/setting'
import { WindowType } from './windowManager'
// import ShortcutCapture from 'shortcut-capture'
class App { class App {
@ -42,6 +43,39 @@ class App {
app.commandLine.appendSwitch('enable-experimental-web-platform-features', 'true') app.commandLine.appendSwitch('enable-experimental-web-platform-features', 'true')
} }
app.on('second-instance', (event, argv, workingDirectory) => {
const { _openFilesCache, _windowManager } = this
const args = parseArgs(argv.slice(1))
const buf = []
for (const pathname of args._) {
// Ignore all unknown flags
if (pathname.startsWith('--')) {
continue
}
const info = normalizeMarkdownPath(path.resolve(workingDirectory, pathname))
if (info) {
buf.push(info)
}
}
if (args['--new-window']) {
this._openPathList(buf, true)
return
}
_openFilesCache.push(...buf)
if (_openFilesCache.length) {
this._openFilesToOpen()
} else {
const activeWindow = _windowManager.getActiveWindow()
if (activeWindow) {
activeWindow.bringToFront()
}
}
})
app.on('open-file', this.openFile) // macOS only app.on('open-file', this.openFile) // macOS only
app.on('ready', this.ready) app.on('ready', this.ready)
@ -89,7 +123,9 @@ class App {
} }
ready = () => { ready = () => {
const { _args: args } = this const { _args: args, _openFilesCache } = this
const { preferences } = this._accessor
if (!isOsx && args._.length) { if (!isOsx && args._.length) {
for (const pathname of args._) { for (const pathname of args._) {
// Ignore all unknown flags // Ignore all unknown flags
@ -97,13 +133,21 @@ class App {
continue continue
} }
const info = this.normalizePath(pathname) const info = normalizeMarkdownPath(pathname)
if (info) { if (info) {
this._openFilesCache.push(info) _openFilesCache.push(info)
} }
} }
} }
const { startUpAction, defaultDirectoryToOpen } = preferences.getAll()
if (startUpAction === 'folder' && defaultDirectoryToOpen) {
const info = normalizeMarkdownPath(defaultDirectoryToOpen)
if (info) {
_openFilesCache.unshift(info)
}
}
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
app.dock.setMenu(dockMenu) app.dock.setMenu(dockMenu)
@ -135,11 +179,12 @@ class App {
) )
} }
if (this._openFilesCache.length) { if (_openFilesCache.length) {
this.openFileCache() this._openFilesToOpen()
} else { } else {
this.createEditorWindow() this._createEditorWindow()
} }
// this.shortcutCapture = new ShortcutCapture() // this.shortcutCapture = new ShortcutCapture()
// if (process.env.NODE_ENV === 'development') { // if (process.env.NODE_ENV === 'development') {
// this.shortcutCapture.dirname = path.resolve(path.join(__dirname, '../../../node_modules/shortcut-capture')) // this.shortcutCapture.dirname = path.resolve(path.join(__dirname, '../../../node_modules/shortcut-capture'))
@ -165,7 +210,7 @@ class App {
openFile = (event, pathname) => { openFile = (event, pathname) => {
event.preventDefault() event.preventDefault()
const info = this.normalizePath(pathname) const info = normalizeMarkdownPath(pathname)
if (info) { if (info) {
this._openFilesCache.push(info) this._openFilesCache.push(info)
@ -176,53 +221,37 @@ class App {
} }
this._openFilesTimer = setTimeout(() => { this._openFilesTimer = setTimeout(() => {
this._openFilesTimer = null this._openFilesTimer = null
this.openFileCache() this._openFilesToOpen()
}, 100) }, 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 -------------------------------- // --- private --------------------------------
/** /**
* Creates a new editor window. * Creates a new editor window.
* *
* @param {string} [pathname] Path to a file, directory or link. * @param {string} [rootDirectory] The root directory to open.
* @param {string} [markdown] Markdown content. * @param {string[]} [fileList] A list of markdown files to open.
* @param {*} [options] BrowserWindow options. * @param {string[]} [markdownList] Array of markdown data to open.
* @param {*} [options] The BrowserWindow options.
* @returns {EditorWindow} The created editor window.
*/ */
createEditorWindow (pathname = null, markdown = '', options = {}) { _createEditorWindow (rootDirectory = null, fileList = [], markdownList = [], options = {}) {
const editor = new EditorWindow(this._accessor) const editor = new EditorWindow(this._accessor)
editor.createWindow(pathname, markdown, options) editor.createWindow(rootDirectory, fileList, markdownList, options)
this._windowManager.add(editor) this._windowManager.add(editor)
if (this._windowManager.windowCount === 1) { if (this._windowManager.windowCount === 1) {
this._accessor.menu.setActiveWindow(editor.id) this._accessor.menu.setActiveWindow(editor.id)
} }
return editor
} }
/** /**
* Create a new setting window. * Create a new setting window.
*/ */
createSettingWindow () { _createSettingWindow () {
const setting = new SettingWindow(this._accessor) const setting = new SettingWindow(this._accessor)
setting.createWindow() setting.createWindow()
this._windowManager.add(setting) this._windowManager.add(setting)
@ -231,25 +260,139 @@ class App {
} }
} }
// TODO(sessions): ... _openFilesToOpen () {
// // Make Mark Text a single instance application. this._openPathList(this._openFilesCache, false)
// _makeSingleInstance() { }
// if (process.mas) return
// /**
// app.requestSingleInstanceLock() * Open the path list in the best window(s).
// *
// app.on('second-instance', (event, argv, workingDirectory) => { * @param {string[]} pathsToOpen The path list to open.
// // // TODO: Get active/last active window and open process arvg etc * @param {boolean} openFilesInSameWindow Open all files in the same window with
// // if (currentWindow) { * the first directory and discard other directories.
// // if (currentWindow.isMinimized()) currentWindow.restore() */
// // currentWindow.focus() _openPathList (pathsToOpen, openFilesInSameWindow=false) {
// // } const { _windowManager } = this
// }) const openFilesInNewWindow = this._accessor.preferences.getItem('openFilesInNewWindow')
// }
const fileSet = new Set()
const directorySet = new Set()
for (const { isDir, path } of pathsToOpen) {
if (isDir) {
directorySet.add(path)
} else {
fileSet.add(path)
}
}
// Filter out directories that are already opened.
for (const window of _windowManager.windows.values()) {
if (window.type === WindowType.EDITOR) {
const { openedRootDirectory } = window
if (directorySet.has(openedRootDirectory)) {
window.bringToFront()
directorySet.delete(openedRootDirectory)
}
}
}
const directoriesToOpen = Array.from(directorySet).map(dir => ({ rootDirectory: dir, fileList: [] }))
const filesToOpen = Array.from(fileSet)
// Discard all directories except first one and add files.
if (openFilesInSameWindow) {
if (directoriesToOpen.length) {
directoriesToOpen[0].fileList.push(...filesToOpen)
directoriesToOpen.length = 1
} else {
directoriesToOpen.push({ rootDirectory: null, fileList: filesToOpen })
}
filesToOpen.length = 0
}
// Find the best window(s) to open the files in.
if (!openFilesInSameWindow && !openFilesInNewWindow) {
const isFirstWindow = _windowManager.getActiveEditorId() === null
// Prefer new directories
for (let i = 0; i < directoriesToOpen.length; ++i) {
const { fileList, rootDirectory } = directoriesToOpen[i]
let breakOuterLoop = false
for (let j = 0; j < filesToOpen.length; ++j) {
const pathname = filesToOpen[j]
if (isChildOfDirectory(rootDirectory, pathname)) {
if (isFirstWindow) {
fileList.push(...filesToOpen)
filesToOpen.length = 0
breakOuterLoop = true
break
}
fileList.push(pathname)
filesToOpen.splice(j, 1)
--j
}
}
if (breakOuterLoop) {
break
}
}
// Find for the remaining files the best window to open the files in.
if (isFirstWindow && directoriesToOpen.length && filesToOpen.length) {
const { fileList } = directoriesToOpen[0]
fileList.push(...filesToOpen)
filesToOpen.length = 0
} else {
const windowList = _windowManager.findBestWindowToOpenIn(filesToOpen)
for (const item of windowList) {
const { windowId, fileList } = item
// File list is empty when all files are already opened.
if (fileList.length === 0) {
continue
}
if (windowId !== null) {
const window = _windowManager.get(windowId)
if (window) {
window.openTabs(fileList, 0)
window.bringToFront()
continue
}
// else: fallthrough
}
this._createEditorWindow(null, fileList)
}
}
// Directores are always opened in a new window if not already opened.
for (const item of directoriesToOpen) {
const { rootDirectory, fileList } = item
this._createEditorWindow(rootDirectory, fileList)
}
}
// Open each file and directory in a new window.
else {
for (const pathname of filesToOpen) {
this._createEditorWindow(null, [ pathname ])
}
for (const item of directoriesToOpen) {
const { rootDirectory, fileList } = item
this._createEditorWindow(rootDirectory, fileList)
}
}
// Empty the file list
pathsToOpen.length = 0
}
_listenForIpcMain () { _listenForIpcMain () {
ipcMain.on('app-create-editor-window', () => { ipcMain.on('app-create-editor-window', () => {
this.createEditorWindow() this._createEditorWindow()
}) })
ipcMain.on('screen-capture', win => { ipcMain.on('screen-capture', win => {
@ -281,7 +424,7 @@ class App {
}) })
ipcMain.on('app-create-settings-window', () => { ipcMain.on('app-create-settings-window', () => {
const settingWins = this._windowManager.windowsOfType(WindowType.SETTING) const settingWins = this._windowManager.getWindowsByType(WindowType.SETTING)
if (settingWins.length >= 1) { if (settingWins.length >= 1) {
// A setting window is already created // A setting window is already created
const browserSettingWindow = settingWins[0].win.browserWindow const browserSettingWindow = settingWins[0].win.browserWindow
@ -292,42 +435,77 @@ class App {
} }
return return
} }
this.createSettingWindow() this._createSettingWindow()
}) })
// 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) => { ipcMain.on('app-open-file-by-id', (windowId, filePath) => {
const { openFilesInNewWindow } = this._accessor.preferences.getAll() const openFilesInNewWindow = this._accessor.preferences.getItem('openFilesInNewWindow')
if (openFilesInNewWindow) { if (openFilesInNewWindow) {
this.createEditorWindow(filePath) this._createEditorWindow(null, [ filePath ])
} else { } else {
const editor = this._windowManager.get(windowId) const editor = this._windowManager.get(windowId)
if (editor && !editor.quitting) { if (editor) {
editor.openTab(filePath, true) editor.openTab(filePath, true)
} }
} }
}) })
ipcMain.on('app-open-files-by-id', (windowId, fileList) => {
const openFilesInNewWindow = this._accessor.preferences.getItem('openFilesInNewWindow')
if (openFilesInNewWindow) {
this._createEditorWindow(null, fileList)
} else {
const editor = this._windowManager.get(windowId)
if (editor) {
editor.openTabs(
fileList.map(p => normalizeMarkdownPath(p))
.filter(i => i && !i.isDir)
.map(i => i.path),
0)
}
}
})
ipcMain.on('app-open-markdown-by-id', (windowId, data) => { ipcMain.on('app-open-markdown-by-id', (windowId, data) => {
const { openFilesInNewWindow } = this._accessor.preferences.getAll() const openFilesInNewWindow = this._accessor.preferences.getItem('openFilesInNewWindow')
if (openFilesInNewWindow) { if (openFilesInNewWindow) {
this.createEditorWindow(undefined, data) this._createEditorWindow(null, [], [ data ])
} else { } else {
const editor = this._windowManager.get(windowId) const editor = this._windowManager.get(windowId)
if (editor && !editor.quitting) { if (editor) {
editor.openUntitledTab(true, data) editor.openUntitledTab(true, data)
} }
} }
}) })
ipcMain.on('app-open-directory-by-id', (windowId, pathname) => { ipcMain.on('app-open-directory-by-id', (windowId, pathname, openInSameWindow) => {
// TODO: Open the directory in an existing window if prefered. const { openFolderInNewWindow } = this._accessor.preferences.getAll()
this.createEditorWindow(pathname) if (openInSameWindow || !openFolderInNewWindow) {
const editor = this._windowManager.get(windowId)
if (editor) {
editor.openFolder(pathname)
return
}
}
this._createEditorWindow(pathname)
}) })
// --- renderer -------------------
ipcMain.on('mt::select-default-directory-to-open', e => {
const { preferences } = this._accessor
const { defaultDirectoryToOpen } = preferences.getAll()
const win = BrowserWindow.fromWebContents(e.sender)
dialog.showOpenDialog(win, {
defaultPath: defaultDirectoryToOpen,
properties: ['openDirectory', 'createDirectory']
}, paths => {
if (paths) {
preferences.setItems({ defaultDirectoryToOpen: paths[0] })
}
})
})
} }
} }

View File

@ -1,22 +1,8 @@
import { app, BrowserWindow, ipcMain } from 'electron' import { app, BrowserWindow, ipcMain } from 'electron'
import EventEmitter from 'events' import EventEmitter from 'events'
import log from 'electron-log' import log from 'electron-log'
import Watcher from '../filesystem/watcher' import Watcher, { WATCHER_STABILITY_THRESHOLD, WATCHER_STABILITY_POLL_INTERVAL } from '../filesystem/watcher'
import { WindowType } from '../windows/base'
/**
* 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.
*/
// Window type marktext support.
export const WindowType = {
BASE: 'base', // You shold never create a `BASE` window.
EDITOR: 'editor',
SETTING: 'setting'
}
class WindowActivityList { class WindowActivityList {
constructor() { constructor() {
@ -33,6 +19,14 @@ class WindowActivityList {
return null return null
} }
getSecondNewest () {
const { _buf } = this
if (_buf.length >= 2) {
return _buf[_buf.length - 2]
}
return null
}
setNewest (id) { setNewest (id) {
// I think we do not need a linked list for only a few windows. // I think we do not need a linked list for only a few windows.
const { _buf } = this const { _buf } = this
@ -72,7 +66,7 @@ class WindowManager extends EventEmitter {
this._windows = new Map() this._windows = new Map()
this._windowActivity = new WindowActivityList() this._windowActivity = new WindowActivityList()
// TODO(need::refactor): We should move watcher and search into another process/thread(?) // TODO(need::refactor): Please see #1035.
this._watcher = new Watcher(preferences) this._watcher = new Watcher(preferences)
this._listenForIpcMain() this._listenForIpcMain()
@ -84,19 +78,23 @@ class WindowManager extends EventEmitter {
* @param {IApplicationWindow} window The application window. We take ownership! * @param {IApplicationWindow} window The application window. We take ownership!
*/ */
add (window) { add (window) {
this._windows.set(window.id, window) const { id: windowId } = window
this._windows.set(windowId, window)
if (!this._appMenu.has(window.id)) { if (!this._appMenu.has(windowId)) {
this._appMenu.addDefaultMenu(window.id) this._appMenu.addDefaultMenu(windowId)
} }
if (this.windowCount === 1) { if (this.windowCount === 1) {
this.setActiveWindow(window.id) this.setActiveWindow(windowId)
} }
const { browserWindow } = window
window.on('window-focus', () => { window.on('window-focus', () => {
this.setActiveWindow(browserWindow.id) this.setActiveWindow(windowId)
})
window.on('window-closed', () => {
this.remove(windowId)
this._watcher.unwatchByWindowId(windowId)
}) })
} }
@ -104,7 +102,7 @@ class WindowManager extends EventEmitter {
* Return the application window by id. * Return the application window by id.
* *
* @param {string} windowId The window id. * @param {string} windowId The window id.
* @returns {IApplicationWindow} The application window or undefined. * @returns {BaseWindow} The application window or undefined.
*/ */
get (windowId) { get (windowId) {
return this._windows.get(windowId) return this._windows.get(windowId)
@ -127,7 +125,7 @@ class WindowManager extends EventEmitter {
/** /**
* Remove the given window by id. * Remove the given window by id.
* *
* NOTE: All window event listeners are removed! * NOTE: All window "window-focus" events listeners are removed!
* *
* @param {string} windowId The window id. * @param {string} windowId The window id.
* @returns {IApplicationWindow} Returns the application window. We no longer take ownership. * @returns {IApplicationWindow} Returns the application window. We no longer take ownership.
@ -136,7 +134,7 @@ class WindowManager extends EventEmitter {
const { _windows } = this const { _windows } = this
const window = this.get(windowId) const window = this.get(windowId)
if (window) { if (window) {
window.removeAllListeners() window.removeAllListeners('window-focus')
this._windowActivity.delete(windowId) this._windowActivity.delete(windowId)
let nextWindowId = this._windowActivity.getNewest() let nextWindowId = this._windowActivity.getNewest()
@ -160,29 +158,54 @@ class WindowManager extends EventEmitter {
} }
/** /**
* Returns the active window id or null if no window is registred. * Returns the active window or null if no window is registered.
* @returns {number|null} * @returns {BaseWindow|undefined}
*/ */
getActiveWindow () { getActiveWindow () {
return this._windows.get(this._activeWindowId)
}
/**
* Returns the active window id or null if no window is registered.
* @returns {number|null}
*/
getActiveWindowId () {
return this._activeWindowId return this._activeWindowId
} }
get windows () { /**
return this._windows * Returns the (last) active editor window or null if no editor is registered.
* @returns {EditorWindow|undefined}
*/
getActiveEditor () {
let win = this.getActiveWindow()
if (win && win.type !== WindowType.EDITOR) {
win = this._windows.get(this._windowActivity.getSecondNewest())
if (win && win.type === WindowType.EDITOR) {
return win
}
return undefined
}
return win
} }
get windowCount () { /**
return this._windows.size * Returns the (last) active editor window id or null if no editor is registered.
* @returns {number|null}
*/
getActiveEditorId () {
const win = this.getActiveEditor()
return win ? win.id : null
} }
/** /**
* *
* @param {type} type the WindowType one of ['base', 'editor', 'setting'] * @param {WindowType} type the WindowType one of ['base', 'editor', 'setting']
* Return the windows of the given {type} * @returns {{id: number, win: BaseWindow}[]} Return the windows of the given {type}
*/ */
windowsOfType (type) { getWindowsByType (type) {
if (!WindowType[type.toUpperCase()]) { if (!WindowType[type.toUpperCase()]) {
console.error(`${type} is not a valid window type.`) console.error(`"${type}" is not a valid window type.`)
} }
const { windows } = this const { windows } = this
const result = [] const result = []
@ -197,10 +220,75 @@ class WindowManager extends EventEmitter {
return result return result
} }
/**
* Find the best window to open the files in.
*
* @param {string[]} fileList File full paths.
* @returns {{windowId: string, fileList: string[]}[]} An array of files mapped to a window id or null to open in a new window.
*/
findBestWindowToOpenIn (fileList) {
if (!fileList || !Array.isArray(fileList) || !fileList.length) return []
const { windows } = this
const lastActiveEditorId = this.getActiveEditorId() // editor id or null
if (this.windowCount <= 1) {
return [ { windowId: lastActiveEditorId, fileList } ]
}
// Array of scores, same order like fileList.
let filePathScores = null
for (const window of windows.values()) {
if (window.type === WindowType.EDITOR) {
const scores = window.getCandidateScores(fileList)
if (!filePathScores) {
filePathScores = scores
} else {
const len = filePathScores.length
for (let i = 0; i < len; ++i) {
// Update score only if the file is not already opened.
if (filePathScores[i].score !== -1 && filePathScores[i].score < scores[i].score) {
filePathScores[i] = scores[i]
}
}
}
}
}
const buf = []
const len = filePathScores.length
for (let i = 0; i < len; ++i) {
let { id: windowId, score } = filePathScores[i]
if (score === -1) {
// Skip files that already opened.
continue
} else if (score === 0) {
// There is no best window to open the file(s) in.
windowId = lastActiveEditorId
}
let item = buf.find(w => w.windowId === windowId)
if (!item) {
item = { windowId, fileList: [] }
buf.push(item)
}
item.fileList.push(fileList[i])
}
return buf
}
get windows () {
return this._windows
}
get windowCount () {
return this._windows.size
}
// --- helper --------------------------------- // --- helper ---------------------------------
closeWatcher () { closeWatcher () {
this._watcher.clear() this._watcher.close()
} }
/** /**
@ -213,15 +301,15 @@ class WindowManager extends EventEmitter {
return false return false
} }
const { id } = browserWindow const { id: windowId } = browserWindow
const { _appMenu, _windows } = this const { _appMenu, _windows } = this
// Free watchers used by this window // Free watchers used by this window
this._watcher.unWatchWin(browserWindow) this._watcher.unwatchByWindowId(windowId)
// Application clearup and remove listeners // Application clearup and remove listeners
_appMenu.removeWindowMenu(id) _appMenu.removeWindowMenu(windowId)
const window = this.remove(id) const window = this.remove(windowId)
// Destroy window wrapper and browser window // Destroy window wrapper and browser window
if (window) { if (window) {
@ -251,32 +339,39 @@ class WindowManager extends EventEmitter {
return false return false
} }
// --- events --------------------------------- // --- private --------------------------------
_listenForIpcMain () { _listenForIpcMain () {
// listen for file watch from renderer process eg // HACK: Don't use this event! Please see #1034 and #1035
// 1. click file in folder. ipcMain.on('AGANI::window-add-file-path', (e, filePath) => {
// 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) const win = BrowserWindow.fromWebContents(e.sender)
if (watch) { const editor = this.get(win.id)
// listen for file `change` and `unlink` if (!editor) {
this._watcher.watch(win, pathname, 'file') log.error(`Cannot find window id "${win.id}" to add opened file.`)
} else { return
// unlisten for file `change` and `unlink`
this._watcher.unWatch(win, pathname, 'file')
} }
editor.addToOpenedFiles(filePath)
}) })
// Force close a BrowserWindow // Force close a BrowserWindow
ipcMain.on('AGANI::close-window', e => { ipcMain.on('mt::close-window', e => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
this.forceClose(win) this.forceClose(win)
}) })
ipcMain.on('mt::window-tab-closed', (e, pathname) => {
const win = BrowserWindow.fromWebContents(e.sender)
const editor = this.get(win.id)
if (editor) {
editor.removeFromOpenedFiles(pathname)
}
})
// --- local events --------------- // --- local events ---------------
ipcMain.on('watcher-unwatch-all-by-id', windowId => {
this._watcher.unwatchByWindowId(windowId)
})
ipcMain.on('watcher-watch-file', (win, filePath) => { ipcMain.on('watcher-watch-file', (win, filePath) => {
this._watcher.watch(win, filePath, 'file') this._watcher.watch(win, filePath, 'file')
}) })
@ -284,17 +379,44 @@ class WindowManager extends EventEmitter {
this._watcher.watch(win, pathname, 'dir') this._watcher.watch(win, pathname, 'dir')
}) })
ipcMain.on('watcher-unwatch-file', (win, filePath) => { ipcMain.on('watcher-unwatch-file', (win, filePath) => {
this._watcher.unWatch(win, filePath, 'file') this._watcher.unwatch(win, filePath, 'file')
}) })
ipcMain.on('watcher-unwatch-directory', (win, pathname) => { ipcMain.on('watcher-unwatch-directory', (win, pathname) => {
this._watcher.unWatch(win, pathname, 'dir') this._watcher.unwatch(win, pathname, 'dir')
})
ipcMain.on('window-add-file-path', (windowId, filePath) => {
const editor = this.get(windowId)
if (!editor) {
log.error(`Cannot find window id "${windowId}" to add opened file.`)
return
}
editor.addToOpenedFiles(filePath)
})
ipcMain.on('window-change-file-path', (windowId, pathname, oldPathname) => {
const editor = this.get(windowId)
if (!editor) {
log.error(`Cannot find window id "${windowId}" to change file path.`)
return
}
editor.changeOpenedFilePath(pathname, oldPathname)
})
ipcMain.on('window-file-saved', (windowId, pathname) => {
// A changed event is emitted earliest after the stability threshold.
const duration = WATCHER_STABILITY_THRESHOLD + (WATCHER_STABILITY_POLL_INTERVAL * 2)
this._watcher.ignoreChangedEvent(windowId, pathname, duration)
}) })
// Force close a window by id.
ipcMain.on('window-close-by-id', id => { ipcMain.on('window-close-by-id', id => {
this.forceCloseById(id) this.forceCloseById(id)
}) })
ipcMain.on('window-reload-by-id', id => {
const window = this.get(id)
if (window) {
window.reload()
}
})
ipcMain.on('window-toggle-always-on-top', win => { ipcMain.on('window-toggle-always-on-top', win => {
const flag = !win.isAlwaysOnTop() const flag = !win.isAlwaysOnTop()
win.setAlwaysOnTop(flag) win.setAlwaysOnTop(flag)

View File

@ -24,7 +24,9 @@ const cli = () => {
--debug Enable debug mode --debug Enable debug mode
--safe Disable plugins and other user configuration --safe Disable plugins and other user configuration
--dump-keyboard-layout Dump keyboard information --dump-keyboard-layout Dump keyboard information
-n, --new-window Open a new window on second-instance
--user-data-dir Change the user data directory --user-data-dir Change the user data directory
--disable-gpu Disable GPU hardware acceleration
-v, --verbose Be verbose -v, --verbose Be verbose
--version Print version information --version Print version information
-h, --help Print this help message -h, --help Print this help message

View File

@ -7,8 +7,8 @@ import arg from 'arg'
* @param {boolean} permissive If set to false an exception is throw about unknown flags. * @param {boolean} permissive If set to false an exception is throw about unknown flags.
* @returns {arg.Result} Parsed arguments * @returns {arg.Result} Parsed arguments
*/ */
const parseArgs = (argv=null, permissive=true) => { const parseArgs = (argv = null, permissive = true) => {
if (argv == null) { if (argv === null) {
argv = process.argv.slice(1) argv = process.argv.slice(1)
} }
const spec = { const spec = {
@ -16,6 +16,10 @@ const parseArgs = (argv=null, permissive=true) => {
'--safe': Boolean, '--safe': Boolean,
'--dump-keyboard-layout': Boolean, '--dump-keyboard-layout': Boolean,
'--new-window': Boolean,
'-n': '--new-window',
'--disable-gpu': Boolean,
'--user-data-dir': String, '--user-data-dir': String,
// Misc // Misc

View File

@ -2,7 +2,7 @@ export const isOsx = process.platform === 'darwin'
export const isWindows = process.platform === 'win32' export const isWindows = process.platform === 'win32'
export const isLinux = process.platform === 'linux' export const isLinux = process.platform === 'linux'
export const defaultWinOptions = { export const editorWinOptions = {
minWidth: 450, minWidth: 450,
minHeight: 220, minHeight: 220,
webPreferences: { webPreferences: {
@ -10,7 +10,7 @@ export const defaultWinOptions = {
webSecurity: false webSecurity: false
}, },
useContentSize: true, useContentSize: true,
show: false, show: true,
frame: false, frame: false,
titleBarStyle: 'hiddenInset' titleBarStyle: 'hiddenInset'
} }
@ -31,8 +31,7 @@ export const defaultPreferenceWinOptions = {
show: false, show: false,
frame: false, frame: false,
thickFrame: !isOsx, thickFrame: !isOsx,
titleBarStyle: 'hiddenInset', titleBarStyle: 'hiddenInset'
center: true
} }
export const EXTENSIONS = [ export const EXTENSIONS = [

View File

@ -22,16 +22,14 @@ const getOSInformation = () => {
return `${os.type()} ${os.arch()} ${os.release()} (${os.platform()})` return `${os.type()} ${os.arch()} ${os.release()} (${os.platform()})`
} }
const bundleException = (error, type) => { const exceptionToString = (error, type) => {
const { message, stack } = error const { message, stack } = error
return { return `Version: ${global.MARKTEXT_VERSION_STRING || app.getVersion()}\n` +
version: global.MARKTEXT_VERSION_STRING || app.getVersion(), `OS: ${getOSInformation()}\n` +
os: getOSInformation(), `Type: ${type}\n` +
type, `Date: ${new Date().toGMTString()}\n` +
date: new Date().toGMTString(), `Message: ${message}\n` +
message, `Stack: ${stack}\n`
stack
}
} }
const handleError = (title, error, type) => { const handleError = (title, error, type) => {
@ -39,8 +37,7 @@ const handleError = (title, error, type) => {
// Write error into file // Write error into file
if (type === 'main') { if (type === 'main') {
const info = bundleException(error, type) logger(exceptionToString(error, type))
logger(JSON.stringify(info, null, 2))
} }
if (EXIT_ON_ERROR) { if (EXIT_ON_ERROR) {
@ -48,12 +45,13 @@ const handleError = (title, error, type) => {
process.exit(1) process.exit(1)
// eslint, don't lie to me, the return statement is important! // eslint, don't lie to me, the return statement is important!
return // eslint-disable-line no-unreachable return // eslint-disable-line no-unreachable
} else if (!SHOW_ERROR_DIALOG || (global.MARKTEXT_IS_OFFICIAL_RELEASE && type === 'renderer')) { } else if (!SHOW_ERROR_DIALOG || (global.MARKTEXT_IS_STABLE && type === 'renderer')) {
return return
} }
// show error dialog // show error dialog
if (app.isReady()) { if (app.isReady()) {
// Blocking message box
const result = dialog.showMessageBox({ const result = dialog.showMessageBox({
type: 'error', type: 'error',
buttons: [ buttons: [

View File

@ -3,6 +3,22 @@ import path from 'path'
import { hasMarkdownExtension } from '../utils' import { hasMarkdownExtension } from '../utils'
import { IMAGE_EXTENSIONS } from '../config' import { IMAGE_EXTENSIONS } from '../config'
/**
* Test whether or not the given path exists.
*
* @param {string} p The path to the file or directory.
* @returns {boolean}
*/
export const exists = async p => {
// fs.exists is deprecated.
try {
await fs.access(p)
return true
} catch(_) {
return false
}
}
/** /**
* Ensure that a directory exist. * Ensure that a directory exist.
* *
@ -97,6 +113,45 @@ export const isMarkdownFileOrLink = filepath => {
return false return false
} }
/**
* Check if the both paths point to the same file.
*
* @param {string} pathA The first path.
* @param {string} pathB The second path.
* @param {boolean} [isNormalized] Are both paths already normalized.
*/
export const isSamePathSync = (pathA, pathB, isNormalized = false) => {
if (!pathA || !pathB) return false
const a = isNormalized ? pathA : path.normalize(pathA)
const b = isNormalized ? pathB : path.normalize(pathB)
if (a.length !== b.length) {
return false
} else if (a === b) {
return true
} else if (a.toLowerCase() === b.toLowerCase()) {
try {
const fiA = fs.statSync(a)
const fiB = fs.statSync(b)
return fiA.ino === fiB.ino
} catch (_) {
// Ignore error
}
}
return false
}
/**
* Check whether a file or directory is a child of the given directory.
*
* @param {string} dir The parent directory.
* @param {string} child The file or directory path to check.
*/
export const isChildOfDirectory = (dir, child) => {
if (!dir || !child) return false
const relative = path.relative(dir, child)
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
}
/** /**
* Normalize the path into an absolute path and resolves the link target if needed. * Normalize the path into an absolute path and resolves the link target if needed.
* *

View File

@ -2,7 +2,7 @@ import fs from 'fs-extra'
import path from 'path' import path from 'path'
import log from 'electron-log' import log from 'electron-log'
import { LINE_ENDING_REG, LF_LINE_ENDING_REG, CRLF_LINE_ENDING_REG } from '../config' import { LINE_ENDING_REG, LF_LINE_ENDING_REG, CRLF_LINE_ENDING_REG } from '../config'
import { writeFile } from '../filesystem' import { isDirectory, isMarkdownFileOrLink, normalizeAndResolvePath , writeFile } from '../filesystem'
const getLineEnding = lineEnding => { const getLineEnding = lineEnding => {
if (lineEnding === 'lf') { if (lineEnding === 'lf') {
@ -20,6 +20,27 @@ const convertLineEndings = (text, lineEnding) => {
return text.replace(LINE_ENDING_REG, getLineEnding(lineEnding)) return text.replace(LINE_ENDING_REG, getLineEnding(lineEnding))
} }
/**
* Special function to normalize directory and markdown file paths.
*
* @param {string} pathname The path to the file or directory.
* @returns {{isDir: boolean, path: string}?} Returns the normalize path and a
* directory hint or null if it's not a directory or markdown file.
*/
export const normalizeMarkdownPath = 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
}
/** /**
* Write the content into a file. * Write the content into a file.
* *

View File

@ -1,23 +1,24 @@
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs-extra'
import log from 'electron-log' import log from 'electron-log'
import { promisify } from 'util'
import chokidar from 'chokidar' import chokidar from 'chokidar'
import { getUniqueId, hasMarkdownExtension } from '../utils' import { getUniqueId, hasMarkdownExtension } from '../utils'
import { exists } from '../filesystem'
import { loadMarkdownFile } from '../filesystem/markdown' import { loadMarkdownFile } from '../filesystem/markdown'
import { isLinux } from '../config' import { isLinux } from '../config'
// TODO(need::refactor): // TODO(refactor): Please see GH#1035.
// - Refactor this file
// - Outsource watcher/search features into worker (per window) and use something like "file-matcher" for searching on disk. export const WATCHER_STABILITY_THRESHOLD = 1000
export const WATCHER_STABILITY_POLL_INTERVAL = 150
const EVENT_NAME = { const EVENT_NAME = {
dir: 'AGANI::update-object-tree', dir: 'AGANI::update-object-tree',
file: 'AGANI::update-file' file: 'AGANI::update-file'
} }
const add = async (win, pathname, endOfLine) => { const add = async (win, pathname, type, endOfLine) => {
const stats = await promisify(fs.stat)(pathname) const stats = await fs.stat(pathname)
const birthTime = stats.birthtime const birthTime = stats.birthtime
const isMarkdown = hasMarkdownExtension(pathname) const isMarkdown = hasMarkdownExtension(pathname)
const file = { const file = {
@ -29,11 +30,24 @@ const add = async (win, pathname, endOfLine) => {
isMarkdown isMarkdown
} }
if (isMarkdown) { if (isMarkdown) {
const data = await loadMarkdownFile(pathname, endOfLine) // HACK: But this should be removed completely in #1034/#1035.
file.data = data try {
const data = await loadMarkdownFile(pathname, endOfLine)
file.data = data
} catch(err) {
// Only notify user about opened files.
if (type === 'file') {
win.webContents.send('AGANI::show-notification', {
title: 'Watcher I/O error',
type: 'error',
message: err.message
})
return
}
}
} }
win.webContents.send('AGANI::update-object-tree', { win.webContents.send(EVENT_NAME[type], {
type: 'add', type: 'add',
change: file change: file
}) })
@ -49,21 +63,35 @@ const unlink = (win, pathname, type) => {
const change = async (win, pathname, type, endOfLine) => { const change = async (win, pathname, type, endOfLine) => {
const isMarkdown = hasMarkdownExtension(pathname) const isMarkdown = hasMarkdownExtension(pathname)
if (isMarkdown) { if (isMarkdown) {
const data = await loadMarkdownFile(pathname, endOfLine) // HACK: Markdown data should be removed completely in #1034/#1035 and
const file = { // should be only loaded after user interaction.
pathname, try {
data const data = await loadMarkdownFile(pathname, endOfLine)
const file = {
pathname,
data
}
win.webContents.send(EVENT_NAME[type], {
type: 'change',
change: file
})
} catch (err) {
// Only notify user about opened files.
if (type === 'file') {
win.webContents.send('AGANI::show-notification', {
title: 'Watcher I/O error',
type: 'error',
message: err.message
})
}
} }
win.webContents.send(EVENT_NAME[type], {
type: 'change',
change: file
})
} }
} }
const addDir = (win, pathname) => { const addDir = (win, pathname, type) => {
if (type === 'file') return
const directory = { const directory = {
pathname, pathname,
name: path.basename(pathname), name: path.basename(pathname),
@ -81,7 +109,9 @@ const addDir = (win, pathname) => {
}) })
} }
const unlinkDir = (win, pathname) => { const unlinkDir = (win, pathname, type) => {
if (type === 'file') return
const directory = { pathname } const directory = { pathname }
win.webContents.send('AGANI::update-object-tree', { win.webContents.send('AGANI::update-object-tree', {
type: 'unlinkDir', type: 'unlinkDir',
@ -96,61 +126,117 @@ class Watcher {
*/ */
constructor (preferences) { constructor (preferences) {
this._preferences = preferences this._preferences = preferences
this._ignoreChangeEvents = []
this.watchers = {} this.watchers = {}
} }
// return a unwatch function // Watch a file or directory and return a unwatch function.
watch (win, watchPath, type = 'dir'/* file or dir */) { watch (win, watchPath, type = 'dir'/* file or dir */) {
const id = getUniqueId() const id = getUniqueId()
const watcher = chokidar.watch(watchPath, { const watcher = chokidar.watch(watchPath, {
ignored: /(^|[/\\])(\..|node_modules)/, ignored: /(^|[/\\])(\..|node_modules)/,
ignoreInitial: type === 'file', ignoreInitial: type === 'file',
persistent: true persistent: true,
ignorePermissionErrors: true,
// Just to be sure when a file is replaced with a directory don't watch recursively.
depth: type === 'file' ? 0 : undefined,
// Please see GH#1043
awaitWriteFinish: {
stabilityThreshold: WATCHER_STABILITY_THRESHOLD,
pollInterval: WATCHER_STABILITY_POLL_INTERVAL
}
}) })
let disposed = false
let enospcReached = false
let renameTimer = null
watcher watcher
.on('add', pathname => add(win, pathname, this._preferences.getPreferedEOL())) .on('add', pathname => {
.on('change', pathname => change(win, pathname, type, this._preferences.getPreferedEOL())) if (!this._shouldIgnoreEvent(win.id, pathname, type)) {
add(win, pathname, type, this._preferences.getPreferedEOL())
}
})
.on('change', pathname => {
if (!this._shouldIgnoreEvent(win.id, pathname, type)) {
change(win, pathname, type, this._preferences.getPreferedEOL())
}
})
.on('unlink', pathname => unlink(win, pathname, type)) .on('unlink', pathname => unlink(win, pathname, type))
.on('addDir', pathname => addDir(win, pathname)) .on('addDir', pathname => addDir(win, pathname, type))
.on('unlinkDir', pathname => unlinkDir(win, pathname)) .on('unlinkDir', pathname => unlinkDir(win, pathname, type))
.on('raw', (event, path, details) => { .on('raw', (event, subpath, details) => {
if (global.MARKTEXT_DEBUG_VERBOSE >= 3) { if (global.MARKTEXT_DEBUG_VERBOSE >= 3) {
console.log(event, path, details) console.log('watcher: ', event, subpath, details)
} }
// rename syscall on Linux (chokidar#591) // Fix atomic rename on Linux (chokidar#591).
// TODO: This should also apply to macOS.
// TODO: Do we need to rewatch when the watched directory was renamed?
if (isLinux && type === 'file' && event === 'rename') { if (isLinux && type === 'file' && event === 'rename') {
const { watchedPath } = details if (renameTimer) {
// Use the same watcher and re-watch the file. clearTimeout(renameTimer)
watcher.unwatch(watchedPath) }
watcher.add(watchedPath) renameTimer = setTimeout(async () => {
renameTimer = null
if (disposed) return
const fileExists = await exists(watchPath)
if (fileExists) {
// File still exists but we need to rewatch the file because the inode has changed.
watcher.unwatch(watchPath)
watcher.add(watchPath)
}
}, 150)
} }
}) })
.on('error', error => { .on('error', error => {
const msg = `Watcher error: ${error}` // Check if too many file descriptors are opened and notify the user about this issue.
console.log(msg) if (error.code === 'ENOSPC') {
log.error(msg) if (!enospcReached) {
enospcReached = true
log.warn('inotify limit reached: Too many file descriptors are opened.')
win.webContents.send('AGANI::show-notification', {
title: 'inotify limit reached',
type: 'warning',
message: 'Cannot watch all files and file changes because too many file descriptors are opened.'
})
}
} else {
log.error(error)
}
}) })
const closeFn = () => {
disposed = true
if (this.watchers[id]) {
delete this.watchers[id]
}
if (renameTimer) {
clearTimeout(renameTimer)
renameTimer = null
}
watcher.close()
}
this.watchers[id] = { this.watchers[id] = {
win, win,
watcher, watcher,
pathname: watchPath, pathname: watchPath,
type type,
close: closeFn
} }
// unwatcher function // unwatcher function
return () => { return closeFn
if (this.watchers[id]) {
delete this.watchers[id]
}
watcher.close()
}
} }
// unWatch some single watch // Remove a single watcher.
unWatch (win, watchPath, type = 'dir') { unwatch (win, watchPath, type = 'dir') {
for (const id of Object.keys(this.watchers)) { for (const id of Object.keys(this.watchers)) {
const w = this.watchers[id] const w = this.watchers[id]
if ( if (
@ -165,13 +251,13 @@ class Watcher {
} }
} }
// unwatch for one window, (remove all the watchers in one window) // Remove all watchers from the given window id.
unWatchWin (win) { unwatchByWindowId (windowId) {
const watchers = [] const watchers = []
const watchIds = [] const watchIds = []
for (const id of Object.keys(this.watchers)) { for (const id of Object.keys(this.watchers)) {
const w = this.watchers[id] const w = this.watchers[id]
if (w.win === win) { if (w.win.id === windowId) {
watchers.push(w.watcher) watchers.push(w.watcher)
watchIds.push(id) watchIds.push(id)
} }
@ -182,9 +268,49 @@ class Watcher {
} }
} }
clear () { close () {
Object.keys(this.watchers).forEach(id => this.watchers[id].watcher.close()) Object.keys(this.watchers).forEach(id => this.watchers[id].close())
this.watchers = {} this.watchers = {}
this._ignoreChangeEvents = []
}
/**
* Ignore the next changed event within a certain time for the current file and window.
*
* NOTE: Only valid for files and "add"/"change" event!
*
* @param {number} windowId The window id.
* @param {string} pathname The path to ignore.
* @param {number} [duration] The duration in ms to ignore the changed event.
*/
ignoreChangedEvent (windowId, pathname, duration=WATCHER_STABILITY_THRESHOLD + WATCHER_STABILITY_POLL_INTERVAL + 1000) {
this._ignoreChangeEvents.push({ windowId, pathname, duration, start: new Date() })
}
/**
* Check whether we should ignore the current event because the file may be changed from Mark Text itself.
*
* @param {number} winId
* @param {string} pathname
* @param {string} type
*/
_shouldIgnoreEvent (winId, pathname, type) {
if (type === 'file') {
const { _ignoreChangeEvents } = this
const currentTime = new Date()
const len = _ignoreChangeEvents.length
for (let i = 0; i < len; ++i) {
const { windowId, pathname: pathToIgnore, start, duration } = _ignoreChangeEvents[i]
if (windowId === winId && pathToIgnore === pathname) {
_ignoreChangeEvents.splice(i)
--i
if (currentTime - start < duration) {
return true
}
}
}
}
return false
} }
} }

View File

@ -1,14 +1,11 @@
/** /**
* This file is used specifically and only for development. It installs * This file is used specifically and only for development. It installs
* `electron-debug` & `vue-devtools`. There shouldn't be any need to * `vue-devtools`. There shouldn't be any need to modify this file,
* modify this file, but it can be used to extend your development * but it can be used to extend your development environment.
* environment.
*/ */
/* eslint-disable */ /* eslint-disable */
require('dotenv').config() require('dotenv').config()
// Install `electron-debug` with `devtron`
require('electron-debug')({ showDevTools: false })
// Install `vue-devtools` // Install `vue-devtools`
require('electron').app.on('ready', () => { require('electron').app.on('ready', () => {

View File

@ -1,5 +1,6 @@
import './globalSetting' import './globalSetting'
import path from 'path' import path from 'path'
import { app } from 'electron'
import cli from './cli' import cli from './cli'
import setupExceptionHandler, { initExceptionLogger } from './exceptionHandler' import setupExceptionHandler, { initExceptionLogger } from './exceptionHandler'
import log from 'electron-log' import log from 'electron-log'
@ -32,6 +33,19 @@ const args = cli()
const appEnvironment = setupEnvironment(args) const appEnvironment = setupEnvironment(args)
initializeLogger(appEnvironment) initializeLogger(appEnvironment)
if (args['--disable-gpu']) {
app.disableHardwareAcceleration()
}
// Make Mark Text a single instance application.
if (!process.mas && process.env.NODE_ENV !== 'development') {
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
process.stdout.write('Other Mark Text instance detected: exiting...\n')
app.exit()
}
}
// Mark Text environment is configured successfully. You can now access paths, use the logger etc. // 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. // Create other instances that need access to the modules from above.
const accessor = new Accessor(appEnvironment) const accessor = new Accessor(appEnvironment)
@ -40,5 +54,5 @@ const accessor = new Accessor(appEnvironment)
// Be careful when changing code before this line! // Be careful when changing code before this line!
// NOTE: Do not create classes or other code before this line! // NOTE: Do not create classes or other code before this line!
const app = new App(accessor, args) const marktext = new App(accessor, args)
app.init() marktext.init()

View File

@ -31,12 +31,13 @@ class Keybindings {
['fileNewTab', 'CmdOrCtrl+Shift+T'], ['fileNewTab', 'CmdOrCtrl+Shift+T'],
['fileOpenFile', 'CmdOrCtrl+O'], ['fileOpenFile', 'CmdOrCtrl+O'],
['fileOpenFolder', 'CmdOrCtrl+Shift+O'], ['fileOpenFolder', 'CmdOrCtrl+Shift+O'],
['fileCloseTab', 'CmdOrCtrl+W'],
['fileSave', 'CmdOrCtrl+S'], ['fileSave', 'CmdOrCtrl+S'],
['fileSaveAs', 'CmdOrCtrl+Shift+S'], ['fileSaveAs', 'CmdOrCtrl+Shift+S'],
['filePrint', 'CmdOrCtrl+P'], ['filePrint', 'CmdOrCtrl+P'],
['filePreferences', 'CmdOrCtrl+,'], // marktext menu in macOS ['filePreferences', 'CmdOrCtrl+,'], // marktext menu in macOS
['fileQuit', isOsx ? 'Command+Q' : 'Alt+F4'], ['fileCloseTab', 'CmdOrCtrl+W'],
['fileCloseWindow', 'CmdOrCtrl+Shift+W'],
['fileQuit', 'CmdOrCtrl+Q'],
// edit menu // edit menu
['editUndo', 'CmdOrCtrl+Z'], ['editUndo', 'CmdOrCtrl+Z'],
@ -91,18 +92,16 @@ class Keybindings {
['formatClearFormat', 'Shift+CmdOrCtrl+R'], ['formatClearFormat', 'Shift+CmdOrCtrl+R'],
// window menu // window menu
// ['windowMinimize', 'CmdOrCtrl+M'], deprecated for math. ['windowMinimize', ''], // 'CmdOrCtrl+M' deprecated for math
['windowCloseWindow', 'CmdOrCtrl+Shift+W'],
// view menu // view menu
['viewToggleFullScreen', isOsx ? 'Ctrl+Command+F' : 'F11'], ['viewToggleFullScreen', isOsx ? 'Ctrl+Command+F' : 'F11'],
['viewChangeFont', 'CmdOrCtrl+.'],
['viewSourceCodeMode', 'CmdOrCtrl+Alt+S'], ['viewSourceCodeMode', 'CmdOrCtrl+Alt+S'],
['viewTypewriterMode', 'CmdOrCtrl+Alt+T'], ['viewTypewriterMode', 'CmdOrCtrl+Alt+T'],
['viewFocusMode', 'CmdOrCtrl+Shift+F'], ['viewFocusMode', 'CmdOrCtrl+Shift+F'],
['viewToggleSideBar', 'CmdOrCtrl+J'], ['viewToggleSideBar', 'CmdOrCtrl+J'],
['viewToggleTabBar', 'CmdOrCtrl+Alt+B'], ['viewToggleTabBar', 'CmdOrCtrl+Alt+B'],
['viewDevToggleDeveloperTools', isOsx ? 'Alt+Command+I' : 'Ctrl+Shift+I'], ['viewDevToggleDeveloperTools', 'CmdOrCtrl+Alt+I'],
['viewDevReload', 'CmdOrCtrl+R'] ['viewDevReload', 'CmdOrCtrl+R']
]) ])

View File

@ -9,9 +9,9 @@ import { writeMarkdownFile } from '../../filesystem/markdown'
import { getPath, getRecommendTitleFromMarkdownString } from '../../utils' import { getPath, getRecommendTitleFromMarkdownString } from '../../utils'
import pandoc from '../../utils/pandoc' import pandoc from '../../utils/pandoc'
// TODO: // TODO(refactor): "save" and "save as" should be moved to the editor window (editor.js) and
// - use async dialog version to not block the main process. // the renderer should communicate only with the editor window for file relevant stuff.
// - catch "fs." exceptions. Otherwise the main process crashes... // E.g. "mt::save-tabs" --> "mt::window-save-tabs$wid:<windowId>"
// Handle the export response from renderer process. // Handle the export response from renderer process.
const handleResponseForExport = async (e, { type, content, pathname, markdown }) => { const handleResponseForExport = async (e, { type, content, pathname, markdown }) => {
@ -24,88 +24,101 @@ const handleResponseForExport = async (e, { type, content, pathname, markdown })
} }
const defaultPath = path.join(dirname, `${nakedFilename}${extension}`) const defaultPath = path.join(dirname, `${nakedFilename}${extension}`)
// TODO(need::refactor): use async dialog version dialog.showSaveDialog(win, {
const filePath = dialog.showSaveDialog(win, {
defaultPath defaultPath
}) }, async filePath => {
if (filePath) {
if (filePath) { let data = content
let data = content try {
try { if (!content && type === 'pdf') {
if (!content && type === 'pdf') { data = await promisify(win.webContents.printToPDF.bind(win.webContents))({ printBackground: true })
data = await promisify(win.webContents.printToPDF.bind(win.webContents))({ printBackground: true }) removePrintServiceFromWindow(win)
}
if (data) {
await writeFile(filePath, data, extension)
win.webContents.send('AGANI::export-success', { type, filePath })
}
} catch (err) {
log.error(err)
const ERROR_MSG = err.message || `Error happened when export ${filePath}`
win.webContents.send('AGANI::show-notification', {
title: 'Export failure',
type: 'error',
message: ERROR_MSG
})
}
} else {
// User canceled save dialog
if (type === 'pdf') {
removePrintServiceFromWindow(win) removePrintServiceFromWindow(win)
} }
if (data) {
await writeFile(filePath, data, extension)
win.webContents.send('AGANI::export-success', { type, filePath })
}
} catch (err) {
log.error(err)
const ERROR_MSG = err.message || `Error happened when export ${filePath}`
win.webContents.send('AGANI::show-notification', {
title: 'Export File Error',
type: 'error',
message: ERROR_MSG
})
} }
} else { })
// User canceled save dialog
if (type === 'pdf') {
removePrintServiceFromWindow(win)
}
}
} }
const handleResponseForPrint = e => { const handleResponseForPrint = e => {
const win = BrowserWindow.fromWebContents(e.sender)
// See GH#749, Electron#16085 and Electron#17523. // See GH#749, Electron#16085 and Electron#17523.
dialog.showMessageBox({ dialog.showMessageBox(win, {
type: 'info', type: 'info',
buttons: ['OK'], buttons: ['OK'],
defaultId: 0, defaultId: 0,
noLink: true, noLink: true,
message: 'Printing doesn\'t work', message: 'Printing doesn\'t work',
detail: 'Printing is disabled due to an Electron upstream issue. Please export the document as PDF and print the PDF file. We apologize for the inconvenience!' detail: 'Printing is disabled due to an Electron upstream issue. Please export the document as PDF and print the PDF file. We apologize for the inconvenience!'
}) }, () => {})
// const win = BrowserWindow.fromWebContents(e.sender)
// win.webContents.print({ printBackground: true }, () => { // win.webContents.print({ printBackground: true }, () => {
// removePrintServiceFromWindow(win) // removePrintServiceFromWindow(win)
// }) // })
} }
const handleResponseForSave = (e, { id, markdown, pathname, options }) => { const handleResponseForSave = async (e, { id, markdown, pathname, options }) => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
let recommendFilename = getRecommendTitleFromMarkdownString(markdown) let recommendFilename = getRecommendTitleFromMarkdownString(markdown)
if (!recommendFilename) { if (!recommendFilename) {
recommendFilename = 'Untitled' recommendFilename = 'Untitled'
} }
// If the file doesn't exist on disk add it to the recently used documents later. // If the file doesn't exist on disk add it to the recently used documents later
// and execute file from filesystem watcher for a short time. The file may exists
// on disk nevertheless but is already tracked by Mark Text.
const alreadyExistOnDisk = !!pathname const alreadyExistOnDisk = !!pathname
// TODO(need::refactor): use async dialog version let filePath = pathname
pathname = pathname || dialog.showSaveDialog(win, { if (!filePath) {
defaultPath: path.join(getPath('documents'), `${recommendFilename}.md`) filePath = await new Promise((resolve, reject) => {
}) // TODO: Use asynchronous version that returns a "Promise" with Electron 6.
dialog.showSaveDialog(win, {
defaultPath: path.join(getPath('documents'), `${recommendFilename}.md`)
}, resolve)
})
}
if (pathname && typeof pathname === 'string') { // Save dialog canceled by user - no error.
if (!alreadyExistOnDisk) { if (!filePath) {
ipcMain.emit('menu-clear-recently-used')
}
return writeMarkdownFile(pathname, markdown, options, win)
.then(() => {
if (!alreadyExistOnDisk) {
// it's a new created file, need watch
ipcMain.emit('watcher-watch-file', win, pathname)
}
const filename = path.basename(pathname)
win.webContents.send('AGANI::set-pathname', { id, pathname, filename })
return id
})
} else {
return Promise.resolve() return Promise.resolve()
} }
filePath = path.resolve(filePath)
return writeMarkdownFile(filePath, markdown, options, win)
.then(() => {
if (!alreadyExistOnDisk) {
ipcMain.emit('window-add-file-path', win.id, filePath)
ipcMain.emit('menu-add-recently-used', filePath)
const filename = path.basename(filePath)
win.webContents.send('mt::set-pathname', { id, pathname: filePath, filename })
} else {
ipcMain.emit('window-file-saved', win.id, filePath)
win.webContents.send('mt::tab-saved', id)
}
return id
})
.catch(err => {
log.error(err)
win.webContents.send('mt::tab-save-failure', id, err.message)
})
} }
const showUnsavedFilesMessage = (win, files) => { const showUnsavedFilesMessage = (win, files) => {
@ -159,28 +172,26 @@ const removePrintServiceFromWindow = win => {
// --- events ----------------------------------- // --- events -----------------------------------
ipcMain.on('AGANI::save-all', (e, unsavedFiles) => { ipcMain.on('mt::save-tabs', (e, unsavedFiles) => {
Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file))) Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file)))
.catch(log.error) .catch(log.error)
}) })
ipcMain.on('AGANI::save-close', async (e, unsavedFiles, isSingle) => { ipcMain.on('mt::save-and-close-tabs', async (e, unsavedFiles) => {
const win = BrowserWindow.fromWebContents(e.sender) 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) { if (needSave) {
Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file))) Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file)))
.then(arr => { .then(arr => {
const data = arr.filter(id => id) const tabIds = arr.filter(id => id != null)
win.send(EVENT, { err: null, data }) win.send('mt::force-close-tabs-by-id', tabIds)
}) })
.catch(err => { .catch(err => {
win.send(EVENT, { err, data: null })
log.error(err.error) log.error(err.error)
}) })
} else { } else {
const data = unsavedFiles.map(f => f.id) const tabIds = unsavedFiles.map(f => f.id)
win.send(EVENT, { err: null, data }) win.send('mt::force-close-tabs-by-id', tabIds)
} }
}) })
@ -191,28 +202,44 @@ ipcMain.on('AGANI::response-file-save-as', (e, { id, markdown, pathname, options
recommendFilename = 'Untitled' recommendFilename = 'Untitled'
} }
// TODO(need::refactor): use async dialog version // If the file doesn't exist on disk add it to the recently used documents later
const filePath = dialog.showSaveDialog(win, { // and execute file from filesystem watcher for a short time. The file may exists
defaultPath: pathname || getPath('documents') + `/${recommendFilename}.md` // on disk nevertheless but is already tracked by Mark Text.
}) const alreadyExistOnDisk = !!pathname
if (filePath) { dialog.showSaveDialog(win, {
writeMarkdownFile(filePath, markdown, options, win) defaultPath: pathname || getPath('documents') + `/${recommendFilename}.md`
.then(() => { }, filePath => {
// need watch file after `save as` if (filePath) {
if (pathname !== filePath) { filePath = path.resolve(filePath)
// unwatch the old file writeMarkdownFile(filePath, markdown, options, win)
ipcMain.emit('watcher-unwatch-file', win, pathname) .then(() => {
ipcMain.emit('watcher-watch-file', win, filePath) if (!alreadyExistOnDisk) {
} ipcMain.emit('window-add-file-path', win.id, filePath)
const filename = path.basename(filePath) ipcMain.emit('menu-add-recently-used', filePath)
win.webContents.send('AGANI::set-pathname', { id, pathname: filePath, filename })
}) const filename = path.basename(filePath)
.catch(log.error) win.webContents.send('mt::set-pathname', { id, pathname: filePath, filename })
} } else if (pathname !== filePath) {
// Update window file list and watcher.
ipcMain.emit('window-change-file-path', win.id, filePath, pathname)
const filename = path.basename(filePath)
win.webContents.send('mt::set-pathname', { id, pathname: filePath, filename })
} else {
ipcMain.emit('window-file-saved', win.id, filePath)
win.webContents.send('mt::tab-saved', id)
}
})
.catch(err => {
log.error(err)
win.webContents.send('mt::tab-save-failure', id, err.message)
})
}
})
}) })
ipcMain.on('AGANI::response-close-confirm', async (e, unsavedFiles) => { ipcMain.on('mt::close-window-confirm', async (e, unsavedFiles) => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
const { needSave } = await showUnsavedFilesMessage(win, unsavedFiles) const { needSave } = await showUnsavedFilesMessage(win, unsavedFiles)
if (needSave) { if (needSave) {
@ -223,6 +250,18 @@ ipcMain.on('AGANI::response-close-confirm', async (e, unsavedFiles) => {
.catch(err => { .catch(err => {
console.log(err) console.log(err)
log.error(err) log.error(err)
// Notify user about the problem.
dialog.showMessageBox(win, {
type: 'error',
buttons: ['Close', 'Keep It Open'],
message: 'Failure while saving files',
detail: err.message
}, code => {
if (win.id && code === 0) {
ipcMain.emit('window-close-by-id', win.id)
}
})
}) })
} else { } else {
ipcMain.emit('window-close-by-id', win.id) ipcMain.emit('window-close-by-id', win.id)
@ -240,9 +279,10 @@ ipcMain.on('AGANI::window::drop', async (e, fileList) => {
for (const file of fileList) { for (const file of fileList) {
if (isMarkdownFileOrLink(file)) { if (isMarkdownFileOrLink(file)) {
openFileOrFolder(win, file) openFileOrFolder(win, file)
break continue
} }
// handle import file
// Try to import the file
if (PANDOC_EXTENSIONS.some(ext => file.endsWith(ext))) { if (PANDOC_EXTENSIONS.some(ext => file.endsWith(ext))) {
const existsPandoc = pandoc.exists() const existsPandoc = pandoc.exists()
if (!existsPandoc) { if (!existsPandoc) {
@ -255,15 +295,28 @@ ipcMain.on('AGANI::window::drop', async (e, fileList) => {
} }
}) })
ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => { ipcMain.on('mt::rename', (e, { id, pathname, newPathname }) => {
if (pathname === newPathname) return
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
if (!isFile(newPathname)) {
fs.renameSync(pathname, newPathname) const doRename = () => {
e.sender.send('AGANI::set-pathname', { fs.rename(pathname, newPathname, err => {
id, if (err) {
pathname: newPathname, log.error(`mt::rename: Cannot rename "${pathname}" to "${newPathname}".\n${err.stack}`)
filename: path.basename(newPathname) return
}
ipcMain.emit('window-change-file-path', win.id, newPathname, pathname)
e.sender.send('mt::set-pathname', {
id,
pathname: newPathname,
filename: path.basename(newPathname)
})
}) })
}
if (!isFile(newPathname)) {
doRename()
} else { } else {
dialog.showMessageBox(win, { dialog.showMessageBox(win, {
type: 'warning', type: 'warning',
@ -274,12 +327,7 @@ ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => {
noLink: true noLink: true
}, index => { }, index => {
if (index === 0) { if (index === 0) {
fs.renameSync(pathname, newPathname) doRename()
e.sender.send('AGANI::set-pathname', {
id,
pathname: newPathname,
filename: path.basename(newPathname)
})
} }
}) })
} }
@ -287,28 +335,34 @@ ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => {
ipcMain.on('AGANI::response-file-move-to', (e, { id, pathname }) => { ipcMain.on('AGANI::response-file-move-to', (e, { id, pathname }) => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
dialog.showSaveDialog(win, {
// TODO(need::refactor): use async dialog version
let newPath = dialog.showSaveDialog(win, {
buttonLabel: 'Move to', buttonLabel: 'Move to',
nameFieldLabel: 'Filename:', nameFieldLabel: 'Filename:',
defaultPath: pathname defaultPath: pathname
}, newPath => {
if (newPath) {
fs.rename(pathname, newPath, err => {
if (err) {
log.error(`mt::rename: Cannot rename "${pathname}" to "${newPath}".\n${err.stack}`)
return
}
ipcMain.emit('window-change-file-path', win.id, newPath, pathname)
e.sender.send('mt::set-pathname', { id, pathname: newPath, filename: path.basename(newPath) })
})
}
}) })
if (newPath === undefined) return
fs.renameSync(pathname, newPath)
e.sender.send('AGANI::set-pathname', { id, pathname: newPath, filename: path.basename(newPath) })
}) })
ipcMain.on('AGANI::ask-for-open-project-in-sidebar', e => { ipcMain.on('mt::ask-for-open-project-in-sidebar', e => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
dialog.showOpenDialog(win, {
// TODO(need::refactor): use async dialog version
const pathname = dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory'] properties: ['openDirectory', 'createDirectory']
}, directories => {
if (directories && directories[0]) {
ipcMain.emit('app-open-directory-by-id', win.id, directories[0], true)
}
}) })
if (pathname && pathname[0]) {
ipcMain.emit('app-open-directory-by-id', win.id, pathname[0])
}
}) })
ipcMain.on('AGANI::format-link-click', (e, { data, dirname }) => { ipcMain.on('AGANI::format-link-click', (e, { data, dirname }) => {
@ -341,18 +395,17 @@ export const importFile = async win => {
return noticePandocNotFound(win) return noticePandocNotFound(win)
} }
// TODO(need::refactor): use async dialog version dialog.showOpenDialog(win, {
const filename = dialog.showOpenDialog(win, {
properties: [ 'openFile' ], properties: [ 'openFile' ],
filters: [{ filters: [{
name: 'All Files', name: 'All Files',
extensions: PANDOC_EXTENSIONS extensions: PANDOC_EXTENSIONS
}] }]
}, filePath => {
if (filePath) {
openPandocFile(win.id, filePath)
}
}) })
if (filename && filename[0]) {
openPandocFile(win.id, filename[0])
}
} }
export const print = win => { export const print = win => {
@ -360,27 +413,27 @@ export const print = win => {
} }
export const openFile = win => { export const openFile = win => {
// TODO(need::refactor): use async dialog version dialog.showOpenDialog(win, {
const fileList = dialog.showOpenDialog(win, { properties: ['openFile', 'multiSelections'],
properties: ['openFile'],
filters: [{ filters: [{
name: 'text', name: 'text',
extensions: EXTENSIONS extensions: EXTENSIONS
}] }]
}, paths => {
if (paths && Array.isArray(paths)) {
ipcMain.emit('app-open-files-by-id', win.id, paths)
}
}) })
if (fileList && fileList[0]) {
openFileOrFolder(win, fileList[0])
}
} }
export const openFolder = win => { export const openFolder = win => {
// TODO(need::refactor): use async dialog version dialog.showOpenDialog(win, {
const dirList = dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory'] properties: ['openDirectory', 'createDirectory']
}, directories => {
if (directories && directories[0]) {
openFileOrFolder(win, directories[0])
}
}) })
if (dirList && dirList[0]) {
openFileOrFolder(win, dirList[0])
}
} }
export const openFileOrFolder = (win, pathname) => { export const openFileOrFolder = (win, pathname) => {

View File

@ -7,6 +7,12 @@ import { ensureDirSync, isDirectory, isFile } from '../filesystem'
import { parseMenu } from '../keyboard/shortcutHandler' import { parseMenu } from '../keyboard/shortcutHandler'
import configureMenu, { configSettingMenu } from '../menu/templates' import configureMenu, { configSettingMenu } from '../menu/templates'
export const MenuType = {
DEFAULT: 0,
EDITOR: 1,
SETTINGS: 2
}
class AppMenu { class AppMenu {
/** /**
@ -105,13 +111,13 @@ class AppMenu {
windowMenus.set(window.id, menu) windowMenus.set(window.id, menu)
} }
addEditorMenu (window) { addEditorMenu (window, options = {}) {
const { windowMenus } = this const { windowMenus } = this
windowMenus.set(window.id, this.buildDefaultMenu(true)) windowMenus.set(window.id, this.buildEditorMenu(true))
const { menu, shortcutMap } = windowMenus.get(window.id) const { menu, shortcutMap } = windowMenus.get(window.id)
const currentMenu = Menu.getApplicationMenu() // the menu may be null const currentMenu = Menu.getApplicationMenu() // the menu may be null
updateMenuItemSafe(currentMenu, menu, 'sourceCodeModeMenuItem', false) updateMenuItemSafe(currentMenu, menu, 'sourceCodeModeMenuItem', !!options.sourceCodeModeEnabled)
updateMenuItemSafe(currentMenu, menu, 'typewriterModeMenuItem', false) updateMenuItemSafe(currentMenu, menu, 'typewriterModeMenuItem', false)
// FIXME: Focus mode is being ignored when you open a new window - inconsistency. // FIXME: Focus mode is being ignored when you open a new window - inconsistency.
@ -159,7 +165,7 @@ class AppMenu {
} }
} }
buildDefaultMenu (createShortcutMap, recentUsedDocuments) { buildEditorMenu (createShortcutMap, recentUsedDocuments) {
if (!recentUsedDocuments) { if (!recentUsedDocuments) {
recentUsedDocuments = this.getRecentlyUsedDocuments() recentUsedDocuments = this.getRecentlyUsedDocuments()
} }
@ -174,7 +180,8 @@ class AppMenu {
return { return {
shortcutMap, shortcutMap,
menu menu,
type: MenuType.EDITOR
} }
} }
@ -182,9 +189,9 @@ class AppMenu {
if (this.isOsx) { if (this.isOsx) {
const menuTemplate = configSettingMenu(this._keybindings) const menuTemplate = configSettingMenu(this._keybindings)
const menu = Menu.buildFromTemplate(menuTemplate) const menu = Menu.buildFromTemplate(menuTemplate)
return { menu } return { menu, type: MenuType.SETTINGS }
} }
return { menu: null } return { menu: null, type: MenuType.SETTINGS }
} }
updateAppMenu (recentUsedDocuments) { updateAppMenu (recentUsedDocuments) {
@ -193,13 +200,15 @@ class AppMenu {
} }
// "we don't support changing menu object after calling setMenu, the behavior // "we don't support changing menu object after calling setMenu, the behavior
// is undefined if user does that." That means we have to recreate the // is undefined if user does that." That mean we have to recreate the editor
// application menu each time. // application menu each time.
// rebuild all window menus // rebuild all window menus
this.windowMenus.forEach((value, key) => { this.windowMenus.forEach((value, key) => {
const { menu: oldMenu } = value const { menu: oldMenu, type } = value
const { menu: newMenu } = this.buildDefaultMenu(false, recentUsedDocuments) if (type !== MenuType.EDITOR) return
const { menu: newMenu } = this.buildEditorMenu(false, recentUsedDocuments)
// all other menu items are set automatically // all other menu items are set automatically
updateMenuItem(oldMenu, newMenu, 'sourceCodeModeMenuItem') updateMenuItem(oldMenu, newMenu, 'sourceCodeModeMenuItem')
@ -231,7 +240,9 @@ class AppMenu {
updateThemeMenu = theme => { updateThemeMenu = theme => {
this.windowMenus.forEach((value, key) => { this.windowMenus.forEach((value, key) => {
const { menu } = value const { menu, type } = value
if (type !== MenuType.EDITOR) return
const themeMenus = menu.getMenuItemById('themeMenu') const themeMenus = menu.getMenuItemById('themeMenu')
if (!themeMenus) { if (!themeMenus) {
return return
@ -248,7 +259,9 @@ class AppMenu {
updateAutoSaveMenu = autoSave => { updateAutoSaveMenu = autoSave => {
this.windowMenus.forEach((value, key) => { this.windowMenus.forEach((value, key) => {
const { menu } = value const { menu, type } = value
if (type !== MenuType.EDITOR) return
const autoSaveMenu = menu.getMenuItemById('autoSaveMenuItem') const autoSaveMenu = menu.getMenuItemById('autoSaveMenuItem')
if (!autoSaveMenu) { if (!autoSaveMenu) {
return return
@ -259,7 +272,9 @@ class AppMenu {
updateAidouMenu = bool => { updateAidouMenu = bool => {
this.windowMenus.forEach((value, key) => { this.windowMenus.forEach((value, key) => {
const { menu } = value const { menu, type } = value
if (type !== MenuType.EDITOR) return
const aidouMenu = menu.getMenuItemById('aidou') const aidouMenu = menu.getMenuItemById('aidou')
if (!aidouMenu) { if (!aidouMenu) {
return return
@ -283,6 +298,9 @@ class AppMenu {
this.addRecentlyUsedDocument(pathname) this.addRecentlyUsedDocument(pathname)
}) })
ipcMain.on('menu-add-recently-used', pathname => {
this.addRecentlyUsedDocument(pathname)
})
ipcMain.on('menu-clear-recently-used', () => { ipcMain.on('menu-clear-recently-used', () => {
this.clearRecentlyUsedDocuments() this.clearRecentlyUsedDocuments()
}) })

View File

@ -2,10 +2,10 @@ import { app } from 'electron'
import * as actions from '../actions/file' import * as actions from '../actions/file'
import { userSetting } from '../actions/marktext' import { userSetting } from '../actions/marktext'
import { showTabBar } from '../actions/view' import { showTabBar } from '../actions/view'
import { isOsx } from '../../config'
export default function (keybindings, userPreference, recentlyUsedFiles) { export default function (keybindings, userPreference, recentlyUsedFiles) {
const { autoSave } = userPreference.getAll() const { autoSave } = userPreference.getAll()
const notOsx = process.platform !== 'darwin'
let fileMenu = { let fileMenu = {
label: 'File', label: 'File',
submenu: [{ submenu: [{
@ -38,7 +38,7 @@ export default function (keybindings, userPreference, recentlyUsedFiles) {
}] }]
} }
if (notOsx) { if (!isOsx) {
let recentlyUsedMenu = { let recentlyUsedMenu = {
label: 'Open Recent', label: 'Open Recent',
submenu: [] submenu: []
@ -77,12 +77,6 @@ export default function (keybindings, userPreference, recentlyUsedFiles) {
fileMenu.submenu.push({ fileMenu.submenu.push({
type: 'separator' type: 'separator'
}, {
label: 'Close Tab',
accelerator: keybindings.getAccelerator('fileCloseTab'),
click (menuItem, browserWindow) {
actions.closeTab(browserWindow)
}
}, { }, {
type: 'separator' type: 'separator'
}, { }, {
@ -139,8 +133,6 @@ export default function (keybindings, userPreference, recentlyUsedFiles) {
} }
} }
] ]
}, {
type: 'separator'
}, { }, {
label: 'Print', label: 'Print',
accelerator: keybindings.getAccelerator('filePrint'), accelerator: keybindings.getAccelerator('filePrint'),
@ -149,23 +141,34 @@ export default function (keybindings, userPreference, recentlyUsedFiles) {
} }
}, { }, {
type: 'separator', type: 'separator',
visible: notOsx visible: !isOsx
}, { }, {
label: 'Preferences', label: 'Preferences',
accelerator: keybindings.getAccelerator('filePreferences'), accelerator: keybindings.getAccelerator('filePreferences'),
visible: notOsx, visible: !isOsx,
click (menuItem, browserWindow) { click (menuItem, browserWindow) {
userSetting(menuItem, browserWindow) userSetting(menuItem, browserWindow)
} }
}, { }, {
type: 'separator', type: 'separator',
visible: notOsx }, {
label: 'Close Tab',
accelerator: keybindings.getAccelerator('fileCloseTab'),
click (menuItem, browserWindow) {
actions.closeTab(browserWindow)
}
}, {
label: 'Close Window',
accelerator: keybindings.getAccelerator('fileCloseWindow'),
role: 'close'
}, {
type: 'separator',
visible: !isOsx
}, { }, {
label: 'Quit', label: 'Quit',
accelerator: keybindings.getAccelerator('fileQuit'), accelerator: keybindings.getAccelerator('fileQuit'),
visible: notOsx, visible: !isOsx,
click: app.quit click: app.quit
}) })
return fileMenu return fileMenu
} }

View File

@ -1,20 +1,10 @@
import { ipcMain } from 'electron'
import * as actions from '../actions/view' import * as actions from '../actions/view'
import { isOsx } from '../../config'
export default function (keybindings) { export default function (keybindings) {
let viewMenu = { let viewMenu = {
label: 'View', label: 'View',
submenu: [{ submenu: [{
label: 'Toggle Full Screen',
accelerator: keybindings.getAccelerator('viewToggleFullScreen'),
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen())
}
}
}, {
type: 'separator'
}, {
id: 'sourceCodeModeMenuItem', id: 'sourceCodeModeMenuItem',
label: 'Source Code Mode', label: 'Source Code Mode',
accelerator: keybindings.getAccelerator('viewSourceCodeMode'), accelerator: keybindings.getAccelerator('viewSourceCodeMode'),
@ -89,7 +79,6 @@ export default function (keybindings) {
} }
if (global.MARKTEXT_DEBUG) { if (global.MARKTEXT_DEBUG) {
// add devtool when development
viewMenu.submenu.push({ viewMenu.submenu.push({
label: 'Toggle Developer Tools', label: 'Toggle Developer Tools',
accelerator: keybindings.getAccelerator('viewDevToggleDeveloperTools'), accelerator: keybindings.getAccelerator('viewDevToggleDeveloperTools'),
@ -99,25 +88,15 @@ export default function (keybindings) {
} }
} }
}) })
// add reload when development
viewMenu.submenu.push({ viewMenu.submenu.push({
label: 'Reload', label: 'Reload',
accelerator: keybindings.getAccelerator('viewDevReload'), accelerator: keybindings.getAccelerator('viewDevReload'),
click (item, focusedWindow) { click (item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
focusedWindow.reload() ipcMain.emit('window-reload-by-id', focusedWindow.id)
} }
} }
}) })
} }
if (isOsx) {
viewMenu.submenu.push({
type: 'separator'
}, {
label: 'Bring All to Front',
role: 'front'
})
}
return viewMenu return viewMenu
} }

View File

@ -1,7 +1,8 @@
import { toggleAlwaysOnTop } from '../actions/window' import { toggleAlwaysOnTop } from '../actions/window'
import { isOsx } from '../../config'
export default function (keybindings) { export default function (keybindings) {
return { const menu = {
label: 'Window', label: 'Window',
role: 'window', role: 'window',
submenu: [{ submenu: [{
@ -18,9 +19,21 @@ export default function (keybindings) {
}, { }, {
type: 'separator' type: 'separator'
}, { }, {
label: 'Close Window', label: 'Toggle Full Screen',
accelerator: keybindings.getAccelerator('windowCloseWindow'), accelerator: keybindings.getAccelerator('viewToggleFullScreen'),
role: 'close' click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen())
}
}
}] }]
} }
if (isOsx) {
menu.submenu.push({
label: 'Bring All to Front',
role: 'front'
})
}
return menu
} }

View File

@ -16,7 +16,11 @@
] ]
}, },
"openFilesInNewWindow": { "openFilesInNewWindow": {
"description": "General--Open file in new window", "description": "General--Open files in a new window.",
"type": "boolean"
},
"openFolderInNewWindow": {
"description": "General--Open folder via menu in a new window.",
"type": "boolean" "type": "boolean"
}, },
"aidou": { "aidou": {
@ -31,7 +35,7 @@
"title" "title"
] ]
}, },
"startUp": { "startUpAction": {
"description": "General--The action after Mark Text startup, open the last edited content, open the specified folder or blank page", "description": "General--The action after Mark Text startup, open the last edited content, open the specified folder or blank page",
"enum": [ "enum": [
"folder", "folder",
@ -39,6 +43,10 @@
"blank" "blank"
] ]
}, },
"defaultDirectoryToOpen": {
"description": "General--The default directory that should be opened on startup when startUp=folder.",
"type": "string"
},
"language": { "language": {
"description": "General--The language Mark Text use.", "description": "General--The language Mark Text use.",
"type": "string" "type": "string"
@ -160,7 +168,6 @@
"description": "Theme--Select the theme used in Mark Text", "description": "Theme--Select the theme used in Mark Text",
"type": "string" "type": "string"
}, },
"imageInsertAction": { "imageInsertAction": {
"description": "Image--The default behavior after insert image from local folder", "description": "Image--The default behavior after insert image from local folder",
"enum": [ "enum": [
@ -168,5 +175,17 @@
"folder", "folder",
"path" "path"
] ]
},
"sideBarVisibility": {
"description": "View--Whether the side bar is visible.",
"type": "boolean"
},
"tabBarVisibility": {
"description": "View--Whether the tabs are shown.",
"type": "boolean"
},
"sourceCodeModeEnabled": {
"description": "View--Whether the source-code mode is enabled by default.",
"type": "boolean"
} }
} }

View File

@ -1,5 +1,28 @@
import EventEmitter from 'events' import EventEmitter from 'events'
import { WindowType } from '../app/windowManager' import { isLinux } from '../config'
/**
* A Mark Text window.
* @typedef {BaseWindow} IApplicationWindow
* @property {number | null} id Identifier (= browserWindow.id) or null during initialization.
* @property {Electron.BrowserWindow} browserWindow The browse window.
* @property {WindowLifecycle} lifecycle The window lifecycle state.
* @property {WindowType} type The window type.
*/
// Window type marktext support.
export const WindowType = {
BASE: 'base', // You shold never create a `BASE` window.
EDITOR: 'editor',
SETTING: 'setting'
}
export const WindowLifecycle = {
NONE: 0,
LOADING: 1,
READY: 2,
QUITTED: 3
}
class BaseWindow extends EventEmitter { class BaseWindow extends EventEmitter {
@ -10,26 +33,43 @@ class BaseWindow extends EventEmitter {
super() super()
this._accessor = accessor this._accessor = accessor
this.type = WindowType.BASE
this.id = null this.id = null
this.browserWindow = null this.browserWindow = null
this.quitting = false this.lifecycle = WindowLifecycle.NONE
this.type = WindowType.BASE
}
bringToFront () {
const { browserWindow: win } = this
if (win.isMinimized()) win.restore()
if (!win.isVisible()) win.show()
if (isLinux) {
win.focus()
} else {
win.moveTop()
}
}
reload () {
this.browserWindow.reload()
} }
destroy () { destroy () {
this.quitting = true this.lifecycle = WindowLifecycle.QUITTED
this.emit('bye') this.emit('window-closed')
this.removeAllListeners() this.removeAllListeners()
this.browserWindow.destroy() if (this.browserWindow) {
this.browserWindow = null this.browserWindow.destroy()
this.browserWindow = null
}
this.id = null this.id = null
} }
// --- private --------------------------------- // --- private ---------------------------------
_buildUrlWithSettings (windowId, env, userPreference) { _buildUrlWithSettings (windowId, env, userPreference) {
// NOTE: Only send absolutely necessary values. Theme and titlebar settings // NOTE: Only send absolutely necessary values. Full settings are delay loaded.
// are sended because we delay load the preferences.
const { type } = this const { type } = this
const { debug, paths } = env const { debug, paths } = env
const { codeFontFamily, codeFontSize, theme, titleBarStyle } = userPreference.getAll() const { codeFontFamily, codeFontSize, theme, titleBarStyle } = userPreference.getAll()
@ -50,7 +90,11 @@ class BaseWindow extends EventEmitter {
url.searchParams.set('theme', theme) url.searchParams.set('theme', theme)
url.searchParams.set('tbs', titleBarStyle) url.searchParams.set('tbs', titleBarStyle)
return url.toString() return url
}
_buildUrlString (windowId, env, userPreference) {
return this._buildUrlWithSettings(windowId, env, userPreference).toString()
} }
} }

View File

@ -1,13 +1,12 @@
import path from 'path' import path from 'path'
import BaseWindow from './base' import { BrowserWindow, dialog, ipcMain } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import log from 'electron-log' import log from 'electron-log'
import windowStateKeeper from 'electron-window-state' import windowStateKeeper from 'electron-window-state'
import { WindowType } from '../app/windowManager' import BaseWindow, { WindowLifecycle, WindowType } from './base'
import { TITLE_BAR_HEIGHT, defaultWinOptions, isLinux, isOsx } from '../config'
import { isDirectory, isMarkdownFile, normalizeAndResolvePath } from '../filesystem'
import { loadMarkdownFile } from '../filesystem/markdown'
import { ensureWindowPosition } from './utils' import { ensureWindowPosition } from './utils'
import { TITLE_BAR_HEIGHT, editorWinOptions, isLinux, isOsx } from '../config'
import { isChildOfDirectory, isSamePathSync } from '../filesystem'
import { loadMarkdownFile } from '../filesystem/markdown'
class EditorWindow extends BaseWindow { class EditorWindow extends BaseWindow {
@ -17,22 +16,29 @@ class EditorWindow extends BaseWindow {
constructor (accessor) { constructor (accessor) {
super(accessor) super(accessor)
this.type = WindowType.EDITOR this.type = WindowType.EDITOR
// Root directory and file list to open when the window is ready.
this._directoryToOpen = null
this._filesToOpen = [] // {doc: IMarkdownDocumentRaw, selected: boolean}
this._markdownToOpen = [] // List of markdown strings or an empty string will open a new untitled tab
// Root directory and file list that are currently opened. These lists are
// used to find the best window to open new files in.
this._openedRootDirectory = ''
this._openedFiles = []
} }
/** /**
* Creates a new editor window. * Creates a new editor window.
* *
* @param {string} [pathname] Path to a file, directory or link. * @param {string} [rootDirectory] The root directory to open.
* @param {string} [markdown] Markdown content. * @param {string[]} [fileList] A list of markdown files to open.
* @param {*} [options] BrowserWindow options. * @param {string[]} [markdownList] Array of markdown data to open.
* @param {*} [options] The BrowserWindow options.
*/ */
createWindow (pathname = null, markdown = '', options = {}) { createWindow (rootDirectory = null, fileList = [], markdownList = [], options = {}) {
const { menu: appMenu, env, preferences } = this._accessor const { menu: appMenu, env, preferences } = this._accessor
const addBlankTab = !rootDirectory && fileList.length === 0 && markdownList.length === 0
// Ensure path is normalized
if (pathname) {
pathname = normalizeAndResolvePath(pathname)
}
const mainWindowState = windowStateKeeper({ const mainWindowState = windowStateKeeper({
defaultWidth: 1200, defaultWidth: 1200,
@ -40,13 +46,19 @@ class EditorWindow extends BaseWindow {
}) })
const { x, y, width, height } = ensureWindowPosition(mainWindowState) const { x, y, width, height } = ensureWindowPosition(mainWindowState)
const winOptions = Object.assign({ x, y, width, height }, defaultWinOptions, options) const winOptions = Object.assign({ x, y, width, height }, editorWinOptions, options)
if (isLinux) { if (isLinux) {
winOptions.icon = path.join(__static, 'logo-96px.png') winOptions.icon = path.join(__static, 'logo-96px.png')
} }
// Enable native or custom/frameless window and titlebar // Enable native or custom/frameless window and titlebar
const { titleBarStyle } = preferences.getAll() const {
titleBarStyle,
theme,
sideBarVisibility,
tabBarVisibility,
sourceCodeModeEnabled
} = preferences.getAll()
if (!isOsx) { if (!isOsx) {
winOptions.titleBarStyle = 'default' winOptions.titleBarStyle = 'default'
if (titleBarStyle === 'native') { if (titleBarStyle === 'native') {
@ -54,35 +66,58 @@ class EditorWindow extends BaseWindow {
} }
} }
winOptions.backgroundColor = this._getPreferredBackgroundColor(theme)
let win = this.browserWindow = new BrowserWindow(winOptions) let win = this.browserWindow = new BrowserWindow(winOptions)
this.id = win.id this.id = win.id
// Create a menu for the current window // Create a menu for the current window
appMenu.addEditorMenu(win) appMenu.addEditorMenu(win, { sourceCodeModeEnabled })
win.once('ready-to-show', async () => { win.webContents.once('did-finish-load', () => {
mainWindowState.manage(win) this.lifecycle = WindowLifecycle.READY
win.show() this.emit('window-ready')
this.emit('window-ready-to-show') // Restore and focus window
this.bringToFront()
if (pathname && isMarkdownFile(pathname)) { const lineEnding = preferences.getPreferedEOL()
// Open single markdown file appMenu.updateLineEndingMenu(lineEnding)
appMenu.addRecentlyUsedDocument(pathname)
this._openFile(pathname) win.webContents.send('mt::bootstrap-editor', {
} else if (pathname && isDirectory(pathname)) { addBlankTab,
// Open directory / folder markdownList: this._markdownToOpen,
appMenu.addRecentlyUsedDocument(pathname) lineEnding,
this.openFolder(pathname) sideBarVisibility,
} else { tabBarVisibility,
// Open a blank window sourceCodeModeEnabled
const lineEnding = preferences.getPreferedEOL() })
win.webContents.send('mt::bootstrap-blank-window', {
lineEnding, this._doOpenFilesToOpen()
markdown this._markdownToOpen.length = 0
}) })
appMenu.updateLineEndingMenu(lineEnding)
} win.webContents.once('did-fail-load', (event, errorCode, errorDescription) => {
log.error(`The window failed to load or was cancelled: ${errorCode}; ${errorDescription}`)
})
win.webContents.once('crashed', (event, killed) => {
const msg = `The renderer process has crashed unexpected or is killed (${killed}).`
log.error(msg)
dialog.showMessageBox(win, {
type: 'warning',
buttons: ['Close', 'Reload', 'Keep It Open'],
message: 'Mark Text has crashed',
detail: msg
}, code => {
if (win.id) {
switch(code) {
case 0: return this.destroy()
case 1: return this.reload()
}
}
})
}) })
win.on('focus', () => { win.on('focus', () => {
@ -114,94 +149,295 @@ class EditorWindow extends BaseWindow {
// The window is now destroyed. // The window is now destroyed.
win.on('closed', () => { win.on('closed', () => {
this.lifecycle = WindowLifecycle.QUITTED
this.emit('window-closed') this.emit('window-closed')
// Free window reference // Free window reference
win = null win = null
}) })
win.loadURL(this._buildUrlWithSettings(this.id, env, preferences)) this.lifecycle = WindowLifecycle.LOADING
win.loadURL(this._buildUrlString(this.id, env, preferences))
win.setSheetOffset(TITLE_BAR_HEIGHT) win.setSheetOffset(TITLE_BAR_HEIGHT)
mainWindowState.manage(win)
// Delay load files and directories after the current control flow.
setTimeout(() => {
if (rootDirectory) {
this.openFolder(rootDirectory)
}
if (fileList.length) {
this.openTabs(fileList, 0)
}
}, 0)
return win return win
} }
openTab (filePath, selectTab=true) { /**
if (this.quitting) return * Open a new tab from a markdown file.
*
const { browserWindow } = this * @param {string} filePath The markdown file path.
const { menu: appMenu, preferences } = this._accessor * @param {[boolean]} selected Whether the tab should become the selected tab (true if not set).
*/
// Listen for file changed. openTab (filePath, selected=true) {
ipcMain.emit('watcher-watch-file', browserWindow, filePath) if (this.lifecycle === WindowLifecycle.QUITTED) return
this.openTabs([ filePath ], selected ? 0 : -1 )
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 * Open new tabs from markdown files.
*
* @param {string[]} filePath The markdown file path list to open.
* @param {[number]} selectedIndex Whether one of the given tabs should become the selected tab (-1 if not set).
*/
openTabs (fileList, selectedIndex = -1) {
if (this.lifecycle === WindowLifecycle.QUITTED) return
const { browserWindow } = this const { browserWindow } = this
browserWindow.webContents.send('mt::new-untitled-tab', selectTab, markdownString) const { preferences } = this._accessor
const eol = preferences.getPreferedEOL()
for (let i = 0; i < fileList.length; ++i) {
const filePath = fileList[i]
const selected = i === selectedIndex
loadMarkdownFile(filePath, eol).then(rawDocument => {
if (this.lifecycle === WindowLifecycle.READY) {
this._doOpenTab(rawDocument, selected)
} else {
this._filesToOpen.push({ doc: rawDocument, selected })
}
}).catch(err => {
console.error('[ERROR] Cannot open file or directory.')
log.error(err)
browserWindow.webContents.send('AGANI::show-notification', {
title: 'Cannot open tab',
type: 'error',
message: err.message
})
})
}
} }
/**
* Open a new untitled tab optional with a markdown string.
*
* @param {[boolean]} selected Whether the tab should become the selected tab (true if not set).
* @param {[string]} markdown The markdown string.
*/
openUntitledTab (selected=true, markdown='') {
if (this.lifecycle === WindowLifecycle.QUITTED) return
if (this.lifecycle === WindowLifecycle.READY) {
const { browserWindow } = this
browserWindow.webContents.send('mt::new-untitled-tab', selected, markdown)
} else {
this._markdownToOpen.push(markdown)
}
}
/**
* Open a (new) directory and replaces the old one.
*
* @param {string} pathname The directory path.
*/
openFolder (pathname) { openFolder (pathname) {
if (this.quitting) return if (this.lifecycle === WindowLifecycle.QUITTED ||
isSamePathSync(pathname, this._openedRootDirectory)) {
return
}
const { browserWindow } = this if (this.lifecycle === WindowLifecycle.READY) {
ipcMain.emit('watcher-watch-directory', browserWindow, pathname) const { browserWindow } = this
browserWindow.webContents.send('AGANI::open-project', pathname) if (this._openedRootDirectory) {
ipcMain.emit('watcher-unwatch-directory', browserWindow, this._openedRootDirectory)
}
this._openedRootDirectory = pathname
ipcMain.emit('watcher-watch-directory', browserWindow, pathname)
browserWindow.webContents.send('mt::open-directory', pathname)
} else {
this._directoryToOpen = pathname
}
}
/**
* Add a new path to the file list and watch the given path.
*
* @param {string} filePath The file path.
*/
addToOpenedFiles (filePath) {
const { _openedFiles, browserWindow } = this
_openedFiles.push(filePath)
ipcMain.emit('watcher-watch-file', browserWindow, filePath)
}
/**
* Change a path in the opened file list and update the watcher.
*
* @param {string} pathname
* @param {string} oldPathname
*/
changeOpenedFilePath (pathname, oldPathname) {
const { _openedFiles, browserWindow } = this
const index = _openedFiles.findIndex(p => p === oldPathname)
if (index === -1) {
// The old path was not found but add the new one.
_openedFiles.push(pathname)
} else {
_openedFiles[index] = pathname
}
ipcMain.emit('watcher-unwatch-file', browserWindow, oldPathname)
ipcMain.emit('watcher-watch-file', browserWindow, pathname)
}
/**
* Remove a path from the opened file list and stop watching the path.
*
* @param {string} pathname The full path.
*/
removeFromOpenedFiles (pathname) {
const { _openedFiles, browserWindow } = this
const index = _openedFiles.findIndex(p => p === pathname)
if (index !== -1) {
_openedFiles.splice(index, 1)
}
ipcMain.emit('watcher-unwatch-file', browserWindow, pathname)
}
/**
* Returns a score list for a given file list.
*
* @param {string[]} fileList The file list.
* @returns {number[]}
*/
getCandidateScores (fileList) {
const { _openedFiles, _openedRootDirectory, id } = this
const buf = []
for (const pathname of fileList) {
let score = 0
if (_openedFiles.some(p => p === pathname)) {
score = -1
} else {
if (isChildOfDirectory(_openedRootDirectory, pathname)) {
score += 5
}
for (const item of _openedFiles) {
if (isChildOfDirectory(path.dirname(item), pathname)) {
score += 1
}
}
}
buf.push({ id, score })
}
return buf
}
reload () {
const { id, browserWindow } = this
// Close watchers
ipcMain.emit('watcher-unwatch-all-by-id', id)
// Reset saved state
this._directoryToOpen = ''
this._filesToOpen = []
this._markdownToOpen = []
this._openedRootDirectory = ''
this._openedFiles = []
browserWindow.webContents.once('did-finish-load', () => {
this.lifecycle = WindowLifecycle.READY
const { preferences } = this._accessor
const { sideBarVisibility, tabBarVisibility, sourceCodeModeEnabled } = preferences.getAll()
const lineEnding = preferences.getPreferedEOL()
browserWindow.webContents.send('mt::bootstrap-editor', {
addBlankTab: true,
markdownList: [],
lineEnding,
sideBarVisibility,
tabBarVisibility,
sourceCodeModeEnabled
})
})
this.lifecycle = WindowLifecycle.LOADING
super.reload()
}
destroy () {
super.destroy()
// Watchers are freed from WindowManager.
this._directoryToOpen = null
this._filesToOpen = null
this._markdownToOpen = null
this._openedRootDirectory = null
this._openedFiles = null
}
get openedRootDirectory () {
return this._openedRootDirectory
} }
// --- private --------------------------------- // --- private ---------------------------------
// Only called once during window bootstrapping. _getPreferredBackgroundColor (theme) {
_openFile = async filePath => { // Hardcode the theme background color and show the window direct for the fastet window ready time.
const { browserWindow } = this // Later with custom themes we need the background color (e.g. from meta information) and wait
const { menu: appMenu, preferences } = this._accessor // that the window is loaded and then pass theme data to the renderer.
switch (theme) {
case 'dark':
return '#282828'
case 'material-dark':
return '#34393f'
case 'ulysses':
return '#f3f3f3'
case 'graphite':
return '#f7f7f7'
case 'one-dark':
return '#282c34'
case 'light':
default:
return '#ffffff'
}
}
const data = await loadMarkdownFile(filePath, preferences.getPreferedEOL()) /**
const { * Open a new new tab from the markdown document.
markdown, *
filename, * @param {IMarkdownDocumentRaw} rawDocument The markdown document.
pathname, * @param {boolean} selected Whether the tab should become the selected tab (true if not set).
encoding, */
lineEnding, _doOpenTab (rawDocument, selected) {
adjustLineEndingOnSave, const { _accessor, _openedFiles, browserWindow } = this
isMixedLineEndings const { menu: appMenu } = _accessor
} = data const { pathname } = rawDocument
appMenu.updateLineEndingMenu(lineEnding)
browserWindow.webContents.send('mt::bootstrap-window', {
markdown,
filename,
pathname,
options: {
encoding,
lineEnding,
adjustLineEndingOnSave
}
})
// Listen for file changed. // Listen for file changed.
ipcMain.emit('watcher-watch-file', browserWindow, filePath) ipcMain.emit('watcher-watch-file', browserWindow, pathname)
// Notify user about mixed endings appMenu.addRecentlyUsedDocument(pathname)
if (isMixedLineEndings) { _openedFiles.push(pathname)
browserWindow.webContents.send('AGANI::show-notification', { browserWindow.webContents.send('AGANI::new-tab', rawDocument, selected)
title: 'Mixed Line Endings', }
type: 'error',
message: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, _doOpenFilesToOpen () {
time: 20000 if (this.lifecycle !== WindowLifecycle.READY) {
}) throw new Error('Invalid state.')
} }
if (this._directoryToOpen) {
this.openFolder(this._directoryToOpen)
}
this._directoryToOpen = null
for(const { doc, selected } of this._filesToOpen) {
this._doOpenTab(doc, selected)
}
this._filesToOpen.length = 0
} }
} }

View File

@ -1,10 +1,9 @@
import path from 'path' import path from 'path'
import BaseWindow from './base'
import { BrowserWindow, ipcMain } from 'electron' import { BrowserWindow, ipcMain } from 'electron'
import { WindowType } from '../app/windowManager' import BaseWindow, { WindowLifecycle, WindowType } from './base'
import { centerWindowOptions } from './utils'
import { TITLE_BAR_HEIGHT, defaultPreferenceWinOptions, isLinux, isOsx } from '../config' import { TITLE_BAR_HEIGHT, defaultPreferenceWinOptions, isLinux, isOsx } from '../config'
class SettingWindow extends BaseWindow { class SettingWindow extends BaseWindow {
/** /**
@ -23,6 +22,7 @@ class SettingWindow extends BaseWindow {
createWindow (options = {}) { createWindow (options = {}) {
const { menu: appMenu, env, preferences } = this._accessor const { menu: appMenu, env, preferences } = this._accessor
const winOptions = Object.assign({}, defaultPreferenceWinOptions, options) const winOptions = Object.assign({}, defaultPreferenceWinOptions, options)
centerWindowOptions(winOptions)
if (isLinux) { if (isLinux) {
winOptions.icon = path.join(__static, 'logo-96px.png') winOptions.icon = path.join(__static, 'logo-96px.png')
} }
@ -42,10 +42,10 @@ class SettingWindow extends BaseWindow {
// Create a menu for the current window // Create a menu for the current window
appMenu.addSettingMenu(win) appMenu.addSettingMenu(win)
win.once('ready-to-show', async () => { win.once('ready-to-show', () => {
win.show() win.show()
this.lifecycle = WindowLifecycle.READY
this.emit('window-ready-to-show') this.emit('window-ready')
}) })
win.on('focus', () => { win.on('focus', () => {
@ -74,7 +74,8 @@ class SettingWindow extends BaseWindow {
win = null win = null
}) })
win.loadURL(this._buildUrlWithSettings(this.id, env, preferences)) this.lifecycle = WindowLifecycle.LOADING
win.loadURL(this._buildUrlString(this.id, env, preferences))
win.setSheetOffset(TITLE_BAR_HEIGHT) win.setSheetOffset(TITLE_BAR_HEIGHT)
return win return win

View File

@ -1,6 +1,15 @@
import { screen } from 'electron' import { screen } from 'electron'
import { isLinux } from '../config' import { isLinux } from '../config'
export const centerWindowOptions = options => {
// "workArea" doesn't work on Linux
const { bounds, workArea } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const screenArea = isLinux ? bounds : workArea
const { width, height } = options
options.x = Math.max(0, Math.ceil(screenArea.x + (screenArea.width - width) / 2))
options.y = Math.max(0, Math.ceil(screenArea.y + (screenArea.height - height) / 2))
}
export const ensureWindowPosition = windowState => { export const ensureWindowPosition = windowState => {
// "workArea" doesn't work on Linux // "workArea" doesn't work on Linux
const { bounds, workArea } = screen.getPrimaryDisplay() const { bounds, workArea } = screen.getPrimaryDisplay()
@ -21,7 +30,6 @@ export const ensureWindowPosition = windowState => {
.some(display => display) .some(display => display)
} }
if (center) { if (center) {
// win.center() doesn't work on Linux
x = Math.max(0, Math.ceil(screenArea.x + (screenArea.width - width) / 2)) x = Math.max(0, Math.ceil(screenArea.x + (screenArea.width - width) / 2))
y = Math.max(0, Math.ceil(screenArea.y + (screenArea.height - height) / 2)) y = Math.max(0, Math.ceil(screenArea.y + (screenArea.height - height) / 2))
} }

View File

@ -52,7 +52,8 @@
--maskColor: rgba(255, 255, 255, .7); --maskColor: rgba(255, 255, 255, .7);
} }
::-webkit-scrollbar { ::-webkit-scrollbar,
::-webkit-scrollbar-corner {
background: var(--floatHoverColor); background: var(--floatHoverColor);
} }
::-webkit-scrollbar:vertical { ::-webkit-scrollbar:vertical {

View File

@ -12,7 +12,7 @@ body article.print-container {
width: 100% !important; width: 100% !important;
display: block !important; display: block !important;
height: auto !important; height: auto !important;
background: transparent !important; background: #ffffff !important;
overflow: hidden; overflow: hidden;
} }

View File

@ -48,7 +48,8 @@
--editorAreaWidth: 750px; --editorAreaWidth: 750px;
} }
::-webkit-scrollbar { ::-webkit-scrollbar,
::-webkit-scrollbar-corner {
background: var(--editorBgColor); background: var(--editorBgColor);
} }
::-webkit-scrollbar:vertical { ::-webkit-scrollbar:vertical {

View File

@ -64,6 +64,12 @@ const bootstrapRenderer = () => {
ipcRenderer.send('AGANI::handle-renderer-error', copy) ipcRenderer.send('AGANI::handle-renderer-error', copy)
}) })
// Remove the initial drag area.
const initDragRegion = document.getElementById('init-drag-region')
if (initDragRegion) {
initDragRegion.remove()
}
const { debug, initialState, userDataPath, windowId, type } = parseUrlArgs() const { debug, initialState, userDataPath, windowId, type } = parseUrlArgs()
const marktext = { const marktext = {
initialState, initialState,

View File

@ -142,6 +142,7 @@
'theme': state => state.preferences.theme, 'theme': state => state.preferences.theme,
'currentFile': state => state.editor.currentFile, 'currentFile': state => state.editor.currentFile,
// edit modes // edit modes
'typewriter': state => state.preferences.typewriter, 'typewriter': state => state.preferences.typewriter,
'focus': state => state.preferences.focus, 'focus': state => state.preferences.focus,

View File

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

View File

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

View File

@ -57,7 +57,7 @@
return return
} }
// TODO(need::refactor): See TODO in src/main/filesystem/watcher.js. // TODO(need::refactor): Please see #1034.
this.searchResult = this.fileList.filter(f => f.data.markdown.indexOf(keyword) >= 0) this.searchResult = this.fileList.filter(f => f.data.markdown.indexOf(keyword) >= 0)
} }

View File

@ -41,9 +41,6 @@ import './assets/styles/printService.css'
// ----------------------------------------------- // -----------------------------------------------
// Decode source map in production - must be registered first // Decode source map in production - must be registered first
addElementStyle()
sourceMapSupport.install({ sourceMapSupport.install({
environment: 'node', environment: 'node',
handleUncaughtExceptions: false, handleUncaughtExceptions: false,
@ -53,6 +50,8 @@ sourceMapSupport.install({
global.marktext = {} global.marktext = {}
bootstrapRenderer() bootstrapRenderer()
addElementStyle()
// ----------------------------------------------- // -----------------------------------------------
// Be careful when changing code before this line! // Be careful when changing code before this line!

View File

@ -11,9 +11,9 @@ export const tabsMixins = {
removeFileInTab (file) { removeFileInTab (file) {
const { isSaved } = file const { isSaved } = file
if (isSaved) { if (isSaved) {
this.$store.dispatch('REMOVE_FILE_IN_TABS', file) this.$store.dispatch('FORCE_CLOSE_TAB', file)
} else { } else {
this.$store.dispatch('CLOSE_SINGLE_FILE', file) this.$store.dispatch('CLOSE_UNSAVED_TAB', file)
} }
} }
} }
@ -22,19 +22,17 @@ export const tabsMixins = {
export const fileMixins = { export const fileMixins = {
methods: { methods: {
handleFileClick () { handleFileClick () {
// HACK: Please see #1034 and #1035
const { data, isMarkdown, pathname } = this.file const { data, isMarkdown, pathname } = this.file
if (!isMarkdown || this.currentFile.pathname === pathname) return if (!isMarkdown || this.currentFile.pathname === pathname) return
const { isMixedLineEndings, filename, lineEnding } = data const { isMixedLineEndings, filename, lineEnding } = data
const isOpened = this.tabs.filter(file => file.pathname === pathname)[0] const isOpened = this.tabs.find(file => file.pathname === pathname)
const fileState = isOpened || getFileStateFromData(data) const fileState = isOpened || getFileStateFromData(data)
this.$store.dispatch('UPDATE_CURRENT_FILE', fileState) this.$store.dispatch('UPDATE_CURRENT_FILE', fileState)
// ask main process to watch this file changes
this.$store.dispatch('ASK_FILE_WATCH', {
pathname,
watch: true
})
// HACK: notify main process. Main process should notify the browser window.
ipcRenderer.send('AGANI::window-add-file-path', pathname)
ipcRenderer.send('mt::add-recently-used-document', pathname) ipcRenderer.send('mt::add-recently-used-document', pathname)
if (isMixedLineEndings && !isOpened) { if (isMixedLineEndings && !isOpened) {

View File

@ -2,7 +2,7 @@
<div <div
class="editor-container" class="editor-container"
> >
<side-bar></side-bar> <side-bar v-if="init"></side-bar>
<div class="editor-middle"> <div class="editor-middle">
<title-bar <title-bar
:project="projectTree" :project="projectTree"
@ -13,6 +13,7 @@
:platform="platform" :platform="platform"
:is-saved="isSaved" :is-saved="isSaved"
></title-bar> ></title-bar>
<div class="editor-placeholder" v-if="!init"></div>
<recent <recent
v-if="!hasCurrentFile && init" v-if="!hasCurrentFile && init"
></recent> ></recent>
@ -180,6 +181,7 @@
</script> </script>
<style scoped> <style scoped>
.editor-placeholder,
.editor-container { .editor-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -197,6 +199,9 @@
position: absolute; position: absolute;
left: -10000px; left: -10000px;
} }
.editor-placeholder {
background: var(--editorBgColor);
}
.editor-middle { .editor-middle {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -42,12 +42,12 @@
:onChange="value => onSelectChange('fileSortBy', value)" :onChange="value => onSelectChange('fileSortBy', value)"
:disable="true" :disable="true"
></cur-select> ></cur-select>
<section class="startup-ctrl ag-underdevelop"> <section class="startup-action-ctrl">
<div>The action after Mark Text startup, open the last edited content, open the specified folder or blank page</div> <div>The action after Mark Text startup, open the last edited content, open the specified folder or blank page</div>
<el-radio v-model="startUp" label="lastState">Open the last closed folder and files</el-radio> <el-radio class="ag-underdevelop" v-model="startUpAction" label="lastState">Open the last window state</el-radio>
<el-radio v-model="startUp" label="folder">Open the subfolder</el-radio> <el-radio v-model="startUpAction" label="folder">Open a default directory</el-radio>
<el-button size="small">Select Folder</el-button> <el-button size="small" @click="selectDefaultDirectoryToOpen">Select Folder</el-button>
<el-radio v-model="startUp" label="blank">Open blank page</el-radio> <el-radio v-model="startUpAction" label="blank">Open blank page</el-radio>
</section> </section>
<cur-select <cur-select
description="The language Mark Text use" description="The language Mark Text use"
@ -95,13 +95,16 @@ export default {
openFilesInNewWindow: state => state.preferences.openFilesInNewWindow, openFilesInNewWindow: state => state.preferences.openFilesInNewWindow,
aidou: state => state.preferences.aidou, aidou: state => state.preferences.aidou,
fileSortBy: state => state.preferences.fileSortBy, fileSortBy: state => state.preferences.fileSortBy,
startUp: state => state.preferences.startUp, startUpAction: state => state.preferences.startUpAction,
language: state => state.preferences.language language: state => state.preferences.language
}) })
}, },
methods: { methods: {
onSelectChange (type, value) { onSelectChange (type, value) {
this.$store.dispatch('SET_SINGLE_PREFERENCE', { type, value }) this.$store.dispatch('SET_SINGLE_PREFERENCE', { type, value })
},
selectDefaultDirectoryToOpen () {
this.$store.dispatch('SELECT_DEFAULT_DIRECTORY_TO_OPEN')
} }
} }
} }
@ -114,7 +117,7 @@ export default {
margin: 0; margin: 0;
font-weight: 100; font-weight: 100;
} }
& .startup-ctrl { & .startup-action-ctrl {
font-size: 14px; font-size: 14px;
user-select: none; user-select: none;
margin: 20px 0; margin: 20px 0;

View File

@ -2,7 +2,7 @@ import { clipboard, ipcRenderer, shell } from 'electron'
import path from 'path' import path from 'path'
import bus from '../bus' import bus from '../bus'
import { hasKeys, getUniqueId } from '../util' import { hasKeys, getUniqueId } from '../util'
import { isSameFileSync } from '../util/fileSystem' import { isSamePathSync } from '../util/fileSystem'
import listToTree from '../util/listToTree' import listToTree from '../util/listToTree'
import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help' import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
import notice from '../services/notification' import notice from '../services/notification'
@ -39,7 +39,6 @@ const mutations = {
const { tabs, currentFile } = state const { tabs, currentFile } = state
const index = tabs.indexOf(file) const index = tabs.indexOf(file)
tabs.splice(index, 1) tabs.splice(index, 1)
state.tabs = tabs
if (file.id === currentFile.id) { if (file.id === currentFile.id) {
const fileState = state.tabs[index] || state.tabs[index - 1] || state.tabs[0] || {} const fileState = state.tabs[index] || state.tabs[index - 1] || state.tabs[0] || {}
state.currentFile = fileState state.currentFile = fileState
@ -91,37 +90,40 @@ const mutations = {
}) })
} }
let fileState = null
for (const tab of tabs) {
if (tab.pathname === pathname) {
const oldId = tab.id
Object.assign(tab, newFileState)
tab.id = oldId
fileState = tab
break
}
}
if (!fileState) { const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
if (!tab) {
throw new Error('LOAD_CHANGE: Cannot find tab in tab list.') throw new Error('LOAD_CHANGE: Cannot find tab in tab list.')
} }
// Upate file content but not tab id.
const oldId = tab.id
Object.assign(tab, newFileState)
tab.id = oldId
if (pathname === currentFile.pathname) { if (pathname === currentFile.pathname) {
state.currentFile = fileState state.currentFile = tab
const { id, cursor, history } = fileState const { id, cursor, history } = tab
bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history }) bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
} }
}, },
SET_PATHNAME (state, file) { // NOTE: Please call this function only from main process via "AGANI::set-pathname" and free resources before!
const { filename, pathname, id } = file SET_PATHNAME (state, { tab, fileInfo }) {
if (id === state.currentFile.id && pathname) { const { currentFile } = state
const { filename, pathname, id } = fileInfo
// Change reference path for images.
if (id === currentFile.id && pathname) {
window.DIRNAME = path.dirname(pathname) window.DIRNAME = path.dirname(pathname)
} }
const targetFile = state.tabs.filter(f => f.id === id)[0] if (tab) {
if (targetFile) { Object.assign(tab, { filename, pathname, isSaved: true })
const isSaved = true }
Object.assign(targetFile, { filename, pathname, isSaved }) },
SET_SAVE_STATUS_BY_TAB (state, { tab, status }) {
if (hasKeys(tab)) {
tab.isSaved = status
} }
}, },
SET_SAVE_STATUS (state, status) { SET_SAVE_STATUS (state, status) {
@ -171,25 +173,29 @@ const mutations = {
state.currentFile.history = history state.currentFile.history = history
} }
}, },
CLOSE_TABS (state, arr) { CLOSE_TABS (state, tabIdList) {
if (!tabIdList || tabIdList.length === 0) return
let tabIndex = 0 let tabIndex = 0
arr.forEach(id => { tabIdList.forEach(id => {
const index = state.tabs.findIndex(f => f.id === id) const index = state.tabs.findIndex(f => f.id === id)
const { pathname } = state.tabs.find(f => f.id === id) const { pathname } = state.tabs[index]
// Notify main process to remove the file from the window and free resources.
if (pathname) { if (pathname) {
// close tab and unwatch this file ipcRenderer.send('mt::window-tab-closed', pathname)
ipcRenderer.send('AGANI::file-watch', { pathname, watch: false })
} }
state.tabs.splice(index, 1) state.tabs.splice(index, 1)
if (state.currentFile.id === id) { if (state.currentFile.id === id) {
state.currentFile = {} state.currentFile = {}
window.DIRNAME = '' window.DIRNAME = ''
if (arr.length === 1) { if (tabIdList.length === 1) {
tabIndex = index tabIndex = index
} }
} }
}) })
if (!state.currentFile.id && state.tabs.length) { if (!state.currentFile.id && state.tabs.length) {
state.currentFile = state.tabs[tabIndex] || state.tabs[tabIndex - 1] || state.tabs[0] || {} state.currentFile = state.tabs[tabIndex] || state.tabs[tabIndex - 1] || state.tabs[0] || {}
if (typeof state.currentFile.markdown === 'string') { if (typeof state.currentFile.markdown === 'string') {
@ -261,11 +267,14 @@ const actions = {
}) })
}, },
REMOVE_FILE_IN_TABS ({ commit, dispatch }, file) { FORCE_CLOSE_TAB ({ commit, dispatch }, file) {
commit('REMOVE_FILE_WITHIN_TABS', file) commit('REMOVE_FILE_WITHIN_TABS', file)
// unwatch this file
const { pathname } = file const { pathname } = file
dispatch('ASK_FILE_WATCH', { pathname, watch: false })
// Notify main process to remove the file from the window and free resources.
if (pathname) {
ipcRenderer.send('mt::window-tab-closed', pathname)
}
}, },
EXCHANGE_TABS_BY_ID ({ commit }, tabIDs) { EXCHANGE_TABS_BY_ID ({ commit }, tabIDs) {
@ -287,10 +296,12 @@ const actions = {
} }
}, },
CLOSE_SINGLE_FILE ({ commit, state }, file) { CLOSE_UNSAVED_TAB ({ commit, state }, file) {
const { id, pathname, filename, markdown } = file const { id, pathname, filename, markdown } = file
const options = getOptionsFromState(file) const options = getOptionsFromState(file)
ipcRenderer.send('AGANI::save-close', [{ id, pathname, filename, markdown, options }], true)
// Save the file content via main process and send a close tab response.
ipcRenderer.send('mt::save-and-close-tabs', [{ id, pathname, filename, markdown, options }])
}, },
// need pass some data to main process when `save` menu item clicked // need pass some data to main process when `save` menu item clicked
@ -315,56 +326,89 @@ const actions = {
}) })
}, },
LISTEN_FOR_SET_PATHNAME ({ commit }) { LISTEN_FOR_SET_PATHNAME ({ commit, dispatch, state }) {
ipcRenderer.on('AGANI::set-pathname', (e, file) => { ipcRenderer.on('mt::set-pathname', (e, fileInfo) => {
commit('SET_PATHNAME', file) const { tabs } = state
const { pathname, id } = fileInfo
const tab = tabs.find(f => f.id === id)
if (!tab) {
console.err('[ERROR] Cannot change file path from unknown tab.')
return
}
// If a tab with the same file path already exists we need to close the tab.
// The existing tab is overwritten by this tab.
const existingTab = tabs.find(t => t.id !== id && isSamePathSync(t.pathname, pathname))
if (existingTab) {
dispatch('CLOSE_TAB', existingTab)
}
commit('SET_PATHNAME', { tab, fileInfo })
})
ipcRenderer.on('mt::tab-saved', (e, tabId) => {
const { tabs } = state
const tab = tabs.find(f => f.id === tabId)
if (tab) {
Object.assign(tab, { isSaved: true })
}
})
ipcRenderer.on('mt::tab-save-failure', (e, tabId, msg) => {
notice.notify({
title: 'Save failure',
message: msg,
type: 'error',
time: 20000,
showConfirm: false
})
}) })
}, },
LISTEN_FOR_CLOSE ({ commit, state }) { LISTEN_FOR_CLOSE ({ state }) {
ipcRenderer.on('AGANI::ask-for-close', e => { ipcRenderer.on('AGANI::ask-for-close', e => {
const unSavedFiles = state.tabs.filter(file => !file.isSaved) const unsavedFiles = state.tabs
.filter(file => !file.isSaved)
.map(file => { .map(file => {
const { id, filename, pathname, markdown } = file const { id, filename, pathname, markdown } = file
const options = getOptionsFromState(file) const options = getOptionsFromState(file)
return { id, filename, pathname, markdown, options } return { id, filename, pathname, markdown, options }
}) })
if (unSavedFiles.length) { if (unsavedFiles.length) {
ipcRenderer.send('AGANI::response-close-confirm', unSavedFiles) ipcRenderer.send('mt::close-window-confirm', unsavedFiles)
} else { } else {
ipcRenderer.send('AGANI::close-window') ipcRenderer.send('mt::close-window')
} }
}) })
}, },
LISTEN_FOR_SAVE_CLOSE ({ commit, state }) { LISTEN_FOR_SAVE_CLOSE ({ commit }) {
ipcRenderer.on('AGANI::save-all-response', (e, { err, data }) => { ipcRenderer.on('mt::force-close-tabs-by-id', (e, tabIdList) => {
if (!err && Array.isArray(data)) { if (Array.isArray(tabIdList) && tabIdList.length) {
const toBeClosedTabs = [...state.tabs.filter(f => f.isSaved), ...data] commit('CLOSE_TABS', tabIdList)
commit('CLOSE_TABS', toBeClosedTabs)
}
})
ipcRenderer.on('AGANI::save-single-response', (e, { err, data }) => {
if (!err && Array.isArray(data) && data.length) {
commit('CLOSE_TABS', data)
} }
}) })
}, },
ASK_FOR_SAVE_ALL ({ commit, state }, isClose) { ASK_FOR_SAVE_ALL ({ commit, state }, closeTabs) {
const unSavedFiles = state.tabs.filter(file => !(file.isSaved && /[^\n]/.test(file.markdown))) const { tabs } = state
const unsavedFiles = tabs
.filter(file => !(file.isSaved && /[^\n]/.test(file.markdown)))
.map(file => { .map(file => {
const { id, filename, pathname, markdown } = file const { id, filename, pathname, markdown } = file
const options = getOptionsFromState(file) const options = getOptionsFromState(file)
return { id, filename, pathname, markdown, options } return { id, filename, pathname, markdown, options }
}) })
if (unSavedFiles.length) {
const EVENT_NAME = isClose ? 'AGANI::save-close' : 'AGANI::save-all' if (closeTabs) {
const isSingle = false if (unsavedFiles.length) {
ipcRenderer.send(EVENT_NAME, unSavedFiles, isSingle) commit('CLOSE_TABS', tabs.filter(f => f.isSaved).map(f => f.id))
} else if (isClose) { ipcRenderer.send('mt::save-and-close-tabs', unsavedFiles)
commit('CLOSE_TABS', state.tabs.map(f => f.id)) } else {
commit('CLOSE_TABS', tabs.map(f => f.id))
}
} else {
ipcRenderer.send('mt::save-tabs', unsavedFiles)
} }
}, },
@ -406,7 +450,7 @@ const actions = {
const { id, pathname, filename } = state.currentFile const { id, pathname, filename } = state.currentFile
if (typeof filename === 'string' && filename !== newFilename) { if (typeof filename === 'string' && filename !== newFilename) {
const newPathname = path.join(path.dirname(pathname), newFilename) const newPathname = path.join(path.dirname(pathname), newFilename)
ipcRenderer.send('AGANI::rename', { id, pathname, newPathname }) ipcRenderer.send('mt::rename', { id, pathname, newPathname })
} }
}, },
@ -420,56 +464,57 @@ const actions = {
// This events are only used during window creation. // This events are only used during window creation.
LISTEN_FOR_BOOTSTRAP_WINDOW ({ commit, state, dispatch }) { LISTEN_FOR_BOOTSTRAP_WINDOW ({ commit, state, dispatch }) {
ipcRenderer.on('mt::bootstrap-window', (e, { markdown, filename, pathname, options }) => { ipcRenderer.on('mt::bootstrap-editor', (e, config) => {
const fileState = getSingleFileState({ markdown, filename, pathname, options }) const {
const { id } = fileState addBlankTab,
const { lineEnding } = options markdownList,
commit('SET_GLOBAL_LINE_ENDING', lineEnding) lineEnding,
dispatch('INIT_STATUS', true) sideBarVisibility,
dispatch('UPDATE_CURRENT_FILE', fileState) tabBarVisibility,
bus.$emit('file-loaded', { id, markdown }) sourceCodeModeEnabled
commit('SET_LAYOUT', { } = config
rightColumn: 'files',
showSideBar: false,
showTabBar: false
})
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) commit('SET_GLOBAL_LINE_ENDING', lineEnding)
dispatch('INIT_STATUS', true) dispatch('SEND_INITIALIZED')
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
commit('SET_LAYOUT', { commit('SET_LAYOUT', {
rightColumn: 'files', rightColumn: 'files',
showSideBar: false, showSideBar: !!sideBarVisibility,
showTabBar: false showTabBar: !!tabBarVisibility
}) })
dispatch('SET_LAYOUT_MENU_ITEM') dispatch('SET_LAYOUT_MENU_ITEM')
commit('SET_MODE', {
type: 'sourceCode',
checked: !!sourceCodeModeEnabled
})
if (addBlankTab) {
dispatch('NEW_UNTITLED_TAB', {})
} else if (markdownList.length) {
let isFirst = true
for (const markdown of markdownList) {
isFirst = false
dispatch('NEW_UNTITLED_TAB', { markdown, selected: isFirst })
}
}
}) })
}, },
// Open a new tab, optionally with content. // Open a new tab, optionally with content.
LISTEN_FOR_NEW_TAB ({ dispatch }) { LISTEN_FOR_NEW_TAB ({ dispatch }) {
ipcRenderer.on('AGANI::new-tab', (e, markdownDocument, selectTab=true) => { ipcRenderer.on('AGANI::new-tab', (e, markdownDocument, selected=true) => {
// TODO: allow to add a tab without selecting it
if (markdownDocument) { if (markdownDocument) {
// Create tab with content. // Create tab with content.
dispatch('NEW_TAB_WITH_CONTENT', markdownDocument) dispatch('NEW_TAB_WITH_CONTENT', { markdownDocument, selected })
} else { } else {
// Fallback: create a blank tab // Fallback: create a blank tab and always select it
dispatch('NEW_UNTITLED_TAB') dispatch('NEW_UNTITLED_TAB', {})
} }
}) })
ipcRenderer.on('mt::new-untitled-tab', (e, selectTab=true, markdownString='', ) => { ipcRenderer.on('mt::new-untitled-tab', (e, selected=true, markdown='', ) => {
// TODO: allow to add a tab without selecting it
// Create a blank tab // Create a blank tab
dispatch('NEW_UNTITLED_TAB', markdownString) dispatch('NEW_UNTITLED_TAB', { markdown, selected })
}) })
}, },
@ -477,56 +522,100 @@ const actions = {
ipcRenderer.on('AGANI::close-tab', e => { ipcRenderer.on('AGANI::close-tab', e => {
const file = state.currentFile const file = state.currentFile
if (!hasKeys(file)) return if (!hasKeys(file)) return
const { isSaved } = file dispatch('CLOSE_TAB', file)
if (isSaved) {
dispatch('REMOVE_FILE_IN_TABS', file)
} else {
dispatch('CLOSE_SINGLE_FILE', file)
}
}) })
}, },
// Create a new untitled tab optional with markdown string. CLOSE_TAB ({ dispatch }, file) {
NEW_UNTITLED_TAB ({ commit, state, dispatch }, markdownString) { const { isSaved } = file
dispatch('SHOW_TAB_VIEW', false) if (isSaved) {
const { tabs, lineEnding } = state dispatch('FORCE_CLOSE_TAB', file)
const fileState = getBlankFileState(tabs, lineEnding, markdownString) } else {
const { id, markdown } = fileState dispatch('CLOSE_UNSAVED_TAB', file)
dispatch('UPDATE_CURRENT_FILE', fileState) }
bus.$emit('file-loaded', { id, markdown })
}, },
/** /**
* Create a new tab from the given markdown document * Create a new untitled tab optional from a markdown string.
* *
* @param {*} context Store context * @param {*} context The store context.
* @param {IMarkdownDocumentRaw} markdownDocument Class that represent a markdown document * @param {{markdown?: string, selected?: boolean}} obj Optional markdown string
* and whether the tab should become the selected tab (true if not set).
*/ */
NEW_TAB_WITH_CONTENT ({ commit, state, dispatch }, markdownDocument) { NEW_UNTITLED_TAB ({ commit, state, dispatch }, { markdown: markdownString, selected }) {
// If not set select the tab.
if (selected == null) {
selected = true
}
dispatch('SHOW_TAB_VIEW', false)
const { tabs, lineEnding } = state
const fileState = getBlankFileState(tabs, lineEnding, markdownString)
if (selected) {
const { id, markdown } = fileState
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
} else {
commit('ADD_FILE_TO_TABS', fileState)
}
},
/**
* Create a new tab from the given markdown document.
*
* @param {*} context The store context.
* @param {{markdownDocument: IMarkdownDocumentRaw, selected?: boolean}} obj The markdown document
* and optional whether the tab should become the selected tab (true if not set).
*/
NEW_TAB_WITH_CONTENT ({ commit, state, dispatch }, { markdownDocument, selected }) {
if (!markdownDocument) { if (!markdownDocument) {
console.warn('Cannot create a file tab without a markdown document!') console.warn('Cannot create a file tab without a markdown document!')
dispatch('NEW_UNTITLED_TAB') dispatch('NEW_UNTITLED_TAB', {})
return return
} }
// Check if tab already exist and select existing tab if so. // Select the tab if not value is specified.
const { tabs } = state if (selected == null) {
selected = true
}
// Check if tab already exist and always select existing tab if so.
const { currentFile, tabs } = state
const { pathname } = markdownDocument const { pathname } = markdownDocument
const existingTab = tabs.find(t => isSameFileSync(t.pathname, pathname)) const existingTab = tabs.find(t => isSamePathSync(t.pathname, pathname))
if (existingTab) { if (existingTab) {
dispatch('UPDATE_CURRENT_FILE', existingTab) dispatch('UPDATE_CURRENT_FILE', existingTab)
return return
} }
dispatch('SHOW_TAB_VIEW', false) // Replace/close selected untitled empty tab
let keepTabBarState = false
if (currentFile) {
const { isSaved, pathname } = currentFile
if (isSaved && !pathname) {
keepTabBarState = true
dispatch('FORCE_CLOSE_TAB', currentFile)
}
}
if (!keepTabBarState) {
dispatch('SHOW_TAB_VIEW', false)
}
const { markdown, isMixedLineEndings } = markdownDocument const { markdown, isMixedLineEndings } = markdownDocument
const docState = createDocumentState(markdownDocument) const docState = createDocumentState(markdownDocument)
const { id } = docState const { id } = docState
dispatch('UPDATE_CURRENT_FILE', docState)
bus.$emit('file-loaded', { id, markdown }) if (selected) {
dispatch('UPDATE_CURRENT_FILE', docState)
bus.$emit('file-loaded', { id, markdown })
} else {
commit('ADD_FILE_TO_TABS', docState)
}
if (isMixedLineEndings) { if (isMixedLineEndings) {
// TODO: Show (this) notification(s) per tab.
const { filename, lineEnding } = markdownDocument const { filename, lineEnding } = markdownDocument
notice.notify({ notice.notify({
title: 'Line Ending', title: 'Line Ending',
@ -540,7 +629,7 @@ const actions = {
SHOW_TAB_VIEW ({ commit, state, dispatch }, always) { SHOW_TAB_VIEW ({ commit, state, dispatch }, always) {
const { tabs } = state const { tabs } = state
if (always || tabs.length <= 1) { if (always || tabs.length === 1) {
commit('SET_LAYOUT', { showTabBar: true }) commit('SET_LAYOUT', { showTabBar: true })
dispatch('SET_LAYOUT_MENU_ITEM') dispatch('SET_LAYOUT_MENU_ITEM')
} }
@ -676,43 +765,54 @@ const actions = {
LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) { LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) {
ipcRenderer.on('AGANI::update-file', (e, { type, change }) => { ipcRenderer.on('AGANI::update-file', (e, { type, change }) => {
// TODO: Set `isSaved` to false.
// TODO: A new "changed" notification from different files overwrite the old notification - the old notification disappears. // TODO: A new "changed" notification from different files overwrite the old notification
if (type === 'unlink') { // and the old notification disappears. I think we should bind the notification to the tab.
return notice.notify({
title: 'File Removed on Disk', const { tabs } = state
message: `${change.pathname} has been removed or moved to other place`, const { pathname } = change
type: 'warning', const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
time: 0, if (tab) {
showConfirm: false commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
}) }
} else {
const { autoSave } = rootState.preferences switch (type) {
const { windowActive } = rootState case 'unlink': {
const { filename } = change.data
if (windowActive) return
if (autoSave) {
commit('LOAD_CHANGE', change)
} else {
notice.clear()
notice.notify({ notice.notify({
title: 'File Changed on Disk', title: 'File Removed on Disk',
message: `${filename} has been changed on disk, do you want to reload it?`, message: `${pathname} has been removed or moved.`,
showConfirm: true, type: 'warning',
time: 0 time: 0,
showConfirm: false
}) })
.then(() => { break
commit('LOAD_CHANGE', change)
})
} }
case 'add':
case 'change': {
const { autoSave } = rootState.preferences
const { filename } = change.data
if (autoSave) {
commit('LOAD_CHANGE', change)
} else {
notice.clear()
notice.notify({
title: 'File Changed on Disk',
message: `${filename} has been changed on disk, do you want to reload it?`,
showConfirm: true,
time: 0
})
.then(() => {
commit('LOAD_CHANGE', change)
})
}
break
}
default:
console.error(`LISTEN_FOR_FILE_CHANGE: Invalid type "${type}"`)
} }
}) })
}, },
ASK_FILE_WATCH ({ commit }, { pathname, watch }) {
ipcRenderer.send('AGANI::file-watch', { pathname, watch })
},
ASK_FOR_IMAGE_PATH ({ commit }) { ASK_FOR_IMAGE_PATH ({ commit }) {
return ipcRenderer.sendSync('mt::ask-for-image-path') return ipcRenderer.sendSync('mt::ask-for-image-path')
} }

View File

@ -18,9 +18,8 @@ Vue.use(Vuex)
const state = { const state = {
platform: process.platform, // platform of system `darwin` | `win32` | `linux` platform: process.platform, // platform of system `darwin` | `win32` | `linux`
appVersion: process.versions.MARKTEXT_VERSION_STRING, // Mark Text version string appVersion: process.versions.MARKTEXT_VERSION_STRING, // Mark Text version string
windowActive: true, // weather current window is active or focused windowActive: true, // whether current window is active or focused
// TODO: "init" is nowhere used init: false, // whether Mark Text is initialized
init: process.env.NODE_ENV === 'development' // whether Mark Text is inited
} }
const getters = {} const getters = {}
@ -29,8 +28,8 @@ const mutations = {
SET_WIN_STATUS (state, status) { SET_WIN_STATUS (state, status) {
state.windowActive = status state.windowActive = status
}, },
SET_INIT_STATUS (state, status) { SET_INITIALIZED (state) {
state.init = status state.init = true
} }
} }
@ -40,8 +39,9 @@ const actions = {
commit('SET_WIN_STATUS', status) commit('SET_WIN_STATUS', status)
}) })
}, },
INIT_STATUS ({ commit }, status) {
commit('SET_INIT_STATUS', status) SEND_INITIALIZED ({ commit }) {
commit('SET_INITIALIZED')
} }
} }

View File

@ -5,8 +5,7 @@ const state = {}
const getters = {} const getters = {}
const mutations = { const mutations = {}
}
const actions = { const actions = {
LISTEN_FOR_NOTIFICATION ({ commit }) { LISTEN_FOR_NOTIFICATION ({ commit }) {

View File

@ -3,13 +3,15 @@ import { getOptionsFromState } from './help'
// user preference // user preference
const state = { const state = {
autoSave: true, autoSave: false,
autoSaveDelay: 3000, autoSaveDelay: 5000,
titleBarStyle: 'csd', titleBarStyle: 'custom',
openFilesInNewWindow: false, openFilesInNewWindow: false,
openFolderInNewWindow: false,
aidou: true, aidou: true,
fileSortBy: 'created', fileSortBy: 'created',
startUp: 'folder', startUpAction: 'lastState',
defaultDirectoryToOpen: '',
language: 'en', language: 'en',
editorFontFamily: 'Open Sans', editorFontFamily: 'Open Sans',
@ -33,7 +35,13 @@ const state = {
listIndentation: 1, listIndentation: 1,
theme: 'light', theme: 'light',
// edit modes (they are not in preference.md, but still put them here)
// Default values that are overwritten with the entries below.
sideBarVisibility: false,
tabBarVisibility: false,
sourceCodeModeEnabled: false,
// Edit modes of the current window (not part of persistent settings)
typewriter: false, // typewriter mode typewriter: false, // typewriter mode
focus: false, // focus mode focus: false, // focus mode
sourceCode: false, // source code mode sourceCode: false, // source code mode
@ -101,7 +109,6 @@ const actions = {
}, },
SET_SINGLE_PREFERENCE ({ commit }, { type, value }) { SET_SINGLE_PREFERENCE ({ commit }, { type, value }) {
// commit('SET_USER_PREFERENCE', { [type]: value })
// save to electron-store // save to electron-store
ipcRenderer.send('mt::set-user-preference', { [type]: value }) ipcRenderer.send('mt::set-user-preference', { [type]: value })
}, },
@ -112,6 +119,10 @@ const actions = {
SET_IMAGE_FOLDER_PATH ({ commit }) { SET_IMAGE_FOLDER_PATH ({ commit }) {
ipcRenderer.send('mt::ask-for-modify-image-folder-path') ipcRenderer.send('mt::ask-for-modify-image-folder-path')
},
SELECT_DEFAULT_DIRECTORY_TO_OPEN ({ commit }) {
ipcRenderer.send('mt::select-default-directory-to-open')
} }
} }

View File

@ -35,7 +35,7 @@ const getters = {
} }
const mutations = { const mutations = {
SET_PROJECT_TREE (state, pathname) { SET_ROOT_DIRECTORY (state, pathname) {
let name = path.basename(pathname) let name = path.basename(pathname)
if (!name) { if (!name) {
// Root directory such "/" or "C:\" // Root directory such "/" or "C:\"
@ -108,12 +108,8 @@ const mutations = {
const actions = { const actions = {
LISTEN_FOR_LOAD_PROJECT ({ commit, dispatch }) { LISTEN_FOR_LOAD_PROJECT ({ commit, dispatch }) {
ipcRenderer.on('AGANI::open-project', (e, pathname) => { ipcRenderer.on('mt::open-directory', (e, pathname) => {
// Initialize editor and show empty/new tab commit('SET_ROOT_DIRECTORY', pathname)
dispatch('NEW_UNTITLED_TAB')
dispatch('INIT_STATUS', true)
commit('SET_PROJECT_TREE', pathname)
commit('SET_LAYOUT', { commit('SET_LAYOUT', {
rightColumn: 'files', rightColumn: 'files',
showSideBar: true, showSideBar: true,
@ -163,7 +159,7 @@ const actions = {
commit('SET_CLIPBOARD', data) commit('SET_CLIPBOARD', data)
}, },
ASK_FOR_OPEN_PROJECT ({ commit }) { ASK_FOR_OPEN_PROJECT ({ commit }) {
ipcRenderer.send('AGANI::ask-for-open-project-in-sidebar') ipcRenderer.send('mt::ask-for-open-project-in-sidebar')
}, },
LISTEN_FOR_SIDEBAR_CONTEXT_MENU ({ commit, state }) { LISTEN_FOR_SIDEBAR_CONTEXT_MENU ({ commit, state }) {
bus.$on('SIDEBAR::show-in-folder', () => { bus.$on('SIDEBAR::show-in-folder', () => {

View File

@ -25,11 +25,11 @@ export const rename = (src, dest) => {
/** /**
* Check if the both paths point to the same file. * Check if the both paths point to the same file.
* *
* @param {*} pathA The first path. * @param {string} pathA The first path.
* @param {*} pathB The second path. * @param {string} pathB The second path.
* @param {*} isNormalized Are both paths already normalized. * @param {boolean} [isNormalized] Are both paths already normalized.
*/ */
export const isSameFileSync = (pathA, pathB, isNormalized=false) => { export const isSamePathSync = (pathA, pathB, isNormalized = false) => {
if (!pathA || !pathB) return false if (!pathA || !pathB) return false
const a = isNormalized ? pathA : path.normalize(pathA) const a = isNormalized ? pathA : path.normalize(pathA)
const b = isNormalized ? pathB : path.normalize(pathB) const b = isNormalized ? pathB : path.normalize(pathB)

View File

@ -3,9 +3,11 @@
"autoSaveDelay": 5000, "autoSaveDelay": 5000,
"titleBarStyle": "custom", "titleBarStyle": "custom",
"openFilesInNewWindow": false, "openFilesInNewWindow": false,
"openFolderInNewWindow": false,
"aidou": true, "aidou": true,
"fileSortBy": "created", "fileSortBy": "created",
"startUp": "folder", "startUpAction": "lastState",
"defaultDirectoryToOpen": "",
"language": "en", "language": "en",
"editorFontFamily": "Open Sans", "editorFontFamily": "Open Sans",
@ -28,5 +30,9 @@
"tabSize": 4, "tabSize": 4,
"listIndentation": 1, "listIndentation": 1,
"theme": "light" "theme": "light",
"sideBarVisibility": false,
"tabBarVisibility": false,
"sourceCodeModeEnabled": false
} }

View File

@ -3993,14 +3993,6 @@ electron-chromedriver@~3.0.0:
electron-download "^4.1.0" electron-download "^4.1.0"
extract-zip "^1.6.5" extract-zip "^1.6.5"
electron-debug@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/electron-debug/-/electron-debug-3.0.0.tgz#55b7895df7f371558d0595d14dc2f8a738a41c81"
integrity sha512-rLrnn7L2soeIqwB6FIzn4+pj6RwT66XhUxadcrS3okjB3ezAv8LsolqrFrO2UrTrvchZgTCcEapV4J0UxlYWIw==
dependencies:
electron-is-dev "^1.1.0"
electron-localshortcut "^3.1.0"
electron-devtools-installer@^2.2.4: electron-devtools-installer@^2.2.4:
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.4.tgz#261a50337e37121d338b966f07922eb4939a8763" resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.4.tgz#261a50337e37121d338b966f07922eb4939a8763"
@ -4031,21 +4023,6 @@ electron-is-accelerator@^0.1.0, electron-is-accelerator@^0.1.2:
resolved "https://registry.yarnpkg.com/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz#509e510c26a56b55e17f863a4b04e111846ab27b" resolved "https://registry.yarnpkg.com/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz#509e510c26a56b55e17f863a4b04e111846ab27b"
integrity sha1-UJ5RDCala1Xhf4Y6SwThEYRqsns= integrity sha1-UJ5RDCala1Xhf4Y6SwThEYRqsns=
electron-is-dev@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.1.0.tgz#b15a2a600bdc48a51a857d460e05f15b19a2522c"
integrity sha512-Z1qA/1oHNowGtSBIcWk0pcLEqYT/j+13xUw/MYOrBUOL4X7VN0i0KCTf5SqyvMPmW5pSPKbo28wkxMxzZ20YnQ==
electron-localshortcut@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/electron-localshortcut/-/electron-localshortcut-3.1.0.tgz#10c1ffd537b8d39170aaf6e1551341f7780dd2ce"
integrity sha512-MgL/j5jdjW7iA0R6cI7S045B0GlKXWM1FjjujVPjlrmyXRa6yH0bGSaIAfxXAF9tpJm3pLEiQzerYHkRh9JG/A==
dependencies:
debug "^2.6.8"
electron-is-accelerator "^0.1.0"
keyboardevent-from-electron-accelerator "^1.1.0"
keyboardevents-areequal "^0.2.1"
electron-log@^3.0.5: electron-log@^3.0.5:
version "3.0.5" version "3.0.5"
resolved "https://registry.yarnpkg.com/electron-log/-/electron-log-3.0.5.tgz#9bdd307f1f1aec85c0873babd6bdfffb1a661436" resolved "https://registry.yarnpkg.com/electron-log/-/electron-log-3.0.5.tgz#9bdd307f1f1aec85c0873babd6bdfffb1a661436"
@ -4622,11 +4599,6 @@ eve-raphael@0.5.0:
resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30"
integrity sha1-F8dUt5K+7z+maE15z1pHxjxM2jA= integrity sha1-F8dUt5K+7z+maE15z1pHxjxM2jA=
eve@~0.5.1:
version "0.5.4"
resolved "https://registry.yarnpkg.com/eve/-/eve-0.5.4.tgz#67d080b9725291d7e389e34c26860dd97f1debaa"
integrity sha1-Z9CAuXJSkdfjieNMJoYN2X8d66o=
event-kit@^2.0.0: event-kit@^2.0.0:
version "2.5.3" version "2.5.3"
resolved "https://registry.yarnpkg.com/event-kit/-/event-kit-2.5.3.tgz#d47e4bc116ec0aacd00263791fa1a55eb5e79ba1" resolved "https://registry.yarnpkg.com/event-kit/-/event-kit-2.5.3.tgz#d47e4bc116ec0aacd00263791fa1a55eb5e79ba1"
@ -6686,11 +6658,6 @@ keyboard-layout@^2.0.15:
event-kit "^2.0.0" event-kit "^2.0.0"
nan "^2.10.0" nan "^2.10.0"
keyboardevent-from-electron-accelerator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-1.1.0.tgz#324614f6e33490c37ffc5be5876b3e85fe223c84"
integrity sha512-VDC4vKWGrR3VgIKCE4CsXnvObGgP8C2idnTKEMUkuEuvDGE1GEBX9FtNdJzrD00iQlhI3xFxRaeItsUmlERVng==
keyboardevents-areequal@^0.2.1: keyboardevents-areequal@^0.2.1:
version "0.2.2" version "0.2.2"
resolved "https://registry.yarnpkg.com/keyboardevents-areequal/-/keyboardevents-areequal-0.2.2.tgz#88191ec738ce9f7591c25e9056de928b40277194" resolved "https://registry.yarnpkg.com/keyboardevents-areequal/-/keyboardevents-areequal-0.2.2.tgz#88191ec738ce9f7591c25e9056de928b40277194"
@ -10030,13 +9997,6 @@ snapdragon@^0.8.1:
source-map-resolve "^0.5.0" source-map-resolve "^0.5.0"
use "^3.1.0" use "^3.1.0"
snapsvg@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/snapsvg/-/snapsvg-0.5.1.tgz#0caf52c79189a290746fc446cc5e863f6bdddfe3"
integrity sha1-DK9Sx5GJopB0b8RGzF6GP2vd3+M=
dependencies:
eve "~0.5.1"
socket.io-adapter@~1.1.0: socket.io-adapter@~1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b" resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"