aggiunto layer di caricamento quando faccio partire la conversione

This commit is contained in:
gabriele-cusato 2026-03-25 20:52:33 +01:00
parent e4f6059fcc
commit e02f936401
4 changed files with 215 additions and 32 deletions

View file

@ -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

View file

@ -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 });

View file

@ -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'<img> 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. */

111
README.md
View file

@ -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.
---