從零開始用 electron 手擼一個截圖工具

徐健本尊發表於2018-10-08

最近在嘗試利用 electron 將一個 web 版的聊天工具包裝成一個桌面 APP。作為一個聊天工具,截圖可以說是一個必備功能了。不過遺憾的是沒有找到很成熟的庫來用,也可能是開啟方式不對,總之呢沒看到現成的,於是就想從頭擼一個簡單的截圖工具。下面就進入正題吧!


思路

electron 提供了擷取螢幕的 API,可以輕鬆的獲取每個螢幕(存在外接顯示器的情況)和每個視窗的影像資訊。

  1. 把圖片擷取出來,然後建立一個全屏的視窗蓋住整個螢幕,將擷取的圖片繪製在視窗上,然後再覆蓋一層黑色半透明的元素,看起來就像螢幕定住了一樣;
  2. 在視窗上增加互動制作選區的效果;
  3. 點選確定,利用 canvas 對應選區的位置擷取圖片內容,寫入剪貼簿和儲存圖片。

搭建專案

首先建立 package.json 填寫專案的必要資訊, 注意 main 為入口檔案。

{
  "name": "electorn-capture-screen",
  "version": "1.0.0",
  "main": "main.js",
  "repository": "https://github.com/chrisbing/electorn-capture-screen.git",
  "author": "Chris",
  "license": "MIT",
  "scripts": {
    "start": "electron ."
  },
  "dependencies": {
    "electron": "^3.0.2"
  }
}
複製程式碼

建立 main.js, 程式碼來自 electron 官方文件

const { app, BrowserWindow, ipcMain, globalShortcut } = require('electron')
const os = require('os')

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win

function createWindow() {
    
    // 建立瀏覽器視窗。
    win = new BrowserWindow({ width: 800, height: 600 })

    // 然後載入應用的 index.html。
    win.loadFile('index.html')

    // 開啟開發者工具
    win.webContents.openDevTools()

    // 當 window 被關閉,這個事件會被觸發。
    win.on('closed', () => {
        // 取消引用 window 物件,如果你的應用支援多視窗的話,
        // 通常會把多個 window 物件存放在一個陣列裡面,
        // 與此同時,你應該刪除相應的元素。
        win = null
    })
}

// Electron 會在初始化後並準備
// 建立瀏覽器視窗時,呼叫這個函式。
// 部分 API 在 ready 事件觸發後才能使用。
app.on('ready', createWindow)

// 當全部視窗關閉時退出。
app.on('window-all-closed', () => {
    // 在 macOS 上,除非使用者用 Cmd + Q 確定地退出,
    // 否則絕大部分應用及其選單欄會保持啟用。
    if (process.platform !== 'darwin') {
        app.quit()
    }
})

app.on('activate', () => {
    // 在macOS上,當單擊dock圖示並且沒有其他視窗開啟時,
    // 通常在應用程式中重新建立一個視窗。
    if (win === null) {
        createWindow()
    }
})
複製程式碼

建立 index.html, html 中放了一個按鈕, 用來觸發截圖操作

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
</head>
<body>
<button id="js-capture">Capture Screen</button>
<script>
    const { ipcRenderer } = require('electron')

    document.getElementById('js-capture').addEventListener('click', ()=>{
        ipcRenderer.send('capture-screen')
    })

</script>
</body>
</html>

複製程式碼

這樣一個簡單的 electron 專案就完成了, 執行 yarn start 或者 npm start 即可看到一個視窗, 視窗中有一個按鈕

從零開始用 electron 手擼一個截圖工具

觸發截圖

截圖是一個相對獨立的功能, 並且有可能會有全域性快捷鍵以及選單觸發等脫離視窗的情況, 所以截圖的觸發應該放在 main 程式中來實現

在 renderer 程式中可以通過 ipc 通訊來完成, 在頁面的程式碼中使用 ipcRenderer 傳送事件, 而在 main 中使用 ipcMain 接收事件

// index.html
	const { ipcRenderer } = require('electron')

	document.getElementById('js-capture').addEventListener('click', ()=>{
		ipcRenderer.send('capture-screen')
	})
複製程式碼

在 main 程式中接收 capture-screen 事件

// main.js

// 接收事件
ipcMain.on('capture-screen', captureScreen)
複製程式碼

同時加入全域性快捷鍵觸發和取消截圖

// main.js

// 註冊全域性快捷鍵
// globalShortcut 需要在 app ready 之後
globalShortcut.register('CmdOrCtrl+Shift+A', captureScreen)
globalShortcut.register('Esc', () => {
    if (captureWin) {
        captureWin.close()
        captureWin = null
    }
})
複製程式碼

