mirror of
https://github.com/gabriele-cusato/HandTranscriptMd.git
synced 2026-07-22 06:14:06 +00:00
primo commit, vedere claude.md per storico
This commit is contained in:
commit
d543fcdb97
27 changed files with 3353 additions and 0 deletions
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(cd \"C:/Projects/pluginObsidian/handWrittenMarkdownConverter/obsidian-sample-plugin\" && node esbuild.config.mjs production && bash cloudDeploy.sh)",
|
||||
"WebFetch(domain:groups.google.com)",
|
||||
"WebFetch(domain:css-tricks.com)",
|
||||
"WebFetch(domain:chromium.googlesource.com)",
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
HandTranscriptMd/main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Plugin di riferimento esterno (non fa parte di questo progetto)
|
||||
obsidian_ink/
|
||||
242
CLAUDE.md
Normal file
242
CLAUDE.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Obsidian Handwriting Plugin — Contesto per Claude Code
|
||||
|
||||
## Chi sono / Setup esistente
|
||||
|
||||
- Sviluppatore IT con esperienza in C#, JavaScript, TypeScript, Python, SQL Server, VB6
|
||||
- Vault Obsidian organizzato: `Project/CLIENTI/NOME_CLIENTE/Progetto/`
|
||||
- Sottocartella `_docs/` sincronizzata via **Google Drive**
|
||||
- Struttura: `01_Riunioni/`, `02_Documentazione/`, `03_UI_Diagrammi/`, `DECISIONS.md`, `IDEAS.md`, `TODO.md`
|
||||
- Su PC usa **Claude Code** che legge i file markdown direttamente
|
||||
- Tablet Android con pennino (in valutazione acquisto)
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo: Plugin Obsidian "Handwriting to Markdown"
|
||||
|
||||
### Cosa voglio
|
||||
|
||||
Un plugin Obsidian che inserisce un **riquadro canvas inline** in un file `.md` dove posso:
|
||||
|
||||
1. **Scrivere a mano con il pennino** (su tablet Android)
|
||||
2. Il testo scritto viene **convertito automaticamente in markdown strutturato**:
|
||||
- `# testo` → H1, `## testo` → H2, `### testo` → H3
|
||||
- `- testo` → lista, `1. testo` → lista numerata
|
||||
- `- [ ] testo` → checkbox, `- [x]` → checkbox spuntata
|
||||
- `> testo` → blockquote
|
||||
- `` `testo` `` → codice inline, ` ```js ... ``` ` → blocco codice
|
||||
- `==testo==` → highlight, `**testo**` → grassetto, `*testo*` → corsivo
|
||||
- `~~testo~~` → barrato, `---` → separatore
|
||||
3. Il risultato è **testo markdown puro** inserito nel file `.md` esistente
|
||||
4. I disegni/schemi restano come immagine SVG linkati nel markdown
|
||||
5. **Bidirezionale**: modificabile sia da tablet che da PC
|
||||
6. **Nessun formato proprietario** — i file devono essere leggibili anche senza il plugin installato
|
||||
|
||||
### Requisiti tecnici
|
||||
|
||||
- Funziona su **Android** (Obsidian Mobile) e **Windows**
|
||||
- Sync via **Google Drive** (già configurato)
|
||||
- I file `.md` devono restare leggibili da **Claude Code** su PC
|
||||
- Preferenza per soluzioni **senza dipendenze cloud** dove possibile
|
||||
|
||||
---
|
||||
|
||||
## Stato attuale — Fase 2 COMPLETATA, Fase 3 IN CORSO (fix Android handwriting)
|
||||
|
||||
### File del plugin (`HandTranscriptMd/src/`)
|
||||
|
||||
| File | Cosa fa |
|
||||
|------|---------|
|
||||
| `main.ts` | Entry point: registra embed, comando "Insert handwriting block", ribbon icon, settings |
|
||||
| `settings.ts` | Impostazioni: cartella SVG, dimensioni canvas, sfondo, lingue OCR, chiave API Gemini (campo password) |
|
||||
| `drawing-canvas.ts` | Motore disegno Canvas API: Bézier quadratiche, penna, gomma parziale, undo/redo history-based, auto-expand, righe foglio |
|
||||
| `svg-utils.ts` | Conversione tratti ↔ SVG (dati riedit in `<desc>` JSON), righe e sfondo inclusi nell'SVG |
|
||||
| `embed.ts` | Code block processor, toolbar, bottone Converti collegato (OCR → markdown → sostituisce code block), `svgToBase64Png()` helper |
|
||||
| `recognizer.ts` | `IRecognizer` interface + `GeminiRecognizer`: invia PNG base64 a Gemini API, restituisce testo |
|
||||
| `md-parser.ts` | `parseMarkdown()`: post-processa testo OCR riga per riga applicando sintassi markdown |
|
||||
|
||||
### Funzionalità implementate
|
||||
|
||||
- **Canvas inline** nel markdown via code block `handwriting`
|
||||
- **Disegno diretto** — click sul blocco = disegno immediato (no click "Edit")
|
||||
- **Curve smooth** — Bézier quadratiche con tecnica midpoint
|
||||
- **Gomma parziale** — cancella solo i punti toccati, taglia i tratti in segmenti
|
||||
- **Undo/Redo** — basato su history di stati (funziona sia per disegno che per gomma)
|
||||
- **Auto-expand** — il canvas si espande con animazione smooth (requestAnimationFrame, ease-out cubico)
|
||||
- **Resize manuale** — handle trascinabile in basso
|
||||
- **Clear → reset** alla dimensione di default con animazione
|
||||
- **Righe orizzontali** — foglio a righe (32px), sia nel canvas che nell'SVG
|
||||
- **Temi sfondo** — chiaro/scuro/custom con color picker nelle impostazioni
|
||||
- **Remapping colori automatico** — i tratti si adattano al cambio tema (nero↔bianco, blu↔azzurro, ecc.)
|
||||
- **Toolbar completa** — penna, gomma, 4 colori, undo, redo, clear, converti, salva, elimina (X)
|
||||
- **Elimina riquadro** — bottone X rimuove code block dal .md e cancella SVG
|
||||
- **Auto-save** — salvataggio debounced 2s dopo l'ultima modifica
|
||||
- **SVG standard** — file `.svg` nella cartella `_handwriting/`, visibili da qualsiasi dispositivo
|
||||
- **Palette colori adattiva** — colori scuri su sfondo chiaro, colori chiari su sfondo scuro
|
||||
- **Resize handle tematizzato** — si adatta al tema scuro
|
||||
- **OCR via Gemini** — bottone Converti: SVG → PNG base64 → Gemini 3.1 Flash Lite → testo → `md-parser` → sostituisce code block nel `.md`
|
||||
- **Archiviazione SVG** — dopo la conversione, il file SVG viene spostato in `_handwriting/_converted/AAAA-MM-GG_HH-MM-SS.svg`
|
||||
- **Settings OCR** — chiave API Gemini (campo password) + lingue OCR configurabili (default: `it, en`)
|
||||
- **Supporto Android** — comportamenti differenziati tra Windows e Android:
|
||||
- Toolbar compatta su mobile (▼/▲ toggle), espansa su Windows
|
||||
- Icone SVG inline (mappa `ICONS` in `embed.ts`) — identiche su Windows e Android, nessuna dipendenza da `setIcon`/Lucide bundled — **RISOLTO**
|
||||
- Disegno solo con penna (`pointerType === 'pen'`), dito ignorato su mobile per il disegno
|
||||
- Focus fix multi-livello: `inputmode="none"` + listener `beforeinput` in capture phase + blur `.cm-editor` + `canvas.focus()` su pointerdown penna
|
||||
- Cerchi colori: bottoni colore sono `<div>` (non `<button>`) + dimensioni forzate via `style.setProperty(..., 'important')` — **RISOLTO**
|
||||
|
||||
### Embedding nel markdown
|
||||
|
||||
````markdown
|
||||
```handwriting
|
||||
{"id":"hw_abc123","svg":"_handwriting/hw_abc123.svg"}
|
||||
```
|
||||
````
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
# Sorgente plugin
|
||||
cd C:/Projects/pluginObsidian/handWrittenMarkdownConverter/HandTranscriptMd
|
||||
|
||||
# Build + deploy al vault locale (solo PC)
|
||||
node esbuild.config.mjs production && bash deploy.sh
|
||||
|
||||
# Build + deploy su Google Drive (per testare su tablet Android)
|
||||
node esbuild.config.mjs production && bash cloudDeploy.sh
|
||||
|
||||
# Vault locale di test
|
||||
C:/Projects/CLIENTI/IOTTI/IOTTI_APP/_docs/handwriting-to-markdown/
|
||||
# Plugin installato in .obsidian/plugins/handwriting-to-markdown/
|
||||
|
||||
# Vault Google Drive (sincronizzato con tablet)
|
||||
C:/Users/gabri/Il mio Drive (gabrielecusato@gmail.com)/Projects/handwriting-to-markdown/
|
||||
```
|
||||
|
||||
### Come sviluppare
|
||||
|
||||
```bash
|
||||
# Dev mode (watch)
|
||||
npm run dev
|
||||
# Dopo ogni modifica per testare su PC:
|
||||
bash deploy.sh
|
||||
# Dopo ogni modifica per testare su tablet Android:
|
||||
bash cloudDeploy.sh
|
||||
# In Obsidian: Ctrl+P → "Reload app without saving"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Note architetturali — Fase 2 (sessione corrente)
|
||||
|
||||
### Come funziona il flusso OCR
|
||||
|
||||
1. `canvas.getStrokes()` → array di `Stroke[]`
|
||||
2. `strokesToSvg()` → stringa SVG (già usata per il salvataggio)
|
||||
3. `DOMParser` → `SVGElement` DOM
|
||||
4. `svgToBase64Png(svgEl)` → PNG base64 via canvas HTML temporaneo (Blob URL → Image → canvas `toDataURL`)
|
||||
5. `GeminiRecognizer.recognize(base64)` → POST a Gemini con `inline_data` + prompt
|
||||
6. `parseMarkdown(testo)` → post-processing riga per riga
|
||||
7. `archiveSvg()` → sposta SVG in `_converted/` con nome timestamp
|
||||
8. `replaceEmbedWithMarkdown()` → regex sul file `.md` sostituisce il code block
|
||||
|
||||
### Perché Gemini e non API native Android
|
||||
|
||||
- `navigator.createHandwritingRecognizer` era un Origin Trial Chrome sperimentale, mai arrivato a stable
|
||||
- Obsidian Mobile usa WebView → API non disponibile su Xiaomi Pad 5 né altri dispositivi
|
||||
- `window.prompt()` non funziona in Electron (Obsidian desktop)
|
||||
- Gemini REST API funziona identicamente su Windows e Android
|
||||
|
||||
### Modello Gemini usato
|
||||
|
||||
`gemini-3.1-flash-lite-preview` — documentazione: https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite-preview
|
||||
|
||||
---
|
||||
|
||||
## Prossimi passi — Da fare nella prossima sessione
|
||||
|
||||
1. **Migliorare il riconoscimento dei caratteri speciali markdown** — `src/recognizer.ts` + `src/md-parser.ts`:
|
||||
- Il testo normale viene riconosciuto correttamente da Gemini
|
||||
- Il problema riguarda solo i **simboli markdown**: `#`, `-`, `>`, `**`, `==`, ecc. non vengono riconosciuti/applicati correttamente
|
||||
- Migliorare il prompt con few-shot examples espliciti per i simboli (es. "Se vedi `# Titolo` → scrivi `# Titolo`")
|
||||
- Valutare se `md-parser.ts` può coprire i casi che il prompt non gestisce
|
||||
|
||||
2. **Fix handwriting Android — IN CORSO (15+ tentativi)**:
|
||||
|
||||
### Problema fondamentale
|
||||
Il canvas vive dentro `cm-content[contenteditable="true"]` (l'editor CodeMirror di Obsidian). Android/Chrome attiva lo stylus handwriting-to-text a **livello nativo** quando il pennino tocca dentro/vicino (40dp) a un elemento `contenteditable`. Questo avviene **prima** di qualsiasi evento JavaScript.
|
||||
|
||||
### Perché è sempre "tutto o niente"
|
||||
`cm-content` è un UNICO elemento `contenteditable="true"`. Qualsiasi modifica all'editabilità di un figlio contamina lo stato dell'intero editor.
|
||||
|
||||
### Tentativi falliti e scoperte chiave
|
||||
|
||||
| # | Approccio | Risultato | Perché non funziona |
|
||||
|---|-----------|-----------|---------------------|
|
||||
| 1-8 | `touch-action: none` su `.cm-editor`/`.cm-scroller`/`.cm-content` | Handwriting resta attivo | `touch-action` controlla scroll/zoom, NON l'handwriting |
|
||||
| 9 | `contenteditable="false"` + `handwriting="false"` sul **container** (figlio di cm-content) | Handwriting morto ovunque | Chrome tratta l'intero cm-content come blocco; un figlio false rompe tutto |
|
||||
| 10 | CSS `-webkit-user-modify: read-only` sul container | Handwriting morto ovunque | Stesso problema: figlio dentro cm-content |
|
||||
| 11 | `blur()` + `focus(canvas)` su `pointerenter pen` | Handwriting resta attivo | Rilevamento basato su **prossimità** al contenteditable, non sul focus |
|
||||
| 12 | `touchstart` + `preventDefault()` + `touch-action: none` | Handwriting resta attivo | Chrome decide prima degli eventi JS |
|
||||
| 13 | **Lock Button** — `contenteditable="false"` sul **container** (sbagliato) | Handwriting non si blocca | Il container è dentro cm-content → sbagliato mirare al container |
|
||||
| 14 | **Iframe** — canvas dentro `<iframe>` con `document.write` | Iframe creato (confermato da debug), handwriting resta attivo | Android's handwriting detection lavora a livello WebView (un'unica View Android), vede attraverso gli iframe |
|
||||
| 15 | **Overlay fullscreen** — pannello `position:fixed` su `document.body` + `cm-content.contenteditable="false"` mentre overlay è aperto | **PARZIALMENTE TESTATO** — handwriting ancora attivo su vecchi documenti, non testato su documento nuovo | Da verificare: testare su documento nuovo appena creato; possibile che vecchi documenti abbiano stato residuo |
|
||||
|
||||
### Scoperta critica: `inputmode="none"` contamina il WebView
|
||||
Bug confermato ([Flutter #176913](https://github.com/flutter/flutter/issues/176913)): dare focus a un elemento con `inputmode="none"` disabilita l'handwriting per l'INTERO WebView, effetto persiste fino al riavvio. **MAI usare `inputmode="none"` nel plugin.**
|
||||
|
||||
### Anche il plugin Ink ha lo stesso problema
|
||||
[Issue #156](https://github.com/daledesilva/obsidian_ink/issues/156): "Writing just puts a dot and doesn't follow pen on Android" — stessa causa, nessuna soluzione trovata.
|
||||
|
||||
### Approccio attuale (tentativo 15) — DA TESTARE MEGLIO
|
||||
Overlay quasi-fullscreen che si apre al tap sul riquadro:
|
||||
- `document.body.appendChild(overlay)` → fuori da cm-content
|
||||
- Quando overlay apre → `cm-content.setAttribute('contenteditable', 'false')`
|
||||
- Quando overlay chiude → ripristina `contenteditable="true"`
|
||||
- Backdrop semitrasparente con `backdrop-filter: blur(6px)`
|
||||
- Pannello con margini 12px, bordi arrotondati, ombra
|
||||
- Bottone ← in alto a sinistra, tool a destra
|
||||
- File: `src/embed.ts` → `showMobilePreview()`, `openFullscreenEditor()`
|
||||
|
||||
### Miglioramenti overlay ancora da fare
|
||||
- **Bordi più spessi/visibili** sul pannello (attualmente `box-shadow` sottile)
|
||||
- **Scroll interno** quando l'altezza del canvas supera quella dello schermo
|
||||
- **Verificare handwriting** su documento NUOVO (non su documenti già aperti che potrebbero avere stato residuo)
|
||||
- **Verificare causa** se handwriting ancora attivo: potrebbe essere che `cm-content.contenteditable="false"` non è sufficiente da solo, oppure che il timing è sbagliato
|
||||
|
||||
3. **Auto-expand — animazione scattosa** — **BUG APERTO** (parzialmente risolto):
|
||||
- Fix precedente: aggiunto guard `if (this.animFrameId !== null) return` in `checkAutoExpand()` per evitare restart multipli → il loop up/down è risolto
|
||||
- Problema residuo: l'espansione avviene in modo scattoso invece di animarsi fluidamente
|
||||
- Effetto collaterale: quando il canvas si espande oltre il viewport, la pagina scrolla bruscamente verso il basso
|
||||
- File coinvolto: `src/drawing-canvas.ts` → `animateHeight()` e `checkAutoExpand()`
|
||||
|
||||
4. **Toolbar unificata Windows/Android** — **DA FARE**:
|
||||
- Attualmente la toolbar su Windows è sempre espansa e quella su Android è compatta con toggle ▼/▲
|
||||
- Obiettivo: unico codice toolbar condiviso — compatta di default su entrambe le piattaforme
|
||||
- File coinvolti: `src/embed.ts` + `styles.css`
|
||||
|
||||
### Problemi risolti in passato
|
||||
- **Toolbar — tema scuro** ✅
|
||||
- **Spazio vuoto sezione colori in toolbar compatta** ✅
|
||||
- **Trashcan non cancella visualmente** ✅
|
||||
|
||||
---
|
||||
|
||||
## Ricerca effettuata — Plugin esistenti
|
||||
|
||||
### Nessuno fa esattamente questo. Gap confermato.
|
||||
|
||||
| Plugin | Cosa fa | Manca |
|
||||
|--------|---------|-------|
|
||||
| **Ink** (`daledesilva/obsidian_ink`) | Canvas inline nel `.md`, tldraw, penna | OCR/conversione testo (in roadmap) |
|
||||
| **Handwriting to Text** (`jirayu3141`) | Foto → Gemini AI → testo nel cursore | Non è canvas inline, è workflow foto |
|
||||
| **Petrify** (`jo-minjun/petrify`) | File tablet e-ink → Excalidraw/MD con OCR | Pensato per reMarkable/Boox, non canvas inline |
|
||||
| **AI Image OCR** (`rootiest`) | Immagine → AI OCR → testo | Non è canvas inline |
|
||||
| **Pergament** (`hobyte`) | Canvas embedded primitivo | Nessun OCR, sviluppo lento |
|
||||
|
||||
### Differenze rispetto a Ink (nostro riferimento)
|
||||
|
||||
| Ink | Il nostro plugin |
|
||||
|-----|-----------------|
|
||||
| tldraw (pesante, React) | Canvas API nativa (leggero, zero dipendenze extra) |
|
||||
| File `.drawing` proprietari JSON | File **SVG standard** visibili ovunque |
|
||||
| Nessuna conversione testo | **OCR + conversione markdown** (Fase 2) |
|
||||
| React + Jotai | Vanilla TypeScript |
|
||||
10
HandTranscriptMd/.editorconfig
Normal file
10
HandTranscriptMd/.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
28
HandTranscriptMd/.github/workflows/lint.yml
vendored
Normal file
28
HandTranscriptMd/.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
1
HandTranscriptMd/.npmrc
Normal file
1
HandTranscriptMd/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
251
HandTranscriptMd/AGENTS.md
Normal file
251
HandTranscriptMd/AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- To use eslint to analyze this project use this command: `eslint main.ts`
|
||||
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
|
||||
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
|
||||
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
|
||||
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
5
HandTranscriptMd/LICENSE
Normal file
5
HandTranscriptMd/LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
16
HandTranscriptMd/cloudDeploy.sh
Normal file
16
HandTranscriptMd/cloudDeploy.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
# Deploy plugin files to Google Drive vault (sync cloud)
|
||||
# Uso: bash cloudDeploy.sh
|
||||
|
||||
VAULT_PLUGIN="C:/Users/gabri/Il mio Drive (gabrielecusato@gmail.com)/Projects/handwriting-to-markdown"
|
||||
SRC_DIR="$(dirname "$0")"
|
||||
|
||||
mkdir -p "$VAULT_PLUGIN"
|
||||
cp "$SRC_DIR/main.js" "$SRC_DIR/manifest.json" "$SRC_DIR/styles.css" "$VAULT_PLUGIN/"
|
||||
|
||||
echo "Deployed to $VAULT_PLUGIN"
|
||||
echo " main.js $(wc -c < "$VAULT_PLUGIN/main.js") bytes"
|
||||
echo " manifest.json"
|
||||
echo " styles.css"
|
||||
echo ""
|
||||
echo "In Obsidian: Ctrl+P -> 'Reload app without saving'"
|
||||
16
HandTranscriptMd/deploy.sh
Normal file
16
HandTranscriptMd/deploy.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
# Deploy plugin files to Obsidian vault
|
||||
# Uso: bash deploy.sh
|
||||
|
||||
VAULT_PLUGIN="C:/Projects/CLIENTI/IOTTI/IOTTI_APP/_docs/handwriting-to-markdown/.obsidian/plugins/handwriting-to-markdown"
|
||||
SRC_DIR="$(dirname "$0")"
|
||||
|
||||
mkdir -p "$VAULT_PLUGIN"
|
||||
cp "$SRC_DIR/main.js" "$SRC_DIR/manifest.json" "$SRC_DIR/styles.css" "$VAULT_PLUGIN/"
|
||||
|
||||
echo "Deployed to $VAULT_PLUGIN"
|
||||
echo " main.js $(wc -c < "$VAULT_PLUGIN/main.js") bytes"
|
||||
echo " manifest.json"
|
||||
echo " styles.css"
|
||||
echo ""
|
||||
echo "In Obsidian: Ctrl+P -> 'Reload app without saving'"
|
||||
49
HandTranscriptMd/esbuild.config.mjs
Normal file
49
HandTranscriptMd/esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import { builtinModules } from 'node:module';
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
34
HandTranscriptMd/eslint.config.mts
Normal file
34
HandTranscriptMd/eslint.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'eslint.config.js',
|
||||
'manifest.json'
|
||||
]
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
]),
|
||||
);
|
||||
9
HandTranscriptMd/manifest.json
Normal file
9
HandTranscriptMd/manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "handwriting-to-markdown",
|
||||
"name": "Handwriting to Markdown",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Inline handwriting canvas with conversion to structured markdown",
|
||||
"author": "GabrieleC",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
614
HandTranscriptMd/package-lock.json
generated
Normal file
614
HandTranscriptMd/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
{
|
||||
"name": "handwriting-to-markdown",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "handwriting-to-markdown",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
|
||||
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.38.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
|
||||
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
|
||||
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
|
||||
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
|
||||
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
|
||||
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
|
||||
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
|
||||
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
|
||||
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
|
||||
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
|
||||
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/codemirror": {
|
||||
"version": "5.60.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
|
||||
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/tern": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "16.18.126",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
|
||||
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/tern": {
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
|
||||
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.5",
|
||||
"@esbuild/android-arm": "0.25.5",
|
||||
"@esbuild/android-arm64": "0.25.5",
|
||||
"@esbuild/android-x64": "0.25.5",
|
||||
"@esbuild/darwin-arm64": "0.25.5",
|
||||
"@esbuild/darwin-x64": "0.25.5",
|
||||
"@esbuild/freebsd-arm64": "0.25.5",
|
||||
"@esbuild/freebsd-x64": "0.25.5",
|
||||
"@esbuild/linux-arm": "0.25.5",
|
||||
"@esbuild/linux-arm64": "0.25.5",
|
||||
"@esbuild/linux-ia32": "0.25.5",
|
||||
"@esbuild/linux-loong64": "0.25.5",
|
||||
"@esbuild/linux-mips64el": "0.25.5",
|
||||
"@esbuild/linux-ppc64": "0.25.5",
|
||||
"@esbuild/linux-riscv64": "0.25.5",
|
||||
"@esbuild/linux-s390x": "0.25.5",
|
||||
"@esbuild/linux-x64": "0.25.5",
|
||||
"@esbuild/netbsd-arm64": "0.25.5",
|
||||
"@esbuild/netbsd-x64": "0.25.5",
|
||||
"@esbuild/openbsd-arm64": "0.25.5",
|
||||
"@esbuild/openbsd-x64": "0.25.5",
|
||||
"@esbuild/sunos-x64": "0.25.5",
|
||||
"@esbuild/win32-arm64": "0.25.5",
|
||||
"@esbuild/win32-ia32": "0.25.5",
|
||||
"@esbuild/win32-x64": "0.25.5"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/obsidian": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.3.tgz",
|
||||
"integrity": "sha512-VP+ZSxNMG7y6Z+sU9WqLvJAskCfkFrTz2kFHWmmzis+C+4+ELjk/sazwcTHrHXNZlgCeo8YOlM6SOrAFCynNew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/codemirror": "5.60.8",
|
||||
"moment": "2.29.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.38.6"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
23
HandTranscriptMd/package.json
Normal file
23
HandTranscriptMd/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "handwriting-to-markdown",
|
||||
"version": "0.1.0",
|
||||
"description": "Inline handwriting canvas with conversion to structured markdown",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
477
HandTranscriptMd/src/drawing-canvas.ts
Normal file
477
HandTranscriptMd/src/drawing-canvas.ts
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
/* =============================================
|
||||
DrawingCanvas — Motore di disegno su Canvas API
|
||||
Usa curve di Bézier quadratiche (midpoint) per
|
||||
tratti fluidi. Supporta penna e gomma parziale.
|
||||
Undo/redo basato su history di stati completi
|
||||
(funziona sia per disegno che per gomma).
|
||||
============================================= */
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
pressure: number;
|
||||
}
|
||||
|
||||
export interface Stroke {
|
||||
points: Point[];
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export type DrawMode = 'pen' | 'eraser';
|
||||
|
||||
// Deep copy di un array di Stroke
|
||||
function cloneStrokes(strokes: Stroke[]): Stroke[] {
|
||||
return strokes.map(s => ({
|
||||
points: s.points.map(p => ({ ...p })),
|
||||
color: s.color,
|
||||
width: s.width
|
||||
}));
|
||||
}
|
||||
|
||||
export class DrawingCanvas {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private strokes: Stroke[] = [];
|
||||
private currentStroke: Stroke | null = null;
|
||||
private mode: DrawMode = 'pen';
|
||||
private color = '#000000';
|
||||
private lineWidth = 2;
|
||||
private isDrawing = false;
|
||||
private changeCb: (() => void) | null = null;
|
||||
// Se true: siamo su mobile (Android/iOS)
|
||||
private mobileMode = false;
|
||||
|
||||
// History per undo/redo: ogni entry è uno snapshot completo dei tratti.
|
||||
// Funziona sia per disegno che per gomma.
|
||||
private history: Stroke[][] = [];
|
||||
private historyIdx = -1;
|
||||
// Flag per sapere se la gomma ha modificato qualcosa durante un drag
|
||||
private eraserChanged = false;
|
||||
|
||||
// Altezza di default delle settings (usata per reset su clear)
|
||||
private defaultHeight: number;
|
||||
|
||||
// Righe e sfondo
|
||||
readonly LINE_SPACING = 32;
|
||||
private bgColor = '#ffffff';
|
||||
private lineColor = '#e0e0e0';
|
||||
|
||||
// Auto-expand
|
||||
private readonly EXPAND_MARGIN = 40;
|
||||
private readonly EXPAND_AMOUNT = 150;
|
||||
|
||||
private animFrameId: number | null = null;
|
||||
|
||||
private boundDown: (e: PointerEvent) => void;
|
||||
private boundMove: (e: PointerEvent) => void;
|
||||
private boundUp: (e: PointerEvent) => void;
|
||||
private boundBeforeInput: ((e: Event) => void) | null = null;
|
||||
// Riferimento al container per il check "tap fuori"
|
||||
private container: HTMLElement | null = null;
|
||||
// Callback debug: se impostato, mostra Notice all'utente per ogni evento IME/touch
|
||||
private debugFn: ((msg: string) => void) | null = null;
|
||||
|
||||
constructor(container: HTMLElement, width: number, height: number, defaultHeight: number, mobileMode = false, debugFn: ((msg: string) => void) | null = null) {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.defaultHeight = defaultHeight;
|
||||
this.mobileMode = mobileMode;
|
||||
this.container = container;
|
||||
this.debugFn = debugFn;
|
||||
// HANDWRITING ANDROID — problema aperto, vedi CLAUDE.md per storico tentativi.
|
||||
// Safety net: blocca beforeinput durante il disegno (utile anche in fullscreen)
|
||||
if (mobileMode) {
|
||||
this.boundBeforeInput = (e: Event) => {
|
||||
if (this.isDrawing) {
|
||||
this.debugFn?.('🚫 beforeinput bloccato');
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('beforeinput', this.boundBeforeInput, true);
|
||||
}
|
||||
this.canvas.classList.add('hwm_canvas');
|
||||
// touch-action: none sul canvas previene scroll/zoom durante il disegno
|
||||
this.canvas.style.setProperty('touch-action', 'none', 'important');
|
||||
container.appendChild(this.canvas);
|
||||
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
this.clearBackground();
|
||||
|
||||
// Stato iniziale nella history (canvas vuoto)
|
||||
this.pushHistory();
|
||||
|
||||
this.boundDown = this.onPointerDown.bind(this);
|
||||
this.boundMove = this.onPointerMove.bind(this);
|
||||
this.boundUp = this.onPointerUp.bind(this);
|
||||
|
||||
this.canvas.addEventListener('pointerdown', this.boundDown);
|
||||
this.canvas.addEventListener('pointermove', this.boundMove);
|
||||
this.canvas.addEventListener('pointerup', this.boundUp);
|
||||
this.canvas.addEventListener('pointerleave', this.boundUp);
|
||||
}
|
||||
|
||||
/* --- API pubblica --- */
|
||||
|
||||
onChange(cb: () => void) { this.changeCb = cb; }
|
||||
|
||||
setMode(mode: DrawMode) { this.mode = mode; }
|
||||
getMode(): DrawMode { return this.mode; }
|
||||
|
||||
setColor(color: string) { this.color = color; }
|
||||
setLineWidth(w: number) { this.lineWidth = w; }
|
||||
|
||||
getStrokes(): Stroke[] { return [...this.strokes]; }
|
||||
getWidth(): number { return this.canvas.width; }
|
||||
getHeight(): number { return this.canvas.height; }
|
||||
|
||||
setBackground(bgColor: string, lineColor: string) {
|
||||
this.bgColor = bgColor;
|
||||
this.lineColor = lineColor;
|
||||
this.redraw();
|
||||
}
|
||||
getBgColor(): string { return this.bgColor; }
|
||||
getLineColor(): string { return this.lineColor; }
|
||||
|
||||
loadStrokes(strokes: Stroke[]) {
|
||||
this.strokes = cloneStrokes(strokes);
|
||||
// Reset history con lo stato caricato
|
||||
this.history = [];
|
||||
this.historyIdx = -1;
|
||||
this.pushHistory();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
// Torna allo stato precedente nella history
|
||||
undo(): boolean {
|
||||
if (this.historyIdx <= 0) return false;
|
||||
this.historyIdx--;
|
||||
this.strokes = cloneStrokes(this.history[this.historyIdx]!);
|
||||
this.redraw();
|
||||
this.changeCb?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Avanza allo stato successivo nella history
|
||||
redo(): boolean {
|
||||
if (this.historyIdx >= this.history.length - 1) return false;
|
||||
this.historyIdx++;
|
||||
this.strokes = cloneStrokes(this.history[this.historyIdx]!);
|
||||
this.redraw();
|
||||
this.changeCb?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.strokes = [];
|
||||
this.pushHistory();
|
||||
// Ridisegna subito (canvas visualmente vuoto) anche se l'altezza
|
||||
// è già quella di default (animateHeight ritornerebbe senza fare nulla)
|
||||
this.redraw();
|
||||
this.animateHeight(this.defaultHeight);
|
||||
this.changeCb?.();
|
||||
}
|
||||
|
||||
resizeHeight(newHeight: number) {
|
||||
if (newHeight < 100) return;
|
||||
this.canvas.height = newHeight;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
}
|
||||
this.canvas.removeEventListener('pointerdown', this.boundDown);
|
||||
this.canvas.removeEventListener('pointermove', this.boundMove);
|
||||
this.canvas.removeEventListener('pointerup', this.boundUp);
|
||||
this.canvas.removeEventListener('pointerleave', this.boundUp);
|
||||
// Rimuove listener beforeinput (registrato solo in mobileMode)
|
||||
if (this.boundBeforeInput) {
|
||||
document.removeEventListener('beforeinput', this.boundBeforeInput, true);
|
||||
this.boundBeforeInput = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- History --- */
|
||||
|
||||
// Salva uno snapshot dei tratti correnti nella history.
|
||||
// Taglia eventuali stati futuri (redo) quando si aggiunge un nuovo stato.
|
||||
private pushHistory() {
|
||||
this.history = this.history.slice(0, this.historyIdx + 1);
|
||||
this.history.push(cloneStrokes(this.strokes));
|
||||
this.historyIdx = this.history.length - 1;
|
||||
}
|
||||
|
||||
/* --- Pointer Events --- */
|
||||
|
||||
private onPointerDown(e: PointerEvent) {
|
||||
// pointerType vuoto ("") = evento degradato da Android → trattato come penna
|
||||
const ptype = e.pointerType || 'pen';
|
||||
|
||||
// Su mobile: il dito non disegna mai
|
||||
if (this.mobileMode && ptype === 'touch') {
|
||||
this.debugFn?.('👆 Dito sul canvas');
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (this.mobileMode) {
|
||||
this.debugFn?.(`🖊 pointerdown tipo="${e.pointerType}" → "${ptype}"`);
|
||||
e.stopPropagation();
|
||||
}
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
this.isDrawing = true;
|
||||
const pt = this.eventToPoint(e);
|
||||
|
||||
if (this.mode === 'pen') {
|
||||
this.currentStroke = {
|
||||
points: [pt],
|
||||
color: this.color,
|
||||
width: this.lineWidth,
|
||||
};
|
||||
} else {
|
||||
// Inizio drag gomma: reset flag
|
||||
this.eraserChanged = false;
|
||||
this.eraseAt(pt);
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove(e: PointerEvent) {
|
||||
// Su mobile: ignora il dito
|
||||
if (this.mobileMode && (e.pointerType || 'pen') === 'touch') return;
|
||||
if (!this.isDrawing) return;
|
||||
e.preventDefault();
|
||||
const pt = this.eventToPoint(e);
|
||||
|
||||
if (this.mode === 'pen' && this.currentStroke) {
|
||||
this.currentStroke.points.push(pt);
|
||||
this.drawSegment(this.currentStroke);
|
||||
this.checkAutoExpand(pt);
|
||||
} else if (this.mode === 'eraser') {
|
||||
this.eraseAt(pt);
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(e: PointerEvent) {
|
||||
// Su mobile: ignora il dito
|
||||
if (this.mobileMode && (e.pointerType || 'pen') === 'touch') return;
|
||||
|
||||
if (!this.isDrawing) return;
|
||||
this.isDrawing = false;
|
||||
|
||||
if (this.mode === 'pen' && this.currentStroke) {
|
||||
if (this.currentStroke.points.length >= 2) {
|
||||
this.strokes.push(this.currentStroke);
|
||||
// Salva nella history dopo ogni tratto completato
|
||||
this.pushHistory();
|
||||
this.changeCb?.();
|
||||
}
|
||||
this.currentStroke = null;
|
||||
} else if (this.mode === 'eraser' && this.eraserChanged) {
|
||||
// Salva nella history dopo un drag gomma che ha cancellato qualcosa
|
||||
this.pushHistory();
|
||||
this.changeCb?.();
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Auto-expand --- */
|
||||
|
||||
private checkAutoExpand(pt: Point) {
|
||||
// Se un'animazione è già in corso non lanciarne un'altra:
|
||||
// ripartire da un'altezza intermedia causerebbe un effetto di restringimento.
|
||||
if (this.animFrameId !== null) return;
|
||||
if (pt.y > this.canvas.height - this.EXPAND_MARGIN) {
|
||||
const newHeight = this.canvas.height + this.EXPAND_AMOUNT;
|
||||
this.animateHeight(newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private animateHeight(targetHeight: number) {
|
||||
const startHeight = this.canvas.height;
|
||||
if (startHeight === targetHeight) return;
|
||||
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
|
||||
const duration = 300;
|
||||
const startTime = performance.now();
|
||||
|
||||
const step = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const h = Math.round(startHeight + (targetHeight - startHeight) * eased);
|
||||
|
||||
this.canvas.height = h;
|
||||
this.redraw();
|
||||
if (this.currentStroke) {
|
||||
this.drawFullStroke(this.currentStroke);
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
this.animFrameId = requestAnimationFrame(step);
|
||||
} else {
|
||||
this.animFrameId = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.animFrameId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/* --- Coordinate --- */
|
||||
|
||||
private eventToPoint(e: PointerEvent): Point {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
pressure: e.pressure > 0 ? e.pressure : 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
/* --- Gomma parziale --- */
|
||||
|
||||
// La gomma rimuove solo i punti vicini, tagliando i tratti in segmenti.
|
||||
// Non salva nella history ad ogni singolo punto cancellato —
|
||||
// lo snapshot viene salvato una sola volta al pointerup.
|
||||
private eraseAt(pt: Point) {
|
||||
const radius = 15;
|
||||
const r2 = radius * radius;
|
||||
let changed = false;
|
||||
const newStrokes: Stroke[] = [];
|
||||
|
||||
for (const stroke of this.strokes) {
|
||||
let segment: Point[] = [];
|
||||
let strokeTouched = false;
|
||||
|
||||
for (const p of stroke.points) {
|
||||
const dx = p.x - pt.x;
|
||||
const dy = p.y - pt.y;
|
||||
|
||||
if (dx * dx + dy * dy < r2) {
|
||||
if (segment.length >= 2) {
|
||||
newStrokes.push({
|
||||
points: [...segment],
|
||||
color: stroke.color,
|
||||
width: stroke.width
|
||||
});
|
||||
}
|
||||
segment = [];
|
||||
strokeTouched = true;
|
||||
} else {
|
||||
segment.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (!strokeTouched) {
|
||||
newStrokes.push(stroke);
|
||||
} else {
|
||||
changed = true;
|
||||
if (segment.length >= 2) {
|
||||
newStrokes.push({
|
||||
points: [...segment],
|
||||
color: stroke.color,
|
||||
width: stroke.width
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.strokes = newStrokes;
|
||||
this.eraserChanged = true;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Rendering --- */
|
||||
|
||||
private clearBackground() {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
|
||||
this.ctx.fillStyle = this.bgColor;
|
||||
this.ctx.fillRect(0, 0, w, h);
|
||||
|
||||
this.ctx.strokeStyle = this.lineColor;
|
||||
this.ctx.lineWidth = 0.5;
|
||||
for (let y = this.LINE_SPACING; y < h; y += this.LINE_SPACING) {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, y);
|
||||
this.ctx.lineTo(w, y);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private redraw() {
|
||||
this.clearBackground();
|
||||
for (const stroke of this.strokes) {
|
||||
this.drawFullStroke(stroke);
|
||||
}
|
||||
}
|
||||
|
||||
private drawFullStroke(stroke: Stroke) {
|
||||
const pts = stroke.points;
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
|
||||
if (pts.length === 2) {
|
||||
ctx.lineTo(pts[1]!.x, pts[1]!.y);
|
||||
} else {
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const curr = pts[i]!;
|
||||
const next = pts[i + 1]!;
|
||||
const midX = (curr.x + next.x) / 2;
|
||||
const midY = (curr.y + next.y) / 2;
|
||||
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
|
||||
}
|
||||
const last = pts[pts.length - 1]!;
|
||||
ctx.lineTo(last.x, last.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
private drawSegment(stroke: Stroke) {
|
||||
const pts = stroke.points;
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
|
||||
if (pts.length === 2) {
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
ctx.lineTo(pts[1]!.x, pts[1]!.y);
|
||||
} else {
|
||||
const i = pts.length - 2;
|
||||
const prev = i > 0 ? pts[i - 1]! : pts[0]!;
|
||||
const curr = pts[i]!;
|
||||
const next = pts[i + 1]!;
|
||||
const startX = (prev.x + curr.x) / 2;
|
||||
const startY = (prev.y + curr.y) / 2;
|
||||
const endX = (curr.x + next.x) / 2;
|
||||
const endY = (curr.y + next.y) / 2;
|
||||
|
||||
ctx.moveTo(startX, startY);
|
||||
ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
595
HandTranscriptMd/src/embed.ts
Normal file
595
HandTranscriptMd/src/embed.ts
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
/* =============================================
|
||||
Embed — Code block processor + Toolbar
|
||||
Gestisce il rendering dell'embed nel markdown.
|
||||
Il blocco si apre direttamente in modalità edit
|
||||
(disegno immediato, senza click extra).
|
||||
============================================= */
|
||||
|
||||
import {
|
||||
MarkdownPostProcessorContext,
|
||||
MarkdownView,
|
||||
TFile,
|
||||
Notice,
|
||||
Platform
|
||||
} from 'obsidian';
|
||||
|
||||
// Icone SVG inline (stile Lucide 24×24) — funzionano identicamente su Windows e Android,
|
||||
// senza dipendere dalla versione di Lucide bundled in Obsidian.
|
||||
const ICONS: Record<string, string> = {
|
||||
'pencil': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>`,
|
||||
'eraser': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`,
|
||||
'rotate-ccw': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>`,
|
||||
'rotate-cw': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>`,
|
||||
'trash': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
|
||||
'file-text': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`,
|
||||
'save': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>`,
|
||||
'x': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
|
||||
'chevron-down':`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`,
|
||||
'chevron-up': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>`,
|
||||
};
|
||||
import type HandwritingPlugin from './main';
|
||||
import { DrawingCanvas, Stroke } from './drawing-canvas';
|
||||
import { strokesToSvg, parseSvgStrokes, generateId } from './svg-utils';
|
||||
import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor } from './settings';
|
||||
import { getRecognizer } from './recognizer';
|
||||
import { parseMarkdown } from './md-parser';
|
||||
|
||||
// Dati JSON salvati dentro il code block ```handwriting
|
||||
interface EmbedData {
|
||||
id: string;
|
||||
svg: string; // percorso relativo al file SVG nel vault
|
||||
}
|
||||
|
||||
/* ---------- Registrazione ---------- */
|
||||
|
||||
export function registerEmbed(plugin: HandwritingPlugin) {
|
||||
plugin.registerMarkdownCodeBlockProcessor(
|
||||
'handwriting',
|
||||
async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
||||
await renderEmbed(source, el, ctx, plugin);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Rendering dell'embed ---------- */
|
||||
|
||||
async function renderEmbed(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
plugin: HandwritingPlugin
|
||||
) {
|
||||
// Parsa il JSON dal code block
|
||||
let data: EmbedData;
|
||||
try {
|
||||
data = JSON.parse(source.trim());
|
||||
} catch {
|
||||
el.createEl('p', { text: 'Handwriting: JSON non valido', cls: 'hwm_error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Container principale
|
||||
const container = el.createDiv({ cls: 'hwm_container' });
|
||||
|
||||
// Carica tratti esistenti dal file SVG
|
||||
const { strokes, canvasHeight } = await loadSvgData(data.svg, plugin);
|
||||
|
||||
// Apre DIRETTAMENTE in modalità edit (disegno immediato)
|
||||
showEditor(container, strokes, canvasHeight, data, ctx, plugin);
|
||||
}
|
||||
|
||||
/* ---------- Editor mode (default) ---------- */
|
||||
|
||||
function showEditor(
|
||||
container: HTMLElement,
|
||||
strokes: Stroke[],
|
||||
savedHeight: number | null,
|
||||
data: EmbedData,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
plugin: HandwritingPlugin
|
||||
) {
|
||||
container.empty();
|
||||
container.classList.add('hwm_editing');
|
||||
|
||||
const isMobile = Platform.isMobile;
|
||||
|
||||
// Su mobile aggiunge classe per CSS dedicato (stile bottoni, icone)
|
||||
if (isMobile) container.classList.add('hwm_mobile');
|
||||
|
||||
// Determina tema subito (usato sia per toolbar che per palette colori)
|
||||
const isDark = plugin.settings.bgMode === 'dark';
|
||||
|
||||
// Toolbar (sempre visibile, in alto a destra)
|
||||
const toolbar = container.createDiv({ cls: 'hwm_toolbar' });
|
||||
// Tema scuro: sfondo e icone invertiti
|
||||
if (isDark) toolbar.classList.add('hwm_toolbar--dark');
|
||||
|
||||
// Su mobile: parte compatta, toggle per espandere.
|
||||
// Il handler del toggle chiama updateColorBtnSizes, definita più in basso
|
||||
// ma già disponibile a runtime grazie alla chiusura (closure).
|
||||
let toggleBtn: HTMLElement | null = null;
|
||||
if (isMobile) {
|
||||
toolbar.classList.add('hwm_toolbar--compact');
|
||||
toggleBtn = createBtn(toolbar, 'chevron-down', 'Mostra tutti i controlli');
|
||||
toggleBtn.classList.add('hwm_toggle-btn');
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const isCompact = toolbar.classList.contains('hwm_toolbar--compact');
|
||||
if (isCompact) {
|
||||
toolbar.classList.remove('hwm_toolbar--compact');
|
||||
// Ripristina dimensioni di tutti i pallini colore
|
||||
updateColorBtnSizes(false);
|
||||
// Cambia icona a chevron-up (comprimi)
|
||||
toggleBtn!.innerHTML = ICONS['chevron-up'] ?? '';
|
||||
toggleBtn!.title = 'Comprimi toolbar';
|
||||
} else {
|
||||
toolbar.classList.add('hwm_toolbar--compact');
|
||||
// Collassa i pallini non attivi
|
||||
updateColorBtnSizes(true);
|
||||
// Ripristina icona chevron-down (espandi)
|
||||
toggleBtn!.innerHTML = ICONS['chevron-down'] ?? '';
|
||||
toggleBtn!.title = 'Mostra tutti i controlli';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tool: Penna ---
|
||||
const penBtn = createBtn(toolbar, 'pencil', 'Penna');
|
||||
penBtn.classList.add('hwm_active', 'hwm_pen-btn');
|
||||
|
||||
// --- Tool: Gomma ---
|
||||
const eraserBtn = createBtn(toolbar, 'eraser', 'Gomma');
|
||||
eraserBtn.classList.add('hwm_eraser-btn');
|
||||
|
||||
toolbar.createDiv({ cls: 'hwm_separator' });
|
||||
|
||||
// --- Colori (palette adattata al tema: scuro=colori chiari, chiaro=colori scuri) ---
|
||||
const colors = isDark
|
||||
? ['#ffffff', '#60a5fa', '#f87171', '#4ade80'] // bianco, azzurro, rosso chiaro, verde chiaro
|
||||
: ['#000000', '#1e40af', '#dc2626', '#16a34a']; // nero, blu, rosso, verde
|
||||
const colorWrap = toolbar.createDiv({ cls: 'hwm_colors' });
|
||||
const colorBtns: HTMLElement[] = [];
|
||||
for (const color of colors) {
|
||||
// Usiamo <div> invece di <button> per evitare che Obsidian Mobile
|
||||
// sovrascriva width/height con i propri stili globali sui button.
|
||||
const btn = colorWrap.createEl('div', {
|
||||
cls: 'hwm_color-btn',
|
||||
attr: { title: color, role: 'button', tabindex: '0' }
|
||||
});
|
||||
btn.style.backgroundColor = color;
|
||||
// Dimensioni circolari forzate via setProperty (bypass stili Obsidian)
|
||||
btn.style.setProperty('width', '22px', 'important');
|
||||
btn.style.setProperty('height', '22px', 'important');
|
||||
btn.style.setProperty('min-width', '22px', 'important');
|
||||
btn.style.setProperty('min-height', '22px', 'important');
|
||||
btn.style.setProperty('border-radius', '50%', 'important');
|
||||
btn.style.setProperty('box-sizing', 'border-box', 'important');
|
||||
btn.style.setProperty('flex-shrink', '0', 'important');
|
||||
if (color === colors[0]) btn.classList.add('hwm_active');
|
||||
colorBtns.push(btn);
|
||||
}
|
||||
|
||||
toolbar.createDiv({ cls: 'hwm_separator' });
|
||||
|
||||
// Helper: aggiorna min-width sui pallini colore in base alla modalità compatta.
|
||||
// Necessario perché min-width è impostato via JS con !important (per Android)
|
||||
// e non può essere sovrascritto da CSS — va gestito via JS.
|
||||
const updateColorBtnSizes = (compact: boolean) => {
|
||||
colorBtns.forEach(b => {
|
||||
// In compact: i pallini non attivi devono collassare a 0
|
||||
const isActive = b.classList.contains('hwm_active');
|
||||
const size = (!compact || isActive) ? '22px' : '0';
|
||||
b.style.setProperty('min-width', size, 'important');
|
||||
b.style.setProperty('min-height', size, 'important');
|
||||
});
|
||||
};
|
||||
|
||||
// Stato iniziale min-width: se mobile (parte compatta), i pallini non attivi partono a 0
|
||||
if (isMobile) updateColorBtnSizes(true);
|
||||
|
||||
// --- Undo / Redo / Clear ---
|
||||
// Stesse icone SVG inline su Windows e Android (nessuna dipendenza da Lucide bundled)
|
||||
const undoBtn = createBtn(toolbar, 'rotate-ccw', 'Annulla (Undo)');
|
||||
undoBtn.classList.add('hwm_undo-btn');
|
||||
const redoBtn = createBtn(toolbar, 'rotate-cw', 'Ripristina (Redo)');
|
||||
redoBtn.classList.add('hwm_redo-btn');
|
||||
const clearBtn = createBtn(toolbar, 'trash', 'Cancella tutto');
|
||||
clearBtn.classList.add('hwm_clear-btn');
|
||||
|
||||
toolbar.createDiv({ cls: 'hwm_separator' });
|
||||
|
||||
// --- Converti in Markdown ---
|
||||
const convertBtn = createBtn(toolbar, 'file-text', 'Converti in Markdown');
|
||||
convertBtn.classList.add('hwm_convert-btn');
|
||||
|
||||
// --- Salva ---
|
||||
const saveBtn = createBtn(toolbar, 'save', 'Salva');
|
||||
saveBtn.classList.add('hwm_save-btn');
|
||||
|
||||
// --- Elimina riquadro ---
|
||||
const deleteBtn = createBtn(toolbar, 'x', 'Elimina riquadro');
|
||||
deleteBtn.classList.add('hwm_delete-btn');
|
||||
|
||||
// --- Canvas ---
|
||||
const canvasWrap = container.createDiv({ cls: 'hwm_canvas-wrap' });
|
||||
const { canvasWidth, canvasHeight } = plugin.settings;
|
||||
// Usa l'altezza salvata se disponibile (per canvas espansi)
|
||||
// canvasHeight = default settings, savedHeight = altezza dal SVG (può essere espanso)
|
||||
const height = savedHeight ?? canvasHeight;
|
||||
// Se debugMode è attivo, ogni evento IME/touch mostra una Notice in tempo reale
|
||||
const debugFn = plugin.settings.debugMode
|
||||
? (msg: string) => new Notice(msg, 3000)
|
||||
: null;
|
||||
const canvas = new DrawingCanvas(canvasWrap, canvasWidth, height, canvasHeight, isMobile, debugFn);
|
||||
|
||||
// Imposta colori sfondo e righe dalle settings
|
||||
const bgColor = getEffectiveBgColor(plugin.settings);
|
||||
const lineColor = getEffectiveLineColor(plugin.settings);
|
||||
canvas.setBackground(bgColor, lineColor);
|
||||
// Colore penna di default adattato al tema
|
||||
canvas.setColor(colors[0]!);
|
||||
|
||||
// Applica sfondo anche al container CSS
|
||||
container.style.backgroundColor = bgColor;
|
||||
|
||||
// Carica tratti esistenti, rimappando i colori al tema corrente
|
||||
if (strokes.length > 0) {
|
||||
const remapped = strokes.map(s => ({
|
||||
...s,
|
||||
color: remapStrokeColor(s.color, plugin.settings.bgMode)
|
||||
}));
|
||||
canvas.loadStrokes(remapped);
|
||||
// Ri-salva l'SVG con colori e sfondo aggiornati
|
||||
saveToSvg(canvas, data, plugin);
|
||||
}
|
||||
|
||||
// --- Handle di resize in basso (colori adattati al tema) ---
|
||||
const resizeHandle = container.createDiv({ cls: 'hwm_resize-handle' });
|
||||
resizeHandle.createEl('span', { text: '⋯' });
|
||||
if (isDark) {
|
||||
resizeHandle.style.background = '#2a2a2a';
|
||||
resizeHandle.style.borderTopColor = '#444';
|
||||
resizeHandle.style.color = '#888';
|
||||
}
|
||||
setupResizeHandle(resizeHandle, canvas);
|
||||
|
||||
// --- Event handlers ---
|
||||
|
||||
// Penna / Gomma
|
||||
penBtn.addEventListener('click', () => {
|
||||
canvas.setMode('pen');
|
||||
penBtn.classList.add('hwm_active');
|
||||
eraserBtn.classList.remove('hwm_active');
|
||||
});
|
||||
eraserBtn.addEventListener('click', () => {
|
||||
canvas.setMode('eraser');
|
||||
eraserBtn.classList.add('hwm_active');
|
||||
penBtn.classList.remove('hwm_active');
|
||||
});
|
||||
|
||||
// Colori
|
||||
for (let i = 0; i < colorBtns.length; i++) {
|
||||
const btn = colorBtns[i]!;
|
||||
const color = colors[i]!;
|
||||
btn.addEventListener('click', () => {
|
||||
colorBtns.forEach(b => b.classList.remove('hwm_active'));
|
||||
btn.classList.add('hwm_active');
|
||||
canvas.setColor(color);
|
||||
// Aggiorna min-width: il nuovo pallino attivo torna a 22px,
|
||||
// gli altri restano collassati se siamo in compact mode
|
||||
if (isMobile) {
|
||||
updateColorBtnSizes(toolbar.classList.contains('hwm_toolbar--compact'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Undo / Redo / Clear
|
||||
undoBtn.addEventListener('click', () => canvas.undo());
|
||||
redoBtn.addEventListener('click', () => canvas.redo());
|
||||
clearBtn.addEventListener('click', () => canvas.clear());
|
||||
|
||||
// Converti in Markdown: SVG → PNG base64 → Gemini OCR → parser → sostituisce code block
|
||||
convertBtn.addEventListener('click', async () => {
|
||||
const strokes = canvas.getStrokes();
|
||||
if (strokes.length === 0) {
|
||||
new Notice('Nessun tratto da convertire');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new Notice('Riconoscimento in corso…');
|
||||
|
||||
// 1. Genera la stringa SVG dai tratti correnti del canvas
|
||||
const svgString = strokesToSvg(
|
||||
strokes,
|
||||
canvas.getWidth(),
|
||||
canvas.getHeight(),
|
||||
canvas.getBgColor(),
|
||||
canvas.getLineColor()
|
||||
);
|
||||
|
||||
// 2. Parsa la stringa SVG in un elemento DOM per la conversione
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(svgString, 'image/svg+xml');
|
||||
const svgEl = svgDoc.documentElement as unknown as SVGElement;
|
||||
|
||||
// 3. Converte l'SVG in PNG base64 tramite un canvas HTML temporaneo
|
||||
const base64 = await svgToBase64Png(svgEl);
|
||||
|
||||
// 4. Invia l'immagine a Gemini per il riconoscimento OCR
|
||||
const recognizer = getRecognizer(plugin.settings.geminiApiKey, plugin.settings.ocrLanguages);
|
||||
const rawText = await recognizer.recognize(base64);
|
||||
|
||||
if (!rawText.trim()) {
|
||||
new Notice('Nessun testo riconosciuto');
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Converte il testo grezzo in markdown strutturato
|
||||
const markdown = parseMarkdown(rawText);
|
||||
|
||||
// 6. Sposta l'SVG nella cartella _converted con nome data-ora
|
||||
await archiveSvg(data, plugin);
|
||||
|
||||
// 7. Sostituisce il code block handwriting con il testo markdown
|
||||
canvas.destroy();
|
||||
await replaceEmbedWithMarkdown(ctx, data, markdown, plugin);
|
||||
|
||||
new Notice('Conversione completata!');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
new Notice('Errore OCR: ' + msg);
|
||||
}
|
||||
});
|
||||
|
||||
// Salva SVG su disco
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
await saveToSvg(canvas, data, plugin);
|
||||
});
|
||||
|
||||
// Elimina: rimuove il code block dal file markdown e cancella l'SVG
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
const confirmed = confirm('Eliminare questo riquadro handwriting e il file SVG associato?');
|
||||
if (!confirmed) return;
|
||||
canvas.destroy();
|
||||
await removeEmbed(ctx, data, plugin);
|
||||
});
|
||||
|
||||
// Auto-save debounced: salva automaticamente 2 secondi dopo l'ultimo cambiamento
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
canvas.onChange(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
await saveToSvg(canvas, data, plugin);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Resize handle ---------- */
|
||||
|
||||
// Permette di trascinare il bordo inferiore per ridimensionare il canvas
|
||||
function setupResizeHandle(handle: HTMLElement, canvas: DrawingCanvas) {
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.clientY - startY;
|
||||
const newHeight = Math.max(100, startHeight + delta);
|
||||
canvas.resizeHeight(newHeight);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
startY = e.clientY;
|
||||
startHeight = canvas.getHeight();
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- File I/O ---------- */
|
||||
|
||||
// Carica il file SVG e ne estrae i tratti + altezza canvas
|
||||
async function loadSvgData(
|
||||
svgPath: string,
|
||||
plugin: HandwritingPlugin
|
||||
): Promise<{ strokes: Stroke[]; canvasHeight: number | null }> {
|
||||
const file = plugin.app.vault.getAbstractFileByPath(svgPath);
|
||||
if (file instanceof TFile) {
|
||||
const content = await plugin.app.vault.read(file);
|
||||
const strokes = parseSvgStrokes(content);
|
||||
// Estrai altezza dal viewBox dell'SVG per mantenere canvas espansi
|
||||
const heightMatch = content.match(/viewBox="0 0 \d+ (\d+)"/);
|
||||
const canvasHeight = heightMatch ? parseInt(heightMatch[1] ?? '0') : null;
|
||||
return { strokes, canvasHeight };
|
||||
}
|
||||
return { strokes: [], canvasHeight: null };
|
||||
}
|
||||
|
||||
// Salva i tratti come file SVG nel vault
|
||||
async function saveToSvg(
|
||||
canvas: DrawingCanvas,
|
||||
data: EmbedData,
|
||||
plugin: HandwritingPlugin
|
||||
) {
|
||||
const strokes = canvas.getStrokes();
|
||||
const width = canvas.getWidth();
|
||||
const height = canvas.getHeight();
|
||||
const svg = strokesToSvg(strokes, width, height, canvas.getBgColor(), canvas.getLineColor());
|
||||
|
||||
// Crea la cartella se non esiste
|
||||
const folderPath = data.svg.substring(0, data.svg.lastIndexOf('/'));
|
||||
if (folderPath) {
|
||||
const folder = plugin.app.vault.getAbstractFileByPath(folderPath);
|
||||
if (!folder) {
|
||||
await plugin.app.vault.createFolder(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrivi o aggiorna il file SVG
|
||||
const existing = plugin.app.vault.getAbstractFileByPath(data.svg);
|
||||
if (existing instanceof TFile) {
|
||||
await plugin.app.vault.modify(existing, svg);
|
||||
} else {
|
||||
await plugin.app.vault.create(data.svg, svg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Elimina embed ---------- */
|
||||
|
||||
// Rimuove il code block dal file markdown e cancella il file SVG
|
||||
async function removeEmbed(
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
data: EmbedData,
|
||||
plugin: HandwritingPlugin
|
||||
) {
|
||||
// Trova il file markdown che contiene il code block
|
||||
const mdFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
|
||||
if (!(mdFile instanceof TFile)) {
|
||||
new Notice('File markdown non trovato');
|
||||
return;
|
||||
}
|
||||
|
||||
// Leggi il contenuto e trova il code block da rimuovere
|
||||
const content = await plugin.app.vault.read(mdFile);
|
||||
// Cerca il blocco ```handwriting con l'id corrispondente
|
||||
const pattern = new RegExp(
|
||||
'\\n?```handwriting\\n.*?"id"\\s*:\\s*"' + escapeRegex(data.id) + '".*?\\n```\\n?',
|
||||
's'
|
||||
);
|
||||
const newContent = content.replace(pattern, '\n');
|
||||
|
||||
if (newContent !== content) {
|
||||
await plugin.app.vault.modify(mdFile, newContent);
|
||||
}
|
||||
|
||||
// Cancella il file SVG associato
|
||||
const svgFile = plugin.app.vault.getAbstractFileByPath(data.svg);
|
||||
if (svgFile instanceof TFile) {
|
||||
await plugin.app.vault.delete(svgFile);
|
||||
}
|
||||
|
||||
new Notice('Riquadro eliminato');
|
||||
}
|
||||
|
||||
// Escape caratteri speciali per regex
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/* ---------- Archivia SVG dopo conversione ---------- */
|
||||
|
||||
// Rinomina e sposta l'SVG in _handwriting/_converted/AAAA-MM-GG_HH-MM-SS.svg
|
||||
async function archiveSvg(data: EmbedData, plugin: HandwritingPlugin) {
|
||||
const svgFile = plugin.app.vault.getAbstractFileByPath(data.svg);
|
||||
if (!(svgFile instanceof TFile)) return;
|
||||
|
||||
// Costruisce il timestamp nel formato 2026-03-08_14-30-00
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||
`_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
|
||||
// Cartella destinazione: _handwriting/_converted/
|
||||
const destFolder = `${plugin.settings.svgFolder}/_converted`;
|
||||
if (!plugin.app.vault.getAbstractFileByPath(destFolder)) {
|
||||
await plugin.app.vault.createFolder(destFolder);
|
||||
}
|
||||
|
||||
// Sposta il file con il nuovo nome
|
||||
await plugin.app.vault.rename(svgFile, `${destFolder}/${timestamp}.svg`);
|
||||
}
|
||||
|
||||
/* ---------- Sostituisce il code block con il markdown convertito ---------- */
|
||||
|
||||
// Trova il blocco ```handwriting con l'id corrispondente e lo rimpiazza con il testo markdown
|
||||
async function replaceEmbedWithMarkdown(
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
data: EmbedData,
|
||||
markdown: string,
|
||||
plugin: HandwritingPlugin
|
||||
) {
|
||||
const mdFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
|
||||
if (!(mdFile instanceof TFile)) {
|
||||
new Notice('File markdown non trovato');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await plugin.app.vault.read(mdFile);
|
||||
// Cerca il blocco ```handwriting con questo specifico id
|
||||
const pattern = new RegExp(
|
||||
'\\n?```handwriting\\n.*?"id"\\s*:\\s*"' + escapeRegex(data.id) + '".*?\\n```\\n?',
|
||||
's'
|
||||
);
|
||||
const newContent = content.replace(pattern, '\n' + markdown + '\n');
|
||||
|
||||
if (newContent !== content) {
|
||||
await plugin.app.vault.modify(mdFile, newContent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Comando: inserisci nuovo blocco ---------- */
|
||||
|
||||
export function insertHandwritingBlock(plugin: HandwritingPlugin) {
|
||||
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!view) {
|
||||
new Notice('Apri un file markdown prima');
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = view.editor;
|
||||
const id = generateId();
|
||||
const svgPath = `${plugin.settings.svgFolder}/${id}.svg`;
|
||||
|
||||
const block = `\n\`\`\`handwriting\n{"id":"${id}","svg":"${svgPath}"}\n\`\`\`\n`;
|
||||
editor.replaceSelection(block);
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
function createBtn(parent: HTMLElement, icon: string, title: string): HTMLElement {
|
||||
const btn = parent.createEl('button', {
|
||||
cls: 'hwm_btn',
|
||||
attr: { title }
|
||||
});
|
||||
// Usa SVG inline dalla mappa ICONS — identico su Windows e Android,
|
||||
// nessuna dipendenza da setIcon/Lucide bundled
|
||||
btn.innerHTML = ICONS[icon] ?? '';
|
||||
return btn;
|
||||
}
|
||||
|
||||
// Converte un SVGElement in immagine PNG base64 tramite un canvas HTML temporaneo.
|
||||
// Serializza l'SVG in un Blob → URL oggetto → disegna su canvas → estrae base64.
|
||||
function svgToBase64Png(svgElement: SVGElement): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const img = new Image();
|
||||
|
||||
// Serializza l'SVGElement in testo e crea un URL temporaneo
|
||||
const svgBlob = new Blob(
|
||||
[new XMLSerializer().serializeToString(svgElement)],
|
||||
{ type: 'image/svg+xml' }
|
||||
);
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
// Libera l'URL temporaneo e restituisce solo la parte base64 (senza prefisso "data:...")
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(canvas.toDataURL('image/png').split(',')[1]!);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Errore conversione SVG → PNG'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
52
HandTranscriptMd/src/main.ts
Normal file
52
HandTranscriptMd/src/main.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* =============================================
|
||||
Handwriting to Markdown — Plugin Entry Point
|
||||
|
||||
Registra:
|
||||
- Code block processor "handwriting" (embed inline)
|
||||
- Comando per inserire un nuovo blocco handwriting
|
||||
- Tab impostazioni
|
||||
============================================= */
|
||||
|
||||
import { Plugin } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, HandwritingSettings, HandwritingSettingTab } from './settings';
|
||||
import { registerEmbed, insertHandwritingBlock } from './embed';
|
||||
|
||||
export default class HandwritingPlugin extends Plugin {
|
||||
settings: HandwritingSettings;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// Registra il code block processor per ```handwriting
|
||||
registerEmbed(this);
|
||||
|
||||
// Comando: inserisce un nuovo blocco handwriting nel file corrente
|
||||
this.addCommand({
|
||||
id: 'insert-handwriting',
|
||||
name: 'Insert handwriting block',
|
||||
editorCallback: () => {
|
||||
insertHandwritingBlock(this);
|
||||
}
|
||||
});
|
||||
|
||||
// Icona nella ribbon (sidebar sinistra)
|
||||
this.addRibbonIcon('pencil', 'Insert handwriting', () => {
|
||||
insertHandwritingBlock(this);
|
||||
});
|
||||
|
||||
// Tab impostazioni
|
||||
this.addSettingTab(new HandwritingSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData() as Partial<HandwritingSettings>
|
||||
);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
55
HandTranscriptMd/src/md-parser.ts
Normal file
55
HandTranscriptMd/src/md-parser.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* =============================================
|
||||
md-parser — Parser prefissi Markdown
|
||||
Converte testo grezzo (output OCR) in markdown
|
||||
strutturato, analizzando riga per riga.
|
||||
============================================= */
|
||||
|
||||
// Converte tutto il testo grezzo in markdown formattato
|
||||
export function parseMarkdown(raw: string): string {
|
||||
const lines = raw.split('\n');
|
||||
return lines.map(parseLine).join('\n');
|
||||
}
|
||||
|
||||
// Processa una singola riga, rilevando il prefisso e applicando la sintassi markdown
|
||||
function parseLine(line: string): string {
|
||||
const t = line.trim();
|
||||
if (!t) return '';
|
||||
|
||||
// Separatore orizzontale: "---" o più trattini
|
||||
if (/^-{3,}$/.test(t)) return '---';
|
||||
|
||||
// Intestazioni H1 / H2 / H3 (es. "# Titolo", "## Sezione")
|
||||
if (/^#{1,3}\s+/.test(t)) return t;
|
||||
|
||||
// Checkbox spuntata: "- [x] testo" (case insensitive)
|
||||
if (/^-\s*\[x\]\s*/i.test(t)) {
|
||||
const content = t.replace(/^-\s*\[x\]\s*/i, '');
|
||||
return `- [x] ${content}`;
|
||||
}
|
||||
|
||||
// Checkbox vuota: "- [ ] testo"
|
||||
if (/^-\s*\[\s*\]\s*/.test(t)) {
|
||||
const content = t.replace(/^-\s*\[\s*\]\s*/, '');
|
||||
return `- [ ] ${content}`;
|
||||
}
|
||||
|
||||
// Lista non ordinata: "- testo" o "* testo"
|
||||
if (/^[-*]\s+/.test(t)) {
|
||||
const content = t.replace(/^[-*]\s+/, '');
|
||||
return `- ${content}`;
|
||||
}
|
||||
|
||||
// Lista ordinata: "1. testo", "2. testo", ecc.
|
||||
if (/^\d+\.\s+/.test(t)) return t;
|
||||
|
||||
// Blockquote: "> testo"
|
||||
if (/^>\s*/.test(t)) return t;
|
||||
|
||||
// Blocco codice: riga che inizia con ``` (anche con linguaggio, es. ```js)
|
||||
if (t.startsWith('```')) return t;
|
||||
|
||||
// Testo con inline code, grassetto, corsivo, highlight, barrato:
|
||||
// questi sono già nella forma giusta (es. **testo**), li passiamo invariati.
|
||||
// L'OCR dovrebbe produrli come scritti a mano con i simboli.
|
||||
return t;
|
||||
}
|
||||
88
HandTranscriptMd/src/recognizer.ts
Normal file
88
HandTranscriptMd/src/recognizer.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* =============================================
|
||||
Recognizer — Abstraction layer OCR via Gemini
|
||||
Funziona sia su Windows che su Android.
|
||||
Riceve un'immagine PNG in base64, la invia
|
||||
all'API Gemini e restituisce il testo riconosciuto.
|
||||
L'interfaccia IRecognizer permette di aggiungere
|
||||
in futuro altri backend OCR senza toccare embed.ts.
|
||||
============================================= */
|
||||
|
||||
// Modello Gemini da usare per il riconoscimento visivo
|
||||
const GEMINI_MODEL = 'gemini-3.1-flash-lite-preview';
|
||||
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
||||
|
||||
// Tipo della risposta JSON di Gemini (solo i campi che ci servono)
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{ text?: string }>;
|
||||
};
|
||||
}>;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
/* ---------- Interfaccia comune ---------- */
|
||||
// Mantiene l'astrazione: in futuro si può aggiungere un backend
|
||||
// alternativo (es. OCR locale) senza modificare embed.ts
|
||||
|
||||
export interface IRecognizer {
|
||||
recognize(imageBase64: string): Promise<string>;
|
||||
}
|
||||
|
||||
/* ---------- GeminiRecognizer ---------- */
|
||||
|
||||
class GeminiRecognizer implements IRecognizer {
|
||||
constructor(
|
||||
private apiKey: string,
|
||||
private languages: string[]
|
||||
) {}
|
||||
|
||||
async recognize(imageBase64: string): Promise<string> {
|
||||
// Costruisce il prompt specificando le lingue attese e il formato di output
|
||||
const langList = this.languages.join(', ');
|
||||
const prompt =
|
||||
`Sei un sistema OCR specializzato in scrittura a mano. ` +
|
||||
`Analizza l'immagine e trascrivi esattamente il testo scritto. ` +
|
||||
`Le lingue attese sono: ${langList}. ` +
|
||||
`Preserva i simboli markdown scritti dall'utente ` +
|
||||
`(es. #, ##, ###, -, *, >, \`\`\`, **testo**, *testo*, ==testo==, ~~testo~~, - [ ], - [x]). ` +
|
||||
`Restituisci SOLO il testo trascritto, senza alcuna spiegazione aggiuntiva.`;
|
||||
|
||||
const resp = await fetch(`${GEMINI_URL}?key=${this.apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{
|
||||
parts: [
|
||||
// Immagine PNG in base64
|
||||
{ inline_data: { mime_type: 'image/png', data: imageBase64 } },
|
||||
// Istruzioni OCR
|
||||
{ text: prompt }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
// Gestione errori HTTP (chiave non valida, quota esaurita, ecc.)
|
||||
if (!resp.ok) {
|
||||
const errJson = await resp.json().catch(() => ({})) as GeminiResponse;
|
||||
throw new Error(`Gemini ${resp.status}: ${errJson?.error?.message ?? resp.statusText}`);
|
||||
}
|
||||
|
||||
const json = await resp.json() as GeminiResponse;
|
||||
// Estrae il testo dal primo candidato
|
||||
const text = json?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Factory ---------- */
|
||||
|
||||
// Lancia un errore subito se la chiave manca, così embed.ts
|
||||
// può mostrare un avviso chiaro all'utente prima di chiamare l'API
|
||||
export function getRecognizer(apiKey: string, languages: string[]): IRecognizer {
|
||||
if (!apiKey.trim()) {
|
||||
throw new Error('Chiave API Gemini non configurata — aprire le impostazioni del plugin');
|
||||
}
|
||||
return new GeminiRecognizer(apiKey, languages);
|
||||
}
|
||||
228
HandTranscriptMd/src/settings.ts
Normal file
228
HandTranscriptMd/src/settings.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/* =============================================
|
||||
Settings — Configurazione del plugin
|
||||
============================================= */
|
||||
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type HandwritingPlugin from './main';
|
||||
|
||||
// Modalità sfondo: chiaro, scuro o colore personalizzato
|
||||
export type BgMode = 'light' | 'dark' | 'custom';
|
||||
|
||||
export interface HandwritingSettings {
|
||||
svgFolder: string; // cartella dove salvare i file SVG
|
||||
canvasWidth: number; // larghezza interna del canvas (px)
|
||||
canvasHeight: number; // altezza interna del canvas (px)
|
||||
bgMode: BgMode; // modalità sfondo
|
||||
bgCustomColor: string; // colore hex se bgMode === 'custom'
|
||||
ocrLanguages: string[]; // lingue per il riconoscimento OCR (codici BCP-47, es. 'it', 'en')
|
||||
geminiApiKey: string; // chiave API Google Gemini per l'OCR
|
||||
debugMode: boolean; // mostra Notice di debug per eventi IME/touch
|
||||
}
|
||||
|
||||
// Colori predefiniti per le modalità
|
||||
export const BG_COLORS: Record<BgMode, string> = {
|
||||
light: '#ffffff',
|
||||
dark: '#1e1e1e',
|
||||
custom: '#ffffff',
|
||||
};
|
||||
|
||||
// Colore righe adattato allo sfondo
|
||||
export const LINE_COLORS: Record<BgMode, string> = {
|
||||
light: '#e0e0e0',
|
||||
dark: '#3a3a3a',
|
||||
custom: '#cccccc',
|
||||
};
|
||||
|
||||
// Ritorna il colore sfondo effettivo in base alle impostazioni
|
||||
export function getEffectiveBgColor(settings: HandwritingSettings): string {
|
||||
if (settings.bgMode === 'custom') return settings.bgCustomColor;
|
||||
return BG_COLORS[settings.bgMode];
|
||||
}
|
||||
|
||||
// Ritorna il colore righe effettivo in base alle impostazioni
|
||||
export function getEffectiveLineColor(settings: HandwritingSettings): string {
|
||||
if (settings.bgMode === 'custom') {
|
||||
// Per colore custom, calcola righe leggermente più scure/chiare
|
||||
return adjustLineColor(settings.bgCustomColor);
|
||||
}
|
||||
return LINE_COLORS[settings.bgMode];
|
||||
}
|
||||
|
||||
// Calcola un colore righe adatto allo sfondo custom
|
||||
// Se lo sfondo è chiaro → righe più scure, se scuro → righe più chiare
|
||||
function adjustLineColor(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
// Luminosità percepita (formula sRGB)
|
||||
const lum = (r * 0.299 + g * 0.587 + b * 0.114);
|
||||
const shift = lum > 128 ? -30 : 30; // scurisci o schiarisci
|
||||
const clamp = (v: number) => Math.max(0, Math.min(255, v + shift));
|
||||
return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Mappa colori chiari ↔ scuri per adattare i tratti al cambio tema.
|
||||
// Quando l'utente cambia tema, i tratti con colori della palette opposta
|
||||
// vengono rimappati ai corrispondenti colori leggibili.
|
||||
const LIGHT_COLORS = ['#000000', '#1e40af', '#dc2626', '#16a34a'];
|
||||
const DARK_COLORS = ['#ffffff', '#60a5fa', '#f87171', '#4ade80'];
|
||||
|
||||
// Rimappa il colore di un tratto in base al tema corrente
|
||||
export function remapStrokeColor(color: string, bgMode: BgMode): string {
|
||||
const c = color.toLowerCase();
|
||||
if (bgMode === 'dark') {
|
||||
// Se il tratto ha un colore "chiaro" (della palette light), mappalo al corrispondente dark
|
||||
const idx = LIGHT_COLORS.indexOf(c);
|
||||
if (idx >= 0) return DARK_COLORS[idx]!;
|
||||
} else if (bgMode === 'light') {
|
||||
// Viceversa: colori dark → light
|
||||
const idx = DARK_COLORS.indexOf(c);
|
||||
if (idx >= 0) return LIGHT_COLORS[idx]!;
|
||||
}
|
||||
// Per custom o colori non in palette, lascia invariato
|
||||
return color;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: HandwritingSettings = {
|
||||
svgFolder: '_handwriting',
|
||||
canvasWidth: 800,
|
||||
canvasHeight: 300,
|
||||
bgMode: 'light',
|
||||
bgCustomColor: '#ffffff',
|
||||
ocrLanguages: ['it', 'en'], // italiano e inglese di default
|
||||
geminiApiKey: '',
|
||||
debugMode: false,
|
||||
};
|
||||
|
||||
export class HandwritingSettingTab extends PluginSettingTab {
|
||||
plugin: HandwritingPlugin;
|
||||
|
||||
constructor(app: App, plugin: HandwritingPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', { text: 'Handwriting to Markdown' });
|
||||
|
||||
// --- Cartella SVG ---
|
||||
new Setting(containerEl)
|
||||
.setName('Cartella SVG')
|
||||
.setDesc('Cartella nel vault dove vengono salvati i file SVG dei disegni')
|
||||
.addText(text => text
|
||||
.setPlaceholder('_handwriting')
|
||||
.setValue(this.plugin.settings.svgFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.svgFolder = value || '_handwriting';
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// --- Larghezza canvas ---
|
||||
new Setting(containerEl)
|
||||
.setName('Larghezza canvas')
|
||||
.setDesc('Risoluzione orizzontale del canvas in pixel')
|
||||
.addText(text => text
|
||||
.setValue(String(this.plugin.settings.canvasWidth))
|
||||
.onChange(async (value) => {
|
||||
const n = parseInt(value);
|
||||
if (!isNaN(n) && n > 100) {
|
||||
this.plugin.settings.canvasWidth = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
|
||||
// --- Altezza canvas ---
|
||||
new Setting(containerEl)
|
||||
.setName('Altezza canvas')
|
||||
.setDesc('Risoluzione verticale del canvas in pixel')
|
||||
.addText(text => text
|
||||
.setValue(String(this.plugin.settings.canvasHeight))
|
||||
.onChange(async (value) => {
|
||||
const n = parseInt(value);
|
||||
if (!isNaN(n) && n > 50) {
|
||||
this.plugin.settings.canvasHeight = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
|
||||
// --- Sfondo canvas ---
|
||||
new Setting(containerEl)
|
||||
.setName('Sfondo canvas')
|
||||
.setDesc('Colore di sfondo del riquadro di disegno')
|
||||
.addDropdown(drop => drop
|
||||
.addOption('light', 'Chiaro (bianco)')
|
||||
.addOption('dark', 'Scuro (grigio scuro)')
|
||||
.addOption('custom', 'Personalizzato')
|
||||
.setValue(this.plugin.settings.bgMode)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.bgMode = value as BgMode;
|
||||
await this.plugin.saveSettings();
|
||||
// Aggiorna la UI per mostrare/nascondere il color picker
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// --- Colore personalizzato (visibile solo se bgMode === 'custom') ---
|
||||
if (this.plugin.settings.bgMode === 'custom') {
|
||||
new Setting(containerEl)
|
||||
.setName('Colore sfondo personalizzato')
|
||||
.setDesc('Scegli il colore hex dello sfondo')
|
||||
.addColorPicker(picker => picker
|
||||
.setValue(this.plugin.settings.bgCustomColor)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.bgCustomColor = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Chiave API Gemini ---
|
||||
new Setting(containerEl)
|
||||
.setName('Chiave API Gemini')
|
||||
.setDesc('Necessaria per il riconoscimento OCR della scrittura a mano. Ottienila da Google AI Studio (aistudio.google.com).')
|
||||
.addText(text => {
|
||||
text
|
||||
.setPlaceholder('AIza...')
|
||||
.setValue(this.plugin.settings.geminiApiKey)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.geminiApiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
// Maschera il testo come password per nascondere la chiave
|
||||
text.inputEl.type = 'password';
|
||||
});
|
||||
|
||||
// --- Lingue OCR ---
|
||||
new Setting(containerEl)
|
||||
.setName('Lingue OCR')
|
||||
.setDesc(
|
||||
'Codici lingua BCP-47 separati da virgola (es. "it, en, fr"). ' +
|
||||
'Usati dal riconoscitore di scrittura su Android.'
|
||||
)
|
||||
.addText(text => text
|
||||
// Mostra l'array come stringa "it, en"
|
||||
.setValue(this.plugin.settings.ocrLanguages.join(', '))
|
||||
.setPlaceholder('it, en')
|
||||
.onChange(async (value) => {
|
||||
// Parsa la stringa in array, rimuovendo spazi e voci vuote
|
||||
const langs = value
|
||||
.split(',')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0);
|
||||
this.plugin.settings.ocrLanguages = langs.length > 0 ? langs : ['it', 'en'];
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// --- Modalità debug ---
|
||||
new Setting(containerEl)
|
||||
.setName('Modalità debug')
|
||||
.setDesc('Mostra notifiche in tempo reale per eventi IME/touch (utile per diagnosticare problemi su Android).')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.debugMode)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debugMode = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
124
HandTranscriptMd/src/svg-utils.ts
Normal file
124
HandTranscriptMd/src/svg-utils.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* =============================================
|
||||
SVG Utilities — Conversione tratti ↔ SVG
|
||||
I tratti vengono salvati come <path> nell'SVG,
|
||||
e i dati grezzi in un elemento <desc> (JSON)
|
||||
per poter ricaricare e rieditare il disegno.
|
||||
============================================= */
|
||||
|
||||
import { Point, Stroke } from './drawing-canvas';
|
||||
|
||||
// Genera ID univoco per nuovi disegni (timestamp + random)
|
||||
export function generateId(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const rnd = Math.random().toString(36).substring(2, 6);
|
||||
return `hw_${ts}_${rnd}`;
|
||||
}
|
||||
|
||||
// Converte un array di punti in un attributo SVG path "d"
|
||||
// Usa curve quadratiche Bézier con midpoint per smoothing
|
||||
function pointsToPathD(points: Point[]): string {
|
||||
if (points.length < 2) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
// Move to primo punto
|
||||
parts.push(`M ${r(points[0]!.x)},${r(points[0]!.y)}`);
|
||||
|
||||
if (points.length === 2) {
|
||||
parts.push(`L ${r(points[1]!.x)},${r(points[1]!.y)}`);
|
||||
} else {
|
||||
// Curve quadratiche con midpoint (stessa tecnica del canvas)
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const curr = points[i]!;
|
||||
const next = points[i + 1]!;
|
||||
const midX = (curr.x + next.x) / 2;
|
||||
const midY = (curr.y + next.y) / 2;
|
||||
parts.push(`Q ${r(curr.x)},${r(curr.y)} ${r(midX)},${r(midY)}`);
|
||||
}
|
||||
// Ultimo punto
|
||||
const last = points[points.length - 1]!;
|
||||
parts.push(`L ${r(last.x)},${r(last.y)}`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Arrotonda a 1 decimale per SVG più compatti
|
||||
function r(n: number): string {
|
||||
return Math.round(n * 10) / 10 + '';
|
||||
}
|
||||
|
||||
// Converte array di Stroke in contenuto SVG completo
|
||||
// I dati grezzi dei tratti sono dentro <desc> come JSON
|
||||
// per permettere il riedit senza perdere informazioni
|
||||
export function strokesToSvg(
|
||||
strokes: Stroke[], width: number, height: number,
|
||||
bgColor = '#ffffff', lineColor = '#e0e0e0'
|
||||
): string {
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const stroke of strokes) {
|
||||
const d = pointsToPathD(stroke.points);
|
||||
if (!d) continue;
|
||||
paths.push(
|
||||
` <path d="${d}" stroke="${stroke.color}" fill="none" ` +
|
||||
`stroke-width="${stroke.width}" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
);
|
||||
}
|
||||
|
||||
const strokesJson = JSON.stringify(strokes);
|
||||
|
||||
// Righe orizzontali (foglio a righe) — stessa spaziatura del canvas
|
||||
const LINE_SPACING = 32;
|
||||
const lines: string[] = [];
|
||||
for (let y = LINE_SPACING; y < height; y += LINE_SPACING) {
|
||||
lines.push(` <line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${lineColor}" stroke-width="0.5"/>`);
|
||||
}
|
||||
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
|
||||
` <rect width="100%" height="100%" fill="${bgColor}"/>`,
|
||||
...lines,
|
||||
` <desc class="hwm-strokes">${escapeXml(strokesJson)}</desc>`,
|
||||
...paths,
|
||||
`</svg>`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Estrae i tratti dal JSON nella <desc> dell'SVG
|
||||
// Restituisce array vuoto se non trova dati validi
|
||||
export function parseSvgStrokes(svgContent: string): Stroke[] {
|
||||
try {
|
||||
// Cerca il contenuto del tag <desc class="hwm-strokes">
|
||||
const match = svgContent.match(/<desc class="hwm-strokes">([\s\S]*?)<\/desc>/);
|
||||
if (!match) return [];
|
||||
|
||||
const json = unescapeXml(match[1] ?? '');
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
// Validazione base: deve essere un array di oggetti con points, color, width
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((s: Stroke) =>
|
||||
Array.isArray(s.points) && typeof s.color === 'string' && typeof s.width === 'number'
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Escape caratteri speciali XML per inserimento in <desc>
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Ripristina i caratteri XML escapati
|
||||
function unescapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
259
HandTranscriptMd/styles.css
Normal file
259
HandTranscriptMd/styles.css
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/* ========================================
|
||||
Handwriting to Markdown — Plugin Styles
|
||||
Prefisso hwm_ per evitare conflitti
|
||||
======================================== */
|
||||
|
||||
/* --- Container principale dell'embed --- */
|
||||
.hwm_container {
|
||||
position: relative;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
/* Sfondo SEMPRE bianco (anche in dark mode) */
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* --- Toolbar in alto a destra --- */
|
||||
.hwm_toolbar {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0; /* spacing gestito via margin-right sui bottoni, così i bottoni
|
||||
nascosti (max-width:0, margin:0) non creano spazi vuoti */
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(240, 240, 240, 0.95);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
/* --- Bottoni toolbar --- */
|
||||
.hwm_btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
/* Spacing via margin-right invece di gap, così i bottoni nascosti
|
||||
(margin:0) non lasciano spazi vuoti nella toolbar compatta */
|
||||
margin-right: 4px;
|
||||
/* Animazione espansione/compressione toolbar */
|
||||
overflow: hidden;
|
||||
max-width: 40px;
|
||||
flex-shrink: 0;
|
||||
transition: max-width 0.2s ease, opacity 0.2s ease,
|
||||
margin 0.2s ease, padding 0.2s ease;
|
||||
}
|
||||
/* Icone SVG inline dentro i bottoni */
|
||||
.hwm_btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hwm_btn:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
/* Bottone attivo (tool selezionato) */
|
||||
.hwm_btn.hwm_active {
|
||||
background: var(--interactive-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* --- Bottone salva (evidenziato) --- */
|
||||
.hwm_save-btn {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.hwm_save-btn:hover {
|
||||
background: var(--interactive-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* --- Bottone elimina (X rossa su hover) --- */
|
||||
.hwm_delete-btn:hover {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* --- Selettore colori --- */
|
||||
.hwm_colors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
/* I pallini colore sono <div> (non <button>) per evitare che Obsidian Mobile
|
||||
sovrascriva width/height con i propri stili globali sui button.
|
||||
Le dimensioni (22px) sono forzate anche via style.setProperty inline in embed.ts */
|
||||
.hwm_color-btn {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hwm_color-btn.hwm_active {
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
.hwm_color-btn:hover {
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
/* --- Area canvas (editing) --- */
|
||||
.hwm_canvas-wrap {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hwm_canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: crosshair;
|
||||
/* Sfondo bianco fisso */
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* --- Handle di resize in basso --- */
|
||||
.hwm_resize-handle {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ns-resize;
|
||||
background: #f5f5f5;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.hwm_resize-handle:hover {
|
||||
background: #ebebeb;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* --- Placeholder per canvas vuoto --- */
|
||||
.hwm_placeholder {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Errore parsing --- */
|
||||
.hwm_error {
|
||||
color: var(--text-error);
|
||||
padding: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Separatore visivo nella toolbar --- */
|
||||
.hwm_separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #d0d0d0;
|
||||
margin: 0 6px 0 2px; /* destra più ampia per bilanciare visivamente */
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: max-width 0.2s ease, opacity 0.2s ease,
|
||||
margin 0.2s ease, padding 0.2s ease;
|
||||
}
|
||||
|
||||
/* --- Pallini colore: transizione per animazione toolbar --- */
|
||||
.hwm_color-btn {
|
||||
transition: max-width 0.2s ease, opacity 0.2s ease,
|
||||
margin 0.2s ease, padding 0.2s ease;
|
||||
}
|
||||
|
||||
/* --- Toolbar compatta (mobile) ---
|
||||
Bottoni nascosti: collassati a larghezza 0 con fade-out.
|
||||
La transizione su .hwm_btn e .hwm_separator produce l'animazione
|
||||
quando la classe viene aggiunta/rimossa via JS. */
|
||||
.hwm_toolbar--compact .hwm_pen-btn,
|
||||
.hwm_toolbar--compact .hwm_eraser-btn,
|
||||
.hwm_toolbar--compact .hwm_clear-btn,
|
||||
.hwm_toolbar--compact .hwm_save-btn,
|
||||
.hwm_toolbar--compact .hwm_color-btn:not(.hwm_active),
|
||||
.hwm_toolbar--compact .hwm_separator {
|
||||
max-width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Riduce il margine attorno al pallino colore rimasto e azzera il gap
|
||||
tra i pallini (gli inattivi sono collassati a width:0 ma il gap persiste) */
|
||||
.hwm_toolbar--compact .hwm_colors {
|
||||
margin: 0 2px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* --- Tema scuro per la toolbar ---
|
||||
Applicato via classe hwm_toolbar--dark quando bgMode === 'dark' */
|
||||
.hwm_toolbar--dark {
|
||||
background: rgba(40, 40, 40, 0.97) !important;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
.hwm_toolbar--dark .hwm_btn {
|
||||
color: #bbb !important;
|
||||
}
|
||||
.hwm_toolbar--dark .hwm_btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.hwm_toolbar--dark .hwm_btn.hwm_active {
|
||||
background: var(--interactive-accent) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.hwm_toolbar--dark .hwm_separator {
|
||||
background: #555 !important;
|
||||
}
|
||||
|
||||
/* --- Mobile: bottoni grandi e visibili ---
|
||||
Forza sfondo chiaro + colore scuro per le icone SVG,
|
||||
indipendentemente dal tema di Obsidian Mobile */
|
||||
.hwm_mobile .hwm_toolbar {
|
||||
gap: 0;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.hwm_mobile .hwm_toolbar .hwm_btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
margin-right: 6px !important; /* spacing via margin, non gap */
|
||||
background: rgba(245, 245, 245, 0.98) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.18) !important;
|
||||
border-radius: 7px !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18) !important;
|
||||
color: #222 !important;
|
||||
}
|
||||
.hwm_mobile .hwm_toolbar .hwm_btn.hwm_active {
|
||||
background: var(--interactive-accent) !important;
|
||||
border-color: var(--interactive-accent) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* --- Tema scuro su mobile: sovrascrive i bianchi !important del mobile --- */
|
||||
.hwm_mobile .hwm_toolbar--dark .hwm_btn {
|
||||
background: rgba(55, 55, 55, 0.98) !important;
|
||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||
color: #ddd !important;
|
||||
}
|
||||
.hwm_mobile .hwm_toolbar--dark .hwm_btn.hwm_active {
|
||||
background: var(--interactive-accent) !important;
|
||||
border-color: var(--interactive-accent) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
30
HandTranscriptMd/tsconfig.json
Normal file
30
HandTranscriptMd/tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strictBindCallApply": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
17
HandTranscriptMd/version-bump.mjs
Normal file
17
HandTranscriptMd/version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
3
HandTranscriptMd/versions.json
Normal file
3
HandTranscriptMd/versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
90
README.md
Normal file
90
README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Obsidian Sample Plugin
|
||||
|
||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
||||
|
||||
This project uses TypeScript to provide type checking and documentation.
|
||||
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open modal (simple)" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
- Registers a global click event and output 'click' to the console.
|
||||
- Registers a global interval which logs 'setInterval' to the console.
|
||||
|
||||
## First time developing plugins?
|
||||
|
||||
Quick starting guide for new plugin devs:
|
||||
|
||||
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
|
||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
||||
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
|
||||
- Install NodeJS, then run `npm i` in the command line under your repo folder.
|
||||
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
|
||||
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
|
||||
- Reload Obsidian to load the new version of your plugin.
|
||||
- Enable plugin in settings window.
|
||||
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
|
||||
|
||||
## Releasing new releases
|
||||
|
||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
||||
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
|
||||
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
|
||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
|
||||
- Publish the release.
|
||||
|
||||
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
|
||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
||||
|
||||
## Adding your plugin to the community plugin list
|
||||
|
||||
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
|
||||
- Publish an initial version.
|
||||
- Make sure you have a `README.md` file in the root of your repo.
|
||||
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
|
||||
|
||||
## How to use
|
||||
|
||||
- Clone this repo.
|
||||
- Make sure your NodeJS is at least v16 (`node --version`).
|
||||
- `npm i` or `yarn` to install dependencies.
|
||||
- `npm run dev` to start compilation in watch mode.
|
||||
|
||||
## Manually installing the plugin
|
||||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
|
||||
## Improve code quality with eslint
|
||||
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
|
||||
- This project already has eslint preconfigured, you can invoke a check by running`npm run lint`
|
||||
- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines.
|
||||
- A GitHub action is preconfigured to automatically lint every commit on all branches.
|
||||
|
||||
## Funding URL
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
}
|
||||
```
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
See https://docs.obsidian.md
|
||||
Loading…
Reference in a new issue