import type { TextTransformResult } from './source-formatting' /** * Map from MIME type to file extension for image paste. * Only covers common image types; unknown types default to .png. */ const MIME_TO_EXTENSION: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/x-icon': 'ico', } /** * Get file extension for a MIME type. * Returns 'png' for unknown types (safe default for pasted images). */ export function extensionFromMimeType(mimeType: string): string { return MIME_TO_EXTENSION[mimeType] ?? 'png' } /** * Generate a filename for a pasted image. * Format: "Pasted image YYYYMMDDHHMMSSmmm.ext" * * Includes milliseconds to avoid collisions on rapid pastes within the same second. * * @param mimeType - The image MIME type (e.g., 'image/png') * @param now - Timestamp in ms (defaults to Date.now(), injectable for testing) */ export function generateImageFilename(mimeType: string, now?: number): string { const d = new Date(now ?? Date.now()) const pad2 = (n: number) => String(n).padStart(2, '0') const pad3 = (n: number) => String(n).padStart(3, '0') const timestamp = [ d.getFullYear(), pad2(d.getMonth() + 1), pad2(d.getDate()), pad2(d.getHours()), pad2(d.getMinutes()), pad2(d.getSeconds()), pad3(d.getMilliseconds()), ].join('') const ext = extensionFromMimeType(mimeType) return `Pasted image ${timestamp}.${ext}` } /** * Check if a DataTransfer contains an image file. * * Returns true when: * 1. clipboardData has 'Files' in types * 2. At least one item has an image/* MIME type with kind 'file' * * NOTE: We intentionally do NOT exclude clipboards that also contain * text/plain or text/html. Mobile browsers (Firefox, Safari, Chrome) * routinely include text types alongside image files when pasting images. * The previous "text takes priority" heuristic blocked all mobile image * paste. If an image file is present, we treat it as an image paste. * * KNOWN LIMITATION: Firefox Android delivers empty clipboardData for * JPEG images (types=[], files=0, items=[]). PNG paste works. This is a * browser-level limitation — no web API (clipboardData, beforeinput, * navigator.clipboard.read(), MutationObserver) exposes JPEG data. * Use the toolbar photo picker button as a workaround on Firefox Android. */ export function hasClipboardImage(dataTransfer: DataTransfer | null): boolean { if (!dataTransfer) return false if (!dataTransfer.types.includes('Files')) return false // Check for image/* file items (must be kind 'file', not 'string') for (let i = 0; i < dataTransfer.items.length; i++) { const item = dataTransfer.items[i] if (item.type.startsWith('image/') && item.kind === 'file') return true } return false } /** * Extract the first image file from a DataTransfer. * Returns null if no image file is found. */ export function getImageFile(dataTransfer: DataTransfer): File | null { for (let i = 0; i < dataTransfer.items.length; i++) { const item = dataTransfer.items[i] if (item.type.startsWith('image/') && item.kind === 'file') { return item.getAsFile() } } return null } /** * Read a File as a Uint8Array. */ export function readFileAsUint8Array(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { if (reader.result instanceof ArrayBuffer) { resolve(new Uint8Array(reader.result)) } else { reject(new Error('FileReader did not return ArrayBuffer')) } } reader.onerror = () => reject(reader.error ?? new Error('FileReader failed')) reader.onabort = () => reject(new Error('FileReader aborted')) reader.readAsArrayBuffer(file) }) } /** * Sanitize a filename for safe use in wikilink syntax. * Strips characters that would break `![[filename]]` parsing: `[`, `]`, `|`, `#`, `^`. * Replaces them with hyphens, then collapses consecutive hyphens. */ export function sanitizeFilename(name: string): string { const sanitized = name .replace(/[[\]|#^]/g, '-') .replace(/-{2,}/g, '-') .replace(/^-|-$/g, '') // Guard against filenames that become empty after sanitization // (e.g., '[[[' → ''). Fall back to a timestamp-based name. return sanitized || `file-${Date.now()}` } /** * Insert ![[filename]] text at the cursor position in source mode. * Returns a TextTransformResult compatible with applySourceFormat. * * If start !== end, the selected text is replaced with the embed. * * @param text - Full document text * @param start - Selection start (absolute offset) * @param end - Selection end (absolute offset) * @param filename - The filename to embed */ export function insertEmbedAtCursor( _text: string, start: number, end: number, filename: string, ): TextTransformResult { const embed = `![[${filename}]]` const cursorPos = start + embed.length return { changes: { from: start, to: end, insert: embed }, selectionStart: cursorPos, selectionEnd: cursorPos, } }