mirror of
https://github.com/marktext/marktext.git
synced 2025-05-01 20:42:02 +08:00
1412 lines
42 KiB
JavaScript
1412 lines
42 KiB
JavaScript
import { clipboard, ipcRenderer, shell, webFrame } from 'electron'
|
|
import path from 'path'
|
|
import equal from 'fast-deep-equal'
|
|
import { isSamePathSync } from 'common/filesystem/paths'
|
|
import bus from '../bus'
|
|
import { hasKeys, getUniqueId } from '../util'
|
|
import listToTree from '../util/listToTree'
|
|
import { createDocumentState, getOptionsFromState, getSingleFileState, getBlankFileState } from './help'
|
|
import notice from '../services/notification'
|
|
import {
|
|
FileEncodingCommand,
|
|
LineEndingCommand,
|
|
QuickOpenCommand,
|
|
TrailingNewlineCommand
|
|
} from '../commands'
|
|
|
|
const autoSaveTimers = new Map()
|
|
|
|
const state = {
|
|
currentFile: {},
|
|
tabs: [],
|
|
listToc: [], // Just use for deep equal check. and replace with new toc if needed.
|
|
toc: []
|
|
}
|
|
|
|
const mutations = {
|
|
// set search key and matches also index
|
|
SET_SEARCH (state, value) {
|
|
state.currentFile.searchMatches = value
|
|
},
|
|
SET_TOC (state, toc) {
|
|
state.listToc = toc
|
|
state.toc = listToTree(toc)
|
|
},
|
|
SET_CURRENT_FILE (state, currentFile) {
|
|
const oldCurrentFile = state.currentFile
|
|
if (!oldCurrentFile.id || oldCurrentFile.id !== currentFile.id) {
|
|
const { id, markdown, cursor, history, pathname } = currentFile
|
|
window.DIRNAME = pathname ? path.dirname(pathname) : ''
|
|
// set state first, then emit file changed event
|
|
state.currentFile = currentFile
|
|
bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
|
|
}
|
|
},
|
|
ADD_FILE_TO_TABS (state, currentFile) {
|
|
state.tabs.push(currentFile)
|
|
},
|
|
REMOVE_FILE_WITHIN_TABS (state, file) {
|
|
const { tabs, currentFile } = state
|
|
const index = tabs.indexOf(file)
|
|
tabs.splice(index, 1)
|
|
|
|
if (file.id && autoSaveTimers.has(file.id)) {
|
|
const timer = autoSaveTimers.get(file.id)
|
|
clearTimeout(timer)
|
|
autoSaveTimers.delete(file.id)
|
|
}
|
|
|
|
if (file.id === currentFile.id) {
|
|
const fileState = state.tabs[index] || state.tabs[index - 1] || state.tabs[0] || {}
|
|
state.currentFile = fileState
|
|
if (typeof fileState.markdown === 'string') {
|
|
const { id, markdown, cursor, history, pathname } = fileState
|
|
window.DIRNAME = pathname ? path.dirname(pathname) : ''
|
|
bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
|
|
}
|
|
}
|
|
|
|
if (state.tabs.length === 0) {
|
|
// Handle close the last tab, need to reset the TOC state
|
|
state.listToc = []
|
|
state.toc = []
|
|
}
|
|
},
|
|
// Exchange from with to and move from to the end if to is null or empty.
|
|
EXCHANGE_TABS_BY_ID (state, tabIDs) {
|
|
const { fromId } = tabIDs
|
|
const toId = tabIDs.toId // may be null
|
|
|
|
const { tabs } = state
|
|
const moveItem = (arr, from, to) => {
|
|
if (from === to) return true
|
|
const len = arr.length
|
|
const item = arr.splice(from, 1)
|
|
if (item.length === 0) return false
|
|
|
|
arr.splice(to, 0, item[0])
|
|
return arr.length === len
|
|
}
|
|
|
|
const fromIndex = tabs.findIndex(t => t.id === fromId)
|
|
if (!toId) {
|
|
moveItem(tabs, fromIndex, tabs.length - 1)
|
|
} else {
|
|
const toIndex = tabs.findIndex(t => t.id === toId)
|
|
const realToIndex = fromIndex < toIndex ? toIndex - 1 : toIndex
|
|
moveItem(tabs, fromIndex, realToIndex)
|
|
}
|
|
},
|
|
LOAD_CHANGE (state, change) {
|
|
const { tabs, currentFile } = state
|
|
const { data, pathname } = change
|
|
const {
|
|
isMixedLineEndings,
|
|
lineEnding,
|
|
adjustLineEndingOnSave,
|
|
trimTrailingNewline,
|
|
encoding,
|
|
markdown,
|
|
filename
|
|
} = data
|
|
const options = { encoding, lineEnding, adjustLineEndingOnSave, trimTrailingNewline }
|
|
|
|
// Create a new document and update few entires later.
|
|
const newFileState = getSingleFileState({ markdown, filename, pathname, options })
|
|
|
|
const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
|
|
if (!tab) {
|
|
// The tab may be closed in the meanwhile.
|
|
console.error('LOAD_CHANGE: Cannot find tab in tab list.')
|
|
notice.notify({
|
|
title: 'Error loading tab',
|
|
message: 'There was an error while loading the file change because the tab cannot be found.',
|
|
type: 'error',
|
|
time: 20000,
|
|
showConfirm: false
|
|
})
|
|
return
|
|
}
|
|
|
|
// Backup few entries that we need to restore later.
|
|
const oldId = tab.id
|
|
const oldNotifications = tab.notifications
|
|
let oldHistory = null
|
|
if (tab.history.index >= 0 && tab.history.stack.length >= 1) {
|
|
// Allow to restore the old document.
|
|
oldHistory = {
|
|
stack: [tab.history.stack[tab.history.index]],
|
|
index: 0
|
|
}
|
|
|
|
// Free reference from array
|
|
tab.history.index--
|
|
tab.history.stack.pop()
|
|
}
|
|
|
|
// Update file content and restore some entries.
|
|
Object.assign(tab, newFileState)
|
|
tab.id = oldId
|
|
tab.notifications = oldNotifications
|
|
if (oldHistory) {
|
|
tab.history = oldHistory
|
|
}
|
|
|
|
if (isMixedLineEndings) {
|
|
tab.notifications.push({
|
|
msg: `"${filename}" has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`,
|
|
showConfirm: false,
|
|
style: 'info',
|
|
exclusiveType: '',
|
|
action: () => {}
|
|
})
|
|
}
|
|
|
|
// Reload the editor if the tab is currently opened.
|
|
if (pathname === currentFile.pathname) {
|
|
state.currentFile = tab
|
|
const { id, cursor, history } = tab
|
|
bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
|
|
}
|
|
},
|
|
// NOTE: Please call this function only from main process via "mt::set-pathname" and free resources before!
|
|
SET_PATHNAME (state, { tab, fileInfo }) {
|
|
const { currentFile } = state
|
|
const { filename, pathname, id } = fileInfo
|
|
|
|
// Change reference path for images.
|
|
if (id === currentFile.id && pathname) {
|
|
window.DIRNAME = path.dirname(pathname)
|
|
}
|
|
|
|
if (tab) {
|
|
Object.assign(tab, { filename, pathname, isSaved: true })
|
|
}
|
|
},
|
|
SET_SAVE_STATUS_BY_TAB (state, { tab, status }) {
|
|
if (hasKeys(tab)) {
|
|
tab.isSaved = status
|
|
}
|
|
},
|
|
SET_SAVE_STATUS (state, status) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.isSaved = status
|
|
}
|
|
},
|
|
SET_SAVE_STATUS_WHEN_REMOVE (state, { pathname }) {
|
|
state.tabs.forEach(f => {
|
|
if (f.pathname === pathname) {
|
|
f.isSaved = false
|
|
}
|
|
})
|
|
},
|
|
SET_MARKDOWN (state, markdown) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.markdown = markdown
|
|
}
|
|
},
|
|
SET_DOCUMENT_ENCODING (state, encoding) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.encoding = encoding
|
|
}
|
|
},
|
|
SET_LINE_ENDING (state, lineEnding) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.lineEnding = lineEnding
|
|
}
|
|
},
|
|
SET_FILE_ENCODING_BY_NAME (state, encodingName) {
|
|
if (hasKeys(state.currentFile)) {
|
|
const { encoding: encodingObj } = state.currentFile
|
|
encodingObj.encoding = encodingName
|
|
encodingObj.isBom = false
|
|
}
|
|
},
|
|
SET_FINAL_NEWLINE (state, value) {
|
|
if (hasKeys(state.currentFile) && value >= 0 && value <= 3) {
|
|
state.currentFile.trimTrailingNewline = value
|
|
}
|
|
},
|
|
SET_ADJUST_LINE_ENDING_ON_SAVE (state, adjustLineEndingOnSave) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.adjustLineEndingOnSave = adjustLineEndingOnSave
|
|
}
|
|
},
|
|
SET_WORD_COUNT (state, wordCount) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.wordCount = wordCount
|
|
}
|
|
},
|
|
SET_CURSOR (state, cursor) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.cursor = cursor
|
|
}
|
|
},
|
|
SET_HISTORY (state, history) {
|
|
if (hasKeys(state.currentFile)) {
|
|
state.currentFile.history = history
|
|
}
|
|
},
|
|
CLOSE_TABS (state, tabIdList) {
|
|
if (!tabIdList || tabIdList.length === 0) return
|
|
|
|
let tabIndex = 0
|
|
tabIdList.forEach(id => {
|
|
const index = state.tabs.findIndex(f => f.id === id)
|
|
const { pathname } = state.tabs[index]
|
|
|
|
// Notify main process to remove the file from the window and free resources.
|
|
if (pathname) {
|
|
ipcRenderer.send('mt::window-tab-closed', pathname)
|
|
}
|
|
|
|
state.tabs.splice(index, 1)
|
|
if (state.currentFile.id === id) {
|
|
state.currentFile = {}
|
|
window.DIRNAME = ''
|
|
if (tabIdList.length === 1) {
|
|
tabIndex = index
|
|
}
|
|
}
|
|
})
|
|
|
|
if (!state.currentFile.id && state.tabs.length) {
|
|
state.currentFile = state.tabs[tabIndex] || state.tabs[tabIndex - 1] || state.tabs[0] || {}
|
|
if (typeof state.currentFile.markdown === 'string') {
|
|
const { id, markdown, cursor, history, pathname } = state.currentFile
|
|
window.DIRNAME = pathname ? path.dirname(pathname) : ''
|
|
bus.$emit('file-changed', { id, markdown, cursor, renderCursor: true, history })
|
|
}
|
|
}
|
|
|
|
if (state.tabs.length === 0) {
|
|
// Handle close the last tab, need to reset the TOC state
|
|
state.listToc = []
|
|
state.toc = []
|
|
}
|
|
},
|
|
RENAME_IF_NEEDED (state, { src, dest }) {
|
|
const { tabs } = state
|
|
tabs.forEach(f => {
|
|
if (f.pathname === src) {
|
|
f.pathname = dest
|
|
f.filename = path.basename(dest)
|
|
}
|
|
})
|
|
},
|
|
|
|
// Push a tab specific notification on stack that never disappears.
|
|
PUSH_TAB_NOTIFICATION (state, data) {
|
|
const defaultAction = () => {}
|
|
const { tabId, msg } = data
|
|
const action = data.action || defaultAction
|
|
const showConfirm = data.showConfirm || false
|
|
const style = data.style || 'info'
|
|
// Whether only one notification should exist.
|
|
const exclusiveType = data.exclusiveType || ''
|
|
|
|
const { tabs } = state
|
|
const tab = tabs.find(t => t.id === tabId)
|
|
if (!tab) {
|
|
console.error('PUSH_TAB_NOTIFICATION: Cannot find tab in tab list.')
|
|
return
|
|
}
|
|
|
|
const { notifications } = tab
|
|
|
|
// Remove the old notification if only one should exist.
|
|
if (exclusiveType) {
|
|
const index = notifications.findIndex(n => n.exclusiveType === exclusiveType)
|
|
if (index >= 0) {
|
|
// Reorder current notification
|
|
notifications.splice(index, 1)
|
|
}
|
|
}
|
|
|
|
// Push new notification on stack.
|
|
notifications.push({
|
|
msg,
|
|
showConfirm,
|
|
style,
|
|
exclusiveType,
|
|
action: action
|
|
})
|
|
}
|
|
}
|
|
|
|
const actions = {
|
|
FORMAT_LINK_CLICK ({ commit }, { data, dirname }) {
|
|
ipcRenderer.send('mt::format-link-click', { data, dirname })
|
|
},
|
|
|
|
LISTEN_SCREEN_SHOT ({ commit }) {
|
|
ipcRenderer.on('mt::screenshot-captured', e => {
|
|
bus.$emit('screenshot-captured')
|
|
})
|
|
},
|
|
|
|
// image path auto complement
|
|
ASK_FOR_IMAGE_AUTO_PATH ({ commit, state }, src) {
|
|
const { pathname } = state.currentFile
|
|
if (pathname) {
|
|
let rs
|
|
const promise = new Promise((resolve, reject) => {
|
|
rs = resolve
|
|
})
|
|
const id = getUniqueId()
|
|
ipcRenderer.once(`mt::response-of-image-path-${id}`, (e, files) => {
|
|
rs(files)
|
|
})
|
|
ipcRenderer.send('mt::ask-for-image-auto-path', { pathname, src, id })
|
|
return promise
|
|
} else {
|
|
return []
|
|
}
|
|
},
|
|
|
|
SEARCH ({ commit }, value) {
|
|
commit('SET_SEARCH', value)
|
|
},
|
|
|
|
SHOW_IMAGE_DELETION_URL ({ commit }, deletionUrl) {
|
|
notice.notify({
|
|
title: 'Image deletion URL',
|
|
message: `Click to copy the deletion URL of the uploaded image to the clipboard (${deletionUrl}).`,
|
|
showConfirm: true,
|
|
time: 20000
|
|
})
|
|
.then(() => {
|
|
clipboard.writeText(deletionUrl)
|
|
})
|
|
},
|
|
|
|
FORCE_CLOSE_TAB ({ commit, dispatch }, file) {
|
|
commit('REMOVE_FILE_WITHIN_TABS', file)
|
|
const { pathname } = file
|
|
|
|
// Notify main process to remove the file from the window and free resources.
|
|
if (pathname) {
|
|
ipcRenderer.send('mt::window-tab-closed', pathname)
|
|
}
|
|
},
|
|
|
|
EXCHANGE_TABS_BY_ID ({ commit }, tabIDs) {
|
|
commit('EXCHANGE_TABS_BY_ID', tabIDs)
|
|
},
|
|
|
|
// We need to update line endings menu when changing tabs.
|
|
UPDATE_LINE_ENDING_MENU ({ state }) {
|
|
const { lineEnding } = state.currentFile
|
|
if (lineEnding) {
|
|
const { windowId } = global.marktext.env
|
|
ipcRenderer.send('mt::update-line-ending-menu', windowId, lineEnding)
|
|
}
|
|
},
|
|
|
|
CLOSE_UNSAVED_TAB ({ commit, state }, file) {
|
|
const { id, pathname, filename, markdown } = file
|
|
const options = getOptionsFromState(file)
|
|
|
|
// Save the file content via main process and send a close tab response.
|
|
ipcRenderer.send('mt::save-and-close-tabs', [{ id, pathname, filename, markdown, options }])
|
|
},
|
|
|
|
// need pass some data to main process when `save` menu item clicked
|
|
LISTEN_FOR_SAVE ({ state, rootState }) {
|
|
ipcRenderer.on('mt::editor-ask-file-save', () => {
|
|
const { id, filename, pathname, markdown } = state.currentFile
|
|
const options = getOptionsFromState(state.currentFile)
|
|
const defaultPath = getRootFolderFromState(rootState)
|
|
if (id) {
|
|
ipcRenderer.send('mt::response-file-save', {
|
|
id,
|
|
filename,
|
|
pathname,
|
|
markdown,
|
|
options,
|
|
defaultPath
|
|
})
|
|
}
|
|
})
|
|
},
|
|
|
|
// need pass some data to main process when `save as` menu item clicked
|
|
LISTEN_FOR_SAVE_AS ({ state, rootState }) {
|
|
ipcRenderer.on('mt::editor-ask-file-save-as', () => {
|
|
const { id, filename, pathname, markdown } = state.currentFile
|
|
const options = getOptionsFromState(state.currentFile)
|
|
const defaultPath = getRootFolderFromState(rootState)
|
|
if (id) {
|
|
ipcRenderer.send('mt::response-file-save-as', {
|
|
id,
|
|
filename,
|
|
pathname,
|
|
markdown,
|
|
options,
|
|
defaultPath
|
|
})
|
|
}
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_SET_PATHNAME ({ commit, dispatch, state }) {
|
|
ipcRenderer.on('mt::set-pathname', (e, fileInfo) => {
|
|
const { tabs } = state
|
|
const { pathname, id } = fileInfo
|
|
const tab = tabs.find(f => f.id === id)
|
|
if (!tab) {
|
|
console.err('[ERROR] Cannot change file path from unknown tab.')
|
|
return
|
|
}
|
|
|
|
// If a tab with the same file path already exists we need to close the tab.
|
|
// The existing tab is overwritten by this tab.
|
|
const existingTab = tabs.find(t => t.id !== id && isSamePathSync(t.pathname, pathname))
|
|
if (existingTab) {
|
|
dispatch('CLOSE_TAB', existingTab)
|
|
}
|
|
commit('SET_PATHNAME', { tab, fileInfo })
|
|
})
|
|
|
|
ipcRenderer.on('mt::tab-saved', (e, tabId) => {
|
|
const { tabs } = state
|
|
const tab = tabs.find(f => f.id === tabId)
|
|
if (tab) {
|
|
Object.assign(tab, { isSaved: true })
|
|
}
|
|
})
|
|
|
|
ipcRenderer.on('mt::tab-save-failure', (e, tabId, msg) => {
|
|
const { tabs } = state
|
|
const tab = tabs.find(t => t.id === tabId)
|
|
if (!tab) {
|
|
notice.notify({
|
|
title: 'Save failure',
|
|
message: msg,
|
|
type: 'error',
|
|
time: 20000,
|
|
showConfirm: false
|
|
})
|
|
return
|
|
}
|
|
|
|
commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
|
|
commit('PUSH_TAB_NOTIFICATION', {
|
|
tabId,
|
|
msg: `There was an error while saving: ${msg}`,
|
|
style: 'crit'
|
|
})
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_CLOSE ({ state }) {
|
|
ipcRenderer.on('mt::ask-for-close', e => {
|
|
const unsavedFiles = state.tabs
|
|
.filter(file => !file.isSaved)
|
|
.map(file => {
|
|
const { id, filename, pathname, markdown } = file
|
|
const options = getOptionsFromState(file)
|
|
return { id, filename, pathname, markdown, options }
|
|
})
|
|
|
|
if (unsavedFiles.length) {
|
|
ipcRenderer.send('mt::close-window-confirm', unsavedFiles)
|
|
} else {
|
|
ipcRenderer.send('mt::close-window')
|
|
}
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_SAVE_CLOSE ({ commit }) {
|
|
ipcRenderer.on('mt::force-close-tabs-by-id', (e, tabIdList) => {
|
|
if (Array.isArray(tabIdList) && tabIdList.length) {
|
|
commit('CLOSE_TABS', tabIdList)
|
|
}
|
|
})
|
|
},
|
|
|
|
ASK_FOR_SAVE_ALL ({ commit, state }, closeTabs) {
|
|
const { tabs } = state
|
|
const unsavedFiles = tabs
|
|
.filter(file => !(file.isSaved && /[^\n]/.test(file.markdown)))
|
|
.map(file => {
|
|
const { id, filename, pathname, markdown } = file
|
|
const options = getOptionsFromState(file)
|
|
return { id, filename, pathname, markdown, options }
|
|
})
|
|
|
|
if (closeTabs) {
|
|
if (unsavedFiles.length) {
|
|
commit('CLOSE_TABS', tabs.filter(f => f.isSaved).map(f => f.id))
|
|
ipcRenderer.send('mt::save-and-close-tabs', unsavedFiles)
|
|
} else {
|
|
commit('CLOSE_TABS', tabs.map(f => f.id))
|
|
}
|
|
} else {
|
|
ipcRenderer.send('mt::save-tabs', unsavedFiles)
|
|
}
|
|
},
|
|
|
|
LISTEN_FOR_MOVE_TO ({ state, rootState }) {
|
|
ipcRenderer.on('mt::editor-move-file', () => {
|
|
const { id, filename, pathname, markdown } = state.currentFile
|
|
const options = getOptionsFromState(state.currentFile)
|
|
const defaultPath = getRootFolderFromState(rootState)
|
|
if (!id) return
|
|
if (!pathname) {
|
|
// if current file is a newly created file, just save it!
|
|
ipcRenderer.send('mt::response-file-save', {
|
|
id,
|
|
filename,
|
|
pathname,
|
|
markdown,
|
|
options,
|
|
defaultPath
|
|
})
|
|
} else {
|
|
// if not, move to a new(maybe) folder
|
|
ipcRenderer.send('mt::response-file-move-to', { id, pathname })
|
|
}
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_RENAME ({ commit, state, dispatch }) {
|
|
ipcRenderer.on('mt::editor-rename-file', () => {
|
|
dispatch('RESPONSE_FOR_RENAME')
|
|
})
|
|
},
|
|
|
|
RESPONSE_FOR_RENAME ({ state, rootState }) {
|
|
const { id, filename, pathname, markdown } = state.currentFile
|
|
const options = getOptionsFromState(state.currentFile)
|
|
const defaultPath = getRootFolderFromState(rootState)
|
|
if (!id) return
|
|
if (!pathname) {
|
|
// if current file is a newly created file, just save it!
|
|
ipcRenderer.send('mt::response-file-save', {
|
|
id,
|
|
filename,
|
|
pathname,
|
|
markdown,
|
|
options,
|
|
defaultPath
|
|
})
|
|
} else {
|
|
bus.$emit('rename')
|
|
}
|
|
},
|
|
|
|
// ask for main process to rename this file to a new name `newFilename`
|
|
RENAME ({ commit, state }, newFilename) {
|
|
const { id, pathname, filename } = state.currentFile
|
|
if (typeof filename === 'string' && filename !== newFilename) {
|
|
const newPathname = path.join(path.dirname(pathname), newFilename)
|
|
ipcRenderer.send('mt::rename', { id, pathname, newPathname })
|
|
}
|
|
},
|
|
|
|
UPDATE_CURRENT_FILE ({ commit, state, dispatch }, currentFile) {
|
|
commit('SET_CURRENT_FILE', currentFile)
|
|
const { tabs } = state
|
|
if (!tabs.some(file => file.id === currentFile.id)) {
|
|
commit('ADD_FILE_TO_TABS', currentFile)
|
|
}
|
|
dispatch('UPDATE_LINE_ENDING_MENU')
|
|
},
|
|
|
|
// This events are only used during window creation.
|
|
LISTEN_FOR_BOOTSTRAP_WINDOW ({ commit, state, dispatch, rootState }) {
|
|
// Delay load runtime commands and initialize commands.
|
|
setTimeout(() => {
|
|
bus.$emit('cmd::register-command', new FileEncodingCommand(rootState.editor))
|
|
bus.$emit('cmd::register-command', new QuickOpenCommand(rootState))
|
|
bus.$emit('cmd::register-command', new LineEndingCommand(rootState.editor))
|
|
bus.$emit('cmd::register-command', new TrailingNewlineCommand(rootState.editor))
|
|
|
|
setTimeout(() => {
|
|
ipcRenderer.send('mt::request-keybindings')
|
|
bus.$emit('cmd::sort-commands')
|
|
}, 100)
|
|
}, 400)
|
|
|
|
ipcRenderer.on('mt::bootstrap-editor', (e, config) => {
|
|
const {
|
|
addBlankTab,
|
|
markdownList,
|
|
lineEnding,
|
|
sideBarVisibility,
|
|
tabBarVisibility,
|
|
sourceCodeModeEnabled
|
|
} = config
|
|
|
|
dispatch('SEND_INITIALIZED')
|
|
commit('SET_USER_PREFERENCE', { endOfLine: lineEnding })
|
|
commit('SET_LAYOUT', {
|
|
rightColumn: 'files',
|
|
showSideBar: !!sideBarVisibility,
|
|
showTabBar: !!tabBarVisibility
|
|
})
|
|
dispatch('DISPATCH_LAYOUT_MENU_ITEMS')
|
|
|
|
commit('SET_MODE', {
|
|
type: 'sourceCode',
|
|
checked: !!sourceCodeModeEnabled
|
|
})
|
|
|
|
if (addBlankTab) {
|
|
dispatch('NEW_UNTITLED_TAB', {})
|
|
} else if (markdownList.length) {
|
|
let isFirst = true
|
|
for (const markdown of markdownList) {
|
|
isFirst = false
|
|
dispatch('NEW_UNTITLED_TAB', { markdown, selected: isFirst })
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
// Open a new tab, optionally with content.
|
|
LISTEN_FOR_NEW_TAB ({ dispatch }) {
|
|
ipcRenderer.on('mt::open-new-tab', (e, markdownDocument, options = {}, selected = true) => {
|
|
if (markdownDocument) {
|
|
// Create tab with content.
|
|
dispatch('NEW_TAB_WITH_CONTENT', { markdownDocument, options, selected })
|
|
} else {
|
|
// Fallback: create a blank tab and always select it
|
|
dispatch('NEW_UNTITLED_TAB', {})
|
|
}
|
|
})
|
|
|
|
ipcRenderer.on('mt::new-untitled-tab', (e, selected = true, markdown = '') => {
|
|
// Create a blank tab
|
|
dispatch('NEW_UNTITLED_TAB', { markdown, selected })
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_CLOSE_TAB ({ commit, state, dispatch }) {
|
|
ipcRenderer.on('mt::editor-close-tab', e => {
|
|
const file = state.currentFile
|
|
if (!hasKeys(file)) return
|
|
dispatch('CLOSE_TAB', file)
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_TAB_CYCLE ({ commit, state, dispatch }) {
|
|
ipcRenderer.on('mt::tabs-cycle-left', e => {
|
|
dispatch('CYCLE_TABS', false)
|
|
})
|
|
ipcRenderer.on('mt::tabs-cycle-right', e => {
|
|
dispatch('CYCLE_TABS', true)
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_SWITCH_TABS ({ commit, state, dispatch }) {
|
|
ipcRenderer.on('mt::switch-tab-by-index', (event, index) => {
|
|
dispatch('SWITCH_TAB_BY_INDEX', index)
|
|
})
|
|
},
|
|
|
|
CLOSE_TAB ({ dispatch }, file) {
|
|
const { isSaved } = file
|
|
if (isSaved) {
|
|
dispatch('FORCE_CLOSE_TAB', file)
|
|
} else {
|
|
dispatch('CLOSE_UNSAVED_TAB', file)
|
|
}
|
|
},
|
|
|
|
CLOSE_OTHER_TABS ({ state, dispatch }, file) {
|
|
const { tabs } = state
|
|
tabs.filter(f => f.id !== file.id).forEach(tab => {
|
|
dispatch('CLOSE_TAB', tab)
|
|
})
|
|
},
|
|
|
|
CLOSE_SAVED_TABS ({ state, dispatch }) {
|
|
const { tabs } = state
|
|
tabs.filter(f => f.isSaved).forEach(tab => {
|
|
dispatch('CLOSE_TAB', tab)
|
|
})
|
|
},
|
|
|
|
CLOSE_ALL_TABS ({ state, dispatch }) {
|
|
const { tabs } = state
|
|
tabs.slice().forEach(tab => {
|
|
dispatch('CLOSE_TAB', tab)
|
|
})
|
|
},
|
|
|
|
RENAME_FILE ({ commit, dispatch }, file) {
|
|
commit('SET_CURRENT_FILE', file)
|
|
dispatch('UPDATE_LINE_ENDING_MENU')
|
|
bus.$emit('rename')
|
|
},
|
|
|
|
// Direction is a boolean where false is left and true right.
|
|
CYCLE_TABS ({ commit, dispatch, state }, direction) {
|
|
const { tabs, currentFile } = state
|
|
if (tabs.length <= 1) {
|
|
return
|
|
}
|
|
|
|
const currentIndex = tabs.findIndex(t => t.id === currentFile.id)
|
|
if (currentIndex === -1) {
|
|
console.error('CYCLE_TABS: Cannot find current tab index.')
|
|
return
|
|
}
|
|
|
|
let nextTabIndex = 0
|
|
if (!direction) {
|
|
// Switch tab to the left.
|
|
nextTabIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1
|
|
} else {
|
|
// Switch tab to the right.
|
|
nextTabIndex = (currentIndex + 1) % tabs.length
|
|
}
|
|
|
|
const nextTab = tabs[nextTabIndex]
|
|
if (!nextTab || !nextTab.id) {
|
|
console.error(`CYCLE_TABS: Cannot find next tab (index="${nextTabIndex}").`)
|
|
return
|
|
}
|
|
|
|
commit('SET_CURRENT_FILE', nextTab)
|
|
dispatch('UPDATE_LINE_ENDING_MENU')
|
|
},
|
|
|
|
SWITCH_TAB_BY_INDEX ({ commit, dispatch, state }, nextTabIndex) {
|
|
const { tabs, currentFile } = state
|
|
if (nextTabIndex < 0 || nextTabIndex >= tabs.length) {
|
|
console.warn('Invalid tab index:', nextTabIndex)
|
|
return
|
|
}
|
|
|
|
const currentIndex = tabs.findIndex(t => t.id === currentFile.id)
|
|
if (currentIndex === -1) {
|
|
console.error('Cannot find current tab index.')
|
|
return
|
|
}
|
|
|
|
const nextTab = tabs[nextTabIndex]
|
|
if (!nextTab || !nextTab.id) {
|
|
console.error(`Cannot find tab by index="${nextTabIndex}".`)
|
|
return
|
|
}
|
|
|
|
commit('SET_CURRENT_FILE', nextTab)
|
|
dispatch('UPDATE_LINE_ENDING_MENU')
|
|
},
|
|
|
|
/**
|
|
* Create a new untitled tab optional from a markdown string.
|
|
*
|
|
* @param {*} context The store context.
|
|
* @param {{markdown?: string, selected?: boolean}} obj Optional markdown string
|
|
* and whether the tab should become the selected tab (true if not set).
|
|
*/
|
|
NEW_UNTITLED_TAB ({ commit, state, dispatch, rootState }, { markdown: markdownString, selected }) {
|
|
// If not set select the tab.
|
|
if (selected == null) {
|
|
selected = true
|
|
}
|
|
|
|
dispatch('SHOW_TAB_VIEW', false)
|
|
|
|
const { defaultEncoding, endOfLine } = rootState.preferences
|
|
const { tabs } = state
|
|
const fileState = getBlankFileState(tabs, defaultEncoding, endOfLine, markdownString)
|
|
|
|
if (selected) {
|
|
const { id, markdown } = fileState
|
|
dispatch('UPDATE_CURRENT_FILE', fileState)
|
|
bus.$emit('file-loaded', { id, markdown })
|
|
} else {
|
|
commit('ADD_FILE_TO_TABS', fileState)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Create a new tab from the given markdown document.
|
|
*
|
|
* @param {*} context The store context.
|
|
* @param {{markdownDocument: IMarkdownDocumentRaw, selected?: boolean}} obj The markdown document
|
|
* and optional whether the tab should become the selected tab (true if not set).
|
|
*/
|
|
NEW_TAB_WITH_CONTENT ({ commit, state, dispatch }, { markdownDocument, options = {}, selected }) {
|
|
if (!markdownDocument) {
|
|
console.warn('Cannot create a file tab without a markdown document!')
|
|
dispatch('NEW_UNTITLED_TAB', {})
|
|
return
|
|
}
|
|
|
|
// Select the tab if not value is specified.
|
|
if (typeof selected === 'undefined') {
|
|
selected = true
|
|
}
|
|
// Check if tab already exist and always select existing tab if so.
|
|
const { currentFile, tabs } = state
|
|
const { pathname } = markdownDocument
|
|
const existingTab = tabs.find(t => isSamePathSync(t.pathname, pathname))
|
|
if (existingTab) {
|
|
dispatch('UPDATE_CURRENT_FILE', existingTab)
|
|
return
|
|
}
|
|
|
|
// Replace/close selected untitled empty tab
|
|
let keepTabBarState = false
|
|
if (currentFile) {
|
|
const { isSaved, pathname } = currentFile
|
|
if (isSaved && !pathname) {
|
|
keepTabBarState = true
|
|
dispatch('FORCE_CLOSE_TAB', currentFile)
|
|
}
|
|
}
|
|
|
|
if (!keepTabBarState) {
|
|
dispatch('SHOW_TAB_VIEW', false)
|
|
}
|
|
|
|
const { markdown, isMixedLineEndings } = markdownDocument
|
|
const docState = createDocumentState(Object.assign(markdownDocument, options))
|
|
const { id, cursor } = docState
|
|
|
|
if (selected) {
|
|
dispatch('UPDATE_CURRENT_FILE', docState)
|
|
bus.$emit('file-loaded', { id, markdown, cursor })
|
|
} else {
|
|
commit('ADD_FILE_TO_TABS', docState)
|
|
}
|
|
|
|
if (isMixedLineEndings) {
|
|
const { filename, lineEnding } = markdownDocument
|
|
commit('PUSH_TAB_NOTIFICATION', {
|
|
tabId: id,
|
|
msg: `${filename}" has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`
|
|
})
|
|
}
|
|
},
|
|
|
|
SHOW_TAB_VIEW ({ commit, state, dispatch }, always) {
|
|
const { tabs } = state
|
|
if (always || tabs.length === 1) {
|
|
commit('SET_LAYOUT', { showTabBar: true })
|
|
dispatch('DISPATCH_LAYOUT_MENU_ITEMS')
|
|
}
|
|
},
|
|
|
|
// Content change from realtime preview editor and source code editor
|
|
// WORKAROUND: id is "muya" if changes come from muya and not source code editor! So we don't have to apply the workaround.
|
|
LISTEN_FOR_CONTENT_CHANGE ({ commit, dispatch, state, rootState }, { id, markdown, wordCount, cursor, history, toc }) {
|
|
const { autoSave } = rootState.preferences
|
|
const {
|
|
id: currentId,
|
|
filename,
|
|
pathname,
|
|
markdown: oldMarkdown,
|
|
trimTrailingNewline
|
|
} = state.currentFile
|
|
const { listToc } = state
|
|
|
|
if (!id) {
|
|
throw new Error('Listen for document change but id was not set!')
|
|
} else if (!currentId || state.tabs.length === 0) {
|
|
// Discard changes - this case should normally not occur.
|
|
return
|
|
} else if (id !== 'muya' && currentId !== id) {
|
|
// WORKAROUND: We commit changes after switching the tab in source code mode.
|
|
// Update old tab or discard changes
|
|
for (const tab of state.tabs) {
|
|
if (tab.id && tab.id === id) {
|
|
tab.markdown = adjustTrailingNewlines(markdown, tab.trimTrailingNewline)
|
|
// Set cursor
|
|
if (cursor) {
|
|
tab.cursor = cursor
|
|
}
|
|
// Set history
|
|
if (history) {
|
|
tab.history = history
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
markdown = adjustTrailingNewlines(markdown, trimTrailingNewline)
|
|
commit('SET_MARKDOWN', markdown)
|
|
|
|
// Ignore new line which is added if the editor text is empty (#422)
|
|
if (oldMarkdown.length === 0 && markdown.length === 1 && markdown[0] === '\n') {
|
|
return
|
|
}
|
|
|
|
// Word count
|
|
if (wordCount) {
|
|
commit('SET_WORD_COUNT', wordCount)
|
|
}
|
|
// Set cursor
|
|
if (cursor) {
|
|
commit('SET_CURSOR', cursor)
|
|
}
|
|
// Set history
|
|
if (history) {
|
|
commit('SET_HISTORY', history)
|
|
}
|
|
// Set toc
|
|
if (toc && !equal(toc, listToc)) {
|
|
commit('SET_TOC', toc)
|
|
}
|
|
|
|
// Change save status/save to file only when the markdown changed!
|
|
if (markdown !== oldMarkdown) {
|
|
commit('SET_SAVE_STATUS', false)
|
|
|
|
// Save file is auto save is enable and file exist on disk.
|
|
if (pathname && autoSave) {
|
|
const options = getOptionsFromState(state.currentFile)
|
|
dispatch('HANDLE_AUTO_SAVE', {
|
|
id: currentId,
|
|
filename,
|
|
pathname,
|
|
markdown,
|
|
options
|
|
})
|
|
}
|
|
}
|
|
},
|
|
|
|
HANDLE_AUTO_SAVE ({ commit, state, rootState }, { id, filename, pathname, markdown, options }) {
|
|
if (!id || !pathname) {
|
|
throw new Error('HANDLE_AUTO_SAVE: Invalid tab.')
|
|
}
|
|
|
|
const { tabs } = state
|
|
const { autoSaveDelay } = rootState.preferences
|
|
|
|
if (autoSaveTimers.has(id)) {
|
|
const timer = autoSaveTimers.get(id)
|
|
clearTimeout(timer)
|
|
autoSaveTimers.delete(id)
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
autoSaveTimers.delete(id)
|
|
|
|
// Validate that the tab still exists. A tab is unchanged until successfully saved
|
|
// or force closed. The user decides whether to discard or save the tab when
|
|
// gracefully closed. The automatically save event may fire meanwhile.
|
|
const tab = tabs.find(t => t.id === id)
|
|
if (tab && !tab.isSaved) {
|
|
const defaultPath = getRootFolderFromState(rootState)
|
|
|
|
// Tab changed status is set after the file is saved.
|
|
ipcRenderer.send('mt::response-file-save', {
|
|
id,
|
|
filename,
|
|
pathname,
|
|
markdown,
|
|
options,
|
|
defaultPath
|
|
})
|
|
}
|
|
}, autoSaveDelay)
|
|
autoSaveTimers.set(id, timer)
|
|
},
|
|
|
|
SELECTION_CHANGE ({ commit }, changes) {
|
|
const { start, end } = changes
|
|
// Set search keyword to store.
|
|
if (start.key === end.key && start.block.text) {
|
|
const value = start.block.text.substring(start.offset, end.offset)
|
|
commit('SET_SEARCH', {
|
|
matches: [],
|
|
index: -1,
|
|
value
|
|
})
|
|
}
|
|
|
|
const { windowId } = global.marktext.env
|
|
ipcRenderer.send('mt::editor-selection-changed', windowId, createApplicationMenuState(changes))
|
|
},
|
|
|
|
SELECTION_FORMATS (_, formats) {
|
|
const { windowId } = global.marktext.env
|
|
ipcRenderer.send('mt::update-format-menu', windowId, createSelectionFormatState(formats))
|
|
},
|
|
|
|
EXPORT ({ state }, { type, content, pageOptions }) {
|
|
if (!hasKeys(state.currentFile)) return
|
|
|
|
// Extract title from TOC buffer.
|
|
let title = ''
|
|
const { listToc } = state
|
|
if (listToc && listToc.length > 0) {
|
|
let headerRef = listToc[0]
|
|
|
|
// The main title should be at the beginning of the document.
|
|
const len = Math.min(listToc.length, 6)
|
|
for (let i = 1; i < len; ++i) {
|
|
if (headerRef.lvl === 1) {
|
|
break
|
|
}
|
|
|
|
const header = listToc[i]
|
|
if (headerRef.lvl > header.lvl) {
|
|
headerRef = header
|
|
}
|
|
}
|
|
title = headerRef.content
|
|
}
|
|
|
|
const { filename, pathname } = state.currentFile
|
|
ipcRenderer.send('mt::response-export', {
|
|
type,
|
|
title,
|
|
content,
|
|
filename,
|
|
pathname,
|
|
pageOptions
|
|
})
|
|
},
|
|
|
|
LINTEN_FOR_EXPORT_SUCCESS ({ commit }) {
|
|
ipcRenderer.on('mt::export-success', (e, { type, filePath }) => {
|
|
notice.notify({
|
|
title: 'Exported successfully',
|
|
message: `Exported "${path.basename(filePath)}" successfully!`,
|
|
showConfirm: true
|
|
})
|
|
.then(() => {
|
|
shell.showItemInFolder(filePath)
|
|
})
|
|
})
|
|
},
|
|
|
|
PRINT_RESPONSE ({ commit }) {
|
|
ipcRenderer.send('mt::response-print')
|
|
},
|
|
|
|
LINTEN_FOR_PRINT_SERVICE_CLEARUP ({ commit }) {
|
|
ipcRenderer.on('mt::print-service-clearup', e => {
|
|
bus.$emit('print-service-clearup')
|
|
})
|
|
},
|
|
|
|
LINTEN_FOR_SET_LINE_ENDING ({ commit, dispatch, state }) {
|
|
ipcRenderer.on('mt::set-line-ending', (e, lineEnding) => {
|
|
const { lineEnding: oldLineEnding } = state.currentFile
|
|
if (lineEnding !== oldLineEnding) {
|
|
commit('SET_LINE_ENDING', lineEnding)
|
|
commit('SET_ADJUST_LINE_ENDING_ON_SAVE', lineEnding !== 'lf')
|
|
commit('SET_SAVE_STATUS', true)
|
|
|
|
// Update menu when emitted from renderer process.
|
|
if (!e) {
|
|
dispatch('UPDATE_LINE_ENDING_MENU')
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
LINTEN_FOR_SET_ENCODING ({ commit, state }) {
|
|
ipcRenderer.on('mt::set-file-encoding', (e, encodingName) => {
|
|
const { encoding } = state.currentFile.encoding
|
|
if (encoding !== encodingName) {
|
|
commit('SET_FILE_ENCODING_BY_NAME', encodingName)
|
|
commit('SET_SAVE_STATUS', true)
|
|
}
|
|
})
|
|
},
|
|
|
|
LINTEN_FOR_SET_FINAL_NEWLINE ({ commit, state }) {
|
|
ipcRenderer.on('mt::set-final-newline', (e, value) => {
|
|
const { trimTrailingNewline } = state.currentFile
|
|
if (trimTrailingNewline !== value) {
|
|
commit('SET_FINAL_NEWLINE', value)
|
|
commit('SET_SAVE_STATUS', true)
|
|
}
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) {
|
|
ipcRenderer.on('mt::update-file', (e, { type, change }) => {
|
|
// TODO: We should only load the changed content if the user want to reload the document.
|
|
|
|
const { tabs } = state
|
|
const { pathname } = change
|
|
const tab = tabs.find(t => isSamePathSync(t.pathname, pathname))
|
|
if (tab) {
|
|
const { id, isSaved, filename } = tab
|
|
switch (type) {
|
|
case 'unlink': {
|
|
commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
|
|
commit('PUSH_TAB_NOTIFICATION', {
|
|
tabId: id,
|
|
msg: `"${filename}" has been removed on disk.`,
|
|
style: 'warn',
|
|
showConfirm: false,
|
|
exclusiveType: 'file_changed'
|
|
})
|
|
break
|
|
}
|
|
case 'add':
|
|
case 'change': {
|
|
const { autoSave } = rootState.preferences
|
|
if (autoSave) {
|
|
if (autoSaveTimers.has(id)) {
|
|
const timer = autoSaveTimers.get(id)
|
|
clearTimeout(timer)
|
|
autoSaveTimers.delete(id)
|
|
}
|
|
|
|
// Only reload the content if the tab is saved.
|
|
if (isSaved) {
|
|
commit('LOAD_CHANGE', change)
|
|
return
|
|
}
|
|
}
|
|
|
|
commit('SET_SAVE_STATUS_BY_TAB', { tab, status: false })
|
|
commit('PUSH_TAB_NOTIFICATION', {
|
|
tabId: id,
|
|
msg: `"${filename}" has been changed on disk. Do you want to reload it?`,
|
|
showConfirm: true,
|
|
exclusiveType: 'file_changed',
|
|
action: status => {
|
|
if (status) {
|
|
commit('LOAD_CHANGE', change)
|
|
}
|
|
}
|
|
})
|
|
break
|
|
}
|
|
default:
|
|
console.error(`LISTEN_FOR_FILE_CHANGE: Invalid type "${type}"`)
|
|
}
|
|
} else {
|
|
console.error(`LISTEN_FOR_FILE_CHANGE: Cannot find tab for path "${pathname}".`)
|
|
}
|
|
})
|
|
},
|
|
|
|
ASK_FOR_IMAGE_PATH ({ commit }) {
|
|
return ipcRenderer.sendSync('mt::ask-for-image-path')
|
|
},
|
|
|
|
LISTEN_WINDOW_ZOOM ({ dispatch, rootState }) {
|
|
ipcRenderer.on('mt::window-zoom', (e, zoomFactor) => {
|
|
zoomFactor = Number.parseFloat(zoomFactor.toFixed(3)) // prevent float rounding errors
|
|
const { zoom } = rootState.preferences
|
|
if (zoom !== zoomFactor) {
|
|
dispatch('SET_SINGLE_PREFERENCE', { type: 'zoom', value: zoomFactor })
|
|
}
|
|
webFrame.setZoomFactor(zoomFactor)
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_RELOAD_IMAGES () {
|
|
ipcRenderer.on('mt::invalidate-image-cache', () => {
|
|
bus.$emit('invalidate-image-cache')
|
|
})
|
|
},
|
|
|
|
LISTEN_FOR_CONTEXT_MENU () {
|
|
// General context menu
|
|
ipcRenderer.on('mt::cm-copy-as-markdown', () => {
|
|
bus.$emit('copyAsMarkdown', 'copyAsMarkdown')
|
|
})
|
|
ipcRenderer.on('mt::cm-copy-as-html', () => {
|
|
bus.$emit('copyAsHtml', 'copyAsHtml')
|
|
})
|
|
ipcRenderer.on('mt::cm-paste-as-plain-text', () => {
|
|
bus.$emit('pasteAsPlainText', 'pasteAsPlainText')
|
|
})
|
|
ipcRenderer.on('mt::cm-insert-paragraph', (e, location) => {
|
|
bus.$emit('insertParagraph', location)
|
|
})
|
|
|
|
// Spelling
|
|
ipcRenderer.on('mt::spelling-replace-misspelling', (e, info) => {
|
|
bus.$emit('replace-misspelling', info)
|
|
})
|
|
ipcRenderer.on('mt::spelling-show-switch-language', () => {
|
|
bus.$emit('open-command-spellchecker-switch-language')
|
|
})
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return the opened root folder or an empty string.
|
|
*
|
|
* @param {*} rootState The root state.
|
|
*/
|
|
const getRootFolderFromState = rootState => {
|
|
const openedFolder = rootState.project.projectTree
|
|
if (openedFolder) {
|
|
return openedFolder.pathname
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Trim the final newlines according `trimTrailingNewlineOption`.
|
|
*
|
|
* @param {string} markdown The text to trim.
|
|
* @param {*} trimTrailingNewlineOption The option how we should trim the final newlines.
|
|
*/
|
|
const adjustTrailingNewlines = (markdown, trimTrailingNewlineOption) => {
|
|
if (!markdown) {
|
|
return ''
|
|
}
|
|
|
|
switch (trimTrailingNewlineOption) {
|
|
// Trim trailing newlines.
|
|
case 0: {
|
|
return trimTrailingNewlines(markdown)
|
|
}
|
|
// Ensure single trailing newline.
|
|
case 1: {
|
|
// Muya will always add a final new line to the markdown text. Check first whether
|
|
// only one newline exist to prevent copying the string.
|
|
const lastIndex = markdown.length - 1
|
|
if (markdown[lastIndex] === '\n') {
|
|
if (markdown.length === 1) {
|
|
// Just return nothing because adding a final new line makes no sense.
|
|
return ''
|
|
} else if (markdown[lastIndex - 1] !== '\n') {
|
|
return markdown
|
|
}
|
|
}
|
|
|
|
// Otherwise trim trailing newlines and add one.
|
|
markdown = trimTrailingNewlines(markdown)
|
|
if (markdown.length === 0) {
|
|
// Just return nothing because adding a final new line makes no sense.
|
|
return ''
|
|
}
|
|
return markdown + '\n'
|
|
}
|
|
// Disabled, use text as it is.
|
|
default:
|
|
return markdown
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trim trailing newlines from `text`.
|
|
*
|
|
* @param {string} text The text to trim.
|
|
*/
|
|
const trimTrailingNewlines = text => {
|
|
return text.replace(/[\r?\n]+$/, '')
|
|
}
|
|
|
|
/**
|
|
* Creates a object that contains the application menu state.
|
|
*
|
|
* @param {*} selection The selection.
|
|
* @returns A object that represents the application menu state.
|
|
*/
|
|
const createApplicationMenuState = ({ start, end, affiliation }) => {
|
|
const state = {
|
|
isDisabled: false,
|
|
// Whether multiple lines are selected.
|
|
isMultiline: start.key !== end.key,
|
|
// List information - a list must be selected.
|
|
isLooseListItem: false,
|
|
isTaskList: false,
|
|
// Whether the selection is code block like (math, html or code block).
|
|
isCodeFences: false,
|
|
// Whether a code block line is selected.
|
|
isCodeContent: false,
|
|
// Whether the selection contains a table.
|
|
isTable: false,
|
|
// Contains keys about the selection type(s) (string, boolean) like "ul: true".
|
|
affiliation: {}
|
|
}
|
|
const { isMultiline } = state
|
|
|
|
// Get code block information from selection.
|
|
if (
|
|
(start.block.functionType === 'cellContent' && end.block.functionType === 'cellContent') ||
|
|
(start.type === 'span' && start.block.functionType === 'codeContent') ||
|
|
(end.type === 'span' && end.block.functionType === 'codeContent')
|
|
) {
|
|
// A code block like block is selected (code, math, ...).
|
|
state.isCodeFences = true
|
|
|
|
// A code block line is selected.
|
|
if (start.block.functionType === 'codeContent' || end.block.functionType === 'codeContent') {
|
|
state.isCodeContent = true
|
|
}
|
|
}
|
|
|
|
// Query list information.
|
|
if (affiliation.length >= 1 && /ul|ol/.test(affiliation[0].type)) {
|
|
const listBlock = affiliation[0]
|
|
state.affiliation[listBlock.type] = true
|
|
state.isLooseListItem = listBlock.children[0].isLooseListItem
|
|
state.isTaskList = listBlock.listType === 'task'
|
|
} else if (affiliation.length >= 3 && affiliation[1].type === 'li') {
|
|
const listItem = affiliation[1]
|
|
const listType = listItem.listItemType === 'order' ? 'ol' : 'ul'
|
|
state.affiliation[listType] = true
|
|
state.isLooseListItem = listItem.isLooseListItem
|
|
state.isTaskList = listItem.listItemType === 'task'
|
|
}
|
|
|
|
// Search with block depth 3 (e.g. "ul -> li -> p" where p is the actually paragraph inside the list (item)).
|
|
for (const b of affiliation.slice(0, 3)) {
|
|
if (b.type === 'pre' && b.functionType) {
|
|
if (/frontmatter|html|multiplemath|code$/.test(b.functionType)) {
|
|
state.isCodeFences = true
|
|
state.affiliation[b.functionType] = true
|
|
}
|
|
break
|
|
} else if (b.type === 'figure' && b.functionType) {
|
|
if (b.functionType === 'table') {
|
|
state.isTable = true
|
|
state.isDisabled = true
|
|
}
|
|
break
|
|
} else if (isMultiline && /^h{1,6}$/.test(b.type)) {
|
|
// Multiple block elements are selected.
|
|
state.affiliation = {}
|
|
break
|
|
} else {
|
|
if (!state.affiliation[b.type]) {
|
|
state.affiliation[b.type] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
if (Object.getOwnPropertyNames(state.affiliation).length >= 2 && state.affiliation.p) {
|
|
delete state.affiliation.p
|
|
}
|
|
if ((state.affiliation.ul || state.affiliation.ol) && state.affiliation.li) {
|
|
delete state.affiliation.li
|
|
}
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Creates a object that contains the formats selection state.
|
|
*
|
|
* @param {*} selection The selection.
|
|
* @returns A object that represents the formats menu state.
|
|
*/
|
|
const createSelectionFormatState = formats => {
|
|
// NOTE: Normally only one format can be selected but the selection is
|
|
// given as array by Muya.
|
|
const state = {}
|
|
for (const item of formats) {
|
|
state[item.type] = true
|
|
}
|
|
return state
|
|
}
|
|
|
|
export default { state, mutations, actions }
|