fix: collapse/expand animation uses img.naturalWidth for correct height calculation

The collapse animation in the inline SVG embed was broken on Windows due to
  three compounding issues: CSS height transitions don't animate from auto→px in
  Electron; the guard direction was inverted for SVGs shorter than canvasHeight;
  and the canvas auto-widens when the modal is opened on wide screens
  (expandWorld=true), making the real SVG naturalWidth differ from
  settings.canvasWidth, causing the computed target height to be larger than the
  current rendered height and inverting the animation direction.

  Fix: use img.naturalWidth (actual SVG viewBox width) instead of
  settings.canvasWidth for the collapsed height calculation. Also moves
  touch-action from JS style.setProperty to CSS (.hwm_canvas) to resolve the
  Obsidian Required ESLint violation.
This commit is contained in:
gabriele-cusato 2026-03-28 19:03:17 +01:00
parent 791e5870c9
commit 61329f2f43
10 changed files with 111 additions and 35 deletions

View file

@ -0,0 +1,29 @@
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
import tsparser from "@typescript-eslint/parser";
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
globals: {
window: "readonly",
document: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
requestAnimationFrame: "readonly",
cancelAnimationFrame: "readonly",
performance: "readonly",
console: "readonly",
process: "readonly",
Image: "readonly",
},
},
},
]);

View file

@ -0,0 +1,10 @@
{
"id": "handwriting-to-markdown",
"name": "HandTranscriptMd",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"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

@ -12,7 +12,9 @@
"obsidian": "latest"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@types/node": "^16.11.6",
"@typescript-eslint/parser": "^8.57.2",
"esbuild": "0.25.5",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.1.9",

View file

@ -13,7 +13,9 @@
"keywords": [],
"license": "MIT",
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@types/node": "^16.11.6",
"@typescript-eslint/parser": "^8.57.2",
"esbuild": "0.25.5",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.1.9",

View file

@ -108,8 +108,7 @@ export class DrawingCanvas {
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.canvas.classList.add('hwm_canvas');
// touch-action: none sul canvas previene scroll/zoom durante il disegno
this.canvas.style.setProperty('touch-action', 'none', 'important');
// touch-action gestito in styles.css (.hwm_canvas { touch-action: none !important })
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d')!;

View file

@ -478,20 +478,22 @@ export class DrawingModal extends Modal {
await this.buildEditor();
}
async onClose() {
if (this.canvas) {
await this.saveSvg();
this.canvas.destroy();
this.canvas = null;
}
if (this.saveTimer) clearTimeout(this.saveTimer);
// Deregistra il listener bgMode
if (this.bgModeListener) {
this.plugin.bgModeListeners.delete(this.bgModeListener);
this.bgModeListener = null;
}
// Notifica il chiamante che il modal è stato chiuso
this.onClosed?.();
onClose() {
void (async () => {
if (this.canvas) {
await this.saveSvg();
this.canvas.destroy();
this.canvas = null;
}
if (this.saveTimer) clearTimeout(this.saveTimer);
// Deregistra il listener bgMode
if (this.bgModeListener) {
this.plugin.bgModeListeners.delete(this.bgModeListener);
this.bgModeListener = null;
}
// Notifica il chiamante che il modal è stato chiuso
this.onClosed?.();
})();
}
private async buildEditor() {

View file

@ -72,7 +72,9 @@ export function registerEmbed(plugin: HandwritingPlugin) {
plugin.refreshPreview(embedId, newSvg);
}
};
// eslint-disable-next-line @typescript-eslint/no-misused-promises
plugin.bgModeListeners.add(onBgModeRemap);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
plugin.register(() => plugin.bgModeListeners.delete(onBgModeRemap));
// --- NUOVO: MutationObserver su document.body ---
@ -145,14 +147,18 @@ function setupMutationObserver(plugin: HandwritingPlugin) {
// Aggiorna l'altezza del wrapper (se espanso) dopo ogni refresh dell'img.
// Su Android, CM6 Live Preview non rileva i cambi organici di altezza.
const syncWrapperHeight = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const wrapper = span.querySelector('.hwm_clip-wrapper') as HTMLElement | null;
if (!wrapper || wrapper.classList.contains('hwm_overflow-hidden')) return;
const newH = wrapper.scrollHeight;
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
wrapper.style.transition = 'none';
wrapper.style.height = newH + 'px';
requestAnimationFrame(() => {
wrapper!.style.height = '';
wrapper!.style.transition = '';
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
wrapper.style.height = '';
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
wrapper.style.transition = '';
});
};
@ -652,7 +658,7 @@ function createPortalPanel(
state: { id: embedId, svg: svgPath, sourcePath },
active: true,
});
plugin.app.workspace.revealLeaf(leaf);
void plugin.app.workspace.revealLeaf(leaf);
}
})(); });
@ -685,6 +691,7 @@ function createPortalPanel(
// Animiamo il wrapper (non il container span) così il ResizeObserver
// di Obsidian Mobile non si attiva sul container e non re-imposta le dimensioni dell'img.
const ensureWrapper = (): HTMLElement | null => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
let wrapper = container.querySelector('.hwm_clip-wrapper') as HTMLElement | null;
if (wrapper) return wrapper;
const img = container.querySelector('img');
@ -710,8 +717,9 @@ function createPortalPanel(
const cleanup = () => {
if (done) return;
done = true;
wrapper!.style.height = '';
wrapper!.classList.remove('hwm_overflow-hidden');
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
wrapper.style.height = '';
wrapper.classList.remove('hwm_overflow-hidden');
};
wrapper.addEventListener('transitionend', cleanup, { once: true });
setTimeout(cleanup, 400); // 300ms transizione + 100ms buffer
@ -726,17 +734,35 @@ function createPortalPanel(
const wrapper = ensureWrapper();
if (wrapper) {
const img = container.querySelector('img');
// Altezza compressa = altezza naturale dell'img per un canvas non espanso,
// calcolata dall'aspect ratio reale (canvasHeight / canvasWidth × larghezza img).
// Varia in base alla larghezza del container nello schermo corrente,
// ma è coerente sullo stesso dispositivo.
const naturalH = img && img.clientWidth > 0
? Math.round(plugin.settings.canvasHeight * img.clientWidth / plugin.settings.canvasWidth)
// Guard: controlla se il viewBox SVG è stato espanso rispetto alle impostazioni.
// img.naturalHeight = altezza intrinseca del viewBox (in unità SVG), indipendente
// dalla larghezza del pannello. È > settings.canvasHeight solo se il canvas
// è stato auto-espanso durante il disegno.
// Target collapse: altezza che l'img avrebbe se il viewBox fosse alto canvasHeight.
// Usa img.naturalWidth (larghezza reale del SVG) invece di settings.canvasWidth
// perché il canvas può auto-allargarsi aprendo il modal su schermi larghi.
const naturalH = img && img.naturalWidth > 0
? Math.round(plugin.settings.canvasHeight * img.clientWidth / img.naturalWidth)
: plugin.settings.canvasHeight;
wrapper.style.height = wrapper.scrollHeight + 'px';
const startH = wrapper.scrollHeight;
wrapper.classList.add('hwm_overflow-hidden');
void wrapper.offsetHeight; // forza reflow: il browser registra il "from"
wrapper.style.height = naturalH + 'px';
if (startH <= naturalH) {
// SVG non espanso: altezza già nella norma, nessuna area vuota da tagliare.
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
wrapper.style.height = startH + 'px';
} else {
// SVG auto-espanso: anima da startH → naturalH per nascondere l'area vuota.
// WAAPI garantisce keyframe px→px senza dipendere da height:auto come "from".
const anim = wrapper.animate(
[{ height: startH + 'px' }, { height: naturalH + 'px' }],
{ duration: 300, easing: 'ease', fill: 'forwards' }
);
anim.onfinish = () => {
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
wrapper.style.height = naturalH + 'px';
anim.cancel(); // cede il controllo all'inline style
};
}
}
container.classList.add('hwm_is-collapsed');
collapseBtn.classList.add('hwm_rotated');
@ -888,7 +914,7 @@ function createLegacyPortalButton(
state: { id: embedId, svg: svgPath, sourcePath },
active: true,
});
plugin.app.workspace.revealLeaf(leaf);
void plugin.app.workspace.revealLeaf(leaf);
})(); });
// RAF loop: aggiorna posizione e visibilità via CSS var (--hwm-top, --hwm-left)

View file

@ -17,7 +17,8 @@
export function normalizeMarkdownSymbols(rawText: string): string {
// Rimuove BOM e caratteri zero-width Unicode che Gemini inserisce all'inizio
// del testo o all'inizio di righe (U+FEFF, U+200B, U+200C, U+200D, U+2060)
const cleaned = rawText.replace(/[\uFEFF\u200B\u200C\u200D\u2060]/g, '');
// eslint-disable-next-line no-misleading-character-class
const cleaned = rawText.replace(/[\uFEFF\u200B\u200C\u200D\u2060]/gu, '');
const lines = cleaned.split('\n');
const out: string[] = [];
@ -361,7 +362,7 @@ export function expandKeywords(text: string, fnStart = 1): string {
while (i < lines.length) {
let rowLine = lines[i].trim();
// Chiusura esplicita //TABLE
if (/^\/\/TABLE/i.test(rowLine)) { i++; break; }
if (/^\/\/TABLE/i.test(rowLine)) { i++; break; }
// Qualsiasi altra keyword chiude implicitamente senza consumarla
if (/^\/\//.test(rowLine)) break;
// Fine implicita: riga vuota

View file

@ -121,15 +121,16 @@ export class HandwritingSettingTab extends PluginSettingTab {
// Prima voce: automatico
drop.addOption('auto', t('ui_language_auto'));
// Una voce per ogni lingua disponibile nel plugin, con nome nativo
// eslint-disable-next-line @typescript-eslint/no-misused-promises
availableLocales().forEach(code => drop.addOption(code, localeNames[code] ?? code));
drop.setValue(this.plugin.settings.uiLanguage);
drop.onChange(async (value) => {
drop.onChange((value) => { void (async () => {
this.plugin.settings.uiLanguage = value;
await this.plugin.saveSettings();
// Aggiorna il dizionario attivo e ridisegna la pagina impostazioni
setLocale(value);
this.display();
});
})(); });
});
// --- Cartella SVG ---
@ -194,6 +195,7 @@ export class HandwritingSettingTab extends PluginSettingTab {
.setDesc(t('gemini_key_desc'))
.addText(text => {
text
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setPlaceholder('AIza...')
.setValue(this.plugin.settings.geminiApiKey)
.onChange(async (value) => {
@ -211,6 +213,7 @@ export class HandwritingSettingTab extends PluginSettingTab {
.addText(text => text
// Mostra l'array come stringa "it, en"
.setValue(this.plugin.settings.ocrLanguages.join(', '))
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setPlaceholder('it, en')
.onChange(async (value) => {
// Parsa la stringa in array, rimuovendo spazi e voci vuote

View file

@ -251,6 +251,8 @@
cursor: crosshair;
/* Sfondo bianco fisso */
background: #ffffff;
/* Previene scroll/zoom del browser durante il disegno con pennino/dito */
touch-action: none !important;
}
/* --- Handle di resize in basso --- */