业务分享-桌面端课件板书保存到本地 (内附画笔、橡皮擦、撤销demo)

237 阅读6分钟

image.png

涉及技术栈:Vue + Electron

本期分享的是偏业务性,各位彦祖看个单纯思路就好,可能在真实业务中写法各有千秋

画笔、橡皮擦、撤销功能简易 demo 请移步gitee.com/song-xinyua…

业务背景:

在课堂上,老师会通过课件进行讲解,并会有对应的笔记记录。为了方便学生课后复习,这些课件和笔记在课后将被保存并发送给学生进行留存。整个流程如图所示。

前提:

在开始之前,我们需要先普及一下Electron中的进程概念。

在Electron框架中,主进程(Main Process)和渲染进程(Renderer Process)是两个核心概念,每个都有不同的职责和特性。以下是它们的主要区别:

主进程(Main Process)

职责

负责创建和管理所有的渲染进程。
处理与操作系统相关的任务,如文件系统访问、原生菜单、对话框、剪贴板操作等。
负责应用的生命周期,管理应用事件(如启动、关闭等)。

运行环境

运行在Node.js环境中,因此具有完全的Node.js API访问权限。
可以使用Electron的主进程API。

与渲染进程的通信

 使用IPC(进程间通信)模块进行通信,主要通过`ipcMain`(主进程)和`ipcRenderer`(渲染进程)模块。
 可以通过`BrowserWindow`类创建并管理渲染进程。

单例

  Electron应用只有一个主进程,主进程启动后创建并管理多个渲染进程。

渲染进程(Renderer Process)

职责

负责处理应用的用户界面。
每个Electron窗口(如`BrowserWindow`)都运行在一个独立的渲染进程中。
渲染进程主要用于渲染网页和处理用户交互。

运行环境

运行在一个包含Node.js和Chromium环境的混合上下文中,因此可以同时访问Web APIs和Node.js APIs(通常为了安全会对Node.js访问进行限制)。
主要用于加载和渲染HTML、CSS和JavaScript文件。

与主进程的通信

使用`ipcRenderer`模块与主进程通信。
可以通过`remote`模块(注意:从Electron 10起该模块被弃用,推荐使用IPC模式)访问主进程的一些功能。

多实例

 每个`BrowserWindow`实例都会创建一个新的渲染进程,因此一个应用可以有多个渲染进程,每个窗口或标签页有单独的渲染进程。

 

彦祖耐心看一下,这个功能挺有意思的,也不用一个逻辑一个逻辑去理解去看,大概逻辑扫一眼,主要就是主进程和渲染进程通信一下,然后就是操作图层和笔记的保存

 

概述

  • 主进程:主要负责应用的后台逻辑和系统级操作,有且只有一个主进程,运行在Node.js环境。
  • 渲染进程:主要负责用户界面的渲染和用户交互,可能有多个渲染进程,每个窗口都有自己的渲染进程,运行在Web和Node.js混合环境。

 

代码笔记

1、做笔记后点击保存板书

image.png

页面 demo

这块就干了两个事儿,和主进程通信获取路径,然后触发事件

   <div
      class="common-wrap"
      @click="saveScreenshot"
    >
      <i class="common-icon scheduleGray"></i>
      <span class="font">保存板书</span>
    </div>
    import { ipcRenderer } from 'electron'
    mounted() {
      ipcRenderer.on('screenshot-saved', (event, folderPath) => {
        this.$emit('saveScreenshot', folderPath)
      });
    },
    
    methods: {
       saveScreenshot() {
        if (!this.courseDatas.length) {
          // 暂无课件导出
          this.$message.warning('暂无课件导出')
          return
        }
        ipcRenderer.send('save-screenshot', this.courseDatas)
      },
    }

 

主进程模块 handleIpcMain.js

ipcMain 和 ipcRenderer 是 Electron 的两个模块,用于在主进程和渲染进程之间进行通信。

// 主进程代码
import { ipcMain, dialog } from 'electron'
import path from 'path'


