modifiche qos al progetto

This commit is contained in:
gabriele-cusato 2026-03-25 12:56:22 +01:00
parent cffaef0d69
commit 47efae3c04
22 changed files with 1197 additions and 769 deletions

View file

@ -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)"
]
}
}

19
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -29,10 +29,10 @@ const ICONS: Record<string, string> = {
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<string> {
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<string> {
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;
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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ファイルを開いてください"
}

View file

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

View file

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

View file

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

View file

@ -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文件"
}

View file

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

View file

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

View file

@ -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(` <line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${lineColor}" stroke-width="0.5"/>`);
@ -125,3 +126,43 @@ function unescapeXml(s: string): string {
.replace(/&lt;/g, '<')
.replace(/&amp;/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<string> {
return new Promise((resolve, reject) => {
const cvs = document.createElement('canvas');
const ctx = cvs.getContext('2d')!;
const img = new Image();
const blob = new Blob(
[new XMLSerializer().serializeToString(svgElement)],
{ type: 'image/svg+xml' }
);
const url = URL.createObjectURL(blob);
img.onload = () => {
cvs.width = img.width; cvs.height = img.height;
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(cvs.toDataURL('image/png').split(',')[1]!);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG → PNG fallito')); };
img.src = url;
});
}
// 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<void> {
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`);
}

View file

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

597
README.md
View file

@ -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 `<span class="internal-embed image-embed">`. 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 <canvas>
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:
- <path> elements for each Bézier stroke
- <line> elements for ruler lines
- <desc class="hwm-strokes"> 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 <canvas>
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 `<button>` with the Obsidian icon and the localized `title` attribute.
> **Why one place?** Before the refactor, `DrawingEditorView.buildEditor()` and `DrawingModal.buildEditor()` were two separate copies. The `buildEditorUI()` function eliminates that duplication.
---
### How to Add a Portal Panel Button
The portal panel (the floating overlay on the preview image) is built in `src/embed.ts` inside `createPortalPanel()`.
1. Add the i18n key.
2. Create a button element: `const btn = panel.createEl('button', { cls: 'hwm_portal-btn' })`.
3. Set its icon: `setIcon(btn, 'icon-name')` and tooltip: `btn.title = t('your_key', plugin)`.
4. Add the click handler.
---
### How to Add an Obsidian Command (Shortcut)
Commands are registered in `src/main.ts` inside `onload()`, using `this.addCommand({...})`.
```typescript
this.addCommand({
id: 'your-command-id',
name: 'Human readable name', // shown in Ctrl+P palette
callback: () => { /* your logic */ },
// optional: hotkeys: [{ modifiers: ['Ctrl'], key: 'K' }]
});
```
Obsidian users can reassign hotkeys in **Settings → Hotkeys**.
---
### How to Add a Ribbon Icon
Ribbon icons are registered in `src/main.ts` inside `onload()`.
```typescript
this.addRibbonIcon('icon-name', 'Tooltip text', (evt) => {
/* your logic */
});
```
Find icon names in the [Obsidian Lucide icon set](https://lucide.dev/icons/).
---
### How to Add a Language Key (i18n)
The plugin has a simple i18n system. Locale files live in `src/locales/`.
1. Add the new key to **every** locale file (`en.json`, `it.json`, `de.json`, `fr.json`, `es.json`, `ru.json`, `ja.json`, `zh-cn.json`, `pt-br.json`, `pl.json`).
Always start with `en.json` (the fallback language).
2. Use the `t('your_key', plugin)` helper wherever you need the translated string.
The `t()` function falls back to `en.json` if the key is missing in the active locale.
---
### How to Add a New Language
1. Create `src/locales/XX.json` (where `XX` is the BCP-47 code, e.g. `ko` for Korean).
2. Copy all keys from `en.json` and translate the values.
3. In `src/settings.ts`, add the language to the `UI_LANGUAGES` array:
```typescript
{ code: 'ko', label: '한국어' }
```
4. In `src/settings.ts`, update the dynamic `import()` switch inside the `loadLocale()` function (or equivalent loader) to handle the new code.
---
### How to Add a Setting
Settings are defined in `src/settings.ts`.
1. Add the new field to the `HandwritingSettings` interface and to `DEFAULT_SETTINGS`.
2. In `HandwritingSettingTab.display()`, add a `new Setting(containerEl)` block with `.setName(t(...))`, `.setDesc(t(...))`, and the appropriate control (`.addText()`, `.addToggle()`, `.addDropdown()`, etc.).
3. Save the value in the control's `onChange` callback: `this.plugin.settings.yourField = value; await this.plugin.saveSettings();`.
---
### How to Add an OCR Keyword
Keywords are parsed in `src/md-parser.ts` and documented in `src/settings.ts`.
**Rule: both files must be updated together. They must stay in sync.**
1. **`src/md-parser.ts`** — in `expandKeywords()`, add a new `case` (or `if/else`) for the new `//KEYWORD`. Return the corresponding Markdown string.
2. **`src/settings.ts`** — in the `KEYWORDS` constant (displayed in the settings table), add a new row:
```typescript
{ keyword: '//KEYWORD', syntax: '//KEYWORD text', output: 'markdown output' }
```
---
### How to Update the Plugin Version
The version is declared in two files that must be kept in sync:
- `package.json``"version"` field
- `manifest.json``"version"` field
The settings page reads the version from `plugin.manifest.version` at runtime, so no code changes are needed in TypeScript.
---
### How to Add a Third Embed Format
Currently the plugin supports two embed formats:
- **Wiki** (new default): `![[_handwriting/hw_xxx.svg]]`
- **Legacy code block**: `` ```handwriting {"id":"...", "svg":"..."}``` ``
To add a third format:
1. **Registration** — in `src/main.ts``onload()`, register a new processor (e.g. `this.registerMarkdownCodeBlockProcessor('new-format', ...)` or a new `MutationObserver` pattern).
2. **Detection** — in `src/embed.ts`, the `tryDecorate()` function checks for the wiki format. Add detection logic for your new format alongside it.
3. **Read/Write** — in `src/editor-view.ts`, the module-level helpers `wikiEmbedRegex()` / `codeBlockRegex()` and `replaceInMdFile()` handle finding and replacing the embed text in the `.md` file. Add a new regex + replacement branch for the new format. The `doSave`, `doConvert`, and `doDelete` callbacks passed to `buildEditorUI()` call these helpers — update them to try the new format as well.
4. **Backward compat** — always try the new format first, then fall back to wiki, then legacy code block (follow the existing fallback pattern in `replaceInMdFile`).
---
### Key File Map
| File | Responsibility |
|------|---------------|
| `src/main.ts` | Plugin entry point: commands, ribbon, embed registration, settings, MutationObserver |
| `src/settings.ts` | Settings interface, defaults, tab UI, i18n loader, `LIGHT_COLORS`, `DARK_COLORS`, `resolveIsDark()` |
| `src/drawing-canvas.ts` | Canvas drawing engine: Bézier strokes, eraser, undo/redo, auto-expand, `LINE_SPACING` |
| `src/svg-utils.ts` | SVG ↔ strokes serialization, `svgToBase64Png()`, `archiveSvgFile()` |
| `src/embed.ts` | Preview decoration (wiki + legacy), portal panel, OCR pipeline runner |
| `src/editor-view.ts` | `buildEditorUI()` shared builder, `DrawingEditorView` (Android tab), `DrawingModal` (desktop) |
| `src/recognizer.ts` | `IRecognizer` interface + `GeminiRecognizer` (REST call to Gemini) |
| `src/md-parser.ts` | `parseHandwritingToMarkdown()`: keyword expansion, OCR text → Markdown |
| `src/locales/*.json` | Locale strings for each supported language |