Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155import 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<string, string> = { '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<Uint8Array> { 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, }}