mirror of
https://github.com/gabriele-cusato/HandTranscriptMd.git
synced 2026-07-22 14:30:25 +00:00
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:
parent
791e5870c9
commit
61329f2f43
10 changed files with 111 additions and 35 deletions
29
HandTranscriptMd/eslint.config.mjs
Normal file
29
HandTranscriptMd/eslint.config.mjs
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
10
HandTranscriptMd/manifest.json
Normal file
10
HandTranscriptMd/manifest.json
Normal 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
|
||||
}
|
||||
2
HandTranscriptMd/package-lock.json
generated
2
HandTranscriptMd/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')!;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 --- */
|
||||
|
|
|
|||
Loading…
Reference in a new issue