285 lines
8.3 KiB
JavaScript
285 lines
8.3 KiB
JavaScript
// 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 = URL.createObjectURL(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;
|