Electron Markdown编辑器实战:资源管理器实现

前言

平时记笔记的时候一般使用VsCodemarkdown,不过在本地写的话上传图片就需要搭配图床来使用,不然的话这种markdown发布到其他地方就有问题。想着能折腾就折腾的心态,干脆就自己实现一个好了。需要实现的功能如下:

  1. 资源管理器------包括文件/文件夹的:
    • 创建
    • 删除
    • 复制
    • 移动
    • 重命名
  2. 接入掘金markdown编辑器组件并拓展,数据同步到本地
  3. 基于GitHub实现图床,配合CDN加速

这篇文章会详细介绍第一点,话不多说,让我们立刻开始~

读取本地资源

首先刚打开编辑器的时候需要用户选择一个文件夹,可以把这个文件夹理解为后续操作的根目录。

选择文件夹这里用到了electrondialog模块,使用dialog.showOpenDialog这个api就可以唤起本地的文件对话框,让用户选择文件或者文件夹。拿到选择的文件夹之后,需要递归获取下面的子文件夹和文件,由于我们实现的是一个markdown编辑器,所以我在取文件的时候只取了md文件。组装成数结构之后传给渲染进程,然后渲染进程再以树的形式渲染出来。

渲染进程给主进程发送一个事件,这个IPC通信以及搭建这个项目的脚手架在之前的文章🔥Electron打造你自己的录屏软件🔥介绍过,感兴趣的同学可以点进去看看,这里就不多赘述。

js 复制代码
  const ipcRenderer = window.electron.ipcRenderer
  const handleSelectFolder = () => {
    ipcRenderer.send(OPEN_FILE_DIALOG)
  }

在主进程中监听这个事件,在用户选择完文件夹后,可以拿到文件夹的路径。然后实现一个generateTree函数,把获取到的根目录递归处理,组装成一个树结构。

js 复制代码
import { dialog, ipcMain } from 'electron'
import { OPEN_FILE_DIALOG, SELECTED_DIRECTORY } from '../event'
import fs from 'fs'
import path from 'path'
export default () => {
  ipcMain.on(OPEN_FILE_DIALOG, (event) => {
    dialog
      .showOpenDialog({
        properties: ['openDirectory']
      })
      .then((result) => {
        if (!result.canceled) {
          const directoryPath = result.filePaths[0]
          const generateTree = (dir) => {
            const items = fs.readdirSync(dir, { withFileTypes: true })
            return items
              .map((item) => {
                const fullPath = path.join(dir, item.name)
                if (item.isDirectory()) {
                  return {
                    title: item.name,
                    key: fullPath,
                    children: generateTree(fullPath)
                  }
                } else if (item.isFile() && item.name.endsWith('.md')) {
                  return {
                    title: item.name,
                    key: fullPath,
                    isLeaf: true//后续根据这个判断是不是文件
                  }
                }
              })
              .filter(Boolean)
          }

          let folderTreeData = generateTree(directoryPath)
          folderTreeData = [
            { title: path.basename(directoryPath), key: directoryPath, children: folderTreeData }
          ]
          event.sender.send(SELECTED_DIRECTORY, { folderTreeData, directoryPath })
        }
      })
      .catch((err) => {
        console.log(err)
      })
  })
}

然后渲染进程再监听SELECTED_DIRECTORY这个事件,拿到组装好的结构配合AntdTree组件就可以很快的把一棵树渲染出来。

js 复制代码
  ipcRenderer.on(SELECTED_DIRECTORY, (event, data) => {
    const { directoryPath, folderTreeData } = data
    if (folderTreeData.length === 0) {
      message.info('文件夹为空,请重新选择')
      return
    }
    setCurrentPath(directoryPath)
    setTreeData(folderTreeData)
  })
  
  // ...
  
  <DirectoryTree
    defaultExpandedKeys={[currentPath]}
    rootClassName={styles.folderTree}
    treeData={treeData}
    onRightClick={handleRightClick}
  />

右键菜单

这里我是做了一个右键菜单,来承载文件夹树的各个操作。

Tree组件本身也提供了右键点击事件,而配合AntdDropdown组件的右键触发就可以实现一个右键菜单。但是常规的做法来做的话就要给每一个树节点都需要被一个Dropdown包裹组件,必然会产生一定的性能开销。这里我只用了一个Dropdown组件,只需要动态调整被包裹组件的位置,也可以实现每一个节点的右键菜单功能。

