Creating a Popup Dictionary with Electron (Part 2)

John Dykes
7 min readNov 12, 2023

--

Read part 1 first here

I have been working on a popup dictionary build with electron which causes a dictionary to appear at at your cursor with the push of a button.

In part 1, we created a dictionary window with an iframe to show the actual dictionary, and a search bar at the top of the window. We also embed some CSS in the page to remove the title bar, search bar, and make the page work better on a small screen.

Searching ‘apple’ in our Korean dictionary with embedded CSS

But we still haven’t implemented the ‘popup’ part of the popup dictionary! In part 2 we will assign a global hotkey that grabs the currently selected text, and opens the dictionary entry for that text at the current mouse cursor position.

To get the currently selected text on Linux, we can take advantage of the ‘selection’ clipboard which holds the currently selected text!

const { clipboard } = require('electron')

const getSelectedText = () => {
return clipboard.readText('selection');
}

To achieve the same thing on Windows or macOS, we would have to manually send a Control-C to copy the text and then access the clipboard. We could achieve this with a library like RobotJS, but since I’m using Linux we will leave that for a later time.

To get the mouse position on the screen, we can just call the electron function screen.getCursorScreenPoint .

const { screen } = require('electron')

function getMousePos() {
return screen.getCursorScreenPoint()
}

Now, we will register a hotkey to show the dictionary, and do a query on the currently selected text

const { globalShortcut } = require('electron')

const registerHotkeys = (win, browserView, app) => {
globalShortcut.register(settings.dictionaryHotkey, async () => {
const selectedText = getSelectedText();
await dictQuery(win, browserView, app, selectedText);
win.webContents.send('focus-search');
});
};

where our code to do a dictionary query is as follows

const dictQuery = async (win, browserView, app, text) => {
const mousePos = getMousePos()
await MoveWindowToCursor(win, app, mousePos)
showWindow(win, app)

if (text !== '') {
await changeWebView(win, browserView, text)
}
};

So, we get the current mouse position, and move the window to the cursor. Then, we load the appropriate webpage to show the results of our query.

Note that the MoveWindowToCursor function also takes into account multiple monitors, and whether the dictionary window will be partially off screen if placed normally.

For changing the dictionary URL, we have changed the approach since part 1. In part 1, we had an HTML iframe in our index.html which was used to load the dictionary URL. The problem with this approach is that some webpages don’t allow themselves to be embedded in an iframe for security reasons. There may be ways around this in electron by turning off certain security features, but a better idea is to just use the BrowserView object provided by electron for this very purpose.

So, when we create our main window, we will also create a BrowserView window and place it appropriately.