// handleIpcMain.js
const ipc = win => {


ipcMain.on('save-screenshot', async (event, courseDatas) => {
  try {
    // 打开一个对话框,让用户选择保存截图的目录
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openDirectory']
    });


    // 如果用户取消了选择,不执行任何操作
    if (result.canceled) return;


    // 获取用户选择的目录路径
    const savePath = result.filePaths[0];
    const folderPath = path.join(savePath);


    // 告知渲染进程,截图保存路径已生成
    event.sender.send('screenshot-saved', folderPath);
  } catch (error) {
    console.error('Error during save-screenshot process:', error);
  }
});
}
export default ipc

 

主进程模块 main.js

  • app:控制应用生命周期的模块。
  • BrowserWindow:创建和管理浏览器窗口的模块。
  • ipcMain:主进程中的事件模块,用于接受来自渲染进程的异步和同步消息。
  • screen:提供有关显示器的信息。
  • version:从 package.json 导入应用的版本信息。
  • ipc:导入自定义的 handleIpcMain 模块,用于处理 IPC 事件(需要自行实现)。

 

// main.js
import { app, BrowserWindow, ipcMain, screen } from 'electron'
const { version } = require('../../package.json')
import ipc from './handleIpcMain'
import path from 'path'
//创建窗口
async function createWindow() {
  const exeName = path.basename(process.execPath)


  app.setLoginItemSettings({
    // 设置为true注册开机自起
    openAtLogin: false,
    openAsHidden: false, //macOs
    path: process.execPath,
    args: ['--processStart', `"${exeName}"`],
  })


  const { width, height } = screen.getPrimaryDisplay().workAreaSize


  mainWindow = new BrowserWindow({
    width: width,
    height: height,
    resizable: false, // 是否可以改变窗口大小
    minimizable: true, // 是否可以最小化
    maximizable: true, //是否可以最大化
    useContentSize: true,
    frame: false, // 无边框窗口
    webPreferences: {
      enableRemoteModule: true,
      nodeIntegration: true,
      contextIsolation: false,
      defaultFontFamily: { sansSerif: 'Microsoft YaHei' },
      backgroundThrottling: false,
      webSecurity: false,
      webviewTag: true,
    },
    show: false,
    title:
      process.env.NODE_ENV !== 'development' ? `xxxx ${version}` : `xxxx ${version}-测试版`,
  })
  
  // 启用自定义的 IPC 事件处理
    ipc(mainWindow)
 }

 

2、路径选择、以便更快捷的找到对应数据

image.png

 

到这里就实现了第一步、点击保存板书,渲染进程与主进程通信,用户选择路径信息,主进程讲路径信息发送给渲染进程

 

导出事件主函数

主要是开发起来涉及到多个分散点,需要考虑到只提供一个相关逻辑仅供参考

 // 主动导出课件页 函数
  async saveScreenshot (folderPath) {
    let promises = []
    this.saveCanvasData().then(
      value => {
        // 函数存到stroe里面,记录是什么操作
        this.changeCourseState({ courseState: 'saved' })
        this.changeCourse(this.nowIndex)
      },
      error => { },
    )
    
    setTimeout(async () => {
        for (let i = 0; i < this.courseDatas.length; i++) {
          promises.push(this.saveShowScheduleImage(i, this.courseDatas[i], folderPath))
        }
        let resultLocal
        try {
          resultLocal = await Promise.all(promises)
        } catch (error) {
          resultLocal = []
        }
        if (resultLocal.length) {
            this.$message.success('导出成功')
        } else {
            this.$message.error('导出异常')
        }
      }, 300)
  },

 

导出主页面

canvasPanel 这个组件是 页脚

courseware-content.vue 这个组件是 课件主体

 

保存笔记数据 (saveCanvasData)

  • saveCanvasData 是一个保存用户笔记的函数,返回一个Promise对象。
  • 逻辑判定 this.currentPage && this.currentPage.state === false 确保当前页面存在且处于某种状态(比如未保存)。
  • 如果符合条件,程序会进行以下步骤:
    • preCourse 用于存储当前的课程 ID。
    • cutScreenHide() 被调用以执行某种屏幕相关的操作(可能是暂停或隐藏)。
    • 使用 this.$nextTick 延迟执行,以确保 DOM 完成更新后再继续。
    • setTimeout 确保在 200 毫秒后调用 this.$refs.canvasPanel.saveData 保存数据。在保存数据成功后,调用 cutScreenShow() 恢复之前屏幕的操作状态。
  • 若不符合条件,pageTurnDiableFlag 被重置为 false 并立刻 resolve

