From 47efae3c046cab503676acb2d5cbd4cfc2ff3c13 Mon Sep 17 00:00:00 2001 From: gabriele-cusato Date: Wed, 25 Mar 2026 12:56:22 +0100 Subject: [PATCH] modifiche qos al progetto --- .claude/settings.local.json | 15 + .gitignore | 19 + HandTranscriptMd/manifest.json | 3 +- HandTranscriptMd/package.json | 2 +- HandTranscriptMd/src/drawing-canvas.ts | 39 +- HandTranscriptMd/src/editor-view.ts | 923 +++++++++--------------- HandTranscriptMd/src/embed.ts | 139 +--- HandTranscriptMd/src/locales/de.json | 12 +- HandTranscriptMd/src/locales/en.json | 12 +- HandTranscriptMd/src/locales/es.json | 12 +- HandTranscriptMd/src/locales/fr.json | 12 +- HandTranscriptMd/src/locales/it.json | 12 +- HandTranscriptMd/src/locales/ja.json | 12 +- HandTranscriptMd/src/locales/pl.json | 12 +- HandTranscriptMd/src/locales/pt-br.json | 12 +- HandTranscriptMd/src/locales/ru.json | 12 +- HandTranscriptMd/src/locales/zh-cn.json | 12 +- HandTranscriptMd/src/md-parser.ts | 6 - HandTranscriptMd/src/settings.ts | 16 +- HandTranscriptMd/src/svg-utils.ts | 45 +- HandTranscriptMd/styles.css | 42 ++ README.md | 597 +++++++++++++-- 22 files changed, 1197 insertions(+), 769 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..fbed6ee --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Bash(cd \"C:/Projects/pluginObsidian/handWrittenMarkdownConverter/obsidian-sample-plugin\" && node esbuild.config.mjs production && bash cloudDeploy.sh)", + "WebFetch(domain:groups.google.com)", + "WebFetch(domain:css-tricks.com)", + "WebFetch(domain:chromium.googlesource.com)", + "WebFetch(domain:github.com)", + "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/.gitignore b/.gitignore index 190760d..09ff9ae 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,22 @@ obsidian_ink/ # Cartella C creata per errore dai vecchi script deploy (bug MSYS path conversion) HandTranscriptMd/C*/ + +# Build output — included only in GitHub Releases, not in source +main.js +main.js.map + +# Dependencies +node_modules/ + +# OS artifacts +.DS_Store +Thumbs.db + +# Development-only / internal files — kept locally, not published +AGENTS.md +CLAUDE.md +NOTES.md +obsidianGuide.md +deploy.sh +cloudDeploy.sh diff --git a/HandTranscriptMd/manifest.json b/HandTranscriptMd/manifest.json index 223ff02..c13d5bb 100644 --- a/HandTranscriptMd/manifest.json +++ b/HandTranscriptMd/manifest.json @@ -3,7 +3,8 @@ "name": "HandTranscriptMd", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Inline handwriting canvas with conversion to structured markdown", + "description": "Inline handwriting canvas with OCR conversion to structured markdown. Requires a free Gemini API key.", "author": "GabrieleC", + "authorUrl": "https://github.com/gabriele-cusato", "isDesktopOnly": false } diff --git a/HandTranscriptMd/package.json b/HandTranscriptMd/package.json index d7c0291..0e83152 100644 --- a/HandTranscriptMd/package.json +++ b/HandTranscriptMd/package.json @@ -1,6 +1,6 @@ { "name": "handwriting-to-markdown", - "version": "0.1.0", + "version": "1.0.0", "description": "Inline handwriting canvas with conversion to structured markdown", "main": "main.js", "type": "module", diff --git a/HandTranscriptMd/src/drawing-canvas.ts b/HandTranscriptMd/src/drawing-canvas.ts index ceb96eb..e7022aa 100644 --- a/HandTranscriptMd/src/drawing-canvas.ts +++ b/HandTranscriptMd/src/drawing-canvas.ts @@ -20,6 +20,9 @@ export interface Stroke { export type DrawMode = 'pen' | 'eraser'; +// Spaziatura righe orizzontali — costante condivisa con svg-utils.ts +export const LINE_SPACING = 32; + // Deep copy di un array di Stroke function cloneStrokes(strokes: Stroke[]): Stroke[] { return strokes.map(s => ({ @@ -54,8 +57,8 @@ export class DrawingCanvas { // Altezza di default delle settings (usata per reset su clear) private defaultHeight: number; - // Righe e sfondo - readonly LINE_SPACING = 32; + // Righe e sfondo — usa la costante esportata del modulo + readonly LINE_SPACING = LINE_SPACING; private bgColor = '#ffffff'; private lineColor = '#e0e0e0'; @@ -68,6 +71,8 @@ export class DrawingCanvas { private boundDown: (e: PointerEvent) => void; private boundMove: (e: PointerEvent) => void; private boundUp: (e: PointerEvent) => void; + // Cleanup per i listener aggiuntivi di allowFingerScroll() + private fingerScrollCleanup: (() => void) | null = null; // Callback debug: se impostato, mostra Notice all'utente per ogni evento IME/touch private debugFn: ((msg: string) => void) | null = null; @@ -158,26 +163,36 @@ export class DrawingCanvas { let startY = 0; let startScroll = 0; - this.canvas.addEventListener('pointerdown', (e: PointerEvent) => { + // Listener con riferimento nominale → possono essere rimossi in destroy() + const onDown = (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) => { + }; + const onMove = (e: PointerEvent) => { if (!scrolling || (e.pointerType || 'pen') !== 'touch') return; e.preventDefault(); scrollContainer.scrollTop = startScroll + (startY - e.clientY); - }); - - const stop = (e: PointerEvent) => { + }; + const onStop = (e: PointerEvent) => { if ((e.pointerType || 'pen') !== 'touch') return; scrolling = false; }; - this.canvas.addEventListener('pointerup', stop); - this.canvas.addEventListener('pointerleave', stop); + + this.canvas.addEventListener('pointerdown', onDown); + this.canvas.addEventListener('pointermove', onMove); + this.canvas.addEventListener('pointerup', onStop); + this.canvas.addEventListener('pointerleave', onStop); + + // Registra la funzione di cleanup per destroy() + this.fingerScrollCleanup = () => { + this.canvas.removeEventListener('pointerdown', onDown); + this.canvas.removeEventListener('pointermove', onMove); + this.canvas.removeEventListener('pointerup', onStop); + this.canvas.removeEventListener('pointerleave', onStop); + }; } setMode(mode: DrawMode) { this.mode = mode; } @@ -258,6 +273,8 @@ export class DrawingCanvas { this.canvas.removeEventListener('pointermove', this.boundMove); this.canvas.removeEventListener('pointerup', this.boundUp); this.canvas.removeEventListener('pointerleave', this.boundUp); + // Rimuove i listener aggiuntivi per lo scroll con il dito (se impostati) + this.fingerScrollCleanup?.(); } /* --- History --- */ diff --git a/HandTranscriptMd/src/editor-view.ts b/HandTranscriptMd/src/editor-view.ts index 2b6e6e6..80550a6 100644 --- a/HandTranscriptMd/src/editor-view.ts +++ b/HandTranscriptMd/src/editor-view.ts @@ -8,20 +8,14 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, Modal, App, MarkdownView } 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 { strokesToSvg, parseSvgStrokes, svgToBase64Png, archiveSvgFile } from './svg-utils'; +import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor, LIGHT_COLORS, DARK_COLORS, resolveIsDark } from './settings'; import { getRecognizer } from './recognizer'; -import { parseMarkdown } from './md-parser'; +import { parseHandwritingToMarkdown } from './md-parser'; import { t } from './i18n'; 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': ``, @@ -38,6 +32,277 @@ const ICONS: Record = { 'arrow-left': ``, }; +/* ============================================= + Utilità condivise tra DrawingEditorView e DrawingModal + ============================================= */ + +// Regex per trovare ![[svgPath]] nel file .md (nuovo formato wiki) +function wikiEmbedRegex(svgPath: string): RegExp { + const esc = svgPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`\\n?!\\[\\[${esc}\\]\\]\\n?`); +} + +// Regex per trovare il code block legacy con l'id specifico +function codeBlockRegex(embedId: string): RegExp { + const esc = embedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp('\\n?```handwriting\\n.*?"id"\\s*:\\s*"' + esc + '".*?\\n```\\n?', 's'); +} + +// Applica una sostituzione sul file .md. +// Prova prima il formato wiki ![[svg]], poi il code block legacy come fallback. +async function replaceInMdFile( + mdPath: string, + svgPath: string, + embedId: string, + replacement: string, + plugin: HandwritingPlugin +): Promise { + const mdFile = plugin.app.vault.getAbstractFileByPath(mdPath); + if (!(mdFile instanceof TFile)) { new Notice(t('error_file_not_found')); return; } + const content = await plugin.app.vault.read(mdFile); + let updated = content.replace(wikiEmbedRegex(svgPath), replacement); + if (updated === content) updated = content.replace(codeBlockRegex(embedId), replacement); + if (updated !== content) await plugin.app.vault.modify(mdFile, updated); +} + +// Carica i tratti da un file SVG nel vault. Restituisce anche le dimensioni del viewBox. +async function loadStrokesFromSvg( + svgPath: string, + plugin: HandwritingPlugin +): Promise<{ strokes: Stroke[]; canvasWidth: number | null; canvasHeight: number | null }> { + const file = plugin.app.vault.getAbstractFileByPath(svgPath); + if (file instanceof TFile) { + const content = await plugin.app.vault.read(file); + const m = content.match(/viewBox="0 0 (\d+) (\d+)"/); + return { + strokes: parseSvgStrokes(content), + canvasWidth: m ? parseInt(m[1] ?? '0') : null, + canvasHeight: m ? parseInt(m[2] ?? '0') : null, + }; + } + return { strokes: [], canvasWidth: null, canvasHeight: null }; +} + +// Salva il contenuto SVG del canvas su disco e aggiorna la preview inline. +async function saveSvgToDisk( + canvas: DrawingCanvas, + svgPath: string, + embedId: string, + plugin: HandwritingPlugin +): Promise { + const svg = strokesToSvg( + canvas.getStrokes(), canvas.getWidth(), canvas.getHeight(), + canvas.getBgColor(), canvas.getLineColor() + ); + const folder = svgPath.substring(0, svgPath.lastIndexOf('/')); + if (folder && !plugin.app.vault.getAbstractFileByPath(folder)) { + await plugin.app.vault.createFolder(folder); + } + const existing = plugin.app.vault.getAbstractFileByPath(svgPath); + if (existing instanceof TFile) { + await plugin.app.vault.modify(existing, svg); + } else { + await plugin.app.vault.create(svgPath, svg); + } + plugin.refreshPreview(embedId, svg); +} + +// Crea un bottone con icona SVG inline. +// Funzione standalone (non metodo) — usata da entrambe le classi editor. +function mkBtn(parent: HTMLElement, icon: string, key: string): HTMLElement { + const label = t(key as any); + const btn = parent.createEl('button', { cls: 'hwm_btn', attr: { title: label } }); + btn.setAttribute('data-hwm-key', key); + btn.innerHTML = ICONS[icon] ?? ''; + return btn; +} + +/* ============================================= + buildEditorUI — Costruisce la toolbar e il canvas + condivisi tra DrawingEditorView e DrawingModal. + + Accetta callback per i comportamenti specifici: + - onClose: cosa fare quando si clicca X + - afterCanvas: setup post-canvas (ResizeObserver su Android, + requestAnimationFrame su Desktop) + Restituisce { canvas, bgModeListener } per consentire + alla classe chiamante di fare cleanup in onClose(). + ============================================= */ +async function buildEditorUI(opts: { + el: HTMLElement; + plugin: HandwritingPlugin; + svgPath: string; + embedId: string; + sourcePath: string; + onClose: () => void | Promise; + afterCanvas: (canvas: DrawingCanvas, scrollWrap: HTMLElement, canvasWidth: number) => void; + doSave: () => Promise; + doConvert: () => Promise; + doDelete: () => Promise; +}): Promise<{ canvas: DrawingCanvas; bgModeListener: (bgMode: string) => void }> { + const { el, plugin } = opts; + const isMobile = Platform.isMobile; + const isDark = resolveIsDark(plugin.settings.bgMode); + const bgColor = getEffectiveBgColor(plugin.settings); + const lineColor = getEffectiveLineColor(plugin.settings); + el.style.backgroundColor = bgColor; + + // --- Top bar: contiene la toolbar centrata e il bottone X --- + 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'); + + // Penna / Gomma + const penBtn = mkBtn(toolbar, 'pencil', 'btn_pen'); + penBtn.classList.add('hwm_active', 'hwm_pen-btn'); + const eraserBtn = mkBtn(toolbar, 'eraser', 'btn_eraser'); + eraserBtn.classList.add('hwm_eraser-btn'); + toolbar.createDiv({ cls: 'hwm_separator' }); + + // Palette colori — valori importati da settings.ts (unica fonte di verità) + const colors = isDark ? [...DARK_COLORS] : [...LIGHT_COLORS]; + 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' }); + + // Undo / Redo / Clear + const undoBtn = mkBtn(toolbar, 'rotate-ccw', 'btn_undo'); + undoBtn.classList.add('hwm_undo-btn'); + const redoBtn = mkBtn(toolbar, 'rotate-cw', 'btn_redo'); + redoBtn.classList.add('hwm_redo-btn'); + const clearBtn = mkBtn(toolbar, 'trash', 'btn_clear'); + clearBtn.classList.add('hwm_clear-btn'); + toolbar.createDiv({ cls: 'hwm_separator' }); + + // Converti / Salva / Elimina + const convertBtn = mkBtn(toolbar, 'file-text', 'btn_convert'); + convertBtn.classList.add('hwm_convert-btn'); + const saveBtn = mkBtn(toolbar, 'save', 'btn_save'); + saveBtn.classList.add('hwm_save-btn'); + const deleteBtn = mkBtn(toolbar, 'file-x', 'btn_delete'); + deleteBtn.classList.add('hwm_delete-btn'); + + // Bottone chiudi (X): posizionato a destra via CSS absolute + const closeBtn = mkBtn(topbar, 'x', 'btn_close'); + closeBtn.classList.add('hwm_close-btn'); + closeBtn.addEventListener('click', () => opts.onClose()); + + // --- Scroll container e canvas --- + const scrollWrap = el.createDiv({ cls: 'hwm_editor-scroll' }); + const canvasWrap = scrollWrap.createDiv({ cls: 'hwm_canvas-wrap' }); + + // Carica i tratti dal file SVG + const { strokes, canvasWidth: savedW, canvasHeight: savedH } = await loadStrokesFromSvg(opts.svgPath, plugin); + const { canvasWidth, canvasHeight } = plugin.settings; + // Usa le dimensioni salvate nel viewBox per preservare i tratti di sessioni precedenti più larghe + const w = savedW ?? canvasWidth; + const h = savedH ?? canvasHeight; + const debugFn = plugin.settings.debugMode ? (msg: string) => new Notice(msg, 3000) : null; + + const canvas = new DrawingCanvas(canvasWrap, w, h, canvasHeight, isMobile, debugFn); + canvas.setBackground(bgColor, lineColor); + canvas.setColor(colors[0]!); + // Su mobile: dito = scroll manuale del container, penna = disegno + if (isMobile) canvas.allowFingerScroll(scrollWrap); + + // Carica i tratti con remapping colori al tema corrente + if (strokes.length > 0) { + const remapped = strokes.map(s => ({ + ...s, color: remapStrokeColor(s.color, plugin.settings.bgMode) + })); + canvas.loadStrokes(remapped); + } + + // Setup specifico della classe chiamante (ResizeObserver su Android, rAF su Desktop) + opts.afterCanvas(canvas, scrollWrap, canvasWidth); + + // Resize handle (visibile ma non interattivo) + // NOTA: handle è dichiarato dopo colorBtns ma catturato dal bgModeListener per closure: + // la closure legge il valore corrente di 'handle' quando viene invocata (non quando è definita). + let handle!: HTMLElement; + handle = scrollWrap.createDiv({ cls: 'hwm_resize-handle hwm_resize-handle--disabled' }); + handle.createEl('span', { text: '⋯' }); + handle.classList.toggle('hwm_resize-handle--dark', isDark); + + // Listener bgMode: aggiorna toolbar, pallini colore e sfondo canvas al cambio tema. + // Registrato da buildEditorUI e restituito alla classe per poterlo rimuovere in onClose(). + const 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(plugin.settings); + // Aggiorna i pallini colore palette + const newColors = dark ? DARK_COLORS : LIGHT_COLORS; + colorBtns.forEach((btn, i) => { + btn.style.backgroundColor = newColors[i] ?? ''; + btn.setAttribute('title', newColors[i] ?? ''); + }); + // Aggiorna sfondo e righe nel canvas + canvas.setBackground( + getEffectiveBgColor(plugin.settings), + getEffectiveLineColor(plugin.settings) + ); + }; + plugin.bgModeListeners.add(bgModeListener); + + // Auto-scroll quando il canvas si espande, ma solo se non si sta disegnando. + // Durante il disegno, lo scroll sposterebbe il canvas e le coordinate salterebbero. + canvas.onResize(() => { + if (!canvas.isPointerDown()) scrollWrap.scrollTop = scrollWrap.scrollHeight; + }); + + // --- Event handlers --- + const cv = 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]!); + }); + } + undoBtn.addEventListener('click', () => cv.undo()); + redoBtn.addEventListener('click', () => cv.redo()); + clearBtn.addEventListener('click', () => cv.clear()); + convertBtn.addEventListener('click', () => opts.doConvert()); + saveBtn.addEventListener('click', async () => { await opts.doSave(); new Notice(t('notice_saved')); }); + deleteBtn.addEventListener('click', () => opts.doDelete()); + + return { canvas, bgModeListener }; +} + +/* ============================================= + DrawingEditorView — Tab dedicata (Android) + ============================================= */ + export class DrawingEditorView extends ItemView { plugin: HandwritingPlugin; private canvas: DrawingCanvas | null = null; @@ -92,371 +357,82 @@ export class DrawingEditorView extends ItemView { this.displayRo = 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: toolbar centrata --- - const topbar = el.createDiv({ cls: 'hwm_editor-topbar hwm_editor-topbar--modal' }); - if (isDark) topbar.classList.add('hwm_editor-topbar--dark'); - - // Toolbar centrata nel topbar - const toolbar = topbar.createDiv({ cls: 'hwm_toolbar hwm_editor-toolbar' }); - if (isDark) toolbar.classList.add('hwm_toolbar--dark'); - - // Penna / Gomma - const penBtn = this.mkBtn(toolbar, 'pencil', 'btn_pen'); - penBtn.classList.add('hwm_active', 'hwm_pen-btn'); - const eraserBtn = this.mkBtn(toolbar, 'eraser', 'btn_eraser'); - 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' }); - - // 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', 'btn_undo'); - undoBtn.classList.add('hwm_undo-btn'); - const redoBtn = this.mkBtn(toolbar, 'rotate-cw', 'btn_redo'); - redoBtn.classList.add('hwm_redo-btn'); - const clearBtn = this.mkBtn(toolbar, 'trash', 'btn_clear'); - clearBtn.classList.add('hwm_clear-btn'); - toolbar.createDiv({ cls: 'hwm_separator' }); - - // Converti / Salva / Elimina - const convertBtn = this.mkBtn(toolbar, 'file-text', 'btn_convert'); - convertBtn.classList.add('hwm_convert-btn'); - const saveBtn = this.mkBtn(toolbar, 'save', 'btn_save'); - saveBtn.classList.add('hwm_save-btn'); - const deleteBtn = this.mkBtn(toolbar, 'file-x', 'btn_delete'); - deleteBtn.classList.add('hwm_delete-btn'); - - // Bottone chiudi (X): nel topbar, posizionata a destra via CSS absolute - const closeBtn = this.mkBtn(topbar, 'x', 'btn_close'); - closeBtn.classList.add('hwm_close-btn'); - closeBtn.addEventListener('click', async () => { - await this.saveSvg(); - this.leaf.detach(); + const { canvas, bgModeListener } = await buildEditorUI({ + el, + plugin: this.plugin, + svgPath: this.svgPath, + embedId: this.embedId, + sourcePath: this.sourcePath, + // Chiude la tab dopo aver salvato + onClose: async () => { await this.saveSvg(); this.leaf.detach(); }, + // Adatta il canvas alla larghezza reale e la mantiene sincronizzata + // ad ogni cambio orientamento (portrait ↔ landscape). + afterCanvas: (cv, scrollWrap) => { + this.displayRo = new ResizeObserver(() => { + const displayW = scrollWrap.clientWidth || el.clientWidth; + if (displayW === 0) return; + cv.setDisplayWidth(displayW); + }); + this.displayRo.observe(scrollWrap); + this.displayRo.observe(el); + }, + doSave: () => this.saveSvg(), + doConvert: () => this.doConvert(), + doDelete: () => this.doDelete(), }); - // --- Scroll container --- - const scrollWrap = el.createDiv({ cls: 'hwm_editor-scroll' }); - const canvasWrap = scrollWrap.createDiv({ cls: 'hwm_canvas-wrap' }); + this.canvas = canvas; + this.bgModeListener = bgModeListener; - // Carica tratti dal file SVG - const { strokes, canvasWidth: savedW, canvasHeight: savedH } = await this.loadStrokes(); - const { canvasWidth, canvasHeight } = this.plugin.settings; - // Usa la larghezza salvata nel viewBox SVG come worldWidth iniziale: garantisce che - // setDisplayWidth() non tagli i tratti disegnati in sessioni precedenti più larghe. - const w = savedW ?? canvasWidth; - 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, w, 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); - } - - // Adatta il canvas alla larghezza reale del container e la mantiene sincronizzata - // ad ogni cambio di orientamento (portrait ↔ landscape). - // L'observer resta attivo per tutta la vita della tab; viene rimosso in onClose(). - // Se clientWidth è ancora 0 (tab non renderizzata), salta e riprova al prossimo evento. - this.displayRo = new ResizeObserver(() => { - const displayW = scrollWrap.clientWidth || el.clientWidth; - if (displayW === 0) return; - this.canvas?.setDisplayWidth(displayW); - }); - this.displayRo.observe(scrollWrap); - this.displayRo.observe(el); - - // 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(() => { + // Auto-save debounced (2s dopo l'ultimo cambiamento) + canvas.onChange(() => { if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(() => this.saveSvg(), 2000); }); - - // Bottone ← → salva e chiudi la tab - } - - /* ---------- File I/O ---------- */ - - private async loadStrokes(): Promise<{ strokes: Stroke[]; canvasWidth: number | null; 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); - // Legge sia la larghezza che l'altezza dal viewBox per ripristinare - // il worldWidth originale (evita il taglio dei tratti oltre settings.canvasWidth) - const m = content.match(/viewBox="0 0 (\d+) (\d+)"/); - return { - strokes, - canvasWidth: m ? parseInt(m[1] ?? '0') : null, - canvasHeight: m ? parseInt(m[2] ?? '0') : null, - }; - } - return { strokes: [], canvasWidth: null, 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); + await saveSvgToDisk(this.canvas, this.svgPath, this.embedId, this.plugin); } - /* ---------- Converti OCR ---------- */ - private async doConvert() { if (!this.canvas || this.canvas.getStrokes().length === 0) { - new Notice('Nessun tratto da convertire'); - return; + new Notice(t('error_no_strokes')); return; } try { - new Notice('Riconoscimento in corso…'); + new Notice(t('notice_recognizing')); 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 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('Nessun testo riconosciuto'); return; } - // Markdown + archivia + sostituisci - const markdown = parseMarkdown(rawText); - await this.archiveSvg(); - await this.replaceCodeBlock(markdown); - this.canvas.destroy(); - this.canvas = null; + if (!rawText.trim()) { new Notice(t('error_no_text')); return; } + const markdown = parseHandwritingToMarkdown(rawText); + await archiveSvgFile(this.svgPath, this.plugin); + await replaceInMdFile(this.sourcePath, this.svgPath, this.embedId, '\n' + markdown + '\n', this.plugin); + this.canvas.destroy(); this.canvas = null; this.leaf.detach(); - new Notice('Conversione completata!'); + new Notice(t('notice_converted')); } catch (e: unknown) { - new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e))); + new Notice(t('error_ocr') + (e instanceof Error ? e.message : String(e))); } } - /* ---------- Elimina ---------- */ - private async doDelete() { if (!confirm(t('confirm_delete'))) 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); + await replaceInMdFile(this.sourcePath, this.svgPath, this.embedId, '\n', this.plugin); + const svgFile = this.plugin.app.vault.getAbstractFileByPath(this.svgPath); + if (svgFile instanceof TFile) await this.plugin.app.vault.delete(svgFile); this.leaf.detach(); - new Notice(t('btn_delete')); - } - - /* ---------- 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, key: string): HTMLElement { - const label = t(key as any); - const btn = parent.createEl('button', { cls: 'hwm_btn', attr: { title: label } }); - btn.setAttribute('data-hwm-key', key); - 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; - }); + new Notice(t('notice_deleted')); } } @@ -510,193 +486,61 @@ export class DrawingModal extends Modal { 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 centrata. La X nativa di Obsidian è nascosta via CSS - // (hwm_modal .modal-close-button); la chiusura avviene dal bottone X in toolbar. - 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'); - - const penBtn = this.mkBtn(toolbar, 'pencil', 'btn_pen'); - penBtn.classList.add('hwm_active', 'hwm_pen-btn'); - const eraserBtn = this.mkBtn(toolbar, 'eraser', 'btn_eraser'); - 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' }); - - // 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', 'btn_undo'); - undoBtn.classList.add('hwm_undo-btn'); - const redoBtn = this.mkBtn(toolbar, 'rotate-cw', 'btn_redo'); - redoBtn.classList.add('hwm_redo-btn'); - const clearBtn = this.mkBtn(toolbar, 'trash', 'btn_clear'); - clearBtn.classList.add('hwm_clear-btn'); - toolbar.createDiv({ cls: 'hwm_separator' }); - - const convertBtn = this.mkBtn(toolbar, 'file-text', 'btn_convert'); - convertBtn.classList.add('hwm_convert-btn'); - const saveBtn = this.mkBtn(toolbar, 'save', 'btn_save'); - saveBtn.classList.add('hwm_save-btn'); - const deleteBtn = this.mkBtn(toolbar, 'file-x', 'btn_delete'); - deleteBtn.classList.add('hwm_delete-btn'); - - // Bottone chiudi (X): nel topbar, posizionata a destra via CSS absolute - const closeBtn = this.mkBtn(topbar, 'x', 'btn_close'); - closeBtn.classList.add('hwm_close-btn'); - closeBtn.addEventListener('click', () => this.close()); - - const scrollWrap = el.createDiv({ cls: 'hwm_editor-scroll' }); - const canvasWrap = scrollWrap.createDiv({ cls: 'hwm_canvas-wrap' }); - - const { strokes, canvasWidth: savedW, canvasHeight: savedH } = await this.loadStrokes(); - const { canvasWidth, canvasHeight } = this.plugin.settings; - const w = savedW ?? canvasWidth; - const h = savedH ?? canvasHeight; - const debugFn = this.plugin.settings.debugMode ? (msg: string) => new Notice(msg, 3000) : null; - - this.canvas = new DrawingCanvas(canvasWrap, w, 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); - } - - // Espande il canvas a tutta la larghezza del modal (elimina le bande laterali). - // requestAnimationFrame garantisce che il layout del modal sia pronto prima di misurarlo. - requestAnimationFrame(() => { - const displayW = scrollWrap.clientWidth; - if (this.canvas && displayW > canvasWidth) { - this.canvas.setDisplayWidth(displayW); - } + const { canvas, bgModeListener } = await buildEditorUI({ + el, + plugin: this.plugin, + svgPath: this.svgPath, + embedId: this.embedId, + sourcePath: this.sourcePath, + // Chiude il modal (Obsidian gestisce il cleanup via onClose) + onClose: () => this.close(), + // Espande il canvas a tutta la larghezza del modal eliminando le bande laterali. + // requestAnimationFrame garantisce che il layout del modal sia pronto prima di misurarlo. + afterCanvas: (cv, scrollWrap, canvasWidth) => { + requestAnimationFrame(() => { + const displayW = scrollWrap.clientWidth; + if (displayW > canvasWidth) cv.setDisplayWidth(displayW); + }); + }, + doSave: () => this.saveSvg(), + doConvert: () => this.doConvert(), + doDelete: () => this.doDelete(), }); - const handle = scrollWrap.createDiv({ cls: 'hwm_resize-handle hwm_resize-handle--disabled' }); - handle.createEl('span', { text: '⋯' }); - handle.classList.toggle('hwm_resize-handle--dark', isDark); + this.canvas = canvas; + this.bgModeListener = bgModeListener; - // 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(() => { + // Auto-save debounced (2s dopo l'ultimo cambiamento) + canvas.onChange(() => { if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(() => this.saveSvg(), 2000); }); } - private async loadStrokes(): Promise<{ strokes: Stroke[]; canvasWidth: number | null; 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), - canvasWidth: m ? parseInt(m[1] ?? '0') : null, - canvasHeight: m ? parseInt(m[2] ?? '0') : null, - }; - } - return { strokes: [], canvasWidth: null, 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); + await saveSvgToDisk(this.canvas, this.svgPath, this.embedId, this.plugin); } private async doConvert() { - if (!this.canvas || this.canvas.getStrokes().length === 0) { new Notice('Nessun tratto da convertire'); return; } + if (!this.canvas || this.canvas.getStrokes().length === 0) { new Notice(t('error_no_strokes')); return; } try { - new Notice('Riconoscimento in corso…'); + 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 this.svgToPng(svgEl); + 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('Nessun testo riconosciuto'); return; } - const markdown = parseMarkdown(rawText); - await this.archiveSvg(); - await this.replaceCodeBlock(markdown); + if (!rawText.trim()) { new Notice(t('error_no_text')); return; } + const markdown = parseHandwritingToMarkdown(rawText); + await archiveSvgFile(this.svgPath, this.plugin); + await replaceInMdFile(this.sourcePath, this.svgPath, this.embedId, '\n' + markdown + '\n', this.plugin); 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))); } + new Notice(t('notice_converted')); + } catch (e: unknown) { new Notice(t('error_ocr') + (e instanceof Error ? e.message : String(e))); } } // Overlay di conferma inline: nessun Modal annidato → nessun furto di focus @@ -740,7 +584,6 @@ export class DrawingModal extends Modal { }; // Registra il listener PRIMA di modificare il file, così non perdiamo l'evento. - // vault.on('modify') scatta con certezza quando removeCodeBlock() scrive il file. const ref = this.app.vault.on('modify', (file) => { if (file.path === srcPath) { this.app.vault.offref(ref); @@ -748,7 +591,7 @@ export class DrawingModal extends Modal { } }); - await this.removeCodeBlock(); + await replaceInMdFile(srcPath, this.svgPath, this.embedId, '\n', this.plugin); const svgFile = this.app.vault.getAbstractFileByPath(this.svgPath); if (svgFile instanceof TFile) await this.app.vault.delete(svgFile); @@ -756,68 +599,6 @@ export class DrawingModal extends Modal { setTimeout(() => { this.app.vault.offref(ref); doFocus(); }, 3000); this.close(); - new Notice(t('btn_delete')); - } - - 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, key: string): HTMLElement { - const label = t(key as any); - const btn = parent.createEl('button', { cls: 'hwm_btn', attr: { title: label } }); - btn.setAttribute('data-hwm-key', key); - 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; - }); + new Notice(t('notice_deleted')); } } - diff --git a/HandTranscriptMd/src/embed.ts b/HandTranscriptMd/src/embed.ts index 52953e8..37edd62 100644 --- a/HandTranscriptMd/src/embed.ts +++ b/HandTranscriptMd/src/embed.ts @@ -29,10 +29,10 @@ const ICONS: Record = { import type HandwritingPlugin from './main'; import { t } from './i18n'; import { Stroke } from './drawing-canvas'; -import { strokesToSvg, parseSvgStrokes, generateId } from './svg-utils'; -import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor, BgMode } from './settings'; +import { strokesToSvg, parseSvgStrokes, generateId, svgToBase64Png, archiveSvgFile } from './svg-utils'; +import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor, BgMode, resolveIsDark } from './settings'; import { getRecognizer } from './recognizer'; -import { parseMarkdown } from './md-parser'; +import { parseHandwritingToMarkdown } from './md-parser'; import { VIEW_TYPE_HANDWRITING, DrawingEditorView, DrawingModal } from './editor-view'; // Dati JSON salvati dentro il code block ```handwriting (formato legacy) @@ -302,7 +302,7 @@ function showLegacyPreview( convertBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (!currentSvgContent || currentStrokes.length === 0) { - new Notice('Nessun tratto da convertire'); + new Notice(t('error_no_strokes')); return; } await doConvert(currentSvgContent, data, ctx, plugin); @@ -329,10 +329,30 @@ function renderPreviewContent(preview: HTMLElement, svgContent: string | null) { const svgH = m ? parseInt(m[2]!) : 300; div.style.paddingBottom = (svgH / svgW * 100) + '%'; } else { - preview.createDiv({ cls: 'hwm_placeholder', text: 'Usa il bottone matita in alto a destra per disegnare' }); + preview.createDiv({ cls: 'hwm_placeholder', text: t('notice_placeholder_draw') }); } } +/* ============================================= + Pipeline OCR comune (wiki + legacy) + ============================================= */ + +// Esegue il riconoscimento OCR su un SVG e restituisce il testo markdown. +// Lancia eccezione in caso di errore — il chiamante decide se catturarla o propagarla. +async function runOcrPipeline(svgContent: string, plugin: HandwritingPlugin): Promise { + new Notice(t('notice_recognizing')); + const svgEl = new DOMParser() + .parseFromString(svgContent, 'image/svg+xml') + .documentElement as unknown as SVGElement; + const base64 = await svgToBase64Png(svgEl); + const recognizer = getRecognizer(plugin.settings.geminiApiKey, plugin.settings.ocrLanguages); + const rawText = await recognizer.recognize(base64); + if (!rawText.trim()) throw new Error(t('error_no_text')); + // In modalità debug mostra il testo grezzo restituito da Gemini (prima del parsing) + if (plugin.settings.debugMode) new Notice(`[DEBUG] Testo grezzo Gemini:\n${rawText}`, 30000); + return parseHandwritingToMarkdown(rawText); +} + /* ============================================= Conversione OCR — Nuovo formato wiki ============================================= */ @@ -344,20 +364,10 @@ async function doConvertWiki( plugin: HandwritingPlugin ) { // Lancia eccezione in caso di errore (il chiamante decide se mostrare Notice o propagare) - new Notice('Riconoscimento in corso…'); - const parser = new DOMParser(); - const svgEl = parser.parseFromString(svgContent, 'image/svg+xml') - .documentElement as unknown as SVGElement; - const base64 = await svgToBase64Png(svgEl); - const recognizer = getRecognizer(plugin.settings.geminiApiKey, plugin.settings.ocrLanguages); - const rawText = await recognizer.recognize(base64); - if (!rawText.trim()) throw new Error('Nessun testo riconosciuto'); - const markdown = parseMarkdown(rawText); - // In modalità debug mostra il testo grezzo restituito da Gemini (prima del parsing) - if (plugin.settings.debugMode) new Notice(`[DEBUG] Testo grezzo Gemini:\n${rawText}`, 30000); - await archiveSvgByPath(svgPath, plugin); + const markdown = await runOcrPipeline(svgContent, plugin); + await archiveSvgFile(svgPath, plugin); await replaceWikiEmbedWithMarkdown(svgPath, markdown, sourcePath, plugin); - new Notice('Conversione completata!'); + new Notice(t('notice_converted')); } /* ============================================= @@ -371,23 +381,12 @@ async function doConvert( plugin: HandwritingPlugin ) { try { - new Notice('Riconoscimento in corso…'); - const parser = new DOMParser(); - const svgEl = parser.parseFromString(svgContent, 'image/svg+xml') - .documentElement as unknown as SVGElement; - const base64 = await svgToBase64Png(svgEl); - const recognizer = getRecognizer(plugin.settings.geminiApiKey, plugin.settings.ocrLanguages); - const rawText = await recognizer.recognize(base64); - if (!rawText.trim()) { new Notice('Nessun testo riconosciuto'); return; } - - const markdown = parseMarkdown(rawText); - // In modalità debug mostra il testo grezzo restituito da Gemini (prima del parsing) - if (plugin.settings.debugMode) new Notice(`[DEBUG] Testo grezzo Gemini:\n${rawText}`, 30000); - await archiveSvg(data, plugin); + const markdown = await runOcrPipeline(svgContent, plugin); + await archiveSvgFile(data.svg, plugin); await replaceEmbedWithMarkdown(ctx, data, markdown, plugin); - new Notice('Conversione completata!'); + new Notice(t('notice_converted')); } catch (e: unknown) { - new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e))); + new Notice(t('error_ocr') + (e instanceof Error ? e.message : String(e))); } } @@ -442,7 +441,7 @@ async function removeWikiEmbed( plugin: HandwritingPlugin ) { const mdFile = plugin.app.vault.getAbstractFileByPath(sourcePath); - if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; } + if (!(mdFile instanceof TFile)) { new Notice(t('error_file_not_found')); return; } const content = await plugin.app.vault.read(mdFile); const updated = content.replace(wikiEmbedRegex(svgPath), '\n'); @@ -452,7 +451,7 @@ async function removeWikiEmbed( const svgFile = plugin.app.vault.getAbstractFileByPath(svgPath); if (svgFile instanceof TFile) await plugin.app.vault.delete(svgFile); - new Notice('Riquadro eliminato'); + new Notice(t('notice_deleted')); } // Rimuove il code block legacy dal .md e cancella il file SVG @@ -462,7 +461,7 @@ async function removeLegacyEmbed( plugin: HandwritingPlugin ) { const mdFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath); - if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; } + if (!(mdFile instanceof TFile)) { new Notice(t('error_file_not_found')); return; } const content = await plugin.app.vault.read(mdFile); const updated = content.replace(codeBlockRegex(data.id), '\n'); @@ -471,37 +470,7 @@ async function removeLegacyEmbed( const svgFile = plugin.app.vault.getAbstractFileByPath(data.svg); if (svgFile instanceof TFile) await plugin.app.vault.delete(svgFile); - new Notice('Riquadro eliminato'); -} - -/* ============================================= - Archivia SVG dopo conversione - ============================================= */ - -async function archiveSvgByPath(svgPath: string, plugin: HandwritingPlugin) { - const svgFile = plugin.app.vault.getAbstractFileByPath(svgPath); - if (!(svgFile instanceof TFile)) return; - await _moveSvgToConverted(svgFile, plugin); -} - -async function archiveSvg(data: EmbedData, plugin: HandwritingPlugin) { - const svgFile = plugin.app.vault.getAbstractFileByPath(data.svg); - if (!(svgFile instanceof TFile)) return; - await _moveSvgToConverted(svgFile, plugin); -} - -// Sposta il file SVG nella cartella _converted con nome timestamp -async function _moveSvgToConverted(svgFile: TFile, plugin: HandwritingPlugin) { - 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 destFolder = `${plugin.settings.svgFolder}/_converted`; - if (!plugin.app.vault.getAbstractFileByPath(destFolder)) { - await plugin.app.vault.createFolder(destFolder); - } - await plugin.app.vault.rename(svgFile, `${destFolder}/${ts}.svg`); + new Notice(t('notice_deleted')); } /* ============================================= @@ -515,7 +484,7 @@ async function replaceWikiEmbedWithMarkdown( plugin: HandwritingPlugin ) { const mdFile = plugin.app.vault.getAbstractFileByPath(sourcePath); - if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; } + if (!(mdFile instanceof TFile)) { new Notice(t('error_file_not_found')); return; } const content = await plugin.app.vault.read(mdFile); const updated = content.replace(wikiEmbedRegex(svgPath), '\n' + markdown + '\n'); @@ -529,7 +498,7 @@ async function replaceEmbedWithMarkdown( plugin: HandwritingPlugin ) { const mdFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath); - if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; } + if (!(mdFile instanceof TFile)) { new Notice(t('error_file_not_found')); return; } const content = await plugin.app.vault.read(mdFile); const updated = content.replace(codeBlockRegex(data.id), '\n' + markdown + '\n'); @@ -544,7 +513,7 @@ async function replaceEmbedWithMarkdown( export async function insertHandwritingBlock(plugin: HandwritingPlugin) { const view = plugin.app.workspace.getActiveViewOfType(MarkdownView); if (!view) { - new Notice('Apri un file markdown prima'); + new Notice(t('notice_open_md_first')); return; } @@ -600,10 +569,6 @@ function createPortalPanel( sourcePath: string, plugin: HandwritingPlugin ) { - // Risolve il tema effettivo tenendo conto di 'auto' - const resolveIsDark = (bgMode: string) => - bgMode === 'auto' ? document.body.classList.contains('theme-dark') : bgMode === 'dark'; - const isDark = resolveIsDark(plugin.settings.bgMode); const collapsedHeight = plugin.settings.canvasHeight; let isExpanded = true; @@ -826,7 +791,7 @@ function createLegacyPortalButton( const btn = document.createElement('button'); btn.className = 'hwm_portal-btn'; btn.innerHTML = ICONS['pencil'] ?? ''; - btn.title = 'Apri editor disegno'; + btn.title = t('btn_open_editor'); document.body.appendChild(btn); // Apre la tab editor al click @@ -874,27 +839,3 @@ function createBtn(parent: HTMLElement, icon: string, key: string): HTMLElement return btn; } -function svgToBase64Png(svgElement: SVGElement): Promise { - return new Promise((resolve, reject) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - const img = new Image(); - const svgBlob = new Blob( - [new XMLSerializer().serializeToString(svgElement)], - { type: 'image/svg+xml' } - ); - const url = URL.createObjectURL(svgBlob); - img.onload = () => { - canvas.width = img.width; - canvas.height = img.height; - ctx.drawImage(img, 0, 0); - URL.revokeObjectURL(url); - resolve(canvas.toDataURL('image/png').split(',')[1]!); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Errore conversione SVG → PNG')); - }; - img.src = url; - }); -} diff --git a/HandTranscriptMd/src/locales/de.json b/HandTranscriptMd/src/locales/de.json index 69c39c3..af536cf 100644 --- a/HandTranscriptMd/src/locales/de.json +++ b/HandTranscriptMd/src/locales/de.json @@ -46,5 +46,15 @@ "btn_open_editor": "Zeichnungseditor öffnen", "confirm_delete": "Diesen Handschrift-Block und die zugehörige SVG-Datei löschen?", "confirm_ok": "Löschen", - "confirm_cancel": "Abbrechen" + "confirm_cancel": "Abbrechen", + "notice_recognizing": "Erkenne…", + "error_no_strokes": "Keine Striche zum Konvertieren", + "error_no_text": "Kein Text erkannt", + "error_file_not_found": "Markdown-Datei nicht gefunden", + "notice_deleted": "Block gelöscht", + "notice_saved": "Gespeichert", + "notice_converted": "Konvertierung abgeschlossen!", + "error_ocr": "OCR-Fehler: ", + "notice_placeholder_draw": "Verwende den Bleistift-Button (oben rechts) zum Zeichnen", + "notice_open_md_first": "Öffne zuerst eine Markdown-Datei" } diff --git a/HandTranscriptMd/src/locales/en.json b/HandTranscriptMd/src/locales/en.json index 83451b1..d0bec0d 100644 --- a/HandTranscriptMd/src/locales/en.json +++ b/HandTranscriptMd/src/locales/en.json @@ -46,5 +46,15 @@ "btn_open_editor": "Open drawing editor", "confirm_delete": "Delete this handwriting block and the associated SVG file?", "confirm_ok": "Delete", - "confirm_cancel": "Cancel" + "confirm_cancel": "Cancel", + "notice_recognizing": "Recognizing…", + "error_no_strokes": "No strokes to convert", + "error_no_text": "No text recognized", + "error_file_not_found": "Markdown file not found", + "notice_deleted": "Block deleted", + "notice_saved": "Saved", + "notice_converted": "Conversion complete!", + "error_ocr": "OCR error: ", + "notice_placeholder_draw": "Use the pencil button (top right) to draw", + "notice_open_md_first": "Open a markdown file first" } diff --git a/HandTranscriptMd/src/locales/es.json b/HandTranscriptMd/src/locales/es.json index e5dd01f..c005188 100644 --- a/HandTranscriptMd/src/locales/es.json +++ b/HandTranscriptMd/src/locales/es.json @@ -46,5 +46,15 @@ "btn_open_editor": "Abrir editor de dibujo", "confirm_delete": "¿Eliminar este bloque de escritura a mano y el archivo SVG asociado?", "confirm_ok": "Eliminar", - "confirm_cancel": "Cancelar" + "confirm_cancel": "Cancelar", + "notice_recognizing": "Reconociendo…", + "error_no_strokes": "No hay trazos para convertir", + "error_no_text": "No se reconoció texto", + "error_file_not_found": "Archivo Markdown no encontrado", + "notice_deleted": "Bloque eliminado", + "notice_saved": "Guardado", + "notice_converted": "¡Conversión completada!", + "error_ocr": "Error OCR: ", + "notice_placeholder_draw": "Usa el botón lápiz (arriba a la derecha) para dibujar", + "notice_open_md_first": "Abre primero un archivo Markdown" } diff --git a/HandTranscriptMd/src/locales/fr.json b/HandTranscriptMd/src/locales/fr.json index 8308db1..4f53a74 100644 --- a/HandTranscriptMd/src/locales/fr.json +++ b/HandTranscriptMd/src/locales/fr.json @@ -46,5 +46,15 @@ "btn_open_editor": "Ouvrir l'éditeur de dessin", "confirm_delete": "Supprimer ce bloc manuscrit et le fichier SVG associé ?", "confirm_ok": "Supprimer", - "confirm_cancel": "Annuler" + "confirm_cancel": "Annuler", + "notice_recognizing": "Reconnaissance en cours…", + "error_no_strokes": "Aucun trait à convertir", + "error_no_text": "Aucun texte reconnu", + "error_file_not_found": "Fichier Markdown introuvable", + "notice_deleted": "Bloc supprimé", + "notice_saved": "Sauvegardé", + "notice_converted": "Conversion terminée !", + "error_ocr": "Erreur OCR : ", + "notice_placeholder_draw": "Utilisez le bouton crayon (en haut à droite) pour dessiner", + "notice_open_md_first": "Ouvrez d'abord un fichier Markdown" } diff --git a/HandTranscriptMd/src/locales/it.json b/HandTranscriptMd/src/locales/it.json index eb0d14a..63d509b 100644 --- a/HandTranscriptMd/src/locales/it.json +++ b/HandTranscriptMd/src/locales/it.json @@ -46,5 +46,15 @@ "btn_open_editor": "Apri editor disegno", "confirm_delete": "Eliminare questo riquadro handwriting e il file SVG associato?", "confirm_ok": "Elimina", - "confirm_cancel": "Annulla" + "confirm_cancel": "Annulla", + "notice_recognizing": "Riconoscimento in corso…", + "error_no_strokes": "Nessun tratto da convertire", + "error_no_text": "Nessun testo riconosciuto", + "error_file_not_found": "File markdown non trovato", + "notice_deleted": "Riquadro eliminato", + "notice_saved": "Salvato", + "notice_converted": "Conversione completata!", + "error_ocr": "Errore OCR: ", + "notice_placeholder_draw": "Usa il bottone matita in alto a destra per disegnare", + "notice_open_md_first": "Apri un file markdown prima" } diff --git a/HandTranscriptMd/src/locales/ja.json b/HandTranscriptMd/src/locales/ja.json index 5d87424..e4d6678 100644 --- a/HandTranscriptMd/src/locales/ja.json +++ b/HandTranscriptMd/src/locales/ja.json @@ -46,5 +46,15 @@ "btn_open_editor": "描画エディタを開く", "confirm_delete": "この手書きブロックと関連するSVGファイルを削除しますか?", "confirm_ok": "削除", - "confirm_cancel": "キャンセル" + "confirm_cancel": "キャンセル", + "notice_recognizing": "認識中…", + "error_no_strokes": "変換するストロークがありません", + "error_no_text": "テキストが認識されませんでした", + "error_file_not_found": "Markdownファイルが見つかりません", + "notice_deleted": "ブロックを削除しました", + "notice_saved": "保存しました", + "notice_converted": "変換が完了しました!", + "error_ocr": "OCRエラー: ", + "notice_placeholder_draw": "右上の鉛筆ボタンを使って描画してください", + "notice_open_md_first": "先にMarkdownファイルを開いてください" } diff --git a/HandTranscriptMd/src/locales/pl.json b/HandTranscriptMd/src/locales/pl.json index 9aa6819..9ca0357 100644 --- a/HandTranscriptMd/src/locales/pl.json +++ b/HandTranscriptMd/src/locales/pl.json @@ -46,5 +46,15 @@ "btn_open_editor": "Otwórz edytor rysunku", "confirm_delete": "Usunąć ten blok pisma odręcznego i powiązany plik SVG?", "confirm_ok": "Usuń", - "confirm_cancel": "Anuluj" + "confirm_cancel": "Anuluj", + "notice_recognizing": "Rozpoznawanie…", + "error_no_strokes": "Brak kresek do konwersji", + "error_no_text": "Nie rozpoznano tekstu", + "error_file_not_found": "Nie znaleziono pliku Markdown", + "notice_deleted": "Blok usunięty", + "notice_saved": "Zapisano", + "notice_converted": "Konwersja zakończona!", + "error_ocr": "Błąd OCR: ", + "notice_placeholder_draw": "Użyj przycisku ołówka (prawy górny róg) do rysowania", + "notice_open_md_first": "Najpierw otwórz plik Markdown" } diff --git a/HandTranscriptMd/src/locales/pt-br.json b/HandTranscriptMd/src/locales/pt-br.json index aedac0f..4a30a96 100644 --- a/HandTranscriptMd/src/locales/pt-br.json +++ b/HandTranscriptMd/src/locales/pt-br.json @@ -46,5 +46,15 @@ "btn_open_editor": "Abrir editor de desenho", "confirm_delete": "Excluir este bloco de manuscrito e o arquivo SVG associado?", "confirm_ok": "Excluir", - "confirm_cancel": "Cancelar" + "confirm_cancel": "Cancelar", + "notice_recognizing": "Reconhecendo…", + "error_no_strokes": "Nenhum traço para converter", + "error_no_text": "Nenhum texto reconhecido", + "error_file_not_found": "Arquivo Markdown não encontrado", + "notice_deleted": "Bloco excluído", + "notice_saved": "Salvo", + "notice_converted": "Conversão concluída!", + "error_ocr": "Erro OCR: ", + "notice_placeholder_draw": "Use o botão lápis (canto superior direito) para desenhar", + "notice_open_md_first": "Abra primeiro um arquivo Markdown" } diff --git a/HandTranscriptMd/src/locales/ru.json b/HandTranscriptMd/src/locales/ru.json index 8f58314..ef94e28 100644 --- a/HandTranscriptMd/src/locales/ru.json +++ b/HandTranscriptMd/src/locales/ru.json @@ -46,5 +46,15 @@ "btn_open_editor": "Открыть редактор рисунков", "confirm_delete": "Удалить этот блок рукописного ввода и связанный файл SVG?", "confirm_ok": "Удалить", - "confirm_cancel": "Отмена" + "confirm_cancel": "Отмена", + "notice_recognizing": "Распознавание…", + "error_no_strokes": "Нет штрихов для конвертации", + "error_no_text": "Текст не распознан", + "error_file_not_found": "Файл Markdown не найден", + "notice_deleted": "Блок удалён", + "notice_saved": "Сохранено", + "notice_converted": "Конвертация завершена!", + "error_ocr": "Ошибка OCR: ", + "notice_placeholder_draw": "Используйте кнопку карандаша (вверху справа) для рисования", + "notice_open_md_first": "Сначала откройте файл Markdown" } diff --git a/HandTranscriptMd/src/locales/zh-cn.json b/HandTranscriptMd/src/locales/zh-cn.json index 55fcb2b..82211b1 100644 --- a/HandTranscriptMd/src/locales/zh-cn.json +++ b/HandTranscriptMd/src/locales/zh-cn.json @@ -46,5 +46,15 @@ "btn_open_editor": "打开绘图编辑器", "confirm_delete": "删除此手写块及关联的SVG文件?", "confirm_ok": "删除", - "confirm_cancel": "取消" + "confirm_cancel": "取消", + "notice_recognizing": "识别中…", + "error_no_strokes": "没有可转换的笔划", + "error_no_text": "未识别到文本", + "error_file_not_found": "未找到Markdown文件", + "notice_deleted": "块已删除", + "notice_saved": "已保存", + "notice_converted": "转换完成!", + "error_ocr": "OCR错误:", + "notice_placeholder_draw": "使用右上角的铅笔按钮进行绘图", + "notice_open_md_first": "请先打开一个Markdown文件" } diff --git a/HandTranscriptMd/src/md-parser.ts b/HandTranscriptMd/src/md-parser.ts index c7fb6db..5d94074 100644 --- a/HandTranscriptMd/src/md-parser.ts +++ b/HandTranscriptMd/src/md-parser.ts @@ -361,12 +361,6 @@ export function parseHandwritingToMarkdown(rawOcrText: string): string { return expandKeywords(normalizeMarkdownSymbols(rawOcrText)); } -/** - * Alias di compatibilità con il codice pre-esistente che chiamava parseMarkdown(). - */ -export function parseMarkdown(raw: string): string { - return parseHandwritingToMarkdown(raw); -} // ============================================================================= // HELPER PRIVATI diff --git a/HandTranscriptMd/src/settings.ts b/HandTranscriptMd/src/settings.ts index 13a216f..21cd2cc 100644 --- a/HandTranscriptMd/src/settings.ts +++ b/HandTranscriptMd/src/settings.ts @@ -52,8 +52,15 @@ export function getEffectiveLineColor(settings: HandwritingSettings): string { // Mappa colori chiari ↔ scuri per adattare i tratti al cambio tema. // Quando l'utente cambia tema, i tratti con colori della palette opposta // vengono rimappati ai corrispondenti colori leggibili. -const LIGHT_COLORS = ['#000000', '#1e40af', '#dc2626', '#16a34a']; -const DARK_COLORS = ['#ffffff', '#60a5fa', '#f87171', '#4ade80']; +// Esportati per essere usati da editor-view.ts senza ridefinirli. +export const LIGHT_COLORS = ['#000000', '#1e40af', '#dc2626', '#16a34a']; +export const DARK_COLORS = ['#ffffff', '#60a5fa', '#f87171', '#4ade80']; + +// Risolve se il tema è scuro tenendo conto di 'auto' (legge la classe Obsidian sul body) +export function resolveIsDark(bgMode: string): boolean { + if (bgMode === 'auto') return document.body.classList.contains('theme-dark'); + return bgMode === 'dark'; +} // Rimappa il colore di un tratto in base al tema corrente export function remapStrokeColor(color: string, bgMode: BgMode): string { @@ -84,9 +91,6 @@ export const DEFAULT_SETTINGS: HandwritingSettings = { uiLanguage: 'auto', // default: segue la lingua di sistema di Obsidian }; -// Nome del branch corrente — aggiornare manualmente ad ogni cambio di branch -const PLUGIN_BRANCH = 'overlay'; - export class HandwritingSettingTab extends PluginSettingTab { plugin: HandwritingPlugin; @@ -98,6 +102,8 @@ export class HandwritingSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); + // Classe per scopare i CSS responsive delle impostazioni + containerEl.addClass('hwm_settings'); containerEl.createEl('h2', { text: 'Handwriting to Markdown' }); diff --git a/HandTranscriptMd/src/svg-utils.ts b/HandTranscriptMd/src/svg-utils.ts index a57953a..e48fb49 100644 --- a/HandTranscriptMd/src/svg-utils.ts +++ b/HandTranscriptMd/src/svg-utils.ts @@ -5,7 +5,9 @@ per poter ricaricare e rieditare il disegno. ============================================= */ -import { Point, Stroke } from './drawing-canvas'; +import { TFile } from 'obsidian'; +import type HandwritingPlugin from './main'; +import { Point, Stroke, LINE_SPACING } from './drawing-canvas'; // Genera ID univoco per nuovi disegni nel formato HTMD_YYYYMMDDHHMMSS_XXXX export function generateId(): string { @@ -71,7 +73,6 @@ export function strokesToSvg( const strokesJson = JSON.stringify(strokes); // Righe orizzontali (foglio a righe) — stessa spaziatura del canvas - const LINE_SPACING = 32; const lines: string[] = []; for (let y = LINE_SPACING; y < height; y += LINE_SPACING) { lines.push(` `); @@ -125,3 +126,43 @@ function unescapeXml(s: string): string { .replace(/</g, '<') .replace(/&/g, '&'); } + +// Converte un SVGElement in PNG base64 via canvas HTML temporaneo. +// Usato dalla pipeline OCR (embed.ts e editor-view.ts) prima di inviare a Gemini. +export function svgToBase64Png(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; + }); +} + +// Sposta il file SVG nella cartella _converted con nome timestamp. +// Chiamata dopo la conversione OCR riuscita (embed.ts e editor-view.ts). +export async function archiveSvgFile(svgPath: string, plugin: HandwritingPlugin): Promise { + const svgFile = plugin.app.vault.getAbstractFileByPath(svgPath); + if (!(svgFile instanceof TFile)) return; + + const now = new Date(); + const p = (n: number) => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}` + + `_${p(now.getHours())}-${p(now.getMinutes())}-${p(now.getSeconds())}`; + const destFolder = `${plugin.settings.svgFolder}/_converted`; + if (!plugin.app.vault.getAbstractFileByPath(destFolder)) { + await plugin.app.vault.createFolder(destFolder); + } + await plugin.app.vault.rename(svgFile, `${destFolder}/${ts}.svg`); +} diff --git a/HandTranscriptMd/styles.css b/HandTranscriptMd/styles.css index 03a3ab5..8bb3f2f 100644 --- a/HandTranscriptMd/styles.css +++ b/HandTranscriptMd/styles.css @@ -719,3 +719,45 @@ color: var(--text-accent); white-space: nowrap; } + +/* --- Impostazioni responsive: controlli compatti, descrizioni sempre leggibili --- + Il problema su portrait mobile: .setting-item-control ha flex-shrink:0 nel CSS + di Obsidian, quindi cresce fino a spingere la descrizione fuori schermo. + Soluzione: limitiamo la larghezza max dei controlli e, sotto 480px, + impiliamo verticalmente (descrizione sopra, controllo sotto). */ + +/* Permette al controllo di restringersi se lo spazio è poco */ +.hwm_settings .setting-item-control { + flex-shrink: 1; + min-width: 0; +} + +/* Input e select: larghezza max fissa, non crescono oltre */ +.hwm_settings .setting-item-control input[type="text"], +.hwm_settings .setting-item-control input[type="password"], +.hwm_settings .setting-item-control select { + max-width: 160px; + min-width: 60px; + width: auto; +} + +/* Su schermi stretti (portrait tablet/phone): layout verticale */ +@media (max-width: 480px) { + .hwm_settings .setting-item { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + /* Il controllo va sotto, allineato a sinistra */ + .hwm_settings .setting-item-control { + width: auto; + justify-content: flex-start; + } + /* Input e select: tornano a dimensione naturale, non più cappati */ + .hwm_settings .setting-item-control input[type="text"], + .hwm_settings .setting-item-control input[type="password"], + .hwm_settings .setting-item-control select { + max-width: none; + width: auto; + } +} diff --git a/README.md b/README.md index 8ffa20e..809c3ac 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,561 @@ -# Obsidian Sample Plugin +# Handwriting to Markdown — Obsidian Plugin -This is a sample plugin for Obsidian (https://obsidian.md). +Convert handwritten notes (drawn with a stylus on a canvas) into structured Markdown, directly inside Obsidian. Works on both Windows (desktop) and Android (mobile with stylus). -This project uses TypeScript to provide type checking and documentation. -The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. +--- -This sample plugin demonstrates some of the basic functionality the plugin API can do. -- Adds a ribbon icon, which shows a Notice when clicked. -- Adds a command "Open modal (simple)" which opens a Modal. -- Adds a plugin setting tab to the settings page. -- Registers a global click event and output 'click' to the console. -- Registers a global interval which logs 'setInterval' to the console. +## Table of Contents -## First time developing plugins? +1. [User Guide](#user-guide) +2. [Project Structure & Architecture](#project-structure--architecture) +3. [Maintainability Cheat Sheet](#maintainability-cheat-sheet) -Quick starting guide for new plugin devs: +--- -- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. -- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). -- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. -- Install NodeJS, then run `npm i` in the command line under your repo folder. -- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. -- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. -- Reload Obsidian to load the new version of your plugin. -- Enable plugin in settings window. -- For updates to the Obsidian API run `npm update` in the command line under your repo folder. +## User Guide -## Releasing new releases +### What the Plugin Does -- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. -- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. -- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases -- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. -- Publish the release. +This plugin embeds a **handwriting canvas** inside any `.md` file. You draw or write with a stylus (or mouse), and the plugin can: -> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. -> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` +- **Save the drawing** as an SVG file in your vault — visible as an image even without the plugin installed +- **Convert the handwriting to Markdown** using Google Gemini OCR, replacing the drawing block with structured text (headings, lists, tables, etc.) -## Adding your plugin to the community plugin list +The SVG embed is standard Obsidian wiki syntax (`![[_handwriting/hw_xxx.svg]]`), so the image appears in any Obsidian view and is readable by tools like Claude Code. -- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). -- Publish an initial version. -- Make sure you have a `README.md` file in the root of your repo. -- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. +--- -## How to use +### Inserting a Handwriting Block -- Clone this repo. -- Make sure your NodeJS is at least v16 (`node --version`). -- `npm i` or `yarn` to install dependencies. -- `npm run dev` to start compilation in watch mode. +1. Open a Markdown file. +2. Click the **pencil ribbon icon** in the left sidebar (or run the command `Insert handwriting block` via `Ctrl+P`). +3. A new block `![[_handwriting/hw_xxx.svg]]` is inserted at the cursor position. +4. A **portal panel** (toolbar overlay) appears on the image. Click the **pencil button** (✏️) to open the drawing editor. -## Manually installing the plugin +--- -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. +### The Drawing Editor -## Improve code quality with eslint -- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. -- This project already has eslint preconfigured, you can invoke a check by running`npm run lint` -- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines. -- A GitHub action is preconfigured to automatically lint every commit on all branches. +The editor opens differently depending on your platform: -## Funding URL +| Platform | How it opens | +|----------|-------------| +| **Windows (desktop)** | Full-screen overlay modal on top of your document | +| **Android (mobile)** | A new Obsidian tab | -You can include funding URLs where people who use your plugin can financially support it. +#### Toolbar Buttons -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: +| Button | Action | +|--------|--------| +| **Pen** | Switch to drawing mode (stylus or mouse draws strokes) | +| **Eraser** | Switch to eraser mode (drag to erase strokes under the pointer) | +| **Color dots** (4) | Select the current drawing color | +| **Undo** | Undo last stroke or erase action | +| **Redo** | Redo last undone action | +| **Clear** | Remove all strokes and reset canvas to default size | +| **Convert** | Run OCR and replace the drawing block with Markdown text | +| **Save** | Save the current drawing as SVG and update the preview | +| **Delete** (🗑️) | Delete the handwriting block and its SVG file | +| **Close / ←** | Close the editor (Windows: close modal; Android: go back) | -```json -{ - "fundingUrl": "https://buymeacoffee.com" +#### Drawing Tips + +- **Stylus draws, finger scrolls** — on Android, a finger touch scrolls the canvas; the stylus draws. No conflict. +- **Canvas auto-expands** — as you draw near the bottom edge, the canvas grows automatically. +- **Horizontal lines** — the canvas shows ruled lines (like a notebook) as a visual guide; they appear in the saved SVG too. +- **Colors adapt to theme** — strokes drawn in black on a light theme are automatically remapped to white when you switch to dark theme (and vice versa). + +--- + +### Portal Panel (Inline Controls) + +When you hover over a handwriting image in your document, a small floating panel appears with four buttons: + +| Button | Action | +|--------|--------| +| ✏️ | Open drawing editor | +| 📄 | Convert drawing to Markdown (OCR) directly from the preview | +| ↕️ | Collapse / expand the image preview | +| ✕ | Delete the block and its SVG file | + +--- + +### OCR Conversion to Markdown + +The plugin uses **Google Gemini** to recognize handwritten text and converts it to Markdown based on special keywords you write in the drawing. + +#### Supported Keywords + +Write these keywords in your drawing to produce structured Markdown output. All keywords start with `//` and are **case-insensitive** (`//h1` = `//H1`). + +| 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 | + +Plain text lines (without a `//` keyword) are inserted as-is. + +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. + +--- + +### Settings + +Open **Settings → Handwriting to Markdown** to configure: + +| Setting | Description | +|---------|-------------| +| **Interface language** | Language for the settings UI. "Auto" follows Obsidian's language. | +| **SVG folder** | Vault subfolder where SVG drawing files are saved (default: `_handwriting`) | +| **Canvas width / height** | Default canvas resolution in pixels | +| **Canvas background** | Light / Dark / Auto (follows Obsidian theme) | +| **Gemini API key** | Required for OCR. Get it free at [aistudio.google.com](https://aistudio.google.com). | +| **OCR languages** | Comma-separated BCP-47 codes (e.g. `it, en, fr`). Tells Gemini which languages to expect. | +| **Debug mode** | Shows real-time notifications for IME/touch events (useful for Android troubleshooting) | +| **Handwriting mode (Android)** | If ON, collapses all drawing previews to 48 px thumbnails so the stylus can write freely in the surrounding text | + +--- + +### Platform Support + +| Feature | Windows | Android | +|---------|---------|---------| +| Drawing (stylus/mouse) | ✅ | ✅ | +| Finger scroll while drawing | — | ✅ | +| OCR conversion | ✅ | ✅ | +| Editor opens as modal | ✅ | — | +| Editor opens as new tab | — | ✅ | +| Collapse/expand preview | ✅ | ✅ | +| Handwriting mode toggle | — | ✅ (recommended) | + +--- + +## Project Structure & Architecture + +This section explains how the codebase is organized, how Obsidian's plugin system works, and which file to open for any given task. + +--- + +### Folder & File Layout + +``` +HandTranscriptMd/ +│ +├── src/ ← all TypeScript source files +│ ├── main.ts ← plugin entry point (class HandwritingPlugin) +│ ├── settings.ts ← settings definition, defaults, settings tab UI +│ ├── i18n.ts ← translation loader and t() helper +│ ├── locales/ ← one JSON file per language +│ │ ├── en.json ← English (the fallback — always the reference) +│ │ ├── it.json +│ │ ├── de.json fr.json es.json ru.json ja.json +│ │ ├── zh-cn.json pt-br.json pl.json +│ ├── drawing-canvas.ts ← HTML Canvas drawing engine (strokes, eraser, undo) +│ ├── svg-utils.ts ← SVG ↔ strokes serialization, PNG conversion, archive +│ ├── embed.ts ← inline preview decoration + portal panel +│ ├── editor-view.ts ← drawing editor (modal on Windows, tab on Android) +│ ├── recognizer.ts ← Gemini OCR interface + HTTP call +│ ├── md-parser.ts ← keyword-based OCR text → Markdown converter +│ └── parser.test.ts ← unit tests for the markdown parser +│ +├── main.js ← ⚠ compiled output (generated by esbuild, do not edit) +├── styles.css ← all plugin CSS (classes prefixed with hwm_) +├── manifest.json ← plugin metadata (id, name, version, minAppVersion) +├── package.json ← npm dependencies, build scripts +├── esbuild.config.mjs ← build configuration (entry: src/main.ts → main.js) +├── deploy.sh ← copies main.js + manifest.json + styles.css to local vault +├── cloudDeploy.sh ← same but to Google Drive vault (for Android testing) +├── README.md ← this file +├── CLAUDE.md ← context notes for Claude Code AI assistant +└── NOTES.md ← developer session log, resolved bugs, completed tasks +``` + +The three files that Obsidian loads are: **`main.js`**, **`manifest.json`**, **`styles.css`**. Everything under `src/` is TypeScript source that gets compiled down to the single `main.js` by esbuild. + +--- + +### How an Obsidian Plugin Works + +Obsidian plugins are JavaScript modules that run inside the Obsidian Electron app (desktop) or WebView (mobile). The key concepts: + +#### 1. The Plugin Class (`src/main.ts`) + +Every plugin exports a default class that extends Obsidian's `Plugin`. Obsidian calls **`onload()`** when the plugin is enabled and **`onunload()`** when it is disabled. + +```typescript +export default class HandwritingPlugin extends Plugin { + async onload() { /* register everything here */ } } ``` -If you have multiple URLs, you can also do: +Inside `onload()` this plugin registers: +- **A view type** (`registerView`) — the drawing editor tab on Android +- **A code block processor** (`registerMarkdownCodeBlockProcessor`) — renders `handwriting` code blocks +- **Commands** (`addCommand`) — appear in `Ctrl+P` palette +- **A ribbon icon** (`addRibbonIcon`) — the pencil button in the left sidebar +- **A settings tab** (`addSettingTab`) +- **Event listeners** (`registerEvent`) — e.g. the right-click file menu -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} +The plugin class also carries three **shared state maps** used to coordinate between the preview (embed.ts) and the editor (editor-view.ts): +- `previewCallbacks` — after a save, the editor calls `refreshPreview()` to update the inline image +- `embedPaths` — maps embed IDs to SVG file paths, used for color remapping on theme change +- `bgModeListeners` — `Set` of callbacks notified when the background mode setting changes +- `embedActions` — maps embed IDs to their expand/collapse/convert functions, used by the right-click menu + +#### 2. The Vault API + +The **Vault** is Obsidian's file system abstraction. Use `this.app.vault` (or `plugin.app.vault`) to read/write files: + +```typescript +// Read a file as text +const content = await plugin.app.vault.read(tFile); + +// Write / overwrite a file +await plugin.app.vault.modify(tFile, newContent); + +// Create a file +await plugin.app.vault.create(path, content); + +// Move / rename +await plugin.app.vault.rename(tFile, newPath); ``` -## API Documentation +A `TFile` is Obsidian's object for a file. Get one with: +```typescript +const file = plugin.app.vault.getAbstractFileByPath('folder/name.md'); +``` -See https://docs.obsidian.md +#### 3. The Workspace API + +The **Workspace** manages the layout of open tabs and panels. Used to open the editor tab on Android: + +```typescript +const leaf = plugin.app.workspace.getLeaf('tab'); // open in a new tab +await leaf.setViewState({ type: VIEW_TYPE_HANDWRITING, state: { ... } }); +``` + +#### 4. ItemView — The Drawing Editor Tab (`src/editor-view.ts`) + +`DrawingEditorView extends ItemView` is an Obsidian **custom view** — a full tab with its own DOM. Key lifecycle methods: +- `getViewType()` — returns a unique string ID (`'handwriting-editor'`) +- `getDisplayText()` — the tab title +- `onOpen()` — called when the tab opens; here `buildEditor()` is called to build the canvas UI +- `onClose()` — called when the tab closes; cleanup (remove listeners, disconnect observers) + +The view receives data (which SVG to load, which MD file to update) via `leaf.setViewState({ state: { svgPath, sourcePath, embedId } })`, read back in `getState()`. + +#### 5. Modal — The Desktop Drawing Overlay (`src/editor-view.ts`) + +`DrawingModal extends Modal` is an Obsidian **modal dialog** — a fullscreen overlay on desktop. Key methods: +- `onOpen()` — builds the canvas UI by calling `buildEditor()` +- `onClose()` — cleanup +- `this.close()` — closes the modal programmatically (used in the ← and ✕ buttons) + +`Modal` and `ItemView` are completely different Obsidian base classes, which is why `buildEditorUI()` was extracted as a shared standalone function — both classes call it and pass their specific callbacks for save/close/delete. + +#### 6. Code Block Processor (Legacy Format) + +`registerMarkdownCodeBlockProcessor('handwriting', callback)` tells Obsidian: "when you render a ` ```handwriting ``` ` block, run my callback instead." The callback receives the block source text and the DOM element to fill. This is the legacy embed format. + +#### 7. MutationObserver (Wiki Format) + +For the new `![[svg]]` format, Obsidian renders the embed itself as a ``. The plugin cannot intercept this with a code block processor. Instead, a **MutationObserver** watches `document.body` for new nodes and decorates any span whose `src` attribute points to the `_handwriting/` folder. This happens in `registerEmbed()` in `embed.ts`. + +#### 8. Settings (`src/settings.ts`) + +Settings are stored as a JSON object in Obsidian's `data.json` (inside the plugin folder). `plugin.loadData()` reads it; `plugin.saveData(obj)` writes it. The `HandwritingSettings` interface defines the shape; `DEFAULT_SETTINGS` provides initial values. `HandwritingSettingTab extends PluginSettingTab` builds the settings UI using `new Setting(containerEl)`. + +#### 9. The Build System + +esbuild bundles all TypeScript files starting from `src/main.ts` into a single `main.js`. The `obsidian` package is marked **external** — it is provided at runtime by Obsidian itself and must never be bundled. esbuild does **not** run TypeScript type-checking — type errors are invisible at build time. To catch them: `npx tsc --noEmit`. + +Two build modes: +- `npm run dev` → watch mode, inline sourcemap, not minified +- `node esbuild.config.mjs production` → single build, minified, no sourcemap + +--- + +### What File to Open for a Given Task + +| I want to… | Open this file | +|-----------|---------------| +| Change what happens when the plugin loads/unloads | `src/main.ts` → `onload()` / `onunload()` | +| Add or remove a command (`Ctrl+P`) | `src/main.ts` → `this.addCommand(...)` | +| Add or remove the ribbon icon | `src/main.ts` → `this.addRibbonIcon(...)` | +| Add an item to the right-click file menu | `src/main.ts` → `this.app.workspace.on('file-menu', ...)` | +| Change a setting (add field, change default, add UI control) | `src/settings.ts` → `HandwritingSettings`, `DEFAULT_SETTINGS`, `HandwritingSettingTab.display()` | +| Change the color palette for light/dark theme | `src/settings.ts` → `LIGHT_COLORS`, `DARK_COLORS` | +| Change how "is dark mode" is resolved | `src/settings.ts` → `resolveIsDark()` | +| Add or fix a translation string | `src/locales/en.json` first, then all other locale files | +| Add a new interface language | `src/locales/XX.json` + `src/i18n.ts` → `locales` map + `localeNames` | +| Change how the `t()` lookup or fallback works | `src/i18n.ts` | +| Change drawing behavior (stroke, eraser, pressure, auto-expand) | `src/drawing-canvas.ts` → `DrawingCanvas` class | +| Change the ruler line spacing | `src/drawing-canvas.ts` → `export const LINE_SPACING` | +| Change how strokes are saved into / read from SVG | `src/svg-utils.ts` → `strokesToSvg()`, `svgToStrokes()` | +| Change how the SVG is converted to a PNG for OCR | `src/svg-utils.ts` → `svgToBase64Png()` | +| Change where archived SVGs go after conversion | `src/svg-utils.ts` → `archiveSvgFile()` | +| Change how the inline image preview is decorated | `src/embed.ts` → `tryDecorate()`, `decorateWikiEmbed()` | +| Add or change buttons in the portal panel overlay | `src/embed.ts` → `createPortalPanel()` | +| Change the OCR pipeline (what happens when "Convert" is clicked from the preview) | `src/embed.ts` → `runOcrPipeline()` | +| Change the drawing editor toolbar or canvas layout | `src/editor-view.ts` → `buildEditorUI()` | +| Change behavior specific to the desktop modal only | `src/editor-view.ts` → `DrawingModal` class | +| Change behavior specific to the Android tab only | `src/editor-view.ts` → `DrawingEditorView` class | +| Change the save / delete / convert logic inside the editor | `src/editor-view.ts` → `DrawingModal.doSave/doConvert/doDelete` or `DrawingEditorView.doSave/doConvert/doDelete` | +| Change which OCR model is called or the prompt sent to Gemini | `src/recognizer.ts` → `GeminiRecognizer.recognize()` | +| Change how OCR text is parsed into Markdown keywords | `src/md-parser.ts` → `parseHandwritingToMarkdown()`, `expandKeywords()` | +| Change how `//TABLE` blocks are parsed | `src/md-parser.ts` → table handling logic inside `parseHandwritingToMarkdown()` | +| Change plugin CSS (colors, sizes, layout) | `styles.css` | +| Change the plugin version | `manifest.json` + `package.json` (both must match) | +| Change the build configuration | `esbuild.config.mjs` | +| Change the deploy target path (local vault) | `deploy.sh` → `VAULT_PLUGIN` variable | +| Change the deploy target path (Google Drive / Android) | `cloudDeploy.sh` → `VAULT_PLUGIN` variable | + +--- + +### Data Flow: From Drawing to Saved SVG + +``` +User draws strokes on + │ + ▼ +DrawingCanvas (drawing-canvas.ts) + stores strokes as Stroke[] array in memory + │ + ▼ (on Save button or auto-save debounce) +saveSvgToDisk() ─── editor-view.ts (module-level helper) + │ + ▼ +strokesToSvg() ─── svg-utils.ts + builds an SVG string: + - elements for each Bézier stroke + - elements for ruler lines + - with JSON of all strokes (for re-editing) + │ + ▼ +plugin.app.vault.modify(tFile, svgString) + saves the .svg file to the vault + │ + ▼ +plugin.refreshPreview(embedId, svgString) + calls the previewCallback registered by embed.ts + │ + ▼ +embed.ts updates img.src with a cache-busting ?t=timestamp + so the inline preview refreshes without reloading the page +``` + +--- + +### Data Flow: From Drawing to Markdown (OCR) + +``` +User clicks Convert (in editor toolbar or portal panel) + │ + ▼ +runOcrPipeline() / doConvert() + │ + ├─ reads SVG content from vault + ├─ parses SVG to DOM via DOMParser + │ + ▼ +svgToBase64Png() ─── svg-utils.ts + draws SVG onto a temporary + exports as base64 PNG via canvas.toDataURL() + │ + ▼ +GeminiRecognizer.recognize(base64) ─── recognizer.ts + POST to Gemini REST API with inline_data (image) + text prompt + returns recognized text as a plain string + │ + ▼ +parseHandwritingToMarkdown(text) ─── md-parser.ts + splits text into lines + maps //keywords → Markdown syntax + returns final Markdown string + │ + ▼ +replaceInMdFile() ─── editor-view.ts (module-level helper) + reads the .md source file + finds the ![[svg]] embed line via regex + replaces it with the Markdown text + writes the .md file back to vault + │ + ▼ +archiveSvgFile() ─── svg-utils.ts + moves the .svg from _handwriting/ to _handwriting/_converted/YYYY-MM-DD_HH-MM-SS.svg +``` + +--- + +### CSS Class Naming Convention + +All plugin CSS classes use the `hwm_` prefix (short for **H**and**W**riting **M**arkdown) to avoid collisions with Obsidian's own classes or other plugins. + +Examples: `hwm_portal-panel`, `hwm_portal-btn`, `hwm_modal`, `hwm_toolbar`, `hwm-badge-mode`. + +All styles live in **`styles.css`** at the project root. There is no CSS-in-JS. + +--- + +## Maintainability Cheat Sheet + +This section is a quick reference for developers who need to extend or modify the plugin. Assumes familiarity with TypeScript and the Obsidian Plugin API. + +--- + +### How to Add a Toolbar Button + +The entire toolbar for both the desktop modal and the Android tab is built by the shared function `buildEditorUI()` in `src/editor-view.ts`. You only need to edit **one place**. + +1. **Add the i18n key** (see [How to Add a Language Key](#how-to-add-a-language-key)). +2. Inside `buildEditorUI()`, find the toolbar section and call `mkBtn(toolbar, 'icon-name', 'your_i18n_key')`. + - `mkBtn` returns the button element if you need to attach a click handler. +3. Add the click handler immediately after: `btn.addEventListener('click', () => { ... })`. + +`mkBtn(parent, icon, key)` is a module-level helper that creates a `