I'm trying to create a drag & drop functionality into an electron window, using the recommended approach of sandboxing the processes. So that ipcMain is isolated from ipcRenderer, and the bridge is done via a preload.js script (see: Electron's preload tutorial
After much trial and error, I finally managed to make it work going back and forth the renderer process receiving the dropped file, sending it to the main process for reading it, and then back to the renderer to display it.
The problem now is that when I drag the file from the desktop or Finder too quickly, it won't drop the file, and will raise the following error:
caught (in promise) Error: Error invoking remote method 'open-file': TypeError: Cannot read properties of null (reading 'webContents') Promise.then (async) getDroppedFile @ renderer.js:41 (anonymous) @ renderer.js:23
I have tried checking with BrowserWindow.getFocusedWindow()
, but this doesn't help because if it is undefined I'd need a way to message the OS to make this window focused again so that the renderer can receive the contents of the file.
I want to maintain this separation between main/renderer processes, so I don't want to make the renderer aware of the operating system.
What am I missing?
I'm using Electron 24.1.1, node 19.8.1, npm 9.5.1 on MacOS Ventura.
The basic structure of my project is (before running the command > npm install electron --save
):
.
├── app
│ ├── index.html
│ ├── main.js
│ ├── preload.js
│ ├── renderer.js
│ └── style.css
└── package-lock.json
Below are the main scripts of the code, the full version can be found at repository .
index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'" />
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css" type="text/css">
<title>Electron's Drag & Drop</title>
</head>
<body>
<section class="content">
<label for="markdown" hidden>Markdown Content</label>
<textarea class="raw-markdown" id="markdown">Drop your markdown here</textarea>
</section>
</body>
<script src="./renderer.js"></script>
</html>
preload.js:
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
openFile: (file) => ipcRenderer.invoke('open-file', file),
onFileOpened: (callback) => ipcRenderer.on('opened-file', callback),
});
main.js:
const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('fs');
const path = require('path');
let mainWindow = null;
ipcMain.handle('open-file', (event, file) => openFile(BrowserWindow.getFocusedWindow(), file));
const createWindow = () => {
mainWindow = new BrowserWindow(
{
show: false,
width:800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
mainWindow.loadFile('./app/index.html');
mainWindow.once('ready-to-show', () => {
mainWindow.show();
mainWindow.webContents.openDevTools();
});
mainWindow.on('closed', () => mainWindow = null);
};
app.whenReady().then(() => {
createWindow();
app.on('activate', (event, hasVisibleWindows) => {
if (!hasVisibleWindows) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.Quit();
});
const openFile = (targetWindow, file) => {
const contents = fs.readFileSync(file).toString();
targetWindow.setRepresentedFilename(file);
targetWindow.webContents.send('opened-file', file, contents);
}
renderer.js:
const markdownView = document.querySelector('#markdown');
document.addEventListener('dragstart', event => event.preventDefault());
document.addEventListener('dragover', event => event.preventDefault());
document.addEventListener('dragleave', event => event.preventDefault());
document.addEventListener('drop', event => event.preventDefault());
markdownView.addEventListener('dragover', (event) => {
const file = event.dataTransfer.items[0];
if (fileTypeIsSupported(file)) {
markdownView.classList.add('drag-over');
} else {
markdownView.classList.add('drag-error');
}
})
markdownView.addEventListener('dragleave', () => {
removeMarkdownDropStyle();
})
markdownView.addEventListener('drop', (event) => {
removeMarkdownDropStyle();
getDroppedFile(event);
})
const removeMarkdownDropStyle = () => {
markdownView.classList.remove('drag-over');
markdownView.classList.remove('drag-error');
}
const fileTypeIsSupported = (file) => {
return ['text/plain', 'text/markdown'].includes(file.type);
}
window.electronAPI.onFileOpened(async(_event, file, content) => {
markdownView.value = content;
})
const getDroppedFile = (event) => {
const file = event.dataTransfer.files[0].path;
window.focus();
window.electronAPI.openFile(file);
}
style.css: -> only to visually indicate the drag/dragover/drop events (please see github)
The key here is for the main process to send the BrowserWindow
id
for the created window; the renderer listens to it on a 'browser-window-created'
channel with a callback and defined on preload.js.
When the file is dropped, we then use let targetWindow = BrowserWindow.fromId(browserWindowId)
in order to get the unfocused window (targetWindow would be undefined).
With that, we can then reactivate the window with targetWindow.show();
and open/read the file and display it on markdownView. Full source code is available on github repo.
Roadmap:
On main.js:
createWindow()
raises
newWindow.webContents.send('browser-window-created', newWindow.id);
which is caught up by the renderer process listener:
window.electronAPI.onBrowserWindowCreated (async(_event, winId) => {
browserWindowId = winId;
})
This simply stores on renderer's browserWindowId
variable.
On renderer.js:
Once the window receives a dropped file, the listener is activated and asks the bridged readDroppedFile
function to run on the main process:
markdownView.addEventListener('drop', (event) => {
removeMarkdownDropStyle();
if (fileTypeIsSupported(event.dataTransfer.items[0])) {
const file = event.dataTransfer.files[0].path;
getDroppedFile(file);
}
})
const getDroppedFile = (file) => {
// call main process API to open the dropped file,
// as defined by the bridge (of same name) on preload.js
window.electronAPI.readDroppedFile(file, browserWindowId);
}
Back to main.js, this API call calls main's readDroppedFile, which does the magic:
const readDroppedFile = (file, browserWindowId) => {
let targetWindow = BrowserWindow.getFocusedWindow();
if (!targetWindow) { // no focused window was found, so we'll use browserWindowId
if (browserWindowId < 0) { console.log('Invalid browser id'); return; }
// we get the window using the static BrowserWindow.fromId() function
targetWindow = BrowserWindow.fromId(browserWindowId);
if (!targetWindow) { console.log('Unable to identify which window to use'); return; }
}
targetWindow.show(); // targetWindow.focus(); ?
openFile(targetWindow, file);
}
Below, is the full source code:
// main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('fs');
const path = require('path');
// **** ipcMain handlers ****
ipcMain.handle('open-file', (event, file) => openFile(BrowserWindow.getFocusedWindow(), file));
ipcMain.handle('read-dropped-file', (event, file, browserWindowId) => readDroppedFile(file, browserWindowId));
// **** Main Process Functions ****
const readDroppedFile = (file, browserWindowId) => {
let targetWindow = BrowserWindow.getFocusedWindow();
if (!targetWindow) { // no focused window was found, so we'll use browserWindowId
if (browserWindowId < 0) { console.log('Invalid browser id'); return; }
// we get the window using the static BrowserWindow.fromId() function
targetWindow = BrowserWindow.fromId(browserWindowId);
if (!targetWindow) { console.log('Unable to identify which window to use'); return; }
}
targetWindow.show(); // targetWindow.focus(); ?
openFile(targetWindow, file);
}
const createWindow = (continuation) => {
let newWindow = new BrowserWindow(
{
show: false,
width:800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
newWindow.loadFile('./app/index.html');
newWindow.once('ready-to-show', () => {
newWindow.show();
newWindow.webContents.openDevTools();
// raise event on channel 'browser-window-created' for renderer process
newWindow.webContents.send('browser-window-created', newWindow.id);
});
newWindow.on('closed', () => newWindow = null);
if (continuation) // this would be run on MacOS from the app's icon if no window is open
continuation(newWindow);
};
app.whenReady().then(() => {
createWindow();
app.on('activate', (event, hasVisibleWindows) => {
if (!hasVisibleWindows) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.Quit();
});
// nice app listener event for launching file from the app icon's recent files menu
app.on('will-finish-launching', () => {
app.on('open-file', (event, file) => {
createWindow((targetWindow) => {
targetWindow.once('ready-to-show', () => openFile(targetWindow, file));
});
});
});
const openFile = (targetWindow, file) => {
const content = fs.readFileSync(file).toString();
// nice features to set
app.addRecentDocument(file);
targetWindow.setRepresentedFilename(file);
// raise envent on channel 'file-opened' for renderer process
targetWindow.webContents.send('file-opened', file, content);
}
/* style.css */
html {
box-sizing: border-box;
}
*, *.before, *.after {
box-sizing: inherit;
}
html, body { height: 100%;
width: 100%;
overflow: hidden;
}
body {
margin: 0;
padding: 0;
position: absolute;
}
body, input {
font: menu;
}
textarea, input, div {
outline: none;
margin: 0;
}
.controls {
background-color: rgb(217, 241, 238);
padding: 10px 10px 10px 10px;
}
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
min-width: 100vw;
position: relative;
}
.content {
height: 100vh;
display: flex;
}
.raw-markdown {
min-height: 100%;
min-width: 100%;
flex-grow: 1;
padding: 1em;
overflow: scroll;
font-size: 16px;
border: 5px solid rgb(238, 252, 250);
background-color: rgb(238, 252, 250);
font-family: monospace;
}
.raw-markdown.drag-over {
background-color: rgb(181, 220, 216);
border-color: rgb(75, 160, 151);
}
.raw-markdown.drag-error {
background-color: rgba(170, 57, 57, 1);
border-color: rgba(255, 170, 170, 1);
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'" />
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css" type="text/css">
<title>Electron's Drag & Drop</title>
</head>
<body>
<section class="content">
<label for="markdown" hidden>Markdown Content</label>
<textarea class="raw-markdown" id="markdown">Drop your markdown here</textarea>
</section>
</body>
<script src="./renderer.js"></script>
</html>
We also need preload.js and the renderer.js.
// preload.js
// preload script to make the bridge between main and renderer processes
const { contextBridge, ipcRenderer } = require('electron');
// contextBridge exposes functions and events to be called or listened to, respectively, by the renderer process
contextBridge.exposeInMainWorld('electronAPI', {
// main process function available to renderer which, is picked up by ipcMain handler when invoked
readDroppedFile: (file, browserWindowId) => ipcRenderer.invoke('read-dropped-file', file, browserWindowId),
// events that are sent to renderer process
onFileOpened: (callback) => ipcRenderer.on('file-opened', callback),
onBrowserWindowCreated: (callback) => ipcRenderer.on('browser-window-created', callback),
});
// renderer.js
let browserWindowId = -1;
const markdownView = document.querySelector('#markdown');
// **** DOM event listeners ***
document.addEventListener('dragstart', event => event.preventDefault());
document.addEventListener('dragover', event => event.preventDefault());
document.addEventListener('dragleave', event => event.preventDefault());
document.addEventListener('drop', event => event.preventDefault());
markdownView.addEventListener('dragover', (event) => {
const file = event.dataTransfer.items[0];
if (fileTypeIsSupported(file)) {
markdownView.classList.add('drag-over');
} else {
markdownView.classList.add('drag-error');
}
})
markdownView.addEventListener('dragleave', () => {
removeMarkdownDropStyle();
})
markdownView.addEventListener('drop', (event) => {
removeMarkdownDropStyle();
if (fileTypeIsSupported(event.dataTransfer.items[0])) {
const file = event.dataTransfer.files[0].path;
getDroppedFile(file);
}
})
// **** Renderer Process Functions ****
const removeMarkdownDropStyle = () => {
markdownView.classList.remove('drag-over');
markdownView.classList.remove('drag-error');
}
const fileTypeIsSupported = (file) => {
console.log(`file type is supported: ${file}`);
return ['text/plain', 'text/markdown'].includes(file.type);
}
const getDroppedFile = (file) => {
// call main process API to open the dropped file,
// as defined by the bridge (of same name) on preload.js
window.electronAPI.readDroppedFile(file, browserWindowId);
}
// **** Main process event listeners ****
// Listener for file opened event
// Attention: input should be sanitized first
// Sanitization not implemented
window.electronAPI.onFileOpened(async(_event, file, content) => {
// markdownView.value = DOMPurify.sanitize(content); // eg. of sanitization
markdownView.value = content;
})
// Listener for browser window created event: that's where we get the browser for this renderer process
window.electronAPI.onBrowserWindowCreated (async(_event, winId) => {
browserWindowId = winId;
console.log(`browser window id = ${browserWindowId}`);
})
The basic file structure is:
.
├── app
│ ├── index.html
│ ├── main.js
│ ├── preload.js
│ ├── renderer.js
│ └── style.css
└── package.json
On parent's app folder, run commands:
npm init
npm install electron --save
package.json
to point to "./app/main.js"npm start
to run the applicationPackage.json should look something like:
{
"name": "dragdrop",
"version": "1.0.0",
"description": "Test of drag and drop for unfocused electron window.",
"main": "app/main.js",
"scripts": {
"start": "electron .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/aloworld/dragdrop.git"
},
"keywords": [
"drag",
"drop",
"electron"
],
"author": "Albert",
"license": "MIT",
"bugs": {
"url": "https://github.com/aloworld/dragdrop/issues"
},
"homepage": "https://github.com/aloworld/dragdrop#readme",
"dependencies": {
"electron": "^24.1.1"
},
"devDependencies": {
"electron": "^24.1.1"
}
}