通過快捷鍵和事件來觸發截圖方法 captureScreen, 接下來實現這個方法來建立一個截圖視窗

建立截圖視窗

截圖視窗是要建立一個全屏的視窗, 並且把螢幕圖片繪製在視窗上, 再通過滑鼠拖拽等互動操作選出特定區域的影像.

第一步是要建立視窗

// main.js
let captureWin = null

const captureScreen = (e, args) => {
    if (captureWin) {
        return
    }
    const { screen } = require('electron')
    let { width, height } = screen.getPrimaryDisplay().bounds
    captureWin = new BrowserWindow({
        // window 使用 fullscreen,  mac 設定為 undefined, 不可為 false
        fullscreen: os.platform() === 'win32' || undefined, // win
        width,
        height,
        x: 0,
        y: 0,
        transparent: true,
        frame: false,
        skipTaskbar: true,
        autoHideMenuBar: true,
        movable: false,
        resizable: false,
        enableLargerThanScreen: true, // mac
        hasShadow: false,
    })
    captureWin.setAlwaysOnTop(true, 'screen-saver') // mac
    captureWin.setVisibleOnAllWorkspaces(true) // mac
    captureWin.setFullScreenable(false) // mac

    captureWin.loadFile(path.join(__dirname, 'capture.html'))

    // 除錯用
    // captureWin.openDevTools()

    captureWin.on('closed', () => {
        captureWin = null
    })

}
複製程式碼

視窗需要覆蓋全屏, 並且完全置頂, 在 windows 下可以使用 fullscreen 來保證全屏, Mac 下 fullscreen 會把視窗移到單獨桌面, 所以採用了另外的辦法, 程式碼註釋上標註了不同系統的相關選項, 具體內容可以檢視文件

注意這裡視窗載入了另外一個 html 檔案, 這個檔案用來負責截圖和裁剪的一些互動工作

capture.html

首先 html 結構

// capture.html

<div id="js-bg" class="bg"></div>
<div id="js-mask" class="mask"></div>
<canvas id="js-canvas" class="image-canvas"></canvas>
<div id="js-size-info" class="size-info"></div>
<div id="js-toolbar" class="toolbar">
    <div class="iconfont icon-zhongzhi" id="js-tool-reset"></div>
    <div class="iconfont icon-xiazai" id="js-tool-save"></div>
    <div class="iconfont icon-guanbi" id="js-tool-close"></div>
    <div class="iconfont icon-duihao" id="js-tool-ok"></div>
</div>
<script src="capture-renderer.js"></script>
複製程式碼

Bg : 截圖圖片 Mask : 一層灰色遮罩 Canvas : 繪製選中的圖片區域和邊框 Size info : 標識擷取範圍的尺寸 Toolbar : 操作按鈕, 用來取消和儲存等 capture-renderer.js : js 程式碼

@import "./assets/iconfont/iconfont.css";

html, body, div {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

.mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.6);
}

.bg {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}

.image-canvas {
    position: absolute;
    display: none;
    z-index: 1;
}

.size-info {
    position: absolute;
    color: #ffffff;
    font-size: 12px;
    background: rgba(40, 40, 40, 0.8);
    padding: 5px 10px;
    border-radius: 2px;
    font-family: Arial Consolas sans-serif;
    display: none;
    z-index: 2;
}

.toolbar {
    position: absolute;
    color: #343434;
    font-size: 12px;
    background: #f5f5f5;
    padding: 5px 10px;
    border-radius: 4px;
    font-family: Arial Consolas sans-serif;
    display: none;
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
    z-index: 2;
    align-items: center;
}

.toolbar .iconfont {
    font-size: 24px;
    padding: 2px 5px;
}
複製程式碼

各個元素基本為 absolute 定位, 由 js 控制位置 按鈕使用了 iconfont , 所有涉及到的資原始檔和完整專案可以到 GitHub - chrisbing/electorn-capture-screen: electron capture screen 中下載

截圖互動

從零開始用 electron 手擼一個截圖工具

完成的功能有擷取指定區域圖片, 拖拽移動和改變選區尺寸, 實時尺寸顯示和工具條

獲取螢幕截圖

// capture-renderer.js

const { ipcRenderer, clipboard, nativeImage, remote, desktopCapturer, screen } = require('electron')
const Event = require('events')
const fs = require('fs')

