diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a4c6011..36863e8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 7562f28..cdec43a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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 -- `` 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 `` 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 `` dal documento e mostrare solo l'`` 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 `` con `` nel motore di disegno** (tentativo 31): -- Motivazione (Opzione C dalle ipotesi): il `` è l'elemento che Android WebView riconosce come drawing surface. Sostituendolo con un `` (che disegna tramite elementi ``), il motore di disegno non avrebbe più alcun canvas DOM. -- Implementazione: riscrittura completa di `drawing-canvas.ts` con `SVGElement`, `` per sfondo, `` per righe e tratti, `` 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 `` 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 | `
` in cm-content | Non risolve | -| 18 | `` → 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 `` con `` 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.) \ No newline at end of file diff --git a/HandTranscriptMd/src/drawing-canvas.ts b/HandTranscriptMd/src/drawing-canvas.ts index f7ccbe0..e8b7ad7 100644 --- a/HandTranscriptMd/src/drawing-canvas.ts +++ b/HandTranscriptMd/src/drawing-canvas.ts @@ -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); diff --git a/HandTranscriptMd/src/editor-view.ts b/HandTranscriptMd/src/editor-view.ts new file mode 100644 index 0000000..6ea6314 --- /dev/null +++ b/HandTranscriptMd/src/editor-view.ts @@ -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 = { + 'pencil': ``, + 'eraser': ``, + 'rotate-ccw': ``, + 'rotate-cw': ``, + 'trash': ``, + 'file-text': ``, + 'save': ``, + 'x': ``, + 'chevron-down':``, + 'chevron-up': ``, + 'arrow-left': ``, +}; + +export class DrawingEditorView extends ItemView { + plugin: HandwritingPlugin; + private canvas: DrawingCanvas | null = null; + private embedId = ''; + private svgPath = ''; + private sourcePath = ''; + private saveTimer: ReturnType | 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 { + 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 | 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 { + 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; + }); + } +} diff --git a/HandTranscriptMd/src/embed.ts b/HandTranscriptMd/src/embed.ts index 3ec70b0..ade9374 100644 --- a/HandTranscriptMd/src/embed.ts +++ b/HandTranscriptMd/src/embed.ts @@ -1,8 +1,13 @@ /* ============================================= - Embed — Code block processor + Toolbar - Gestisce il rendering dell'embed nel markdown. - Il blocco si apre direttamente in modalità edit - (disegno immediato, senza click extra). + Embed — Processor per blocchi handwriting + + Supporta due formati: + 1. NUOVO: ![[_handwriting/hw_xxx.svg]] + - Visibile anche senza plugin (immagine SVG nativa) + - Gestito da registerMarkdownPostProcessor + 2. LEGACY: ```handwriting { "id": ..., "svg": ... } ``` + - Formato originale, mantenuto per compatibilità + - Gestito da registerMarkdownCodeBlockProcessor ============================================= */ import { @@ -10,50 +15,189 @@ import { MarkdownView, TFile, Notice, - Platform + Platform, } from 'obsidian'; -// Icone SVG inline (stile Lucide 24×24) — funzionano identicamente su Windows e Android, -// senza dipendere dalla versione di Lucide bundled in Obsidian. +// Icone SVG inline (stile Lucide 24×24) const ICONS: Record = { - 'pencil': ``, - 'eraser': ``, - 'rotate-ccw': ``, - 'rotate-cw': ``, - 'trash': ``, 'file-text': ``, - 'save': ``, 'x': ``, - 'chevron-down':``, 'chevron-up': ``, + 'pencil': ``, }; import type HandwritingPlugin from './main'; -import { DrawingCanvas, Stroke } from './drawing-canvas'; +import { Stroke } from './drawing-canvas'; import { strokesToSvg, parseSvgStrokes, generateId } from './svg-utils'; -import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor } from './settings'; +import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor, BgMode } from './settings'; import { getRecognizer } from './recognizer'; import { parseMarkdown } from './md-parser'; +import { VIEW_TYPE_HANDWRITING, DrawingEditorView, DrawingModal } from './editor-view'; -// Dati JSON salvati dentro il code block ```handwriting +// Dati JSON salvati dentro il code block ```handwriting (formato legacy) interface EmbedData { id: string; svg: string; // percorso relativo al file SVG nel vault } -/* ---------- Registrazione ---------- */ +/* ============================================= + Registrazione di entrambi i processor + ============================================= */ export function registerEmbed(plugin: HandwritingPlugin) { + // --- Listener globale: remap automatico colori SVG al cambio bgMode --- + // Quando l'utente cambia sfondo canvas nelle impostazioni, tutti gli SVG del plugin + // attualmente tracciati in embedPaths vengono letti, rimappati e risalvati nel vault. + // Identifica gli SVG del plugin tramite — gli SVG + // dell'utente non hanno questo tag e vengono ignorati. + const onBgModeRemap = async (bgMode: string) => { + for (const [embedId, svgPath] of plugin.embedPaths) { + const file = plugin.app.vault.getAbstractFileByPath(svgPath); + if (!(file instanceof TFile)) continue; + const content = await plugin.app.vault.read(file); + // Salta SVG non creati dal plugin (assenza del tag hwm-strokes) + if (!content.includes('hwm-strokes')) continue; + const strokes = parseSvgStrokes(content); + // Rimappa i colori dei tratti al nuovo tema + const remapped = strokes.map(s => ({ + ...s, color: remapStrokeColor(s.color, bgMode as BgMode) + })); + // Legge dimensioni reali dal viewBox per preservare l'altezza raggiunta con auto-expand. + // Usare plugin.settings.canvasHeight causerebbe il "collasso" degli SVG cresciuti. + const dimMatch = content.match(/viewBox="0 0 (\d+) (\d+)"/); + const svgWidth = dimMatch ? parseInt(dimMatch[1]!) : plugin.settings.canvasWidth; + const svgHeight = dimMatch ? parseInt(dimMatch[2]!) : plugin.settings.canvasHeight; + const newSvg = strokesToSvg( + remapped, + svgWidth, + svgHeight, + getEffectiveBgColor(plugin.settings), + getEffectiveLineColor(plugin.settings) + ); + await plugin.app.vault.modify(file, newSvg); + // Aggiorna l' nella preview inline con cache-bust + plugin.refreshPreview(embedId, newSvg); + } + }; + plugin.bgModeListeners.add(onBgModeRemap); + plugin.register(() => plugin.bgModeListeners.delete(onBgModeRemap)); + + // --- NUOVO: MutationObserver su document.body --- + // Intercetta gli span .internal-embed con src _handwriting/ non appena + // appaiono nel DOM — funziona sia in reading view che in live preview + // (dove il post-processor non viene chiamato sui widget CM6 delle immagini). + setupMutationObserver(plugin); + + // --- LEGACY: code block processor per ```handwriting {...} ``` --- + // Mantenuto per compatibilità con i blocchi esistenti nel vault. plugin.registerMarkdownCodeBlockProcessor( 'handwriting', async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - await renderEmbed(source, el, ctx, plugin); + await renderLegacyEmbed(source, el, ctx, plugin); } ); } -/* ---------- Rendering dell'embed ---------- */ +/* ============================================= + NUOVO FORMATO — MutationObserver per ![[svg]] + ============================================= */ -async function renderEmbed( +// Registra un MutationObserver su document.body che intercetta +// gli span .internal-embed con src _handwriting/ nel momento in cui +// appaiono nel DOM. Funziona sia in reading view che in live preview +// (in live preview il post-processor non viene chiamato sui widget CM6). +function setupMutationObserver(plugin: HandwritingPlugin) { + const tryDecorate = (span: HTMLElement) => { + // Salta se già decorato (flag data attribute — più affidabile del parent check) + if (span.dataset.hwmDecorated === '1') return; + + const svgPath = span.getAttribute('src') ?? ''; + if (!svgPath.includes('_handwriting/') || !svgPath.endsWith('.svg')) return; + + const filename = svgPath.split('/').pop() ?? ''; + const embedId = filename.replace('.svg', ''); + if (!embedId.startsWith('hw_')) return; + + // Se Obsidian non ha ancora caricato l'immagine (classe image-embed + // assente), riprova tra 150 ms — il caricamento è asincrono. + if (!span.classList.contains('image-embed')) { + setTimeout(() => tryDecorate(span), 150); + return; + } + + // Marca subito come decorato per evitare doppia elaborazione + span.dataset.hwmDecorated = '1'; + + // TEST A: pointer-events: none sullo span. + // Ipotesi: Chrome usa lo stesso hit-test dei pointer events per la + // proximity detection dell'handwriting → con none, ignora lo span e + // trova il cm-content[ce=true] sottostante → handwriting torna attivo. + span.style.pointerEvents = 'none'; + + // NESSUNA modifica allo span dentro cm-content. + // Lo lasciamo identico a un'immagine normale: nessuna classe extra, + // nessun figlio aggiunto. Questo evita di rompere l'handwriting Android + // (il contenteditable="false" + figli extra confondono il hit-test di Chrome). + // Tutti i bottoni vivono in document.body via pannello portale. + + const sourcePath = resolveSourcePath(span, plugin); + + // Callback refresh: aggiorna l' dopo il salvataggio dalla tab editor. + // Usa cache-bust URL (non data-URI) per non rompere l'handwriting Android. + plugin.previewCallbacks.set(embedId, () => { + if (!span.isConnected) return; + const img = span.querySelector('img'); + if (!img) return; + const base = img.src.split('?')[0]!; + img.src = base + '?t=' + Date.now(); + }); + + // Pannello portale con tutti i bottoni, in document.body (fuori da cm-content) + createPortalPanel(span, embedId, svgPath, sourcePath, plugin); + }; + + const observer = new MutationObserver((mutations) => { + for (const mut of mutations) { + for (const node of mut.addedNodes) { + if (!(node instanceof HTMLElement)) continue; + // Il nodo stesso potrebbe essere l'embed, oppure contenerlo + if (node.classList.contains('internal-embed') && + node.getAttribute('src')?.includes('_handwriting/')) { + tryDecorate(node); + } else { + node.querySelectorAll('.internal-embed[src*="_handwriting/"]') + .forEach(tryDecorate); + } + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + // Disconnette l'observer quando il plugin viene disabilitato + plugin.register(() => observer.disconnect()); +} + +// Risale il DOM per trovare il leaf markdown che contiene `el`, +// e restituisce il path del file aperto in quel leaf. +function resolveSourcePath(el: HTMLElement, plugin: HandwritingPlugin): string { + const leaves = plugin.app.workspace.getLeavesOfType('markdown'); + for (const leaf of leaves) { + const contentEl = (leaf.view as unknown as { contentEl: HTMLElement }).contentEl; + if (contentEl?.contains(el)) { + return (leaf.view as unknown as { file?: { path: string } }).file?.path ?? ''; + } + } + // Fallback: file attualmente attivo + return plugin.app.workspace.getActiveFile()?.path ?? ''; +} + + +/* ============================================= + LEGACY — Code block processor + ============================================= */ + +// Gestisce il vecchio formato ```handwriting {...}``` +// Parsa il JSON, mostra la preview SVG con bottoni. +async function renderLegacyEmbed( source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext, @@ -68,446 +212,308 @@ async function renderEmbed( return; } + // Rimuove contenteditable="false" dai wrapper CM6 per ridurre + // l'impatto sull'handwriting Android (non risolve il problema ma + // è la mitigazione che avevamo già in precedenza). + stripContentEditableFalse(el); + // Container principale const container = el.createDiv({ cls: 'hwm_container' }); - // Carica tratti esistenti dal file SVG - const { strokes, canvasHeight } = await loadSvgData(data.svg, plugin); + // Carica SVG esistente + const { strokes, svgContent } = await loadSvgData(data.svg, plugin); - // Apre DIRETTAMENTE in modalità edit (disegno immediato) - showEditor(container, strokes, canvasHeight, data, ctx, plugin); + // Mostra la preview con i bottoni + showLegacyPreview(container, strokes, svgContent, data, ctx, plugin); } -/* ---------- Editor mode (default) ---------- */ - -function showEditor( +// Renderizza la preview SVG (formato legacy) con i 3 bottoni inline. +function showLegacyPreview( container: HTMLElement, strokes: Stroke[], - savedHeight: number | null, + svgContent: string | null, data: EmbedData, ctx: MarkdownPostProcessorContext, plugin: HandwritingPlugin ) { container.empty(); - container.classList.add('hwm_editing'); - const isMobile = Platform.isMobile; - - // Su mobile aggiunge classe per CSS dedicato (stile bottoni, icone) - if (isMobile) container.classList.add('hwm_mobile'); - - // Determina tema subito (usato sia per toolbar che per palette colori) const isDark = plugin.settings.bgMode === 'dark'; - - // Toolbar (sempre visibile, in alto a destra) - const toolbar = container.createDiv({ cls: 'hwm_toolbar' }); - // Tema scuro: sfondo e icone invertiti - if (isDark) toolbar.classList.add('hwm_toolbar--dark'); - - // Su mobile: parte compatta, toggle per espandere. - // Il handler del toggle chiama updateColorBtnSizes, definita più in basso - // ma già disponibile a runtime grazie alla chiusura (closure). - let toggleBtn: HTMLElement | null = null; - if (isMobile) { - toolbar.classList.add('hwm_toolbar--compact'); - toggleBtn = createBtn(toolbar, 'chevron-down', 'Mostra tutti i controlli'); - toggleBtn.classList.add('hwm_toggle-btn'); - toggleBtn.addEventListener('click', () => { - const isCompact = toolbar.classList.contains('hwm_toolbar--compact'); - if (isCompact) { - toolbar.classList.remove('hwm_toolbar--compact'); - // Ripristina dimensioni di tutti i pallini colore - updateColorBtnSizes(false); - // Cambia icona a chevron-up (comprimi) - toggleBtn!.innerHTML = ICONS['chevron-up'] ?? ''; - toggleBtn!.title = 'Comprimi toolbar'; - } else { - toolbar.classList.add('hwm_toolbar--compact'); - // Collassa i pallini non attivi - updateColorBtnSizes(true); - // Ripristina icona chevron-down (espandi) - toggleBtn!.innerHTML = ICONS['chevron-down'] ?? ''; - toggleBtn!.title = 'Mostra tutti i controlli'; - } - }); - } - - // --- Tool: Penna --- - const penBtn = createBtn(toolbar, 'pencil', 'Penna'); - penBtn.classList.add('hwm_active', 'hwm_pen-btn'); - - // --- Tool: Gomma --- - const eraserBtn = createBtn(toolbar, 'eraser', 'Gomma'); - eraserBtn.classList.add('hwm_eraser-btn'); - - toolbar.createDiv({ cls: 'hwm_separator' }); - - // --- Colori (palette adattata al tema: scuro=colori chiari, chiaro=colori scuri) --- - const colors = isDark - ? ['#ffffff', '#60a5fa', '#f87171', '#4ade80'] // bianco, azzurro, rosso chiaro, verde chiaro - : ['#000000', '#1e40af', '#dc2626', '#16a34a']; // nero, blu, rosso, verde - const colorWrap = toolbar.createDiv({ cls: 'hwm_colors' }); - const colorBtns: HTMLElement[] = []; - for (const color of colors) { - // Usiamo
invece di