我正在参加跨端技术专题征文活动,详情查看:juejin.cn/post/710123…
介绍
相信每个人都用过微信电脑端的截图工具吧,想没想过有什么办法能让electron也实现这样一个工具呢?本期就跟大家具体说说,我是如何来对系统屏幕进行获取和区域裁切,快捷键设置,以及最终打包成这样一个截屏工具软件的。
在做之前先康康最终展示效果吧:
正文
原理逻辑
其实也并不难理解,首先是主窗体发起截图请求,然后会打开另一个负责截图透明且全屏的窗体,唤起后透明窗体会让electron截取整个屏幕发给逻辑页面,页面会把图片绘制满屏实现定格效果,然后再用canvas做绘制区域的生成,根据生成出的区域对刚才满屏图片进行裁切导出,最后传递给主窗体去显示还可以存到剪贴板种。
路由配置
本次开发使用了vite+vue3+electron,具体构建和配置,已经在上期【用Vite+Electron搞个码上掘金(前端代码编辑工具)】 已经说的非常明确了,本期就不再赘述。
唯一要说的是,本次需要要做两个窗体,一个主窗体,一个截屏窗体,索性就引入vue-router了。
先来安装一下:
pnpm add vue-router
但是要注意的是,我们需要把路由设置成hash模式,不然本地打包时会无法找到。
// router/index.js
import {createRouter,createWebHashHistory} from 'vue-router';
const routes = [
{ path: "/", redirect: "/home" },
{
path: "/home",
name: "home",
component: () => import("../pages/Home/Home.vue")
},
{
path: "/cut",
name: "cut",
component: () => import("../pages/Cut/Cut.vue")
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;
主窗体
我们先准备好主页面Home,里面很简单,就是放入一个按钮和img标签,img标签是为了接收后面从截屏窗体发来 GET_CUT_INFO
事件得到图片地址的,而按钮这里点击后我们会发出 OPEN_CUT_SCREEN
让主窗体去再创建一个截屏窗体的响应。
<!-- Home.vue -->
<script setup>
import { ref, onMounted } from "vue";
const { ipcRenderer } = window.require("electron");
const router = useRouter();
const previewImage = ref("");
async function handleCutScreen() {
await ipcRenderer.send("OPEN_CUT_SCREEN");
ipcRenderer.off("GET_CUT_INFO", getCutInfo);
ipcRenderer.on("GET_CUT_INFO", getCutInfo);
}
function getCutInfo(event, pic) {
previewImage.value = pic;
}
</script>
<template>
<div class="container">
<button @click="handleCutScreen">截屏</button>
<div>
<img :src="previewImage" style="max-width: 100%" />
</div>
</div>
</template>
创建主窗体时,千万别忘了判断其关闭后也要同时关闭后面的其他窗体。
// electron/main.js
const { app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')
const NODE_ENV = process.env.NODE_ENV
let mainWindow, cutWindow;
function closeCutWindow() {
cutWindow && cutWindow.close()
cutWindow = null;
}
function createWindow() {
// 创建浏览器窗口
mainWindow = new BrowserWindow({
width: 800,
height: 600,
focusable: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
contextIsolation: false,
},
// ...
})
mainWindow.loadURL(NODE_ENV === 'development'
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../dist/index.html')}`)
// 打开开发工具
if (NODE_ENV === "development") {
mainWindow.webContents.openDevTools()
}
mainWindow.on("closed", () => {
closeCutWindow()
})
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
下面我们再写入主窗体要唤起的事件,目的就是隐藏主窗体,然后去创建截屏窗体显示出来。
ipcMain.on('OPEN_CUT_SCREEN', async e => {
closeCutWindow()
mainWindow.hide();
createCutWindow()
cutWindow.show()
})
截屏窗体
这里的目的是创建一个全屏且透明而且没有标题菜单没法缩小没法移动的一个窗体,因为我们后面要截取整个屏幕的图片完全填充满整个窗体业务中,让人在上面绘制区域。
// electron/main.js
const { desktopCapturer, screen } = require('electron')
function getSize() {
const { size, scaleFactor } = screen.getPrimaryDisplay();
return {
width: size.width * scaleFactor,
height: size.height * scaleFactor
}
}
function createCutWindow() {
const { width, height } = getSize();
cutWindow = new BrowserWindow({
width,
height,
autoHideMenuBar: true,
useContentSize: true,
movable: false,
frame: false,
resizable: false,
hasShadow: false,
transparent: true,
fullscreenable: true,
fullscreen: true,
simpleFullscreen: true,
alwaysOnTop: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
contextIsolation: false,
}
})
if (NODE_ENV === 'development') {
cutWindow.loadURL("http://localhost:3000/#/cut")
} else {
cutWindow.loadFile(path.join(__dirname, '../dist/index.html'), {
hash: "cut"
})
}
cutWindow.maximize()
cutWindow.setFullScreen(true);
}
这里配置打包时注意,hash的地址,我们要特殊处理一下,这样打包后才能找到。
然后,需要提前写一下,其逻辑页面加载完要响应的获取截屏事件。
ipcMain.on("SHOW_CUT_SCREEN", async e => {
let sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: getSize(),
});
cutWindow.webContents.send("GET_SCREEN_IMAGE", sources[0])
})
这里获取完后还要发回到页面中,我们可以通过e对发送让其接收屏幕数据,也可以单独发出一个事件。为了清晰些,上面选择单独再往页面发出一个 GET_SCREEN_IMAGE
事件来把屏幕数据返回到逻辑页面中。接收后,在里面找到 thumbnail
就可以生成全屏的图片了,为了方便我们把图片不用canvas绘制了,而是直接做背景图使用。
<template>
<div
class="container"
:style="'background-image:url(' + bg + ')'"
ref="container"
></div>
</template>
<script setup>
const { ipcRenderer } = window.require("electron");
import { ref, onMounted } from "vue";
let container = ref(null);
let bg = ref("");
onMounted(() => {
ipcRenderer.send("SHOW_CUT_SCREEN");
ipcRenderer.off("GET_SCREEN_IMAGE", getSource);
ipcRenderer.on("GET_SCREEN_IMAGE", getSource);
});
async function getSource(event, source) {
const { thumbnail } = source;
bg.value = await thumbnail.toDataURL();
render(); // 绘制渲染canvas舞台等任务
}
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100vh;
overflow: hidden;
background-color: transparent;
background-size: 100% 100%;
background-repeat: no-repeat;
}
</style>
区域裁切
为了方便绘制和大小及位置的调整,这里我们将引入Konva库来帮助我们完成。
安装:
pnpm add Konva
然后引入它,创建舞台和主层。
import Konva from "konva";
let bg = ref("");
let stage, layer, rect, transformer;
function createStage() {
return new Konva.Stage({
container: container.value,
width: window.innerWidth,
height: window.innerHeight,
});
}
function createLayer(stage) {
let layer = new Konva.Layer();
stage.add(layer);
layer.draw();
return layer;
}
function render() {
stage = createStage();
layer = createLayer(stage);
}
接下来,我们要在这个layer主层中去完成一个矩形的绘制,首先,我们要在它的主容器上加入鼠标事件。
<div class="container"
:style="'background-image:url(' + bg + ')'"
ref="container"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
></div>
在响应鼠标事件之前,我们先写一个根据坐标和大小生成矩形的方法。
function createRect(layer, x, y, w = 0, h = 0, opacity = 0, draggable = false) {
const { clientWidth, clientHeight } = container.value;
const width = w,
height = h;
let rect = new Konva.Rect({
x,
y,
width,
height,
fill: `rgba(255,0,0,${opacity})`,
name: "rect",
draggable,
// ...
},
});
layer.add(rect);
return rect;
}
然后分别在鼠标按下,移动,抬起事件中不停的绘制矩形,直到鼠标抬起完成后,才会生成这个矩形,然后会用 createTransformer
给其创建一套调整节点尺寸的功能,具体可以查看konvajs文档 ,同时还要做一些边界判断和现在这里就不一一赘述了。
let isDown = false;
let rectOption = {};
function onMouseDown(e) {
if (rect || isDown) return;
isDown = true;
const { pageX, pageY } = e;
rectOption.x = pageX || 0;
rectOption.y = pageY || 0;
rect = createRect(layer, pageX, pageY, 0, 0, 0.25, false);
rect.draw();
}
function onMouseMove(e) {
if (!isDown) return;
const { pageX, pageY } = e;
let w = pageX - rectOption.x;
let h = pageY - rectOption.y;
rect.remove();
rect = createRect(layer, rectOption.x, rectOption.y, w, h, 0.25, false);
rect.draw();
}
function onMouseUp(e) {
if (!isDown) return;
isDown = false;
const { pageX, pageY } = e;
let w = pageX - rectOption.x;
let h = pageY - rectOption.y;
rect.remove();
rect = createRect(layer, rectOption.x, rectOption.y, w, h, 0, true);
rect.draw();
//
transformer = createTransformer(rect);
}
最后还要两个方法,一个是确认区域进行canvas裁切的方法,一个是直接退出截屏的方法。
// 根据区域生成图片
async function getCutImage(info) {
const { x, y, width, height } = info;
let img = new Image();
img.src = bg.value;
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
canvas.width = ctx.width = width;
canvas.height = ctx.height = height;
ctx.drawImage(img, -x, -y, window.innerWidth, window.innerHeight);
return canvas.toDataURL("image/png");
}
// 确认截图方法
async function handleCut() {
const { width, height, x, y, scaleX = 1, scaleY = 1 } = rect.attrs;
let _x = width > 0 ? x : x + width * scaleX;
let _y = height > 0 ? y : y + height * scaleY;
let pic = await getCutImage({
x: _x,
y: _y,
width: Math.abs(width) * scaleX,
height: Math.abs(height) * scaleY,
});
// 目的是发给主窗体页面让其接收到这个图片
ipcRenderer.send("CUT_SCREEN", pic);
}
// 直接退出截屏
function closeCut() {
ipcRenderer.send("CLOSE_CUT_SCREEN");
}
下面会通知窗体,把接收这两个事件,然后进行窗体操作和事件回发。
ipcMain.on('CUT_SCREEN', async (e, cutInfo) => {
closeCutWindow()
mainWindow.webContents.send('GET_CUT_INFO', cutInfo)
mainWindow.show()
})
ipcMain.on('CLOSE_CUT_SCREEN', async e => {
closeCutWindow()
mainWindow.show()
})
这样事件部分都写完了,可我们这么让其执行这些事件呢?方便起见,我们想使用键盘来实现执行,比如按Enter执行裁切确认,按Esc便退出裁切。接下来,我们就讲讲怎么优雅的设置设置这些按键吧~
按键设置
按键说来其实用 document.addEventListener("keydown", event)
判断keyCode的值就可以实现。但考虑到以后如果出现组合按键的扩展问题,所以这里推荐使用shortcuts第三方库来帮助我们完成。
import { Shortcuts } from "shortcuts";
const shortcuts = new Shortcuts();
shortcuts.add([
{
shortcut: "Enter",
handler: handleCut,
},
{
shortcut: "Esc",
handler: closeCut,
},
])
你会发现这样写起来非常直观,甚至我们还可以在最早那个主窗体页面中加入一些组合快捷键来方便我们操作,如,通过 Ctrl+alt+c
按下后可以直接唤起截屏:
shortcuts.add([
{
shortcut: "Ctrl+alt+c",
handler: handleCutScreen,
},
])
打包配置
与上一期相同要在package.json,配置一下打包的一些参数。
{
"name": "cut-demo",
"private": true,
"main": "electron/main.js",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"electron": "wait-on tcp:3000 && cross-env NODE_ENV=development electron .",
"electron:serve": "concurrently -k \"npm run dev\" \"npm run electron\"",
"electron:build": "npm run build && electron-builder",
},
"build": {
"appId": "",
"productName": "截图DEMO",
"copyright": "Copyright © 2022 jsmask",
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "./public/favicon.ico",
"uninstallerIcon": "./public/favicon.ico",
"installerHeaderIcon": "./public/favicon.ico"
},
"files": [
"dist/**/*",
"electron/**/*"
],
"directories": {
"buildResources": "assets",
"output": "build"
}
}
}
最后执行打包指令,等待其完成后,我们的这个截屏小工具就完成啦~