/** * Attachment plumbing for the editor. * * An `` tag cannot carry an Authorization header, so attachment bytes are * fetched with an authenticated request, kept in IndexedDB and handed to the * DOM as a blob URL. That also means images render offline, and each one is * downloaded at most once per device. */ import * as sync from "../syncEngine"; import * as db from "../offlineStore"; const _urls = new Map(); // attachmentId -> object URL const _inflight = new Map(); // attachmentId -> Promise export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024; export const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]; const readAsDataUrl = (file) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error("Could not read file")); r.readAsDataURL(file); }); /** Uploads a pasted or dropped image and returns its attachment id. */ export async function uploadImage(file) { if (!ACCEPTED_TYPES.includes(file.type)) throw new Error(`Unsupported image type: ${file.type || "unknown"}`); if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("Image is larger than 15 MB"); const dataUrl = await readAsDataUrl(file); const { id } = await sync.uploadAttachment(dataUrl, file.name); // Seed the cache from the bytes already in hand — no round trip to display it. try { await db.putAttachmentBlob(id, file); } catch { /* cache is best-effort */ } return id; } /** A displayable URL for an attachment, from cache when possible. */ export function resolveUrl(id) { if (!id) return Promise.resolve(null); const cached = _urls.get(id); if (cached) return Promise.resolve(cached); if (_inflight.has(id)) return _inflight.get(id); const job = (async () => { let blob = await db.getAttachmentBlob(id).catch(() => null); if (!blob) { blob = await sync.fetchAttachmentBlob(id); db.putAttachmentBlob(id, blob).catch(() => {}); } const url = URL.createObjectURL(blob); _urls.set(id, url); return url; })().finally(() => _inflight.delete(id)); _inflight.set(id, job); return job; } /* Object URLs live as long as the page does. They are a few dozen bytes of * bookkeeping each and the same image usually reappears as the user moves * between notes, so they are kept rather than revoked per unmount. */ export function releaseAll() { for (const url of _urls.values()) URL.revokeObjectURL(url); _urls.clear(); }