这里树节点的右键点击操作,我们来看看它做了什么:

js 复制代码
  const [rightMenus, setRightMenus] = useState([])
  const handleRightClick = ({ event, node }) => {
    const overlay = rightTriggerRef.current
    const { pageX, pageY } = event
    overlay.style.left = `${pageX}px`
    overlay.style.top = `${pageY}px`
    setSelectNode(node)
    setTimeout(() => {
      // overlay
      const event = new MouseEvent('contextmenu', {
        bubbles: true,
        cancelable: true,
        view: window,
        button: 2, // 2 表示右键
        // 如果需要设置鼠标坐标,可以添加以下属性
        clientX: pageX,
        clientY: pageY
      })

      }
      const items = [//...]
      setRightMenus(items)
      // 触发右键事件
      overlay.dispatchEvent(event)
    })
  }
  
//...
 <Dropdown menu={{ items: rightMenus }} trigger={['contextMenu']}>
    <div ref={rightTriggerRef} style={{ position: 'absolute' }}></div>
  </Dropdown>

当右键点击树节点时,可以从event对象中获取到鼠标的坐标位置,同时可以获取到触发的节点对象。这个时候根据鼠标的坐标位置动态设置rightTriggerRef的位置,设置完之后再对这个div创建并触发一个右键事件,那么Dropdown就会被触发了。

创建

点击创建菜单项的时候会弹出一个弹窗,输入完标题点击确定之后就会真正走创建的逻辑。

js 复制代码
const path = selectNode.key
ipcRenderer.send(action, path, title, treeData, selectNode)

点击确定之后渲染进程会向主进程发一个事件,介绍一下上面的几个参数:

  • selectNode:右键菜单触发的节点
  • action:对应的操作------创建、删除、复制等
  • path:节点在文件系统的路径,同时作为节点的id
  • title:标题
  • treeData:当前的树数据

创建文件

主进程接收到这个事件之后,path参数就是即将被创建的文件的父文件夹路径(因为我们只能在文件夹下创建文件)。我们可以拼出新文件的路径,然后通过fs检查一下这个文件名是否已经存在了,如果不存在的话就通过writeFile创建。

js 复制代码
import { sep, join } from 'path'
ipcMain.on(ADD_FILE, (event, path, title, oldData) => {
    const newPath = `${path}${sep}${title}.md`
    const exists = fs.existsSync(newPath)
    if (exists) {
      event.sender.send(COMMON_ERROR, '文件已存在')
      return
    }
    fs.writeFile(newPath, '', { encoding: 'utf8' }, (err) => {
      if (!err) {
        const newData = [...oldData]
        addChildNode(newData, path, { title: `${title}.md`, key: newPath, isLeaf: true })
        event.sender.send(UPDATE_TREE, newData)
      } else {
        console.log('err', err)
        event.sender.send(COMMON_ERROR_LOG, err)
      }
    })
})

注意一点writeFile创建完之后,文件确实在文件系统存在了,但是在页面上的文件树还没有更新,我们得把新创建的节点添加到treeData中,主要关注的是addChildNode这个函数。

js 复制代码
// 给定一个父节点 key,在父节点下增加子节点
export const addChildNode = (treeData, parentKey, newNode) => {
  const parentNode = findNodeByKey(treeData, parentKey)
  if (parentNode) {
    parentNode.children.push(newNode)
  } else {
    console.error('Parent node with key', parentKey, 'not found.')
  }
}

// 给定一个节点 key 找到该节点
export const findNodeByKey = (nodes, key) => {
  for (let node of nodes) {
    if (node.key === key) {
      return node
    }
    if (node.children) {
      const foundNode = findNodeByKey(node.children, key)
      if (foundNode) {
        return foundNode
      }
    }
  }
  return null
}

上面两个辅助函数帮我们在指定的父节点下插入子节点,插入完成之后通知渲染进程更新即可。

创建文件夹

创建文件夹的流程基本一样,只不过是创建的APIwriteFile换成了mkdir而已,具体代码如下:

