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(_) {
// 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 {
'global.MARKTEXT_GIT_SHORT_HASH': JSON.stringify(shortHash),
'global.MARKTEXT_GIT_HASH': JSON.stringify(fullHash),
'global.MARKTEXT_VERSION': JSON.stringify(version),
'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.
- 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**
- GUI settings (#1028)
- The cursor jump to the end of format or to the next brackets when press `tab`(#976)
- Tab drag & drop inside the window
- 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**
- 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)
- Improved startup time
- Replace empty untitled tabs (#830)
- Editor window is shown immediately while loading
**:beetle:Bug fix**
@ -23,6 +33,14 @@
- Fixed some bugs after press `backspace` (#934, #938)
- Change `inline math` vertical align to `top` (#977)
- 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

View File

@ -7,11 +7,11 @@ matrix:
include:
- os: osx
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
- os: linux
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
cache:

View File

@ -14,7 +14,7 @@ branches:
skip_tags: true
environment:
MARKTEXT_IS_OFFICIAL_RELEASE: 1
MARKTEXT_IS_STABLE: 1
MARKTEXT_EXIT_ON_ERROR: 1
GH_TOKEN:
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
```
Usage: marktext [commands] [path]
Usage: marktext [commands] [path ...]
Available commands:
Available commands:
--debug Enable debug mode
--safe Disable plugins and other user configuration
--dump-keyboard-layout Dump keyboard information
--version Print version information
--help Print this help message
--debug Enable debug mode
--safe Disable plugins and other user configuration
--dump-keyboard-layout Dump keyboard information
--user-data-dir Change the user data directory
--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
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):**
| Id | Description |
| -------------- | --------------------------------------- |
| `mtHide` | Hide Mark Text |
| `mtHideOthers` | Hide all other windows except Mark Text |
| Id | Description |
| ----------------- | --------------------------------------- |
| `mtHide` | Hide 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:**
@ -80,7 +97,7 @@ Here is an example:
| `paragraphBulletList` | Insert a unordered list |
| `paragraphTaskList` | Insert a task list |
| `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 |
| `paragraphYAMLFrontMatter` | Insert a YAML frontmatter block |
@ -99,17 +116,15 @@ Here is an example:
**Window menu:**
| Id | Description |
| ------------------- | ------------------- |
| `windowMinimize` | Minimize the window |
| `windowCloseWindow` | Close the window |
| Id | Description |
| ------------------------ | ---------------------- |
| `windowMinimize` | Minimize the window |
| `windowToggleFullScreen` | Toggle fullscreen mode |
**View menu:**
| Id | Description |
| ----------------------------- | ---------------------------------------- |
| `viewToggleFullScreen` | Toggle fullscreen mode |
| `viewChangeFont` | Open font dialog |
| `viewSourceCodeMode` | Switch to source code mode |
| `viewTypewriterMode` | Enable typewriter mode |
| `viewFocusMode` | Enable focus mode |
@ -117,4 +132,3 @@ Here is an example:
| `viewToggleTabBar` | Toggle tabbar |
| `viewDevToggleDeveloperTools` | Toggle developer tools (debug mode only) |
| `viewDevReload` | Reload window (debug mode only) |

View File

@ -2,16 +2,18 @@
#### General
| Key | Type | Default Value | Description |
| -------------------- | ------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| autoSave | Boolean | ture | 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 |
| titleBarStyle | String | csd | The title bar style. the native option will result in a standard gray opaque title bar. `csd` (macOS only), `custom`, `native` |
| openFilesInNewWindow | Boolean | false | true, false |
| aidou | Boolean | true | Enable aidou. Optional value: true, false |
| fileSortBy | String | modified | Sort files in opened folder by `created` time, modified time and title. |
| 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` |
| language | String | en | The language Mark Text use. |
| Key | Type | Default Value | Description |
| ---------------------- | ------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| autoSave | Boolean | false | Automatically save the content being edited. option value: true, false |
| autoSaveDelay | Number | 5000 | The delay in milliseconds after a changed file is saved automatically? 3000 ~10000 |
| titleBarStyle | String | custom | The title bar style on Linux and Window: `custom` or `native` |
| openFilesInNewWindow | Boolean | false | true, false |
| openFolderInNewWindow | Boolean | false | true, false |
| aidou | Boolean | true | Enable aidou. Optional value: true, false |
| fileSortBy | String | created | Sort files in opened folder by `created` time, modified time and title. |
| 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
@ -32,17 +34,27 @@
#### Markdown
| Key | Type | Default | Description |
| ------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| preferLooseListItem | Boolean | true | The preferred list type. |
| Key | Type | Default | Description |
| ------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| preferLooseListItem | Boolean | true | The preferred list type. |
| 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) |
| 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 |
| 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 |
#### 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
| Key | Type | Default | Description |
| ----- | ------ | ------- | -------------------------------------------------------------- |
| theme | String | light | `dark` `graphite` `material-dark` `one-dark` `light` `ulysses` |
| Key | Type | Default | Description |
| ----- | ------ | ------- | --------------------------------------------------------------------- |
| theme | String | light | `dark`, `graphite`, `material-dark`, `one-dark`, `light` or `ulysses` |

View File

@ -2,7 +2,7 @@
- Create a release candidate
- 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
- Bump version in `package.json` and changelog
- 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:win": "node .electron-vue/build.js && electron-builder build --win --publish always",
"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:dev": "node .electron-vue/build.js",
"dev": "node .electron-vue/dev-runner.js",
@ -189,7 +189,6 @@
"prismjs": "^1.16.0",
"snabbdom": "^0.7.3",
"snabbdom-to-html": "^5.1.1",
"snapsvg": "^0.5.1",
"source-map-support": "^0.5.12",
"turndown": "^5.0.3",
"turndown-plugin-gfm": "^1.0.2",
@ -226,7 +225,6 @@
"dotenv": "^8.0.0",
"electron": "^5.0.1",
"electron-builder": "^20.40.2",
"electron-debug": "^3.0.0",
"electron-devtools-installer": "^2.2.4",
"electron-rebuild": "^1.8.4",
"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;
Keywords=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>
<style>
html, body {
background: #ffffff;
margin: 0;
padding: 0;
}
@ -18,7 +17,12 @@
<% } %>
</head>
<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>
<!-- Set `__static` path to static files in production -->
<script>
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 fse from 'fs-extra'
import log from 'electron-log'
import { exec } from 'child_process'
import { app, ipcMain, systemPreferences, clipboard } from 'electron'
import dayjs from 'dayjs'
import log from 'electron-log'
import { app, BrowserWindow, clipboard, dialog, ipcMain, systemPreferences } from 'electron'
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 { selectTheme } from '../menu/actions/theme'
import { dockMenu } from '../menu/templates'
import { watchers } from '../utils/imagePathAutoComplement'
import { WindowType } from '../windows/base'
import EditorWindow from '../windows/editor'
import SettingWindow from '../windows/setting'
import { WindowType } from './windowManager'
// import ShortcutCapture from 'shortcut-capture'
class App {
@ -42,6 +43,39 @@ class App {
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('ready', this.ready)
@ -89,7 +123,9 @@ class App {
}
ready = () => {
const { _args: args } = this
const { _args: args, _openFilesCache } = this
const { preferences } = this._accessor
if (!isOsx && args._.length) {
for (const pathname of args._) {
// Ignore all unknown flags
@ -97,13 +133,21 @@ class App {
continue
}
const info = this.normalizePath(pathname)
const info = normalizeMarkdownPath(pathname)
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') {
app.dock.setMenu(dockMenu)
@ -135,11 +179,12 @@ class App {
)
}
if (this._openFilesCache.length) {
this.openFileCache()
if (_openFilesCache.length) {
this._openFilesToOpen()
} else {
this.createEditorWindow()
this._createEditorWindow()
}
// this.shortcutCapture = new ShortcutCapture()
// if (process.env.NODE_ENV === 'development') {
// this.shortcutCapture.dirname = path.resolve(path.join(__dirname, '../../../node_modules/shortcut-capture'))
@ -165,7 +210,7 @@ class App {
openFile = (event, pathname) => {
event.preventDefault()
const info = this.normalizePath(pathname)
const info = normalizeMarkdownPath(pathname)
if (info) {
this._openFilesCache.push(info)
@ -176,53 +221,37 @@ class App {
}
this._openFilesTimer = setTimeout(() => {
this._openFilesTimer = null
this.openFileCache()
this._openFilesToOpen()
}, 100)
}
}
}
openFileCache = () => {
// TODO: Allow to open multiple files in the same window.
this._openFilesCache.forEach(fileInfo => this.createEditorWindow(fileInfo.path))
this._openFilesCache.length = 0 // empty the open file path cache
}
normalizePath = pathname => {
const isDir = isDirectory(pathname)
if (isDir || isMarkdownFileOrLink(pathname)) {
// Normalize and resolve the path or link target.
const resolved = normalizeAndResolvePath(pathname)
if (resolved) {
return { isDir, path: resolved }
} else {
console.error(`[ERROR] Cannot resolve "${pathname}".`)
}
}
return null
}
// --- private --------------------------------
/**
* Creates a new editor window.
*
* @param {string} [pathname] Path to a file, directory or link.
* @param {string} [markdown] Markdown content.
* @param {*} [options] BrowserWindow options.
* @param {string} [rootDirectory] The root directory to open.
* @param {string[]} [fileList] A list of markdown files to open.
* @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)
editor.createWindow(pathname, markdown, options)
editor.createWindow(rootDirectory, fileList, markdownList, options)
this._windowManager.add(editor)
if (this._windowManager.windowCount === 1) {
this._accessor.menu.setActiveWindow(editor.id)
}
return editor
}
/**
* Create a new setting window.
*/
createSettingWindow () {
_createSettingWindow () {
const setting = new SettingWindow(this._accessor)
setting.createWindow()
this._windowManager.add(setting)
@ -231,25 +260,139 @@ class App {
}
}
// TODO(sessions): ...
// // Make Mark Text a single instance application.
// _makeSingleInstance() {
// if (process.mas) return
//
// app.requestSingleInstanceLock()
//
// app.on('second-instance', (event, argv, workingDirectory) => {
// // // TODO: Get active/last active window and open process arvg etc
// // if (currentWindow) {
// // if (currentWindow.isMinimized()) currentWindow.restore()
// // currentWindow.focus()
// // }
// })
// }
_openFilesToOpen () {
this._openPathList(this._openFilesCache, false)
}
/**
* Open the path list in the best window(s).
*
* @param {string[]} pathsToOpen The path list to open.
* @param {boolean} openFilesInSameWindow Open all files in the same window with
* the first directory and discard other directories.
*/
_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 () {
ipcMain.on('app-create-editor-window', () => {
this.createEditorWindow()
this._createEditorWindow()
})
ipcMain.on('screen-capture', win => {
@ -281,7 +424,7 @@ class App {
})
ipcMain.on('app-create-settings-window', () => {
const settingWins = this._windowManager.windowsOfType(WindowType.SETTING)
const settingWins = this._windowManager.getWindowsByType(WindowType.SETTING)
if (settingWins.length >= 1) {
// A setting window is already created
const browserSettingWindow = settingWins[0].win.browserWindow
@ -292,42 +435,77 @@ class App {
}
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) => {
const { openFilesInNewWindow } = this._accessor.preferences.getAll()
const openFilesInNewWindow = this._accessor.preferences.getItem('openFilesInNewWindow')
if (openFilesInNewWindow) {
this.createEditorWindow(filePath)
this._createEditorWindow(null, [ filePath ])
} else {
const editor = this._windowManager.get(windowId)
if (editor && !editor.quitting) {
if (editor) {
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) => {
const { openFilesInNewWindow } = this._accessor.preferences.getAll()
const openFilesInNewWindow = this._accessor.preferences.getItem('openFilesInNewWindow')
if (openFilesInNewWindow) {
this.createEditorWindow(undefined, data)
this._createEditorWindow(null, [], [ data ])
} else {
const editor = this._windowManager.get(windowId)
if (editor && !editor.quitting) {
if (editor) {
editor.openUntitledTab(true, data)
}
}
})
ipcMain.on('app-open-directory-by-id', (windowId, pathname) => {
// TODO: Open the directory in an existing window if prefered.
this.createEditorWindow(pathname)
ipcMain.on('app-open-directory-by-id', (windowId, pathname, openInSameWindow) => {
const { openFolderInNewWindow } = this._accessor.preferences.getAll()
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 EventEmitter from 'events'
import log from 'electron-log'
import Watcher from '../filesystem/watcher'
/**
* A Mark Text window.
* @typedef {EditorWindow} IApplicationWindow
* @property {number | null} id Identifier (= browserWindow.id) or null during initialization.
* @property {Electron.BrowserWindow} browserWindow The browse window.
* @property {WindowType} type The window type.
*/
// Window type marktext support.
export const WindowType = {
BASE: 'base', // You shold never create a `BASE` window.
EDITOR: 'editor',
SETTING: 'setting'
}
import Watcher, { WATCHER_STABILITY_THRESHOLD, WATCHER_STABILITY_POLL_INTERVAL } from '../filesystem/watcher'
import { WindowType } from '../windows/base'
class WindowActivityList {
constructor() {
@ -33,6 +19,14 @@ class WindowActivityList {
return null
}
getSecondNewest () {
const { _buf } = this
if (_buf.length >= 2) {
return _buf[_buf.length - 2]
}
return null
}
setNewest (id) {
// I think we do not need a linked list for only a few windows.
const { _buf } = this
@ -72,7 +66,7 @@ class WindowManager extends EventEmitter {
this._windows = new Map()
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._listenForIpcMain()
@ -84,19 +78,23 @@ class WindowManager extends EventEmitter {
* @param {IApplicationWindow} window The application window. We take ownership!
*/
add (window) {
this._windows.set(window.id, window)
const { id: windowId } = window
this._windows.set(windowId, window)
if (!this._appMenu.has(window.id)) {
this._appMenu.addDefaultMenu(window.id)
if (!this._appMenu.has(windowId)) {
this._appMenu.addDefaultMenu(windowId)
}
if (this.windowCount === 1) {
this.setActiveWindow(window.id)
this.setActiveWindow(windowId)
}
const { browserWindow } = window
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.
*
* @param {string} windowId The window id.
* @returns {IApplicationWindow} The application window or undefined.
* @returns {BaseWindow} The application window or undefined.
*/
get (windowId) {
return this._windows.get(windowId)
@ -127,7 +125,7 @@ class WindowManager extends EventEmitter {
/**
* 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.
* @returns {IApplicationWindow} Returns the application window. We no longer take ownership.
@ -136,7 +134,7 @@ class WindowManager extends EventEmitter {
const { _windows } = this
const window = this.get(windowId)
if (window) {
window.removeAllListeners()
window.removeAllListeners('window-focus')
this._windowActivity.delete(windowId)
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 {number|null}
* Returns the active window or null if no window is registered.
* @returns {BaseWindow|undefined}
*/
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
}
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']
* Return the windows of the given {type}
* @param {WindowType} type the WindowType one of ['base', 'editor', 'setting']
* @returns {{id: number, win: BaseWindow}[]} Return the windows of the given {type}
*/
windowsOfType (type) {
getWindowsByType (type) {
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 result = []
@ -197,10 +220,75 @@ class WindowManager extends EventEmitter {
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 ---------------------------------
closeWatcher () {
this._watcher.clear()
this._watcher.close()
}
/**
@ -213,15 +301,15 @@ class WindowManager extends EventEmitter {
return false
}
const { id } = browserWindow
const { id: windowId } = browserWindow
const { _appMenu, _windows } = this
// Free watchers used by this window
this._watcher.unWatchWin(browserWindow)
this._watcher.unwatchByWindowId(windowId)
// Application clearup and remove listeners
_appMenu.removeWindowMenu(id)
const window = this.remove(id)
_appMenu.removeWindowMenu(windowId)
const window = this.remove(windowId)
// Destroy window wrapper and browser window
if (window) {
@ -251,32 +339,39 @@ class WindowManager extends EventEmitter {
return false
}
// --- events ---------------------------------
// --- private --------------------------------
_listenForIpcMain () {
// listen for file watch from renderer process eg
// 1. click file in folder.
// 2. new tab and save it.
// 3. close tab(s) need unwatch.
ipcMain.on('AGANI::file-watch', (e, { pathname, watch }) => {
// HACK: Don't use this event! Please see #1034 and #1035
ipcMain.on('AGANI::window-add-file-path', (e, filePath) => {
const win = BrowserWindow.fromWebContents(e.sender)
if (watch) {
// listen for file `change` and `unlink`
this._watcher.watch(win, pathname, 'file')
} else {
// unlisten for file `change` and `unlink`
this._watcher.unWatch(win, pathname, 'file')
const editor = this.get(win.id)
if (!editor) {
log.error(`Cannot find window id "${win.id}" to add opened file.`)
return
}
editor.addToOpenedFiles(filePath)
})
// Force close a BrowserWindow
ipcMain.on('AGANI::close-window', e => {
ipcMain.on('mt::close-window', e => {
const win = BrowserWindow.fromWebContents(e.sender)
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 ---------------
ipcMain.on('watcher-unwatch-all-by-id', windowId => {
this._watcher.unwatchByWindowId(windowId)
})
ipcMain.on('watcher-watch-file', (win, filePath) => {
this._watcher.watch(win, filePath, 'file')
})
@ -284,17 +379,44 @@ class WindowManager extends EventEmitter {
this._watcher.watch(win, pathname, 'dir')
})
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) => {
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 => {
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 => {
const flag = !win.isAlwaysOnTop()
win.setAlwaysOnTop(flag)

View File

@ -24,7 +24,9 @@ const cli = () => {
--debug Enable debug mode
--safe Disable plugins and other user configuration
--dump-keyboard-layout Dump keyboard information
-n, --new-window Open a new window on second-instance
--user-data-dir Change the user data directory
--disable-gpu Disable GPU hardware acceleration
-v, --verbose Be verbose
--version Print version information
-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.
* @returns {arg.Result} Parsed arguments
*/
const parseArgs = (argv=null, permissive=true) => {
if (argv == null) {
const parseArgs = (argv = null, permissive = true) => {
if (argv === null) {
argv = process.argv.slice(1)
}
const spec = {
@ -16,6 +16,10 @@ const parseArgs = (argv=null, permissive=true) => {
'--safe': Boolean,
'--dump-keyboard-layout': Boolean,
'--new-window': Boolean,
'-n': '--new-window',
'--disable-gpu': Boolean,
'--user-data-dir': String,
// Misc

View File

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

View File

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

View File

@ -3,6 +3,22 @@ import path from 'path'
import { hasMarkdownExtension } from '../utils'
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.
*
@ -67,7 +83,7 @@ export const isMarkdownFile = filepath => {
}
/**
* Returns ture if the path is an image file.
*
*
* @param {string} filepath The path
*/
export const isImageFile = filepath => {
@ -97,6 +113,45 @@ export const isMarkdownFileOrLink = filepath => {
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.
*

View File

@ -2,7 +2,7 @@ import fs from 'fs-extra'
import path from 'path'
import log from 'electron-log'
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 => {
if (lineEnding === 'lf') {
@ -20,6 +20,27 @@ const convertLineEndings = (text, 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.
*

View File

@ -1,23 +1,24 @@
import path from 'path'
import fs from 'fs'
import fs from 'fs-extra'
import log from 'electron-log'
import { promisify } from 'util'
import chokidar from 'chokidar'
import { getUniqueId, hasMarkdownExtension } from '../utils'
import { exists } from '../filesystem'
import { loadMarkdownFile } from '../filesystem/markdown'
import { isLinux } from '../config'
// TODO(need::refactor):
// - Refactor this file
// - Outsource watcher/search features into worker (per window) and use something like "file-matcher" for searching on disk.
// TODO(refactor): Please see GH#1035.
export const WATCHER_STABILITY_THRESHOLD = 1000
export const WATCHER_STABILITY_POLL_INTERVAL = 150
const EVENT_NAME = {
dir: 'AGANI::update-object-tree',
file: 'AGANI::update-file'
}
const add = async (win, pathname, endOfLine) => {
const stats = await promisify(fs.stat)(pathname)
const add = async (win, pathname, type, endOfLine) => {
const stats = await fs.stat(pathname)
const birthTime = stats.birthtime
const isMarkdown = hasMarkdownExtension(pathname)
const file = {
@ -29,11 +30,24 @@ const add = async (win, pathname, endOfLine) => {
isMarkdown
}
if (isMarkdown) {
const data = await loadMarkdownFile(pathname, endOfLine)
file.data = data
// HACK: But this should be removed completely in #1034/#1035.
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',
change: file
})
@ -49,21 +63,35 @@ const unlink = (win, pathname, type) => {
const change = async (win, pathname, type, endOfLine) => {
const isMarkdown = hasMarkdownExtension(pathname)
if (isMarkdown) {
const data = await loadMarkdownFile(pathname, endOfLine)
const file = {
pathname,
data
// HACK: Markdown data should be removed completely in #1034/#1035 and
// should be only loaded after user interaction.
try {
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 = {
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 }
win.webContents.send('AGANI::update-object-tree', {
type: 'unlinkDir',
@ -96,61 +126,117 @@ class Watcher {
*/
constructor (preferences) {
this._preferences = preferences
this._ignoreChangeEvents = []
this.watchers = {}
}
// return a unwatch function
// Watch a file or directory and return a unwatch function.
watch (win, watchPath, type = 'dir'/* file or dir */) {
const id = getUniqueId()
const watcher = chokidar.watch(watchPath, {
ignored: /(^|[/\\])(\..|node_modules)/,
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
.on('add', pathname => add(win, pathname, this._preferences.getPreferedEOL()))
.on('change', pathname => change(win, pathname, type, this._preferences.getPreferedEOL()))
.on('add', pathname => {
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('addDir', pathname => addDir(win, pathname))
.on('unlinkDir', pathname => unlinkDir(win, pathname))
.on('raw', (event, path, details) => {
.on('addDir', pathname => addDir(win, pathname, type))
.on('unlinkDir', pathname => unlinkDir(win, pathname, type))
.on('raw', (event, subpath, details) => {
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') {
const { watchedPath } = details
// Use the same watcher and re-watch the file.
watcher.unwatch(watchedPath)
watcher.add(watchedPath)
if (renameTimer) {
clearTimeout(renameTimer)
}
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 => {
const msg = `Watcher error: ${error}`
console.log(msg)
log.error(msg)
// Check if too many file descriptors are opened and notify the user about this issue.
if (error.code === 'ENOSPC') {
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] = {
win,
watcher,
pathname: watchPath,
type
type,
close: closeFn
}
// unwatcher function
return () => {
if (this.watchers[id]) {
delete this.watchers[id]
}
watcher.close()
}
return closeFn
}
// unWatch some single watch
unWatch (win, watchPath, type = 'dir') {
// Remove a single watcher.
unwatch (win, watchPath, type = 'dir') {
for (const id of Object.keys(this.watchers)) {
const w = this.watchers[id]
if (
@ -165,13 +251,13 @@ class Watcher {
}
}
// unwatch for one window, (remove all the watchers in one window)
unWatchWin (win) {
// Remove all watchers from the given window id.
unwatchByWindowId (windowId) {
const watchers = []
const watchIds = []
for (const id of Object.keys(this.watchers)) {
const w = this.watchers[id]
if (w.win === win) {
if (w.win.id === windowId) {
watchers.push(w.watcher)
watchIds.push(id)
}
@ -182,9 +268,49 @@ class Watcher {
}
}
clear () {
Object.keys(this.watchers).forEach(id => this.watchers[id].watcher.close())
close () {
Object.keys(this.watchers).forEach(id => this.watchers[id].close())
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
* `electron-debug` & `vue-devtools`. There shouldn't be any need to
* modify this file, but it can be used to extend your development
* environment.
* `vue-devtools`. There shouldn't be any need to modify this file,
* but it can be used to extend your development environment.
*/
/* eslint-disable */
require('dotenv').config()
// Install `electron-debug` with `devtron`
require('electron-debug')({ showDevTools: false })
// Install `vue-devtools`
require('electron').app.on('ready', () => {

View File

@ -1,5 +1,6 @@
import './globalSetting'
import path from 'path'
import { app } from 'electron'
import cli from './cli'
import setupExceptionHandler, { initExceptionLogger } from './exceptionHandler'
import log from 'electron-log'
@ -32,6 +33,19 @@ const args = cli()
const appEnvironment = setupEnvironment(args)
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.
// Create other instances that need access to the modules from above.
const accessor = new Accessor(appEnvironment)
@ -40,5 +54,5 @@ const accessor = new Accessor(appEnvironment)
// Be careful when changing code before this line!
// NOTE: Do not create classes or other code before this line!
const app = new App(accessor, args)
app.init()
const marktext = new App(accessor, args)
marktext.init()

View File

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

View File

@ -9,9 +9,9 @@ import { writeMarkdownFile } from '../../filesystem/markdown'
import { getPath, getRecommendTitleFromMarkdownString } from '../../utils'
import pandoc from '../../utils/pandoc'
// TODO:
// - use async dialog version to not block the main process.
// - catch "fs." exceptions. Otherwise the main process crashes...
// TODO(refactor): "save" and "save as" should be moved to the editor window (editor.js) and
// the renderer should communicate only with the editor window for file relevant stuff.
// E.g. "mt::save-tabs" --> "mt::window-save-tabs$wid:<windowId>"
// Handle the export response from renderer process.
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}`)
// TODO(need::refactor): use async dialog version
const filePath = dialog.showSaveDialog(win, {
dialog.showSaveDialog(win, {
defaultPath
})
if (filePath) {
let data = content
try {
if (!content && type === 'pdf') {
data = await promisify(win.webContents.printToPDF.bind(win.webContents))({ printBackground: true })
}, async filePath => {
if (filePath) {
let data = content
try {
if (!content && type === 'pdf') {
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)
}
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 win = BrowserWindow.fromWebContents(e.sender)
// See GH#749, Electron#16085 and Electron#17523.
dialog.showMessageBox({
dialog.showMessageBox(win, {
type: 'info',
buttons: ['OK'],
defaultId: 0,
noLink: true,
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!'
})
// const win = BrowserWindow.fromWebContents(e.sender)
}, () => {})
// win.webContents.print({ printBackground: true }, () => {
// removePrintServiceFromWindow(win)
// })
}
const handleResponseForSave = (e, { id, markdown, pathname, options }) => {
const handleResponseForSave = async (e, { id, markdown, pathname, options }) => {
const win = BrowserWindow.fromWebContents(e.sender)
let recommendFilename = getRecommendTitleFromMarkdownString(markdown)
if (!recommendFilename) {
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
// TODO(need::refactor): use async dialog version
pathname = pathname || dialog.showSaveDialog(win, {
defaultPath: path.join(getPath('documents'), `${recommendFilename}.md`)
})
let filePath = pathname
if (!filePath) {
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') {
if (!alreadyExistOnDisk) {
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 {
// Save dialog canceled by user - no error.
if (!filePath) {
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) => {
@ -159,28 +172,26 @@ const removePrintServiceFromWindow = win => {
// --- events -----------------------------------
ipcMain.on('AGANI::save-all', (e, unsavedFiles) => {
ipcMain.on('mt::save-tabs', (e, unsavedFiles) => {
Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file)))
.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 { needSave } = await showUnsavedFilesMessage(win, unsavedFiles)
const EVENT = isSingle ? 'AGANI::save-single-response' : 'AGANI::save-all-response'
if (needSave) {
Promise.all(unsavedFiles.map(file => handleResponseForSave(e, file)))
.then(arr => {
const data = arr.filter(id => id)
win.send(EVENT, { err: null, data })
const tabIds = arr.filter(id => id != null)
win.send('mt::force-close-tabs-by-id', tabIds)
})
.catch(err => {
win.send(EVENT, { err, data: null })
log.error(err.error)
})
} else {
const data = unsavedFiles.map(f => f.id)
win.send(EVENT, { err: null, data })
const tabIds = unsavedFiles.map(f => f.id)
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'
}
// TODO(need::refactor): use async dialog version
const filePath = dialog.showSaveDialog(win, {
defaultPath: pathname || getPath('documents') + `/${recommendFilename}.md`
})
// 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
if (filePath) {
writeMarkdownFile(filePath, markdown, options, win)
.then(() => {
// need watch file after `save as`
if (pathname !== filePath) {
// unwatch the old file
ipcMain.emit('watcher-unwatch-file', win, pathname)
ipcMain.emit('watcher-watch-file', win, filePath)
}
const filename = path.basename(filePath)
win.webContents.send('AGANI::set-pathname', { id, pathname: filePath, filename })
})
.catch(log.error)
}
dialog.showSaveDialog(win, {
defaultPath: pathname || getPath('documents') + `/${recommendFilename}.md`
}, filePath => {
if (filePath) {
filePath = path.resolve(filePath)
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 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 { needSave } = await showUnsavedFilesMessage(win, unsavedFiles)
if (needSave) {
@ -223,6 +250,18 @@ ipcMain.on('AGANI::response-close-confirm', async (e, unsavedFiles) => {
.catch(err => {
console.log(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 {
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) {
if (isMarkdownFileOrLink(file)) {
openFileOrFolder(win, file)
break
continue
}
// handle import file
// Try to import the file
if (PANDOC_EXTENSIONS.some(ext => file.endsWith(ext))) {
const existsPandoc = pandoc.exists()
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)
if (!isFile(newPathname)) {
fs.renameSync(pathname, newPathname)
e.sender.send('AGANI::set-pathname', {
id,
pathname: newPathname,
filename: path.basename(newPathname)
const doRename = () => {
fs.rename(pathname, newPathname, err => {
if (err) {
log.error(`mt::rename: Cannot rename "${pathname}" to "${newPathname}".\n${err.stack}`)
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 {
dialog.showMessageBox(win, {
type: 'warning',
@ -274,12 +327,7 @@ ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => {
noLink: true
}, index => {
if (index === 0) {
fs.renameSync(pathname, newPathname)
e.sender.send('AGANI::set-pathname', {
id,
pathname: newPathname,
filename: path.basename(newPathname)
})
doRename()
}
})
}
@ -287,28 +335,34 @@ ipcMain.on('AGANI::rename', (e, { id, pathname, newPathname }) => {
ipcMain.on('AGANI::response-file-move-to', (e, { id, pathname }) => {
const win = BrowserWindow.fromWebContents(e.sender)
// TODO(need::refactor): use async dialog version
let newPath = dialog.showSaveDialog(win, {
dialog.showSaveDialog(win, {
buttonLabel: 'Move to',
nameFieldLabel: 'Filename:',
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)
// TODO(need::refactor): use async dialog version
const pathname = dialog.showOpenDialog(win, {
dialog.showOpenDialog(win, {
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 }) => {
@ -341,18 +395,17 @@ export const importFile = async win => {
return noticePandocNotFound(win)
}
// TODO(need::refactor): use async dialog version
const filename = dialog.showOpenDialog(win, {
dialog.showOpenDialog(win, {
properties: [ 'openFile' ],
filters: [{
name: 'All Files',
extensions: PANDOC_EXTENSIONS
}]
}, filePath => {
if (filePath) {
openPandocFile(win.id, filePath)
}
})
if (filename && filename[0]) {
openPandocFile(win.id, filename[0])
}
}
export const print = win => {
@ -360,27 +413,27 @@ export const print = win => {
}
export const openFile = win => {
// TODO(need::refactor): use async dialog version
const fileList = dialog.showOpenDialog(win, {
properties: ['openFile'],
dialog.showOpenDialog(win, {
properties: ['openFile', 'multiSelections'],
filters: [{
name: 'text',
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 => {
// TODO(need::refactor): use async dialog version
const dirList = dialog.showOpenDialog(win, {
dialog.showOpenDialog(win, {
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) => {

View File

@ -7,6 +7,12 @@ import { ensureDirSync, isDirectory, isFile } from '../filesystem'
import { parseMenu } from '../keyboard/shortcutHandler'
import configureMenu, { configSettingMenu } from '../menu/templates'
export const MenuType = {
DEFAULT: 0,
EDITOR: 1,
SETTINGS: 2
}
class AppMenu {
/**
@ -105,13 +111,13 @@ class AppMenu {
windowMenus.set(window.id, menu)
}
addEditorMenu (window) {
addEditorMenu (window, options = {}) {
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 currentMenu = Menu.getApplicationMenu() // the menu may be null
updateMenuItemSafe(currentMenu, menu, 'sourceCodeModeMenuItem', false)
updateMenuItemSafe(currentMenu, menu, 'sourceCodeModeMenuItem', !!options.sourceCodeModeEnabled)
updateMenuItemSafe(currentMenu, menu, 'typewriterModeMenuItem', false)
// 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) {
recentUsedDocuments = this.getRecentlyUsedDocuments()
}
@ -174,7 +180,8 @@ class AppMenu {
return {
shortcutMap,
menu
menu,
type: MenuType.EDITOR
}
}
@ -182,9 +189,9 @@ class AppMenu {
if (this.isOsx) {
const menuTemplate = configSettingMenu(this._keybindings)
const menu = Menu.buildFromTemplate(menuTemplate)
return { menu }
return { menu, type: MenuType.SETTINGS }
}
return { menu: null }
return { menu: null, type: MenuType.SETTINGS }
}
updateAppMenu (recentUsedDocuments) {
@ -193,13 +200,15 @@ class AppMenu {
}
// "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.
// rebuild all window menus
this.windowMenus.forEach((value, key) => {
const { menu: oldMenu } = value
const { menu: newMenu } = this.buildDefaultMenu(false, recentUsedDocuments)
const { menu: oldMenu, type } = value
if (type !== MenuType.EDITOR) return
const { menu: newMenu } = this.buildEditorMenu(false, recentUsedDocuments)
// all other menu items are set automatically
updateMenuItem(oldMenu, newMenu, 'sourceCodeModeMenuItem')
@ -231,7 +240,9 @@ class AppMenu {
updateThemeMenu = theme => {
this.windowMenus.forEach((value, key) => {
const { menu } = value
const { menu, type } = value
if (type !== MenuType.EDITOR) return
const themeMenus = menu.getMenuItemById('themeMenu')
if (!themeMenus) {
return
@ -248,7 +259,9 @@ class AppMenu {
updateAutoSaveMenu = autoSave => {
this.windowMenus.forEach((value, key) => {
const { menu } = value
const { menu, type } = value
if (type !== MenuType.EDITOR) return
const autoSaveMenu = menu.getMenuItemById('autoSaveMenuItem')
if (!autoSaveMenu) {
return
@ -259,7 +272,9 @@ class AppMenu {
updateAidouMenu = bool => {
this.windowMenus.forEach((value, key) => {
const { menu } = value
const { menu, type } = value
if (type !== MenuType.EDITOR) return
const aidouMenu = menu.getMenuItemById('aidou')
if (!aidouMenu) {
return
@ -283,6 +298,9 @@ class AppMenu {
this.addRecentlyUsedDocument(pathname)
})
ipcMain.on('menu-add-recently-used', pathname => {
this.addRecentlyUsedDocument(pathname)
})
ipcMain.on('menu-clear-recently-used', () => {
this.clearRecentlyUsedDocuments()
})

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { toggleAlwaysOnTop } from '../actions/window'
import { isOsx } from '../../config'
export default function (keybindings) {
return {
const menu = {
label: 'Window',
role: 'window',
submenu: [{
@ -18,9 +19,21 @@ export default function (keybindings) {
}, {
type: 'separator'
}, {
label: 'Close Window',
accelerator: keybindings.getAccelerator('windowCloseWindow'),
role: 'close'
label: 'Toggle Full Screen',
accelerator: keybindings.getAccelerator('viewToggleFullScreen'),
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": {
"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"
},
"aidou": {
@ -31,7 +35,7 @@
"title"
]
},
"startUp": {
"startUpAction": {
"description": "General--The action after Mark Text startup, open the last edited content, open the specified folder or blank page",
"enum": [
"folder",
@ -39,6 +43,10 @@
"blank"
]
},
"defaultDirectoryToOpen": {
"description": "General--The default directory that should be opened on startup when startUp=folder.",
"type": "string"
},
"language": {
"description": "General--The language Mark Text use.",
"type": "string"
@ -160,7 +168,6 @@
"description": "Theme--Select the theme used in Mark Text",
"type": "string"
},
"imageInsertAction": {
"description": "Image--The default behavior after insert image from local folder",
"enum": [
@ -168,5 +175,17 @@
"folder",
"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 { 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 {
@ -10,26 +33,43 @@ class BaseWindow extends EventEmitter {
super()
this._accessor = accessor
this.type = WindowType.BASE
this.id = 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 () {
this.quitting = true
this.emit('bye')
this.lifecycle = WindowLifecycle.QUITTED
this.emit('window-closed')
this.removeAllListeners()
this.browserWindow.destroy()
this.browserWindow = null
if (this.browserWindow) {
this.browserWindow.destroy()
this.browserWindow = null
}
this.id = null
}
// --- private ---------------------------------
_buildUrlWithSettings (windowId, env, userPreference) {
// NOTE: Only send absolutely necessary values. Theme and titlebar settings
// are sended because we delay load the preferences.
// NOTE: Only send absolutely necessary values. Full settings are delay loaded.
const { type } = this
const { debug, paths } = env
const { codeFontFamily, codeFontSize, theme, titleBarStyle } = userPreference.getAll()
@ -50,7 +90,11 @@ class BaseWindow extends EventEmitter {
url.searchParams.set('theme', theme)
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 BaseWindow from './base'
import { BrowserWindow, ipcMain } from 'electron'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import log from 'electron-log'
import windowStateKeeper from 'electron-window-state'
import { WindowType } from '../app/windowManager'
import { TITLE_BAR_HEIGHT, defaultWinOptions, isLinux, isOsx } from '../config'
import { isDirectory, isMarkdownFile, normalizeAndResolvePath } from '../filesystem'
import { loadMarkdownFile } from '../filesystem/markdown'
import BaseWindow, { WindowLifecycle, WindowType } from './base'
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 {
@ -17,22 +16,29 @@ class EditorWindow extends BaseWindow {
constructor (accessor) {
super(accessor)
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.
*
* @param {string} [pathname] Path to a file, directory or link.
* @param {string} [markdown] Markdown content.
* @param {*} [options] BrowserWindow options.
* @param {string} [rootDirectory] The root directory to open.
* @param {string[]} [fileList] A list of markdown files to open.
* @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
// Ensure path is normalized
if (pathname) {
pathname = normalizeAndResolvePath(pathname)
}
const addBlankTab = !rootDirectory && fileList.length === 0 && markdownList.length === 0
const mainWindowState = windowStateKeeper({
defaultWidth: 1200,
@ -40,13 +46,19 @@ class EditorWindow extends BaseWindow {
})
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) {
winOptions.icon = path.join(__static, 'logo-96px.png')
}
// Enable native or custom/frameless window and titlebar
const { titleBarStyle } = preferences.getAll()
const {
titleBarStyle,
theme,
sideBarVisibility,
tabBarVisibility,
sourceCodeModeEnabled
} = preferences.getAll()
if (!isOsx) {
winOptions.titleBarStyle = 'default'
if (titleBarStyle === 'native') {
@ -54,35 +66,58 @@ class EditorWindow extends BaseWindow {
}
}
winOptions.backgroundColor = this._getPreferredBackgroundColor(theme)
let win = this.browserWindow = new BrowserWindow(winOptions)
this.id = win.id
// Create a menu for the current window
appMenu.addEditorMenu(win)
appMenu.addEditorMenu(win, { sourceCodeModeEnabled })
win.once('ready-to-show', async () => {
mainWindowState.manage(win)
win.show()
win.webContents.once('did-finish-load', () => {
this.lifecycle = WindowLifecycle.READY
this.emit('window-ready')
this.emit('window-ready-to-show')
// Restore and focus window
this.bringToFront()
if (pathname && isMarkdownFile(pathname)) {
// Open single markdown file
appMenu.addRecentlyUsedDocument(pathname)
this._openFile(pathname)
} else if (pathname && isDirectory(pathname)) {
// Open directory / folder
appMenu.addRecentlyUsedDocument(pathname)
this.openFolder(pathname)
} else {
// Open a blank window
const lineEnding = preferences.getPreferedEOL()
win.webContents.send('mt::bootstrap-blank-window', {
lineEnding,
markdown
})
appMenu.updateLineEndingMenu(lineEnding)
}
const lineEnding = preferences.getPreferedEOL()
appMenu.updateLineEndingMenu(lineEnding)
win.webContents.send('mt::bootstrap-editor', {
addBlankTab,
markdownList: this._markdownToOpen,
lineEnding,
sideBarVisibility,
tabBarVisibility,
sourceCodeModeEnabled
})
this._doOpenFilesToOpen()
this._markdownToOpen.length = 0
})
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', () => {
@ -114,94 +149,295 @@ class EditorWindow extends BaseWindow {
// The window is now destroyed.
win.on('closed', () => {
this.lifecycle = WindowLifecycle.QUITTED
this.emit('window-closed')
// Free window reference
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)
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
}
openTab (filePath, selectTab=true) {
if (this.quitting) return
const { browserWindow } = this
const { menu: appMenu, preferences } = this._accessor
// Listen for file changed.
ipcMain.emit('watcher-watch-file', browserWindow, filePath)
loadMarkdownFile(filePath, preferences.getPreferedEOL()).then(rawDocument => {
appMenu.addRecentlyUsedDocument(filePath)
browserWindow.webContents.send('AGANI::new-tab', rawDocument, selectTab)
}).catch(err => {
// TODO: Handle error --> create a end-user error handler.
console.error('[ERROR] Cannot open file or directory.')
log.error(err)
})
/**
* Open a new tab from a markdown file.
*
* @param {string} filePath The markdown file path.
* @param {[boolean]} selected Whether the tab should become the selected tab (true if not set).
*/
openTab (filePath, selected=true) {
if (this.lifecycle === WindowLifecycle.QUITTED) return
this.openTabs([ filePath ], selected ? 0 : -1 )
}
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
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) {
if (this.quitting) return
if (this.lifecycle === WindowLifecycle.QUITTED ||
isSamePathSync(pathname, this._openedRootDirectory)) {
return
}
const { browserWindow } = this
ipcMain.emit('watcher-watch-directory', browserWindow, pathname)
browserWindow.webContents.send('AGANI::open-project', pathname)
if (this.lifecycle === WindowLifecycle.READY) {
const { browserWindow } = this
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 ---------------------------------
// Only called once during window bootstrapping.
_openFile = async filePath => {
const { browserWindow } = this
const { menu: appMenu, preferences } = this._accessor
_getPreferredBackgroundColor (theme) {
// Hardcode the theme background color and show the window direct for the fastet window ready time.
// Later with custom themes we need the background color (e.g. from meta information) and wait
// 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 {
markdown,
filename,
pathname,
encoding,
lineEnding,
adjustLineEndingOnSave,
isMixedLineEndings
} = data
appMenu.updateLineEndingMenu(lineEnding)
browserWindow.webContents.send('mt::bootstrap-window', {
markdown,
filename,
pathname,
options: {
encoding,
lineEnding,
adjustLineEndingOnSave
}
})
/**
* Open a new new tab from the markdown document.
*
* @param {IMarkdownDocumentRaw} rawDocument The markdown document.
* @param {boolean} selected Whether the tab should become the selected tab (true if not set).
*/
_doOpenTab (rawDocument, selected) {
const { _accessor, _openedFiles, browserWindow } = this
const { menu: appMenu } = _accessor
const { pathname } = rawDocument
// Listen for file changed.
ipcMain.emit('watcher-watch-file', browserWindow, filePath)
ipcMain.emit('watcher-watch-file', browserWindow, pathname)
// Notify user about mixed endings
if (isMixedLineEndings) {
browserWindow.webContents.send('AGANI::show-notification', {
title: 'Mixed Line Endings',
type: 'error',
message: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`,
time: 20000
})
appMenu.addRecentlyUsedDocument(pathname)
_openedFiles.push(pathname)
browserWindow.webContents.send('AGANI::new-tab', rawDocument, selected)
}
_doOpenFilesToOpen () {
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 BaseWindow from './base'
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'
class SettingWindow extends BaseWindow {
/**
@ -23,6 +22,7 @@ class SettingWindow extends BaseWindow {
createWindow (options = {}) {
const { menu: appMenu, env, preferences } = this._accessor
const winOptions = Object.assign({}, defaultPreferenceWinOptions, options)
centerWindowOptions(winOptions)
if (isLinux) {
winOptions.icon = path.join(__static, 'logo-96px.png')
}
@ -42,10 +42,10 @@ class SettingWindow extends BaseWindow {
// Create a menu for the current window
appMenu.addSettingMenu(win)
win.once('ready-to-show', async () => {
win.once('ready-to-show', () => {
win.show()
this.emit('window-ready-to-show')
this.lifecycle = WindowLifecycle.READY
this.emit('window-ready')
})
win.on('focus', () => {
@ -74,7 +74,8 @@ class SettingWindow extends BaseWindow {
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)
return win

View File

@ -1,6 +1,15 @@
import { screen } from 'electron'
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 => {
// "workArea" doesn't work on Linux
const { bounds, workArea } = screen.getPrimaryDisplay()
@ -21,7 +30,6 @@ export const ensureWindowPosition = windowState => {
.some(display => display)
}
if (center) {
// win.center() doesn't work on Linux
x = Math.max(0, Math.ceil(screenArea.x + (screenArea.width - width) / 2))
y = Math.max(0, Math.ceil(screenArea.y + (screenArea.height - height) / 2))
}

View File

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

View File

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

View File

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

View File

@ -64,6 +64,12 @@ const bootstrapRenderer = () => {
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 marktext = {
initialState,

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@
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)
}

View File

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

View File

@ -11,9 +11,9 @@ export const tabsMixins = {
removeFileInTab (file) {
const { isSaved } = file
if (isSaved) {
this.$store.dispatch('REMOVE_FILE_IN_TABS', file)
this.$store.dispatch('FORCE_CLOSE_TAB', file)
} 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 = {
methods: {
handleFileClick () {
// HACK: Please see #1034 and #1035
const { data, isMarkdown, pathname } = this.file
if (!isMarkdown || this.currentFile.pathname === pathname) return
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)
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)
if (isMixedLineEndings && !isOpened) {

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { clipboard, ipcRenderer, shell } from 'electron'
import path from 'path'
import bus from '../bus'
import { hasKeys, getUniqueId } from '../util'
import { isSameFileSync } from '../util/fileSystem'
import { isSamePathSync } from '../util/fileSystem'
import listToTree from '../util/listToTree'
import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
import notice from '../services/notification'
@ -39,7 +39,6 @@ const mutations = {
const { tabs, currentFile } = state
const index = tabs.indexOf(file)
tabs.splice(index, 1)
state.tabs = tabs
if (file.id === currentFile.id) {
const fileState = state.tabs[index] || state.tabs[index - 1] || state.tabs[0] || {}
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.')
}
// Upate file content but not tab id.
const oldId = tab.id
Object.assign(tab, newFileState)
tab.id = oldId
if (pathname === currentFile.pathname) {
state.currentFile = fileState
const { id, cursor, history } = fileState
state.currentFile = tab
const { id, cursor, history } = tab
bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
}
},
SET_PATHNAME (state, file) {
const { filename, pathname, id } = file
if (id === state.currentFile.id && pathname) {
// NOTE: Please call this function only from main process via "AGANI::set-pathname" and free resources before!
SET_PATHNAME (state, { tab, fileInfo }) {
const { currentFile } = state
const { filename, pathname, id } = fileInfo
// Change reference path for images.
if (id === currentFile.id && pathname) {
window.DIRNAME = path.dirname(pathname)
}
const targetFile = state.tabs.filter(f => f.id === id)[0]
if (targetFile) {
const isSaved = true
Object.assign(targetFile, { filename, pathname, isSaved })
if (tab) {
Object.assign(tab, { filename, pathname, isSaved: true })
}
},
SET_SAVE_STATUS_BY_TAB (state, { tab, status }) {
if (hasKeys(tab)) {
tab.isSaved = status
}
},
SET_SAVE_STATUS (state, status) {
@ -171,25 +173,29 @@ const mutations = {
state.currentFile.history = history
}
},
CLOSE_TABS (state, arr) {
CLOSE_TABS (state, tabIdList) {
if (!tabIdList || tabIdList.length === 0) return
let tabIndex = 0
arr.forEach(id => {
tabIdList.forEach(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) {
// close tab and unwatch this file
ipcRenderer.send('AGANI::file-watch', { pathname, watch: false })
ipcRenderer.send('mt::window-tab-closed', pathname)
}
state.tabs.splice(index, 1)
if (state.currentFile.id === id) {
state.currentFile = {}
window.DIRNAME = ''
if (arr.length === 1) {
if (tabIdList.length === 1) {
tabIndex = index
}
}
})
if (!state.currentFile.id && state.tabs.length) {
state.currentFile = state.tabs[tabIndex] || state.tabs[tabIndex - 1] || state.tabs[0] || {}
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)
// unwatch this 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) {
@ -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 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
@ -315,56 +326,89 @@ const actions = {
})
},
LISTEN_FOR_SET_PATHNAME ({ commit }) {
ipcRenderer.on('AGANI::set-pathname', (e, file) => {
commit('SET_PATHNAME', file)
LISTEN_FOR_SET_PATHNAME ({ commit, dispatch, state }) {
ipcRenderer.on('mt::set-pathname', (e, fileInfo) => {
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 => {
const unSavedFiles = state.tabs.filter(file => !file.isSaved)
const unsavedFiles = state.tabs
.filter(file => !file.isSaved)
.map(file => {
const { id, filename, pathname, markdown } = file
const options = getOptionsFromState(file)
return { id, filename, pathname, markdown, options }
})
if (unSavedFiles.length) {
ipcRenderer.send('AGANI::response-close-confirm', unSavedFiles)
if (unsavedFiles.length) {
ipcRenderer.send('mt::close-window-confirm', unsavedFiles)
} else {
ipcRenderer.send('AGANI::close-window')
ipcRenderer.send('mt::close-window')
}
})
},
LISTEN_FOR_SAVE_CLOSE ({ commit, state }) {
ipcRenderer.on('AGANI::save-all-response', (e, { err, data }) => {
if (!err && Array.isArray(data)) {
const toBeClosedTabs = [...state.tabs.filter(f => f.isSaved), ...data]
commit('CLOSE_TABS', toBeClosedTabs)
}
})
ipcRenderer.on('AGANI::save-single-response', (e, { err, data }) => {
if (!err && Array.isArray(data) && data.length) {
commit('CLOSE_TABS', data)
LISTEN_FOR_SAVE_CLOSE ({ commit }) {
ipcRenderer.on('mt::force-close-tabs-by-id', (e, tabIdList) => {
if (Array.isArray(tabIdList) && tabIdList.length) {
commit('CLOSE_TABS', tabIdList)
}
})
},
ASK_FOR_SAVE_ALL ({ commit, state }, isClose) {
const unSavedFiles = state.tabs.filter(file => !(file.isSaved && /[^\n]/.test(file.markdown)))
ASK_FOR_SAVE_ALL ({ commit, state }, closeTabs) {
const { tabs } = state
const unsavedFiles = tabs
.filter(file => !(file.isSaved && /[^\n]/.test(file.markdown)))
.map(file => {
const { id, filename, pathname, markdown } = file
const options = getOptionsFromState(file)
return { id, filename, pathname, markdown, options }
})
if (unSavedFiles.length) {
const EVENT_NAME = isClose ? 'AGANI::save-close' : 'AGANI::save-all'
const isSingle = false
ipcRenderer.send(EVENT_NAME, unSavedFiles, isSingle)
} else if (isClose) {
commit('CLOSE_TABS', state.tabs.map(f => f.id))
if (closeTabs) {
if (unsavedFiles.length) {
commit('CLOSE_TABS', tabs.filter(f => f.isSaved).map(f => f.id))
ipcRenderer.send('mt::save-and-close-tabs', unsavedFiles)
} 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
if (typeof filename === 'string' && filename !== 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.
LISTEN_FOR_BOOTSTRAP_WINDOW ({ commit, state, dispatch }) {
ipcRenderer.on('mt::bootstrap-window', (e, { markdown, filename, pathname, options }) => {
const fileState = getSingleFileState({ markdown, filename, pathname, options })
const { id } = fileState
const { lineEnding } = options
commit('SET_GLOBAL_LINE_ENDING', lineEnding)
dispatch('INIT_STATUS', true)
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
commit('SET_LAYOUT', {
rightColumn: 'files',
showSideBar: false,
showTabBar: false
})
dispatch('SET_LAYOUT_MENU_ITEM')
})
ipcRenderer.on('mt::bootstrap-editor', (e, config) => {
const {
addBlankTab,
markdownList,
lineEnding,
sideBarVisibility,
tabBarVisibility,
sourceCodeModeEnabled
} = config
ipcRenderer.on('mt::bootstrap-blank-window', (e, { lineEnding, markdown: source }) => {
const { tabs } = state
const fileState = getBlankFileState(tabs, lineEnding, source)
const { id, markdown } = fileState
commit('SET_GLOBAL_LINE_ENDING', lineEnding)
dispatch('INIT_STATUS', true)
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
dispatch('SEND_INITIALIZED')
commit('SET_LAYOUT', {
rightColumn: 'files',
showSideBar: false,
showTabBar: false
showSideBar: !!sideBarVisibility,
showTabBar: !!tabBarVisibility
})
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.
LISTEN_FOR_NEW_TAB ({ dispatch }) {
ipcRenderer.on('AGANI::new-tab', (e, markdownDocument, selectTab=true) => {
// TODO: allow to add a tab without selecting it
ipcRenderer.on('AGANI::new-tab', (e, markdownDocument, selected=true) => {
if (markdownDocument) {
// Create tab with content.
dispatch('NEW_TAB_WITH_CONTENT', markdownDocument)
dispatch('NEW_TAB_WITH_CONTENT', { markdownDocument, selected })
} else {
// Fallback: create a blank tab
dispatch('NEW_UNTITLED_TAB')
// Fallback: create a blank tab and always select it
dispatch('NEW_UNTITLED_TAB', {})
}
})
ipcRenderer.on('mt::new-untitled-tab', (e, selectTab=true, markdownString='', ) => {
// TODO: allow to add a tab without selecting it
ipcRenderer.on('mt::new-untitled-tab', (e, selected=true, markdown='', ) => {
// 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 => {
const file = state.currentFile
if (!hasKeys(file)) return
const { isSaved } = file
if (isSaved) {
dispatch('REMOVE_FILE_IN_TABS', file)
} else {
dispatch('CLOSE_SINGLE_FILE', file)
}
dispatch('CLOSE_TAB', file)
})
},
// Create a new untitled tab optional with markdown string.
NEW_UNTITLED_TAB ({ commit, state, dispatch }, markdownString) {
dispatch('SHOW_TAB_VIEW', false)
const { tabs, lineEnding } = state
const fileState = getBlankFileState(tabs, lineEnding, markdownString)
const { id, markdown } = fileState
dispatch('UPDATE_CURRENT_FILE', fileState)
bus.$emit('file-loaded', { id, markdown })
CLOSE_TAB ({ dispatch }, file) {
const { isSaved } = file
if (isSaved) {
dispatch('FORCE_CLOSE_TAB', file)
} else {
dispatch('CLOSE_UNSAVED_TAB', file)
}
},
/**
* Create a new tab from the given markdown document
* Create a new untitled tab optional from a markdown string.
*
* @param {*} context Store context
* @param {IMarkdownDocumentRaw} markdownDocument Class that represent a markdown document
* @param {*} context The store context.
* @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) {
console.warn('Cannot create a file tab without a markdown document!')
dispatch('NEW_UNTITLED_TAB')
dispatch('NEW_UNTITLED_TAB', {})
return
}
// Check if tab already exist and select existing tab if so.
const { tabs } = state
// Select the tab if not value is specified.
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 existingTab = tabs.find(t => isSameFileSync(t.pathname, pathname))
const existingTab = tabs.find(t => isSamePathSync(t.pathname, pathname))
if (existingTab) {
dispatch('UPDATE_CURRENT_FILE', existingTab)
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 docState = createDocumentState(markdownDocument)
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) {
// TODO: Show (this) notification(s) per tab.
const { filename, lineEnding } = markdownDocument
notice.notify({
title: 'Line Ending',
@ -540,7 +629,7 @@ const actions = {
SHOW_TAB_VIEW ({ commit, state, dispatch }, always) {
const { tabs } = state
if (always || tabs.length <= 1) {
if (always || tabs.length === 1) {
commit('SET_LAYOUT', { showTabBar: true })
dispatch('SET_LAYOUT_MENU_ITEM')
}
@ -676,43 +765,54 @@ const actions = {
LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) {
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.
if (type === 'unlink') {
return notice.notify({
title: 'File Removed on Disk',
message: `${change.pathname} has been removed or moved to other place`,
type: 'warning',
time: 0,
showConfirm: false
})
} else {
const { autoSave } = rootState.preferences
const { windowActive } = rootState
const { filename } = change.data
if (windowActive) return
if (autoSave) {
commit('LOAD_CHANGE', change)
} else {
notice.clear()
// TODO: A new "changed" notification from different files overwrite the old notification
// and the old notification disappears. I think we should bind the notification to the tab.
const { tabs } = state
const { pathname } = change
const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
if (tab) {
commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
}
switch (type) {
case 'unlink': {
notice.notify({
title: 'File Changed on Disk',
message: `${filename} has been changed on disk, do you want to reload it?`,
showConfirm: true,
time: 0
title: 'File Removed on Disk',
message: `${pathname} has been removed or moved.`,
type: 'warning',
time: 0,
showConfirm: false
})
.then(() => {
commit('LOAD_CHANGE', change)
})
break
}
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 }) {
return ipcRenderer.sendSync('mt::ask-for-image-path')
}

View File

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

View File

@ -3,13 +3,15 @@ import { getOptionsFromState } from './help'
// user preference
const state = {
autoSave: true,
autoSaveDelay: 3000,
titleBarStyle: 'csd',
autoSave: false,
autoSaveDelay: 5000,
titleBarStyle: 'custom',
openFilesInNewWindow: false,
openFolderInNewWindow: false,
aidou: true,
fileSortBy: 'created',
startUp: 'folder',
startUpAction: 'lastState',
defaultDirectoryToOpen: '',
language: 'en',
editorFontFamily: 'Open Sans',
@ -33,7 +35,13 @@ const state = {
listIndentation: 1,
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
focus: false, // focus mode
sourceCode: false, // source code mode
@ -101,7 +109,6 @@ const actions = {
},
SET_SINGLE_PREFERENCE ({ commit }, { type, value }) {
// commit('SET_USER_PREFERENCE', { [type]: value })
// save to electron-store
ipcRenderer.send('mt::set-user-preference', { [type]: value })
},
@ -112,6 +119,10 @@ const actions = {
SET_IMAGE_FOLDER_PATH ({ commit }) {
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 = {
SET_PROJECT_TREE (state, pathname) {
SET_ROOT_DIRECTORY (state, pathname) {
let name = path.basename(pathname)
if (!name) {
// Root directory such "/" or "C:\"
@ -108,12 +108,8 @@ const mutations = {
const actions = {
LISTEN_FOR_LOAD_PROJECT ({ commit, dispatch }) {
ipcRenderer.on('AGANI::open-project', (e, pathname) => {
// Initialize editor and show empty/new tab
dispatch('NEW_UNTITLED_TAB')
dispatch('INIT_STATUS', true)
commit('SET_PROJECT_TREE', pathname)
ipcRenderer.on('mt::open-directory', (e, pathname) => {
commit('SET_ROOT_DIRECTORY', pathname)
commit('SET_LAYOUT', {
rightColumn: 'files',
showSideBar: true,
@ -163,7 +159,7 @@ const actions = {
commit('SET_CLIPBOARD', data)
},
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 }) {
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.
*
* @param {*} pathA The first path.
* @param {*} pathB The second path.
* @param {*} isNormalized Are both paths already normalized.
* @param {string} pathA The first path.
* @param {string} pathB The second path.
* @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
const a = isNormalized ? pathA : path.normalize(pathA)
const b = isNormalized ? pathB : path.normalize(pathB)
@ -174,7 +174,7 @@ export const uploadImage = async (pathname, image, preferences) => {
uploadByGithub(reader.result, image.name)
}
}
reader.readAsDataURL(image)
}
}

View File

@ -3,9 +3,11 @@
"autoSaveDelay": 5000,
"titleBarStyle": "custom",
"openFilesInNewWindow": false,
"openFolderInNewWindow": false,
"aidou": true,
"fileSortBy": "created",
"startUp": "folder",
"startUpAction": "lastState",
"defaultDirectoryToOpen": "",
"language": "en",
"editorFontFamily": "Open Sans",
@ -28,5 +30,9 @@
"tabSize": 4,
"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"
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:
version "2.2.4"
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"
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:
version "3.0.5"
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"
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:
version "2.5.3"
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"
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:
version "0.2.2"
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"
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:
version "1.1.1"
resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"