marktext/src/renderer/store/editor.js
2023-08-03 22:20:35 +08:00

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 }