Files
odysseus/static/js/fileHandler.js
Paulo Victor Cordeiro 9c68ceafeb fix: use cached blob URL in _createChip to prevent memory leak (#1266)
_createChip called URL.createObjectURL directly, bypassing the
_getPreviewUrl/_revokePreviewUrl cache. Each re-render of the
attachment strip leaked blob URLs that were never revoked.
2026-06-03 01:55:59 +09:00

285 lines
8.2 KiB
JavaScript
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.
// static/js/fileHandler.js
/**
* File attachment and upload handling
*/
import uiModule from './ui.js';
import spinnerModule from './spinner.js';
let pendingFiles = [];
let uploaded = [];
// Holds the full meta (id/name/mime/size/width/height/…) from the most recent
// uploadPending() so callers can stamp width/height onto their attachment
// objects without changing uploadPending()'s return signature.
let _lastUploadedMeta = [];
let API_BASE = '';
let _uploadSpinners = [];
const _previewUrls = new WeakMap();
function _getPreviewUrl(f) {
if (!f) return '';
let url = _previewUrls.get(f);
if (!url) {
url = URL.createObjectURL(f);
_previewUrls.set(f, url);
}
return url;
}
function _revokePreviewUrl(f) {
const url = _previewUrls.get(f);
if (url) {
try { URL.revokeObjectURL(url); } catch (_) {}
_previewUrls.delete(f);
}
}
/**
* Initialize with dependencies
*/
export function init(apiBase) {
API_BASE = apiBase;
}
/**
* Open file picker dialog
*/
export function openPicker() {
document.getElementById('file-input').click();
}
const MAX_VISIBLE = 3;
const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview)
let _expanded = false;
/**
* Render the attachment strip with pending files.
* 1-3 files: show individual chips.
* 4+ files: collapse into a single "N files" badge (click to expand).
*/
export function renderAttachStrip() {
const strip = document.getElementById('attach-strip');
while (strip.firstChild) strip.removeChild(strip.firstChild);
if (pendingFiles.length === 0) {
_expanded = false;
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
return;
}
const total = pendingFiles.length;
const collapsed = total > MAX_VISIBLE && !_expanded;
if (collapsed) {
// Single compact badge: "5 files ×"
const badge = document.createElement('div');
badge.className = 'thumb thumb-collapsed';
const label = document.createElement('span');
label.textContent = total + ' file' + (total > 1 ? 's' : '');
label.className = 'thumb-collapsed-label';
badge.appendChild(label);
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
const canExpand = total <= MAX_EXPAND;
badge.style.cursor = canExpand ? 'pointer' : 'default';
badge.addEventListener('click', (e) => {
if (e.target.closest('.thumb-collapsed-x')) return;
if (!canExpand) return; // too many files — don't expand into chips
_expanded = true;
renderAttachStrip();
});
const x = document.createElement('button');
x.className = 'thumb-collapsed-x';
x.textContent = '\u00d7';
x.title = 'Remove all';
x.addEventListener('click', (e) => { e.stopPropagation(); clearPending(); });
badge.appendChild(x);
strip.appendChild(badge);
} else {
// Show individual chips
for (let idx = 0; idx < total; idx++) {
strip.appendChild(_createChip(pendingFiles[idx], idx));
}
}
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
}
function _createChip(f, idx) {
const chip = document.createElement('div');
chip.className = 'thumb';
const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || '');
if (isImage) {
chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile)
const img = document.createElement('img');
img.className = 'thumb-img';
img.src = _getPreviewUrl(f);
img.alt = f.name || 'image';
chip.appendChild(img);
} else {
const span = document.createElement('span');
span.textContent = f.name || 'pasted-image';
chip.appendChild(span);
}
const x = document.createElement('button');
x.textContent = '\u00d7';
x.setAttribute('aria-label', 'Remove attachment');
x.addEventListener('click', (e) => { e.stopPropagation(); removePending(idx); });
chip.appendChild(x);
return chip;
}
/**
* Remove a pending file by index
*/
export function removePending(idx) {
_revokePreviewUrl(pendingFiles[idx]);
pendingFiles.splice(idx, 1);
renderAttachStrip();
}
/**
* Upload all pending files to server
*/
export async function uploadPending() {
if (pendingFiles.length === 0) return [];
// The message bubble is shown immediately, but the upload can take a moment —
// dim the chips and overlay a whirlpool so it's clear the files are still
// being sent (and aren't stuck). Cleared in the finally below.
const strip = document.getElementById('attach-strip');
if (strip) {
strip.classList.add('attach-uploading');
// Put a whirlpool ON each attachment chip (image/doc) so the spinner sits on
// the thing being uploaded, not floating over the whole strip.
strip.querySelectorAll('.thumb').forEach(chip => {
try {
const sp = spinnerModule.create('', 'clean', 'whirlpool');
const ov = document.createElement('span');
ov.className = 'thumb-upload-spinner';
ov.appendChild(sp.createElement());
chip.appendChild(ov);
sp.start();
_uploadSpinners.push(sp);
} catch (_) { /* spinner is best-effort */ }
});
}
const fd = new FormData();
pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png'));
try {
const res = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
body: fd
});
const data = await res.json();
uploaded = (data.files || []);
pendingFiles = []; // clear only on success
// Stash the full meta (incl. width/height for images) on the module so
// callers that want it can grab it via getLastUploadedMeta(). Keep the
// returned shape as `ids` for backward-compatibility with existing call sites.
_lastUploadedMeta = uploaded;
return uploaded.map(x => x.id);
} finally {
_uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} });
_uploadSpinners = [];
if (strip) strip.classList.remove('attach-uploading');
// Re-render: empty on success (chips gone), or restored on error so the
// user can retry — and either way the spinners are removed.
renderAttachStrip();
}
}
const MAX_FILES = 10;
/**
* Add files to pending list (capped at MAX_FILES)
*/
export function addFiles(files) {
for (const f of files) {
if (pendingFiles.length >= MAX_FILES) {
_showToast(`Max ${MAX_FILES} files allowed`);
break;
}
pendingFiles.push(f);
}
renderAttachStrip();
}
function _showToast(msg) {
if (window.showToast) { window.showToast(msg); return; }
// Fallback inline toast
let t = document.getElementById('_attach-toast');
if (!t) {
t = document.createElement('div');
t.id = '_attach-toast';
t.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--red);color:var(--red);padding:6px 14px;border-radius:6px;font-size:13px;z-index:9999;opacity:0;transition:opacity .3s';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2500);
}
/**
* Get pending files count
*/
export function getPendingCount() {
return pendingFiles.length;
}
/**
* Get raw pending File objects (for reading content before upload clears them)
*/
export function getPendingRaw() {
return [...pendingFiles];
}
/**
* Get pending file metadata (name, size, type) for display
*/
export function getPendingInfo() {
return pendingFiles.map(f => {
const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || '');
return {
name: f.name || 'pasted-image',
size: f.size || 0,
mime: f.type || '',
previewUrl: isImage ? _getPreviewUrl(f) : '',
};
});
}
/**
* Clear all pending files
*/
export function clearPending() {
pendingFiles.forEach(_revokePreviewUrl);
pendingFiles = [];
renderAttachStrip();
}
/** Full meta (incl. width/height for images) from the most recent uploadPending(). */
export function getLastUploadedMeta() {
return _lastUploadedMeta;
}
var escapeHtml = uiModule.esc;
const fileHandlerModule = {
init,
openPicker,
renderAttachStrip,
removePending,
uploadPending,
addFiles,
getPendingCount,
getPendingInfo,
getPendingRaw,
clearPending,
getLastUploadedMeta,
};
export default fileHandlerModule;