Convert handwritten notes (drawn with a stylus on a canvas) into structured Markdown, directly inside Obsidian. Works on both Windows (desktop) and Android (mobile with stylus).
The SVG embed is standard Obsidian wiki syntax (`![[_handwriting/hw_xxx.svg]]`), so the image appears in any Obsidian view and is readable by tools like Claude Code.
- **Stylus draws, finger scrolls** — on Android, a finger touch scrolls the canvas; the stylus draws. No conflict.
- **Canvas auto-expands** — as you draw near the bottom edge, the canvas grows automatically.
- **Horizontal lines** — the canvas shows ruled lines (like a notebook) as a visual guide; they appear in the saved SVG too.
- **Colors adapt to theme** — strokes drawn in black on a light theme are automatically remapped to white when you switch to dark theme (and vice versa).
---
### Portal Panel (Inline Controls)
When you hover over a handwriting image in your document, a small floating panel appears with four buttons:
| Button | Action |
|--------|--------|
| ✏️ | Open drawing editor |
| 📄 | Convert drawing to Markdown (OCR) directly from the preview |
| ↕️ | Collapse / expand the image preview |
| ✕ | Delete the block and its SVG file |
---
### OCR Conversion to Markdown
The plugin uses **Google Gemini** to recognize handwritten text and converts it to Markdown based on special keywords you write in the drawing.
Write these keywords in your drawing to produce structured Markdown output. All keywords start with `//` and are **case-insensitive** (`//list` = `//LIST`). The colon after the keyword name is **optional** (`//H1 Title` and `//H1: Title` both work).
Any keyword that accepts a comma-separated list (`//LIST`, `//NUMLIST`, `//CHECK`, `//TABLE` rows) supports **wrapping across lines**: if a line ends with a comma, the next line is automatically treated as a continuation.
```
//LIST groceries, milk, bread,
butter, eggs
```
Output:
```markdown
- groceries
- milk
- bread
- butter
- eggs
```
---
#### CHECK with Mixed States
Prefix any item with `x` or `X` (with or without brackets) to mark it as already checked:
```
//CHECK x bought milk, prepare slides, x sent email, review PR
```
Output:
```markdown
- [x] bought milk
- [ ] prepare slides
- [x] sent email
- [ ] review PR
```
---
#### Multi-line Callouts
The text on the keyword line becomes the callout **title**. Any lines that follow (up to the first blank line or next `//` keyword) become the callout **body**:
After conversion, the SVG is archived to `_handwriting/_converted/YYYY-MM-DD_HH-MM-SS.svg` and the drawing block is replaced with the generated Markdown.
---
### Settings
Open **Settings → Handwriting to Markdown** to configure:
| Setting | Description |
|---------|-------------|
| **Interface language** | Language for the settings UI. "Auto" follows Obsidian's language. |
| **SVG folder** | Vault subfolder where SVG drawing files are saved (default: `_handwriting`) |
> **Note — Free API key limitations:** With the free tier of Google AI Studio, your data may be used by Google to improve their models. Additionally, under high traffic you may see a **"Too many requests — please try again later"** error. To avoid rate limits, enable billing on [Google AI Studio](https://aistudio.google.com); costs are minimal for occasional OCR use.
The three files that Obsidian loads are: **`main.js`**, **`manifest.json`**, **`styles.css`**. Everything under `src/` is TypeScript source that gets compiled down to the single `main.js` by esbuild.
Obsidian plugins are JavaScript modules that run inside the Obsidian Electron app (desktop) or WebView (mobile). The key concepts:
#### 1. The Plugin Class (`src/main.ts`)
Every plugin exports a default class that extends Obsidian's `Plugin`. Obsidian calls **`onload()`** when the plugin is enabled and **`onunload()`** when it is disabled.
```typescript
export default class HandwritingPlugin extends Plugin {
#### 4. ItemView — The Drawing Editor Tab (`src/editor-view.ts`)
`DrawingEditorView extends ItemView` is an Obsidian **custom view** — a full tab with its own DOM. Key lifecycle methods:
-`getViewType()` — returns a unique string ID (`'handwriting-editor'`)
-`getDisplayText()` — the tab title
-`onOpen()` — called when the tab opens; here `buildEditor()` is called to build the canvas UI
-`onClose()` — called when the tab closes; cleanup (remove listeners, disconnect observers)
The view receives data (which SVG to load, which MD file to update) via `leaf.setViewState({ state: { svgPath, sourcePath, embedId } })`, read back in `getState()`.
#### 5. Modal — The Desktop Drawing Overlay (`src/editor-view.ts`)
`DrawingModal extends Modal` is an Obsidian **modal dialog** — a fullscreen overlay on desktop. Key methods:
-`onOpen()` — builds the canvas UI by calling `buildEditor()`
-`onClose()` — cleanup
-`this.close()` — closes the modal programmatically (used in the ← and ✕ buttons)
`Modal` and `ItemView` are completely different Obsidian base classes, which is why `buildEditorUI()` was extracted as a shared standalone function — both classes call it and pass their specific callbacks for save/close/delete.
#### 6. Code Block Processor (Legacy Format)
`registerMarkdownCodeBlockProcessor('handwriting', callback)` tells Obsidian: "when you render a ` ```handwriting ``` ` block, run my callback instead." The callback receives the block source text and the DOM element to fill. This is the legacy embed format.
#### 7. MutationObserver (Wiki Format)
For the new `![[svg]]` format, Obsidian renders the embed itself as a `<span class="internal-embed image-embed">`. The plugin cannot intercept this with a code block processor. Instead, a **MutationObserver** watches `document.body` for new nodes and decorates any span whose `src` attribute points to the `_handwriting/` folder. This happens in `registerEmbed()` in `embed.ts`.
#### 8. Settings (`src/settings.ts`)
Settings are stored as a JSON object in Obsidian's `data.json` (inside the plugin folder). `plugin.loadData()` reads it; `plugin.saveData(obj)` writes it. The `HandwritingSettings` interface defines the shape; `DEFAULT_SETTINGS` provides initial values. `HandwritingSettingTab extends PluginSettingTab` builds the settings UI using `new Setting(containerEl)`.
#### 9. The Build System
esbuild bundles all TypeScript files starting from `src/main.ts` into a single `main.js`. The `obsidian` package is marked **external** — it is provided at runtime by Obsidian itself and must never be bundled. esbuild does **not** run TypeScript type-checking — type errors are invisible at build time. To catch them: `npx tsc --noEmit`.
Two build modes:
-`npm run dev` → watch mode, inline sourcemap, not minified
-`node esbuild.config.mjs production` → single build, minified, no sourcemap
---
### What File to Open for a Given Task
| I want to… | Open this file |
|-----------|---------------|
| Change what happens when the plugin loads/unloads | `src/main.ts` → `onload()` / `onunload()` |
| Add or remove a command (`Ctrl+P`) | `src/main.ts` → `this.addCommand(...)` |
| Add or remove the ribbon icon | `src/main.ts` → `this.addRibbonIcon(...)` |
| Add an item to the right-click file menu | `src/main.ts` → `this.app.workspace.on('file-menu', ...)` |
| Change the ruler line spacing | `src/drawing-canvas.ts` → `export const LINE_SPACING` |
| Change how strokes are saved into / read from SVG | `src/svg-utils.ts` → `strokesToSvg()`, `svgToStrokes()` |
| Change how the SVG is converted to a PNG for OCR | `src/svg-utils.ts` → `svgToBase64Png()` |
| Change where archived SVGs go after conversion | `src/svg-utils.ts` → `archiveSvgFile()` |
| Change how the inline image preview is decorated | `src/embed.ts` → `tryDecorate()`, `decorateWikiEmbed()` |
| Add or change buttons in the portal panel overlay | `src/embed.ts` → `createPortalPanel()` |
| Change the OCR pipeline (what happens when "Convert" is clicked from the preview) | `src/embed.ts` → `runOcrPipeline()` |
| Change the drawing editor toolbar or canvas layout | `src/editor-view.ts` → `buildEditorUI()` |
| Change behavior specific to the desktop modal only | `src/editor-view.ts` → `DrawingModal` class |
| Change behavior specific to the Android tab only | `src/editor-view.ts` → `DrawingEditorView` class |
| Change the save / delete / convert logic inside the editor | `src/editor-view.ts` → `DrawingModal.doSave/doConvert/doDelete` or `DrawingEditorView.doSave/doConvert/doDelete` |
| Change which OCR model is called or the prompt sent to Gemini | `src/recognizer.ts` → `GeminiRecognizer.recognize()` |
| Change how OCR text is parsed into Markdown keywords | `src/md-parser.ts` → `parseHandwritingToMarkdown()`, `expandKeywords()` |
| Change how `//TABLE` blocks are parsed | `src/md-parser.ts` → table handling logic inside `parseHandwritingToMarkdown()` |
moves the .svg from _handwriting/ to _handwriting/_converted/YYYY-MM-DD_HH-MM-SS.svg
```
---
### CSS Class Naming Convention
All plugin CSS classes use the `hwm_` prefix (short for **H**and**W**riting **M**arkdown) to avoid collisions with Obsidian's own classes or other plugins.
All styles live in **`styles.css`** at the project root. There is no CSS-in-JS.
---
## Maintainability Cheat Sheet
This section is a quick reference for developers who need to extend or modify the plugin. Assumes familiarity with TypeScript and the Obsidian Plugin API.
---
### How to Add a Toolbar Button
The entire toolbar for both the desktop modal and the Android tab is built by the shared function `buildEditorUI()` in `src/editor-view.ts`. You only need to edit **one place**.
1.**Add the i18n key** (see [How to Add a Language Key](#how-to-add-a-language-key)).
2. Inside `buildEditorUI()`, find the toolbar section and call `mkBtn(toolbar, 'icon-name', 'your_i18n_key')`.
-`mkBtn` returns the button element if you need to attach a click handler.
`mkBtn(parent, icon, key)` is a module-level helper that creates a `<button>` with the Obsidian icon and the localized `title` attribute.
> **Why one place?** Before the refactor, `DrawingEditorView.buildEditor()` and `DrawingModal.buildEditor()` were two separate copies. The `buildEditorUI()` function eliminates that duplication.
---
### How to Add a Portal Panel Button
The portal panel (the floating overlay on the preview image) is built in `src/embed.ts` inside `createPortalPanel()`.
Find icon names in the [Obsidian Lucide icon set](https://lucide.dev/icons/).
---
### How to Add a Language Key (i18n)
The plugin has a simple i18n system. Locale files live in `src/locales/`.
1. Add the new key to **every** locale file (`en.json`, `it.json`, `de.json`, `fr.json`, `es.json`, `ru.json`, `ja.json`, `zh-cn.json`, `pt-br.json`, `pl.json`).
Always start with `en.json` (the fallback language).
2. Use the `t('your_key', plugin)` helper wherever you need the translated string.
The `t()` function falls back to `en.json` if the key is missing in the active locale.
---
### How to Add a New Language
1. Create `src/locales/XX.json` (where `XX` is the BCP-47 code, e.g. `ko` for Korean).
2. Copy all keys from `en.json` and translate the values.
3. In `src/settings.ts`, add the language to the `UI_LANGUAGES` array:
```typescript
{ code: 'ko', label: '한국어' }
```
4. In `src/settings.ts`, update the dynamic `import()` switch inside the `loadLocale()` function (or equivalent loader) to handle the new code.
---
### How to Add a Setting
Settings are defined in `src/settings.ts`.
1. Add the new field to the `HandwritingSettings` interface and to `DEFAULT_SETTINGS`.
2. In `HandwritingSettingTab.display()`, add a `new Setting(containerEl)` block with `.setName(t(...))`, `.setDesc(t(...))`, and the appropriate control (`.addText()`, `.addToggle()`, `.addDropdown()`, etc.).
3. Save the value in the control's `onChange` callback: `this.plugin.settings.yourField = value; await this.plugin.saveSettings();`.
---
### How to Add an OCR Keyword
Keywords are parsed in `src/md-parser.ts` and documented in `src/settings.ts`.
**Rule: both files must be updated together. They must stay in sync.**
1.**`src/md-parser.ts`** — in `expandKeywords()`, add a new `case` (or `if/else`) for the new `//KEYWORD`. Return the corresponding Markdown string.
2.**`src/settings.ts`** — in the `KEYWORDS` constant (displayed in the settings table), add a new row:
1.**Registration** — in `src/main.ts` → `onload()`, register a new processor (e.g. `this.registerMarkdownCodeBlockProcessor('new-format', ...)` or a new `MutationObserver` pattern).
2.**Detection** — in `src/embed.ts`, the `tryDecorate()` function checks for the wiki format. Add detection logic for your new format alongside it.
3.**Read/Write** — in `src/editor-view.ts`, the module-level helpers `wikiEmbedRegex()` / `codeBlockRegex()` and `replaceInMdFile()` handle finding and replacing the embed text in the `.md` file. Add a new regex + replacement branch for the new format. The `doSave`, `doConvert`, and `doDelete` callbacks passed to `buildEditorUI()` call these helpers — update them to try the new format as well.
4.**Backward compat** — always try the new format first, then fall back to wiki, then legacy code block (follow the existing fallback pattern in `replaceInMdFile`).