前言

公众号:【可乐前端】,期待关注交换,分享一些有意思的前端常识

平时记笔记的时候个别应用VsCodemarkdown,不过在本地写的话上传图片就须要搭配图床来应用,不然的话这种markdown公布到其余中央就有问题。想着能折腾就折腾的心态,罗唆就本人实现一个好了。须要实现的性能如下:

  1. 资源管理器——包含文件/文件夹的:

    • 创立
    • 删除
    • 复制
    • 挪动
    • 重命名
  2. 接入掘金markdown编辑器组件并拓展,数据同步到本地
  3. 基于GitHub实现图床,配合CDN减速

这篇文章会具体介绍第一点,话不多说,让咱们立即开始~

读取本地资源

首先刚关上编辑器的时候须要用户抉择一个文件夹,能够把这个文件夹了解为后续操作的根目录。

抉择文件夹这里用到了electrondialog模块,应用dialog.showOpenDialog这个api就能够唤起本地的文件对话框,让用户抉择文件或者文件夹。拿到抉择的文件夹之后,须要递归获取上面的子文件夹和文件,因为咱们实现的是一个markdown编辑器,所以我在取文件的时候只取了md文件。组装成数构造之后传给渲染过程,而后渲染过程再以树的模式渲染进去。

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

  const ipcRenderer = window.electron.ipcRenderer  const handleSelectFolder = () => {    ipcRenderer.send(OPEN_FILE_DIALOG)  }

在主过程中监听这个事件,在用户抉择完文件夹后,能够拿到文件夹的门路。而后实现一个generateTree函数,把获取到的根目录递归解决,组装成一个树结构。

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组件就能够很快的把一棵树渲染进去。

  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组件,只须要动静调整被包裹组件的地位,也能够实现每一个节点的右键菜单性能。

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

  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就会被触发了。

创立

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

const path = selectNode.keyipcRenderer.send(action, path, title, treeData, selectNode)

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

  • selectNode:右键菜单触发的节点
  • action:对应的操作——创立、删除、复制等
  • path:节点在文件系统的门路,同时作为节点的id
  • title:题目
  • treeData:以后的树数据

创立文件

主过程接管到这个事件之后,path参数就是行将被创立的文件的父文件夹门路(因为咱们只能在文件夹下创立文件)。咱们能够拼出新文件的门路,而后通过fs检查一下这个文件名是否曾经存在了,如果不存在的话就通过writeFile创立。

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这个函数。

// 给定一个父节点 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而已,具体代码如下:

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来宰割残缺门路,最初一项就是旧名字。

  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属性。

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 }参数。

  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的节点,而后把它删除。

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的非叶子节点,即文件夹节点。

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给页面插入新节点。

  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行代码搞定

  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)    }  })

最初

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