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

最终效果

在这里插入图片描述

页面

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 = ''
}
}
}
)
posted on 2025-06-21 22:47  ljbguanli  阅读(14)  评论(0)    收藏  举报