merge del branch overlay

This commit is contained in:
gabriele-cusato 2026-03-23 18:58:06 +01:00
commit 5cc2f0bfa2
9 changed files with 2287 additions and 735 deletions

View file

@ -12,7 +12,10 @@
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:crbug.com)",
"WebFetch(domain:www.reddit.com)",
"WebFetch(domain:bugs.chromium.org)"
"WebFetch(domain:bugs.chromium.org)",
"WebFetch(domain:developer.chrome.com)",
"Bash(\"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\" --login -c \"cd 'C:/Projects/pluginObsidian/handWrittenMarkdownConverter/HandTranscriptMd' && bash deploy.sh && bash cloudDeploy.sh\" 2>&1)",
"Bash(\"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\" --login -c \"cd 'C:/Projects/pluginObsidian/handWrittenMarkdownConverter/HandTranscriptMd' && bash deploy.sh\" 2>&1)"
]
}
}

246
CLAUDE.md
View file

@ -40,7 +40,7 @@ Un plugin Obsidian che inserisce un **riquadro canvas inline** in un file `.md`
---
## Stato attuale — Fase 5 (fix overlay + centramento modal + badge mode + auto-scroll) — TESTATO
## Stato attuale — Fase 6 (pannello portale inline + bgMode live update + fix CSS tema) — TESTATO
### File del plugin (`HandTranscriptMd/src/`)
@ -50,7 +50,7 @@ Un plugin Obsidian che inserisce un **riquadro canvas inline** in un file `.md`
| `settings.ts` | Impostazioni: cartella SVG, dimensioni canvas, sfondo, lingue OCR, chiave API Gemini, toggle `hwmHandwritingMode`; mostra versione (`plugin.manifest.version`) e branch (`PLUGIN_BRANCH` costante hardcoded) nell'header della pagina impostazioni |
| `drawing-canvas.ts` | Motore disegno Canvas API: Bézier quadratiche, penna, gomma parziale, undo/redo history-based, auto-expand, righe foglio, `allowFingerScroll()` |
| `svg-utils.ts` | Conversione tratti ↔ SVG (dati riedit in `<desc>` JSON), righe e sfondo inclusi nell'SVG |
| `embed.ts` | Code block processor → preview SVG inline + pannello portale in document.body; click matita → Modal (Windows) o nuova tab (Android); scroll fix; badge mode |
| `embed.ts` | Code block processor → preview SVG inline + pannello portale (figlio dello span, position:absolute); click matita → Modal (Windows) o nuova tab (Android); badge mode; `onBgModeRemap` per aggiornare SVG al cambio tema |
| `editor-view.ts` | `DrawingEditorView` (tab dedicata, Android) + `DrawingModal` (overlay fullscreen, Windows), toolbar completa, auto-save |
| `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 |
@ -66,8 +66,13 @@ Un plugin Obsidian che inserisce un **riquadro canvas inline** in un file `.md`
- **MutationObserver su `document.body`** intercetta gli span con `src*="_handwriting/"` non appena appaiono nel DOM (funziona sia in reading view che in live preview dove il post-processor non viene chiamato)
- `tryDecorate()` con flag `data-hwm-decorated="1"` per evitare doppia elaborazione; ritenta dopo 150ms se la classe `image-embed` non è ancora presente (caricamento asincrono)
- **Classe badge mode**: `span.classList.toggle('hwm-badge-mode', hwmHandwritingMode)` — unica modifica allo span quando lo switch è attivo
- **Pannello portale** (`hwm_portal-panel`) in `document.body` con 4 bottoni: ✏️ (apre Modal su Windows / tab su Android), 📄 (converti OCR), ↕️ (comprimi/espandi), ✕ (elimina). Posizionato via RAF + `getBoundingClientRect()` + `position: fixed`
- **Scroll fix**: listener `scroll` su `.cm-scroller` / `.markdown-reading-view` che nasconde il pannello immediatamente durante lo scroll (visibilità ripristinata dopo 150ms)
- **Pannello portale** (`hwm_portal-panel`) con 4 bottoni: ✏️ (apre Modal su Windows / tab su Android), 📄 (converti OCR), ↕️ (comprimi/espandi), ✕ (elimina).
- **Posizione**: `container.appendChild(panel)` — il pannello è un figlio diretto dello span; `position: absolute; top: 6px; right: 6px` ancorato allo span (che ha `position: relative`). Non più in `document.body`.
- **Nessun RAF loop**: eliminato il loop `requestAnimationFrame` e il listener `scroll`. Il pannello segue lo span naturalmente nel DOM.
- **Cleanup**: `plugin.register(() => panel.remove())` — il pannello viene rimosso quando il plugin viene disabilitato.
- **Click dei bottoni**: lo span ha `pointer-events: none` (per handwriting Android); il pannello ha `pointer-events: auto` in CSS per ripristinare i click.
- **Nascondere con modal aperto**: Desktop → `panel.style.display = 'none'` nel click handler, ripristinato nel callback `modal.onClosed`; Mobile → `workspace.on('layout-change', ...)` per rilevare apertura/chiusura tab.
- **Comprimi/Espandi**: usa `container.style.height + overflow: hidden` sullo span contenitore — modifica solo l'altezza visibile senza toccare la larghezza dell'immagine.
- **Refresh immagine**: `previewCallbacks.set(embedId, ...)` aggiorna `img.src` con cache-bust `?t=timestamp` dopo ogni salvataggio dalla tab editor (Obsidian non aggiorna automaticamente `![[svg]]` in live preview quando il file cambia)
- **NO data-URI**: il src dell'img resta sempre l'URL vault (`http://localhost/_capacitor_file_/...`) — un data-URI verrebbe interpretato da Android come drawing surface
@ -88,8 +93,9 @@ Un plugin Obsidian che inserisce un **riquadro canvas inline** in un file `.md`
`DrawingModal extends Modal` — usato su **Windows**:
- Overlay fullscreen (`hwm_modal`: `95vw × 90vh`) che appare sopra il documento
- Stessa toolbar e stesso canvas di `DrawingEditorView`
- `onClosed?: () => void` — callback invocato alla chiusura per aggiornare il flag `modalOpen` nel RAF loop del pannello portale
- `onClosed?: () => void` — callback invocato alla chiusura per ripristinare `panel.style.display` nel pannello portale
- `replaceCodeBlock` / `removeCodeBlock` gestiscono sia formato wiki `![[svg]]` che legacy (prova prima wiki, poi code block come fallback)
- `private bgModeListener` registrato in `buildEditor()` (dopo `colorBtns`) e rimosso in `onClose()` — aggiorna topbar, toolbar, pallini colore e canvas al cambio bgMode in tempo reale (utile se le impostazioni fossero accessibili con modal aperto)
**Comportamento bottone matita (pannello portale):**
- `Platform.isDesktop``new DrawingModal(...).open()` — si apre nella stessa finestra
@ -124,7 +130,7 @@ Un plugin Obsidian che inserisce un **riquadro canvas inline** in un file `.md`
- **Settings OCR** — chiave API Gemini (campo password) + lingue OCR configurabili (default: `it, en`)
- **Supporto Android** — penna disegna, dito scrolla, nessun conflitto handwriting
- **Comprimi/Espandi** — preview inline si può compattare all'altezza di default (freccia con rotazione 180°)
- **Bottone portale** — pannello floating in `document.body`, si posiziona sopra il riquadro via RAF + getBoundingClientRect, scompare durante lo scroll (listener scroll + visibility hidden/visible)
- **Bottone portale** — pannello `position: absolute` figlio diretto dello span (no RAF loop, no scroll listener, no document.body)
- **Preview non tappabile** — click handler rimosso; l'unico modo per aprire l'editor è il bottone matita nel pannello portale
- **Modal Windows** — click matita su Desktop apre `DrawingModal` (overlay fullscreen nella stessa finestra, no tab separata)
- **Nuova tab Android** — click matita su Mobile apre `DrawingEditorView` in tab separata
@ -147,14 +153,14 @@ Il file SVG è visibile come immagine anche senza il plugin. I tratti sono salva
### Deploy (comandi copia-incolla per PowerShell)
> **Nota**: usare la forma `bash -c "..."` perché in PowerShell `bash deploy.sh` lancia bash in una directory diversa (home di WSL/Git Bash), non nella directory corrente. Racchiudendo tutto dentro bash il `cd` funziona correttamente per tutti i comandi.
> **Nota**: usare Git Bash esplicitamente con `--login` perché PowerShell usa WSL bash di default (che non riconosce i percorsi Windows) e senza `--login` il PATH non include i tool Unix (dirname, cp, wc, ecc.).
```powershell
# Build + deploy al vault locale (solo PC)
bash -c "cd '/c/Projects/pluginObsidian/handWrittenMarkdownConverter/HandTranscriptMd' && node esbuild.config.mjs production && bash deploy.sh"
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; node esbuild.config.mjs production; & "C:\Program Files\Git\bin\bash.exe" --login deploy.sh
# Build + deploy su Google Drive (per testare su tablet Android)
bash -c "cd '/c/Projects/pluginObsidian/handWrittenMarkdownConverter/HandTranscriptMd' && node esbuild.config.mjs production && bash cloudDeploy.sh"
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; node esbuild.config.mjs production; & "C:\Program Files\Git\bin\bash.exe" --login cloudDeploy.sh
```
### Percorsi vault
@ -170,10 +176,10 @@ bash -c "cd '/c/Projects/pluginObsidian/handWrittenMarkdownConverter/HandTranscr
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; npm run dev
# Dopo ogni modifica per testare su PC:
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; node esbuild.config.mjs production; & "C:\Program Files\Git\bin\bash.exe" --login deploy.sh
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; node esbuild.config.mjs production; bash deploy.sh
# Dopo ogni modifica per testare su tablet Android:
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; node esbuild.config.mjs production; & "C:\Program Files\Git\bin\bash.exe" --login cloudDeploy.sh
cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; node esbuild.config.mjs production; bash cloudDeploy.sh
# In Obsidian: Ctrl+P → "Reload app without saving"
```
@ -206,215 +212,15 @@ cd C:\Projects\pluginObsidian\handWrittenMarkdownConverter\HandTranscriptMd; nod
---
> **Storico sessioni, bug risolti e funzionalità completate** → vedi [`NOTES.md`](./NOTES.md)
> I task completati vanno spostati in `NOTES.md`; in questa sezione restano solo i task ancora da fare.
## Prossimi passi
### ✅ TEST EFFETTUATI (sessione 2026-03-22)
### Task aperti
#### Bug 1 — Pannello portale parzialmente visibile con Modal aperto — RISOLTO ✅
**Sintomo**: su Windows, cliccando il bottone matita, i bottoni "Converti" e "Comprimi" del pannello portale restavano visibili sopra il modal mentre il bottone matita scompariva correttamente.
**Causa**: il RAF loop nascondeva solo il bottone matita (`btn.style.display`) ma non l'intero pannello.
**Fix**: il RAF loop ora setta `panel.style.display = 'none'` quando `modalOpen || tabOpen`, nascondendo l'intero pannello portale (tutti e 4 i bottoni).
**File**: `src/embed.ts` — RAF loop in `decorateSpan()`.
---
#### Bug 2 — Canvas modal troppo largo, toolbar non centrata — RISOLTO ✅
**Sintomo**: nel Modal Windows, il canvas occupava tutta la larghezza dell'overlay (troppo largo e non centrato). La toolbar era allineata a sinistra invece che al centro.
**Fix CSS** (`styles.css`):
- `.hwm_canvas-wrap { display: flex; justify-content: center; }` — centra il canvas orizzontalmente
- `.hwm_canvas { max-width: 100%; }` — rimosso `width: 100%` fisso
- `.hwm_editor-topbar--modal { justify-content: center; }` — centra la toolbar nel modal
**Fix TS** (`editor-view.ts`): `DrawingModal.buildEditor()` aggiunge classe `hwm_editor-topbar--modal` alla topbar.
---
#### Bug 3 — Auto-scroll sposta i tratti durante il disegno — RISOLTO ✅
**Sintomo**: quando il canvas si espandeva automaticamente (auto-expand) mentre stavo disegnando, lo scroll automatico verso il basso spostava i punti del tratto corrente rispetto alla posizione del pennino.
**Causa**: l'evento `onResize` faceva scroll immediatamente anche con il pointer premuto, spostando il canvas mentre le coordinate del puntatore erano ancora relative alla posizione pre-scroll.
**Fix**: aggiunto metodo pubblico `isPointerDown(): boolean` in `DrawingCanvas` (`drawing-canvas.ts`). Sia `DrawingEditorView` che `DrawingModal` in `editor-view.ts` ora controllano `!canvas.isPointerDown()` prima di eseguire il `scrollTop` automatico.
---
#### Bug 4 — Badge mode mostra icona in riquadro piccolissimo — RISOLTO ✅
**Sintomo**: attivando "Modalità handwriting Android" nelle impostazioni, i riquadri SVG si riducevano a un quadratino minuscolo invece di un badge orizzontale a piena larghezza.
**Causa**: lo span `.internal-embed` senza figli visibili collassava alla sua larghezza intrinseca (quasi zero).
**Fix CSS** (`.hwm-handwriting-mode .hwm-badge-mode`):
```css
width: 100% !important; box-sizing: border-box !important;
height: 72px !important; display: flex !important;
align-items: center; justify-content: center;
background: var(--background-secondary); border-radius: 6px;
```
Più: `img { display: none !important }` per nascondere l'SVG e `::after { content: "✏️"; font-size: 28px; opacity: 0.5; }` per l'icona.
**File**: `styles.css`.
### Altri task aperti
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. **Auto-expand — animazione scattosa****BUG APERTO** (parzialmente risolto):
- Fix precedente: guard `if (this.animFrameId !== null) return` in `checkAutoExpand()` → loop up/down risolto
- Problema residuo: espansione scattosa invece che fluida
- File: `src/drawing-canvas.ts``animateHeight()` e `checkAutoExpand()`
3. **Toolbar unificata Windows/Android****DA FARE**:
- Attualmente la toolbar su Windows è sempre espansa, su Android è compatta con toggle ▼/▲
- Obiettivo: unico codice toolbar condiviso, compatta di default su entrambe le piattaforme
- File coinvolti: `src/editor-view.ts` + `styles.css`
### Problemi risolti
- **Handwriting Android (disegno)** ✅ — risolto con editor in tab separata (`ItemView`), canvas fuori da `cm-content`
- **Pen scroll** ✅ — penna non scrolla più, solo dito (JS manuale via `setPointerCapture`)
- **Toolbar — tema scuro**
- **Spazio vuoto sezione colori in toolbar compatta**
- **Trashcan non cancella visualmente**
- **Bottoni inline coprivano `</>` di Obsidian** ✅ — spostati a `left: 6px`
- **Ordine bottoni inline** ✅ — invertito: X, Converti, Freccia (da sinistra)
- **Placeholder text** ✅ — aggiornato a "Usa il bottone matita in alto a destra per disegnare"
- **Bottone portale non cerchio perfetto** ✅ — risolto con `width/height/min-width/min-height: 36px !important`, `padding: 0 !important`, `overflow: hidden`
- **Icona bottone portale non visibile** ✅ — SVG con `stroke="currentColor"` non diventava bianco; risolto con `.hwm_portal-btn svg { stroke: #ffffff !important }`
- **Bottone portale non si nasconde con editor aperto** ✅ — check `workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING).some(...)` nel RAF loop (Android) + flag `modalOpen` (Windows)
- **Bottone portale `position: absolute` invece di `fixed`** ✅ — `getBoundingClientRect()` restituisce coordinate viewport, non serviva aggiungere `scrollY/scrollX`
- **Bottoni pannello portale rimangono visibili durante lo scroll** ✅ — listener `scroll` su `.cm-scroller`/`.markdown-reading-view` che setta `visibility: hidden` durante lo scroll
- **Modal Windows** ✅ — implementato `DrawingModal extends Modal`; click matita su Desktop apre modal invece di nuova tab
- **Switch handwriting** ✅ — `hwmHandwritingMode` in settings; badge mode via classe CSS su `document.body` e sullo span
- **DrawingModal non gestiva formato wiki** ✅ — aggiunto `wikiEmbedRegex()` e logica try-wiki-then-legacy in `replaceInMd()`
- **Pannello portale visibile con Modal aperto (Bug 1)** ✅ — RAF loop ora nasconde l'intero panel (`display: none`) quando `modalOpen || tabOpen`, non solo il bottone matita
- **Canvas modal non centrato, toolbar a sinistra (Bug 2)** ✅ — `display: flex; justify-content: center` su `.hwm_canvas-wrap`; `max-width: 100%` su `.hwm_canvas`; classe `hwm_editor-topbar--modal` aggiunta al topbar del modal per centrare la toolbar
- **Auto-scroll sposta tratti durante disegno (Bug 3)** ✅ — aggiunto `isPointerDown()` in `DrawingCanvas`; scroll automatico bloccato se il pointer è premuto, sia in `DrawingEditorView` che in `DrawingModal`
- **Badge mode mostra riquadro minuscolo (Bug 4)** ✅ — CSS `.hwm-handwriting-mode .hwm-badge-mode` con `width: 100% !important`, `height: 72px`, flex centrato, `img { display: none }` + `::after` con emoji matita
### BUG APERTO — Handwriting disabilitato nel documento quando il riquadro è presente
**Sintomo**: quando nel documento è presente un riquadro handwriting con un disegno (SVG non vuoto), la stylus handwriting-to-text di Android smette di funzionare nell'intero editor. Cancellare il riquadro ripristina l'handwriting. Il problema persiste tra riavvii di Obsidian.
**Progressione delle scoperte**:
**Fase 1 — Formato code block** (tentativi 16-26):
- `contenteditable="false"` su wrapper CM6 → rimosso → non risolve
- `touch-action: none` → rimosso → non risolve
- `background-image` SVG → rimossa → non risolve
- `<canvas>` nel DOM → rimosso → non risolve
- Canvas in `document.body` (fuori da CM6) toccato con stylus → **rompe handwriting** — conclusione: è il canvas element quando toccato dalla stylus, non la sua posizione nel DOM
- **Causa root fase 1**: Android WebView tratta qualsiasi `<canvas>` toccato dalla stylus come "drawing surface" e disabilita handwriting-to-text a livello di sessione WebView
**Fase 2 — Passaggio a formato wiki `![[svg]]`** (sessione 2026-03-19):
L'obiettivo era eliminare il `<canvas>` dal documento e mostrare solo l'`<img>` nativa di Obsidian.
Problema riscontrato: l'SVG vuoto (300px di altezza) NON rompe l'handwriting. L'SVG con un disegno (altezza variabile dopo auto-expand) SÌ lo rompe — anche dopo riavvio Obsidian, anche senza mai aprire la tab editor.
Cambiamenti implementati durante la fase 2:
- Passaggio da code block a `![[svg]]` come formato principale
- `insertHandwritingBlock()` crea il file SVG PRIMA di inserire il wikilink (altrimenti Obsidian mostra "could not be found")
- MutationObserver su `document.body` per intercettare gli span (il post-processor non funziona per i widget CM6 immagine in live preview)
- Fix data-URI: `img.src = data:image/svg+xml,...` → cambiato in cache-bust URL (`?t=timestamp`) perché la data-URI veniva interpretata da Android come drawing surface
- Rimosso `addWikiOverlay` (aggiungeva `hwm_inline-buttons` come figlio dello span): la struttura dello span è ora identica a un'immagine normale
- Pannello portale (`hwm_portal-panel`) in `document.body` con tutti e 4 i bottoni
**Fase 3 — Test approfonditi (sessione 2026-03-22)**:
**Test A — Rimozione `touch-action: none` da `.hwm_resize-handle`** (tentativo 29):
- Motivazione: `touch-action: none` è uno dei segnali che Android WebView usa per identificare "drawing surfaces". Rimuovendolo dal resize handle, si riduce il numero di elementi che si qualificano come drawing surface.
- Risultato: **non risolve**. L'handwriting si rompe ugualmente dopo aver aperto la tab editor.
**Test B — IME reset alla chiusura della tab editor** (tentativo 30):
- Motivazione: ipotesi che la sessione IME (Input Method Engine) di Android venisse "bloccata" dall'apertura della DrawingEditorView. Un blur/focus sul `cm-content` dopo la chiusura avrebbe potuto resettarla.
- Implementazione: `DrawingEditorView.onClose()` fa `cm.blur(); setTimeout(() => cm.focus(), 80)` solo su mobile.
- Risultato: **non risolve**. L'handwriting rimane rotto dopo la chiusura.
**Test C — Sostituzione `<canvas>` con `<svg>` nel motore di disegno** (tentativo 31):
- Motivazione (Opzione C dalle ipotesi): il `<canvas>` è l'elemento che Android WebView riconosce come drawing surface. Sostituendolo con un `<svg>` (che disegna tramite elementi `<path>`), il motore di disegno non avrebbe più alcun canvas DOM.
- Implementazione: riscrittura completa di `drawing-canvas.ts` con `SVGElement`, `<rect>` per sfondo, `<g>` per righe e tratti, `<path>` per ogni tratto con Bézier midpoint. Rimosso `touch-action: none`.
- Risultato: **non risolve**. L'handwriting si rompe ugualmente dopo aver aperto la tab editor SVG. Implementazione reverted dall'utente.
**Test D — Apertura tab senza disegnare nulla** (tentativo 32):
- Motivazione: verificare se il trigger fosse il canvas toccato dalla stylus oppure la semplice apertura della tab.
- Test: aprire la DrawingEditorView, non toccarla, chiuderla → tentare handwriting nel documento.
- Risultato: **handwriting rotto anche senza aver toccato nulla nel canvas**. Il trigger è l'apertura della tab, non il disegno. Questo esclude che `touch-action`, `setPointerCapture` o qualsiasi evento di disegno siano la causa.
**Test E — Apertura in nuova finestra Obsidian** (tentativo 33):
- Motivazione: Obsidian Mobile già apre la tab editor in una nuova finestra separata (`workspace.getLeaf('tab')`). Ipotesi che la separazione di finestra potesse isolare lo stato IME.
- Risultato: **non risolve**. Confermato dall'utente che l'editor già apre in una finestra separata, ma il problema persiste. Tutto il runtime condivide lo stesso processo WebView → lo stato `StylusWritingManager` è condiviso a livello di processo, non di finestra.
**Stato finale (2026-03-22)**: limite architetturale di Obsidian Mobile confermato. Il `StylusWritingManager` di Android WebView (componente compositor-level di Chromium) viene disabilitato al livello del processo WebView quando rileva l'apertura di una "drawing surface" (qualsiasi tab con canvas/SVG interattivo). Non è risolvibile via JS/CSS dall'interno dell'app.
**Workaround attuale**: lo switch "Modalità handwriting Android" nelle impostazioni. Quando attivo, i riquadri mostrano solo il badge 72px (non interferisce con il proximity detection) e l'utente deve aprire l'editor consapevolmente quando vuole disegnare, sapendo che l'handwriting-to-text nel documento verrà interrotto per quella sessione.
**Conferma esterna**: il plugin **Excalidraw** e il plugin **Handwritten Notes** hanno lo stesso identico problema — appena si apre la tab di disegno, l'handwriting-to-text si disattiva nell'intero Obsidian e non si ripristina nemmeno riavviando l'app. È un limite di Android WebView, non specifico al nostro plugin. **Non esiste soluzione lato plugin**; il compromesso dello switch è la scelta definitiva.
**Ricerca approfondita sul sorgente Chromium (2026-03-22)**:
- **Nessun bug Chromium aperto trovato** per questo problema specifico (il tracker non è scrapeable)
- **Obsidian Ink Issue #156** — ancora aperta, nessuna soluzione
- **Scoperta chiave**: il `<canvas>` da solo NON disabilita l'handwriting — non esiste codice canvas-specifico in Chromium che imposti `kInternalNotWritable`. Il trigger reale è **`touch-action: none`**: qualsiasi elemento con quella proprietà CSS fa scattare il flag `kInternalNotWritable` in `touch_action_util.cc`, disabilitando la scrittura con stilo su quell'elemento
- **Commit rilevante** (~5 mesi fa): `b06690ad` — Samsung DirectWriting disabilitato su Android 14+. Se il dispositivo è Android 14+, il percorso Samsung proprietario (closed source, potenzialmente causa di session-corruption) è già escluso
- **Ipotesi residua più probabile**: quando `DrawingEditorView` si apre, il `touch-action: none` del canvas o del suo scroll container viene propagato a livello di Android View dal WebView, e alla chiusura della tab quella configurazione non viene resettata
**Possibile prossimo test**: rimuovere completamente `touch-action: none` dal canvas e dal suo scroll container in `drawing-canvas.ts` / `editor-view.ts` e verificare se il problema persiste. Se il canvas da solo non rompe nulla (come confermato dal sorgente Chromium), potrebbe bastare rimuovere quella proprietà.
**Fonti**:
- [Chromium Stylus Handwriting README](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/stylus_handwriting/README.md)
- [ProseMirror Issue #565](https://github.com/ProseMirror/prosemirror/issues/565)
- Plugin Ink ha lo stesso problema irrisolto ([Issue #156](https://github.com/daledesilva/obsidian_ink/issues/156))
**Scoperte chiave consolidate**:
- `inputmode="none"` contamina l'intero WebView — MAI usare
- Empty SVG (300px) NON rompe handwriting; SVG con disegno (altezza > 300px per auto-expand) SÌ (sessione 2026-03-19)
- data-URI come `img.src` rompe handwriting → usare sempre URL vault + cache-bust
- Il problema è persistente tra sessioni (riavvio Obsidian) — non è corruzione di sessione temporanea
- Rimuovere completamente la nostra decorazione (nessun figlio nello span) non risolve — la causa è nell'SVG stesso o nella sua altezza
- **Il trigger è l'apertura della DrawingEditorView, non il disegno** — aprire senza toccare nulla già rompe l'handwriting (scoperta sessione 2026-03-22)
- **Anche una finestra Obsidian separata non isola il problema** — il WebView process è condiviso (scoperta sessione 2026-03-22)
**Tentativi falliti (completo)**:
| # | Approccio | Risultato |
|---|-----------|-----------|
| 16 | Rimuovere `beforeinput` listener globale | Non risolve |
| 17 | `<button>``<div role="button">` in cm-content | Non risolve |
| 18 | `<img>` → CSS `background-image` | Non risolve |
| 19 | Rimuovere `contenteditable="false"` dai wrapper CM6 + `pointer-events: none` | Non risolve |
| 20-23 | DevTools: rimuovere CE=false, touch-action, background-image, canvas dal DOM | Non risolve |
| 24 | Editor in Modal invece di tab | Non praticabile (stylus non disegna nel modal) |
| 25 | Bottone portale in `document.body` | Non risolve (canvas nella tab corrompe sessione) |
| 26 | Test console: canvas fake in `document.body` toccato con stylus | Conferma: canvas + stylus = handwriting rotto |
| 27 | Passaggio a `![[svg]]` con MutationObserver | Non risolve |
| 28 | Rimozione totale decorazione dallo span (nessun figlio aggiunto) | Non risolve |
| 29 | Rimosso `touch-action: none` da `.hwm_resize-handle` | Non risolve |
| 30 | IME reset (blur/focus su `cm-content`) in `DrawingEditorView.onClose()` | Non risolve |
| 31 | Sostituzione `<canvas>` con `<svg>` nel motore di disegno (Option C) | Non risolve — reverted |
| 32 | Apertura tab senza disegnare nulla | Conferma: il trigger è l'apertura della tab, non il disegno |
| 33 | Apertura in nuova finestra Obsidian (già fatto di default su Mobile) | Non risolve — WebView process condiviso |
---
## 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 |
- **Migliorare il riconoscimento OCR da Gemini**`src/recognizer.ts` + `src/md-parser.ts`:
- Migliorare la qualità complessiva del riconoscimento, non solo i simboli markdown
- Affinare il prompt con few-shot examples per simboli (`#`, `-`, `>`, `**`, `==`, ecc.)
- Valutare se `md-parser.ts` può coprire i casi che il prompt non gestisce
- Valutare modelli Gemini alternativi o parametri diversi (temperatura, ecc.)

View file

@ -48,6 +48,8 @@ export class DrawingCanvas {
private historyIdx = -1;
// Flag per sapere se la gomma ha modificato qualcosa durante un drag
private eraserChanged = false;
// Callback invocato quando l'altezza del canvas cambia (auto-expand)
private resizeCb: (() => void) | null = null;
// Altezza di default delle settings (usata per reset su clear)
private defaultHeight: number;
@ -66,9 +68,6 @@ export class DrawingCanvas {
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;
@ -78,19 +77,7 @@ export class DrawingCanvas {
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');
@ -115,9 +102,42 @@ export class DrawingCanvas {
/* --- API pubblica --- */
onChange(cb: () => void) { this.changeCb = cb; }
// Registra callback per quando l'altezza cambia (utile per auto-scroll nell'overlay)
onResize(cb: () => void) { this.resizeCb = cb; }
// Abilita scroll manuale con il dito sul canvas.
// touch-action resta 'none' (la penna non trigga scroll del browser),
// il dito scrolla il container via JS.
allowFingerScroll(scrollContainer: HTMLElement) {
let scrolling = false;
let startY = 0;
let startScroll = 0;
this.canvas.addEventListener('pointerdown', (e: PointerEvent) => {
if ((e.pointerType || 'pen') !== 'touch') return;
scrolling = true;
startY = e.clientY;
startScroll = scrollContainer.scrollTop;
this.canvas.setPointerCapture(e.pointerId);
});
this.canvas.addEventListener('pointermove', (e: PointerEvent) => {
if (!scrolling || (e.pointerType || 'pen') !== 'touch') return;
e.preventDefault();
scrollContainer.scrollTop = startScroll + (startY - e.clientY);
});
const stop = (e: PointerEvent) => {
if ((e.pointerType || 'pen') !== 'touch') return;
scrolling = false;
};
this.canvas.addEventListener('pointerup', stop);
this.canvas.addEventListener('pointerleave', stop);
}
setMode(mode: DrawMode) { this.mode = mode; }
getMode(): DrawMode { return this.mode; }
// Restituisce true se un tratto è in corso (pointer down)
isPointerDown(): boolean { return this.isDrawing; }
setColor(color: string) { this.color = color; }
setLineWidth(w: number) { this.lineWidth = w; }
@ -187,11 +207,6 @@ export class DrawingCanvas {
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 --- */
@ -312,6 +327,8 @@ export class DrawingCanvas {
if (this.currentStroke) {
this.drawFullStroke(this.currentStroke);
}
// Notifica chi ascolta (overlay auto-scroll)
this.resizeCb?.();
if (progress < 1) {
this.animFrameId = requestAnimationFrame(step);

View file

@ -0,0 +1,765 @@
/* =============================================
DrawingEditorView Editor in tab Obsidian
Apre il canvas in una tab dedicata, fuori dal
DOM di CodeMirror nessun conflitto
handwriting Android.
============================================= */
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, Modal, App } from 'obsidian';
import type HandwritingPlugin from './main';
import { DrawingCanvas, Stroke } from './drawing-canvas';
import { strokesToSvg, parseSvgStrokes } from './svg-utils';
import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor } from './settings';
import { getRecognizer } from './recognizer';
import { parseMarkdown } from './md-parser';
export const VIEW_TYPE_HANDWRITING = 'handwriting-editor';
// Risolve se il tema è scuro tenendo conto di 'auto' (legge la classe Obsidian sul body)
function resolveIsDark(bgMode: string): boolean {
if (bgMode === 'auto') return document.body.classList.contains('theme-dark');
return bgMode === 'dark';
}
// Icone SVG inline (stile Lucide 24×24)
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>`,
'arrow-left': `<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="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>`,
};
export class DrawingEditorView extends ItemView {
plugin: HandwritingPlugin;
private canvas: DrawingCanvas | null = null;
private embedId = '';
private svgPath = '';
private sourcePath = '';
private saveTimer: ReturnType<typeof setTimeout> | null = null;
// Listener per aggiornare la classe dark al cambio bgMode
private bgModeListener: ((bgMode: string) => void) | null = null;
constructor(leaf: WorkspaceLeaf, plugin: HandwritingPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType() { return VIEW_TYPE_HANDWRITING; }
getDisplayText() { return 'Handwriting Editor'; }
getIcon() { return 'pencil'; }
getEmbedId() { return this.embedId; }
async setState(state: any, result: any) {
if (state?.id) this.embedId = state.id;
if (state?.svg) this.svgPath = state.svg;
if (state?.sourcePath) this.sourcePath = state.sourcePath;
// Costruisci la UI solo quando abbiamo i dati
if (this.embedId && this.svgPath) await this.buildEditor();
await super.setState(state, result);
}
getState() {
return { id: this.embedId, svg: this.svgPath, sourcePath: this.sourcePath };
}
async onOpen() { /* UI costruita in setState */ }
async onClose() {
if (this.canvas) {
await this.saveSvg();
this.canvas.destroy();
this.canvas = null;
}
if (this.saveTimer) clearTimeout(this.saveTimer);
// Deregistra il listener bgMode
if (this.bgModeListener) {
this.plugin.bgModeListeners.delete(this.bgModeListener);
this.bgModeListener = null;
}
}
/* ---------- Costruisce la UI dell'editor ---------- */
private async buildEditor() {
const el = this.contentEl;
el.empty();
el.classList.add('hwm_editor-view');
const isMobile = Platform.isMobile;
const isDark = resolveIsDark(this.plugin.settings.bgMode);
const bgColor = getEffectiveBgColor(this.plugin.settings);
const lineColor = getEffectiveLineColor(this.plugin.settings);
el.style.backgroundColor = bgColor;
// --- Top bar: ← a sinistra | toolbar a destra ---
const topbar = el.createDiv({ cls: 'hwm_editor-topbar' });
if (isDark) topbar.classList.add('hwm_editor-topbar--dark');
// Bottone ← (torna al documento)
const backBtn = this.mkBtn(topbar, 'arrow-left', 'Torna al documento');
backBtn.classList.add('hwm_back-btn');
// Toolbar (allineata a destra dentro il topbar)
const toolbar = topbar.createDiv({ cls: 'hwm_toolbar hwm_editor-toolbar' });
if (isDark) toolbar.classList.add('hwm_toolbar--dark');
// Toggle compatta (mobile)
if (isMobile) {
toolbar.classList.add('hwm_toolbar--compact');
const toggleBtn = this.mkBtn(toolbar, 'chevron-down', 'Mostra tutti');
toggleBtn.classList.add('hwm_toggle-btn');
toggleBtn.addEventListener('click', () => {
const compact = toolbar.classList.toggle('hwm_toolbar--compact');
updateColorSizes(compact);
toggleBtn.innerHTML = ICONS[compact ? 'chevron-down' : 'chevron-up'] ?? '';
});
}
// Penna / Gomma
const penBtn = this.mkBtn(toolbar, 'pencil', 'Penna');
penBtn.classList.add('hwm_active', 'hwm_pen-btn');
const eraserBtn = this.mkBtn(toolbar, 'eraser', 'Gomma');
eraserBtn.classList.add('hwm_eraser-btn');
toolbar.createDiv({ cls: 'hwm_separator' });
// Colori
const colors = isDark
? ['#ffffff', '#60a5fa', '#f87171', '#4ade80']
: ['#000000', '#1e40af', '#dc2626', '#16a34a'];
const colorWrap = toolbar.createDiv({ cls: 'hwm_colors' });
const colorBtns: HTMLElement[] = [];
for (const c of colors) {
const btn = colorWrap.createEl('div', {
cls: 'hwm_color-btn',
attr: { title: c, role: 'button', tabindex: '0' }
});
btn.style.backgroundColor = c;
// Dimensioni forzate (bypass stili Obsidian Mobile)
for (const [k, v] of Object.entries({
width: '22px', height: '22px', 'min-width': '22px',
'min-height': '22px', 'border-radius': '50%',
'box-sizing': 'border-box', 'flex-shrink': '0'
})) btn.style.setProperty(k, v, 'important');
if (c === colors[0]) btn.classList.add('hwm_active');
colorBtns.push(btn);
}
toolbar.createDiv({ cls: 'hwm_separator' });
// Helper per dimensioni pallini in toolbar compatta
const updateColorSizes = (compact: boolean) => {
colorBtns.forEach(b => {
const sz = (!compact || b.classList.contains('hwm_active')) ? '22px' : '0';
b.style.setProperty('min-width', sz, 'important');
b.style.setProperty('min-height', sz, 'important');
});
};
if (isMobile) updateColorSizes(true);
// Listener bgMode: aggiorna toolbar, pallini colore e sfondo canvas al cambio tema.
// Deve stare DOPO la dichiarazione di colorBtns per poterli aggiornare nel closure.
const lightColors = ['#000000', '#1e40af', '#dc2626', '#16a34a'];
const darkColors = ['#ffffff', '#60a5fa', '#f87171', '#4ade80'];
this.bgModeListener = (bgMode: string) => {
const dark = resolveIsDark(bgMode);
topbar.classList.toggle('hwm_editor-topbar--dark', dark);
toolbar.classList.toggle('hwm_toolbar--dark', dark);
handle.classList.toggle('hwm_resize-handle--dark', dark);
el.style.backgroundColor = getEffectiveBgColor(this.plugin.settings);
// Aggiorna i pallini colore palette (backgroundColor inline con !important)
const newColors = dark ? darkColors : lightColors;
colorBtns.forEach((btn, i) => {
btn.style.backgroundColor = newColors[i] ?? '';
btn.setAttribute('title', newColors[i] ?? '');
});
// Aggiorna sfondo e righe nel canvas
if (this.canvas) {
this.canvas.setBackground(
getEffectiveBgColor(this.plugin.settings),
getEffectiveLineColor(this.plugin.settings)
);
}
};
this.plugin.bgModeListeners.add(this.bgModeListener);
// Undo / Redo / Clear
const undoBtn = this.mkBtn(toolbar, 'rotate-ccw', 'Annulla');
undoBtn.classList.add('hwm_undo-btn');
const redoBtn = this.mkBtn(toolbar, 'rotate-cw', 'Ripristina');
redoBtn.classList.add('hwm_redo-btn');
const clearBtn = this.mkBtn(toolbar, 'trash', 'Cancella tutto');
clearBtn.classList.add('hwm_clear-btn');
toolbar.createDiv({ cls: 'hwm_separator' });
// Converti / Salva / Elimina
const convertBtn = this.mkBtn(toolbar, 'file-text', 'Converti in Markdown');
convertBtn.classList.add('hwm_convert-btn');
const saveBtn = this.mkBtn(toolbar, 'save', 'Salva');
saveBtn.classList.add('hwm_save-btn');
const deleteBtn = this.mkBtn(toolbar, 'x', 'Elimina riquadro');
deleteBtn.classList.add('hwm_delete-btn');
// --- Scroll container ---
const scrollWrap = el.createDiv({ cls: 'hwm_editor-scroll' });
const canvasWrap = scrollWrap.createDiv({ cls: 'hwm_canvas-wrap' });
// Carica tratti dal file SVG
const { strokes, canvasHeight: savedH } = await this.loadStrokes();
const { canvasWidth, canvasHeight } = this.plugin.settings;
const h = savedH ?? canvasHeight;
const debugFn = this.plugin.settings.debugMode
? (msg: string) => new Notice(msg, 3000) : null;
// Crea il canvas
this.canvas = new DrawingCanvas(canvasWrap, canvasWidth, h, canvasHeight, isMobile, debugFn);
this.canvas.setBackground(bgColor, lineColor);
this.canvas.setColor(colors[0]!);
// Su mobile: dito = scroll, penna = disegno
// Su mobile: dito = scroll manuale del container, penna = disegno
if (isMobile) this.canvas.allowFingerScroll(scrollWrap);
// Carica tratti con remapping colori
if (strokes.length > 0) {
const remapped = strokes.map(s => ({
...s, color: remapStrokeColor(s.color, this.plugin.settings.bgMode)
}));
this.canvas.loadStrokes(remapped);
}
// Resize handle (visibile ma non interattivo)
const handle = scrollWrap.createDiv({ cls: 'hwm_resize-handle hwm_resize-handle--disabled' });
handle.createEl('span', { text: '⋯' });
handle.classList.toggle('hwm_resize-handle--dark', isDark);
// Auto-scroll quando il canvas si espande, ma solo se non si sta disegnando.
// Durante il disegno, lo scroll sposterebbe il canvas nel viewport e le
// coordinate del tratto salterebbero (getBoundingClientRect cambia).
this.canvas.onResize(() => {
if (!this.canvas?.isPointerDown()) scrollWrap.scrollTop = scrollWrap.scrollHeight;
});
// --- Event handlers ---
const cv = this.canvas;
penBtn.addEventListener('click', () => {
cv.setMode('pen');
penBtn.classList.add('hwm_active');
eraserBtn.classList.remove('hwm_active');
});
eraserBtn.addEventListener('click', () => {
cv.setMode('eraser');
eraserBtn.classList.add('hwm_active');
penBtn.classList.remove('hwm_active');
});
for (let i = 0; i < colorBtns.length; i++) {
colorBtns[i]!.addEventListener('click', () => {
colorBtns.forEach(b => b.classList.remove('hwm_active'));
colorBtns[i]!.classList.add('hwm_active');
cv.setColor(colors[i]!);
if (isMobile) updateColorSizes(toolbar.classList.contains('hwm_toolbar--compact'));
});
}
undoBtn.addEventListener('click', () => cv.undo());
redoBtn.addEventListener('click', () => cv.redo());
clearBtn.addEventListener('click', () => cv.clear());
convertBtn.addEventListener('click', () => this.doConvert());
saveBtn.addEventListener('click', async () => { await this.saveSvg(); new Notice('Salvato'); });
deleteBtn.addEventListener('click', () => this.doDelete());
// Auto-save debounced (2s dopo ultimo cambiamento)
cv.onChange(() => {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveSvg(), 2000);
});
// Bottone ← → salva e chiudi la tab
backBtn.addEventListener('click', async () => {
await this.saveSvg();
this.leaf.detach();
});
}
/* ---------- File I/O ---------- */
private async loadStrokes(): Promise<{ strokes: Stroke[]; canvasHeight: number | null }> {
const file = this.app.vault.getAbstractFileByPath(this.svgPath);
if (file instanceof TFile) {
const content = await this.app.vault.read(file);
const strokes = parseSvgStrokes(content);
const m = content.match(/viewBox="0 0 \d+ (\d+)"/);
return { strokes, canvasHeight: m ? parseInt(m[1] ?? '0') : null };
}
return { strokes: [], canvasHeight: null };
}
private async saveSvg() {
if (!this.canvas) return;
const strokes = this.canvas.getStrokes();
const svg = strokesToSvg(strokes, this.canvas.getWidth(), this.canvas.getHeight(),
this.canvas.getBgColor(), this.canvas.getLineColor());
// Crea cartella se necessario
const folder = this.svgPath.substring(0, this.svgPath.lastIndexOf('/'));
if (folder && !this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder);
}
const existing = this.app.vault.getAbstractFileByPath(this.svgPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, svg);
} else {
await this.app.vault.create(this.svgPath, svg);
}
// Aggiorna preview inline se visibile
this.plugin.refreshPreview(this.embedId, svg);
}
/* ---------- Converti OCR ---------- */
private async doConvert() {
if (!this.canvas || this.canvas.getStrokes().length === 0) {
new Notice('Nessun tratto da convertire');
return;
}
try {
new Notice('Riconoscimento in corso…');
const svg = strokesToSvg(this.canvas.getStrokes(), this.canvas.getWidth(),
this.canvas.getHeight(), this.canvas.getBgColor(), this.canvas.getLineColor());
// SVG → PNG base64
const parser = new DOMParser();
const svgEl = parser.parseFromString(svg, 'image/svg+xml').documentElement as unknown as SVGElement;
const base64 = await this.svgToPng(svgEl);
// OCR via Gemini
const recognizer = getRecognizer(this.plugin.settings.geminiApiKey, this.plugin.settings.ocrLanguages);
const rawText = await recognizer.recognize(base64);
if (!rawText.trim()) { new Notice('Nessun testo riconosciuto'); return; }
// Markdown + archivia + sostituisci
const markdown = parseMarkdown(rawText);
await this.archiveSvg();
await this.replaceCodeBlock(markdown);
this.canvas.destroy();
this.canvas = null;
this.leaf.detach();
new Notice('Conversione completata!');
} catch (e: unknown) {
new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e)));
}
}
/* ---------- Elimina ---------- */
private async doDelete() {
if (!confirm('Eliminare questo riquadro handwriting e il file SVG associato?')) return;
if (this.canvas) { this.canvas.destroy(); this.canvas = null; }
// Rimuovi code block dal .md
await this.removeCodeBlock();
// Cancella il file SVG
const svgFile = this.app.vault.getAbstractFileByPath(this.svgPath);
if (svgFile instanceof TFile) await this.app.vault.delete(svgFile);
this.leaf.detach();
new Notice('Riquadro eliminato');
}
/* ---------- Manipolazione file .md ---------- */
private async archiveSvg() {
const svgFile = this.app.vault.getAbstractFileByPath(this.svgPath);
if (!(svgFile instanceof TFile)) return;
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const dest = `${this.plugin.settings.svgFolder}/_converted`;
if (!this.app.vault.getAbstractFileByPath(dest)) await this.app.vault.createFolder(dest);
await this.app.vault.rename(svgFile, `${dest}/${ts}.svg`);
}
// Regex per trovare ![[svgPath]] nel file .md (nuovo formato wiki)
private wikiEmbedRegex(): RegExp {
const escaped = this.svgPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\n?!\\[\\[${escaped}\\]\\]\\n?`);
}
// Regex per trovare il code block legacy con l'id specifico
private codeBlockRegex(): RegExp {
const escaped = this.embedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(
'\\n?```handwriting\\n.*?"id"\\s*:\\s*"' + escaped + '".*?\\n```\\n?', 's'
);
}
// Applica una sostituzione sul file .md, tentando prima il formato
// wiki ![[svg]] e poi il code block legacy come fallback.
// markdown === null → rimozione; stringa → sostituzione con il testo.
private async replaceInMd(markdown: string | null) {
const mdFile = this.app.vault.getAbstractFileByPath(this.sourcePath);
if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; }
const content = await this.app.vault.read(mdFile);
const wikiRegex = this.wikiEmbedRegex();
const cbRegex = this.codeBlockRegex();
const repl = markdown === null ? '\n' : '\n' + markdown + '\n';
// Prova prima il formato wiki; se non trova, prova il code block legacy
let updated = content.replace(wikiRegex, repl);
if (updated === content) updated = content.replace(cbRegex, repl);
if (updated !== content) await this.app.vault.modify(mdFile, updated);
}
private async replaceCodeBlock(markdown: string) {
await this.replaceInMd(markdown);
}
private async removeCodeBlock() {
await this.replaceInMd(null);
}
/* ---------- Helpers ---------- */
private mkBtn(parent: HTMLElement, icon: string, title: string): HTMLElement {
const btn = parent.createEl('button', { cls: 'hwm_btn', attr: { title } });
btn.innerHTML = ICONS[icon] ?? '';
return btn;
}
// Converte SVGElement → PNG base64 via canvas HTML temporaneo
private svgToPng(svgElement: SVGElement): Promise<string> {
return new Promise((resolve, reject) => {
const cvs = document.createElement('canvas');
const ctx = cvs.getContext('2d')!;
const img = new Image();
const blob = new Blob(
[new XMLSerializer().serializeToString(svgElement)],
{ type: 'image/svg+xml' }
);
const url = URL.createObjectURL(blob);
img.onload = () => {
cvs.width = img.width; cvs.height = img.height;
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(cvs.toDataURL('image/png').split(',')[1]!);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG → PNG fallito')); };
img.src = url;
});
}
}
/* =============================================
DrawingModal Editor disegno come Modal overlay.
Aperto tramite bottone portale (document.body)
per evitare tap su widget CM6.
============================================= */
export class DrawingModal extends Modal {
private plugin: HandwritingPlugin;
private embedId: string;
private svgPath: string;
private sourcePath: string;
private canvas: DrawingCanvas | null = null;
private saveTimer: ReturnType<typeof setTimeout> | null = null;
// Listener per aggiornare la classe dark al cambio bgMode
private bgModeListener: ((bgMode: string) => void) | null = null;
// Callback invocato alla chiusura del modal (usato per nascondere/mostrare il bottone matita)
onClosed?: () => void;
constructor(app: App, plugin: HandwritingPlugin, embedId: string, svgPath: string, sourcePath: string) {
super(app);
this.plugin = plugin;
this.embedId = embedId;
this.svgPath = svgPath;
this.sourcePath = sourcePath;
this.modalEl.addClass('hwm_modal');
}
async onOpen() {
this.contentEl.addClass('hwm_editor-view');
await this.buildEditor();
}
async onClose() {
if (this.canvas) {
await this.saveSvg();
this.canvas.destroy();
this.canvas = null;
}
if (this.saveTimer) clearTimeout(this.saveTimer);
// Deregistra il listener bgMode
if (this.bgModeListener) {
this.plugin.bgModeListeners.delete(this.bgModeListener);
this.bgModeListener = null;
}
// Notifica il chiamante che il modal è stato chiuso
this.onClosed?.();
}
private async buildEditor() {
const el = this.contentEl;
const isMobile = Platform.isMobile;
const isDark = resolveIsDark(this.plugin.settings.bgMode);
const bgColor = getEffectiveBgColor(this.plugin.settings);
const lineColor = getEffectiveLineColor(this.plugin.settings);
el.style.backgroundColor = bgColor;
// Top bar con toolbar (nessun backBtn: il Modal ha già la X nativa).
// La classe --modal centra la toolbar orizzontalmente (nel DrawingEditorView
// c'è il backBtn a sinistra e space-between; qui invece la toolbar è l'unico figlio).
const topbar = el.createDiv({ cls: 'hwm_editor-topbar hwm_editor-topbar--modal' });
if (isDark) topbar.classList.add('hwm_editor-topbar--dark');
const toolbar = topbar.createDiv({ cls: 'hwm_toolbar hwm_editor-toolbar' });
if (isDark) toolbar.classList.add('hwm_toolbar--dark');
if (isMobile) {
toolbar.classList.add('hwm_toolbar--compact');
const toggleBtn = this.mkBtn(toolbar, 'chevron-down', 'Mostra tutti');
toggleBtn.classList.add('hwm_toggle-btn');
toggleBtn.addEventListener('click', () => {
const compact = toolbar.classList.toggle('hwm_toolbar--compact');
updateColorSizes(compact);
toggleBtn.innerHTML = ICONS[compact ? 'chevron-down' : 'chevron-up'] ?? '';
});
}
const penBtn = this.mkBtn(toolbar, 'pencil', 'Penna');
penBtn.classList.add('hwm_active', 'hwm_pen-btn');
const eraserBtn = this.mkBtn(toolbar, 'eraser', 'Gomma');
eraserBtn.classList.add('hwm_eraser-btn');
toolbar.createDiv({ cls: 'hwm_separator' });
const colors = isDark
? ['#ffffff', '#60a5fa', '#f87171', '#4ade80']
: ['#000000', '#1e40af', '#dc2626', '#16a34a'];
const colorWrap = toolbar.createDiv({ cls: 'hwm_colors' });
const colorBtns: HTMLElement[] = [];
for (const c of colors) {
const btn = colorWrap.createEl('div', { cls: 'hwm_color-btn', attr: { title: c, role: 'button', tabindex: '0' } });
btn.style.backgroundColor = c;
for (const [k, v] of Object.entries({
width: '22px', height: '22px', 'min-width': '22px', 'min-height': '22px',
'border-radius': '50%', 'box-sizing': 'border-box', 'flex-shrink': '0'
})) btn.style.setProperty(k, v, 'important');
if (c === colors[0]) btn.classList.add('hwm_active');
colorBtns.push(btn);
}
toolbar.createDiv({ cls: 'hwm_separator' });
const updateColorSizes = (compact: boolean) => {
colorBtns.forEach(b => {
const sz = (!compact || b.classList.contains('hwm_active')) ? '22px' : '0';
b.style.setProperty('min-width', sz, 'important');
b.style.setProperty('min-height', sz, 'important');
});
};
if (isMobile) updateColorSizes(true);
// Listener bgMode: aggiorna toolbar, pallini colore e sfondo canvas al cambio tema.
const lightColors = ['#000000', '#1e40af', '#dc2626', '#16a34a'];
const darkColors = ['#ffffff', '#60a5fa', '#f87171', '#4ade80'];
this.bgModeListener = (bgMode: string) => {
const dark = resolveIsDark(bgMode);
topbar.classList.toggle('hwm_editor-topbar--dark', dark);
toolbar.classList.toggle('hwm_toolbar--dark', dark);
handle.classList.toggle('hwm_resize-handle--dark', dark);
el.style.backgroundColor = getEffectiveBgColor(this.plugin.settings);
// Aggiorna i pallini colore palette
const newColors = dark ? darkColors : lightColors;
colorBtns.forEach((btn, i) => {
btn.style.backgroundColor = newColors[i] ?? '';
btn.setAttribute('title', newColors[i] ?? '');
});
// Aggiorna sfondo e righe nel canvas
if (this.canvas) {
this.canvas.setBackground(
getEffectiveBgColor(this.plugin.settings),
getEffectiveLineColor(this.plugin.settings)
);
}
};
this.plugin.bgModeListeners.add(this.bgModeListener);
const undoBtn = this.mkBtn(toolbar, 'rotate-ccw', 'Annulla');
undoBtn.classList.add('hwm_undo-btn');
const redoBtn = this.mkBtn(toolbar, 'rotate-cw', 'Ripristina');
redoBtn.classList.add('hwm_redo-btn');
const clearBtn = this.mkBtn(toolbar, 'trash', 'Cancella tutto');
clearBtn.classList.add('hwm_clear-btn');
toolbar.createDiv({ cls: 'hwm_separator' });
const convertBtn = this.mkBtn(toolbar, 'file-text', 'Converti in Markdown');
convertBtn.classList.add('hwm_convert-btn');
const saveBtn = this.mkBtn(toolbar, 'save', 'Salva');
saveBtn.classList.add('hwm_save-btn');
const deleteBtn = this.mkBtn(toolbar, 'x', 'Elimina riquadro');
deleteBtn.classList.add('hwm_delete-btn');
const scrollWrap = el.createDiv({ cls: 'hwm_editor-scroll' });
const canvasWrap = scrollWrap.createDiv({ cls: 'hwm_canvas-wrap' });
const { strokes, canvasHeight: savedH } = await this.loadStrokes();
const { canvasWidth, canvasHeight } = this.plugin.settings;
const h = savedH ?? canvasHeight;
const debugFn = this.plugin.settings.debugMode ? (msg: string) => new Notice(msg, 3000) : null;
this.canvas = new DrawingCanvas(canvasWrap, canvasWidth, h, canvasHeight, isMobile, debugFn);
this.canvas.setBackground(bgColor, lineColor);
this.canvas.setColor(colors[0]!);
if (isMobile) this.canvas.allowFingerScroll(scrollWrap);
if (strokes.length > 0) {
const remapped = strokes.map(s => ({ ...s, color: remapStrokeColor(s.color, this.plugin.settings.bgMode) }));
this.canvas.loadStrokes(remapped);
}
const handle = scrollWrap.createDiv({ cls: 'hwm_resize-handle hwm_resize-handle--disabled' });
handle.createEl('span', { text: '⋯' });
handle.classList.toggle('hwm_resize-handle--dark', isDark);
// Auto-scroll solo se non si sta disegnando (stesso motivo del DrawingEditorView)
this.canvas.onResize(() => {
if (!this.canvas?.isPointerDown()) scrollWrap.scrollTop = scrollWrap.scrollHeight;
});
const cv = this.canvas;
penBtn.addEventListener('click', () => { cv.setMode('pen'); penBtn.classList.add('hwm_active'); eraserBtn.classList.remove('hwm_active'); });
eraserBtn.addEventListener('click', () => { cv.setMode('eraser'); eraserBtn.classList.add('hwm_active'); penBtn.classList.remove('hwm_active'); });
for (let i = 0; i < colorBtns.length; i++) {
colorBtns[i]!.addEventListener('click', () => {
colorBtns.forEach(b => b.classList.remove('hwm_active'));
colorBtns[i]!.classList.add('hwm_active');
cv.setColor(colors[i]!);
if (isMobile) updateColorSizes(toolbar.classList.contains('hwm_toolbar--compact'));
});
}
undoBtn.addEventListener('click', () => cv.undo());
redoBtn.addEventListener('click', () => cv.redo());
clearBtn.addEventListener('click', () => cv.clear());
convertBtn.addEventListener('click', () => this.doConvert());
saveBtn.addEventListener('click', async () => { await this.saveSvg(); new Notice('Salvato'); });
deleteBtn.addEventListener('click', () => this.doDelete());
cv.onChange(() => {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveSvg(), 2000);
});
}
private async loadStrokes(): Promise<{ strokes: Stroke[]; canvasHeight: number | null }> {
const file = this.app.vault.getAbstractFileByPath(this.svgPath);
if (file instanceof TFile) {
const content = await this.app.vault.read(file);
const m = content.match(/viewBox="0 0 \d+ (\d+)"/);
return { strokes: parseSvgStrokes(content), canvasHeight: m ? parseInt(m[1] ?? '0') : null };
}
return { strokes: [], canvasHeight: null };
}
private async saveSvg() {
if (!this.canvas) return;
const svg = strokesToSvg(this.canvas.getStrokes(), this.canvas.getWidth(), this.canvas.getHeight(),
this.canvas.getBgColor(), this.canvas.getLineColor());
const folder = this.svgPath.substring(0, this.svgPath.lastIndexOf('/'));
if (folder && !this.app.vault.getAbstractFileByPath(folder)) await this.app.vault.createFolder(folder);
const existing = this.app.vault.getAbstractFileByPath(this.svgPath);
if (existing instanceof TFile) { await this.app.vault.modify(existing, svg); }
else { await this.app.vault.create(this.svgPath, svg); }
this.plugin.refreshPreview(this.embedId, svg);
}
private async doConvert() {
if (!this.canvas || this.canvas.getStrokes().length === 0) { new Notice('Nessun tratto da convertire'); return; }
try {
new Notice('Riconoscimento in corso…');
const svg = strokesToSvg(this.canvas.getStrokes(), this.canvas.getWidth(), this.canvas.getHeight(),
this.canvas.getBgColor(), this.canvas.getLineColor());
const svgEl = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement as unknown as SVGElement;
const base64 = await this.svgToPng(svgEl);
const recognizer = getRecognizer(this.plugin.settings.geminiApiKey, this.plugin.settings.ocrLanguages);
const rawText = await recognizer.recognize(base64);
if (!rawText.trim()) { new Notice('Nessun testo riconosciuto'); return; }
const markdown = parseMarkdown(rawText);
await this.archiveSvg();
await this.replaceCodeBlock(markdown);
this.canvas.destroy(); this.canvas = null;
this.close();
new Notice('Conversione completata!');
} catch (e: unknown) { new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e))); }
}
private async doDelete() {
if (!confirm('Eliminare questo riquadro handwriting e il file SVG associato?')) return;
if (this.canvas) { this.canvas.destroy(); this.canvas = null; }
await this.removeCodeBlock();
const svgFile = this.app.vault.getAbstractFileByPath(this.svgPath);
if (svgFile instanceof TFile) await this.app.vault.delete(svgFile);
this.close();
new Notice('Riquadro eliminato');
}
private async archiveSvg() {
const svgFile = this.app.vault.getAbstractFileByPath(this.svgPath);
if (!(svgFile instanceof TFile)) return;
const now = new Date(); const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const dest = `${this.plugin.settings.svgFolder}/_converted`;
if (!this.app.vault.getAbstractFileByPath(dest)) await this.app.vault.createFolder(dest);
await this.app.vault.rename(svgFile, `${dest}/${ts}.svg`);
}
// Regex per trovare ![[svgPath]] nel file .md (formato wiki)
private wikiEmbedRegex(): RegExp {
const esc = this.svgPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\n?!\\[\\[${esc}\\]\\]\\n?`);
}
// Regex per il code block legacy con l'id specifico
private codeBlockRegex(): RegExp {
const esc = this.embedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp('\\n?```handwriting\\n.*?"id"\\s*:\\s*"' + esc + '".*?\\n```\\n?', 's');
}
// Applica sostituzione sul .md: prova prima formato wiki, poi legacy come fallback
private async replaceInMd(replacement: string) {
const mdFile = this.app.vault.getAbstractFileByPath(this.sourcePath);
if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; }
const content = await this.app.vault.read(mdFile);
let updated = content.replace(this.wikiEmbedRegex(), replacement);
if (updated === content) updated = content.replace(this.codeBlockRegex(), replacement);
if (updated !== content) await this.app.vault.modify(mdFile, updated);
}
private async replaceCodeBlock(markdown: string) {
await this.replaceInMd('\n' + markdown + '\n');
}
private async removeCodeBlock() {
await this.replaceInMd('\n');
}
private mkBtn(parent: HTMLElement, icon: string, title: string): HTMLElement {
const btn = parent.createEl('button', { cls: 'hwm_btn', attr: { title } });
btn.innerHTML = ICONS[icon] ?? '';
return btn;
}
private svgToPng(svgElement: SVGElement): Promise<string> {
return new Promise((resolve, reject) => {
const cvs = document.createElement('canvas');
const ctx = cvs.getContext('2d')!;
const img = new Image();
const blob = new Blob([new XMLSerializer().serializeToString(svgElement)], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => { cvs.width = img.width; cvs.height = img.height; ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); resolve(cvs.toDataURL('image/png').split(',')[1]!); };
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG → PNG fallito')); };
img.src = url;
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,57 @@
- Tab impostazioni
============================================= */
import { Plugin } from 'obsidian';
import { Plugin, TFile, Notice } from 'obsidian';
import { DEFAULT_SETTINGS, HandwritingSettings, HandwritingSettingTab } from './settings';
import { registerEmbed, insertHandwritingBlock } from './embed';
import { VIEW_TYPE_HANDWRITING, DrawingEditorView } from './editor-view';
export default class HandwritingPlugin extends Plugin {
settings: HandwritingSettings;
// Mappa di callback per aggiornare le preview inline quando l'editor tab salva
public previewCallbacks = new Map<string, (svgContent: string) => void>();
// Mappa embedId → svgPath: permette di trovare i file SVG da rimappare al cambio bgMode
public embedPaths = new Map<string, string>();
// Callback notificate quando l'utente cambia bgMode nelle impostazioni
public bgModeListeners = new Set<(bgMode: string) => void>();
// Mappa embedId → azioni (expand/collapse/convert): usata dal menu "⋮" di Obsidian
public embedActions = new Map<string, {
expand: () => void;
collapse: () => void;
convert: () => Promise<void>;
container: HTMLElement;
sourcePath: string;
}>();
// Invocato dall'editor tab dopo ogni salvataggio per aggiornare la preview inline
refreshPreview(id: string, svgContent: string) {
this.previewCallbacks.get(id)?.(svgContent);
}
// Chiamato da settings quando l'utente cambia bgMode:
// notifica pannelli (aggiornamento classe dark) e SVG attivi (remap colori)
notifyBgModeChange() {
this.bgModeListeners.forEach(cb => cb(this.settings.bgMode));
}
async onload() {
await this.loadSettings();
// Rileva cambio tema Obsidian (aggiunta/rimozione classe 'theme-dark' sul body).
// Se bgMode è 'auto', notifica tutti i listener per aggiornare colori e SVG.
const themeObserver = new MutationObserver(() => {
if (this.settings.bgMode === 'auto') this.notifyBgModeChange();
});
themeObserver.observe(document.body, { attributeFilter: ['class'] });
this.register(() => themeObserver.disconnect());
// Registra la vista editor (tab dedicata per il disegno)
this.registerView(VIEW_TYPE_HANDWRITING, (leaf) => new DrawingEditorView(leaf, this));
// Registra il code block processor per ```handwriting
registerEmbed(this);
@ -24,18 +65,79 @@ export default class HandwritingPlugin extends Plugin {
this.addCommand({
id: 'insert-handwriting',
name: 'Insert handwriting block',
editorCallback: () => {
insertHandwritingBlock(this);
editorCallback: async () => {
await insertHandwritingBlock(this);
}
});
// Icona nella ribbon (sidebar sinistra)
this.addRibbonIcon('pencil', 'Insert handwriting', () => {
insertHandwritingBlock(this);
this.addRibbonIcon('pencil', 'Insert handwriting', async () => {
await insertHandwritingBlock(this);
});
// Tab impostazioni
this.addSettingTab(new HandwritingSettingTab(this.app, this));
// Voci nel menu "⋮" (tre puntini) di Obsidian per operazioni su tutti i disegni.
// Vengono aggiunte con setSection('danger') e poi spostate prima di "Elimina file"
// tramite (menu as any).items — l'unico modo per posizionarle nell'ultima sezione
// sopra Delete senza usare API private più instabili.
this.registerEvent(
this.app.workspace.on('file-menu', (menu, file) => {
if (!(file instanceof TFile) || file.extension !== 'md') return;
menu.addItem(item => item
.setTitle('Espandi tutti i disegni')
.setIcon('chevrons-down')
.setSection('danger')
.onClick(() => {
this.getActiveEmbeds(file.path).forEach(a => a.expand());
})
);
menu.addItem(item => item
.setTitle('Collassa tutti i disegni')
.setIcon('chevrons-up')
.setSection('danger')
.onClick(() => {
this.getActiveEmbeds(file.path).forEach(a => a.collapse());
})
);
menu.addItem(item => item
.setTitle('Converti tutti i disegni in testo')
.setIcon('file-text')
.setSection('danger')
.onClick(async () => {
try {
// Sequenziale: si ferma al primo errore
for (const actions of this.getActiveEmbeds(file.path)) {
await actions.convert();
}
} catch (e: unknown) {
new Notice('Errore nella conversione: ' + (e instanceof Error ? e.message : String(e)));
}
})
);
// Sposta le 3 voci appena aggiunte prima del primo item 'danger' esistente
// (cioè prima di "Elimina file"), in modo che compaiano sopra di esso.
const items: Array<{ section: string }> = (menu as any).items;
const added = items.splice(items.length - 3, 3);
const firstDangerIdx = items.findIndex(i => i.section === 'danger');
items.splice(firstDangerIdx >= 0 ? firstDangerIdx : items.length, 0, ...added);
})
);
}
// Restituisce gli embed attivi (container nel DOM) appartenenti al file indicato.
// Rimuove dalla mappa gli embed il cui container non è più nel DOM.
private getActiveEmbeds(filePath: string) {
const result: Array<{ expand: () => void; collapse: () => void; convert: () => Promise<void> }> = [];
for (const [id, actions] of this.embedActions) {
if (!actions.container.isConnected) {
this.embedActions.delete(id);
continue;
}
if (actions.sourcePath === filePath) result.push(actions);
}
return result;
}
async loadSettings() {
@ -44,6 +146,10 @@ export default class HandwritingPlugin extends Plugin {
DEFAULT_SETTINGS,
await this.loadData() as Partial<HandwritingSettings>
);
// Migrazione: 'custom' non esiste più → 'auto'
if ((this.settings.bgMode as string) === 'custom') {
this.settings.bgMode = 'auto';
}
}
async saveSettings() {

View file

@ -5,60 +5,46 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import type HandwritingPlugin from './main';
// Modalità sfondo: chiaro, scuro o colore personalizzato
export type BgMode = 'light' | 'dark' | 'custom';
// Modalità sfondo: chiaro, scuro o automatico (segue il tema di Obsidian)
export type BgMode = 'light' | 'dark' | 'auto';
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
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
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> = {
// Colori predefiniti per le modalità light e dark
export const BG_COLORS: Record<'light' | 'dark', string> = {
light: '#ffffff',
dark: '#1e1e1e',
custom: '#ffffff',
};
// Colore righe adattato allo sfondo
export const LINE_COLORS: Record<BgMode, string> = {
export const LINE_COLORS: Record<'light' | 'dark', string> = {
light: '#e0e0e0',
dark: '#3a3a3a',
custom: '#cccccc',
};
// Risolve 'auto' al tema effettivo leggendo la classe Obsidian sul body
function resolveAutoMode(): 'light' | 'dark' {
return document.body.classList.contains('theme-dark') ? 'dark' : 'light';
}
// 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];
const mode = settings.bgMode === 'auto' ? resolveAutoMode() : settings.bgMode;
return BG_COLORS[mode];
}
// 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')}`;
const mode = settings.bgMode === 'auto' ? resolveAutoMode() : settings.bgMode;
return LINE_COLORS[mode];
}
// Mappa colori chiari ↔ scuri per adattare i tratti al cambio tema.
@ -69,17 +55,19 @@ 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 {
// 'auto' viene risolto al tema Obsidian attuale al momento della chiamata
const mode = bgMode === 'auto' ? resolveAutoMode() : bgMode;
const c = color.toLowerCase();
if (bgMode === 'dark') {
if (mode === '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') {
} else {
// 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
// Colori non in palette: lascia invariato
return color;
}
@ -87,15 +75,14 @@ export const DEFAULT_SETTINGS: HandwritingSettings = {
svgFolder: '_handwriting',
canvasWidth: 800,
canvasHeight: 300,
bgMode: 'light',
bgCustomColor: '#ffffff',
bgMode: 'auto', // default: segue automaticamente il tema di Obsidian
ocrLanguages: ['it', 'en'], // italiano e inglese di default
geminiApiKey: '',
debugMode: false,
};
// Nome del branch corrente — aggiornare manualmente ad ogni cambio di branch
const PLUGIN_BRANCH = 'master';
const PLUGIN_BRANCH = 'overlay';
export class HandwritingSettingTab extends PluginSettingTab {
plugin: HandwritingPlugin;
@ -160,32 +147,19 @@ export class HandwritingSettingTab extends PluginSettingTab {
// --- Sfondo canvas ---
new Setting(containerEl)
.setName('Sfondo canvas')
.setDesc('Colore di sfondo del riquadro di disegno')
.setDesc('Colore di sfondo del riquadro di disegno. "Automatico" segue il tema di Obsidian.')
.addDropdown(drop => drop
.addOption('auto', 'Automatico (segue tema Obsidian)')
.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();
// Notifica pannelli (dark class) e SVG attivi (remap colori)
this.plugin.notifyBgModeChange();
}));
// --- 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')

View file

@ -7,11 +7,92 @@
.hwm_container {
position: relative;
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
border-radius: var(--radius-m);
overflow: hidden;
margin: 8px 0;
/* Sfondo SEMPRE bianco (anche in dark mode) */
background: #ffffff;
/* Riabilita pointer-events (i wrapper CM6 sopra hanno pointer-events: none
per non bloccare l'hit-test dell'handwriting Android) */
pointer-events: auto;
}
/* --- Modalità handwriting Android (switch nelle impostazioni) ---
Quando attiva (classe hwm-handwriting-mode su document.body), i riquadri
handwriting mostrano un box compatto con icona invece dell'SVG a piena
altezza. Questo riduce l'area CE=false che interferisce con la proximity
detection dello stylus handwriting di Android.
L'SVG viene nascosto; il pannello portale (position:absolute) rimane visibile
sopra il box per permettere apertura editor, conversione ed eliminazione. */
.hwm-handwriting-mode .hwm-badge-mode {
/* Larghezza piena: lo span .internal-embed di default può restare stretto
quanto l'immagine forziamo 100% per contenere correttamente il pannello portale */
width: 100% !important;
box-sizing: border-box !important;
/* Altezza fissa: abbastanza alta da contenere il pannello portale (~34px)
senza occupare troppo spazio nel documento */
height: 72px !important;
max-height: 72px !important;
min-height: 72px !important;
overflow: hidden;
cursor: default;
/* Flex per centrare l'icona */
display: flex !important;
align-items: center;
justify-content: center;
background: var(--background-secondary, #f0f0f0);
border-radius: var(--radius-m);
}
/* Nasconde l'SVG: mostriamo solo il box con l'icona */
.hwm-handwriting-mode .hwm-badge-mode img {
display: none !important;
}
/* Icona matita centrata via pseudo-elemento */
.hwm-handwriting-mode .hwm-badge-mode::after {
content: "✏️";
font-size: 28px;
opacity: 0.5;
pointer-events: none;
}
/* SVG a piena larghezza in tutti i contesti (desktop).
Su mobile le regole .hwm-handwriting-mode sovrastano questo.
NO overflow:hidden sullo span: clipperebbe i tratti ai bordi dell'SVG.
Il border-radius va sull'img per arrotondarne gli angoli visivi. */
.internal-embed[src*="_handwriting/"] {
display: block;
width: 100%;
border-radius: var(--radius-m);
/* Bordo e ombra per rendere il riquadro visibile contro lo sfondo Obsidian */
border: 1.5px solid var(--background-modifier-border);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
/* Elimina whitespace implicito sotto l'img (gap tra img e bordo su Android) */
padding: 0;
line-height: 0;
}
.internal-embed[src*="_handwriting/"] img {
display: block;
width: 100%;
height: auto;
max-height: none;
border-radius: var(--radius-m);
}
/* Variante wiki ![[svg]]:
La classe hwm_container viene aggiunta DIRETTAMENTE allo span
.internal-embed di Obsidian (nessun wrapper esterno, per non
spostare i nodi CM6 e causare crash del virtual DOM).
L'immagine è già renderizzata da Obsidian; noi aggiungiamo solo
i bottoni come figli dello span. */
.hwm_container--wiki {
background: transparent; /* l'SVG ha già il suo sfondo */
width: 100%;
box-sizing: border-box;
}
.hwm_container--wiki img {
display: block;
width: 100%;
height: auto;
}
/* --- Toolbar in alto a destra --- */
@ -25,7 +106,7 @@
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;
border-radius: var(--radius-m);
background: rgba(240, 240, 240, 0.95);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
opacity: 1;
@ -41,7 +122,7 @@
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
border-radius: var(--radius-s);
background: transparent;
color: #666;
cursor: pointer;
@ -116,11 +197,17 @@
/* --- Area canvas (editing) --- */
.hwm_canvas-wrap {
width: 100%;
overflow: hidden;
/* Centra il canvas orizzontalmente quando il modal/tab è più largo
della risoluzione nativa del canvas (canvasWidth dalle settings) */
display: flex;
justify-content: center;
}
.hwm_canvas {
display: block;
width: 100%;
/* max-width invece di width:100% per evitare che il canvas venga stirato
oltre la sua risoluzione nativa (canvasWidth dai settings).
Stirarlo causerebbe tratti pixelati nel modal / tab full-screen. */
max-width: 100%;
cursor: crosshair;
/* Sfondo bianco fisso */
background: #ffffff;
@ -145,6 +232,15 @@
background: #ebebeb;
color: #666;
}
.hwm_resize-handle--dark {
background: #2a2a2a;
border-top-color: #444;
color: #888;
}
.hwm_resize-handle--dark:hover {
background: #333;
color: #aaa;
}
/* --- Placeholder per canvas vuoto --- */
.hwm_placeholder {
@ -236,7 +332,7 @@
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;
border-radius: var(--radius-m) !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18) !important;
color: #222 !important;
}
@ -257,3 +353,254 @@
border-color: var(--interactive-accent) !important;
color: #fff !important;
}
/* ========================================
INLINE PREVIEW Vista nel documento
======================================== */
/* --- Bottoni inline (X, converti, comprimi) ---
Posizionati in alto a sinistra per non coprire il bottone </> di Obsidian */
.hwm_inline-buttons {
position: absolute;
top: 6px;
left: 6px;
z-index: 5;
display: flex;
gap: 4px;
padding: 3px;
border-radius: var(--radius-m);
background: rgba(240, 240, 240, 0.9);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.hwm_inline-buttons--dark {
background: rgba(40, 40, 40, 0.95);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.hwm_inline-buttons--dark .hwm_btn {
color: #bbb;
}
.hwm_inline-buttons--dark .hwm_btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
/* --- Bottone comprimi: rotazione 180° quando compresso --- */
.hwm_collapse-btn {
transition: transform 0.3s ease, max-width 0.2s ease,
opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease;
}
.hwm_collapse-btn.hwm_rotated {
transform: rotate(180deg);
}
/* --- Preview SVG inline --- */
.hwm_inline-preview {
width: 100%;
overflow: hidden;
cursor: pointer;
/* Transizione per l'espansione/compressione */
transition: max-height 0.3s ease;
}
.hwm_inline-preview.hwm_collapsed {
/* max-height impostato via JS */
overflow: hidden;
}
/* Preview SVG via background-image (nessun <img> nel DOM per non
interferire con l'handwriting Android dentro cm-content) */
.hwm_preview-bg {
width: 100%;
height: 0; /* l'altezza è data da padding-bottom (aspect ratio) */
background-size: 100% auto;
background-repeat: no-repeat;
background-position: top left;
pointer-events: none; /* il click è gestito dal div padre */
}
/* ========================================
PANNELLO PORTALE Fuori da cm-content
Contiene tutti i bottoni del formato wiki.
Vive in document.body zero impatto su
cm-content non rompe handwriting Android
======================================== */
/* Pannello con tutti i bottoni (nuovo formato ![[svg]]).
Vive dentro lo span .internal-embed (position: relative impostato via JS).
Con position: absolute segue il DOM: scompare quando lo span non è visibile
(scroll, impostazioni aperte, cambio tab) senza nessuna logica JS aggiuntiva. */
.hwm_portal-panel {
position: absolute;
top: 6px;
right: 6px;
z-index: 10; /* sufficiente per stare sopra l'img interna */
display: flex;
align-items: center;
gap: 0; /* spacing via margin-right sui .hwm_btn */
padding: 3px 6px;
border-radius: var(--radius-m);
background: rgba(240, 240, 240, 0.95);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
/* Lo span padre ha pointer-events:none (per handwriting Android):
ripristina i click sui bottoni del pannello */
pointer-events: auto;
}
/* Tema scuro del pannello portale (classe aggiunta via JS in base a bgMode) */
.hwm_portal-panel--dark {
background: rgba(40, 40, 40, 0.97);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.hwm_portal-panel--dark .hwm_btn {
color: #bbb !important;
}
.hwm_portal-panel--dark .hwm_btn:hover {
background: rgba(255, 255, 255, 0.12) !important;
color: #fff !important;
}
/* Bottone cerchio singolo (formato legacy) */
.hwm_portal-btn {
position: fixed; /* coordinate viewport, aggiornate via JS */
z-index: 100;
/* Cerchio perfetto: dimensioni fisse con !important per sovrascrivere
gli stili globali di Obsidian sui button che aggiungono padding */
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
min-height: 36px !important;
padding: 0 !important;
border-radius: 50% !important;
border: none !important;
background: var(--interactive-accent) !important;
color: #ffffff !important;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden; /* evita che l'SVG allarghi il bottone */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.hwm_portal-btn svg {
width: 18px !important;
height: 18px !important;
flex-shrink: 0;
stroke: #ffffff !important; /* forza icona bianca indipendentemente da currentColor */
}
.hwm_portal-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* ========================================
DRAWING MODAL Editor come overlay
======================================== */
.hwm_modal {
width: 95vw !important;
max-width: 95vw !important;
height: 90vh;
display: flex;
flex-direction: column;
border-radius: var(--radius-l) !important;
overflow: hidden !important;
}
.hwm_modal .modal-content {
flex: 1;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: var(--radius-l);
}
/* ========================================
EDITOR VIEW Tab dedicata al disegno
======================================== */
/* Container principale dell'editor view */
.hwm_editor-view {
display: flex;
flex-direction: column;
height: 100%;
}
/* --- Top bar: ← a sinistra | toolbar a destra --- */
.hwm_editor-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: rgba(240, 240, 240, 0.95) !important; /* !important: impedisce a Obsidian dark mode di sovrascrivere il topbar */
border-bottom: 1px solid #ddd !important;
flex-shrink: 0;
gap: 8px;
}
.hwm_editor-topbar--dark {
background: rgba(40, 40, 40, 0.97) !important;
border-bottom-color: #444 !important;
}
/* Forza background e colore icone dentro il topbar dell'editor.
Obsidian dark theme ha regole tipo ".modal-content button { background: var(...) }"
con specificità 0,1,1 > 0,1,0 di .hwm_btn: senza !important qui sotto, i bottoni
rimangono scuri anche in light mode. Le due regole .hwm_btn e --dark .hwm_btn hanno
stessa specificità (0,2,0) ma --dark appare dopo vince in dark mode. */
.hwm_editor-topbar .hwm_btn {
background: transparent !important;
color: #333 !important;
}
.hwm_editor-topbar--dark .hwm_btn {
background: transparent !important;
color: #bbb !important;
}
/* Hover light: sfondo grigio chiaro */
.hwm_editor-topbar .hwm_btn:hover:not(.hwm_active) {
background: #e0e0e0 !important;
color: #333 !important;
}
/* Hover dark: sfondo bianco semi-trasparente */
.hwm_editor-topbar--dark .hwm_btn:hover:not(.hwm_active) {
background: rgba(255, 255, 255, 0.12) !important;
color: #fff !important;
}
/* Bottone attivo (tool selezionato): sempre sfondo accent e icona bianca */
.hwm_editor-topbar .hwm_btn.hwm_active {
background: var(--interactive-accent) !important;
color: #ffffff !important;
}
/* Bottone ← (indietro, a sinistra) */
.hwm_back-btn {
flex-shrink: 0;
}
/* Toolbar dentro l'editor view: posizione statica, allineata a destra */
.hwm_editor-toolbar {
position: static !important;
top: auto !important;
right: auto !important;
box-shadow: none !important;
background: transparent !important;
padding: 0 !important;
}
/* Topbar del DrawingModal: centra la toolbar (non c'è backBtn come nell'EditorView) */
.hwm_editor-topbar--modal {
justify-content: center;
}
/* --- Scroll container per il canvas nell'editor --- */
.hwm_editor-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* --- Resize handle disabilitato (visibile ma non interattivo) --- */
.hwm_resize-handle--disabled {
pointer-events: none;
cursor: default;
}
.hwm_resize-handle--disabled:hover {
background: #f5f5f5; /* annulla l'effetto hover */
}

293
NOTES.md Normal file
View file

@ -0,0 +1,293 @@
# Note storiche — Handwriting Plugin
Questo file contiene lo storico delle sessioni di sviluppo, bug risolti, tentativi falliti e ricerche effettuate.
Per istruzioni, architettura e task aperti vedi `CLAUDE.md`.
---
## ✅ Task completati (sessione 2026-03-23)
### Tema automatico (bgMode 'auto')
Rinominato 'custom' → 'auto' nel dropdown settings. `MutationObserver` su `document.body` in `main.ts` chiama `notifyBgModeChange()` al cambio di `theme-dark`. Aggiunta `resolveIsDark(bgMode)` in `editor-view.ts` ed `embed.ts` per risolvere 'auto' al tema Obsidian effettivo. Migrazione automatica 'custom'→'auto' in `loadSettings()`. Rimosso `bgCustomColor` e color picker dalle settings.
### Angoli arrotondati
Usato `var(--radius-m)` / `var(--radius-l)` su tutti gli elementi (toolbar, bottoni, pannello portale, modal, container). `border-radius` applicato sull'`<img>` inline invece che sullo span (no `overflow:hidden` sullo span, che clippava l'SVG). Aggiunto `border: 1.5px solid var(--background-modifier-border)` e `box-shadow` al riquadro inline per visibilità contro lo sfondo Obsidian.
### Rimosso switch "Modalità handwriting Android"
Badge mode sempre attiva su `Platform.isMobile`, preview piena sempre su desktop. Rimossi `hwmHandwritingMode` da `HandwritingSettings`, `DEFAULT_SETTINGS`, settings tab, `registerEmbed()` e `tryDecorate()` in `embed.ts`. Le classi CSS `hwm-handwriting-mode` e `hwm-badge-mode` rimangono ma vengono applicate automaticamente.
### Fix toolbar pannello portale al cambio tema
Sostituita classe generica `hwm_toolbar--dark` con `hwm_portal-panel--dark` dedicata. `resolveIsDark` inline in `createPortalPanel` gestisce 'auto', 'light' e 'dark'. `hwm_resize-handle--dark` sostituisce gli inline styles sull'handle del canvas.
---
---
## ✅ TEST EFFETTUATI (sessione 2026-03-22)
### Bug 1 — Pannello portale parzialmente visibile con Modal aperto — RISOLTO ✅
**Sintomo**: su Windows, cliccando il bottone matita, i bottoni "Converti" e "Comprimi" del pannello portale restavano visibili sopra il modal mentre il bottone matita scompariva correttamente.
**Causa**: il RAF loop nascondeva solo il bottone matita (`btn.style.display`) ma non l'intero pannello.
**Fix**: il RAF loop ora setta `panel.style.display = 'none'` quando `modalOpen || tabOpen`, nascondendo l'intero pannello portale (tutti e 4 i bottoni).
**File**: `src/embed.ts` — RAF loop in `decorateSpan()`.
---
### Bug 2 — Canvas modal troppo largo, toolbar non centrata — RISOLTO ✅
**Sintomo**: nel Modal Windows, il canvas occupava tutta la larghezza dell'overlay (troppo largo e non centrato). La toolbar era allineata a sinistra invece che al centro.
**Fix CSS** (`styles.css`):
- `.hwm_canvas-wrap { display: flex; justify-content: center; }` — centra il canvas orizzontalmente
- `.hwm_canvas { max-width: 100%; }` — rimosso `width: 100%` fisso
- `.hwm_editor-topbar--modal { justify-content: center; }` — centra la toolbar nel modal
**Fix TS** (`editor-view.ts`): `DrawingModal.buildEditor()` aggiunge classe `hwm_editor-topbar--modal` alla topbar.
---
### Bug 3 — Auto-scroll sposta i tratti durante il disegno — RISOLTO ✅
**Sintomo**: quando il canvas si espandeva automaticamente (auto-expand) mentre stavo disegnando, lo scroll automatico verso il basso spostava i punti del tratto corrente rispetto alla posizione del pennino.
**Causa**: l'evento `onResize` faceva scroll immediatamente anche con il pointer premuto, spostando il canvas mentre le coordinate del puntatore erano ancora relative alla posizione pre-scroll.
**Fix**: aggiunto metodo pubblico `isPointerDown(): boolean` in `DrawingCanvas` (`drawing-canvas.ts`). Sia `DrawingEditorView` che `DrawingModal` in `editor-view.ts` ora controllano `!canvas.isPointerDown()` prima di eseguire il `scrollTop` automatico.
---
### Bug 4 — Badge mode mostra icona in riquadro piccolissimo — RISOLTO ✅
**Sintomo**: attivando "Modalità handwriting Android" nelle impostazioni, i riquadri SVG si riducevano a un quadratino minuscolo invece di un badge orizzontale a piena larghezza.
**Causa**: lo span `.internal-embed` senza figli visibili collassava alla sua larghezza intrinseca (quasi zero).
**Fix CSS** (`.hwm-handwriting-mode .hwm-badge-mode`):
```css
width: 100% !important; box-sizing: border-box !important;
height: 72px !important; display: flex !important;
align-items: center; justify-content: center;
background: var(--background-secondary); border-radius: 6px;
```
Più: `img { display: none !important }` per nascondere l'SVG e `::after { content: "✏️"; font-size: 28px; opacity: 0.5; }` per l'icona.
**File**: `styles.css`.
---
## ✅ TEST EFFETTUATI (sessione 2026-03-23)
### Bug 5 — Pannello portale visibile nelle impostazioni e ovunque — RISOLTO ✅
**Sintomo**: il pannello portale (`position: fixed` in `document.body`) rimaneva visibile in alto a destra anche navigando nelle impostazioni, in altre schede, ecc.
**Causa**: il pannello era appeso a `document.body` e il RAF loop di posizionamento lo seguiva solo quando lo span era nel viewport.
**Fix**: pannello spostato come figlio diretto dello span contenitore con `position: absolute; top: 6px; right: 6px`. Lo span ha `position: relative`. Il pannello è ora parte del DOM del documento e scompare naturalmente quando si naviga altrove.
**File**: `src/embed.ts``createPortalPanel()` + `styles.css`.
---
### Bug 6 — Comprimi/Espandi modificava anche la larghezza — RISOLTO ✅
**Sintomo**: cliccando il bottone freccia per comprimere/espandere il riquadro, anche la larghezza cambiava (l'immagine si restringeva).
**Causa**: il codice precedente usava `max-height` sull'elemento `<img>`, che lo scalava proporzionalmente.
**Fix**: la compressione ora modifica `container.style.height` + `overflow: hidden` sullo span contenitore. L'immagine viene clippata verticalmente senza alterarne la larghezza.
**File**: `src/embed.ts` — handler del bottone collapse in `createPortalPanel()`.
---
### Bug 7 — Pannello portale rimane nel DOM dopo disabilitazione plugin — RISOLTO ✅
**Sintomo**: disabilitando il plugin, i pannelli portale (bottoni) rimanevano visibili nel documento.
**Fix**: `plugin.register(() => panel.remove())` — Obsidian chiama tutti i callback registrati con `plugin.register()` quando il plugin viene disabilitato.
**File**: `src/embed.ts``createPortalPanel()`.
---
### Bug 8 — Bottoni pannello portale non cliccabili — RISOLTO ✅
**Sintomo**: dopo lo spostamento del pannello dentro lo span, i bottoni non rispondevano al click.
**Causa**: lo span aveva `pointer-events: none` (impostato in `tryDecorate()` per handwriting Android). Il pannello figlio ereditava la proprietà.
**Fix**: `pointer-events: auto` in CSS su `.hwm_portal-panel` — ripristina i click solo sul pannello, lasciando il resto dello span non interattivo.
**File**: `styles.css`.
---
### Bug 9 — Palette colori SVG non aggiornata al cambio bgMode — RISOLTO ✅
**Sintomo**: cambiando bgMode nelle impostazioni, gli SVG nei documenti aperti mantenevano i vecchi colori dei tratti finché non si ricaricava il plugin.
**Fix**: aggiunto listener `onBgModeRemap` in `registerEmbed()` registrato in `plugin.bgModeListeners`. Quando il bgMode cambia:
1. Itera `plugin.embedPaths` (mappa `embedId → svgPath`)
2. Legge il file SVG dal vault e verifica il marker `hwm-strokes` (ignora SVG non del plugin)
3. Parsa `viewBox="0 0 W H"` per ottenere le dimensioni reali (non quelle di default, che perderebbero l'auto-expand)
4. Rimappa i colori dei tratti con `remapStrokeColor()`, rigenera l'SVG con `strokesToSvg()`, salva
5. Chiama `plugin.refreshPreview(embedId, newContent)` → aggiorna `img.src` con cache-bust
**File**: `src/embed.ts``onBgModeRemap` in `registerEmbed()`.
---
### Bug 10 — Toolbar editor non aggiornata al cambio bgMode — RISOLTO ✅
**Sintomo**: i bottoni della toolbar nel `DrawingModal` (Windows) e nel `DrawingEditorView` (Android) non cambiavano colore al cambio bgMode.
**Causa 1 (live update)**: mancava un listener. Aggiunto `bgModeListener` in entrambe le classi, registrato in `bgModeListeners` dopo la costruzione di `colorBtns` (per poterli aggiornare nel closure). Rimosso in `onClose()`.
**Causa 2 (riapertura)**: il cambio bgMode avviene sempre con editor chiuso. `buildEditor()` viene chiamato di nuovo alla riapertura e legge `plugin.settings.bgMode` → corretto per costruzione. Il problema visivo era CSS.
**Causa 3 (CSS)**: Obsidian dark theme ha regole tipo `.modal-content button { background: var(...) }` con specificità `0,1,1` > `0,1,0` di `.hwm_btn`, sovrascrivendo il nostro sfondo trasparente. Il colore del topbar senza `!important` veniva sovrascritto analogamente.
**Fix CSS** (`styles.css`):
- `.hwm_editor-topbar { background: rgba(240,240,240,0.95) !important }` e `.hwm_editor-topbar--dark { background: rgba(40,40,40,0.97) !important }` — entrambe con `!important` (la `--dark` appare dopo → vince in dark mode per cascade order)
- `.hwm_editor-topbar .hwm_btn { background: transparent !important; color: #333 !important }` — batte la specificità di Obsidian
- `.hwm_editor-topbar--dark .hwm_btn { color: #bbb !important }` — appare dopo → vince in dark mode
- Hover e active espliciti con `!important` per entrambe le modalità
**Nota importante**: il cambio bgMode viene SEMPRE effettuato con editor chiuso (il modal copre l'intera finestra). Il listener live è presente ma non è il percorso principale.
**File**: `src/editor-view.ts` + `styles.css`.
---
## Problemi risolti (storico completo)
- **Handwriting Android (disegno)** ✅ — risolto con editor in tab separata (`ItemView`), canvas fuori da `cm-content`
- **Pen scroll** ✅ — penna non scrolla più, solo dito (JS manuale via `setPointerCapture`)
- **Toolbar — tema scuro**
- **Spazio vuoto sezione colori in toolbar compatta**
- **Trashcan non cancella visualmente**
- **Bottoni inline coprivano `</>` di Obsidian** ✅ — spostati a `left: 6px`
- **Ordine bottoni inline** ✅ — invertito: X, Converti, Freccia (da sinistra)
- **Placeholder text** ✅ — aggiornato a "Usa il bottone matita in alto a destra per disegnare"
- **Bottone portale non cerchio perfetto** ✅ — risolto con `width/height/min-width/min-height: 36px !important`, `padding: 0 !important`, `overflow: hidden`
- **Icona bottone portale non visibile** ✅ — SVG con `stroke="currentColor"` non diventava bianco; risolto con `.hwm_portal-btn svg { stroke: #ffffff !important }`
- **Bottone portale non si nasconde con editor aperto** ✅ — check `workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING).some(...)` nel RAF loop (Android) + flag `modalOpen` (Windows)
- **Bottone portale `position: absolute` invece di `fixed`** ✅ — `getBoundingClientRect()` restituisce coordinate viewport, non serviva aggiungere `scrollY/scrollX`
- **Bottoni pannello portale rimangono visibili durante lo scroll** ✅ — listener `scroll` su `.cm-scroller`/`.markdown-reading-view` che setta `visibility: hidden` durante lo scroll
- **Modal Windows** ✅ — implementato `DrawingModal extends Modal`; click matita su Desktop apre modal invece di nuova tab
- **Switch handwriting** ✅ — `hwmHandwritingMode` in settings; badge mode via classe CSS su `document.body` e sullo span
- **DrawingModal non gestiva formato wiki** ✅ — aggiunto `wikiEmbedRegex()` e logica try-wiki-then-legacy in `replaceInMd()`
- **Pannello portale visibile con Modal aperto (Bug 1)** ✅ — RAF loop ora nasconde l'intero panel (`display: none`) quando `modalOpen || tabOpen`, non solo il bottone matita
- **Canvas modal non centrato, toolbar a sinistra (Bug 2)** ✅ — `display: flex; justify-content: center` su `.hwm_canvas-wrap`; `max-width: 100%` su `.hwm_canvas`; classe `hwm_editor-topbar--modal` aggiunta al topbar del modal per centrare la toolbar
- **Auto-scroll sposta tratti durante disegno (Bug 3)** ✅ — aggiunto `isPointerDown()` in `DrawingCanvas`; scroll automatico bloccato se il pointer è premuto, sia in `DrawingEditorView` che in `DrawingModal`
- **Badge mode mostra riquadro minuscolo (Bug 4)** ✅ — CSS `.hwm-handwriting-mode .hwm-badge-mode` con `width: 100% !important`, `height: 72px`, flex centrato, `img { display: none }` + `::after` con emoji matita
- **Pannello portale visibile nelle impostazioni (Bug 5)** ✅ — pannello spostato da `document.body` (position:fixed) a figlio diretto dello span (position:absolute); eliminati RAF loop e scroll listener
- **Comprimi/Espandi modificava larghezza (Bug 6)** ✅ — usa `container.style.height + overflow:hidden` invece di `max-height` sull'img
- **Pannello resta dopo disabilitazione plugin (Bug 7)** ✅ — `plugin.register(() => panel.remove())`
- **Bottoni pannello non cliccabili (Bug 8)** ✅ — `pointer-events: auto` su `.hwm_portal-panel` in CSS
- **SVG non aggiornati al cambio bgMode (Bug 9)** ✅ — listener `onBgModeRemap` in `bgModeListeners`; legge viewBox per dimensioni reali, rimappa colori, salva SVG, refresh preview
- **Toolbar editor non aggiornata al cambio bgMode (Bug 10)** ✅ — `bgModeListener` in `DrawingEditorView` e `DrawingModal`; CSS `!important` su topbar e bottoni per battere specificità Obsidian dark theme
---
## BUG APERTO — Handwriting disabilitato nel documento quando il riquadro è presente
**Sintomo**: quando nel documento è presente un riquadro handwriting con un disegno (SVG non vuoto), la stylus handwriting-to-text di Android smette di funzionare nell'intero editor. Cancellare il riquadro ripristina l'handwriting. Il problema persiste tra riavvii di Obsidian.
**Progressione delle scoperte**:
**Fase 1 — Formato code block** (tentativi 16-26):
- `contenteditable="false"` su wrapper CM6 → rimosso → non risolve
- `touch-action: none` → rimosso → non risolve
- `background-image` SVG → rimossa → non risolve
- `<canvas>` nel DOM → rimosso → non risolve
- Canvas in `document.body` (fuori da CM6) toccato con stylus → **rompe handwriting** — conclusione: è il canvas element quando toccato dalla stylus, non la sua posizione nel DOM
- **Causa root fase 1**: Android WebView tratta qualsiasi `<canvas>` toccato dalla stylus come "drawing surface" e disabilita handwriting-to-text a livello di sessione WebView
**Fase 2 — Passaggio a formato wiki `![[svg]]`** (sessione 2026-03-19):
L'obiettivo era eliminare il `<canvas>` dal documento e mostrare solo l'`<img>` nativa di Obsidian.
Problema riscontrato: l'SVG vuoto (300px di altezza) NON rompe l'handwriting. L'SVG con un disegno (altezza variabile dopo auto-expand) SÌ lo rompe — anche dopo riavvio Obsidian, anche senza mai aprire la tab editor.
Cambiamenti implementati durante la fase 2:
- Passaggio da code block a `![[svg]]` come formato principale
- `insertHandwritingBlock()` crea il file SVG PRIMA di inserire il wikilink (altrimenti Obsidian mostra "could not be found")
- MutationObserver su `document.body` per intercettare gli span (il post-processor non funziona per i widget CM6 immagine in live preview)
- Fix data-URI: `img.src = data:image/svg+xml,...` → cambiato in cache-bust URL (`?t=timestamp`) perché la data-URI veniva interpretata da Android come drawing surface
- Rimosso `addWikiOverlay` (aggiungeva `hwm_inline-buttons` come figlio dello span): la struttura dello span è ora identica a un'immagine normale
- Pannello portale (`hwm_portal-panel`) in `document.body` con tutti e 4 i bottoni
**Fase 3 — Test approfonditi (sessione 2026-03-22)**:
**Test A — Rimozione `touch-action: none` da `.hwm_resize-handle`** (tentativo 29):
- Motivazione: `touch-action: none` è uno dei segnali che Android WebView usa per identificare "drawing surfaces". Rimuovendolo dal resize handle, si riduce il numero di elementi che si qualificano come drawing surface.
- Risultato: **non risolve**. L'handwriting si rompe ugualmente dopo aver aperto la tab editor.
**Test B — IME reset alla chiusura della tab editor** (tentativo 30):
- Motivazione: ipotesi che la sessione IME (Input Method Engine) di Android venisse "bloccata" dall'apertura della DrawingEditorView. Un blur/focus sul `cm-content` dopo la chiusura avrebbe potuto resettarla.
- Implementazione: `DrawingEditorView.onClose()` fa `cm.blur(); setTimeout(() => cm.focus(), 80)` solo su mobile.
- Risultato: **non risolve**. L'handwriting rimane rotto dopo la chiusura.
**Test C — Sostituzione `<canvas>` con `<svg>` nel motore di disegno** (tentativo 31):
- Motivazione (Opzione C dalle ipotesi): il `<canvas>` è l'elemento che Android WebView riconosce come drawing surface. Sostituendolo con un `<svg>` (che disegna tramite elementi `<path>`), il motore di disegno non avrebbe più alcun canvas DOM.
- Implementazione: riscrittura completa di `drawing-canvas.ts` con `SVGElement`, `<rect>` per sfondo, `<g>` per righe e tratti, `<path>` per ogni tratto con Bézier midpoint. Rimosso `touch-action: none`.
- Risultato: **non risolve**. L'handwriting si rompe ugualmente dopo aver aperto la tab editor SVG. Implementazione reverted dall'utente.
**Test D — Apertura tab senza disegnare nulla** (tentativo 32):
- Motivazione: verificare se il trigger fosse il canvas toccato dalla stylus oppure la semplice apertura della tab.
- Test: aprire la DrawingEditorView, non toccarla, chiuderla → tentare handwriting nel documento.
- Risultato: **handwriting rotto anche senza aver toccato nulla nel canvas**. Il trigger è l'apertura della tab, non il disegno. Questo esclude che `touch-action`, `setPointerCapture` o qualsiasi evento di disegno siano la causa.
**Test E — Apertura in nuova finestra Obsidian** (tentativo 33):
- Motivazione: Obsidian Mobile già apre la tab editor in una nuova finestra separata (`workspace.getLeaf('tab')`). Ipotesi che la separazione di finestra potesse isolare lo stato IME.
- Risultato: **non risolve**. Confermato dall'utente che l'editor già apre in una finestra separata, ma il problema persiste. Tutto il runtime condivide lo stesso processo WebView → lo stato `StylusWritingManager` è condiviso a livello di processo, non di finestra.
**Stato finale (2026-03-22)**: limite architetturale di Obsidian Mobile confermato. Il `StylusWritingManager` di Android WebView (componente compositor-level di Chromium) viene disabilitato al livello del processo WebView quando rileva l'apertura di una "drawing surface" (qualsiasi tab con canvas/SVG interattivo). Non è risolvibile via JS/CSS dall'interno dell'app.
**Workaround attuale**: lo switch "Modalità handwriting Android" nelle impostazioni. Quando attivo, i riquadri mostrano solo il badge 72px (non interferisce con il proximity detection) e l'utente deve aprire l'editor consapevolmente quando vuole disegnare, sapendo che l'handwriting-to-text nel documento verrà interrotto per quella sessione.
**Conferma esterna**: il plugin **Excalidraw** e il plugin **Handwritten Notes** hanno lo stesso identico problema — appena si apre la tab di disegno, l'handwriting-to-text si disattiva nell'intero Obsidian e non si ripristina nemmeno riavviando l'app. È un limite di Android WebView, non specifico al nostro plugin. **Non esiste soluzione lato plugin**; il compromesso dello switch è la scelta definitiva.
**Ricerca approfondita sul sorgente Chromium (2026-03-22)**:
- **Nessun bug Chromium aperto trovato** per questo problema specifico (il tracker non è scrapeable)
- **Obsidian Ink Issue #156** — ancora aperta, nessuna soluzione
- **Scoperta chiave**: il `<canvas>` da solo NON disabilita l'handwriting — non esiste codice canvas-specifico in Chromium che imposti `kInternalNotWritable`. Il trigger reale è **`touch-action: none`**: qualsiasi elemento con quella proprietà CSS fa scattare il flag `kInternalNotWritable` in `touch_action_util.cc`, disabilitando la scrittura con stilo su quell'elemento
- **Commit rilevante** (~5 mesi fa): `b06690ad` — Samsung DirectWriting disabilitato su Android 14+. Se il dispositivo è Android 14+, il percorso Samsung proprietario (closed source, potenzialmente causa di session-corruption) è già escluso
- **Ipotesi residua più probabile**: quando `DrawingEditorView` si apre, il `touch-action: none` del canvas o del suo scroll container viene propagato a livello di Android View dal WebView, e alla chiusura della tab quella configurazione non viene resettata
**Possibile prossimo test**: rimuovere completamente `touch-action: none` dal canvas e dal suo scroll container in `drawing-canvas.ts` / `editor-view.ts` e verificare se il problema persiste. Se il canvas da solo non rompe nulla (come confermato dal sorgente Chromium), potrebbe bastare rimuovere quella proprietà.
**Fonti**:
- [Chromium Stylus Handwriting README](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/stylus_handwriting/README.md)
- [ProseMirror Issue #565](https://github.com/ProseMirror/prosemirror/issues/565)
- Plugin Ink ha lo stesso problema irrisolto ([Issue #156](https://github.com/daledesilva/obsidian_ink/issues/156))
**Scoperte chiave consolidate**:
- `inputmode="none"` contamina l'intero WebView — MAI usare
- Empty SVG (300px) NON rompe handwriting; SVG con disegno (altezza > 300px per auto-expand) SÌ (sessione 2026-03-19)
- data-URI come `img.src` rompe handwriting → usare sempre URL vault + cache-bust
- Il problema è persistente tra sessioni (riavvio Obsidian) — non è corruzione di sessione temporanea
- Rimuovere completamente la nostra decorazione (nessun figlio nello span) non risolve — la causa è nell'SVG stesso o nella sua altezza
- **Il trigger è l'apertura della DrawingEditorView, non il disegno** — aprire senza toccare nulla già rompe l'handwriting (scoperta sessione 2026-03-22)
- **Anche una finestra Obsidian separata non isola il problema** — il WebView process è condiviso (scoperta sessione 2026-03-22)
**Tentativi falliti (completo)**:
| # | Approccio | Risultato |
|---|-----------|-----------|
| 16 | Rimuovere `beforeinput` listener globale | Non risolve |
| 17 | `<button>``<div role="button">` in cm-content | Non risolve |
| 18 | `<img>` → CSS `background-image` | Non risolve |
| 19 | Rimuovere `contenteditable="false"` dai wrapper CM6 + `pointer-events: none` | Non risolve |
| 20-23 | DevTools: rimuovere CE=false, touch-action, background-image, canvas dal DOM | Non risolve |
| 24 | Editor in Modal invece di tab | Non praticabile (stylus non disegna nel modal) |
| 25 | Bottone portale in `document.body` | Non risolve (canvas nella tab corrompe sessione) |
| 26 | Test console: canvas fake in `document.body` toccato con stylus | Conferma: canvas + stylus = handwriting rotto |
| 27 | Passaggio a `![[svg]]` con MutationObserver | Non risolve |
| 28 | Rimozione totale decorazione dallo span (nessun figlio aggiunto) | Non risolve |
| 29 | Rimosso `touch-action: none` da `.hwm_resize-handle` | Non risolve |
| 30 | IME reset (blur/focus su `cm-content`) in `DrawingEditorView.onClose()` | Non risolve |
| 31 | Sostituzione `<canvas>` con `<svg>` nel motore di disegno (Option C) | Non risolve — reverted |
| 32 | Apertura tab senza disegnare nulla | Conferma: il trigger è l'apertura della tab, non il disegno |
| 33 | Apertura in nuova finestra Obsidian (già fatto di default su Mobile) | Non risolve — WebView process condiviso |
---
## 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 |