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

748 lines
28 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,
} 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 } from './settings';
import { getRecognizer } from './recognizer';
import { parseMarkdown } from './md-parser';
import { VIEW_TYPE_HANDWRITING, DrawingEditorView } 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) {
// --- 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';
// 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
) {
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);
await archiveSvgByPath(svgPath, plugin);
await replaceWikiEmbedWithMarkdown(svgPath, markdown, sourcePath, plugin);
new Notice('Conversione completata!');
} catch (e: unknown) {
new Notice('Errore OCR: ' + (e instanceof Error ? e.message : String(e)));
}
}
/* =============================================
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);
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
) {
const isDark = plugin.settings.bgMode === 'dark';
const collapsedHeight = plugin.settings.canvasHeight;
let isExpanded = true;
// Pannello floating con sfondo e ombra, posizionato via position: fixed
const panel = document.createElement('div');
panel.className = 'hwm_portal-panel';
if (isDark) panel.classList.add('hwm_toolbar--dark');
document.body.appendChild(panel);
// --- Bottone matita: apre la tab editor ---
const pencilBtn = createPanelBtn(panel, 'pencil', 'Apri editor disegno');
pencilBtn.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);
});
// 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');
convertBtn.addEventListener('click', async () => {
const { strokes, svgContent } = await loadSvgData(svgPath, plugin);
if (!svgContent || strokes.length === 0) {
new Notice('Nessun tratto da convertire');
return;
}
await doConvertWiki(svgContent, svgPath, sourcePath, plugin);
});
// --- Bottone comprimi/espandi ---
const collapseBtn = createPanelBtn(panel, 'chevron-up', 'Comprimi');
collapseBtn.classList.add('hwm_collapse-btn');
collapseBtn.addEventListener('click', () => {
isExpanded = !isExpanded;
if (isExpanded) {
container.style.maxHeight = '';
container.style.overflow = '';
collapseBtn.classList.remove('hwm_rotated');
collapseBtn.title = 'Comprimi';
} else {
container.style.maxHeight = collapsedHeight + 'px';
container.style.overflow = 'hidden';
collapseBtn.classList.add('hwm_rotated');
collapseBtn.title = 'Espandi';
}
});
// --- 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);
});
// RAF loop: aggiorna posizione e visibilità.
// Si auto-ferma quando il container lascia il DOM.
const update = () => {
if (!container.isConnected) {
panel.remove();
return;
}
const rect = container.getBoundingClientRect();
const inViewport = rect.width > 0 && rect.top < window.innerHeight && rect.bottom > 0;
panel.style.display = inViewport ? 'flex' : 'none';
if (inViewport) {
// Posizionato in alto a destra del container via position: fixed.
// "right" con position:fixed = distanza dal bordo destro del viewport.
panel.style.top = (rect.top + 6) + 'px';
panel.style.right = (window.innerWidth - rect.right + 6) + 'px';
panel.style.left = 'auto';
}
// Nasconde il bottone matita se la tab editor è già aperta
const editorOpen = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_HANDWRITING)
.some(l => (l.view as DrawingEditorView).getEmbedId() === embedId);
pencilBtn.style.display = editorOpen ? 'none' : 'flex';
requestAnimationFrame(update);
};
requestAnimationFrame(update);
}
// 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;
});
}