Electron-vite【实战】MD 编辑器 -- 文件列表(含右键快捷菜单,重命名文件,删除本地文件,打开本地目录等)

发布于:2025-05-30 ⋅ 阅读:(17) ⋅ 点赞:(0)

最终效果

在这里插入图片描述

页面

src/renderer/src/App.vue

    <div class="dirPanel">
      <div class="panelTitle">文件列表</div>
      <div class="searchFileBox">
        <Icon class="searchFileInputIcon" icon="material-symbols-light:search" />
        <input
          v-model="searchFileKeyWord"
          class="searchFileInput"
          type="text"
          placeholder="请输入文件名"
        />
        <Icon
          v-show="searchFileKeyWord"
          class="clearSearchFileInputBtn"
          icon="codex:cross"
          @click="clearSearchFileInput"
        />
      </div>
      <div class="dirListBox">
        <div
          v-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.content
  currentFilePath.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
  }
}

重命名文件

在这里插入图片描述

实现思路

  1. 点击右键快捷菜单的“重命名”
  2. 将被点击的文件列表项的 contenteditable 变为 true,使其成为一个可编辑的div
  3. 全选文件列表项内的文本
  4. 输入新的文件名
  5. 在失去焦点/按Enter键时,开始尝试保存文件名
  6. 若新文件名与旧文件名相同,则直接将被点击的文件列表项的 contenteditable 变为 false
  7. 若新文件名与本地文件名重复,则弹窗提示该文件名已存在,需换其他文件名
  8. 若新文件名合规,则执行保存文件名
  9. 被点击的文件列表项的 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,使其成为一个可编辑的div
        file.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 变为 false
  if (newFileName === item.fileName.replace('.md', '')) {
    item.editable = false
    return
  }
  // 拼接新的文件路径
  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 变为 false
    if (currentFilePath.value === item.filePath) {
      currentFilePath.value = newFilePath
    }
    item.fileName = `${newFileName}.md`
    item.filePath = newFilePath
    item.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 = ''
      }
    }
  })