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
This commit is contained in:
Felix Häusler 2018-05-09 14:29:40 +02:00 committed by 冉四夕
parent 6cad091f6d
commit dfffc73e69
16 changed files with 305 additions and 100 deletions

View File

@ -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**

View File

@ -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)

View File

@ -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
}

View File

@ -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/

View File

@ -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

113
src/main/filesystem.js Normal file
View File

@ -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
})
}
})
}

View File

@ -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
}
}

View File

@ -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')
}
}]
}]
}

View File

@ -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)
}

View File

@ -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')
}
}
</script>

View File

@ -1,10 +1,11 @@
<template>
<div
class="bottom-status"
:class="{'error': error}"
v-show="showStatus"
>
<div class="status-wrapper">
<span class="message" :class="{'error': error}">{{ message }}</span>
<span class="message" :title="message">{{ message }}</span>
<span class="yes" v-show="showYes" @click="handleYesClick">[ Y ]</span>
<span class="no" @click="close(true)">[ X ]</span>
</div>
@ -31,10 +32,15 @@
this.error = true
this.message = msg
})
bus.$on('status-message', msg => {
bus.$on('status-message', (msg, timeout) => {
this.showStatus = true
this.error = false
this.message = msg
if (timeout) {
setTimeout(() => {
this.close(true)
}, timeout)
}
})
bus.$on('status-promote', (msg, eventId) => {
this.showStatus = true
@ -72,12 +78,17 @@
.bottom-status {
width: 100%;
height: 25px;
background-color: #2196F3;
color: #fff;
}
.bottom-status.error {
background-color: #F44336;
}
.status-wrapper {
text-align: center;
line-height: 25px;
font-size: 13px;
color: rgb(136, 170, 204);
color: #fff;
}
.message {
max-width: 70%;
@ -89,12 +100,9 @@
.message, .yes {
margin-right: 5px;
}
.message.error {
color: #E6A23C;
}
.yes, .no {
vertical-align: top;
color: rgb(79, 183, 221);
color: #fff;
cursor: pointer;
}
</style>

View File

@ -5,8 +5,8 @@ export const error = msg => {
bus.$emit('status-error', msg)
}
export const message = msg => {
bus.$emit('status-message', msg)
export const message = (msg, timeout) => {
bus.$emit('status-message', msg, timeout)
}
export const promote = msg => {

View File

@ -30,6 +30,8 @@ const state = {
isSaved: true,
markdown: '',
isUtf8BomEncoded: false,
lineEnding: 'lf', // lf or crlf
adjustLineEndingOnSave: false,
cursor: null,
windowActive: true,
wordCount: {
@ -41,6 +43,11 @@ const state = {
platform: process.platform
}
export const getOptionsFromState = state => {
const { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave } = state
return { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave }
}
const mutations = {
SET_MODE (state, { type, checked }) {
state[type] = checked
@ -67,6 +74,12 @@ const mutations = {
SET_IS_UTF8_BOM_ENCODED (state, isUtf8BomEncoded) {
state.isUtf8BomEncoded = isUtf8BomEncoded
},
SET_LINE_ENDING (state, lineEnding) {
state.lineEnding = lineEnding
},
SET_ADJUST_LINE_ENDING_ON_SAVE (state, adjustLineEndingOnSave) {
state.adjustLineEndingOnSave = adjustLineEndingOnSave
},
SET_WORD_COUNT (state, wordCount) {
state.wordCount = wordCount
},
@ -115,11 +128,11 @@ const actions = {
// handle autoSave
if (autoSave) {
const { pathname, markdown, isUtf8BomEncoded } = state
const { pathname, markdown } = state
const options = getOptionsFromState(state)
if (autoSave && pathname) {
commit('SET_SAVE_STATUS', true)
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, isUtf8BomEncoded })
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, options })
}
}
})
@ -141,31 +154,38 @@ const actions = {
})
},
LINTEN_WIN_STATUS ({ commit }) {
LINTEN_WIN_STATUS ({ commit, state }) {
ipcRenderer.on('AGANI::window-active-status', (e, { status }) => {
commit('SET_WIN_STATUS', status)
})
ipcRenderer.on('AGANI::req-update-line-ending-menu', e => {
const { lineEnding } = state
ipcRenderer.send('AGANI::update-line-ending-menu', lineEnding)
})
},
LISTEN_FOR_SAVE ({ commit, state }) {
ipcRenderer.on('AGANI::ask-file-save', () => {
const { pathname, markdown, isUtf8BomEncoded } = state
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, isUtf8BomEncoded })
const { pathname, markdown } = state
const options = getOptionsFromState(state)
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, options })
})
},
LISTEN_FOR_SAVE_AS ({ commit, state }) {
ipcRenderer.on('AGANI::ask-file-save-as', () => {
const { pathname, markdown, isUtf8BomEncoded } = state
ipcRenderer.send('AGANI::response-file-save-as', { pathname, markdown, isUtf8BomEncoded })
const { pathname, markdown } = state
const options = getOptionsFromState(state)
ipcRenderer.send('AGANI::response-file-save-as', { pathname, markdown, options })
})
},
LISTEN_FOR_MOVE_TO ({ commit, state }) {
ipcRenderer.on('AGANI::ask-file-move-to', () => {
const { pathname, markdown, isUtf8BomEncoded } = state
const { pathname, markdown } = state
const options = getOptionsFromState(state)
if (!pathname) {
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, isUtf8BomEncoded })
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, options })
} else {
ipcRenderer.send('AGANI::response-file-move-to', { pathname })
}
@ -174,9 +194,10 @@ const actions = {
LISTEN_FOR_RENAME ({ commit, state }) {
ipcRenderer.on('AGANI::ask-file-rename', () => {
const { pathname, markdown, isUtf8BomEncoded } = state
const { pathname, markdown } = state
const options = getOptionsFromState(state)
if (!pathname) {
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, isUtf8BomEncoded })
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, options })
} else {
bus.$emit('rename')
}
@ -200,13 +221,17 @@ const actions = {
},
LISTEN_FOR_FILE_LOAD ({ commit, state }) {
ipcRenderer.on('AGANI::file-loaded', (e, { file, filename, pathname, isUtf8BomEncoded }) => {
ipcRenderer.on('AGANI::file-loaded', (e, { file, filename, pathname, options }) => {
const { adjustLineEndingOnSave, isUtf8BomEncoded, lineEnding } = options
commit('SET_FILENAME', filename)
commit('SET_PATHNAME', pathname)
commit('SET_MARKDOWN', file)
commit('SET_SAVE_STATUS', true)
commit('SET_IS_UTF8_BOM_ENCODED', isUtf8BomEncoded)
commit('SET_LINE_ENDING', lineEnding)
commit('SET_ADJUST_LINE_ENDING_ON_SAVE', adjustLineEndingOnSave)
bus.$emit('file-loaded', file)
ipcRenderer.send('AGANI::update-line-ending-menu', lineEnding)
})
},
@ -229,7 +254,8 @@ const actions = {
},
LISTEN_FOR_CONTENT_CHANGE ({ commit, state }, { markdown, wordCount, cursor }) {
const { pathname, autoSave, markdown: oldMarkdown, isUtf8BomEncoded } = state
const { pathname, autoSave, markdown: oldMarkdown } = state
const options = getOptionsFromState(state)
commit('SET_MARKDOWN', markdown)
// set word count
if (wordCount) commit('SET_WORD_COUNT', wordCount)
@ -238,7 +264,7 @@ const actions = {
// change save status/save to file only when the markdown changed!
if (markdown !== oldMarkdown) {
if (pathname && autoSave) {
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, isUtf8BomEncoded })
ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, options })
} else {
commit('SET_SAVE_STATUS', false)
}
@ -323,13 +349,27 @@ const actions = {
LISTEN_FOR_CLOSE ({ commit, state }) {
ipcRenderer.on('AGANI::ask-for-close', e => {
const { isSaved, markdown, pathname, filename, isUtf8BomEncoded } = state
const { isSaved, markdown, pathname, filename } = state
const options = getOptionsFromState(state)
if (!isSaved && /[^\n]/.test(markdown)) {
ipcRenderer.send('AGANI::response-close-confirm', { filename, pathname, markdown, isUtf8BomEncoded })
ipcRenderer.send('AGANI::response-close-confirm', { filename, pathname, markdown, options })
} else {
ipcRenderer.send('AGANI::close-window')
}
})
},
LINTEN_FOR_SET_LINE_ENDING ({ commit, state }) {
ipcRenderer.on('AGANI::set-line-ending', (e, { lineEnding, ignoreSaveStatus }) => {
const { lineEnding: oldLineEnding } = state
if (lineEnding !== oldLineEnding) {
commit('SET_LINE_ENDING', lineEnding)
commit('SET_ADJUST_LINE_ENDING_ON_SAVE', lineEnding !== 'lf')
if (!ignoreSaveStatus) {
commit('SET_SAVE_STATUS', false)
}
}
})
}
}

View File

@ -4,13 +4,15 @@ import Vuex from 'vuex'
import editorStore from './editor'
import aidouStore from './aidou'
import autoUpdates from './autoUpdates'
import notification from './notification'
Vue.use(Vuex)
const storeArray = [
editorStore,
aidouStore,
autoUpdates
autoUpdates,
notification
]
const { actions, mutations, state } = storeArray.reduce((acc, s) => {

View File

@ -0,0 +1,21 @@
import { ipcRenderer } from 'electron'
import { error, message } from '../notice'
const state = {
}
const mutations = {
}
const actions = {
LISTEN_FOR_NOTIFICATION ({ commit }) {
ipcRenderer.on('AGANI::show-error-notification', (e, msg) => {
error(msg)
})
ipcRenderer.on('AGANI::show-info-notification', (e, { msg, timeout }) => {
message(msg, timeout)
})
}
}
export default { state, mutations, actions }

View File

@ -1,11 +1,13 @@
### :bust_in_silhouette:User Preferences
Edit and save to update preferences. You can only change the JSON below!
Edit and save to update preferences. You can only change the JSON below!
- **theme**: *String* `dark` or `light`
- **autoSave**: *Boolean* `true` or `false`
- **endOfLine**: *String* `lf`, `crlf` or `default`
```json
{
"fontSize": "16px",
@ -19,7 +21,8 @@ Edit and save to update preferences. You can only change the JSON below!
"preferLooseListItem": true,
"autoPairBracket": true,
"autoPairMarkdownSyntax": true,
"autoPairQuote": true
"autoPairQuote": true,
"endOfLine": "default"
}
```