A modern, cross-platform desktop text editor built with Tauri 2 + React + TypeScript. I wanted a lightweight, Notepad-style editor that also featured a rich Markdown WYSIWYG editor (via Milkdown Crepe) and multi-tab support. I couldn't find a free tool that did both well, so I built my own and open-sourced it.
After first launch you will see a blank Markdown tab. Open any
.mdfile to get the full WYSIWYG experience.
| Feature | Status |
|---|---|
| File → New Tab | ✅ |
| File → New Window | ❌ |
| File → Open / Save / Save As | ✅ |
| File → Print / Page Setup | |
| Edit → Undo / Redo | ✅ |
| Edit → Cut / Copy / Paste / Delete | ✅ |
| Edit → Find / Replace | ✅ |
| Edit → Go To Line | ✅ |
| Edit → Select All | ✅ |
| Edit → Time/Date (F5) | ✅ |
| View → Zoom In / Out / Reset | ✅ (via View menu for now) |
| View → Word Wrap toggle | ✅ |
| View → Status Bar toggle | ✅ |
| Unsaved-changes warning on close | ✅ |
| Feature | Details |
|---|---|
| Multi-tab editing | Drag-and-drop reorder, middle-click close, Ctrl+T / Ctrl+W |
| Markdown WYSIWYG | Rich editor via Milkdown Crepe with slash commands, toolbar, tables, code blocks, images |
| Per-tab word wrap | Each tab remembers its own word-wrap state |
| Global settings | Theme, font size, word wrap default |
| Persistent settings | Saved to disk via tauri-plugin-store, survives restarts |
| Themes | System / Light / Dark — reactive, no restart needed |
| DOCX Export/Import | Export Markdown to Word (.docx) with embedded images, import Word to Markdown |
| Recently Opened | File menu tracks recently opened files |
| "Open With" | Right-click any .txt or .md file → open in Jotter |
| Single-instance | Opening a file while the app is running focuses the existing window |
| Deep links | notepadpro:// scheme for file associations |
| Status bar | Live line/col count, line count, char count, word-wrap indicator |
| Layer | Technology |
|---|---|
| Desktop runtime | Tauri 2 (Rust) |
| Frontend framework | React 18 + TypeScript |
| Build tool | Vite 6 |
| Styling | Tailwind CSS v4 + CSS custom properties |
| UI primitives | Radix UI |
| State management | Zustand 5 + Immer |
| Settings persistence | tauri-plugin-store |
| Markdown editor | Milkdown + Crepe preset |
| Tab drag-and-drop | @dnd-kit/core + @dnd-kit/sortable |
| DOCX export/import | docx (export) + mammoth + turndown (import) |
| Icons | Lucide React |
Download the latest compiled version for your platform from the Releases page.
- Windows:
.exeor.msiinstaller - macOS:
.dmg(Universal) - Linux:
.AppImage,.deb, or.rpm
You need these installed before anything else.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env# Using nvm (recommended)
nvm install 20 && nvm use 20
# Or download from https://nodejs.orgsudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
pkg-configXcode Command Line Tools are required:
xcode-select --installImportant: macOS builds cannot be cross-compiled from Windows or Linux. You must either develop on a Mac or use the provided GitHub Actions workflow (which uses Apple's hosted runners).
git clone https://github.com/POWERHACK69/Jotter.git
cd Jotter
# Install JavaScript dependencies
npm installReplace the placeholder icons with your own 1024×1024 PNG, then run:
npx tauri icon path/to/your-icon.pngThis auto-generates all required sizes for Windows (.ico), macOS (.icns), and Linux (.png).
npm run tauri devThis starts Vite's dev server on port 1420 and launches the Tauri window. Hot-module replacement works for all React components. Rust changes require a restart.
npm run tauri buildOutputs are placed in src-tauri/target/release/bundle/:
| Platform | Format | Location |
|---|---|---|
| Windows | .msi + .exe (NSIS) |
bundle/msi/, bundle/nsis/ |
| macOS | .dmg + .app |
bundle/dmg/, bundle/macos/ |
| Linux | .deb + .AppImage + .rpm |
bundle/deb/, bundle/appimage/, bundle/rpm/ |
Jotter/
├── .github/
│ └── workflows/
│ └── release.yml # Cross-platform CI/CD (Win + Linux + macOS arm64 + x64)
│
├── src/ # React / TypeScript frontend
│ ├── components/
│ │ ├── editor/
│ │ │ ├── EditorPane.tsx # Orchestrator: renders active editor
│ │ │ ├── MarkdownEditor.tsx # Milkdown + Crepe WYSIWYG
│ │ │ └── EmptyState.tsx # No-tabs-open screen
│ │ ├── menu/
│ │ │ ├── MenuBar.tsx # Root + shared primitives
│ │ │ ├── FileMenu.tsx
│ │ │ ├── EditMenu.tsx
│ │ │ └── ViewMenu.tsx
│ │ ├── tabs/
│ │ │ └── TabBar.tsx # DnD sortable tab bar
│ │ ├── dialogs/
│ │ │ ├── FindReplaceDialog.tsx # Floating find/replace panel
│ │ │ ├── GoToDialog.tsx # Go To Line modal
│ │ │ ├── SettingsModal.tsx # Global preferences modal
│ │ │ └── AboutDialog.tsx # About & help dialog
│ │ └── StatusBar.tsx
│ │
│ ├── hooks/
│ │ ├── useAutoUpdater.ts # Checks for app updates on startup (stub)
│ │ ├── useDarkMode.ts # MutationObserver on <html> class
│ │ ├── useFileOperations.ts # All file I/O — open, save, close
│ │ ├── useKeyboardShortcuts.ts # Global hotkey registration
│ │ ├── useOpenFileEvents.ts # "Open With" while app is running
│ │ ├── useRecentFiles.ts # Recently Opened files list (persisted)
│ │ ├── useTheme.ts # Applies theme to <html>
│ │ └── useWindowCloseGuard.ts # Intercepts OS close → dirty tab check
│ │
│ ├── store/
│ │ ├── useAppStore.ts # Zustand store (tabs + settings)
│ │ └── settingsStore.ts # tauri-plugin-store bridge
│ │
│ ├── styles/
│ │ └── milkdown.css # Crepe theme override (CSS variables)
│ │
│ ├── types/
│ │ └── index.ts # Tab, AppSettings, EditorFeatures types
│ │
│ ├── lib/
│ │ ├── utils.ts # cn(), fileNameFromPath(), isMarkdownFile()
│ │ ├── docxUtils.ts # DOCX export/import (markdown ↔ .docx)
│ │ └── gdscript.ts # GDScript syntax for code blocks
│ │
│ ├── App.tsx # Root component + startup sequence
│ ├── main.tsx # React entry point
│ └── index.css # Tailwind v4 + CSS custom properties
│
├── src-tauri/ # Rust / Tauri backend
│ ├── src/
│ │ ├── main.rs # Builder + plugin registration
│ │ ├── commands.rs # Tauri commands (CLI args, file I/O, reveal in folder)
│ │ ├── file_associations.rs # macOS "Open With" via deep-link plugin
│ │ └── single_instance.rs # Single-instance: forward args to existing window
│ ├── entitlements.plist # macOS sandbox entitlements
│ ├── Info.plist # macOS file type + URL scheme registration
│ ├── Cargo.toml
│ ├── build.rs
│ └── tauri.conf.json # App config, bundle targets, file associations
│
├── index.html
├── vite.config.ts
├── tsconfig.json
└── package.json
| Shortcut | Action |
|---|---|
Ctrl+T |
New tab |
Ctrl+O |
Open file |
Ctrl+S |
Save |
Ctrl+Shift+S |
Save As |
Ctrl+W |
Close active tab |
Ctrl+Z |
Undo |
Ctrl+Y |
Redo |
Ctrl+X / C / V |
Cut / Copy / Paste |
Ctrl+A |
Select all |
Ctrl+F |
Find |
Ctrl+R |
Replace |
F5 |
Insert current time/date |
The following shortcuts are available via the menu bar:
Ctrl+N— New Window (File menu) ❌Ctrl+G— Go to Line (Edit menu)⚠️ Ctrl+,— Open Settings (File menu)Ctrl+=/Ctrl+-— Zoom in / out (View menu) ❌Ctrl+0— Reset zoom (View menu) ❌
Each tab carries its own complete state: filePath, content, isDirty, wordWrap. The key={activeTab.id} prop on the editor component forces React to fully unmount and remount the editor when switching tabs — this prevents stale internal state from leaking between documents.
Milkdown's markdownUpdated listener does not reliably fire for paste events. When a user pastes content without making another edit, the Zustand store never receives the updated content, and switching tabs (which triggers a full remount) would lose the pasted text. To work around this, the editor attaches a paste event listener on the ProseMirror element that force-syncs the full markdown to the store after a setTimeout(fn, 0) delay — long enough for Milkdown's internal paste processing to complete.
Disk (tauri-plugin-store)
↕ loadSettingsFromDisk / saveSettingsToDisk
Zustand (useAppStore)
↕ props / hooks
React Components
↕ onChange callbacks
Zustand (setTabContent)
↕ writeTextFile (on Save)
Disk (fs)
The Rust backend calls api.prevent_close() on every close attempt and emits a close-requested event. The React useWindowCloseGuard hook intercepts this, iterates over all dirty tabs one by one (activating each so the user sees which file they're deciding about), shows a native save dialog, and only calls exit(0) once all tabs are resolved. If the user cancels at any point, the close is aborted entirely.
Open File → Settings or press Ctrl+,:
| Setting | Default | Description |
|---|---|---|
| Theme | System | System Default / Light / Dark |
| Font Size | 15px | Editor font size |
| Word Wrap by Default | On | Default for new tabs |
Settings are stored in your OS app data directory:
| Platform | Path |
|---|---|
| Windows | %APPDATA%\com.jotter.app\jotter.settings.json |
| macOS | ~/Library/Application Support/com.jotter.app/jotter.settings.json |
| Linux | ~/.local/share/com.jotter.app/jotter.settings.json |
- Push a tag:
git tag v0.1.0 && git push --tags - The workflow builds for all four targets (Linux x64, Windows x64, macOS arm64, macOS x64) and creates a draft GitHub Release with all installers attached.
- Review the draft, add release notes, and publish.
Add these secrets to your GitHub repository:
| Secret | Description |
|---|---|
APPLE_CERTIFICATE |
Base64-encoded .p12 certificate |
APPLE_CERTIFICATE_PASSWORD |
Password for the .p12 |
APPLE_SIGNING_IDENTITY |
e.g. Developer ID Application: Your Name (TEAMID) |
APPLE_ID |
Your Apple ID email |
APPLE_PASSWORD |
App-specific password from appleid.apple.com |
APPLE_TEAM_ID |
Your 10-character team ID |
Then uncomment the corresponding lines in .github/workflows/release.yml.
Generate a self-signed or purchased code signing certificate, then add:
| Secret | Description |
|---|---|
TAURI_SIGNING_PRIVATE_KEY |
Base64-encoded private key |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
Key password |
To enable in-app update checks, add to tauri.conf.json under plugins:
"updater": {
"endpoints": ["https://your-update-server.com/{{target}}/{{arch}}/{{current_version}}"],
"pubkey": "your-public-key-here"
}See the Tauri updater docs for server setup.
After installing the app:
File associations for .txt, .md, .log are registered automatically by the NSIS/MSI installer. Right-click any file → "Open with" → "Jotter".
The app appears in Finder's "Open With" menu for .txt, .md, .markdown, .log files automatically via Info.plist and CFBundleDocumentTypes.
Register manually after installing the .deb or .AppImage:
# If using the .deb installer, this is done automatically.
# For AppImage, register manually:
xdg-mime default jotter.desktop text/plain
xdg-mime default jotter.desktop text/markdownOpen a file directly from a URL:
jotter://open?path=/absolute/path/to/file.txt
Note: The deep link scheme is configured as jotter:// in tauri.conf.json. The exact URL format depends on your OS file association setup.
Edit the --accent CSS custom property in src/index.css:
:root {
--accent: 0 120 212; /* R G B — Windows blue by default */
}Add extensions to the fileAssociations array in src-tauri/tauri.conf.json and to the open dialog filters in src/hooks/useFileOperations.ts.
| Limitation | Notes |
|---|---|
| Print / Page Setup | Delegates to the browser's native print dialog. A dedicated print preview with margin control is not yet implemented. |
| Auto-updater | The scaffold is in place (useAutoUpdater.ts) but requires a distribution server and signing keys to activate. |
| macOS cross-compilation | Not possible from Windows/Linux. Must use macOS hardware or the provided GitHub Actions workflow. |
Vite HMR works for all frontend changes. For Rust changes, the Tauri process restarts automatically thanks to watch = { ignored = ["**/src-tauri/**"] } in vite.config.ts combined with cargo tauri dev's file watcher.
In dev mode, right-click anywhere in the window and choose Inspect Element (same as Chrome DevTools). In production builds this is disabled by default.
Delete the settings file for your platform (paths listed in the Configuration section above), or use the Tauri Store API from the DevTools console:
// In the DevTools console during development:
window.__TAURI__.invoke('plugin:store|clear', { path: 'jotter.settings.json' })- Fork the repo
- Create a feature branch:
git checkout -b feature/my-feature - Commit your changes:
git commit -m 'Add my feature' - Push:
git push origin feature/my-feature - Open a Pull Request
Please ensure:
- TypeScript strict mode passes (
npx tsc --noEmit) - No new
console.errorcalls without corresponding error handling - New Tauri commands are added to
invoke_handler!inmain.rs
This project was developed with the assistance of AI coding tools for code generation, and debugging.
MIT — see LICENSE for details. Feel free to contribute and fork!
