最終效果
頁面
src/renderer/src/App.vue
<div class="dirPanel"><div class="panelTitle">文件列表</div><div class="searchFileBox"><Icon class="searchFileInputIcon" icon="material-symbols-light:search" /><inputv-model="searchFileKeyWord"class="searchFileInput"type="text"placeholder="請輸入文件名"/><Iconv-show="searchFileKeyWord"class="clearSearchFileInputBtn"icon="codex:cross"@click="clearSearchFileInput"/></div><div class="dirListBox"><divv-for="(item, index) in fileList_filtered":id="`file-${index}`":key="item.filePath"class="dirItem"spellcheck="false":class="currentFilePath === item.filePath ? 'activeDirItem' : ''":contenteditable="item.editable"@click="openFile(item)"@contextmenu.prevent="showContextMenu(item.filePath)"@blur="saveFileName(item, index)"@keydown.enter.prevent="saveFileName_enter(index)">{{ item.fileName.slice(0, -3) }}</div></div></div>
相關樣式
.dirPanel {width: 200px;border: 1px solid gray;
}
.dirListBox {padding: 0px 10px 10px 10px;
}
.dirItem {padding: 6px;font-size: 12px;cursor: pointer;border-radius: 4px;margin-bottom: 6px;
}
.searchFileBox {display: flex;align-items: center;justify-content: center;padding: 10px;
}
.searchFileInput {display: block;font-size: 12px;padding: 4px 20px;
}
.searchFileInputIcon {position: absolute;font-size: 16px;transform: translateX(-80px);
}
.clearSearchFileInputBtn {position: absolute;cursor: pointer;font-size: 16px;transform: translateX(77px);
}
.panelTitle {font-size: 16px;font-weight: bold;text-align: center;background-color: #f0f0f0;height: 34px;line-height: 34px;
}
相關依賴
實現圖標
npm i --save-dev @iconify/vue
導入使用
import { Icon } from '@iconify/vue'
搜索圖標
https://icon-sets.iconify.design/?query=home
常規功能
文件搜索
根據搜索框的值 searchFileKeyWord 的變化動態計算 computed 過濾文件列表 fileList 得到 fileList_filtered ,頁面循環遍歷渲染 fileList_filtered
const fileList = ref<FileItem[]>([])
const searchFileKeyWord = ref('')
const fileList_filtered = computed(() => {return fileList.value.filter((file) => {return file.filePath.toLowerCase().includes(searchFileKeyWord.value.toLowerCase())})
})
文件搜索框的清空按鈕點擊事件
const clearSearchFileInput = (): void => {searchFileKeyWord.value = ''
}
當前文件高亮
const currentFilePath = ref('')
:class="currentFilePath === item.filePath ? 'activeDirItem' : ''"
.activeDirItem {background-color: #e4e4e4;
}
切換打開的文件
點擊文件列表的文件名稱,打開對應的文件
@click="openFile(item)"
const openFile = (item: FileItem): void => {markdownContent.value = item.contentcurrentFilePath.value = item.filePath
}
右鍵快捷菜單
@contextmenu.prevent="showContextMenu(item.filePath)"
const showContextMenu = (filePath: string): void => {window.electron.ipcRenderer.send('showContextMenu', filePath)// 隱藏其他右鍵菜單 -- 不能同時有多個右鍵菜單顯示hide_editor_contextMenu()
}
觸發創建右鍵快捷菜單
src/main/ipc.ts
import { createContextMenu } from './menu'
ipcMain.on('showContextMenu', (_e, filePath) => {createContextMenu(mainWindow, filePath)})
執行創建右鍵快捷菜單
src/main/menu.ts
const createContextMenu = (mainWindow: BrowserWindow, filePath: string): void => {const template = [{label: '重命名',click: async () => {mainWindow.webContents.send('do-rename-file', filePath)}},{ type: 'separator' }, // 添加分割線{label: '移除',click: async () => {mainWindow.webContents.send('removeOut-fileList', filePath)}},{label: '清空文件列表',click: async () => {mainWindow.webContents.send('clear-fileList')}},{ type: 'separator' }, // 添加分割線{label: '打開所在目錄',click: async () => {// 打開目錄shell.openPath(path.dirname(filePath))}},{ type: 'separator' }, // 添加分割線{label: '刪除',click: async () => {try {// 顯示確認對話框const { response } = await dialog.showMessageBox(mainWindow, {type: 'question',buttons: ['確定', '取消'],title: '確認刪除',message: `確定要刪除文件 ${path.basename(filePath)} 嗎?`})if (response === 0) {// 用戶點擊確定,刪除本地文件await fs.unlink(filePath)// 通知渲染進程文件已刪除mainWindow.webContents.send('delete-file', filePath)}} catch {dialog.showMessageBox(mainWindow, {type: 'error',title: '刪除失敗',message: `刪除文件 ${path.basename(filePath)} 時出錯,請稍后重試。`})}}}]const menu = Menu.buildFromTemplate(template as MenuItemConstructorOptions[])menu.popup({ window: mainWindow })
}
export { createMenu, createContextMenu }
隱藏其他右鍵菜單
// 隱藏編輯器右鍵菜單
const hide_editor_contextMenu = (): void => {if (isMenuVisible.value) {isMenuVisible.value = false}
}
重命名文件
實現思路
- 點擊右鍵快捷菜單的“重命名”
- 將被點擊的文件列表項的 contenteditable 變為 true,使其成為一個可編輯的div
- 全選文件列表項內的文本
- 輸入新的文件名
- 在失去焦點/按Enter鍵時,開始嘗試保存文件名
- 若新文件名與舊文件名相同,則直接將被點擊的文件列表項的 contenteditable 變為 false
- 若新文件名與本地文件名重復,則彈窗提示該文件名已存在,需換其他文件名
- 若新文件名合規,則執行保存文件名
- 被點擊的文件列表項的 contenteditable 變為 false
src/renderer/src/App.vue
window.electron.ipcRenderer.on('do-rename-file', (_, filePath) => {fileList_filtered.value.forEach(async (file, index) => {// 找到要重命名的文件if (file.filePath === filePath) {// 將被點擊的文件列表項的 contenteditable 變為 true,使其成為一個可編輯的divfile.editable = true// 等待 DOM 更新await nextTick()// 全選文件列表項內的文本let divElement = document.getElementById(`file-${index}`)if (divElement) {const range = document.createRange()range.selectNodeContents(divElement) // 選擇 div 內所有內容const selection = window.getSelection()if (selection) {selection.removeAllRanges() // 清除現有選擇selection.addRange(range) // 添加新選擇divElement.focus() // 聚焦到 div}}}})})
@blur="saveFileName(item, index)"@keydown.enter.prevent="saveFileName_enter(index)"
// 重命名文件時,保存文件名
const saveFileName = async (item: FileItem, index: number): Promise<void> => {// 獲取新的文件名,若新文件名為空,則命名為 '無標題'let newFileName = document.getElementById(`file-${index}`)?.textContent?.trim() || '無標題'// 若新文件名與舊文件名相同,則直接將被點擊的文件列表項的 contenteditable 變為 falseif (newFileName === item.fileName.replace('.md', '')) {item.editable = falsereturn}// 拼接新的文件路徑const newFilePath = item.filePath.replace(item.fileName, `${newFileName}.md`)// 開始嘗試保存文件名const error = await window.electron.ipcRenderer.invoke('rename-file', {oldFilePath: item.filePath,newFilePath,newFileName})if (error) {// 若重命名報錯,則重新聚焦,讓用戶重新輸入文件名document.getElementById(`file-${index}`)?.focus()} else {// 沒報錯,則重命名成功,更新當前文件路徑,文件列表中的文件名,文件路徑,將被點擊的文件列表項的 contenteditable 變為 falseif (currentFilePath.value === item.filePath) {currentFilePath.value = newFilePath}item.fileName = `${newFileName}.md`item.filePath = newFilePathitem.editable = false}
}
// 按回車時,直接失焦,觸發失焦事件執行保存文件名
const saveFileName_enter = (index: number): void => {document.getElementById(`file-${index}`)?.blur()
}
src/main/ipc.ts
- 檢查新文件名是否包含非法字符 (\ / : * ? " < > |)
- 檢查新文件名是否在本地已存在
- 檢查合規,則重命名文件
ipcMain.handle('rename-file', async (_e, { oldFilePath, newFilePath, newFileName }) => {// 檢查新文件名是否包含非法字符(\ / : * ? " < > |)if (/[\\/:*?"<>|]/.test(newFileName)) {return await dialog.showMessageBox(mainWindow, {type: 'error',title: '重命名失敗',message: `文件名稱 ${newFileName} 包含非法字符,請重新輸入。`})}try {await fs.access(newFilePath)// 若未拋出異常,說明文件存在return await dialog.showMessageBox(mainWindow, {type: 'error',title: '重命名失敗',message: `文件 ${path.basename(newFilePath)} 已存在,請選擇其他名稱。`})} catch {// 若拋出異常,說明文件不存在,可以進行重命名操作return await fs.rename(oldFilePath, newFilePath)}})
移除文件
將文件從文件列表中移除(不會刪除文件)
window.electron.ipcRenderer.on('removeOut-fileList', (_, filePath) => {// 過濾掉要刪除的文件fileList.value = fileList.value.filter((file) => {return file.filePath !== filePath})// 若移除的當前打開的文件if (currentFilePath.value === filePath) {// 若移除目標文件后,還有其他文件,則打開第一個文件if (fileList_filtered.value.length > 0) {openFile(fileList_filtered.value[0])} else {// 若移除目標文件后,沒有其他文件,則清空內容和路徑markdownContent.value = ''currentFilePath.value = ''}}})
清空文件列表
window.electron.ipcRenderer.on('clear-fileList', () => {fileList.value = []markdownContent.value = ''currentFilePath.value = ''})
用資源管理器打開文件所在目錄
直接用 shell 打開
src/main/menu.ts
{label: '打開所在目錄',click: async () => {shell.openPath(path.dirname(filePath))}},
刪除文件
src/main/menu.ts
{label: '刪除',click: async () => {try {// 顯示確認對話框const { response } = await dialog.showMessageBox(mainWindow, {type: 'question',buttons: ['確定', '取消'],title: '確認刪除',message: `確定要刪除文件 ${path.basename(filePath)} 嗎?`})if (response === 0) {// 用戶點擊確定,刪除本地文件await fs.unlink(filePath)// 通知渲染進程,將文件從列表中移除mainWindow.webContents.send('removeOut-fileList', filePath)}} catch {dialog.showMessageBox(mainWindow, {type: 'error',title: '刪除失敗',message: `刪除文件 ${path.basename(filePath)} 時出錯,請稍后重試。`})}}}
src/renderer/src/App.vue
同移除文件
window.electron.ipcRenderer.on('removeOut-fileList', (_, filePath) => {// 過濾掉要刪除的文件fileList.value = fileList.value.filter((file) => {return file.filePath !== filePath})// 若移除的當前打開的文件if (currentFilePath.value === filePath) {// 若移除目標文件后,還有其他文件,則打開第一個文件if (fileList_filtered.value.length > 0) {openFile(fileList_filtered.value[0])} else {// 若移除目標文件后,沒有其他文件,則清空內容和路徑markdownContent.value = ''currentFilePath.value = ''}}})