Building a Local Markdown Editor with Electron, React, and Typescript
This tutorial explains how to create a desktop Markdown editor using Electron with a React UI, Typescript for development, Parcel for bundling, and includes setup of main and renderer processes, preload scripts, environment configuration, and unit testing.
This article introduces a step‑by‑step guide for building a local Markdown editor named ENotes using Electron as the desktop runtime, React for the UI, and Typescript for type‑safe development.
The project relies on two Electron processes: the main process, which handles system interactions via Node.js, and the renderer process, which renders the UI. Communication between them is performed through IPC.
When compiling the main code, the Webpack target electron-main is used so that native Node modules remain external. For the renderer, the newer preload approach allows the target web , keeping Node code out of the renderer bundle and improving security.
const win = new BrowserWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
nodeIntegration: true,
nodeIntegrationInSubFrames: true,
devTools: app.isPackaged ? false : true,
contextIsolation: true,
preload: path.join(app.getAppPath(), './static/preload.js'),
},
})The guide then shows how to initialise the project:
mkdir enotes
cd enotes
yarn init -yand install dependencies for React, Parcel, and Typescript:
yarn add react react-dom
yarn add parcel typescript @types/react @types/react-dom -DA tsconfig.json is generated with Chinese comments by using the --locale zh-cn flag.
Directory structure after setup:
├── package.json
├── public
│ └── template.html
├── src
│ └── renderer
│ └── index.tsx
├── tsconfig.json
└── yarn.lockThe template.html file loads the compiled renderer script via a script tag with type="module" . Parcel is used to serve the HTML during development:
yarn parcel public/template.htmlFor the main process, the article provides a minimal src/main/index.ts that creates a window and loads either the development URL or the packaged HTML file:
import { app, BrowserWindow } from "electron";
import path from "path";
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
devTools: app.isPackaged ? false : true,
},
});
return win;
}
app.whenReady().then(() => {
const win = createWindow();
app.isPackaged
? win.loadFile(path.join(app.getAppPath(), "dist", "template.html"))
: win.loadURL("http://localhost:1234");
win.webContents.on("dom-ready", () => win.show());
});Parcel is also used to bundle the main process code via a config/main.mjs file:
import { Parcel } from "@parcel/core";
let bundler = new Parcel({
entries: "./src/main/index.ts",
defaultConfig: "@parcel/config-default",
targets: {
main: {
distDir: "dist",
context: "electron-main",
},
},
});
await bundler.run();Scripts in package.json use concurrently to run the renderer, preload, and main processes together:
{
"scripts": {
"start": "concurrently \"npm:start:renderer\" \"npm:start:preload\" \"npm:start:main\"",
"start:renderer": "parcel public/template.html",
"start:preload": "node config/preload.mjs",
"start:main": "node config/main.mjs && electron dist/index.js"
}
}A simple preload script exposes a Bridge object to the renderer via contextBridge.exposeInMainWorld :
import { contextBridge } from "electron";
contextBridge.exposeInMainWorld("Bridge", {
test: () => {
console.log("bridge is working");
},
});The article also demonstrates adding a showMessage method that forwards a dialog request to the main process using ipcRenderer.invoke and ipcMain.handle .
Environment variables for development and production are managed with cross-env :
"start:main": "cross-env NODE_ENV=development node config/main.mjs && electron dist/index.js"Unit testing is set up with Vitest . A utility function getFileNameWithoutExt is provided as an example, along with a corresponding test file.
// src/utils/node/index.ts
import { basename, extname } from "path";
export function getFileNameWithoutExt(filePath: string) {
const name = basename(filePath);
const ext = extname(filePath);
return name.substring(0, name.lastIndexOf(ext));
}
// src/utils/node/__tests__/index.test.ts
import { describe, test, expect } from 'vitest';
import { getFileNameWithoutExt } from '..';
describe('utils', () => {
test('getFileName', () => {
const filePath = '/xx/test.md';
const fileName = getFileNameWithoutExt(filePath);
expect(fileName).toEqual('test');
});
});Finally, the author concludes that using Parcel for bundling simplifies the workflow compared to Webpack, and the complete source code is available in the ENotes repository.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.