const { bounds: { width, height }, scaleFactor } = screen.getPrimaryDisplay()
const $canvas = document.getElementById('js-canvas')
const $bg = document.getElementById('js-bg')
const $sizeInfo = document.getElementById('js-size-info')
const $toolbar = document.getElementById('js-toolbar')

const $btnClose = document.getElementById('js-tool-close')
const $btnOk = document.getElementById('js-tool-ok')
const $btnSave = document.getElementById('js-tool-save')
const $btnReset = document.getElementById('js-tool-reset')

console.time('capture')
desktopCapturer.getSources({
    types: ['screen'],
    thumbnailSize: {
        width: width * scaleFactor,
        height: height * scaleFactor,
    }
}, (error, sources) => {
    console.timeEnd('capture')
    let imgSrc = sources[0].thumbnail.toDataURL()

    let capture = new CaptureRenderer($canvas, $bg, imgSrc, scaleFactor)
})
複製程式碼

screen.getPrimaryDisplay() 可以獲取主螢幕的大小和縮放比例, 縮放比例在高分屏中適用, 在高分屏中螢幕的物理尺寸和視窗尺寸並不一致, 一般會有2倍3倍等縮放倍數, 所以為了獲取到高清的螢幕截圖, 需要在螢幕尺寸基礎上乘以縮放倍數

desktopCapturer獲取螢幕截圖的圖片資訊, 獲取的是一個陣列, 包含了每一個螢幕的資訊, 這裡呢暫時只處理了第一個螢幕的資訊

獲取了截圖資訊後建立 CaptureRenderer 進行互動處理

CaptureRenderer

// capture-renderer.js
class CaptureRenderer extends Event {

    constructor($canvas, $bg, imageSrc, scaleFactor) {
        super()
 		  // ...

        this.init().then(() => {
            console.log('init')
        })
    }

    async init() {
        this.$bg.style.backgroundImage = `url(${this.imageSrc})`
        this.$bg.style.backgroundSize = `${width}px ${height}px`
        let canvas = document.createElement('canvas')
        let ctx = canvas.getContext('2d')
        let img = await new Promise(resolve => {
            let img = new Image()
            img.src = this.imageSrc
            if (img.complete) {
                resolve(img)
            } else {
                img.onload = () => resolve(img)
            }
        })

        canvas.width = img.width
        canvas.height = img.height
        ctx.drawImage(img, 0, 0)
        this.bgCtx = ctx
		  // ...
    }
	  
    // ...

    onMouseDrag(e) {
		  // ...
		  this.selectRect = {x, y, w, h, r, b}
        this.drawRect()
        this.emit('dragging', this.selectRect)
        // ...
    }

    drawRect() {
        if (!this.selectRect) {
            this.$canvas.style.display = 'none'
            return
        }
        const { x, y, w, h } = this.selectRect

        const scaleFactor = this.scaleFactor
        let margin = 7
        let radius = 5
        this.$canvas.style.left = `${x - margin}px`
        this.$canvas.style.top = `${y - margin}px`
        this.$canvas.style.width = `${w + margin * 2}px`
        this.$canvas.style.height = `${h + margin * 2}px`
        this.$canvas.style.display = 'block'
        this.$canvas.width = (w + margin * 2) * scaleFactor
        this.$canvas.height = (h + margin * 2) * scaleFactor

        if (w && h) {
            let imageData = this.bgCtx.getImageData(x * scaleFactor, y * scaleFactor, w * scaleFactor, h * scaleFactor)
            this.ctx.putImageData(imageData, margin * scaleFactor, margin * scaleFactor)
        }
        this.ctx.fillStyle = '#ffffff'
        this.ctx.strokeStyle = '#67bade'
        this.ctx.lineWidth = 2 * this.scaleFactor

        this.ctx.strokeRect(margin * scaleFactor, margin * scaleFactor, w * scaleFactor, h * scaleFactor)
        this.drawAnchors(w, h, margin, scaleFactor, radius)
    }

    drawAnchors(w, h, margin, scaleFactor, radius) {
        // ...
    }

    onMouseMove(e) {
        // ...
        document.body.style.cursor = 'move'
        // ...
    }

    onMouseUp(e) {
        this.emit('end-dragging')
        this.drawRect()
    }

    getImageUrl() {
        const { x, y, w, h } = this.selectRect
        if (w && h) {
            let imageData = this.bgCtx.getImageData(x * scaleFactor, y * scaleFactor, w * scaleFactor, h * scaleFactor)
            let canvas = document.createElement('canvas')
            let ctx = canvas.getContext('2d')
            ctx.putImageData(imageData, 0, 0)
            return canvas.toDataURL()
        }
        return ''
    }

