目前各个平台下都有很多优秀的截图软件可以选择,包括qq、微信等社交软件都可以实现快速截图。但除了截图外,我最常用的主要还有快速查看并复制界面颜色码。此前,在windows下我一直使用snipaste这个截图工具,小巧易用,想要的功能都有。现在除了偶尔用到office要切换到windows外,都在linux下折腾。但linux系统下,始终没有找到类似snipaste的截图工具,优秀的截图工具、取色器倒是有,但我又不愿意在电脑上装了这又装那。于是决定自己做一个,技术方面就采用vue和electron,一方面可以增加vue的实践经验,另一方面又掌握了electron的基本使用,一箭双雕。如此,具体做的是什么倒显得不那么重要了。后面的部分我将具体分享如何利用vue前端技术来实现这一桌面截图程序,并总结下这一过程中遇到的各种坑,这些坑对于初次接触electron的人来说,应该会很有帮助。
先贴几张图,看下最后实现的样子。主要包含桌面颜色码显示和拷贝、撤销、恢复、矩形、圆形、箭头、直线、添加文本、涂鸦等功能,还有改变线型、颜色、另存为、设置等实现。
黑色主题 白色主题 设置页首先,在应用启动的时候(或者说在按快捷键的时候)捕获整个桌面,渲染到一个canvas中;然后,创建一个全屏的透明窗口来响应鼠标事件,通过鼠标选取截图区域(也是一个canvas),并且可以任意拖拽这个区域,在鼠标抬起(mouseup)的时候,坐标点、宽高就确定了,然后将前一步渲染桌面的canvas对应位置、宽高的区域渲染到这个截图区域里。最后,显示一个工具条,通过点击工具条可以往截图区域里添加内容,然后将截图区域另存为或者复制到剪贴板,最终退出透明窗口。
整个项目的目录结构如下图所示,主要包含主进程文件main.js
,以及渲染进程文件src
目录下的vue
组件等,另外为了避免主进程文件过长,我将创建托盘的相关程序分离到了mainProcess
文件夹下(这里有个坑,稍微介绍) 。下面从electron角度,先介绍渲染进程,再说主进程。
// src/App.vue
App.vue
中将所有布局放在layout
的盒子中 ,mask
为透明遮罩层,用来响应鼠标选取截图区域的事件onSelectRegion
,同时给截图区域CaptureRegion
绑定样式。这里利用了两个 canvas
元素,一个主显示,一个主辅助来实现对截图区域的显示和操作,辅助canvas
用来响应鼠标事件,mouseup
时将响应结果绘制到显示canvas
上。 这里CaptureRegion
及两个canvas
都为绝对定位,且起点坐标、宽高一致(canvas
起点x,y实际要多一个CaptureRegion
的border
宽度,而宽高实际要少两个border
宽度,可通过vue计算属性computed来实现)。
为什么不直接响应鼠标生成canvas ,反而需要一个CaptureRegion的div?
——因为直接生成canvas在选区时会有拉扯感,canvas元素就跟img一样,鼠标来拉取时会进行拖拽。所以,这里利用额外的一个div来得到所需要的样式,在mouseup时再赋给canvas,就可以使得截图选区变得顺滑。
接下来是工具条ToolBar
组件,通过指令v-show
来控制其在选区完成时显示,再自定义一个v-position
指令来根据选区的坐标位置来控制其显示的位置。
然后是颜色码显示组件ColorTip
,v-show
让其在截图过程中隐藏,非截图时显示。
最后是捕获桌面所需的两个元素,一个canvas
和一个video
,绝对定位且与屏幕同宽高,用以渲染桌面图像且对用户不可见。
整个App.vue
就是一个透明窗口。在组件mounted
的时候进行屏幕捕获captureScreen
。屏幕捕获主要利用navigator.mediaDevices.getUserMedia()
来实现,electron提供了一个desktopCapture
方法来捕获桌面源,但正如下代码注释的一样,老坑了,因为这个方法只在windows下有效(mac不知道是否有效),在linux下是不起作用的,但官方文档并没有说明,害我在切换平台的时候,就跟小朋友一样充满了无数问号???怎么连桌面都捕获不了了,这个截图工具没法做了。最后的办法是,linux下直接用navigator.mediaDevices.getUserMedia()
就行。第二个坑是id
,为了保证捕获的源是所需要的源,需要判断源的id
,捕获的桌面源source的id
和screen
的id
不是一回事,对应的是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
元素中,这里可以直接传递video
给canvas
的上下文绘制视频帧 ,但是我这里先把它转换成了位图,再传递给canvas
上下文的drawImage
,这样可以降低canvas
绘制的延迟(可参见MDN对canvas图片源的说明)
// 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下启动时要隐藏窗口一定不要设置fullscreen
为 true
。
其二,alwaysOnTop
linux下不能为true,因为它是真真儿的alwaysOnTop,其他窗口打开后点都点不着,这样截图后需要另存,打开的文件窗口就无法进行操作;而windows下,alwaysOntop就是骗人的,即使设置为true,打开文件窗口依然可以进行操作。
其三,最后一个踩坑点,也是全过程中最让人懵逼的地方,就是透明窗口。首先一定要记住,开发模式下透明窗口是没效果的(乘以N)(这个不知道坑了多少人,害我一度怀疑自己代码写错了)。然后,Linux下透明窗口的实现需要禁用gpu,开启alpha通道,具体可见electron官方文档。
Frameless Window | Electronwww.electronjs.org然而,linux下我并没有采取这个方案,因为我觉得不到万不得已禁用gpu始终不是一个好的选择,对于这个截图工具而言,还有一个可选的方案,就是放弃透明窗口,直接把整个桌面渲染到canvas中后,不将其隐藏,反正宽高同桌面的宽高看起来差别不大,所以App.vue
中在渲染桌面的canvas中绑定了style{visibility: win32? 'hidden':'visible'}
。
附上我的打包配置,网上也有很多相关资料,可做参考。这里需要提醒的是:首先,打包时一定不要漏了主进程的资源文件,将其添加进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 !