

Building a Snipping Tool with Electron, React and Node.js
source link: https://www.tuicool.com/articles/hit/MnABFzy
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

MediaDevicesdevices API is supported out of the box. Electron’s desktopCapturer module provides access to media sources that can be used to capture audio and video from the renderer process.
desktopCapturermodule. We’ll use React for building our application’s user interface and Nodejs as a backend server for uploading snipped images.
Structure
A few months ago, I wrote a Hacker News app with React and Electron. It was my first Electron app. I’m going to follow the same directory structure. Here’s what it’s gonna look like.
app build # Webpack output directory src main components # React components res # Resources (logos, images etc) snips # Directory for storing snips app.js # Electron Main process index.html # Bootstrap React App release # electron's generated app installer and binary server # Express server for uploading files webpack dev.config.js # webpack config for dev env prod.config.js # webpack config for prod env package.json # you know why
Getting Started
app.jsand
index.htmlfile inside
app/src/maindirectory.
app.jswill run the main process and display
index.htmlinside
BrowserWindow. There’s a lot of boilerplate code in this file. I’m only focusing on the part where we create our main
BrowserWindow. You can view the complete code in the linked repository.
function createWindow() { mainWindow = new BrowserWindow({ width: 400, height: 200, icon: path.join(__dirname, '/res/images/logo.png'), frame: false, }); mainWindow.setMenu(null); mainWindow.setResizable(false); // and load the index.html of the app. mainWindow.loadURL(url.format({ pathname: path.join(__dirname, 'index.html'), protocol: 'file:', slashes: true, }) + '?main'); }
index.html. I would like to point one important line of code here. I’m concatenating
+ '?main'at the end of our formatted URL. I was looking for a way to pass additional information to
BrowserWindowand then retrieve it from renderer process and this seems to be the only way to I found. I’m sure there must have been a better way to do it. if you know one, please share in comments. We’ll render two main components in our BrowserWindow, a Snipper and a Cropper. I didn’t want to create different HTML files for rendering different components. By passing additional info, we can choose which component to render.
index.htmlfile.
<!DOCTYPE html> <html lang="en"> <head> <title>Snipper</title> </head> <body> <div id="root" class="h-100"></div> <script> { if (process.env.NODE_ENV) { const bundle = document.createElement('script'); const port = 9000; bundle.src = 'http://localhost:' + port + '/app.bundle.js'; document.body.appendChild(bundle); const devServer = document.createElement('script'); devServer.src = 'http://localhost:' + port + '/webpack-dev-server.js'; document.body.appendChild(devServer); } else { let headHTML = document.getElementsByTagName('head')[0].innerHTML; headHTML += '<link type="text/css" rel="stylesheet" href="css/main.css">'; document.getElementsByTagName('head')[0].innerHTML = headHTML; const bundle = document.createElement('script'); bundle.src = 'app.bundle.js'; document.body.appendChild(bundle); } } </script> </body> </html>
We’ve configured Webpack to start a dev server and enable hot reloading for development builds. We’ll conditionally render script and styles tags DOM based on the environment. You can view Webpack’s dev and prod config files in the linked repository.
Render
App.jscomponent inside
src/main/componentsdirectory. It doesn’t do much. It renders a child
Snippercomponent and mounts.
import React from 'react'; import ReactDOM from 'react-dom'; import Snipper from './Snipper'; const render = (Component) => { ReactDOM.render( <Component />, document.getElementById('root'), ); }; render(Snipper);
Snipping Component
Snippercomponent will look like this.
Snipper.jscomponent file.
class Snipper extends React.Component{ constructor(props){ super(props); this.state = { view : this.getContext() }; } render(){ return( <Fragment> {this.state.view === 'main' ? ( <Fragment> <div className="snip-controls text-center"> <span className="close" title="close" onClick={this.destroyCurrentWindow.bind(this)}>× </span> <div> <h2> <img height="25" src={require('../res/images/logo-big.png')} alt=""/> Snipper </h2> </div> {!this.state.save_controls ? <div> <button className="btn btn-primary mr-1" onClick={this.captureScreen.bind(this, null)}> Fullscreen </button> <button className="btn btn-primary mr-1" onClick={this.initCropper.bind(this)}> Crop Image </button> </div> : <div> <button className="btn btn-primary mr-1" onClick={this.saveToDisk.bind(this)}> Save to Disk </button> <button className="btn btn-primary mr-1" onClick={this.uploadAndGetURL.bind(this, null)}> Upload URL </button> <button className="btn btn-primary mr-1" onClick={this.discardSnip.bind(this)}> Discard </button> </div> } </div> {this.state.image && <div className="snipped-image"> <img className="preview" src={this.state.image} alt=""/> </div> } </Fragment> ) : <Cropper snip={this.snip.bind(this)} destroySnipView={this.destroySnipView.bind(this)} /> } </Fragment> ) } }
BrowserWindow. We can access it now and set view state with it. Add this method to the component.
getContext(){ const context = global.location.search; return context.substr(1, context.length - 1); }
Snippercomponents into two parts. Whenever we’ll load our app from the main process with
?mainstring concatenated to the URL, we’ll see a Fullscreen and Crop Image buttons.
Cropper Component
Croppercomponents will look like this.
import React from 'react'; import Rnd from 'react-rnd'; const electron = require('electron'); const screenSize = electron.screen.getPrimaryDisplay().size; const style = { display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'solid 2px #3a38d2', margin: '5px' }; class Cropper extends React.Component{ constructor(props){ super(props); this.state = { width: '500px', height: '500px', x: (screenSize.width/2) - 250, y: (screenSize.height/2) - 250 }; } render(){ return( <Rnd style={style} size={{ width: this.state.width, height: this.state.height }} position={{ x: this.state.x, y: this.state.y }} onDragStop={(e, d) => { this.setState({ x: d.x, y: d.y }) }} onResize={(e, direction, ref, delta, position) => { this.setState({ width: ref.style.width, height: ref.style.height, x : position.x, y : position.y }); }} bounds={'parent'} > <div className="rnd-controls"> <button className="btn btn-primary" onClick={this.props.snip.bind(this, this.state)} >Capture</button> <button onClick={this.props.destroySnipView.bind(this)} className="btn btn-primary" >Cancel</button> </div> </Rnd> ) } }
react-rndmodule. It lets you create a component with drag and resize support and keep track of coordinates and offsets with its
onResizeand
OnDragStopprop callbacks.
Snipperand
Croppercomponents will look how. Now, let’s implement their functionality.
Displaying Cropper
initCropperonClick handler method on Crop Image button. Let’s add it to our component.
initCropper(e){ mainWindow = this.getCurrentWindow(); mainWindow.hide(); snipWindow = new BrowserWindow({ width: screenSize.width, height: screenSize.height, frame : false, transparent : true, kiosk: true }); snipWindow.on('close', () => { snipWindow = null }); ipcRenderer.once('snip', (event, data) => { this.captureScreen(data, null); }); ipcRenderer.once('cancelled', (event) => { mainWindow.show(); }); snipWindow.loadURL(path.join('file://', __dirname, '/index.html') + '?snip'); snipWindow.setResizable(false); }
BrowserWindowwith same file URL but a different info string. This time we’re concatenating
?snipto the loadURL file path. In our click handler, we’ll keep a reference to main
BrowserWindowand hide it instead of destroying it. We’ll create a new transparent window with full height and width of client screen. To get current instance of
BrowserWindow, add this method your component.
getCurrentWindow(){ return electron.remote.getCurrentWindow(); }
BrowserWindowloads, it will render
Croppercomponents instead of button controls. We’re also registering event listening with
ipcRenderer.once.
ipcRendereris an instance of
EventEmitterclass and lets us communicate between different windows. We’re registering two events, snip and canceled. We’ll call them from cropper window and listen to them in the main window.
Croppercomponent.
snipmethod will be called when a user clicks capture button after cropping a screen region and
destroySnipViewwill be called when a user cancels a crop. Add these methods to
Croppercomponent.
snip(state, e){ this.getMainInstance().webContents.send('snip', state); this.destroyCurrentWindow(null); } destroySnipView(e){ this.getMainInstance().webContents.send('cancelled'); this.destroyCurrentWindow(null); }
Croppercomponent. It has x and y coordinates and height and width of the cropped region. After calling events and passing the data, we’ll destroy cropper window.
Capturing Screen
We’re listening to snip event in our main window.
ipcRenderer.once('snip', (event, data) => { this.captureScreen(data, null); });
captureScreenmethod. Let’s define it.
captureScreen(coordinates,e){ mainWindow = this.getCurrentWindow(); mainWindow.hide(); this.getScreenShot((base64data) => { let encondedImageBuffer = new Buffer(base64data.replace(/^data:image\/(png|gif|jpeg);base64,/,''), 'base64'); Jimp.read(encondedImageBuffer, (err, image) => { if (err) throw err; let crop = coordinates ? image.crop(coordinates.x, coordinates.y, parseInt(coordinates.width, 10), parseInt(coordinates.height, 10)) : image.crop(0,0, screenSize.width, screenSize.height); crop.getBase64('image/png', (err,base64data) =>{ this.setState({ image : base64data, save_controls : true, }); this.resizeWindowFor('snip'); mainWindow.show(); }); }); }); }
jimpmodule to crop our images. After that, we’re calling
getScreenShotmethod and passing it a callback. It will return us a base64data string after capturing a screenshot. Add this method to your component.
getScreenShot(callback, imageFormat) { let _this = this; this.callback = callback; imageFormat = imageFormat || 'image/png'; this.handleStream = (stream) => { // Create a hidden video element on DOM let video_dom = document.createElement('video'); // hide it somewhere video_dom.style.cssText = 'position:absolute;top:-10000px;left:-10000px;'; // Load stream video_dom.onloadedmetadata = function () { // Set video ORIGINAL height (screenshot) video_dom.style.height = this.videoHeight + 'px'; // videoHeight video_dom.style.width = this.videoWidth + 'px'; // videoWidth // Create canvas let canvas = document.createElement('canvas'); canvas.width = this.videoWidth; canvas.height = this.videoHeight; let ctx = canvas.getContext('2d'); // Draw video on canvas ctx.drawImage(video_dom, 0, 0, canvas.width, canvas.height); if (_this.callback) { // Save screenshot to base64 _this.callback(canvas.toDataURL(imageFormat)); } else { console.log('Need callback!'); } // Remove hidden video tag video_dom.remove(); try { // Destroy connect to stream stream.getTracks()[0].stop(); } catch (e) {} }; video_dom.src = URL.createObjectURL(stream); document.body.appendChild(video_dom); }; this.handleError = (e) => { console.log(e); }; // Get available screen desktopCapturer.getSources({types: ['screen']}, (error, sources) => { if (error) throw error; for (let i = 0; i < sources.length; ++i) { // Filter: main screen if (sources[i].name === "Entire screen") { navigator.webkitGetUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: sources[i].id, minWidth: 1280, maxWidth: 4000, minHeight: 720, maxHeight: 4000 } } }, this.handleStream, this.handleError); // handle stream return; } } }); }
getScreenShotmethod, we’re filtering available screens by iterating over media sources returned by
desktopCapturer.getSources. After selecting the main screen, we’re calling
navigator.webkitGetUserMediaand passing it two methods to handle captured stream and handle errors. In
handleStreammethod, we’re creating a hidden video tag and drawing it on a canvas, then passing base64 data string to callback method after converting canvas to data URL by calling
canvas.toDataURLmethod.
coordinatesparam on
captureScreenmethod. When an snip event from cropper window calls
captureScreenmethod, coordinates will be passed to this method as the state. When a user will click Fullscreen button, it will call
captureScreenwith null coordinates. On null, we’ll capture full main screen. After capturing the image, we’ll update image and set save_controls to true to display Save disk, Upload URL and Discard buttons.
Here’s what it looks like when your click Full screen or select a crop. Now we need to add event handlers to save an image to disk, upload and discard. Add these methods to your component.
discardSnip(e){ this.setState({ image : '', save_controls : false, }); this.resizeWindowFor('main'); } saveToDisk(e){ const directory = path.join(__dirname + '/snips'); const filepath = path.join(directory + '/' + uuidv4() + '.png'); if (!fs.existsSync(directory)){ fs.mkdirSync(directory); } fs.writeFile(filepath, this.state.image.replace(/^data:image\/(png|gif|jpeg);base64,/,''), 'base64', (err) => { if(err) console.log(err); shell.showItemInFolder(filepath); this.discardSnip(null); }); } uploadAndGetURL(e){ post(this.state.upload_url, { image : this.state.image }) .then((response) => { const res = response.data; if(res.uploaded){ shell.openExternal(this.state.upload_url + '/' + res.filename); this.discardSnip(null); } }) .catch((error) => { console.log(error); }); }
fs.writeFilemethod and then open the file in default file explorer using
shell.showItemInFoldermethod. On Upload, we’ll use axios post method to pass base64 data string to our Nodejs backend server. Let’s create a simple express server to handle a post request, saving file and returning image URL.
Upload Server
server.jsfile and add this code.
const express = require('express') const app = express() const bodyParser = require('body-parser'); const fs = require('fs'); const uuidv4 = require('uuid/v4'); const path = require('path'); const port = 8989; app.use(bodyParser.urlencoded({extended: true})); app.use(bodyParser.json({limit: '10000kb'})); app.use('/upload', express.static(path.join(__dirname, 'uploads'))); app.post('/upload', (req, res) => { const image = req.body.image; const directory = path.join(__dirname + '/uploads'); const filename = uuidv4() + '.png'; const filepath = path.join(directory + '/' + filename); if (!fs.existsSync(directory)){ fs.mkdirSync(directory); } fs.writeFile(filepath, image.replace(/^data:image\/(png|gif|jpeg);base64,/,''), 'base64', (err) => { if(err) console.log(err); res.json({ uploaded : true, filename : filename }) }); }); app.listen(port, () => { console.log('Server running on PORT: ' + port); });
/uploadroute as static content. Inside post callback, we’re creating uploads directory if it doesn’t exist. We’re creating a file with base64 data string and returning the filename. After receiving success response from the web server, we’ll concatenate filename with our static content route and open it in default web browser will
shell.openExternalmethod.
Snipper Demo
Here’s a functioning demo of our snipping tool.
I’ve created a GitHub repository. I’ve tested the code on Windows 10 machine. if you get any errors, please open an issue in repository or mention in comments. Setup instructions are available in repository readme file.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK