mirror of
https://github.com/gabriele-cusato/HandTranscriptMd.git
synced 2026-07-22 06:14:06 +00:00
modifiche qos al progetto
This commit is contained in:
parent
cffaef0d69
commit
47efae3c04
22 changed files with 1197 additions and 769 deletions
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
19
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ファイルを開いてください"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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文件"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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(/</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<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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
597
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 `<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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue