diff --git a/doc/wip/README.md b/doc/wip/README.md new file mode 100644 index 00000000..aaf47db5 --- /dev/null +++ b/doc/wip/README.md @@ -0,0 +1,3 @@ +# Internal Documentation + +WIP documentation of Mark Text. diff --git a/doc/wip/renderer/editor.md b/doc/wip/renderer/editor.md new file mode 100644 index 00000000..b04df491 --- /dev/null +++ b/doc/wip/renderer/editor.md @@ -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, + index: number + }, + cursor: any, + wordCount: { + paragraph: number, + word: number, + character: number, + all: number + }, + searchMatches: { + index: number, + matches: Array, + value: string + } +} +``` + +### ... + +TBD + +## View + +TBD + +### Side Bar + +TBD + +### Tabs + +TBD + +### Document + +TBD diff --git a/src/main/actions/file.js b/src/main/actions/file.js index 2ac6c4b6..9c029bbe 100644 --- a/src/main/actions/file.js +++ b/src/main/actions/file.js @@ -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 => { diff --git a/src/main/menus/dock.js b/src/main/menus/dock.js index 307295c9..9f000c18 100644 --- a/src/main/menus/dock.js +++ b/src/main/menus/dock.js @@ -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', diff --git a/src/main/menus/file.js b/src/main/menus/file.js index 2ed8bba7..4887e05a 100755 --- a/src/main/menus/file.js +++ b/src/main/menus/file.js @@ -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) } }) } diff --git a/src/main/utils/filesystem.js b/src/main/utils/filesystem.js index 7662aa34..d0569ee0 100644 --- a/src/main/utils/filesystem.js +++ b/src/main/utils/filesystem.js @@ -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 } } diff --git a/src/main/window.js b/src/main/window.js index 274cdf9c..580d9eda 100644 --- a/src/main/window.js +++ b/src/main/window.js @@ -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 diff --git a/src/renderer/components/sideBar/tree.vue b/src/renderer/components/sideBar/tree.vue index 93af91c3..40e7d91e 100644 --- a/src/renderer/components/sideBar/tree.vue +++ b/src/renderer/components/sideBar/tree.vue @@ -91,7 +91,7 @@
- + @@ -174,7 +174,7 @@ titleIconClick (active) { // }, - openProject () { + openFolder () { this.$store.dispatch('ASK_FOR_OPEN_PROJECT') }, saveAll (isClose) { diff --git a/src/renderer/mixins/index.js b/src/renderer/mixins/index.js index 5aeb36d7..2cacbf0f 100644 --- a/src/renderer/mixins/index.js +++ b/src/renderer/mixins/index.js @@ -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()}.`, diff --git a/src/renderer/store/editor.js b/src/renderer/store/editor.js index a371831d..e4ea7501 100644 --- a/src/renderer/store/editor.js +++ b/src/renderer/store/editor.js @@ -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 diff --git a/src/renderer/store/help.js b/src/renderer/store/help.js index 6e629f8b..7d90ccf8 100644 --- a/src/renderer/store/help.js +++ b/src/renderer/store/help.js @@ -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') || diff --git a/src/renderer/util/index.js b/src/renderer/util/index.js index 10cb636a..24fdcbbd 100644 --- a/src/renderer/util/index.js +++ b/src/renderer/util/index.js @@ -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) +} diff --git a/static/preference.md b/static/preference.md index 3fe1992c..7d1191c9 100755 --- a/static/preference.md +++ b/static/preference.md @@ -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 } ```