const createWindow = async () => {
mainWindow = new BrowserWindow({
backgroundColor: settings.backgroundColor,
width: settings.windowSize.width,
height: settings.windowSize.height,
frame: false,
resizable: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
mainWindow.hide();

// hide window when it goes out of focus
mainWindow.on('blur', () => {
mainWindow.hide();
});

let viewURL = path.join(__dirname, '../views/index.html');
await mainWindow.loadFile(viewURL);

browserView = new BrowserView({
webPreferences: {
preload: path.join(__dirname, 'browserViewPreload.js'),
}
});
browserView.setBackgroundColor(settings.backgroundColor)
browserView.setBounds({x: 0, y: 45, width: settings.windowSize.width, height: settings.windowSize.height - 45});

mainWindow.setBrowserView(browserView);
registerHotkeys(mainWindow, browserView, app);

Note on this line

browserView.setBounds({x: 0, 
y: 45,
width: settings.windowSize.width,
height: settings.windowSize.height - 45});

that the navigation bar at the top of our application is 45 pixels, hence the y: 45 in the browserView bounds makes sure the BrowserView is located directly below the navigation bar.

Finally, we need to implement the changeWebView function that loads the new page in the BrowserView window

const changeWebView = async (win, browserView, text) => {
let url = settings.queryURL;
text = encodeURIComponent(text);
url = url.replace('<<word>>', text);

await browserView.webContents.loadURL(url);
}

In order to be able to change the web view from the rendering process, which in our case we want to do when the user clicks on the search button, we need to include it in the contextBridge of our preload script.

const { contextBridge } = require('electron');

contextBridge.exposeInMainWorld('api', {
changeWebView: (text) => ipcRenderer.invoke('change-webview', text)
});

and we need to make our main process listen on the channel change-webview

// createWindow function

ipcMain.handle('change-webview', async (event, text) => {
await changeWebView(mainWindow, browserView, text);
})

With this, we can add this script to our index.html

<script>
onSubmit = () => {
const text = document.getElementById('SearchBarText').value;
window.api.changeWebView(text);
};
</script>

...
<button type="submit" onclick="onSubmit()" id="SearchBarButton"/>

We can now open our dictionary with a hotkey defined in settings.js . For the hotkey, I’ve been using Control+D :

// settings.js

module.exports = {
...

dictionaryHotkey: 'CommandOrControl+D',
};
Using the popup dictionary

However, as in part 1 we need to inject CSS into the page to make it look better at such a small size.

The simplest way to do this would be to run some javascript to append the CSS after the DOM loads

await browserView.webContents.executeJavaScript(`
document.head.innerHTML += \`${settings.css}\`
`);

However this is not ideal as we first load the entire page, then apply our CSS. It would be better to apply the CSS before loading the page. This can be achieved with a preload script.

browserView = new BrowserView({
webPreferences: {
preload: path.join(__dirname, 'browserViewPreload.js'),
}
});

In this preload script, we need access to the CSS string to be injected to the page. We could just hard-code the string, but a better method is to request it from the main process.

const { ipcRenderer} = require('electron')

window.addEventListener("DOMContentLoaded", async () => {
const css = await ipcRenderer.invoke('get-css')
document.head.innerHTML += css
})

In the main process, we will listen on the channel get-css and send back the required CSS as a string

// createWindow function

ipcMain.handle('get-css', () => settings.css)

The actual CSS, along with the query URL are stored in settings.js

const naverKoreanDict = {
queryURL:'https://ko.dict.naver.com/search.nhn?query=<<word>>&target=dic',
css: `<style>
div.option_area, div#header, div#footer, div#aside, div.component_socialplugin, div.tab_scroll_inner, div.section_suggestion, .section.section_etc{
display: none;
}

...

</style>
`
}

module.exports = {
windowSize: {
width: 500,
height: 550,
},
queryURL: naverKoreanDict.queryURL,
css: naverKoreanDict.css,
dictionaryHotkey: 'CommandOrControl+D',
};

With this, our dictionary is looking much better!

Using the popup dictionary with injected CSS

Now, the great thing about the way we’ve designed our code is that in order to add a new dictionary, all we need to do is modify the settings.js file in order to add a new query string, and if you want, you can also add CSS to inject into the page.

Let’s do an example by making our dictionary load results from dictionary.com. First we trying searching for a word from dictionary.com.

Querying from dictionary.com

We see that the URL is of the form www.dictionary.com/browse/<<word>> , where <<word>> is the word we want to query. So, in our settings let’s add this dictionary.

// settings.js

const dictionaryDotCom = {
queryURL: "https://www.dictionary.com/browse/<<word>>",
css: ``,
}

module.exports = {
windowSize: {
width: 500,
height: 550,
},
queryURL: dictionaryDotCom.queryURL,
css: dictionaryDotCom.css,
dictionaryHotkey: 'CommandOrControl+D',
};

So now we can use the dictionary to look up English words

Using the dictionary to look up English words from dictionary.com

Okay, so that works. But clearly we could benefit from injecting some custom CSS into the page. Let’s start by getting rid of the top bar and that giant ad. To do this, we open up the developer tools in our browser of choice (I’m using Firefox).

Firefox developer tools

And we press Control+Shift+C to pick an element from the page.

Selecting an element from the HTML

We see that the navigation bar is an HTML element header . Similarly, we can see that the advertisement is in an HTML div element with class TeixwVbjB8cchva8bDlg. So, we should be able to remove these with the following CSS

const dictionaryDotCom = {
queryURL: "https://www.dictionary.com/browse/<<word>>",
css: `<style>
header, section.TeixwVbjB8cchva8bDlg {
display: none;
}
</style>`,
}

However, this doesn’t work. The problem is that our CSS is injected before the rest of the page is loaded, which causes it to be overwritten. We can avoid this by adding !important to tell the browser not to overwrite our CSS. This is usually a bad practice in web development as it makes maintaining a large and complicated code base more difficult.

For our purposes, it is fine since we just want to inject some simple CSS into existing pages. Our modified code is

const dictionaryDotCom = {
queryURL: "https://www.dictionary.com/browse/<<word>>",
css: `<style>
header, section.TeixwVbjB8cchva8bDlg {
display: none !important;
}
</style>`,
}

With this our dictionary looks much better:

Looking up English words from dictionary.com with injected CSS

We could of course continue to add CSS to stylize the page however we like.

To wrap up what we’ve done in part 2, we have changed our approach from using an iframe to using electron’s BrowserWindow class. We have also added the ability to use dictionaries other than the Naver Korean dictionary, and the ability to have the dictionary ‘popup’ with the press of a button.

In part 3, we will create a settings UI so that the user can pick which dictionary they want to use, and add their own by providing a query URL and some CSS to inject into the page, as well as other behavior such as the size of the dictionary window, the hotkey to open the dictionary, and so on.

See you in part 3!

You can view the full source code on github.

--

--

John Dykes

I'm a programmer and cryptographer, and I also have a passion for creating web apps.