From dfffc73e6967ff98a26214bd8f982ed65fb1359a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20H=C3=A4usler?= Date: Wed, 9 May 2018 14:29:40 +0200 Subject: [PATCH] Line ending (#234) * Prepare line ending feature * Detect document line ending * Line ending conversion * Add "endOfLine" settings option * Add line ending menu * Notify user about mixed endings * Fixes * Change line ending menu entries to radio style --- .github/CHANGELOG.md | 3 +- src/main/actions/edit.js | 9 +++ src/main/actions/file.js | 57 +++------------ src/main/config.js | 4 + src/main/createWindow.js | 51 +++++++------ src/main/filesystem.js | 113 +++++++++++++++++++++++++++++ src/main/menu.js | 11 +++ src/main/menus/edit.js | 19 +++++ src/main/utils.js | 2 + src/renderer/app.vue | 2 + src/renderer/components/status.vue | 24 ++++-- src/renderer/notice/index.js | 4 +- src/renderer/store/editor.js | 74 ++++++++++++++----- src/renderer/store/index.js | 4 +- src/renderer/store/notification.js | 21 ++++++ static/preference.md | 7 +- 16 files changed, 305 insertions(+), 100 deletions(-) create mode 100644 src/main/filesystem.js create mode 100644 src/renderer/store/notification.js diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index ccc58f10..988e1793 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -4,7 +4,8 @@ - feature: add editorFont setting in user preference. (#175) - Anderson - feature: line break, support event and import and export markdown - Jocs -- feat: unindent list item - Jocs +- feature: unindent list item - Jocs +- feature: Support for CRLF and LF line endings **:butterfly:Optimization** diff --git a/src/main/actions/edit.js b/src/main/actions/edit.js index 8410f7b8..9d8df0fc 100644 --- a/src/main/actions/edit.js +++ b/src/main/actions/edit.js @@ -2,6 +2,7 @@ import path from 'path' import { dialog, ipcMain, BrowserWindow } from 'electron' import { IMAGE_EXTENSIONS } from '../config' import { searchFilesAndDir } from '../imagePathAutoComplement' +import { updateLineEndingnMenu } from '../menu' import { log } from '../utils' const getAndSendImagePath = (win, type) => { @@ -37,10 +38,18 @@ ipcMain.on('AGANI::ask-for-image-auto-path', (e, { pathname, src }) => { .catch(log) }) +ipcMain.on('AGANI::update-line-ending-menu', (e, lineEnding) => { + updateLineEndingnMenu(lineEnding) +}) + export const edit = (win, type) => { win.webContents.send('AGANI::edit', { type }) } +export const lineEnding = (win, lineEnding) => { + win.webContents.send('AGANI::set-line-ending', { lineEnding }) +} + export const insertImage = (win, type) => { if (type === 'absolute' || type === 'relative') { getAndSendImagePath(win, type) diff --git a/src/main/actions/file.js b/src/main/actions/file.js index e7e2195c..43317d67 100644 --- a/src/main/actions/file.js +++ b/src/main/actions/file.js @@ -3,13 +3,15 @@ import fs from 'fs' // import chokidar from 'chokidar' import path from 'path' -import { app, BrowserWindow, dialog, ipcMain } from 'electron' -import createWindow, { windows } from '../createWindow' +import { BrowserWindow, dialog, ipcMain } from 'electron' +import createWindow, { forceClose, windows } from '../createWindow' import { EXTENSION_HASN, EXTENSIONS } from '../config' +import { writeFile, writeMarkdownFile } from '../filesystem' import { clearRecentlyUsedDocuments } from '../menu' import { getPath, isMarkdownFile, log, isFile } from '../utils' import userPreference from '../preference' +// TODO(fxha): Do we still need this? const watchAndReload = (pathname, win) => { // when i build, and failed. // const watcher = chokidar.watch(pathname, { // persistent: true @@ -27,43 +29,6 @@ const watchAndReload = (pathname, win) => { // when i build, and failed. // }) } -const writeFile = (pathname, content, extension, e, callback = null) => { - if (pathname) { - pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` - fs.writeFile(pathname, content, 'utf-8', err => { - if (err) log(err) - if (callback) callback(err, pathname) - }) - } else { - log('[ERROR] Cannot save file without path.') - } -} - -const writeMarkdownFile = (pathname, content, extension, isUtf8BomEncoded, win, e, quitAfterSave = false) => { - if (isUtf8BomEncoded) { - // js is call-by-value, so we can insert BOM - content = '\uFEFF' + content - } - - writeFile(pathname, content, extension, e, (err, filePath) => { - if (!err) e.sender.send('AGANI::file-saved-successfully') - const filename = path.basename(filePath) - if (e && filePath) e.sender.send('AGANI::set-pathname', { pathname: filePath, filename }) - if (!err && quitAfterSave) forceClose(win) - }) -} - -const forceClose = win => { - if (!win) return - if (windows.has(win.id)) { - windows.delete(win.id) - } - win.destroy() // if use win.close(), it will cause a endless loop. - if (windows.size === 0) { - app.quit() - } -} - // handle the response from render process. const handleResponseForExport = (e, { type, content, filename, pathname }) => { const win = BrowserWindow.fromWebContents(e.sender) @@ -85,27 +50,27 @@ const handleResponseForExport = (e, { type, content, filename, pathname }) => { } } -const handleResponseForSave = (e, { markdown, pathname, isUtf8BomEncoded, quitAfterSave = false }) => { +const handleResponseForSave = (e, { markdown, pathname, options, quitAfterSave = false }) => { const win = BrowserWindow.fromWebContents(e.sender) if (pathname) { - writeMarkdownFile(pathname, markdown, '', isUtf8BomEncoded, win, e, quitAfterSave) + writeMarkdownFile(pathname, markdown, '', options, win, e, quitAfterSave) } else { const filePath = dialog.showSaveDialog(win, { defaultPath: getPath('documents') + '/Untitled.md' }) - writeMarkdownFile(filePath, markdown, '.md', isUtf8BomEncoded, win, e, quitAfterSave) + writeMarkdownFile(filePath, markdown, '.md', options, win, e, quitAfterSave) } } -ipcMain.on('AGANI::response-file-save-as', (e, { markdown, pathname, isUtf8BomEncoded }) => { +ipcMain.on('AGANI::response-file-save-as', (e, { markdown, pathname, options }) => { const win = BrowserWindow.fromWebContents(e.sender) let filePath = dialog.showSaveDialog(win, { defaultPath: pathname || getPath('documents') + '/Untitled.md' }) - writeMarkdownFile(filePath, markdown, '.md', isUtf8BomEncoded, win, e) + writeMarkdownFile(filePath, markdown, '.md', options, win, e) }) -ipcMain.on('AGANI::response-close-confirm', (e, { filename, pathname, markdown, isUtf8BomEncoded }) => { +ipcMain.on('AGANI::response-close-confirm', (e, { filename, pathname, markdown, options }) => { const win = BrowserWindow.fromWebContents(e.sender) dialog.showMessageBox(win, { type: 'warning', @@ -122,7 +87,7 @@ ipcMain.on('AGANI::response-close-confirm', (e, { filename, pathname, markdown, break case 0: setTimeout(() => { - handleResponseForSave(e, { pathname, markdown, isUtf8BomEncoded, quitAfterSave: true }) + handleResponseForSave(e, { pathname, markdown, options, quitAfterSave: true }) }) break } diff --git a/src/main/config.js b/src/main/config.js index 3e3defa7..c6e9e03c 100644 --- a/src/main/config.js +++ b/src/main/config.js @@ -35,3 +35,7 @@ export const VIEW_MENU_ITEM = { 'Typewriter Mode': false, 'Focus Mode': false } + +export const LINE_ENDING_REG = /(?:\r\n|\n)/g +export const LF_LINE_ENDING_REG = /(?:[^\r]\n)|(?:^\n$)/ +export const CRLF_LINE_ENDING_REG = /\r\n/ diff --git a/src/main/createWindow.js b/src/main/createWindow.js index d7c18dc1..c0174658 100644 --- a/src/main/createWindow.js +++ b/src/main/createWindow.js @@ -1,12 +1,13 @@ 'use strict' -import fs from 'fs' import path from 'path' -import { BrowserWindow, screen } from 'electron' +import { app, BrowserWindow, screen } from 'electron' import windowStateKeeper from 'electron-window-state' -import { addRecentlyUsedDocuments } from './menu' -import { isMarkdownFile, log } from './utils' +import { getOsLineEndingName, loadMarkdownFile } from './filesystem' +import { addRecentlyUsedDocuments, updateLineEndingnMenu } from './menu' +import { isMarkdownFile } from './utils' +let focusedWindowId = -1 export const windows = new Map() const ensureWindowPosition = mainWindowState => { @@ -66,31 +67,24 @@ const createWindow = (pathname, options = {}) => { if (pathname && isMarkdownFile(pathname)) { addRecentlyUsedDocuments(pathname) - const filename = path.basename(pathname) - fs.readFile(path.resolve(pathname), 'utf-8', (err, file) => { - if (err) { - log(err) - return - } - - // check UTF-8 BOM (EF BB BF) encoding - let isUtf8BomEncoded = file.length >= 1 && file.charCodeAt(0) === 0xFEFF - if (isUtf8BomEncoded) { - file = file.slice(1) - } - - win.webContents.send('AGANI::file-loaded', { - file, - filename, - pathname, - isUtf8BomEncoded - }) + loadMarkdownFile(win, pathname) + } else { + const lineEnding = getOsLineEndingName() + win.webContents.send('AGANI::set-line-ending', { + lineEnding, + ignoreSaveStatus: true }) + updateLineEndingnMenu(lineEnding) } }) win.on('focus', () => { win.webContents.send('AGANI::window-active-status', { status: true }) + + if (win.id !== focusedWindowId) { + focusedWindowId = win.id + win.webContents.send('AGANI::req-update-line-ending-menu') + } }) win.on('blur', () => { @@ -113,4 +107,15 @@ const createWindow = (pathname, options = {}) => { return win } +export const forceClose = win => { + if (!win) return + if (windows.has(win.id)) { + windows.delete(win.id) + } + win.destroy() // if use win.close(), it will cause a endless loop. + if (windows.size === 0) { + app.quit() + } +} + export default createWindow diff --git a/src/main/filesystem.js b/src/main/filesystem.js new file mode 100644 index 00000000..30c0a778 --- /dev/null +++ b/src/main/filesystem.js @@ -0,0 +1,113 @@ +import fs from 'fs' +import path from 'path' +import { LINE_ENDING_REG, LF_LINE_ENDING_REG, CRLF_LINE_ENDING_REG } from './config' +import { forceClose } from './createWindow' +import userPreference from './preference' +import { log } from './utils' + +const isWin = process.platform === 'win32' + +const convertLineEndings = (text, lineEnding) => { + return text.replace(LINE_ENDING_REG, getLineEnding(lineEnding)) +} + +export const getOsLineEndingName = () => { + const { endOfLine } = userPreference.getAll() + if (endOfLine === 'lf') { + return 'lf' + } + return endOfLine === 'crlf' || isWin ? 'crlf' : 'lf' +} + +const getLineEnding = lineEnding => { + if (lineEnding === 'lf') { + return '\n' + } else if (lineEnding === 'crlf') { + return '\r\n' + } + return getOsLineEndingName() === 'crlf' ? '\r\n' : '\n' +} + +export const writeFile = (pathname, content, extension, e, callback = null) => { + if (pathname) { + pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` + fs.writeFile(pathname, content, 'utf-8', err => { + if (err) log(err) + if (callback) callback(err, pathname) + }) + } else { + log('[ERROR] Cannot save file without path.') + } +} + +export const writeMarkdownFile = (pathname, content, extension, options, win, e, quitAfterSave = false) => { + const { adjustLineEndingOnSave, isUtf8BomEncoded, lineEnding } = options + if (isUtf8BomEncoded) { + content = '\uFEFF' + content + } + + if (adjustLineEndingOnSave) { + content = convertLineEndings(content, lineEnding) + } + + writeFile(pathname, content, extension, e, (err, filePath) => { + if (!err) e.sender.send('AGANI::file-saved-successfully') + const filename = path.basename(filePath) + if (e && filePath) e.sender.send('AGANI::set-pathname', { pathname: filePath, filename }) + if (!err && quitAfterSave) forceClose(win) + }) +} + +export const loadMarkdownFile = (win, pathname) => { + fs.readFile(path.resolve(pathname), 'utf-8', (err, file) => { + if (err) { + log(err) + return + } + + // Check UTF-8 BOM (EF BB BF) encoding + const isUtf8BomEncoded = file.length >= 1 && file.charCodeAt(0) === 0xFEFF + if (isUtf8BomEncoded) { + file = file.slice(1) + } + + // Detect line ending + const isLf = LF_LINE_ENDING_REG.test(file) + const isCrlf = CRLF_LINE_ENDING_REG.test(file) + const isMixed = isLf && isCrlf + const isUnknownEnding = !isLf && !isCrlf + let lineEnding = getOsLineEndingName() + if (isLf && !isCrlf) { + lineEnding = 'lf' + } else if (isCrlf && !isLf) { + lineEnding = 'crlf' + } + + let adjustLineEndingOnSave = false + if (isMixed || isUnknownEnding || lineEnding !== 'lf') { + adjustLineEndingOnSave = lineEnding !== 'lf' + // Convert to LF for internal use. + file = convertLineEndings(file, 'lf') + } + + const filename = path.basename(pathname) + win.webContents.send('AGANI::file-loaded', { + file, + filename, + pathname, + options: { + isUtf8BomEncoded, + lineEnding, + adjustLineEndingOnSave + } + }) + + // Notify user about mixed endings + if (isMixed) { + win.webContents.send('AGANI::show-info-notification', { + msg: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, + timeout: 20000 + }) + } + }) +} diff --git a/src/main/menu.js b/src/main/menu.js index 267abe95..d314923a 100644 --- a/src/main/menu.js +++ b/src/main/menu.js @@ -84,3 +84,14 @@ export const updateApplicationMenu = (recentUsedDocuments) => { } initMacDock = true } + +export const updateLineEndingnMenu = lineEnding => { + const menus = Menu.getApplicationMenu() + const crlfMenu = menus.getMenuItemById('crlfLineEndingMenuEntry') + const lfMenu = menus.getMenuItemById('lfLineEndingMenuEntry') + if (lineEnding === 'crlf') { + crlfMenu.checked = true + } else { + lfMenu.checked = true + } +} diff --git a/src/main/menus/edit.js b/src/main/menus/edit.js index c38ea253..e812b7b6 100755 --- a/src/main/menus/edit.js +++ b/src/main/menus/edit.js @@ -88,5 +88,24 @@ export default { actions.insertImage(browserWindow, 'upload') } }] + }, { + type: 'separator' + }, { + label: 'Line Ending', + submenu: [{ + id: 'crlfLineEndingMenuEntry', + label: 'Carriage return and line feed (CRLF)', + type: 'radio', + click (menuItem, browserWindow) { + actions.lineEnding(browserWindow, 'crlf') + } + }, { + id: 'lfLineEndingMenuEntry', + label: 'Line feed (LF)', + type: 'radio', + click (menuItem, browserWindow) { + actions.lineEnding(browserWindow, 'lf') + } + }] }] } diff --git a/src/main/utils.js b/src/main/utils.js index f17032b2..6bf63123 100644 --- a/src/main/utils.js +++ b/src/main/utils.js @@ -20,6 +20,8 @@ export const getPath = directory => { } export const getMenuItem = menuName => { + // TODO(fxha): Please use menu id attribute to find menu entries. This will + // cause problems with internationalization later! const menus = Menu.getApplicationMenu() return menus.items.find(menu => menu.label === menuName) } diff --git a/src/renderer/app.vue b/src/renderer/app.vue index e93ef3fb..163c344d 100644 --- a/src/renderer/app.vue +++ b/src/renderer/app.vue @@ -98,6 +98,8 @@ dispatch('LISTEN_FOR_RENAME') dispatch('LISTEN_FOR_IMAGE_PATH') dispatch('LISTEN_FOR_FILE_SAVED_SUCCESSFULLY') + dispatch('LINTEN_FOR_SET_LINE_ENDING') + dispatch('LISTEN_FOR_NOTIFICATION') } } diff --git a/src/renderer/components/status.vue b/src/renderer/components/status.vue index e022eb0d..d36c887b 100644 --- a/src/renderer/components/status.vue +++ b/src/renderer/components/status.vue @@ -1,10 +1,11 @@