Search code examples
javascriptnode.jsmacoselectronsandbox

Electron Drag & Drop Into Unfocused Window


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)


Solution

  • 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
    • adjust "main" item on package.json to point to "./app/main.js"
    • and under "scripts" section on package.json, add entry "start": "electron ." in order to be able to use
    • npm start to run the application

    Package.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"
      }
    }