gabriele-cusato_HandTranscr.../HandTranscriptMd/src/embed.ts

840 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =============================================
Embed — Processor per blocchi handwriting
Supporta due formati:
1. NUOVO: ![[_handwriting/hw_xxx.svg]]
- Visibile anche senza plugin (immagine SVG nativa)
- Gestito da registerMarkdownPostProcessor
2. LEGACY: ```handwriting { "id": ..., "svg": ... } ```
- Formato originale, mantenuto per compatibilità
- Gestito da registerMarkdownCodeBlockProcessor
============================================= */
import {
MarkdownPostProcessorContext,
MarkdownView,
TFile,
Notice,
Platform,
} from 'obsidian';
// Icone SVG inline (stile Lucide 24×24)
const ICONS: Record<string, string> = {
'file-text': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`,
'x': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
'chevron-up': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>`,
'pencil': `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>`,
};
import type HandwritingPlugin from './main';
import { Stroke } from './drawing-canvas';
import { strokesToSvg, parseSvgStrokes, generateId } from './svg-utils';
import { getEffectiveBgColor, getEffectiveLineColor, remapStrokeColor, BgMode } from './settings';
import { getRecognizer } from './recognizer';
import { parseMarkdown } from './md-parser';
import { VIEW_TYPE_HANDWRITING, DrawingEditorView, DrawingModal } from './editor-view';
// Dati JSON salvati dentro il code block ```handwriting (formato legacy)
interface EmbedData {
id: string;
svg: string; // percorso relativo al file SVG nel vault
}
/* =============================================
Registrazione di entrambi i processor
============================================= */
export function registerEmbed(plugin: HandwritingPlugin) {
// --- Listener globale: remap automatico colori SVG al cambio bgMode ---
// Quando l'utente cambia sfondo canvas nelle impostazioni, tutti gli SVG del plugin
// attualmente tracciati in embedPaths vengono letti, rimappati e risalvati nel vault.
// Identifica gli SVG del plugin tramite <desc class="hwm-strokes"> — gli SVG
// dell'utente non hanno questo tag e vengono ignorati.
const onBgModeRemap = async (bgMode: string) => {
for (const [embedId, svgPath] of plugin.embedPaths) {
const file = plugin.app.vault.getAbstractFileByPath(svgPath);
if (!(file instanceof TFile)) continue;
const content = await plugin.app.vault.read(file);
// Salta SVG non creati dal plugin (assenza del tag hwm-strokes)
if (!content.includes('hwm-strokes')) continue;
const strokes = parseSvgStrokes(content);
// Rimappa i colori dei tratti al nuovo tema
const remapped = strokes.map(s => ({
...s, color: remapStrokeColor(s.color, bgMode as BgMode)
}));
// Legge dimensioni reali dal viewBox per preservare l'altezza raggiunta con auto-expand.
// Usare plugin.settings.canvasHeight causerebbe il "collasso" degli SVG cresciuti.
const dimMatch = content.match(/viewBox="0 0 (\d+) (\d+)"/);
const svgWidth = dimMatch ? parseInt(dimMatch[1]!) : plugin.settings.canvasWidth;
const svgHeight = dimMatch ? parseInt(dimMatch[2]!) : plugin.settings.canvasHeight;
const newSvg = strokesToSvg(
remapped,
svgWidth,
svgHeight,
getEffectiveBgColor(plugin.settings),
getEffectiveLineColor(plugin.settings)
);
await plugin.app.vault.modify(file, newSvg);
// Aggiorna l'<img> nella preview inline con cache-bust
plugin.refreshPreview(embedId, newSvg);
}
};
plugin.bgModeListeners.add(onBgModeRemap);
plugin.register(() => plugin.bgModeListeners.delete(onBgModeRemap));
// --- NUOVO: MutationObserver su document.body ---
// Intercetta gli span .internal-embed con src _handwriting/ non appena
// appaiono nel DOM — funziona sia in reading view che in live preview
// (dove il post-processor non viene chiamato sui widget CM6 delle immagini).
setupMutationObserver(plugin);
// --- LEGACY: code block processor per ```handwriting {...} ``` ---
// Mantenuto per compatibilità con i blocchi esistenti nel vault.
plugin.registerMarkdownCodeBlockProcessor(
'handwriting',
async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
await renderLegacyEmbed(source, el, ctx, plugin);
}
);
}
/* =============================================
NUOVO FORMATO — MutationObserver per ![[svg]]
============================================= */
// Registra un MutationObserver su document.body che intercetta
// gli span .internal-embed con src _handwriting/ nel momento in cui
// appaiono nel DOM. Funziona sia in reading view che in live preview
// (in live preview il post-processor non viene chiamato sui widget CM6).
function setupMutationObserver(plugin: HandwritingPlugin) {
const tryDecorate = (span: HTMLElement) => {
// Salta se già decorato (flag data attribute — più affidabile del parent check)
if (span.dataset.hwmDecorated === '1') return;
const svgPath = span.getAttribute('src') ?? '';
if (!svgPath.includes('_handwriting/') || !svgPath.endsWith('.svg')) return;
const filename = svgPath.split('/').pop() ?? '';
const embedId = filename.replace('.svg', '');
if (!embedId.startsWith('hw_')) return;
// Se Obsidian non ha ancora caricato l'immagine (classe image-embed
// assente), riprova tra 150 ms — il caricamento è asincrono.
if (!span.classList.contains('image-embed')) {
setTimeout(() => tryDecorate(span), 150);
return;
}
// Marca subito come decorato per evitare doppia elaborazione
span.dataset.hwmDecorated = '1';
// TEST A: pointer-events: none sullo span.
// Ipotesi: Chrome usa lo stesso hit-test dei pointer events per la
// proximity detection dell'handwriting → con none, ignora lo span e
// trova il cm-content[ce=true] sottostante → handwriting torna attivo.
span.style.pointerEvents = 'none';
// NESSUNA modifica allo span dentro cm-content.
// Lo lasciamo identico a un'immagine normale: nessuna classe extra,
// nessun figlio aggiunto. Questo evita di rompere l'handwriting Android
// (il contenteditable="false" + figli extra confondono il hit-test di Chrome).
// Tutti i bottoni vivono in document.body via pannello portale.
const sourcePath = resolveSourcePath(span, plugin);
// Callback refresh: aggiorna l'<img> dopo il salvataggio dalla tab editor.
// Usa cache-bust URL (non data-URI) per non rompere l'handwriting Android.
plugin.previewCallbacks.set(embedId, () => {
if (!span.isConnected) return;
const img = span.querySelector('img');
if (!img) return;
const base = img.src.split('?')[0]!;
img.src = base + '?t=' + Date.now();
});
// Pannello portale con tutti i bottoni, in document.body (fuori da cm-content)
createPortalPanel(span, embedId, svgPath, sourcePath, plugin);
};
const observer = new MutationObserver((mutations) => {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
// Il nodo stesso potrebbe essere l'embed, oppure contenerlo
if (node.classList.contains('internal-embed') &&
node.getAttribute('src')?.includes('_handwriting/')) {
tryDecorate(node);
} else {
node.querySelectorAll<HTMLElement>('.internal-embed[src*="_handwriting/"]')
.forEach(tryDecorate);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Disconnette l'observer quando il plugin viene disabilitato
plugin.register(() => observer.disconnect());
}
// Risale il DOM per trovare il leaf markdown che contiene `el`,
// e restituisce il path del file aperto in quel leaf.
function resolveSourcePath(el: HTMLElement, plugin: HandwritingPlugin): string {
const leaves = plugin.app.workspace.getLeavesOfType('markdown');
for (const leaf of leaves) {
const contentEl = (leaf.view as unknown as { contentEl: HTMLElement }).contentEl;
if (contentEl?.contains(el)) {
return (leaf.view as unknown as { file?: { path: string } }).file?.path ?? '';
}
}
// Fallback: file attualmente attivo
return plugin.app.workspace.getActiveFile()?.path ?? '';
}
/* =============================================
LEGACY — Code block processor
============================================= */
// Gestisce il vecchio formato ```handwriting {...}```
// Parsa il JSON, mostra la preview SVG con bottoni.
async function renderLegacyEmbed(
source: string,
el: HTMLElement,
ctx: MarkdownPostProcessorContext,
plugin: HandwritingPlugin
) {
// Parsa il JSON dal code block
let data: EmbedData;
try {
data = JSON.parse(source.trim());
} catch {
el.createEl('p', { text: 'Handwriting: JSON non valido', cls: 'hwm_error' });
return;
}
// Rimuove contenteditable="false" dai wrapper CM6 per ridurre
// l'impatto sull'handwriting Android (non risolve il problema ma
// è la mitigazione che avevamo già in precedenza).
stripContentEditableFalse(el);
// Container principale
const container = el.createDiv({ cls: 'hwm_container' });
// Carica SVG esistente
const { strokes, svgContent } = await loadSvgData(data.svg, plugin);
// Mostra la preview con i bottoni
showLegacyPreview(container, strokes, svgContent, data, ctx, plugin);
}
// Renderizza la preview SVG (formato legacy) con i 3 bottoni inline.
function showLegacyPreview(
container: HTMLElement,
strokes: Stroke[],
svgContent: string | null,
data: EmbedData,
ctx: MarkdownPostProcessorContext,
plugin: HandwritingPlugin
) {
container.empty();
const isDark = plugin.settings.bgMode === 'dark';
const bgColor = getEffectiveBgColor(plugin.settings);
container.style.backgroundColor = bgColor;
const collapsedHeight = plugin.settings.canvasHeight;
// --- 3 bottoni inline ---
const btnBar = container.createDiv({ cls: 'hwm_inline-buttons' });
if (isDark) btnBar.classList.add('hwm_inline-buttons--dark');
const deleteBtn = createBtn(btnBar, 'x', 'Elimina riquadro');
deleteBtn.classList.add('hwm_delete-btn');
const convertBtn = createBtn(btnBar, 'file-text', 'Converti in Markdown');
convertBtn.classList.add('hwm_convert-btn');
const collapseBtn = createBtn(btnBar, 'chevron-up', 'Comprimi');
collapseBtn.classList.add('hwm_collapse-btn');
// --- Preview SVG via CSS background-image (nessun <img> dentro cm-content) ---
const preview = container.createDiv({ cls: 'hwm_inline-preview' });
let isExpanded = true;
let currentSvgContent = svgContent;
let currentStrokes = strokes;
renderPreviewContent(preview, currentSvgContent);
// Callback refresh dalla tab editor
plugin.previewCallbacks.set(data.id, (newSvgContent) => {
if (!preview.isConnected) return;
currentSvgContent = newSvgContent;
currentStrokes = parseSvgStrokes(newSvgContent);
renderPreviewContent(preview, newSvgContent);
});
// Collapse/Expand
collapseBtn.addEventListener('click', (e) => {
e.stopPropagation();
isExpanded = !isExpanded;
if (isExpanded) {
preview.classList.remove('hwm_collapsed');
preview.style.maxHeight = '';
collapseBtn.classList.remove('hwm_rotated');
collapseBtn.title = 'Comprimi';
} else {
preview.classList.add('hwm_collapsed');
preview.style.maxHeight = collapsedHeight + 'px';
collapseBtn.classList.add('hwm_rotated');
collapseBtn.title = 'Espandi';
}
});
// Bottone matita portale (fuori da cm-content)
createLegacyPortalButton(container, plugin.app, plugin, data.id, data.svg, ctx.sourcePath);
// Converti
convertBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!currentSvgContent || currentStrokes.length === 0) {
new Notice('Nessun tratto da convertire');
return;
}
await doConvert(currentSvgContent, data, ctx, plugin);
});
// Elimina
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('Eliminare questo riquadro handwriting e il file SVG associato?')) return;
await removeLegacyEmbed(ctx, data, plugin);
});
}
// Renderizza l'SVG come CSS background-image su un <div> (legacy).
// Evita <img> dentro cm-content che possono influenzare l'handwriting Android.
function renderPreviewContent(preview: HTMLElement, svgContent: string | null) {
preview.empty();
if (svgContent) {
const div = preview.createDiv({ cls: 'hwm_preview-bg' });
div.style.backgroundImage = `url('data:image/svg+xml,${encodeURIComponent(svgContent)}')`;
// Calcola aspect ratio dal viewBox
const m = svgContent.match(/viewBox="0 0 (\d+) (\d+)"/);
const svgW = m ? parseInt(m[1]!) : 800;
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' });
}
}
/* =============================================
Conversione OCR — Nuovo formato wiki
============================================= */
async function doConvertWiki(
svgContent: string,
svgPath: string,
sourcePath: string,
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);
await replaceWikiEmbedWithMarkdown(svgPath, markdown, sourcePath, plugin);
new Notice('Conversione completata!');
}
/* =============================================
Conversione OCR — Legacy (code block)
============================================= */
async function doConvert(
svgContent: string,
data: EmbedData,
ctx: MarkdownPostProcessorContext,
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);
await replaceEmbedWithMarkdown(ctx, data, markdown, plugin);
new Notice('Conversione completata!');
} catch (e: unknown) {
new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e)));
}
}
/* =============================================
File I/O
============================================= */
// Carica il file SVG e ne estrae tratti e contenuto grezzo
async function loadSvgData(
svgPath: string,
plugin: HandwritingPlugin
): Promise<{ strokes: Stroke[]; svgContent: string | null }> {
const file = plugin.app.vault.getAbstractFileByPath(svgPath);
if (file instanceof TFile) {
const content = await plugin.app.vault.read(file);
const strokes = parseSvgStrokes(content);
return { strokes, svgContent: content };
}
return { strokes: [], svgContent: null };
}
/* =============================================
Regex per i due formati nel file .md
============================================= */
// Regex per trovare ![[svgPath]] nel file .md
function wikiEmbedRegex(svgPath: string): RegExp {
const escaped = escapeRegex(svgPath);
return new RegExp(`\\n?!\\[\\[${escaped}\\]\\]\\n?`);
}
// Regex per trovare il code block legacy con l'id specifico
function codeBlockRegex(embedId: string): RegExp {
const escaped = escapeRegex(embedId);
return new RegExp(
'\\n?```handwriting\\n.*?"id"\\s*:\\s*"' + escaped + '".*?\\n```\\n?', 's'
);
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/* =============================================
Elimina embed
============================================= */
// Rimuove ![[svgPath]] dal .md e cancella il file SVG
async function removeWikiEmbed(
svgPath: string,
sourcePath: string,
plugin: HandwritingPlugin
) {
const mdFile = plugin.app.vault.getAbstractFileByPath(sourcePath);
if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; }
const content = await plugin.app.vault.read(mdFile);
const updated = content.replace(wikiEmbedRegex(svgPath), '\n');
if (updated !== content) await plugin.app.vault.modify(mdFile, updated);
// Cancella il file SVG
const svgFile = plugin.app.vault.getAbstractFileByPath(svgPath);
if (svgFile instanceof TFile) await plugin.app.vault.delete(svgFile);
new Notice('Riquadro eliminato');
}
// Rimuove il code block legacy dal .md e cancella il file SVG
async function removeLegacyEmbed(
ctx: MarkdownPostProcessorContext,
data: EmbedData,
plugin: HandwritingPlugin
) {
const mdFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; }
const content = await plugin.app.vault.read(mdFile);
const updated = content.replace(codeBlockRegex(data.id), '\n');
if (updated !== content) await plugin.app.vault.modify(mdFile, updated);
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`);
}
/* =============================================
Sostituisce embed con markdown (conversione OCR)
============================================= */
async function replaceWikiEmbedWithMarkdown(
svgPath: string,
markdown: string,
sourcePath: string,
plugin: HandwritingPlugin
) {
const mdFile = plugin.app.vault.getAbstractFileByPath(sourcePath);
if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; }
const content = await plugin.app.vault.read(mdFile);
const updated = content.replace(wikiEmbedRegex(svgPath), '\n' + markdown + '\n');
if (updated !== content) await plugin.app.vault.modify(mdFile, updated);
}
async function replaceEmbedWithMarkdown(
ctx: MarkdownPostProcessorContext,
data: EmbedData,
markdown: string,
plugin: HandwritingPlugin
) {
const mdFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (!(mdFile instanceof TFile)) { new Notice('File markdown non trovato'); return; }
const content = await plugin.app.vault.read(mdFile);
const updated = content.replace(codeBlockRegex(data.id), '\n' + markdown + '\n');
if (updated !== content) await plugin.app.vault.modify(mdFile, updated);
}
/* =============================================
Comando: inserisce un nuovo blocco handwriting
Usa il NUOVO formato ![[svg]]
============================================= */
export async function insertHandwritingBlock(plugin: HandwritingPlugin) {
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('Apri un file markdown prima');
return;
}
const editor = view.editor;
const id = generateId();
const svgPath = `${plugin.settings.svgFolder}/${id}.svg`;
// Crea il file SVG vuoto PRIMA di inserire il wikilink nel markdown.
// Se il file non esiste quando Obsidian processa ![[svg]], mostra
// "could not be found" e non renderizza image-embed → il post-processor
// non trova nulla da decorare e i bottoni non appaiono.
const bgColor = getEffectiveBgColor(plugin.settings);
const lineColor = getEffectiveLineColor(plugin.settings);
const emptySvg = strokesToSvg([], plugin.settings.canvasWidth, plugin.settings.canvasHeight, bgColor, lineColor);
const folder = plugin.settings.svgFolder;
if (!plugin.app.vault.getAbstractFileByPath(folder)) {
await plugin.app.vault.createFolder(folder);
}
await plugin.app.vault.create(svgPath, emptySvg);
// Inserisce il wikilink: Obsidian trova subito il file → lo renderizza come immagine
editor.replaceSelection(`\n![[${svgPath}]]\n`);
}
/* =============================================
Helpers
============================================= */
// Risale il DOM da `el` fino a cm-content e rimuove contenteditable="false"
// dai wrapper CM6 (mitigazione parziale del bug handwriting Android).
function stripContentEditableFalse(el: HTMLElement) {
let node: HTMLElement | null = el;
while (node) {
if (node.classList.contains('cm-content')) break;
if (node.getAttribute('contenteditable') === 'false') {
node.removeAttribute('contenteditable');
}
node.style.setProperty('pointer-events', 'none');
node = node.parentElement;
}
}
/* ---------- Pannello portale (fuori da cm-content) — Nuovo formato wiki ---------- */
// Crea un pannello floating in document.body con tutti i bottoni di controllo.
// Essendo fuori da cm-content, non interferisce con l'handwriting Android:
// lo span internal-embed rimane identico a un'immagine normale (nessun figlio aggiunto).
function createPortalPanel(
container: HTMLElement,
embedId: string,
svgPath: string,
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;
// Flag per nascondere il pannello quando il modal (Desktop) è aperto
let modalOpen = false;
// Lo span diventa position: relative per ancorarvi il pannello (position: absolute).
container.style.position = 'relative';
const panel = document.createElement('div');
panel.className = 'hwm_portal-panel';
if (isDark) panel.classList.add('hwm_portal-panel--dark');
container.appendChild(panel);
// Rimuove il pannello dal DOM quando il plugin viene disabilitato
plugin.register(() => panel.remove());
// Traccia embedId → svgPath per il remap automatico al cambio bgMode
plugin.embedPaths.set(embedId, svgPath);
// --- Bottone matita ---
// Desktop: apre DrawingModal (overlay fullscreen, senza aprire nuova tab)
// Mobile: apre DrawingEditorView in una nuova tab
const pencilBtn = createPanelBtn(panel, 'pencil', 'Apri editor disegno');
pencilBtn.addEventListener('click', async () => {
if (Platform.isDesktop) {
if (modalOpen) return;
modalOpen = true;
// Nasconde il pannello mentre il modal è aperto (altrimenti galleggerebbe sul canvas)
panel.style.display = 'none';
const modal = new DrawingModal(plugin.app, plugin, embedId, svgPath, sourcePath);
modal.onClosed = () => {
modalOpen = false;
if (container.isConnected) panel.style.display = '';
};
modal.open();
} else {
// Mobile: nuova tab (DrawingEditorView è fuori da cm-content)
const leaves = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING);
const existing = leaves.find(l => (l.view as DrawingEditorView).getEmbedId() === embedId);
if (existing) {
plugin.app.workspace.setActiveLeaf(existing, { focus: true });
return;
}
const leaf = plugin.app.workspace.getLeaf('tab');
await leaf.setViewState({
type: VIEW_TYPE_HANDWRITING,
state: { id: embedId, svg: svgPath, sourcePath },
active: true,
});
plugin.app.workspace.revealLeaf(leaf);
}
});
// Separatore visivo
const sep = document.createElement('div');
sep.className = 'hwm_separator';
panel.appendChild(sep);
// --- Bottone converti in Markdown ---
const convertBtn = createPanelBtn(panel, 'file-text', 'Converti in Markdown');
// --- Bottone comprimi/espandi ---
// Usa height + overflow:hidden sul container (non max-height sull'<img>):
// così l'immagine viene ritagliata verticalmente senza che la larghezza cambi.
// Il pannello (position:absolute, top:6px) resta dentro l'area visibile
// anche da compresso (collapsedHeight è sempre >> 6px + altezza pannello).
const collapseBtn = createPanelBtn(panel, 'chevron-up', 'Comprimi');
collapseBtn.classList.add('hwm_collapse-btn');
// --- Bottone elimina ---
const deleteBtn = createPanelBtn(panel, 'x', 'Elimina riquadro');
deleteBtn.classList.add('hwm_delete-btn');
deleteBtn.addEventListener('click', async () => {
if (!confirm('Eliminare questo riquadro handwriting e il file SVG associato?')) return;
await removeWikiEmbed(svgPath, sourcePath, plugin);
});
// --- Funzioni condivise: usate dai bottoni e dal menu globale (⋮ Obsidian) ---
const doExpand = () => {
isExpanded = true;
container.style.height = '';
container.style.overflow = '';
collapseBtn.classList.remove('hwm_rotated');
collapseBtn.title = 'Comprimi';
};
const doCollapse = () => {
isExpanded = false;
container.style.height = collapsedHeight + 'px';
container.style.overflow = 'hidden';
collapseBtn.classList.add('hwm_rotated');
collapseBtn.title = 'Espandi';
};
// Carica SVG e chiama doConvertWiki (che lancia eccezione in caso di errore)
const doConvertAction = async () => {
const { strokes, svgContent } = await loadSvgData(svgPath, plugin);
if (!svgContent || strokes.length === 0) throw new Error('Nessun tratto da convertire');
await doConvertWiki(svgContent, svgPath, sourcePath, plugin);
};
collapseBtn.addEventListener('click', () => {
if (isExpanded) doCollapse(); else doExpand();
});
convertBtn.addEventListener('click', async () => {
// Bottone singolo: mostra Notice in caso di errore senza propagare
try { await doConvertAction(); }
catch (e: unknown) { new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e))); }
});
// Registra le azioni nel plugin per il menu "⋮ Espandi/Collassa/Converti tutti"
plugin.embedActions.set(embedId, { expand: doExpand, collapse: doCollapse, convert: doConvertAction, container, sourcePath });
plugin.register(() => plugin.embedActions.delete(embedId));
// Layout-change: su Mobile nasconde il pannello quando la tab editor è aperta
const onLayoutChange = () => {
if (!container.isConnected) {
plugin.app.workspace.off('layout-change', onLayoutChange);
plugin.embedPaths.delete(embedId);
return;
}
if (Platform.isMobile) {
const tabOpen = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING)
.some(l => (l.view as DrawingEditorView).getEmbedId() === embedId);
panel.style.display = tabOpen ? 'none' : '';
}
};
plugin.app.workspace.on('layout-change', onLayoutChange);
plugin.register(() => plugin.app.workspace.off('layout-change', onLayoutChange));
// BgMode listener: aggiorna la classe dark sul pannello al cambio tema
const onBgMode = (bgMode: string) => {
if (!container.isConnected) {
plugin.bgModeListeners.delete(onBgMode);
return;
}
panel.classList.toggle('hwm_portal-panel--dark', resolveIsDark(bgMode));
};
plugin.bgModeListeners.add(onBgMode);
plugin.register(() => plugin.bgModeListeners.delete(onBgMode));
}
// Crea un bottone div nel pannello portale
function createPanelBtn(parent: HTMLElement, icon: string, title: string): HTMLElement {
const btn = document.createElement('div');
btn.className = 'hwm_btn';
btn.setAttribute('title', title);
btn.setAttribute('role', 'button');
btn.setAttribute('tabindex', '0');
btn.innerHTML = ICONS[icon] ?? '';
parent.appendChild(btn);
return btn;
}
/* ---------- Bottone portale (fuori da cm-content) — Formato legacy ---------- */
// Crea un singolo <button> in document.body per aprire la tab editor.
// Solo per il vecchio formato ```handwriting``` (il container legacy non è in cm-content,
// quindi i bottoni inline sono già sicuri; questo aggiunge solo la matita portale).
function createLegacyPortalButton(
container: HTMLElement,
app: import('obsidian').App,
plugin: HandwritingPlugin,
embedId: string,
svgPath: string,
sourcePath: string
) {
const btn = document.createElement('button');
btn.className = 'hwm_portal-btn';
btn.innerHTML = ICONS['pencil'] ?? '';
btn.title = 'Apri editor disegno';
document.body.appendChild(btn);
// Apre la tab editor al click
btn.addEventListener('click', async () => {
const leaves = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING);
const existing = leaves.find(l => (l.view as DrawingEditorView).getEmbedId() === embedId);
if (existing) {
plugin.app.workspace.setActiveLeaf(existing, { focus: true });
return;
}
const leaf = plugin.app.workspace.getLeaf('tab');
await leaf.setViewState({
type: VIEW_TYPE_HANDWRITING,
state: { id: embedId, svg: svgPath, sourcePath },
active: true,
});
plugin.app.workspace.revealLeaf(leaf);
});
// RAF loop: aggiorna posizione e visibilità
const update = () => {
if (!container.isConnected) {
btn.remove();
return;
}
const rect = container.getBoundingClientRect();
btn.style.top = (rect.top + 6) + 'px';
btn.style.left = (rect.right - 44) + 'px';
const editorOpen = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING)
.some(l => (l.view as DrawingEditorView).getEmbedId() === embedId);
const inViewport = rect.width > 0 && rect.top < window.innerHeight && rect.bottom > 0;
btn.style.display = (inViewport && !editorOpen) ? 'flex' : 'none';
requestAnimationFrame(update);
};
requestAnimationFrame(update);
}
// Usa <div> invece di <button> per i bottoni dentro cm-content.
// I <button> su Android Mobile possono interferire con l'handwriting.
function createBtn(parent: HTMLElement, icon: string, title: string): HTMLElement {
const btn = parent.createDiv({ cls: 'hwm_btn', attr: { title, role: 'button', tabindex: '0' } });
btn.innerHTML = ICONS[icon] ?? '';
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;
});
}