    reset() {
        // ...
    }
}
複製程式碼

程式碼有點長, 由於篇幅的原因, 這裡只列出了關鍵部分, 完整程式碼請到 GitHub - chrisbing/electorn-capture-screen: electron capture screen 上檢視

初始化時儲存一份繪製了全部圖片的 canvas , 用來後續取選區部分圖片用

繪製過程中從 通過 canvas 中的 getImageData 獲取圖片內容 然後通過 putImageData 繪製到顯示 canvas 中

附加內容

在 CaptureRenderer 類中處理了圖片的選取. 還需要工具條和尺寸資訊

這一部分程式碼和圖片選取關係不是很大, 所以在外部單獨處理, 通過 CaptureRenderer 傳出的事件和一些屬性即可完成互動

// capture-renderer.js

let onDrag = (selectRect) => {
    $toolbar.style.display = 'none'
    $sizeInfo.style.display = 'block'
    $sizeInfo.innerText = `${selectRect.w} * ${selectRect.h}`
    if (selectRect.y > 35) {
        $sizeInfo.style.top = `${selectRect.y - 30}px`
    } else {
        $sizeInfo.style.top = `${selectRect.y + 10}px`
    }
    $sizeInfo.style.left = `${selectRect.x}px`
}
capture.on('start-dragging', onDrag)
capture.on('dragging', onDrag)

let onDragEnd = () => {
    if (capture.selectRect) {
        const { x, r, b, y } = capture.selectRect
        $toolbar.style.display = 'flex'
        $toolbar.style.top = `${b + 15}px`
        $toolbar.style.right = `${window.screen.width - r}px`
    }
}
capture.on('end-dragging', onDragEnd)

capture.on('reset', () => {
    $toolbar.style.display = 'none'
    $sizeInfo.style.display = 'none'
})
複製程式碼

移動過程中計算尺寸, 並且實時計算位置, 移動過程中隱藏工具條

重置選區時隱藏工具條和尺寸標識

儲存剪貼簿

// capture-renderer.js

const audio = new Audio()
audio.src = './assets/audio/capture.mp3'

let selectCapture = () => {
    if (!capture.selectRect) {
        return
    }
    let url = capture.getImageUrl()
    remote.getCurrentWindow().hide()

    audio.play()
    audio.onended = () => {
        window.close()
    }
    clipboard.writeImage(nativeImage.createFromDataURL(url))
    ipcRenderer.send('capture-screen', {
        type: 'complete',
        url,
    })
}

$btnOk.addEventListener('click', selectCapture)
複製程式碼

通過 nativeImage.createFromDataURL 建立圖片寫入剪貼簿, 通知 main 程式截圖完畢, 並附帶圖片的 base64 url, 然後關閉視窗

儲存到檔案

// capture-renderer.js
$btnSave.addEventListener(‘click’, () => {
    let url = capture.getImageUrl()

    remote.getCurrentWindow().hide()
    remote.dialog.showSaveDialog({
        filters: [{
            name: ‘Images’,
            extensions: [‘png’, ‘jpg’, ‘gif’]
        }]
    }, function (path) {
        if (path) {
            fs.writeFile(path, new Buffer(url.replace(‘data:image/png;base64,’, ‘’), ‘base64’), function () {
                ipcRenderer.send(‘capture-screen’, {
                    type: ‘complete’,
                    url,
                    path,
                })
                window.close()
            })
        } else {
            ipcRenderer.send(‘capture-screen’, {
                type: ‘cancel’,
                url,
            })
            window.close()
        }
    })
})
複製程式碼

利用 remote.dialog.showSaveDialog 選擇儲存檔名, 然後通過 fs 模組寫入檔案

最終整體目錄結構

├── index.html
├── lib // 截圖核心程式碼
│   ├── assets // font 和 聲音資源
│   ├── capture-main.js // main 中截圖部分程式碼
│   ├── capture-renderer.js  // 截圖互動程式碼
│   └── capture.html // 截圖 html
├── main.js 
└── package.json

複製程式碼

坑點總結

開發過程中主要遇到了幾個坑

首先全屏視窗,在 windows 和 Mac 上存在不同處理,而且 mac 上這個方案在網上沒有查到,最後翻閱文件無意中發現的

然後就是選區過程中,各個位置,選區的拖拽操作,需要大量時間除錯

再有就是開發過程中程式碼可能出錯,導致全屏視窗蓋在螢幕上無法去掉,最後通過 mac 觸控板五指張開的手勢隱藏了視窗才關掉了程式,?

相關文章