How Losing My Typora License Led to Building My Own Markdown Editor
Introduction
For years, Typora has been my go-to Markdown editor — it’s affordable, reliable, and powerful. But yesterday, out of nowhere, it asked for my license key and threw an error claiming my usage limit was exhausted. While waiting on support to reply, I browsed free and open-source alternatives, but nothing quite clicked. Frustrated and impatient, I decided to take matters into my own hands last night and tasked Bob with building one from scratch. What started as a quick experiment quickly turned into a fully functional Electron app. Taking it a step further, I had Bob rewrite the entire application in Rust and package distributable builds for both versions. Today, both apps are completely open-source and ready to use — feel free to grab them!
Building the Desktop Foundation (Electron & React)
When building a high-performance desktop editor, selecting an extensible, cross-platform architecture is critical. The first edition of the Markdown editor was built using Electron, React 18, and TypeScript. This architecture decouples the main process handling system events and file storage from the isolated renderer process responsible for live preview rendering, syntax highlighting, and Mermaid diagram execution.
Architectural Layout & Communication
To ensure safety and performance, the application enforces Context Isolation and disables Node Integration in the Chromium renderer process. The renderer interacts with native desktop APIs strictly via an IPC (Inter-Process Communication) context bridge.
┌──────────────────────────────────────────────┐
│ Main Process (Node.js) │
│ - Window Lifecycle Management │
│ - System Menu Bar Construction │
│ - Native Dialogs & File Read/Write (fs) │
└──────────────────────┬───────────────────────┘
│ IPC Channels
│ (file:open, file:save, etc.)
┌──────────────────────▼───────────────────────┐
│ Renderer Process (Chromium) │
│ - React UI & Document State │
│ - Real-time Markdown Parsing (markdown-it) │
│ - Local Mermaid & Highlight.js Rendering │
└──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
Core Module Implementation
Native Desktop Bridge & File Operations
The main process configures the application window, constructs the system menu, and exposes secure IPC handlers to safely execute disk operations:
// src/main/index.ts
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import path from 'path';
import fs from 'fs';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
preload: path.join(__dirname, '../renderer/index.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
const startUrl = process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../renderer/index.html')}`;
mainWindow.loadURL(startUrl);
}
// IPC Handlers for Native File Dialogs
ipcMain.handle('file:open', async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile'],
filters: [
{ name: 'Markdown Files', extensions: ['md'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (!result.canceled && result.filePaths.length > 0) {
const content = fs.readFileSync(result.filePaths[0], 'utf-8');
return {
path: result.filePaths[0],
content,
name: path.basename(result.filePaths[0]),
};
}
return null;
});
ipcMain.handle('file:save', async (_event, content: string, filePath?: string) => {
let targetPath = filePath;
if (!targetPath) {
const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: 'document.md',
filters: [{ name: 'Markdown Files', extensions: ['md'] }],
});
if (result.canceled) return null;
targetPath = result.filePath;
}
if (targetPath) {
fs.writeFileSync(targetPath, content, 'utf-8');
return { path: targetPath, name: path.basename(targetPath) };
}
return null;
});
Enter fullscreen mode Exit fullscreen mode
Pure Formatting Engine
To keep state changes predictable and completely testable outside of the UI rendering cycle, document manipulations (wrapping selections, prefixing lines, and inserting dynamic blocks) are decoupled into a pure state transformer:
// src/lib/applyAction.ts
export interface TextareaState {
value: string;
selectionStart: number;
selectionEnd: number;
}
export function applyAction(
textarea: TextareaState,
action: MenuAction,
onChange: (v: string) => void
): { newValue: string; newStart: number; newEnd: number } {
const { selectionStart: start, selectionEnd: end, value } = textarea;
let newValue = value;
let newStart = start;
let newEnd = end;
if (action.type === 'wrap') {
const before = action.before ?? '';
const after = action.after ?? '';
const selected = value.slice(start, end) || action.placeholder || '';
const replacement = `${before}${selected}${after}`;
newValue = value.slice(0, start) + replacement + value.slice(end);
newStart = start + before.length;
newEnd = newStart + selected.length;
} else if (action.type === 'linePrefix') {
const prefix = action.prefix ?? '';
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEndIdx = value.indexOf('\n', end);
const effectiveEnd = lineEndIdx === -1 ? value.length : lineEndIdx;
const block = value.slice(lineStart, effectiveEnd);
const lines = block.split('\n');
const allPrefixed = prefix.length > 0 && lines.every((l) => l.startsWith(prefix));
let newLines: string[];
if (allPrefixed) {
// Toggle off prefix
newLines = lines.map((l) => l.slice(prefix.length));
} else if (prefix.match(/^#{1,6} /)) {
// Heading switch: replace old heading level marker
newLines = lines.map((l) => prefix + l.replace(/^#{1,6} /, ''));
} else {
newLines = lines.map((l) => prefix + l);
}
const newBlock = newLines.join('\n');
newValue = value.slice(0, lineStart) + newBlock + value.slice(effectiveEnd);
newStart = lineStart;
newEnd = lineStart + newBlock.length;
}
if (action.type !== 'custom') {
onChange(newValue);
}
return { newValue, newStart, newEnd };
}
Enter fullscreen mode Exit fullscreen mode
Automated Formatting Verification
The pure formatting logic is thoroughly validated using vitest unit tests to ensure edge cases—such as multi-line prefix toggles or mid-string cursor placements—are handled cleanly:
// tests/applyAction.test.ts
import { describe, it, expect, vi } from 'vitest';
import { applyAction } from './applyAction';
describe('applyAction – linePrefix & wrap', () => {
it('wraps selected text with bold markers', () => {
const state = { value: 'hello world', selectionStart: 6, selectionEnd: 11 };
const onChange = vi.fn();
const { newValue, newStart, newEnd } = applyAction(
state,
{ type: 'wrap', before: '**', after: '**', placeholder: 'text' },
onChange
);
expect(newValue).toBe('hello **world**');
expect(onChange).toHaveBeenCalledWith('hello **world**');
expect(newStart).toBe(8);
expect(newEnd).toBe(13);
});
it('switches heading level, removing old marker', () => {
const state = { value: '# Old heading', selectionStart: 0, selectionEnd: 13 };
const onChange = vi.fn();
const { newValue } = applyAction(
state,
{ type: 'linePrefix', prefix: '## ', placeholder: 'Heading 2' },
onChange
);
expect(newValue).toBe('## Old heading');
});
});
Enter fullscreen mode Exit fullscreen mode
Rewriting with Rust & Tauri for Maximum Performance
While Electron allowed us to prototype a fully featured Markdown editor rapidly, bundling a ~150 MB Chromium binary and consuming ~200 MB of RAM at idle felt unnecessarily heavy. To create a lightweight, blazingly fast alternative, we rebuilt the application core using Rust and Tauri v2.
By leveraging Tauri, the application delegates rendering to the OS’s built-in web engine (WKWebView on macOS) while handing off high-performance logic, native menu creation, and non-blocking disk I/O entirely to a compiled Rust backend.
Architectural Overview
In the Tauri edition, the React UI runs inside the native WebKit window, while all system integrations and file mutations happen on asynchronously spawned Rust tasks.
┌──────────────────────────────────────────────────────────┐
│ Native Desktop Window (Rust) │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ WKWebView (OS Native Engine) │ │
│ │ - React 18 UI / Live Preview / Local Mermaid │ │
│ └─────────────────────────┬──────────────────────────┘ │
│ │ │
│ IPC Channels (invoke / listen) │
│ │ │
│ ┌─────────────────────────▼──────────────────────────┐ │
│ │ Compiled Rust Backend │ │
│ │ - Non-blocking File Dialogs (oneshot channels) │ │
│ │ - Async Disk Write Operations │ │
│ │ - Native macOS System Menu Construction │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
Core Rust Implementation Details
Application Entry Point & Plugin Initializer
The main entry point sets up system-level plugins (file dialogs, shell handling, filesystem access), mounts custom commands, and constructs the native application menu during setup.
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
mod menu;
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
commands::file_open,
commands::file_save,
commands::file_save_as,
commands::file_export_html,
])
.setup(|app| {
menu::build_menu(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode
Non-Blocking File Dialogs & Async Disk Operations
To prevent blocking the Tokio runtime thread or freezing UI animations while waiting for a user to choose a file path, custom pickers utilize asynchronous oneshot channels to yield execution back to the event loop.
// src-tauri/src/commands.rs
use std::path::PathBuf;
use tauri::AppHandle;
use tauri_plugin_dialog::{DialogExt, FilePath};
use tokio::sync::oneshot;
#[derive(serde::Serialize)]
pub struct FileResult {
pub path: String,
pub name: String,
pub content: Option<String>,
}
/// Asynchronous, non-blocking native file picker using Tokio oneshot channels
async fn pick_open_file(app: &AppHandle) -> Option<PathBuf> {
let (tx, rx) = oneshot::channel();
app.dialog()
.file()
.add_filter("Markdown Files", &["md"])
.add_filter("All Files", &["*"])
.pick_file(move |fp| {
let _ = tx.send(fp);
});
match rx.await.ok()? {
Some(FilePath::Path(p)) => Some(p),
_ => None,
}
}
/// Open a Markdown file and return its metadata + content
#[tauri::command]
pub async fn file_open(app: AppHandle) -> Result<Option<FileResult>, String> {
let Some(path) = pick_open_file(&app).await else {
return Ok(None);
};
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Ok(Some(FileResult {
path: path.to_string_lossy().to_string(),
name,
content: Some(content),
}))
}
/// Export live-rendered HTML output as a standalone file
#[tauri::command]
pub async fn file_export_html(
app: AppHandle,
html_content: String,
title: String,
) -> Result<Option<FileResult>, String> {
let default_name = format!("{}.html", title);
let Some(p) = pick_save_file(&app, &default_name, "html", "HTML Document").await else {
return Ok(None);
};
let document = format!(
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n</head>\n<body>\n{}\n</body>\n</html>",
title, html_content
);
std::fs::write(&p, document).map_err(|e| e.to_string())?;
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
Ok(Some(FileResult {
path: p.to_string_lossy().to_string(),
name,
content: None,
}))
}
Enter fullscreen mode Exit fullscreen mode
Native macOS Menu Routing & Event Dispatch
Tauri constructs native OS dropdown menus and maps native accelerator keys (such as Cmd+N, Cmd+S, Cmd+Shift+P) directly to event channels that emit clean messages directly into the React UI.
// src-tauri/src/menu.rs
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
App, Emitter,
};
pub fn build_menu(app: &mut App) -> tauri::Result<()> {
let h = app.handle();
// File Submenu
let file_menu = Submenu::with_items(h, "File", true, &[
&MenuItem::with_id(h, "file_new", "New", true, Some("CmdOrCtrl+N"))?,
&MenuItem::with_id(h, "file_open", "Open…", true, Some("CmdOrCtrl+O"))?,
&PredefinedMenuItem::separator(h)?,
&MenuItem::with_id(h, "file_save", "Save", true, Some("CmdOrCtrl+S"))?,
&MenuItem::with_id(h, "file_save_as", "Save As…", true, Some("CmdOrCtrl+Shift+S"))?,
&PredefinedMenuItem::separator(h)?,
&MenuItem::with_id(h, "file_export", "Export as HTML…", true, Some("CmdOrCtrl+E"))?,
&PredefinedMenuItem::separator(h)?,
&PredefinedMenuItem::close_window(h, Some("Close"))?,
])?;
let menu = Menu::with_items(h, &[&file_menu])?;
app.set_menu(menu)?;
// Global Menu Event Handler
app.on_menu_event(move |app_handle, event| {
let id = event.id().0.as_str();
let payload_channel = match id {
"file_new" => "menu:new",
"file_open" => "menu:open",
"file_save" => "menu:save",
"file_save_as" => "menu:saveAs",
"file_export" => "menu:exportHtml",
_ => return,
};
// Emit native event directly into WKWebView JS context
let _ = app_handle.emit(payload_channel, ());
});
Ok(())
}
Enter fullscreen mode Exit fullscreen mode
Both application have the same exact UI
And once the build is made, either app could be launched through an icon!
Conclusion
What started as a sudden licensing headache turned into one of the most rewarding weekend projects I’ve tackled in a while. Having an AI partner like Bob made bridging the gap from concept to a native, dual-architecture desktop app surprisingly fast — and now I have a lightweight, tailored editor that fits my workflow perfectly. Both the Electron and Rust/Tauri versions are completely open-source, so feel free to explore the repository, grab the latest builds, or even customize the code for your own setup. Happy writing, and may your Markdown files always render smoothly!
Thanks for reading 🎴
Links
- Github repo for this post: https://github.com/aairom/aam-markdown-editor
- IBM Bob: https://bob.ibm.com/






0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.