mirror of
https://github.com/marktext/marktext.git
synced 2025-05-02 00:31:06 +08:00
User-defined open files (#735)
This commit is contained in:
parent
44c537a62c
commit
cbd90bfeb4
3
doc/wip/README.md
Normal file
3
doc/wip/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Internal Documentation
|
||||
|
||||
WIP documentation of Mark Text.
|
109
doc/wip/renderer/editor.md
Normal file
109
doc/wip/renderer/editor.md
Normal 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
|
@ -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 => {
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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()}.`,
|
||||
|
@ -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
|
||||
|
@ -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') ||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user