.desktop 桌面快捷_使用vue+electron实现一个桌面截图工具

项目背景

目前各个平台下都有很多优秀的截图软件可以选择,包括qq、微信等社交软件都可以实现快速截图。但除了截图外,我最常用的主要还有快速查看并复制界面颜色码。此前,在windows下我一直使用snipaste这个截图工具,小巧易用,想要的功能都有。现在除了偶尔用到office要切换到windows外,都在linux下折腾。但linux系统下,始终没有找到类似snipaste的截图工具,优秀的截图工具、取色器倒是有,但我又不愿意在电脑上装了这又装那。于是决定自己做一个,技术方面就采用vue和electron,一方面可以增加vue的实践经验,另一方面又掌握了electron的基本使用,一箭双雕。如此,具体做的是什么倒显得不那么重要了。后面的部分我将具体分享如何利用vue前端技术来实现这一桌面截图程序,并总结下这一过程中遇到的各种坑,这些坑对于初次接触electron的人来说,应该会很有帮助。

项目一览

先贴几张图,看下最后实现的样子。主要包含桌面颜色码显示和拷贝、撤销、恢复、矩形、圆形、箭头、直线、添加文本、涂鸦等功能,还有改变线型、颜色、另存为、设置等实现。

ea9c5da9b2d119067519a83ed3232650.png
黑色主题

d18448968dcad2f7c8ff435faffae0b6.png
白色主题

7ebd22e7687581dccfd400249caae34c.png
设置页

主要思路

首先,在应用启动的时候(或者说在按快捷键的时候)捕获整个桌面,渲染到一个canvas中;然后,创建一个全屏的透明窗口来响应鼠标事件,通过鼠标选取截图区域(也是一个canvas),并且可以任意拖拽这个区域,在鼠标抬起(mouseup)的时候,坐标点、宽高就确定了,然后将前一步渲染桌面的canvas对应位置、宽高的区域渲染到这个截图区域里。最后,显示一个工具条,通过点击工具条可以往截图区域里添加内容,然后将截图区域另存为或者复制到剪贴板,最终退出透明窗口。

具体实现

整个项目的目录结构如下图所示,主要包含主进程文件main.js,以及渲染进程文件src目录下的vue组件等,另外为了避免主进程文件过长,我将创建托盘的相关程序分离到了mainProcess文件夹下(这里有个坑,稍微介绍) 。下面从electron角度,先介绍渲染进程,再说主进程。

b23d59b2c973fc403ba6fb22dc15497e.png
目录结构

渲染进程

// src/App.vue

<template>
  <div id="layout">
    <div id="mask"
      @mousedown="onSelectRegion"
      :style="{cursor: completeSelectRegion? 'default':'crosshair'}">
    </div>

<!-- caputure region -->
    <div id="captureRegion"
      :style="captureRegionStyle"
      v-show="isCapture">
    </div>

<!-- 两个canvas,一个主显示,一个主辅助 -->
    <canvas id='display-canvas'
      ref='display'
      v-show="completeSelectRegion"
      :style="{
        position:'absolute',
        left:canvasX+'px',
        top:canvasY+'px',
        zIndex:999,
        pointerEvents:'none'}"
      >
    </canvas>

    <canvas id='assist-canvas'
      ref='assist'
      v-show="completeSelectRegion"
      :style="{
        position:'absolute',
        left:canvasX+'px',
        top:canvasY+'px',
        zIndex:999,
        cursor:'move'}"
      @mousedown="onDrag">
    </canvas>


<!-- 工具条 -->
    <ToolBar
      @initSelect="initSelect()"
      @closeDrag="canDrag=false"
      v-position="{x,y,width,height}"
      v-show="completeSelectRegion"
      :toolbarBottom="toolbarBottom"
      :canvasProps="{canvasX,canvasY,canvasWidth,canvasHeight}"
      :clipDesktop="clipDesktop"
    />
