Markdown Editor
Build a native markdown editor with:
- Side-by-side editor + preview.
Cmd/Ctrl+O/Cmd/Ctrl+Sto open / save files.- Native File menu.
- Dirty-state tracking with preventable close.
End result: a ~10 MB .exe / .app / .AppImage.
Scaffold
bunx @tynd/cli create md-editor --framework react --runtime lite
cd md-editor
bun install
bun add markedBackend — open / save
Only the thing the WebView can’t do safely: file I/O. Everything else stays on the frontend.
backend/main.ts
import { app } from "@tynd/core";
app.start({
frontendDir: import.meta.dir + "/../dist",
window: {
title: "Markdown Editor",
width: 1280,
height: 800,
center: true,
},
menu: [
{
type: "submenu",
label: "File",
items: [
{ label: "Open", id: "file.open", accelerator: "CmdOrCtrl+O" },
{ label: "Save", id: "file.save", accelerator: "CmdOrCtrl+S" },
{ label: "Save As", id: "file.saveAs", accelerator: "CmdOrCtrl+Shift+S" },
{ type: "separator" },
{ role: "quit" },
],
},
{
type: "submenu",
label: "Edit",
items: [
{ role: "undo" }, { role: "redo" },
{ type: "separator" },
{ role: "cut" }, { role: "copy" }, { role: "paste" },
],
},
],
});That’s the whole backend. The file dialogs, reads, writes all go through OS APIs called directly from the frontend — no round-trip.
Frontend — editor + preview
src/App.tsx
import { useEffect, useRef, useState } from "react";
import { marked } from "marked";
import {
dialog,
fs,
menu,
tyndWindow,
} from "@tynd/core/client";
export default function App() {
const [path, setPath] = useState<string | null>(null);
const [body, setBody] = useState<string>("# Hello\n\nStart typing…");
const [saved, setSaved] = useState<string>(body);
const dirty = body !== saved;
// Window title reflects file + dirty state
useEffect(() => {
const name = path ? path.split(/[/\\]/).pop() : "Untitled";
void tyndWindow.setTitle(`${dirty ? "● " : ""}${name} — Markdown Editor`);
}, [path, dirty]);
// Menu handlers
useEffect(() => {
const offOpen = menu.onClick("file.open", openFile);
const offSave = menu.onClick("file.save", () => saveFile(false));
const offSaveAs = menu.onClick("file.saveAs", () => saveFile(true));
return () => { offOpen(); offSave(); offSaveAs(); };
}, [path, body]);
// Preventable close when dirty
useEffect(() => {
const off = tyndWindow.onCloseRequested(async (e) => {
if (!dirty) return;
e.preventDefault();
const keep = await dialog.confirm(
"You have unsaved changes. Close anyway?",
);
if (keep) {
await tyndWindow.close("main");
}
});
return off;
}, [dirty]);
async function openFile() {
const picked = await dialog.openFile({
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }],
});
if (!picked) return;
const text = await fs.readText(picked);
setPath(picked);
setBody(text);
setSaved(text);
}
async function saveFile(forceDialog: boolean) {
let dest = path;
if (!dest || forceDialog) {
const picked = await dialog.saveFile({
defaultPath: path ?? "untitled.md",
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (!picked) return;
dest = picked;
}
await fs.writeText(dest, body);
setPath(dest);
setSaved(body);
}
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", height: "100vh" }}>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
style={{
padding: "1rem",
border: "none",
outline: "none",
resize: "none",
fontFamily: "ui-monospace, monospace",
fontSize: 14,
}}
/>
<div
style={{ padding: "1rem", overflow: "auto", borderLeft: "1px solid #ddd" }}
dangerouslySetInnerHTML={{ __html: marked.parse(body) as string }}
/>
</div>
);
}Build
tynd build --bundleOutput in release/. On macOS, open the .app; on Windows, run the NSIS setup; on Linux, install the .deb.
What this tutorial covered
- Native menu bar with accelerators (
CmdOrCtrl+O,Cmd+S). menu.onClickhandlers that reach straight into frontend state.dialog.openFile/dialog.saveFilewithout a backend round-trip.fs.readText/fs.writeTextfor user files.tyndWindow.onCloseRequested+preventDefaultfor the “unsaved changes” dialog.- Reactive window title via
tyndWindow.setTitle.
Next ideas
- Watch the file on disk —
fs.watch(path, { recursive: false }, (e) => reload())to detect external edits. - Recent files list — persist to
createStore("com.example.mdeditor"). - Export to PDF — bundle a
wkhtmltopdfsidecar and run viaprocess.exec. - Sync with cloud —
http.postto your API with the body; auth token inkeyring.
Related
Last updated on