辅助屏幕控制 (cutScreenHide)

    1. 用于在特定操作时隐藏某些屏幕显示。
    2. this.noscreening 被设置为 false,随后2秒后定时器将 this.noscreening 设为 true

更改课程 (changeCourse)

    1. changeCourse 方法用于切换当前显示的课程。
    2. this.setNowIndex({ nowIndex: index }):更新当前的课程索引。
    3. 使用 this.$nextTick 确保变更后的 DOM 更新完成,清除当前画布内容(通过调用 this.$refs.canvasPanel.clear())。
    4. 加载新课程的数据:this.$refs.canvasPanel.showDataById(currentCourse, ...),在数据加载完毕后,调用回调函数重置 pageTurnDiableFlag

 

<canvasPanel ref="canvasPanel" @saveScreenshot="saveScreenshot" />


<courseware-content ref="coursewareContent" />


 // 截图函数封装
saveShowScheduleImage(screenIndex, dirName, folderPath) {
    // screenIndex:当前屏幕的索引。
    // dirName:存放截图的目录名称。
    // folderPath:存放截图的文件路径。
    
  return new Promise((resolve, reject) => {
  // 从数组courseDatas中获取当前索引对应的页面信息
    let page = this.courseDatas[screenIndex] 
  // 获取课程 ID。
    let picId = page.courseId
    // page.imgurl:获取图片的 URL
    let url = page.imgurl
    // 获取页面 ID。
    let pageId = page.pageId
    // 生成图片名称,对应当前的索引,确保名称在两位数时用前导零补齐
    let picName = `0${String(screenIndex + 1).padStart(2, '0')}.png`
    // 调用截图方法:
    this.$refs.canvasPanel.saveShowScheduleImage(
      picId,
      url,
      dirName,
      picName,
      localPath => {
        this.changeCourseState({
          courseState: true,
          screenIndex: screenIndex,
        })
        let result = null
        if (localPath) {
          result = {
            id: pageId,
            localPath: localPath,
          }
        }
        resolve(result)
      },
      () => {
        resolve(null)
      },
      folderPath,
      this.$route.query.lesson_name,
    )
  })
},


// 保存笔记数据
saveCanvasData() {
  return new Promise((resolve, reject) => {
    if (this.currentPage && this.currentPage.state === false) {
      let preCourse = this.currentPage.courseId
      this.cutScreenHide()
      this.$nextTick(() => {
        setTimeout(() => {
          this.$refs.canvasPanel.saveData(preCourse, () => {
            this.cutScreenShow()
            resolve()
          })
        }, 200)
      })
    } else {
      this.pageTurnDiableFlag = false
      resolve()
    }
  })
},


cutScreenHide() {
  this.noscreening = false
  this.noscreeningTimer = window.setTimeout(() => {
    this.noscreening = true
  }, 3000)
},


    // 实际跳页逻辑
  changeCourse(index) {
    this.setNowIndex({ nowIndex: index })
    // TODO 预制互动信息读取
    this.$nextTick(() => {
      this.$refs.canvasPanel.clear()
      let currentCourse = this.currentPage.courseId
      this.$refs.canvasPanel.showDataById(currentCourse, res => {
        this.pageTurnDiableFlag = false // 是否可以翻页标志
      })
    })
  },

image.png

 

canvas.js