<!-- 颜色、位置提示框 -->
    <ColorTip v-show="!isCapture" v-if="showColorTip"/>
<!-- 桌面捕获 -->
    <canvas id="desktop-canvas" ref='desktop' :style="{visibility: win32? 'hidden':'visible'}"></canvas>
    <video id="video"></video>
  </div>
</template>

App.vue中将所有布局放在layout的盒子中 ,mask为透明遮罩层,用来响应鼠标选取截图区域的事件onSelectRegion,同时给截图区域CaptureRegion绑定样式。这里利用了两个 canvas元素,一个主显示,一个主辅助来实现对截图区域的显示和操作,辅助canvas用来响应鼠标事件,mouseup时将响应结果绘制到显示canvas上。 这里CaptureRegion及两个canvas都为绝对定位,且起点坐标、宽高一致(canvas起点x,y实际要多一个CaptureRegionborder宽度,而宽高实际要少两个border宽度,可通过vue计算属性computed来实现)。

为什么不直接响应鼠标生成canvas ,反而需要一个CaptureRegion的div?
——因为直接生成canvas在选区时会有拉扯感,canvas元素就跟img一样,鼠标来拉取时会进行拖拽。所以,这里利用额外的一个div来得到所需要的样式,在mouseup时再赋给canvas,就可以使得截图选区变得顺滑。

接下来是工具条ToolBar组件,通过指令v-show来控制其在选区完成时显示,再自定义一个v-position指令来根据选区的坐标位置来控制其显示的位置。
然后是颜色码显示组件ColorTipv-show让其在截图过程中隐藏,非截图时显示。
最后是捕获桌面所需的两个元素,一个canvas和一个video,绝对定位且与屏幕同宽高,用以渲染桌面图像且对用户不可见。

整个App.vue就是一个透明窗口。在组件mounted的时候进行屏幕捕获captureScreen。屏幕捕获主要利用navigator.mediaDevices.getUserMedia()来实现,electron提供了一个desktopCapture方法来捕获桌面源,但正如下代码注释的一样,老坑了,因为这个方法只在windows下有效(mac不知道是否有效),在linux下是不起作用的,但官方文档并没有说明,害我在切换平台的时候,就跟小朋友一样充满了无数问号???怎么连桌面都捕获不了了,这个截图工具没法做了。最后的办法是,linux下直接用navigator.mediaDevices.getUserMedia()就行。第二个坑是id,为了保证捕获的源是所需要的源,需要判断源的id,捕获的桌面源source的idscreenid不是一回事,对应的是source.display_id这个属性,坑中坑的是一个是String类型的,一个是number 类型。一旦id不正确就出不了画面,会报NotReadableError

 // src/utils/captureScreen.js

const { id,size } = remote.screen.getPrimaryDisplay()
export const captureScreen = () => {
  if(process.platform==='win32'){ //老坑:desktopCapture=>linux下无效
    desktopCapturer.getSources(
      { types: ['screen'],thumbnailSize:{width:0,height:0}}
    ).then(async sources => {
      for (let source of sources) {
        // console.log(typeof source.display_id) //坑
        // console.log(typeof id) //坑
        if (source.display_id === id.toString()) {
          const stream = await navigator.mediaDevices.getUserMedia({
            audio: false,
            video: {
              mandatory: {
                chromeMediaSource: 'desktop',
                chromeMediaSourceId: source.id,
                minWidth: size.width,
                maxWidth: size.width,
                minHeight: size.height,
                maxHeight: size.height
              }
            }
          })
          handleStream(stream)
        }
      }
    })

   } else { //linux
     navigator.mediaDevices.getUserMedia({
       audio: false,
       video: {
         mandatory: {
           chromeMediaSource: 'desktop',
           // chromeMediaSourceId: source.id, //出现NotReadableError,是因为getPrimaryDisplay()返回的id不一致,不做多屏幕直接去掉就可以了
           minWidth: size.width,
           maxWidth: size.width,
           minHeight: size.height,
           maxHeight: size.height,
         },
       }
     }).then( stream => handleStream(stream))
   }
}

