Frontend Development 10 min read

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.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Building a Local Markdown Editor with Electron, React, and Typescript

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 -y

and install dependencies for React, Parcel, and Typescript:

yarn add react react-dom
yarn add parcel typescript @types/react @types/react-dom -D

A 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.lock

The 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.html

For 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.

TypeScriptReactElectronPreloaddesktop-appMarkdown EditorParcel
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.