/**
* 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();
}