获取到桌面源(视频流stream)后,将其绘制到canvas元素中,这里可以直接传递videocanvas的上下文绘制视频帧 ,但是我这里先把它转换成了位图,再传递给canvas上下文的drawImage,这样可以降低canvas绘制的延迟(可参见MDN对canvas图片源的说明)

使用图像 Using images​developer.mozilla.org
1b4bcff881bc8d1e0b0c8d8ffbb70bab.png
// src/utils/captureScreen.js

const handleStream = (stream) => {
  const video = document.getElementById('video')
  video.srcObject = stream
  video.onloadedmetadata = () => {
    video.play()

    let canvas = document.getElementById('desktop-canvas')
    canvas.width = size.width
    canvas.height = size.height
    canvas.style.width = size.width+'px'
    canvas.style.height = size.height+'px'

    const ctx = canvas.getContext('2d')

    // ctx.drawImage(video,0, 0)
    ctx.clearRect(0,0,size.width,size.height)
    createImageBitmap(video).then(bmp => { //转为bitmap,可以提高性能,降低canvas渲染延迟
      ctx.drawImage(bmp, 0, 0)
      stream.getTracks()[0].stop() //关闭视频流,序号是反向的,此处只有一个所以是0
    })
  }
}

渲染进程中的最后一个坑,是canvas在高清屏幕下渲染模糊的问题,这个问题需要通过window.devicePixelRatio来解决,网上相关资料挺多的,需要时可自行百度。通常其值为1,我电脑屏幕为1.25就产生了模糊。

关于渲染进程的介绍以及坑点就说到这里,下面介绍主进程中的经验和坑点。

主进程

// main.js

app.on('ready', () => {
    const { width, height } = screen.getPrimaryDisplay().size
    //------------------------------
    // 托盘图标和右键菜单
    createTray()

    //-----------------------------
    //主窗口
    createMainWindow(width,height)

    //----------------------------
    //设置窗口
    createSettingsWindow()

    //-------------
    //设置开机是否自启动
    ipcMain.on('set-autostart',(event,{autostart})=>{
        const exeName = path.basename(process.execPath)
        app.setLoginItemSettings({
            openAtLogin: autostart, //boolean
            openAsHidden:false,
            path: process.execPath,
            args: [
                '--processStart', `"${exeName}"`,
                ]
            })
        }
    )
})

主进程主要完成了托盘的创建、主窗口(截图的透明窗口)的创建、设置窗口的创建以及一些全局快捷键globalShortcut的绑定。先说托盘程序如下,其中有一个坑两个注意点:
一坑是文件路径,如果这部分代码依然放在主进程文件main.js中还好,但将其分离出来后,加载的资源路径就会出问题,比如托盘图标路径,如果是在开发环境下,要将其写成相对于main.js的绝对路径(注意是绝对路径),就是依然把它看作是在main.js中。但是,在打包的时候,这个路径就不能这么写了,又要改回到相对于其自己的绝对路径,不然打包后会提示找不到图片资源。
两个注意点,其一是一定要将tray提升为全局变量let tray=null,否则会出现托盘异常退出(就是托盘图标突然溜了溜了...),tray会被当做垃圾回收。 其二是,windows下的托盘菜单是通过右键点击出现的,Linux下直接点击左键就会出现。所以给tray绑定click事件需要区分platform

// mainProcess/appTray.js

