diff --git a/HandTranscriptMd/src/editor-view.ts b/HandTranscriptMd/src/editor-view.ts index 80550a6..9928cf7 100644 --- a/HandTranscriptMd/src/editor-view.ts +++ b/HandTranscriptMd/src/editor-view.ts @@ -401,27 +401,35 @@ export class DrawingEditorView extends ItemView { await saveSvgToDisk(this.canvas, this.svgPath, this.embedId, this.plugin); } - private async doConvert() { + private async doConvert() { if (!this.canvas || this.canvas.getStrokes().length === 0) { new Notice(t('error_no_strokes')); return; } + // Overlay a tutto schermo sull'editor: spinner + blocco interazione + const overlay = this.contentEl.createDiv({ cls: 'hwm_confirm-overlay hwm_convert-overlay--editor' }); + overlay.createDiv({ cls: 'hwm_spinner' }); try { - new Notice(t('notice_recognizing')); 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 svgToBase64Png(svgEl); const recognizer = getRecognizer(this.plugin.settings.geminiApiKey, this.plugin.settings.ocrLanguages); const rawText = await recognizer.recognize(base64); - if (!rawText.trim()) { new Notice(t('error_no_text')); return; } + if (!rawText.trim()) throw new Error(t('error_no_text')); const markdown = parseHandwritingToMarkdown(rawText); await archiveSvgFile(this.svgPath, this.plugin); await replaceInMdFile(this.sourcePath, this.svgPath, this.embedId, '\n' + markdown + '\n', this.plugin); + overlay.remove(); this.canvas.destroy(); this.canvas = null; this.leaf.detach(); new Notice(t('notice_converted')); } catch (e: unknown) { - new Notice(t('error_ocr') + (e instanceof Error ? e.message : String(e))); + // Errore: sostituisce lo spinner con messaggio + OK + overlay.empty(); + const msg = e instanceof Error ? e.message : String(e); + overlay.createEl('p', { text: msg, cls: 'hwm_convert-error-msg' }); + const okBtn = overlay.createEl('button', { text: 'OK', cls: 'hwm_convert-ok-btn mod-warning' }); + okBtn.addEventListener('click', () => overlay.remove(), { once: true }); } } @@ -523,24 +531,34 @@ export class DrawingModal extends Modal { await saveSvgToDisk(this.canvas, this.svgPath, this.embedId, this.plugin); } - private async doConvert() { + private async doConvert() { if (!this.canvas || this.canvas.getStrokes().length === 0) { new Notice(t('error_no_strokes')); return; } + // Overlay a tutto schermo sul modal: spinner + blocco interazione + const overlay = this.contentEl.createDiv({ cls: 'hwm_confirm-overlay hwm_convert-overlay--editor' }); + overlay.createDiv({ cls: 'hwm_spinner' }); try { - new Notice(t('notice_recognizing')); 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 svgToBase64Png(svgEl); const recognizer = getRecognizer(this.plugin.settings.geminiApiKey, this.plugin.settings.ocrLanguages); const rawText = await recognizer.recognize(base64); - if (!rawText.trim()) { new Notice(t('error_no_text')); return; } + if (!rawText.trim()) throw new Error(t('error_no_text')); const markdown = parseHandwritingToMarkdown(rawText); await archiveSvgFile(this.svgPath, this.plugin); await replaceInMdFile(this.sourcePath, this.svgPath, this.embedId, '\n' + markdown + '\n', this.plugin); + overlay.remove(); this.canvas.destroy(); this.canvas = null; this.close(); new Notice(t('notice_converted')); - } catch (e: unknown) { new Notice(t('error_ocr') + (e instanceof Error ? e.message : String(e))); } + } catch (e: unknown) { + // Errore: sostituisce lo spinner con messaggio + OK + overlay.empty(); + const msg = e instanceof Error ? e.message : String(e); + overlay.createEl('p', { text: msg, cls: 'hwm_convert-error-msg' }); + const okBtn = overlay.createEl('button', { text: 'OK', cls: 'hwm_convert-ok-btn mod-warning' }); + okBtn.addEventListener('click', () => overlay.remove(), { once: true }); + } } // Overlay di conferma inline: nessun Modal annidato → nessun furto di focus diff --git a/HandTranscriptMd/src/embed.ts b/HandTranscriptMd/src/embed.ts index f09fd56..b518f3f 100644 --- a/HandTranscriptMd/src/embed.ts +++ b/HandTranscriptMd/src/embed.ts @@ -574,6 +574,7 @@ function createPortalPanel( let isExpanded = true; // Flag per nascondere il pannello quando il modal (Desktop) è aperto let modalOpen = false; + let isConverting = false; // true mentre OCR e' in corso (o errore non ancora confermato) // Lo span diventa position: relative per ancorarvi il pannello (position: absolute). container.style.position = 'relative'; @@ -699,21 +700,55 @@ function createPortalPanel( collapseBtn.title = t('btn_expand'); collapseBtn.setAttribute('data-hwm-key', 'btn_expand'); }; - // Carica SVG e chiama doConvertWiki (che lancia eccezione in caso di errore) + // Overlay di conversione: spinner mentre OCR e' in corso, + // poi errore + OK se Gemini fallisce. + // Nasconde il pannello portale (come modalOpen) per bloccare tutti i click. + const showConvertOverlay = (): HTMLElement => { + // Nasconde il pannello: stessa logica di modalOpen (evita click sui bottoni durante OCR) + panel.style.display = 'none'; + const overlay = document.createElement('div'); + overlay.className = 'hwm_convert-overlay'; + const spinner = document.createElement('div'); + spinner.className = 'hwm_spinner'; + overlay.appendChild(spinner); + container.appendChild(overlay); + return overlay; + }; + + // Rimuove overlay e ripristina il pannello portale + const removeConvertOverlay = (overlay: HTMLElement) => { + overlay.remove(); + if (container.isConnected) panel.style.display = ''; + isConverting = false; + }; + + // Avvia la conversione OCR con overlay. Non lancia eccezioni: + // gli errori vengono mostrati nell'overlay stesso con pulsante OK. const doConvertAction = async () => { - const { strokes, svgContent } = await loadSvgData(svgPath, plugin); - if (!svgContent || strokes.length === 0) throw new Error('Nessun tratto da convertire'); - await doConvertWiki(svgContent, svgPath, sourcePath, plugin); + if (isConverting) return; // skip se gia' in corso (usato anche da 'converti tutti') + isConverting = true; + const overlay = showConvertOverlay(); + try { + const { strokes, svgContent } = await loadSvgData(svgPath, plugin); + if (!svgContent || strokes.length === 0) throw new Error(t('error_no_strokes')); + await doConvertWiki(svgContent, svgPath, sourcePath, plugin); + // Successo: rimuove overlay e ripristina pannello + removeConvertOverlay(overlay); + } catch (e: unknown) { + // Errore: sostituisce lo spinner con messaggio + OK + overlay.empty(); + const msg = e instanceof Error ? e.message : String(e); + overlay.createEl('p', { text: msg, cls: 'hwm_convert-error-msg' }); + const okBtn = overlay.createEl('button', { text: 'OK', cls: 'hwm_convert-ok-btn mod-warning' }); + // L'overlay resta visibile finche' l'utente non clicca OK + okBtn.addEventListener('click', () => removeConvertOverlay(overlay), { once: true }); + } }; collapseBtn.addEventListener('click', () => { if (isExpanded) doCollapse(); else doExpand(); }); - convertBtn.addEventListener('click', async () => { - // Bottone singolo: mostra Notice in caso di errore senza propagare - try { await doConvertAction(); } - catch (e: unknown) { new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e))); } - }); + convertBtn.addEventListener('click', () => doConvertAction()); // Registra le azioni nel plugin per il menu "⋮ Espandi/Collassa/Converti tutti" plugin.embedActions.set(embedId, { expand: doExpand, collapse: doCollapse, convert: doConvertAction, container, sourcePath }); diff --git a/HandTranscriptMd/styles.css b/HandTranscriptMd/styles.css index 0fbf3cc..432d0c3 100644 --- a/HandTranscriptMd/styles.css +++ b/HandTranscriptMd/styles.css @@ -691,6 +691,55 @@ font-size: 14px; } +/* Overlay di conversione OCR: copre l'SVG mentre Gemini elabora. + Stessa struttura di hwm_confirm-overlay ma con spinner animato + e fase di errore con messaggio + pulsante OK. */ +.hwm_convert-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + z-index: 100; + border-radius: inherit; + pointer-events: auto !important; /* blocca click sull'SVG sottostante */ +} +/* Rotellina di caricamento */ +.hwm_spinner { + width: 36px; + height: 36px; + border: 3px solid rgba(255, 255, 255, 0.25); + border-top-color: #fff; + border-radius: 50%; + animation: hwm_spin 0.75s linear infinite; +} +@keyframes hwm_spin { + to { transform: rotate(360deg); } +} +/* Messaggio di errore mostrato al posto dello spinner */ +.hwm_convert-error-msg { + color: #fff; + font-size: 13px; + text-align: center; + max-width: 80%; + margin: 0; + line-height: 1.4; +} +/* Pulsante OK per chiudere l'overlay di errore */ +.hwm_convert-ok-btn { + pointer-events: auto !important; + cursor: pointer; +} +/* Variante editor: copre l'intero modal/tab con z-index alto + per stare sopra toolbar e canvas */ +.hwm_convert-overlay--editor { + z-index: 1000; + border-radius: 0; +} + /* Wrapper per il collapse: contiene l' e gestisce il clip. Animiamo solo il wrapper, non il container span, così il ResizeObserver di Obsidian Mobile non si attiva e non re-imposta le dimensioni dell'img. */ diff --git a/README.md b/README.md index 5f134da..75dadff 100644 --- a/README.md +++ b/README.md @@ -86,27 +86,108 @@ The plugin uses **Google Gemini** to recognize handwritten text and converts it #### Supported Keywords -Write these keywords in your drawing to produce structured Markdown output. All keywords start with `//` and are **case-insensitive** (`//h1` = `//H1`). +Write these keywords in your drawing to produce structured Markdown output. All keywords start with `//` and are **case-insensitive** (`//list` = `//LIST`). The colon after the keyword name is **optional** (`//H1 Title` and `//H1: Title` both work). | Keyword | Syntax | Output | |---------|--------|--------| -| `//h1` | `//h1 My Title` | `# My Title` | -| `//h2` | `//h2 Section` | `## Section` | -| `//h3` | `//h3 Sub` | `### Sub` | -| `//ul` | `//ul Item` | `- Item` | -| `//ol` | `//ol Item` | `1. Item` | -| `//todo` | `//todo Task` | `- [ ] Task` | -| `//done` | `//done Task` | `- [x] Task` | -| `//quote` | `//quote Text` | `> Text` | -| `//code` | `//code snippet` | `` `snippet` `` | -| `//hr` | `//hr` | `---` | -| `//bold` | `//bold text` | `**text**` | -| `//italic` | `//italic text` | `*text*` | -| `//highlight` | `//highlight text` | `==text==` | -| `//TABLE` ... `//TABLE` | rows between two `//TABLE` markers | Markdown table | +| `//H1` | `//H1 My Title` | `# My Title` | +| `//H2` | `//H2 Section` | `## Section` | +| `//H3` | `//H3 Sub` | `### Sub` | +| `//H4` | `//H4 Sub` | `#### Sub` | +| `//LIST` | `//LIST item1, item2, item3` | bullet list | +| `//NUMLIST` | `//NUMLIST item1, item2` | numbered list (starts at 1) | +| `//NUMLIST` (offset) | `//NUMLIST 3 item1, item2` | numbered list starting at 3 | +| `//CHECK` | `//CHECK task1, task2` | checklist (all unchecked) | +| `//CHECK` (mixed) | `//CHECK x done, pending, x also done` | checklist with checked/unchecked items | +| `//QUOTE` | `//QUOTE Text` | `> Text` | +| `//NOTE` | `//NOTE Title` | Obsidian callout `[!NOTE]` | +| `//WARN` | `//WARN Title` | Obsidian callout `[!WARNING]` | +| `//TIP` | `//TIP Title` | Obsidian callout `[!TIP]` | +| `//INFO` | `//INFO Title` | Obsidian callout `[!INFO]` | +| `//ERROR` | `//ERROR Title` | Obsidian callout `[!ERROR]` | +| `//IMPORTANT` | `//IMPORTANT Title` | Obsidian callout `[!IMPORTANT]` | +| `//CODE` | `//CODE snippet` | `` `snippet` `` (inline code) | +| `//CODEBLOCK` | `//CODEBLOCK js` + lines + blank line | fenced code block | +| `//B` / `//BOLD` | `//BOLD text` | `**text**` | +| `//I` | `//I text` | `*text*` | +| `//BI` | `//BI text` | `***text***` | +| `//S` / `//STRIKE` | `//S text` | `~~text~~` | +| `//HL` | `//HL text` | `==text==` (highlight) | +| `//LINK` | `//LINK label, url` | `[label](url)` | +| `//IMG` | `//IMG alt, url` | `![alt](url)` | +| `//TABLE` | `//TABLE Col1, Col2` + rows + `//TABLE` | Markdown table | +| `//HR` / `//SEP` | `//HR` | `---` | +| `//FN` | `//FN footnote text` | `[^1]: footnote text` (auto-numbered) | +| `//MATH` | `//MATH x^2` | `$x^2$` (inline math) | +| `//MATHBLOCK` | `//MATHBLOCK` + lines + blank line | `$$...$$` math block | +| `//TAG` | `//TAG my tag` | `#my_tag` | +| `//DATE` | `//DATE` | today's date (YYYY-MM-DD) | +| `//TIME` | `//TIME` | current time (HH:MM) | +| `//DATETIME` | `//DATETIME` | date + time | +| `//INDENT` | `//INDENT text` | text indented by 2 spaces | Plain text lines (without a `//` keyword) are inserted as-is. +--- + +#### Multi-line Continuation + +Any keyword that accepts a comma-separated list (`//LIST`, `//NUMLIST`, `//CHECK`, `//TABLE` rows) supports **wrapping across lines**: if a line ends with a comma, the next line is automatically treated as a continuation. + +``` +//LIST groceries, milk, bread, +butter, eggs +``` +Output: +```markdown +- groceries +- milk +- bread +- butter +- eggs +``` + +--- + +#### CHECK with Mixed States + +Prefix any item with `x` or `X` (with or without brackets) to mark it as already checked: + +``` +//CHECK x bought milk, prepare slides, x sent email, review PR +``` +Output: +```markdown +- [x] bought milk +- [ ] prepare slides +- [x] sent email +- [ ] review PR +``` + +--- + +#### Multi-line Callouts + +The text on the keyword line becomes the callout **title**. Any lines that follow (up to the first blank line or next `//` keyword) become the callout **body**: + +``` +//NOTE Database connection +The connection may fail on an unstable network. +Always verify the timeout in the settings. + +Normal paragraph — outside the callout. +``` +Output: +```markdown +> [!NOTE] Database connection +> The connection may fail on an unstable network. +> Always verify the timeout in the settings. + +Normal paragraph — outside the callout. +``` + +--- + After conversion, the SVG is archived to `_handwriting/_converted/YYYY-MM-DD_HH-MM-SS.svg` and the drawing block is replaced with the generated Markdown. ---