User-defined open files (#735)

This commit is contained in:
Felix Häusler 2019-03-07 15:59:40 +01:00 committed by Ran Luo
parent 44c537a62c
commit cbd90bfeb4
13 changed files with 270 additions and 35 deletions

3
doc/wip/README.md Normal file
View File

@ -0,0 +1,3 @@
# Internal Documentation
WIP documentation of Mark Text.

109
doc/wip/renderer/editor.md Normal file
View File

@ -0,0 +1,109 @@
# Editor
TBD
## Internal
### Raw markdown document
```typescript
interface IMarkdownDocumentRaw
{
// Markdown content
markdown: string,
// Filename
filename: string,
// Full path (may be empty?)
pathname: string,
// Indicates whether the document is UTF8 or UTF8-DOM encoded.
isUtf8BomEncoded: boolean,
// "lf" or "crlf"
lineEnding: string,
// Convert document ("lf") to `lineEnding` when saving
adjustLineEndingOnSave: boolean
// Whether the document has mixed line endings (lf and crlf) and was converted to lf.
isMixedLineEndings: boolean
// TODO(refactor:renderer/editor): Remove this entry! This should be loaded separately if needed.
textDirection: boolean
}
```
### Markdowm document
A markdown document (`IMarkdownDocument`) represent a file.
```typescript
interface IMarkdownDocument
{
// Markdown content
markdown: string,
// Filename
filename: string,
// Full path (may be empty?)
pathname: string,
// Indicates whether the document is UTF8 or UTF8-DOM encoded.
isUtf8BomEncoded: boolean,
// "lf" or "crlf"
lineEnding: string,
// Convert document ("lf") to `lineEnding` when saving
adjustLineEndingOnSave: boolean
}
```
### File State
Internal state of a markdown document. `IMarkdownDocument` is used to create a `IFileState`.
```typescript
interface IDocumentState
{
isSaved: boolean,
pathname: string,
filename: string,
markdown: string,
isUtf8BomEncoded: boolean,
lineEnding: string,
adjustLineEndingOnSave: boolean,
textDirection: string,
history: {
stack: Array<any>,
index: number
},
cursor: any,
wordCount: {
paragraph: number,
word: number,
character: number,
all: number
},
searchMatches: {
index: number,
matches: Array<any>,
value: string
}
}
```
### ...
TBD
## View
TBD
### Side Bar
TBD
### Tabs
TBD
### Document
TBD

View File

@ -5,7 +5,7 @@ import { promisify } from 'util'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import appWindow from '../window'
import { EXTENSION_HASN, EXTENSIONS, PANDOC_EXTENSIONS } from '../config'
import { writeFile, writeMarkdownFile } from '../utils/filesystem'
import { loadMarkdownFile, writeFile, writeMarkdownFile } from '../utils/filesystem'
import appMenu from '../menu'
import { getPath, isMarkdownFile, log, isFile, isDirectory, getRecommendTitle } from '../utils'
import userPreference from '../preference'
@ -271,7 +271,7 @@ ipcMain.on('AGANI::ask-for-open-project-in-sidebar', e => {
properties: ['openDirectory', 'createDirectory']
})
if (pathname && pathname[0]) {
appWindow.openProject(win, pathname[0])
appWindow.openFolder(win, pathname[0])
}
})
@ -302,31 +302,46 @@ export const print = win => {
win.webContents.send('AGANI::print')
}
export const openFileOrProject = pathname => {
export const openFileOrFolder = pathname => {
if (isFile(pathname) || isDirectory(pathname)) {
appWindow.createWindow(pathname)
}
}
export const openProject = win => {
export const openFolder = win => {
const pathname = dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
})
if (pathname && pathname[0]) {
openFileOrProject(pathname[0])
openFileOrFolder(pathname[0])
}
}
export const open = win => {
const filename = dialog.showOpenDialog(win, {
export const openFile = win => {
const fileList = dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [{
name: 'text',
extensions: EXTENSIONS
}]
})
if (filename && filename[0]) {
openFileOrProject(filename[0])
if (!fileList || !fileList[0]) {
return
}
const filename = fileList[0]
const { openFilesInNewWindow } = userPreference.getAll()
if (openFilesInNewWindow) {
openFileOrFolder(filename)
} else {
loadMarkdownFile(filename).then(rawDocument => {
newTab(win, rawDocument)
}).catch(err => {
// TODO: Handle error --> create a end-user error handler.
console.error('[ERROR] Cannot open file.')
log(err)
})
}
}
@ -334,8 +349,14 @@ export const newFile = () => {
appWindow.createWindow()
}
export const newTab = win => {
win.webContents.send('AGANI::new-tab')
/**
* Creates a new tab.
*
* @param {BrowserWindow} win Browser window
* @param {IMarkdownDocumentRaw} [rawDocument] Optional markdown document. If null a blank tab is created.
*/
export const newTab = (win, rawDocument = null) => {
win.webContents.send('AGANI::new-tab', rawDocument)
}
export const closeTab = win => {

View File

@ -4,7 +4,7 @@ import * as actions from '../actions/file'
const dockMenu = Menu.buildFromTemplate([{
label: 'Open...',
click (menuItem, browserWindow) {
actions.open(browserWindow)
actions.openFile(browserWindow)
}
}, {
label: 'Clear Recent',

View File

@ -29,13 +29,13 @@ export default function (recentlyUsedFiles) {
label: 'Open File',
accelerator: keybindings.getAccelerator('fileOpenFile'),
click (menuItem, browserWindow) {
actions.open(browserWindow)
actions.openFile(browserWindow)
}
}, {
label: 'Open Folder',
accelerator: keybindings.getAccelerator('fileOpenFolder'),
click (menuItem, browserWindow) {
actions.openProject(browserWindow)
actions.openFolder(browserWindow)
}
}]
}
@ -50,7 +50,7 @@ export default function (recentlyUsedFiles) {
recentlyUsedMenu.submenu.push({
label: item,
click (menuItem, browserWindow) {
actions.openFileOrProject(menuItem.label)
actions.openFileOrFolder(menuItem.label)
}
})
}

View File

@ -31,8 +31,7 @@ const convertLineEndings = (text, lineEnding) => {
export const writeFile = (pathname, content, extension) => {
if (!pathname) {
const errMsg = '[ERROR] Cannot save file without path.'
return Promise.reject(errMsg)
return Promise.reject('[ERROR] Cannot save file without path.')
}
pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}`
@ -54,6 +53,12 @@ export const writeMarkdownFile = (pathname, content, options, win) => {
return writeFile(pathname, content, extension)
}
/**
* Reads the contents of a markdown file.
*
* @param {String} The path to the markdown file.
* @returns {IMarkdownDocumentRaw} Returns a raw markdown document.
*/
export const loadMarkdownFile = async pathname => {
let markdown = await fse.readFile(path.resolve(pathname), 'utf-8')
// Check UTF-8 BOM (EF BB BF) encoding
@ -65,7 +70,7 @@ export const loadMarkdownFile = async pathname => {
// Detect line ending
const isLf = LF_LINE_ENDING_REG.test(markdown)
const isCrlf = CRLF_LINE_ENDING_REG.test(markdown)
const isMixed = isLf && isCrlf
const isMixedLineEndings = isLf && isCrlf
const isUnknownEnding = !isLf && !isCrlf
let lineEnding = getOsLineEndingName()
if (isLf && !isCrlf) {
@ -75,7 +80,7 @@ export const loadMarkdownFile = async pathname => {
}
let adjustLineEndingOnSave = false
if (isMixed || isUnknownEnding || lineEnding !== 'lf') {
if (isMixedLineEndings || isUnknownEnding || lineEnding !== 'lf') {
adjustLineEndingOnSave = lineEnding !== 'lf'
// Convert to LF for internal use.
markdown = convertLineEndings(markdown, 'lf')
@ -83,16 +88,24 @@ export const loadMarkdownFile = async pathname => {
const filename = path.basename(pathname)
// TODO(refactor:renderer/editor): Remove this entry! This should be loaded separately if needed.
const textDirection = getDefaultTextDirection()
return {
// document information
markdown,
filename,
pathname,
// options
isUtf8BomEncoded,
lineEnding,
adjustLineEndingOnSave,
isMixed,
// raw file information
isMixedLineEndings,
// TODO(refactor:renderer/editor): see above
textDirection
}
}

View File

@ -48,7 +48,12 @@ class AppWindow {
}
}
createWindow (pathname, markdown = '', options = {}) {
createWindow (pathname = null, markdown = '', options = {}) {
// Ensure path is normalized
if (pathname) {
pathname = path.resolve(pathname)
}
const { windows } = this
const mainWindowState = windowStateKeeper({
defaultWidth: 1200,
@ -95,7 +100,7 @@ class AppWindow {
isUtf8BomEncoded,
lineEnding,
adjustLineEndingOnSave,
isMixed,
isMixedLineEndings,
textDirection
} = data
@ -113,7 +118,7 @@ class AppWindow {
})
// Notify user about mixed endings
if (isMixed) {
if (isMixedLineEndings) {
win.webContents.send('AGANI::show-notification', {
title: 'Mixed Line Endings',
type: 'error',
@ -125,7 +130,7 @@ class AppWindow {
.catch(log)
// open directory / folder
} else if (pathname && isDirectory(pathname)) {
this.openProject(win, pathname)
this.openFolder(win, pathname)
// open a window but do not open a file or directory
} else {
const lineEnding = getOsLineEndingName()
@ -180,11 +185,10 @@ class AppWindow {
return win
}
openProject (win, pathname) {
openFolder (win, pathname) {
const unwatcher = this.watcher.watch(win, pathname)
this.windows.get(win.id).watchers.push(unwatcher)
try {
// const tree = await loadProject(pathname)
win.webContents.send('AGANI::open-project', {
name: path.basename(pathname),
pathname

View File

@ -91,7 +91,7 @@
</div>
</div>
<div v-else class="open-project">
<a href="javascript:;" @click="openProject" title="Open Folder">
<a href="javascript:;" @click="openFolder" title="Open Folder">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-create-project"></use>
</svg>
@ -174,7 +174,7 @@
titleIconClick (active) {
//
},
openProject () {
openFolder () {
this.$store.dispatch('ASK_FOR_OPEN_PROJECT')
},
saveAll (isClose) {

View File

@ -23,13 +23,13 @@ export const fileMixins = {
handleFileClick () {
const { data, isMarkdown, pathname } = this.file
if (!isMarkdown || this.currentFile.pathname === pathname) return
const { isMixed, filename, lineEnding } = data
const { isMixedLineEndings, filename, lineEnding } = data
const isOpened = this.tabs.filter(file => file.pathname === pathname)[0]
const fileState = isOpened || getFileStateFromData(data)
this.$store.dispatch('UPDATE_CURRENT_FILE', fileState)
if (isMixed && !isOpened) {
if (isMixedLineEndings && !isOpened) {
this.$notify({
title: 'Line Ending',
message: `${filename} has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`,

View File

@ -2,7 +2,7 @@ import { clipboard, ipcRenderer, shell } from 'electron'
import path from 'path'
import bus from '../bus'
import { hasKeys } from '../util'
import { getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
import notice from '../services/notification'
// HACK: When rewriting muya, create and update muya's TOC during heading parsing and pass it to the renderer process.
@ -357,8 +357,14 @@ const actions = {
},
LISTEN_FOR_NEW_TAB ({ dispatch }) {
ipcRenderer.on('AGANI::new-tab', e => {
dispatch('NEW_BLANK_FILE')
ipcRenderer.on('AGANI::new-tab', (e, markdownDocument) => {
if (markdownDocument) {
// Create tab with content.
dispatch('NEW_TAB_WITH_CONTENT', markdownDocument)
} else {
// Create an empty tab
dispatch('NEW_BLANK_FILE')
}
})
},
@ -383,6 +389,36 @@ const actions = {
bus.$emit('file-loaded', markdown)
},
/**
* Create a new tab from the given markdown document
*
* @param {*} context Store context
* @param {IMarkdownDocumentRaw} markdownDocument Class that represent a markdown document
*/
NEW_TAB_WITH_CONTENT ({ commit, state, dispatch }, markdownDocument) {
if (!markdownDocument) {
console.warn('Cannot create a file tab without a markdown document!')
dispatch('NEW_BLANK_FILE')
return
}
const { markdown, isMixedLineEndings } = markdownDocument
const docState = createDocumentState(markdownDocument)
dispatch('UPDATE_CURRENT_FILE', docState)
bus.$emit('file-loaded', markdown)
if (isMixedLineEndings) {
const { filename, lineEnding } = markdownDocument
notice({
title: 'Line Ending',
message: `${filename} has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`,
type: 'primary',
time: 20000,
showConfirm: false
})
}
},
LISTEN_FOR_OPEN_BLANK_WINDOW ({ commit, state, dispatch }) {
ipcRenderer.on('AGANI::open-blank-window', (e, { lineEnding, markdown: source }) => {
const { tabs } = state

View File

@ -1,5 +1,10 @@
import { getUniqueId } from '../util'
import { getUniqueId, cloneObj } from '../util'
/**
* Default internel markdown document with editor options.
*
* @type {IDocumentState} Internel markdown document
*/
export const defaultFileState = {
isSaved: true,
pathname: '',
@ -81,6 +86,8 @@ export const getBlankFileState = (tabs, lineEnding = 'lf', markdown = '') => {
}
export const getSingleFileState = ({ id = getUniqueId(), markdown, filename, pathname, options }) => {
// TODO(refactor:renderer/editor): Replace this function with `createDocumentState`.
const fileState = JSON.parse(JSON.stringify(defaultFileState))
const { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave } = options
@ -97,6 +104,37 @@ export const getSingleFileState = ({ id = getUniqueId(), markdown, filename, pat
})
}
/**
* Creates a internal document from the given document.
*
* @param {IMarkdownDocument} markdownDocument Markdown document
* @param {String} [id] Random identifier
* @returns {IDocumentState} Returns a document state
*/
export const createDocumentState = (markdownDocument, id = getUniqueId()) => {
const docState = cloneObj(defaultFileState, true)
const {
markdown,
filename,
pathname,
isUtf8BomEncoded,
lineEnding,
adjustLineEndingOnSave,
} = markdownDocument
assertLineEnding(adjustLineEndingOnSave, lineEnding)
return Object.assign(docState, {
id,
markdown,
filename,
pathname,
isUtf8BomEncoded,
lineEnding,
adjustLineEndingOnSave
})
}
const assertLineEnding = (adjustLineEndingOnSave, lineEnding) => {
lineEnding = lineEnding.toLowerCase()
if ((adjustLineEndingOnSave && lineEnding !== 'crlf') ||

View File

@ -176,3 +176,13 @@ export const getUniqueId = () => {
}
export const hasKeys = obj => Object.keys(obj).length > 0
/**
* Clone an object as a shallow or deep copy.
*
* @param {*} obj Object to clone
* @param {Boolean} deepCopy Create a shallow (false) or deep copy (true)
*/
export const cloneObj = (obj, deepCopy=true) => {
return deepCopy ? JSON.parse(JSON.stringify(obj)) : Object.assign({}, obj)
}

View File

@ -35,7 +35,8 @@ Edit and save to update preferences. You can only change the JSON below!
"endOfLine": "default",
"tabSize": 4,
"textDirection": "ltr",
"titleBarStyle": "csd"
"titleBarStyle": "csd",
"openFilesInNewWindow": true
}
```