js 复制代码
ipcMain.on(ADD_FOLDER, (event, path, title, oldData) => {
    const newPath = `${path}${sep}${title}`
    const exists = fs.existsSync(newPath)
    if (exists) {
      event.sender.send(COMMON_ERROR, '文件夹已存在')
      return
    }
    fs.mkdir(newPath, {}, (err) => {
      if (!err) {
        const newData = [...oldData]
        addChildNode(newData, path, { title: `${title}`, key: newPath, children: [] })
        event.sender.send(UPDATE_TREE, newData)
      } else {
        console.log('err', err)
        event.sender.send(COMMON_ERROR_LOG, err)
      }
    })
})

重命名

重命名操作的交互跟创建的交互是一样,主要也是关注标题就可以了。来看一下重命名具体做的事情。需要根据文件/文件夹的完整路径中找出旧名字,然后把新名字拼在完整路径上就可以。这里可以根据分割符path.sep来分割完整路径,最后一项就是旧名字。

js 复制代码
  ipcMain.on(RENAME, (event, path, title, oldData, selectNode) => {
    const arr = path.split(sep)
    arr.pop()
    if (selectNode.isLeaf) {
      arr.push(`${title}.md`)
    } else {
      arr.push(title)
    }
    let newPath = `${sep}${join(...arr)}`
    const exists = fs.existsSync(newPath)
    if (exists) {
      event.sender.send(COMMON_ERROR, '文件/文件夹重名')
      return
    }
    fs.rename(path, newPath, (err) => {
      if (err) {
        console.log('err', err)
        event.sender.send(COMMON_ERROR_LOG, err)
      } else {
        const newData = [...oldData]
        updateNodeValue(newData, path, 'title', selectNode.isLeaf ? `${title}.md` : title)
        updateNodeValue(newData, path, 'key', newPath)
        event.sender.send(UPDATE_TREE, newData)
      }
    })
  })

同理,更新完文件系统的名字之后,我们还需要更新界面的数据。这里实现了一个updateNodeValue函数,来更新指定节点的某一个属性值。这里直接复用findNodeByKey这个函数,注意的是更新完title属性之后也需要更新key属性。

js 复制代码
export const updateNodeValue = (treeData, nodeKey, nodeLabel, newValue) => {
  const node = findNodeByKey(treeData, nodeKey)
  if (node) {
    node[nodeLabel] = newValue
  }
}

删除

对于删除来说,会弹一个二次确认,点击确定之后就会真正进入删除的流程。删除的时候也可以使用fs模块封装好的api,删除文件的时候用unlinkSync,删除文件夹用rmSync,因为删除文件夹需要递归删除的,即实现类似 rm -rf 的功能,所以要加上一个{ recursive: true }参数。

js 复制代码
  const deleteFileOrFolder = (event, path, selectNode, oldData) => {
    try {
      if (selectNode.isLeaf) {
        fs.unlinkSync(path)
      } else {
        fs.rmSync(path, { recursive: true })
      }
      const newData = [...oldData]
      deleteNodeByKey(newData, path)
      event.sender.send(UPDATE_TREE, newData)
    } catch (err) {
      console.log('err', err)
      event.sender.send(COMMON_ERROR_LOG, err)
    }
  }

  ipcMain.on(DELETE, deleteFileOrFolder)

删除完文件系统的时候别忘了删除界面上的,这里实现了一个deleteNodeByKey函数。主要也是通过递归找到对应key的节点,然后把它删除。

js 复制代码
export const deleteNodeByKey = (treeData, nodeKey) => {
  // 遍历树
  for (let i = 0; i < treeData.length; i++) {
    const node = treeData[i]
    if (node.key === nodeKey) {
      // 如果当前节点是要删除的节点,直接从树的数组中删除该节点
      treeData.splice(i, 1)
      // 删除成功后退出函数
      return
    }
    if (node.children) {
      // 如果当前节点有子节点,递归删除
      deleteNodeByKey(node.children, nodeKey)
    }
  }
}

复制

复制的时候我这里的交互是把源文件/文件夹,复制到某一个文件夹下:

所以需要有一棵这样的树来选择目标文件夹,这棵树也是基于treeData构建出来的,取的是treeData的非叶子节点,即文件夹节点。

js 复制代码
export const getNonLeafNodesFromArray = (treeData) => {
  const nonLeafNodes = []

  treeData.forEach((tree) => {
    if (tree.children && tree.children.length > 0) {
      // 如果有子节点,则将当前节点加入新树,并递归获取非叶子节点
      nonLeafNodes.push({
        ...tree,
        children: getNonLeafNodesFromArray(tree.children)
      })
    }
  })

  return nonLeafNodes.length > 0 ? nonLeafNodes : []
}