// canvas.js 页面 其实还是会讲这个方法拆出去,但是放到这儿会显得比较乱就这样写啦~
    import { getCurrentWindow } from '@electron/remote'
    this.win = getCurrentWindow() // window对象
    this.historyDatas = {} // 整个课件数据存储
    
  saveShowScheduleImage(id, url = null, dir, name, cb = null, errcb = null, folderPath, lesson_name) {
      id:图像对应的 ID。
      url:图像的 URL 地址(可选)。
      dir:保存图像的目录。
      name:图像文件名。
      cb:成功回调函数(可选)。
      errcb:失败回调函数(可选)。
      folderPath:文件保存路径。
      lesson_name:课程名称。


    let pageData = this.historyDatas[id]
    if (pageData && pageData.screenData) {
      let screenData = pageData.screenData
      if (screenData) {
        this.saveShowSchedulePng(screenData, null, dir, name, cb, errcb, folderPath, lesson_name)
      }
    } else if (url) {
      this.saveShowSchedulePng(null, url, dir, name, cb, errcb, folderPath, lesson_name)
    } else {
      if (errcb) {
        errcb()
      }
    }
  }
  async saveShowSchedulePng(data, url, lectureTitle, baseFileName, cb, errcb, baseFolder, lesson_name) {
    const name = lesson_name || ''
    try {
      const folderName = `${name}板书-${this.formatDate(new Date())}`;
      const folderPath = path.resolve(baseFolder, folderName);
  
      if (!fs.existsSync(folderPath)) {
        fs.mkdirSync(folderPath, { recursive: true });
      }
  
      let fileIdx = baseFileName ? parseInt(baseFileName) : 1;
      let fileName = `0${String(fileIdx).padStart(2, '0')}.jpg`;
      let imagePath = path.resolve(folderPath, fileName);
  
      // Ensure unique filename
      let suffix = 0;
      while (fs.existsSync(imagePath)) {
        suffix++;
        fileName = `0${String(fileIdx).padStart(2, '0')}(${suffix}).jpg`;
        imagePath = path.resolve(folderPath, fileName);
      }


      if (url && !data) {
        try {
          await this.downloadImage(url, imagePath);
          if (cb) cb(imagePath);
        } catch (err) {
          console.error(`Error downloading image from ${url}: ${err.message}`);
          if (errcb) errcb(err);
        }
      } else if (data) {
        const dataBuffer = data.toJPEG(100);
        fs.writeFileSync(imagePath, dataBuffer);
        if (cb) cb(imagePath);
      } else {
        if (cb) cb(null);
      }
    } catch (err) {
      console.error(`Error in savePng: ${err.message}`);
      if (errcb) errcb(err);
    }
  }


  saveData(id, cb, index) {
    // 保存笔记和课件背景数据,向课件发送消息
    logger.info('保存笔记id', id)
    let currentPageData = {
      historyIndex: this.historyIndex,
      eles: this.eles,
      screenData: null,
      index,
    }


    this.historyDatas[id] = currentPageData
    if (currentPageData.index > 3) {
      for (let i = 1; i < currentPageData.index - 2; i++) {
        Object.keys(this.historyDatas).forEach(item => {
          if (this.historyDatas[item].index === i) {
            this.historyDatas[item].eles = null
          }
        })
      }
    }
    let callback = cb || null
    this.win.capturePage()
      .then(e => {
        currentPageData.screenData = e
        if (callback) {
          callback()
        }
      })
      .catch(err => {
        logger.error('截图失败', err)
        logger.error('截图失败', JSON.stringify(err))
        if (callback) {
          callback()
        }
      })
  }
  showDataById(id) {
    logger.info('打开笔记id', id)
    let currentPageData = this.historyDatas[id] || {}
    if (isNaN(this.historyIndex)) {
      this.historyIndex = -1
    }
  }

 

store.js

// store   courseware.js
import _ from 'lodash'


const state = {
  currentCourseData: {}, // 所有的课件信息对象  { "currentLectureId": { 'course': [{ lectureUrl, name, blank, courseId, state, pagetype }, {}]} , 'blankNum': number, 'isold': true }
  currentLectureId: 'init', // 标识当前课件是allCourseData的哪一个对象
  nowIndex: -1, // 标识当前页是课件的那一页
}


changeCourseState({ commit }, payload) {
  commit(mutTypes.CHANGE_COURSESTATE, payload)
},


// 修改当前课件页笔记存储状态
[mutTypes.CHANGE_COURSESTATE](state, { courseState, screenIndex }) {
  const allDataTemp = _.cloneDeep(state.currentCourseData)
  const tmpCurrentCourse = allDataTemp[state.currentLectureId]
  let tmpIndex = state.nowIndex
  if (screenIndex !== undefined) {
    tmpIndex = parseInt(screenIndex)
  }
  if (tmpCurrentCourse && tmpCurrentCourse['course']) {
    tmpCurrentCourse['course'][tmpIndex].state = courseState
    state.currentCourseData = allDataTemp
  }
}

 

3、查看保存文件、以当前课件+时间命名

image.png

 

4、正确展示所有课件和笔记板书、可重复保存

image.png

到此结束,各位彦祖 多嘴一句electron还是很强大的,他有着强大的生态系统跨平台支持强大的生态系统多进程架构高性能 还等什么呢彦祖,搞起来