180 lines
5.4 KiB
JavaScript
180 lines
5.4 KiB
JavaScript
/**
|
|
* initAttachmentZone — wire up drag-and-drop, clipboard paste, and file-picker
|
|
* for a form that already has a multipart file input.
|
|
*
|
|
* Uses a DataTransfer object as a mutable file store so that
|
|
* fileInput.files always reflects the full set of chosen files and
|
|
* native form submission works unchanged.
|
|
*/
|
|
function initAttachmentZone({ formEl, fileInput, dropZone, fileListEl, pasteTarget }) {
|
|
const dt = new DataTransfer();
|
|
let dragCounter = 0;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function fileKey(f) {
|
|
return f.name + '|' + f.size + '|' + f.lastModified;
|
|
}
|
|
|
|
function currentKeys() {
|
|
const keys = new Set();
|
|
for (let i = 0; i < dt.items.length; i++) {
|
|
keys.add(fileKey(dt.files[i]));
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
function addFiles(files) {
|
|
const keys = currentKeys();
|
|
for (const f of files) {
|
|
if (!keys.has(fileKey(f))) {
|
|
dt.items.add(f);
|
|
keys.add(fileKey(f));
|
|
}
|
|
}
|
|
sync();
|
|
}
|
|
|
|
function removeFile(index) {
|
|
dt.items.remove(index);
|
|
sync();
|
|
}
|
|
|
|
function sync() {
|
|
fileInput.files = dt.files;
|
|
renderList();
|
|
}
|
|
|
|
// ── File list rendering ─────────────────────────────────────────────
|
|
|
|
function formatSize(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function renderList() {
|
|
fileListEl.innerHTML = '';
|
|
if (dt.files.length === 0) {
|
|
fileListEl.classList.add('hidden');
|
|
return;
|
|
}
|
|
fileListEl.classList.remove('hidden');
|
|
|
|
for (let i = 0; i < dt.files.length; i++) {
|
|
const f = dt.files[i];
|
|
const row = document.createElement('div');
|
|
row.className = 'flex items-center justify-between rounded-md bg-gray-50 px-3 py-1.5 text-sm';
|
|
|
|
const info = document.createElement('span');
|
|
info.className = 'text-gray-700 truncate';
|
|
info.textContent = f.name;
|
|
|
|
const size = document.createElement('span');
|
|
size.className = 'text-gray-400 ml-2 whitespace-nowrap';
|
|
size.textContent = formatSize(f.size);
|
|
|
|
const left = document.createElement('div');
|
|
left.className = 'flex items-center min-w-0';
|
|
left.appendChild(info);
|
|
left.appendChild(size);
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'ml-2 text-gray-400 hover:text-red-500 text-xs font-bold';
|
|
btn.textContent = '\u00d7';
|
|
btn.setAttribute('aria-label', 'Remove ' + f.name);
|
|
btn.addEventListener('click', function () { removeFile(i); });
|
|
|
|
row.appendChild(left);
|
|
row.appendChild(btn);
|
|
fileListEl.appendChild(row);
|
|
}
|
|
}
|
|
|
|
// ── Drag-and-drop ───────────────────────────────────────────────────
|
|
|
|
function highlight() {
|
|
dropZone.classList.add('ring-blue-400', 'bg-blue-50', 'border-blue-400');
|
|
}
|
|
|
|
function unhighlight() {
|
|
dropZone.classList.remove('ring-blue-400', 'bg-blue-50', 'border-blue-400');
|
|
}
|
|
|
|
dropZone.addEventListener('dragenter', function (e) {
|
|
e.preventDefault();
|
|
dragCounter++;
|
|
highlight();
|
|
});
|
|
|
|
dropZone.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
});
|
|
|
|
dropZone.addEventListener('dragleave', function (e) {
|
|
e.preventDefault();
|
|
dragCounter--;
|
|
if (dragCounter <= 0) {
|
|
dragCounter = 0;
|
|
unhighlight();
|
|
}
|
|
});
|
|
|
|
dropZone.addEventListener('drop', function (e) {
|
|
e.preventDefault();
|
|
dragCounter = 0;
|
|
unhighlight();
|
|
if (e.dataTransfer && e.dataTransfer.files.length) {
|
|
addFiles(e.dataTransfer.files);
|
|
}
|
|
});
|
|
|
|
// ── File picker (additive) ──────────────────────────────────────────
|
|
|
|
fileInput.addEventListener('change', function () {
|
|
addFiles(fileInput.files);
|
|
});
|
|
|
|
// ── Clipboard paste ─────────────────────────────────────────────────
|
|
|
|
function pasteTimestamp() {
|
|
const d = new Date();
|
|
const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
return d.getFullYear() +
|
|
pad(d.getMonth() + 1) +
|
|
pad(d.getDate()) + '-' +
|
|
pad(d.getHours()) +
|
|
pad(d.getMinutes()) +
|
|
pad(d.getSeconds());
|
|
}
|
|
|
|
if (pasteTarget) {
|
|
pasteTarget.addEventListener('paste', function (e) {
|
|
var items = e.clipboardData && e.clipboardData.items;
|
|
if (!items) return;
|
|
|
|
var files = [];
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].kind === 'file') {
|
|
var f = items[i].getAsFile();
|
|
if (!f) continue;
|
|
// Rename generic pasted names like "image.png"
|
|
if (/^image\.\w+$/.test(f.name)) {
|
|
var ext = f.name.split('.').pop();
|
|
var renamed = new File([f], 'paste-' + pasteTimestamp() + '.' + ext, { type: f.type });
|
|
files.push(renamed);
|
|
} else {
|
|
files.push(f);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
addFiles(files);
|
|
// Don't prevent default — let text pastes through
|
|
}
|
|
});
|
|
}
|
|
}
|