首先复制的时候需要判断目标文件夹下有没有跟源文件同名的,如果有,则需要把名称加上一个唯一标识,为了方便我这里使用的是时间戳。其次,如果复制的是文件夹,文件夹下的文件路径中其实会包含文件夹的路径信息,所以这里也需要同步修改一下。然后判断一下目标文件夹是不是源文件夹的子文件夹,如果是,则中断流程。最后使用fs-extra模块的copySync复制就好了,复制完之后调用addChildNode给页面插入新节点。

js 复制代码
  import fsExtra from 'fs-extra'
  const copy = (event, path, targetPath, selectNode, oldData) => {
    let newTitle = selectNode.title
    if (fs.existsSync(`${targetPath}/${newTitle}`)) {
      if (selectNode.isLeaf) {
        const extIndex = newTitle.lastIndexOf('.')
        newTitle = `${selectNode.title.substring(0, extIndex)}_${Date.now()}.md`
      } else {
        newTitle = `${selectNode.title}_${Date.now()}`
      }
    }

    const newNode = {
      title: newTitle,
      key: `${targetPath}${sep}${newTitle}`
    }
    if (selectNode.isLeaf) {
      newNode.isLeaf = true
    } else {
      newNode.children = cloneDeep(selectNode.children)
      //递归遍历,修改文件夹下的文件路径
      dfs(newNode.children, (node) => {
        node.key = node.key.replace(`${targetPath}/${selectNode.title}`, newNode.key)
      })
    }
    if (!selectNode.isLeaf) {
      // 如果目标文件夹是源文件夹的子文件夹,则不进行复制
      let newDestinationDir = newNode.key

      if (isSubdirectory(path, newDestinationDir)) {
        event.sender.send(COMMON_ERROR, '目标文件夹是源文件夹的子文件夹,无法复制!')
        return
      }
      // 使用 fs-extra 的 copy 方法复制文件夹
      try {
        fsExtra.copySync(path, newDestinationDir)
      } catch (error) {
        event.sender.send(COMMON_ERROR_LOG, error)
      }
    } else {
      try {
        const content = fs.readFileSync(path, { encoding: 'utf8' })
        fs.writeFileSync(newNode.key, content, { encoding: 'utf8' })
      } catch (error) {}
    }
    const newData = [...oldData]
    addChildNode(newData, targetPath, newNode)
    event.sender.send(UPDATE_TREE, newData)
    return { data: newData, success: true }
  }

  ipcMain.on(COPY, copy)

移动

移动的交互跟复制一样,对于移动这个操作,我这里是这样实现的,先复制一份,然后再把源文件删除。有了上面的铺垫之后就很简单了,5行代码搞定

js 复制代码
  ipcMain.on(MOVE, (event, path, targetPath, selectNode, oldData) => {
    const copyRes = copy(event, path, targetPath, selectNode, oldData)
    if (copyRes.success) {
      deleteFileOrFolder(event, path, selectNode, copyRes.data)
    }
  })

最后

刚开始的时候我是想着找一个开源组件来着,结果没找到合适的,就自己实现了一个,做完之后自己感觉是复习了一遍文件操作和树结构操作。后面将会介绍编辑器主体接入与图床实现,如果你觉得有意思的话,点点关注点点赞吧~

相关推荐
前端青山5 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
从兄6 小时前
vue 使用docx-preview 预览替换文档内的特定变量
javascript·vue.js·ecmascript
清灵xmf7 小时前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询
薛一半8 小时前
PC端查看历史消息,鼠标向上滚动加载数据时页面停留在上次查看的位置
前端·javascript·vue.js
过期的H2O29 小时前
【H2O2|全栈】JS进阶知识(四)Ajax
开发语言·javascript·ajax
MarcoPage9 小时前
第十九课 Vue组件中的方法
前端·javascript·vue.js
你好龙卷风!!!9 小时前
vue3 怎么判断数据列是否包某一列名
前端·javascript·vue.js
shenweihong11 小时前
javascript实现md5算法(支持微信小程序),可分多次计算
javascript·算法·微信小程序
巧克力小猫猿11 小时前
基于ant组件库挑选框组件-封装滚动刷新的分页挑选框
前端·javascript·vue.js
嚣张农民11 小时前
一文简单看懂Promise实现原理
前端·javascript·面试