let tray = null//提升为全局变量,否则可能出现托盘异常退出,被当作垃圾回收
const createTray = ()=>{
  // tray = new Tray(path.join(__dirname,'./mainProcess/assets/images/orchid.ico')) 
  //注意:开发环境下路径是相对于main.js而言的绝对路径,只是从main.js分离出来而已
 let ico = process.platform==='win32'
  ? path.join(__dirname,'./assets/images/orchid.ico')
  : path.join(__dirname,'./assets/images/orchid.png')
  tray = new Tray(ico) //注意:打包时路径就是自己的绝对路径。
  let settingsIcon = nativeImage.createFromPath(path.join(__dirname,'./assets/images/settings.png'))
  let exitIcon = nativeImage.createFromPath(path.join(__dirname,'./assets/images/exit.png'))
  const menuTemplate = [
      {
        label: '应用设置',
        type: 'normal' ,
        icon:settingsIcon,
        click:()=>{
            ipcMain.emit('open-settings-window')
        }
      },
        {type: 'separator'},
        {label:"退出应用",role:"quit",icon:exitIcon},
    ]

  const contextMenu = Menu.buildFromTemplate(menuTemplate)
  tray.setToolTip('Orchid')
  tray.setContextMenu(contextMenu)

  if(process.platform==='win32'){//linux单击出现的是右键菜单
    tray.on('click',()=>{
      ipcMain.emit('capture')
    })
  }
}

再说说主窗口的创建,要实现一个透明窗口主要依靠窗口配置中{frame:false,transparent:true}两个配置参数实现,坑点在于windows和linux平台的差异 。主要有三个踩坑点:

// main.js

//主窗口 
const createMainWindow = (width,height)=>{
    // require('devtron').install()
    //-------------------------------
    const mainWindowConfig = {
        // width: 1600,
        // height: 800,
        width,
        height,
        resizable: false,
        movable: false,
        center:true,
        frame:false,
        transparent: true,//On Windows, does not work unless the window is frameless.
                          //在linux,必须在命令行中设置 --enable-transparent-visuals --disable-gpu来禁用GPU, 启用ARGB。
        // opacity: 0.3, //windows or mac
        fullscreen: process.platform==='win32', //linux下一定要设置为false,否则和show冲突,一打开就会进入全屏
        alwaysOnTop:process.platform==='win32', //linux 为true时,保存图片窗口会被掩盖,点不了
        skipTaskbar:true,
        hasShadow: false,
        webSecurity:false,
        webPreferences:{
            nodeIntegration:true
          },
        show:false,
        paintWhenInitiallyHidden:false, //启动时屏蔽ready-to-show事件
    }
    // const mainPath = "http://localhost:8080"
    const mainPath = `file://${path.join(__dirname,'./dist/index.html')}`
    let mainWindow = new BrowserWindow(mainWindowConfig)
    mainWindow.loadURL(mainPath)
    mainWindow.on('ready-to-show',()=>{
        mainWindow.show()
    })
    mainWindow.on('close',()=>{
        mainWindow = null
    })
}

其一,作为一个托盘工具,在应用启动的时候并不需要一开始就显示窗口,所以需要将show设置为false,意为窗口创建时不显示。但是为了整个过程中窗口显示更为自然,往往会给窗口绑定ready-to-show事件(官方文档也是这么推荐的),然后显示窗口,这样一开始应用就会打开窗口,即使show设置为了false。所以为了达到一开始隐藏窗口的目的,官方提供了paintWhenInitiallyHidden参数, 设置为false可以在应用启动时屏蔽ready-to-show事件。这样在windows下算是解决问题了,但是在linux下却根本不好使,linux下是单击启动程序,只要一点击应用图标,应用就启动了,同时总是会打开一个黑色背景的窗口。问号又来了,什么原因呢?经过不断尝试,问题出于fullscreen这个配置参数上,在windows下设置为true没毛病,但linux下,即使窗口设置为非show,依然会打开窗口。所以,linux下启动时要隐藏窗口一定不要设置fullscreentrue

其二,alwaysOnToplinux下不能为true,因为它是真真儿的alwaysOnTop,其他窗口打开后点都点不着,这样截图后需要另存,打开的文件窗口就无法进行操作;而windows下,alwaysOntop就是骗人的,即使设置为true,打开文件窗口依然可以进行操作。

