mirror of
https://github.com/marktext/marktext.git
synced 2025-05-03 03:00:19 +08:00
458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
import path from 'path'
|
|
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
|
import log from 'electron-log'
|
|
import windowStateKeeper from 'electron-window-state'
|
|
import { isChildOfDirectory, isSamePathSync } from 'common/filesystem/paths'
|
|
import BaseWindow, { WindowLifecycle, WindowType } from './base'
|
|
import { ensureWindowPosition } from './utils'
|
|
import { TITLE_BAR_HEIGHT, editorWinOptions, isLinux, isOsx } from '../config'
|
|
import { loadMarkdownFile } from '../filesystem/markdown'
|
|
|
|
class EditorWindow extends BaseWindow {
|
|
/**
|
|
* @param {Accessor} accessor The application accessor for application instances.
|
|
*/
|
|
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, options: any, 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} [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 (rootDirectory = null, fileList = [], markdownList = [], options = {}) {
|
|
const { menu: appMenu, env, preferences } = this._accessor
|
|
const addBlankTab = !rootDirectory && fileList.length === 0 && markdownList.length === 0
|
|
|
|
const mainWindowState = windowStateKeeper({
|
|
defaultWidth: 1200,
|
|
defaultHeight: 800
|
|
})
|
|
|
|
const { x, y, width, height } = ensureWindowPosition(mainWindowState)
|
|
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,
|
|
theme,
|
|
sideBarVisibility,
|
|
tabBarVisibility,
|
|
sourceCodeModeEnabled
|
|
} = preferences.getAll()
|
|
if (!isOsx) {
|
|
winOptions.titleBarStyle = 'default'
|
|
if (titleBarStyle === 'native') {
|
|
winOptions.frame = true
|
|
}
|
|
}
|
|
|
|
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, { sourceCodeModeEnabled })
|
|
|
|
win.webContents.once('did-finish-load', () => {
|
|
this.lifecycle = WindowLifecycle.READY
|
|
this.emit('window-ready')
|
|
|
|
// Restore and focus window
|
|
this.bringToFront()
|
|
|
|
const lineEnding = preferences.getPreferedEOL()
|
|
appMenu.updateLineEndingMenu(this.id, 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', async (event, killed) => {
|
|
const msg = `The renderer process has crashed unexpected or is killed (${killed}).`
|
|
log.error(msg)
|
|
|
|
const { response } = await dialog.showMessageBox(win, {
|
|
type: 'warning',
|
|
buttons: ['Close', 'Reload', 'Keep It Open'],
|
|
message: 'Mark Text has crashed',
|
|
detail: msg
|
|
})
|
|
|
|
if (win.id) {
|
|
switch (response) {
|
|
case 0:
|
|
return this.destroy()
|
|
case 1:
|
|
return this.reload()
|
|
}
|
|
}
|
|
})
|
|
|
|
win.on('focus', () => {
|
|
this.emit('window-focus')
|
|
win.webContents.send('AGANI::window-active-status', { status: true })
|
|
})
|
|
|
|
// Lost focus
|
|
win.on('blur', () => {
|
|
this.emit('window-blur')
|
|
win.webContents.send('AGANI::window-active-status', { status: false })
|
|
})
|
|
|
|
;['maximize', 'unmaximize', 'enter-full-screen', 'leave-full-screen'].forEach(channel => {
|
|
win.on(channel, () => {
|
|
win.webContents.send(`mt::window-${channel}`)
|
|
})
|
|
})
|
|
|
|
// Before closed. We cancel the action and ask the editor further instructions.
|
|
win.on('close', event => {
|
|
this.emit('window-close')
|
|
|
|
event.preventDefault()
|
|
win.webContents.send('AGANI::ask-for-close')
|
|
|
|
// TODO: Close all watchers etc. Should we do this manually or listen to 'quit' event?
|
|
})
|
|
|
|
// The window is now destroyed.
|
|
win.on('closed', () => {
|
|
this.lifecycle = WindowLifecycle.QUITTED
|
|
this.emit('window-closed')
|
|
|
|
// Free window reference
|
|
win = null
|
|
})
|
|
|
|
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.openTabsFromPaths(fileList)
|
|
}
|
|
}, 0)
|
|
|
|
return win
|
|
}
|
|
|
|
/**
|
|
* Open a new tab from a markdown file.
|
|
*
|
|
* @param {string} filePath The markdown file path.
|
|
* @param {string} [options] The tab option for the editor window.
|
|
* @param {boolean} [selected] Whether the tab should become the selected tab (true if not set).
|
|
*/
|
|
openTab (filePath, options = {}, selected = true) {
|
|
if (this.lifecycle === WindowLifecycle.QUITTING) return
|
|
this.openTabs([{ filePath, options, selected }])
|
|
}
|
|
|
|
/**
|
|
* Open new tabs from the given file paths.
|
|
*
|
|
* @param {string[]} filePaths The file paths to open.
|
|
*/
|
|
openTabsFromPaths (filePaths) {
|
|
if (!filePaths || filePaths.length === 0) return
|
|
|
|
const fileList = filePaths.map(p => ({ filePath: p, options: {}, selected: false }))
|
|
fileList[0].selected = true
|
|
this.openTabs(fileList)
|
|
}
|
|
|
|
/**
|
|
* Open new tabs from markdown files with options for editor window.
|
|
*
|
|
* @param {{filePath: string, selected: boolean, options: any}[]} filePath A list of markdown file paths and options to open.
|
|
*/
|
|
openTabs (fileList) {
|
|
if (this.lifecycle === WindowLifecycle.QUITTING) return
|
|
|
|
const { browserWindow } = this
|
|
const { preferences } = this._accessor
|
|
const eol = preferences.getPreferedEOL()
|
|
|
|
for (const { filePath, options, selected } of fileList) {
|
|
loadMarkdownFile(filePath, eol).then(rawDocument => {
|
|
if (this.lifecycle === WindowLifecycle.READY) {
|
|
this._doOpenTab(rawDocument, options, selected)
|
|
} else {
|
|
this._filesToOpen.push({ doc: rawDocument, options, selected })
|
|
}
|
|
}).catch(err => {
|
|
const { message, stack } = err
|
|
log.error(`[ERROR] Cannot open file or directory: ${message}\n\n${stack}`)
|
|
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.lifecycle === WindowLifecycle.QUITTED ||
|
|
isSamePathSync(pathname, this._openedRootDirectory)) {
|
|
return
|
|
}
|
|
|
|
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 ---------------------------------
|
|
|
|
_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'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a new new tab from the markdown document.
|
|
*
|
|
* @param {IMarkdownDocumentRaw} rawDocument The markdown document.
|
|
* @param {any} options The tab option for the editor window.
|
|
* @param {boolean} selected Whether the tab should become the selected tab (true if not set).
|
|
*/
|
|
_doOpenTab (rawDocument, options, selected) {
|
|
const { _accessor, _openedFiles, browserWindow } = this
|
|
const { menu: appMenu } = _accessor
|
|
const { pathname } = rawDocument
|
|
|
|
// Listen for file changed.
|
|
ipcMain.emit('watcher-watch-file', browserWindow, pathname)
|
|
|
|
appMenu.addRecentlyUsedDocument(pathname)
|
|
_openedFiles.push(pathname)
|
|
browserWindow.webContents.send('mt::open-new-tab', rawDocument, options, 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, options, selected } of this._filesToOpen) {
|
|
this._doOpenTab(doc, options, selected)
|
|
}
|
|
this._filesToOpen.length = 0
|
|
}
|
|
}
|
|
|
|
export default EditorWindow
|