其三,最后一个踩坑点,也是全过程中最让人懵逼的地方,就是透明窗口。首先一定要记住,开发模式下透明窗口是没效果的(乘以N)(这个不知道坑了多少人,害我一度怀疑自己代码写错了)。然后,Linux下透明窗口的实现需要禁用gpu,开启alpha通道,具体可见electron官方文档。

Frameless Window | Electron​www.electronjs.org

然而,linux下我并没有采取这个方案,因为我觉得不到万不得已禁用gpu始终不是一个好的选择,对于这个截图工具而言,还有一个可选的方案,就是放弃透明窗口,直接把整个桌面渲染到canvas中后,不将其隐藏,反正宽高同桌面的宽高看起来差别不大,所以App.vue中在渲染桌面的canvas中绑定了style{visibility: win32? 'hidden':'visible'}

electron-builder打包

附上我的打包配置,网上也有很多相关资料,可做参考。这里需要提醒的是:首先,打包时一定不要漏了主进程的资源文件,将其添加进files,渲染进程的资源文件不用多费心,因为vue基于webpack打包时,所有依赖的相关资源都打包进了dist目录中,所以渲染进程只需要在files中添加dist目录即可。
此外,打包的target尽量只写一个,否则可能造成体积叠加。虽然可以以数组的形式同时写多个 ,比如windows下"target":["nsis",'msi"],但我的实践是,打包体积是二者的和?!可能是我哪儿配置有误吧,感觉不应该是这样的 ,所以我采取的方案是一次只打包一个目标。
最后,linux下打包的桌面图标问题,即使配置项 icon配置对了,打包后应用依然会找不到图标文件,然后我根据Linux(本文都是基于Ubuntu) 下应用的桌面文件格式添加了desktop参数,将其中的Icon设置为应用安装好后的图标文件的位置,图标文件这才正常显示了,这个办法比较硬核,我想肯定有更好的解决办法吧。

(其中的mac配置,仅仅只是写上去了,图个完整和备用,至于能不能行我就不知道了,毕竟木有mac呀)

// package.json
"build": {
    "productName": "orchid",
    "appId": "orchid",
    "copyright": "Copyright@2020 ${author}",
    "directories": {
      "output": "build"
    },
    "extraResources": {
      "from": "./node_modules/bootstrap/dist/css/bootstrap.min.css",
      "to": "./app/mainProcess/assets/css/bootstrap.min.css"
    },
    "asar": false,
    "files": [
      "dist/**/*",
      "./main.js",
      "./mainProcess",
      "!./mainProcess/appWindow.js"
    ],
    "win": {
      "icon": "./src/assets/orchid256x256.ico",
      "target": "nsis",
      "artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
    },
    "nsis": {
      "oneClick": false,
      "perMachine": false,
      "allowElevation": true,
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true
    },
    "msi": {
      "artifactName": "${productName}-${version}-${platform}-${arch}.${ext}",
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true,
      "oneClick": false,
      "perMachine": false,
      "publish": "github",
      "runAfterFinish": false
    },
    "linux": {
      "icon": "./mainProcess/assets/images/orchid256x256.png",
      "target": "deb",
      "executableName": "orchid",
      "desktop": {
        "Name": "orchid",
        "Type": "Application",
        "Icon": "/opt/orchid/resources/app/mainProcess/assets/images/orchid256x256.png",
        "Categories": "Utility",
        "Terminal": false
      }
    },
    "mac": {
      "icon": "./src/assets/orchid.icns",
      "artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
    },
    "dmg": {
      "contents": [
        {
          "x": 380,
          "y": 280,
          "type": "link",
          "path": "/Applications"
        },
        {
          "x": 110,
          "y": 280,
          "type": "file"
        }
      ],
      "window": {
        "width": 400,
        "height": 400
      }
    },
    "publish": [
      "github"
    ]
  }

最后

整个项目介绍就到这里了,关于更详细的实现,可以参考github:https://github.com/YangShuangjie/orchid.git

水平有限,难免有误,错误的地方请批评指正!

Thanks !

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值