/**
|
* Centralized Upload Manager
|
* Handles all file inputs on the page from a single location
|
*/
|
class UploadManager {
|
constructor(store = null) {
|
this.store = store || new window.jvbStore({
|
name: 'uploads',
|
cacheTTL: 604800, // 7 days
|
useIndexedDB: true
|
});
|
|
this.queue = window.jvbQueue;
|
this.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
this.notifications = window.jvbNotifications;
|
|
// Central state management
|
this.fields = new Map(); // fieldId -> field configuration
|
this.uploads = new Map(); // uploadId -> upload state
|
this.subscribers = new Set();
|
|
this.performanceMonitor = new UploadPerformanceMonitor();
|
this.compressionWorker = null;
|
|
|
// Global settings
|
this.settings = {
|
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'],
|
maxFileSize: 5242880, // 5MB
|
|
// Field type configurations
|
fieldTypes: {
|
'single': { maxFiles: 1, allowMultiple: false },
|
'gallery': { maxFiles: 20, allowMultiple: true },
|
'groupable': {maxFiles: 20, allowMultiple: true }
|
},
|
smartCompression: true,
|
};
|
|
this.statusMapping = {
|
'queued': { status: 'queued', message: 'Waiting in queue...' },
|
'pending': { status: 'pending', message: 'Waiting for server...' },
|
'processing': { status: 'processing', message: 'Processing on server...' },
|
'uploading': { status: 'uploading', message: 'Uploading files...' },
|
'completed': { status: 'completed', message: 'Upload complete!' },
|
'failed': { status: 'failed', message: 'Upload failed (will retry)' },
|
'failed_permanent': { status: 'failed_permanent', message: 'Upload failed permanently' }
|
};
|
|
//Groups!
|
this.groups = new Map(); // groupId -> Set of uploadIds
|
this.groupMetadata = new Map(); // groupId -> group metadata (name, etc.)
|
this.initializeDragState();
|
|
this.init();
|
}
|
|
/**
|
* Initialize the upload manager
|
*/
|
async init() {
|
// Check for unfinished uploads from previous session
|
await this.checkUnfinishedUploads();
|
|
// Set up event listeners
|
this.initListeners();
|
|
// Scan for existing fields
|
this.scanFields();
|
}
|
|
/**
|
* Check for unfinished uploads using DataStore
|
*/
|
async checkUnfinishedUploads() {
|
try {
|
console.log('Checking for unfinished uploads...');
|
|
// Get all forms that might contain upload data
|
const allForms = this.store.getAllForms();
|
const unfinishedUploads = new Map();
|
|
// Look for upload-related form data
|
for (const [formId, formData] of allForms) {
|
if (formData.status === 'pending' && formData.uploadData) {
|
const fieldId = formData.uploadData.fieldId;
|
if (!unfinishedUploads.has(fieldId)) {
|
unfinishedUploads.set(fieldId, []);
|
}
|
unfinishedUploads.get(fieldId).push(formData);
|
}
|
}
|
|
// Show restore notifications for fields with unfinished uploads
|
for (const [fieldId, uploads] of unfinishedUploads) {
|
await this.showRestoreNotification(fieldId, uploads);
|
}
|
|
} catch (error) {
|
console.error('Failed to check unfinished uploads:', error);
|
}
|
}
|
|
/**
|
* Store upload progress and data using DataStore
|
*/
|
cacheUploadProgress(fieldId, uploadId, data) {
|
const cacheKey = `upload_${fieldId}_${uploadId}`;
|
|
const uploadData = {
|
formId: cacheKey,
|
fieldId,
|
uploadId,
|
uploadData: {
|
...data,
|
fieldId,
|
timestamp: Date.now()
|
},
|
status: 'pending',
|
operationId: data.operationId || null
|
};
|
|
// Store using DataStore's form methods
|
this.store.storeForm(cacheKey, uploadData);
|
}
|
|
/**
|
* Get cached upload data
|
*/
|
getCachedUpload(fieldId, uploadId) {
|
const cacheKey = `upload_${fieldId}_${uploadId}`;
|
return this.store.getForm(cacheKey);
|
}
|
|
/**
|
* Clear upload cache for a specific field
|
*/
|
async clearFieldCache(fieldId) {
|
try {
|
console.log(`Clearing cache for field: ${fieldId}`);
|
|
// Get all forms and filter for this field's uploads
|
const allForms = this.store.getAllForms();
|
const keysToDelete = [];
|
|
for (const [formId, formData] of allForms) {
|
if (formData.uploadData && formData.uploadData.fieldId === fieldId) {
|
keysToDelete.push(formId);
|
}
|
}
|
|
// Clear all related form data
|
keysToDelete.forEach(key => {
|
this.store.clearForm(key);
|
});
|
|
// Clear any cached field data
|
this.clearFieldMemoryCache(fieldId);
|
|
console.log(`Cleared cache for field ${fieldId}: ${keysToDelete.length} items removed`);
|
|
} catch (error) {
|
console.error(`Failed to clear field cache for ${fieldId}:`, error);
|
throw error;
|
}
|
}
|
|
/**
|
* Clear memory cache data related to a field
|
*/
|
clearFieldMemoryCache(fieldId) {
|
// Clear uploads related to this field
|
const uploadsToRemove = [];
|
|
for (const [uploadId, upload] of this.uploads) {
|
if (upload.fieldId === fieldId) {
|
// Clean up blob URLs
|
if (upload.preview && upload.preview.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
uploadsToRemove.push(uploadId);
|
}
|
}
|
|
// Remove uploads from memory
|
uploadsToRemove.forEach(uploadId => {
|
this.uploads.delete(uploadId);
|
});
|
|
console.log(`Cleared ${uploadsToRemove.length} uploads from memory for field: ${fieldId}`);
|
}
|
|
/**
|
* Show restore notification for unfinished uploads
|
*/
|
async showRestoreNotification(fieldId, cachedUploads) {
|
const field = this.fields.get(fieldId);
|
if (!field) {
|
console.warn(`Cannot show restore for unknown field: ${fieldId}`);
|
return;
|
}
|
|
// Create restore notification
|
const notification = this.createSelectiveRestoreNotification(fieldId, cachedUploads);
|
|
// Insert into field container
|
field.container.insertBefore(notification, field.container.firstChild);
|
}
|
|
/**
|
* Create selective restore notification UI
|
*/
|
createSelectiveRestoreNotification(fieldId, cachedUploads) {
|
const template = window.getTemplate('restoreNotification');
|
if (!template) {
|
console.error('restoreNotification template not found');
|
return null;
|
}
|
|
const notification = template.cloneNode(true);
|
const details = notification.querySelector('.restore-details');
|
const container = notification.querySelector('.item-grid.restore');
|
|
// Update message
|
details.textContent = `Found ${cachedUploads.length} unfinished upload(s) for this field.`;
|
|
// Create restore items
|
cachedUploads.forEach(upload => {
|
const item = this.createRestoreItem(upload);
|
container.append(item);
|
});
|
|
// Attach event listeners
|
this.attachRestoreEventListeners(notification, fieldId, cachedUploads);
|
|
return notification;
|
}
|
|
/**
|
* Create individual restore item
|
*/
|
createRestoreItem(uploadData) {
|
const template = window.getTemplate('restoreItem');
|
if (!template) {
|
console.error('restoreItem template not found');
|
return null;
|
}
|
|
const item = template.cloneNode(true);
|
const checkbox = item.querySelector('.restore-checkbox');
|
const img = item.querySelector('img');
|
const name = item.querySelector('.item-name');
|
|
// Set up item data
|
checkbox.id = `restore-${uploadData.uploadId}`;
|
checkbox.value = uploadData.uploadId;
|
|
// Use cached preview or create placeholder
|
if (uploadData.uploadData.preview) {
|
img.src = uploadData.uploadData.preview;
|
img.alt = uploadData.uploadData.originalName || 'Upload preview';
|
} else {
|
img.style.display = 'none';
|
item.querySelector('.image-placeholder').style.display = 'block';
|
}
|
|
if (name) {
|
name.textContent = uploadData.uploadData.originalName || 'Untitled upload';
|
}
|
|
return item;
|
}
|
|
/**
|
* Attach event listeners to restore notification
|
*/
|
attachRestoreEventListeners(notification, fieldId, cachedUploads) {
|
const selectAll = notification.querySelector('.select-all-restore');
|
const selectNone = notification.querySelector('.select-none-restore');
|
const restoreSelected = notification.querySelector('.restore-selected');
|
const clearCache = notification.querySelector('.restart-uploads');
|
const dismiss = notification.querySelector('.dismiss-cache-check');
|
|
// Select all/none functionality
|
selectAll?.addEventListener('click', () => {
|
notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = true);
|
});
|
|
selectNone?.addEventListener('click', () => {
|
notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = false);
|
});
|
|
// Restore selected items
|
restoreSelected?.addEventListener('click', async () => {
|
const selectedCheckboxes = notification.querySelectorAll('.restore-checkbox:checked');
|
const selectedIds = Array.from(selectedCheckboxes).map(cb => cb.value);
|
|
if (selectedIds.length === 0) {
|
this.notify('No items selected for restore', 'warning');
|
return;
|
}
|
|
// Restore selected uploads
|
const selectedUploads = cachedUploads.filter(upload =>
|
selectedIds.includes(upload.uploadId)
|
);
|
|
await this.restoreSelectedUploads(fieldId, selectedUploads);
|
notification.remove();
|
this.notify(`Restored ${selectedIds.length} item(s)`, 'success');
|
});
|
|
// Clear cache
|
clearCache?.addEventListener('click', async () => {
|
if (confirm('This will permanently delete all cached data. Are you sure?')) {
|
await this.clearFieldCache(fieldId);
|
notification.remove();
|
this.notify('Cache cleared', 'info');
|
}
|
});
|
|
// Dismiss notification
|
dismiss?.addEventListener('click', () => {
|
notification.remove();
|
});
|
}
|
|
/**
|
* Restore selected uploads
|
*/
|
async restoreSelectedUploads(fieldId, selectedUploads) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
let operations = new Set();
|
|
for (const cachedUpload of selectedUploads) {
|
try {
|
const upload = await this.restoreUploadFromCache(cachedUpload);
|
|
if (upload) {
|
operations.add(upload.operationId);
|
|
// Add to field
|
if (!field.uploads) field.uploads = new Set();
|
field.uploads.add(upload.id);
|
|
// Add to main preview
|
this.addImageToPost(upload.id, field.previewGrid, true);
|
}
|
|
} catch (error) {
|
console.error(`Failed to restore upload ${cachedUpload.uploadId}:`, error);
|
}
|
}
|
|
field.operationId = operations;
|
this.fields.set(fieldId, field);
|
|
// Update field UI
|
this.maybeLockUploads(fieldId);
|
|
if (field.type === 'groupable') {
|
field.container.querySelector('.group-display').hidden = false;
|
}
|
}
|
|
/**
|
* Restore individual upload from cached data
|
*/
|
async restoreUploadFromCache(cachedUpload) {
|
try {
|
const upload = {
|
id: cachedUpload.uploadId,
|
fieldId: cachedUpload.fieldId,
|
status: 'cached',
|
progress: { percent: 100, message: 'Restored from cache' },
|
meta: cachedUpload.uploadData.meta || {},
|
preview: cachedUpload.uploadData.preview || null,
|
createdAt: cachedUpload.uploadData.timestamp || Date.now(),
|
operationId: cachedUpload.operationId
|
};
|
|
// If we have processed file data, restore it
|
if (cachedUpload.uploadData.processedFile) {
|
upload.processedFile = cachedUpload.uploadData.processedFile;
|
}
|
|
if (cachedUpload.uploadData.originalFile) {
|
upload.originalFile = cachedUpload.uploadData.originalFile;
|
}
|
|
// Store upload in memory
|
this.uploads.set(upload.id, upload);
|
|
return upload;
|
|
} catch (error) {
|
console.error('Failed to restore upload from cache:', error);
|
throw error;
|
}
|
}
|
|
/**
|
* Save upload progress during processing
|
*/
|
saveUploadProgress(fieldId, uploadId, progressData) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
// Update upload progress
|
upload.progress = progressData.progress || upload.progress;
|
upload.status = progressData.status || upload.status;
|
|
// Cache the updated data
|
this.cacheUploadProgress(fieldId, uploadId, {
|
...upload,
|
originalFile: upload.originalFile ? {
|
name: upload.originalFile.name,
|
type: upload.originalFile.type,
|
size: upload.originalFile.size,
|
lastModified: upload.originalFile.lastModified
|
} : null,
|
processedFile: null, // Don't store the actual file data in cache
|
operationId: upload.operationId,
|
meta: upload.meta,
|
originalName: upload.originalFile?.name
|
});
|
}
|
|
/**
|
* Mark upload as completed and clean up cache
|
*/
|
completeUpload(fieldId, uploadId) {
|
const cacheKey = `upload_${fieldId}_${uploadId}`;
|
|
// Remove from cache since upload is complete
|
this.store.clearForm(cacheKey);
|
|
// Update upload status in memory
|
const upload = this.uploads.get(uploadId);
|
if (upload) {
|
upload.status = 'completed';
|
}
|
}
|
|
/**
|
* Event system
|
*/
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
|
notify(message, type = 'info') {
|
this.subscribers.forEach(cb => {
|
if (typeof cb === 'function') {
|
cb('notification', { message, type });
|
}
|
});
|
|
// Also log to console
|
console.log(`[BatchFileUploader] ${message}`);
|
}
|
|
|
/**************************************************************
|
EVENTS
|
**************************************************************/
|
|
|
initializeDragState() {
|
this.dragState = {
|
// What's being dragged
|
primaryItem: null,
|
draggedItems: [],
|
isDragging: false,
|
isMultiDrag: false,
|
|
// Drag context
|
fieldId: null,
|
sourceType: null, // 'drag' or 'touch'
|
startTime: null,
|
|
// Position tracking
|
startPosition: null, // { x, y }
|
currentPosition: null, // { x, y }
|
|
// Target tracking
|
currentTarget: null,
|
validTarget: null,
|
|
// Visual elements
|
dragPreview: null,
|
|
// Touch-specific
|
touchId: null,
|
touchMoved: false
|
};
|
}
|
|
/**
|
* Set up global event delegation
|
*/
|
initEventListeners() {
|
this.paste = window.debouncer.schedule(
|
'image-paste',
|
() => this.handlePaste.bind(this),
|
300
|
);
|
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
this.dragStartHandler = this.handleDragStart.bind(this);
|
this.dragEndHandler = this.handleDragEnd.bind(this);
|
this.dragEnterHandler = this.handleDragEnter.bind(this);
|
this.dragOverHandler = this.handleDragOver.bind(this);
|
this.dragLeaveHandler = this.handleDragLeave.bind(this);
|
this.dropHandler = this.handleDrop.bind(this);
|
this.touchStartHandler = this.handleTouchStart.bind(this);
|
this.touchMoveHandler = this.handleTouchMove.bind(this);
|
this.touchEndHandler = this.handleTouchEnd.bind(this);
|
this.touchCancelHandler = this.handleTouchCancel.bind(this);
|
|
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
document.addEventListener('paste', this.paste);
|
window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
|
//Groups
|
document.addEventListener('dragstart', this.dragStartHandler);
|
document.addEventListener('dragend', this.dragEndHandler);
|
document.addEventListener('dragenter', this.dragEnterHandler);
|
document.addEventListener('dragover', this.dragOverHandler);
|
document.addEventListener('dragleave', this.dragLeaveHandler);
|
document.addEventListener('drop', this.dropHandler);
|
|
document.addEventListener('touchstart', this.touchStartHandler, { passive: false });
|
document.addEventListener('touchmove', this.touchMoveHandler, { passive: false });
|
document.addEventListener('touchend', this.touchEndHandler, { passive: false });
|
document.addEventListener('touchcancel', this.touchCancelHandler, { passive: false });
|
}
|
|
/****************************************************************
|
*
|
* Scanning, registering, and validating page uploaders
|
*
|
***************************************************************/
|
/**
|
* Scan page for existing upload fields and register them
|
*/
|
scanExistingFields() {
|
const uploaders = document.querySelectorAll('.field.image');
|
uploaders.forEach(uploader => {
|
try {
|
this.registerUploader(uploader);
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'scanExistingFields',
|
container: uploader.dataset.name
|
});
|
}
|
});
|
}
|
|
registerUploader(uploader, options = {}) {
|
let input = uploader.querySelector('input[type=file]');
|
if (!input) {
|
return;
|
}
|
|
if (!('fieldId' in uploader.dataset)) {
|
uploader.dataset.fieldId = this.createFieldId(uploader);
|
}
|
let fieldId = uploader.dataset.fieldId;
|
|
let typeConfig = this.settings.fieldTypes[uploader.dataset.type] || this.settings.fieldTypes['single'];
|
let config = {
|
id: fieldId,
|
input: input,
|
container: uploader,
|
type: uploader.dataset.type,
|
name: uploader.dataset.name,
|
operationId: new Set(),
|
|
posts: new Map(),
|
|
maxFiles: typeConfig.maxFiles,
|
allowMultiple: typeConfig.allowMultiple,
|
groupsContainer: uploader.querySelector('.item-grid.groups')??false,
|
groupDisplay: uploader.querySelector('.group-display')??false,
|
selectAll: uploader.querySelector('#select-all-uploads'),
|
selectActions: uploader.querySelector('.selection-actions'),
|
selectInfo: uploader.querySelector('.selection-controls .info'),
|
selectCount: uploader.querySelector('.selection-count'),
|
|
content: uploader.dataset.content || 'options',
|
contentFields: uploader.dataset.fields ?? {}, //If this is a content creation uploader, we can add its fields here
|
postId: uploader.dataset.postId??false,
|
termId: uploader.dataset.termId??false,
|
|
mode: uploader.dataset.mode??'direct',
|
hiddenValue: uploader.querySelector('input[type="hidden"]'),
|
fields: {
|
title: { label: 'Image Title', type: 'text', required: false },
|
alt: { label: 'Alt Text', type: 'text', required: true },
|
caption: { label: 'Image Caption', type: 'textarea', required: false }
|
},
|
dropZone: uploader.querySelector('.file-upload-container'),
|
previewGrid: uploader.querySelector('.item-grid.preview'),
|
|
uploads: new Set(),
|
status: 'ready',
|
... options
|
};
|
|
this.fields.set(fieldId, config);
|
|
return fieldId;
|
}
|
|
/*******************************************************************
|
*
|
* Event Listeners
|
*
|
******************************************************************/
|
/**
|
* Handle file input changes
|
*/
|
handleChange(e) {
|
|
//Only run on uploader changes
|
if (!window.targetCheck(e, '.field.image')) {
|
return;
|
}
|
e.preventDefault();
|
|
if (window.targetCheck(e, 'input[type="file"]')) {
|
const fieldId = this.getFieldId(e.target);
|
const field = this.fields.get (fieldId);
|
|
if (!field) {
|
console.warn('File change on unregistered field: ', fieldId);
|
return;
|
}
|
|
const files = Array.from(e.target.files);
|
if (files.length === 0) return;
|
|
this.processFiles(fieldId, files);
|
|
e.target.value = '';
|
} else if (e.target.closest('.upload-group') || e.target.name === 'featured') {
|
|
this.addMetaToPost(e.target);
|
} else if (e.target.closest('.upload-meta')) {
|
|
this.addMetaToImage(e.target);
|
this.maybeUpdateImageMeta();
|
}
|
}
|
|
handleClick(e) {
|
if (!window.targetCheck(e, '.image.field')) return;
|
|
let [
|
restart,
|
dismissCacheCheck,
|
selectAll,
|
selectOne,
|
createFromSelection,
|
removeSelection,
|
addToPost,
|
removePost,
|
remove,
|
submitUploads,
|
retry,
|
] = [
|
window.targetCheck(e, '.restart-uploads'),
|
window.targetCheck(e, '.dismiss-cache-check'),
|
window.targetCheck(e, '#select-all-uploads'),
|
window.targetCheck(e, '.upload-select'),
|
window.targetCheck(e, '.create-from-selection'),
|
window.targetCheck(e, '.remove-selection'),
|
window.targetCheck(e, '.add-to-group')??window.targetCheck(e,'.add-selection-to-group'),
|
window.targetCheck(e, '.remove-group'),
|
window.targetCheck(e, '.remove'), //handle remove from group and removal
|
window.targetCheck(e, '.submit-uploads'),
|
window.targetCheck(e, '.retry-upload'),
|
];
|
|
if (this.isUploadTrigger(e.target)) {
|
this.triggerFileSelection(this.getFieldId(e.target));
|
} else if (restart) {
|
this.clearCache(this.getFieldId(restart));
|
} else if (dismissCacheCheck) {
|
this.dismissCacheCheck(this.getFieldId(dismissCacheCheck));
|
} else if (selectAll) {
|
this.handleSelectAll(selectAll);
|
} else if (selectOne) {
|
if (e.shiftKey && this.lastClickedUpload) {
|
this.handleRangeSelection(selectOne, e);
|
} else {
|
this.handleUploadSelection(selectOne);
|
// Track last clicked upload for range selection
|
this.lastClickedUpload = this.getUploadId(selectOne);
|
}
|
} else if (createFromSelection) {
|
this.createPostFromSelection(createFromSelection);
|
}else if (removeSelection) {
|
this.removeSelection(removeSelection);
|
} else if (addToPost) {
|
let group = addToPost.closest('.upload-group')?.querySelector('.item-grid')??false;
|
if (!group) {
|
group = this.createPostElement(this.getFieldId(addToPost));
|
}
|
this.getSelectedUploads(addToPost).forEach(upload => {
|
this.addImageToPost(upload, group);
|
});
|
} else if (removePost) {
|
this.removePost(removePost);
|
} else if (remove) {
|
this.removeImageFromPost(remove, this.getUploadId(remove));
|
} else if (submitUploads) {
|
this.submitPostData(submitUploads);
|
} else if (retry) {
|
this.retryUpload(this.getFieldId(retry), this.getUploadId(retry));
|
}
|
}
|
|
async retryUpload(fieldId, uploadId) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload || upload.status !== 'error') return;
|
|
this.a11y.announce(`Retrying upload for ${upload.originalFile.name}`);
|
await this.queueUpload(uploadId);
|
}
|
|
/**
|
* Handle page unload
|
*/
|
handleBeforeUnload(e) {
|
const activeUploads = Array.from(this.uploads.values())
|
.filter(upload => ['processing', 'uploading'].includes(upload.status));
|
|
if (activeUploads.length > 0) {
|
e.preventDefault();
|
e.returnValue = `You have ${activeUploads.length} upload(s) in progress. Are you sure you want to leave?`;
|
return e.returnValue;
|
}
|
|
//TODO: Check for unsaved field changes
|
}
|
|
/**
|
* Handle paste events (for image paste support)
|
*/
|
handlePaste(e) {
|
const activeField = document.activeElement?.closest('[data-upload-field]');
|
if (!activeField) return;
|
|
const items = Array.from(e.clipboardData.items);
|
const imageItems = items.filter(item => item.type.startsWith('image/'));
|
|
if (imageItems.length === 0) return;
|
|
e.preventDefault();
|
|
const fieldId = this.getFieldId(activeField);
|
if (!fieldId) return;
|
|
// Convert clipboard items to files
|
const files = [];
|
imageItems.forEach((item, index) => {
|
const file = item.getAsFile();
|
if (file) {
|
// Rename for clarity
|
const newFile = new File([file], `pasted_image_${index + 1}.png`, {
|
type: file.type,
|
lastModified: Date.now()
|
});
|
files.push(newFile);
|
}
|
});
|
|
if (files.length > 0) {
|
this.processFiles(fieldId, files);
|
}
|
}
|
/***************************************************************
|
*
|
* Information Handling
|
*
|
***************************************************************/
|
/**
|
*
|
* @param {string} uploadId the referred uploadId, as set by the this.uploads logic
|
* @param element the target element the image is 'dropping' to
|
* @param {boolean} isPreviewGrid
|
* @returns {string|boolean}
|
*/
|
addImageToPost(uploadId, element, isPreviewGrid = true) {
|
let field = this.checkField(element);
|
if (!field) return false;
|
|
let upload = this.uploads.get(uploadId);
|
if (!upload) {
|
return false;
|
}
|
|
const previousLocation = this.removeImageFromCurrentLocation(uploadId, field);
|
|
if (isPreviewGrid) {
|
const postId = this.generateID();
|
const post = {
|
id: postId,
|
images: new Set([uploadId]), // This post only contains this one image
|
fields: {}
|
};
|
|
this.addImageElementTo(uploadId, element, false, postId);
|
|
// Store the individual post
|
field.posts.set(postId, post);
|
this.fields.set(field.id, field);
|
|
// Announce the move
|
if (previousLocation) {
|
this.a11y.announce(`Image moved from ${previousLocation} back to main area`);
|
}
|
|
// Cache the field data
|
this.cachePostData(field.id);
|
return postId;
|
} else {
|
let post = this.getPostDataFromElement(element);
|
|
post.images.add(uploadId);
|
|
// Handle .empty-group drops by creating the group element here
|
if (element.classList.contains('empty-group')) {
|
element = this.createPostElement(field.id, post.id);
|
}
|
this.addImageElementTo(uploadId, element);
|
element.dataset.postId = post.id;
|
|
// Announce the move
|
const groupNumber = Array.from(field.posts.keys()).indexOf(post.id) + 1;
|
if (previousLocation === 'preview') {
|
this.a11y.announce(`Image moved from main area to group ${groupNumber}`);
|
} else if (previousLocation) {
|
this.a11y.announce(`Image moved from ${previousLocation} to group ${groupNumber}`);
|
} else {
|
this.a11y.announce(`Image added to group ${groupNumber}`);
|
}
|
|
//update field data
|
field.posts.set(post.id, post);
|
this.fields.set(field.id, field);
|
this.cachePostData(field.id);
|
|
return post.id;
|
}
|
}
|
|
/**
|
* Remove an image from its current location (preview grid or another group)
|
* @param {string} uploadId - The upload ID to remove
|
* @param {Object} field - The field object
|
* @returns {string|null} - Description of where image was removed from
|
*/
|
removeImageFromCurrentLocation(uploadId, field) {
|
// Find which post/group currently contains this image
|
let currentPostId = null;
|
let currentPost = null;
|
for (const [postId, post] of field.posts) {
|
if (post.images.has(uploadId)) {
|
currentPostId = postId;
|
currentPost = post;
|
break;
|
}
|
}
|
|
console.log('current post id: ', currentPostId);
|
console.log('current post: ', currentPost);
|
|
if (currentPostId && currentPost) {
|
// Remove from the current post
|
currentPost.images.delete(uploadId);
|
|
// Find the DOM element
|
let item = document.querySelector(`[data-upload-id="${uploadId}"]`);
|
|
if (!item) {
|
console.warn(`Element not found for upload ${uploadId} - may have been moved already`);
|
// Still clean up the data structure even if DOM element is missing
|
if (currentPost.images.size === 0) {
|
this.removeEmptyGroup(currentPostId, field);
|
} else {
|
field.posts.set(currentPostId, currentPost);
|
}
|
this.cachePostData(field.id);
|
return 'unknown location';
|
}
|
|
let parent = item.closest('.upload-group, .item-grid.preview');
|
let type = (parent && parent.classList.contains('preview')) ? 'preview' : 'group ' + (Array.from(field.posts.keys()).indexOf(currentPostId) + 1);
|
|
if (currentPost.images.size === 0) {
|
this.removeEmptyGroup(currentPostId, field);
|
} else {
|
field.posts.set(currentPostId, currentPost);
|
}
|
|
// Remove the DOM element
|
item.remove();
|
this.cachePostData(field.id);
|
|
return type;
|
}
|
|
return null; // Image wasn't found in any location
|
}
|
|
/**
|
* Remove an empty group from the field
|
* @param {string} postId - The post ID of the group to remove
|
* @param {Object} field - The field object
|
*/
|
removeEmptyGroup(postId, field) {
|
// Remove from data structure
|
field.posts.delete(postId);
|
|
// Remove DOM element
|
const groupElement = field.container.querySelector(`[data-post-id="${postId}"]`);
|
if (groupElement) {
|
groupElement.remove();
|
this.a11y.announce('Empty group removed');
|
}
|
|
// Update field
|
this.fields.set(field.id, field);
|
this.cachePostData(field.id);
|
}
|
|
|
checkField(element) {
|
let fieldId = this.getFieldId(element);
|
return this.fields.get(fieldId);
|
}
|
|
getPostDataFromElement(element) {
|
let field = this.checkField(element);
|
if (!field) return;
|
|
let postId = element.dataset.postId??element.closest('.upload-group').dataset.postId??field.postId??field.termId;
|
let post;
|
|
//If this isn't a groupable post, we can just add the information to the post id
|
if (postId && !field.posts.has(postId)) {
|
post = {
|
id: postId,
|
images: new Set(),
|
fields: {}
|
};
|
} else if (postId && field.posts.has(postId)) {
|
post = field.posts.get(postId);
|
} else {
|
post = this.createPost(element);
|
}
|
|
return post;
|
}
|
|
removeImageFromPost(element, uploadId) {
|
let field = this.checkField(element);
|
if (!field) return;
|
|
let postId = element.dataset.postId;
|
if (!postId) {
|
postId = this.getPostIdFromUpload(field, uploadId);
|
if (!postId) {
|
console.warn('Could not find post for upload:', uploadId);
|
return;
|
}
|
}
|
|
const post = field.posts.get(postId);
|
if (!post) return;
|
|
// Remove from data structure
|
post.images.delete(uploadId);
|
|
// Remove DOM element
|
const imageElement = element.closest('.upload-group, .item-grid.preview').querySelector(`[data-upload-id="${uploadId}"]`);
|
if (imageElement) {
|
imageElement.remove();
|
}
|
|
// If group is empty, remove it; otherwise update it
|
if (post.images.size === 0) {
|
this.removeEmptyGroup(postId, field);
|
} else {
|
// Update the post data and move image back to preview
|
field.posts.set(postId, post);
|
this.addImageToPost(uploadId, field.previewGrid, true);
|
this.fields.set(field.id, field);
|
this.cachePostData(field.id);
|
}
|
|
return true;
|
}
|
|
removePost(element, cache = true) {
|
element = element.closest('.upload-group, .upload-item') ?? element;
|
let field = this.getField(element);
|
let postId = element.dataset.postId;
|
|
if (field.posts.has(postId)) {
|
let post = field.posts.get(postId);
|
post.images.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (upload) {
|
this.addImageToPost(uploadId, field.previewGrid, true);
|
}
|
});
|
field.posts.delete(postId);
|
this.fields.set(field.id, field);
|
if (cache) {
|
this.cachePostData(field.id);
|
}
|
}
|
element.remove();
|
this.a11y.announce('Group deleted and images moved back to main area');
|
}
|
|
getPostIdFromUpload(field, uploadId) {
|
for (const [id, post] of field.posts) {
|
if (post.images.has(uploadId)) {
|
return id;
|
}
|
}
|
return null;
|
}
|
|
addMetaToImage(element) {
|
let field = this.checkField(element);
|
if (!field) return;
|
|
let item = element.closest('.upload-item');
|
let uploadId = item?.dataset.uploadId;
|
|
if (!uploadId) return;
|
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
if (!upload.meta) {
|
upload.meta = {};
|
}
|
|
upload.meta[element.name] = element.value;
|
this.uploads.set(uploadId, upload);
|
|
this.cacheUpload(upload);
|
}
|
|
addMetaToPost(element) {
|
console.log('Adding meta to post: ');
|
let field = this.checkField(element);
|
console.log('Field:', field);
|
if (!field || field.type !== 'groupable') return;
|
|
let post = this.getPostDataFromElement(element);
|
console.log('Post: ',post);
|
|
console.log('element: ', element);
|
|
post.fields[element.name] = element.value;
|
field.posts.set(post.id, post);
|
this.fields.set(field.id, field);
|
this.cachePostData(field.id);
|
return post.id;
|
}
|
|
createPost(element) {
|
let id = this.generateID();
|
|
if (!element.classList.contains('empty-group')) {
|
element.dataset.postId = id;
|
}
|
|
const post = {
|
id: id,
|
images: new Set(),
|
fields: {}
|
};
|
|
// Cache the new post immediately
|
const field = this.checkField(element);
|
if (field) {
|
field.posts.set(id, post);
|
this.fields.set(field.id, field);
|
this.cachePostData(field.id);
|
}
|
|
return post;
|
}
|
|
createPostFromSelection(element) {
|
const fieldId = this.getFieldId(element);
|
const selected = this.getSelectedUploads(element);
|
|
if (selected.length === 0) {
|
this.notify('No uploads selected', 'warning');
|
return;
|
}
|
|
// Create new post element
|
const postElement = this.createPostElement(fieldId);
|
|
selected.forEach(uploadId => {
|
this.addImageToPost(uploadId, postElement, false);
|
// Clear selection
|
const uploadItem = document.querySelector(`[data-upload-id="${uploadId}"]`);
|
const checkbox = uploadItem?.querySelector('[name*="select-item"]');
|
if (checkbox) checkbox.checked = false;
|
});
|
|
this.updateSelectAll(element);
|
this.a11y.announce(`Created new group with ${selected.length} images.`);
|
}
|
|
async submitPostData(element) {
|
let field = this.checkField(element);
|
if (!field) return;
|
|
let length = field.posts.size;
|
|
const operation = {
|
endpoint: 'uploads/groups',
|
method: "POST",
|
title: `Uploading ${length} ${field.plural}`,
|
popup: `Sending ${length} ${field.plural} to server...`,
|
canMerge: true,
|
type: 'image_groups',
|
headers: { 'action_nonce': jvbSettings.dash },
|
user: jvbSettings.currentUser,
|
field: field.id,
|
onComplete: (operation) => this.handleFinalCompletion(operation)
|
};
|
|
// Convert Map to plain object with proper structure
|
let data = {};
|
let dependencies = new Set();
|
let index = 0;
|
data.posts = {};
|
for (const [postId, post] of field.posts) {
|
let images = [];
|
for (const uploadId of post.images) {
|
const upload = this.uploads.get(uploadId);
|
if (upload) {
|
images.push({
|
upload_id: uploadId,
|
meta: upload.meta,
|
operationId: upload.operationId
|
});
|
dependencies.add(upload.operationId);
|
}
|
}
|
|
data.posts[index] = {
|
...post,
|
images: images
|
};
|
index++;
|
}
|
|
data.content = field.content;
|
|
operation.data = data;
|
operation['depends_on'] = [...dependencies];
|
|
try {
|
await this.queue.addToQueue(operation);
|
this.notify(`Sent ${field.plural} to server`);
|
} catch (error) {
|
throw error;
|
}
|
}
|
|
handleFinalCompletion (operation) {
|
if(operation.field) {
|
let field = this.fields.get(operation.field);
|
if (field.onGroupingComplete && typeof field.onGroupingComplete === "function") {
|
field.onGroupingComplete(operation);
|
}
|
field.container.querySelector('.group-display').hidden = true;
|
field.container.closest('details').open = false;
|
this.cleanField(operation.field);
|
}
|
|
}
|
cleanField(fieldId) {
|
let field = this.fields.get(fieldId);
|
if(!field) return;
|
if (field.previewGrid) {
|
window.removeChildren(field.previewGrid);
|
}
|
if (field.groupsContainer) {
|
window.removeChildren(field.groupsContainer);
|
}
|
|
this.clearFieldCache(fieldId);
|
}
|
/***************************************************************
|
*
|
* UI HANDLING
|
*
|
***************************************************************/
|
addImageElementTo(upload, element, prepend = true, postId = null) {
|
upload = (typeof upload === 'string') ? this.uploads.get(upload) : upload;
|
if (!upload) {
|
console.warn('Upload not found:', upload);
|
return;
|
}
|
|
let image = window.getTemplate('uploadItem');
|
if (!image) {
|
console.error('uploadItem template not found');
|
return;
|
}
|
|
image.dataset.uploadId = upload.id;
|
if (postId) {
|
image.dataset['postId'] = postId;
|
} else {
|
image.querySelector('.item-actions').remove();
|
let groupActions = window.getTemplate('groupActions');
|
groupActions.querySelector('input[name="featured"]').value = upload.id;
|
image.querySelector('.actions').append(groupActions);
|
|
}
|
|
const img = image.querySelector('img');
|
if (img) {
|
img.src = upload.preview;
|
img.alt = upload.originalFile?.name ?? upload.meta?.originalName ?? 'Unknown File';
|
}
|
|
// Safely add metadata template
|
const details = image.querySelector('details');
|
if (details) {
|
const metaTemplate = window.getTemplate('uploadMeta');
|
if (metaTemplate) {
|
details.append(metaTemplate);
|
}
|
}
|
|
let field = this.getField(element);
|
if (field && field.type === 'groupable') {
|
image.draggable = true;
|
}
|
|
|
|
// Update input IDs safely
|
image.querySelectorAll('input').forEach(input => {
|
let id = input.id;
|
if (id) {
|
let newId = id + upload.id;
|
let label = input.parentNode.querySelector(`label[for="${id}"]`);
|
input.id = newId;
|
if (label) {
|
label.htmlFor = newId;
|
}
|
}
|
});
|
|
if (prepend) {
|
element.prepend(image);
|
} else {
|
element.append(image);
|
}
|
|
this.updateImageUI(upload.id, element);
|
}
|
removeImageElementFrom(uploadId, element, moveBackToPreview = null) {
|
element = (typeof element === 'string') ? field.container.querySelector(element) : element;
|
element.querySelector(`[data-upload-id=${uploadId}]`).remove();
|
|
if (moveBackToPreview) {
|
this.addImageToPost(uploadId, this.getField(element).previewGrid, true);
|
}
|
}
|
updateImageUI(uploadId, element = null) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
element = (element) ? element : this.getField(document.querySelector(`[data-upload-id="${uploadId}"]`))?.container;
|
if (!element) return;
|
|
const item = element.querySelector(`[data-upload-id="${uploadId}"]`);
|
if (!item) return;
|
|
item.dataset.status = upload.status;
|
|
// Safely update progress elements
|
if (upload.progress) {
|
const fillElement = item.querySelector('.fill');
|
const textElement = item.querySelector('.details');
|
|
if (fillElement) {
|
fillElement.style.width = `${upload.progress.percent || 0}%`;
|
}
|
|
if (textElement) {
|
textElement.textContent = upload.progress.message ?? '';
|
}
|
}
|
|
// Safely update status indicator
|
const statusElement = item.querySelector('.status');
|
if (statusElement && !statusElement.classList.contains(upload.status)) {
|
window.removeChildren(statusElement);
|
statusElement.className = `status ${upload.status}`;
|
statusElement.append(this.getStatusIcon(upload.status));
|
}
|
|
// Safely hide/show progress
|
const progressElement = item.querySelector('.progress');
|
if (progressElement) {
|
progressElement.hidden = ['completed'].includes(upload.status);
|
}
|
}
|
|
createPostElement(fieldId, id = null) {
|
let field = this.fields.get(fieldId);
|
if (!field) return;
|
|
let post = window.getTemplate('imageGroup');
|
const postId = id || this.generateID();
|
|
post.dataset.fieldId = fieldId;
|
post.dataset.postId = postId;
|
|
let meta = post.querySelector('.fields');
|
let fields = window.getTemplate('groupMetadata');
|
meta.append(fields);
|
|
field.groupsContainer.insertBefore(post, field.groupsContainer.querySelector('.empty-group').nextElementSibling);
|
|
// Return the grid element, not the post container
|
return field.container.querySelector(`[data-post-id="${postId}"] .item-grid`);
|
}
|
/**
|
* Hide the uploader drop zone if we have reached our limit
|
*/
|
maybeLockUploads(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Hide/show drop zone based on file count
|
if (field.dropZone) {
|
const isAtCapacity = field.uploads && field.uploads.size >= field.maxFiles;
|
field.dropZone.style.hidden = isAtCapacity;
|
}
|
}
|
|
/***************************************************************
|
*
|
* Image Processing
|
*
|
**************************************************************/
|
/**
|
* Process files for a specific field
|
*/
|
async processFiles(fieldId, files) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Validate files
|
const validFiles = files.filter(file => this.validateFile(file, field));
|
if (validFiles.length === 0) return;
|
|
// Check field limits
|
if (!this.checkFieldLimits(fieldId, validFiles.length)) {
|
this.notify(`Cannot add ${validFiles.length} files. Field limit exceeded.`, 'warning');
|
return;
|
}
|
|
const processedUploads = await this.processBatch(fieldId, validFiles, {
|
batchSize: 3, // Process 3 files simultaneously
|
showProgress: true,
|
});
|
this.maybeLockUploads(fieldId);
|
|
if (field.groupDisplay) {
|
field.groupDisplay.hidden = false;
|
}
|
await this.queueUpload(fieldId);
|
|
|
|
this.a11y.announce(`Processed ${processedUploads.length} of ${validFiles.length} files`);
|
}
|
|
/**
|
* Process a single file
|
*/
|
async processFile(fieldId, file) {
|
const field = this.fields.get(fieldId);
|
|
try {
|
let upload = this.storeUpload(fieldId, file);
|
let uploadId = upload.id;
|
if (!field.uploads) field.uploads = new Set();
|
field.uploads.add(uploadId);
|
|
this.addImageToPost(uploadId, field.previewGrid, true);
|
|
// Process image with better error context
|
upload.processedFile = await this.processImage(file, {
|
uploadId,
|
retries: 2
|
});
|
|
upload.status = 'processed';
|
|
if (upload.preview && upload.preview.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
|
// Create new preview URL for processed file
|
upload.preview = URL.createObjectURL(upload.processedFile);
|
|
this.cacheUpload(upload);
|
this.updateImageUI(uploadId);
|
|
// Announce completion (only for small batches)
|
if (this.uploads.size <= 3) {
|
this.a11y.announce(`${file.name} processed and ready`);
|
}
|
|
return upload;
|
|
} catch (error) {
|
this.updateUploadStatus(uploadId, 'error', 'Processing failed');
|
|
// Enhanced error context for batch processing
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'processFile',
|
uploadId,
|
fileName: file.name,
|
fileSize: file.size,
|
batchProcessing: true
|
});
|
|
// Don't spam error announcements for large batches
|
if (this.uploads.size <= 3) {
|
this.a11y.announce(`${file.name} processing failed`);
|
}
|
|
return null;
|
}
|
}
|
|
/**
|
* Stores file in our uploads Map
|
* @param fieldId
|
* @param file
|
* @returns {object}
|
*/
|
storeUpload(fieldId, file) {
|
let uploadId = this.generateUploadId();
|
const upload = {
|
id: uploadId,
|
fieldId: fieldId,
|
originalFile: file,
|
status: 'processing',
|
progress: { percent: 0, message: 'Processing...' },
|
preview: URL.createObjectURL(file),
|
createdAt: Date.now(),
|
meta: {
|
originalName: file.name,
|
originalType: file.type,
|
originalSize: file.size
|
}
|
};
|
|
this.uploads.set(uploadId, upload);
|
return upload;
|
}
|
|
async processImage(file, options = {}) {
|
if (!file.type.startsWith('image/')) {
|
return file;
|
}
|
|
const startTime = performance.now();
|
this.performanceMonitor?.startTiming(options.uploadId, 'processing');
|
|
try {
|
const maxDimension = this.getMaxDimension();
|
const quality = options.quality || 0.85;
|
|
let processedFile;
|
|
if (this.shouldUseWorker(file) && this.compressionWorker) {
|
processedFile = await this.processWithWorker(file, maxDimension, quality);
|
} else {
|
processedFile = await this.processOnMainThread(file, maxDimension, quality);
|
}
|
|
if (!this.isValidProcessedFile(processedFile, file)) {
|
console.warn(`Processing failed for ${file.name}, using original`);
|
return file;
|
}
|
|
const processingTime = performance.now() - startTime;
|
|
this.performanceMonitor?.endTiming(options.uploadId, 'processing');
|
return processedFile;
|
|
} catch (error) {
|
this.performanceMonitor?.endTiming(options.uploadId, 'processing');
|
|
// Let ErrorHandler deal with the error classification and user notification
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'processImage',
|
fileName: file.name,
|
fileSize: file.size,
|
fileType: file.type,
|
uploadId: options.uploadId
|
});
|
|
return file; // Fallback to original
|
}
|
}
|
|
/**
|
* Validate processed file
|
*/
|
isValidProcessedFile(processedFile, originalFile) {
|
if (!processedFile || !(processedFile instanceof File)) {
|
return false;
|
}
|
|
if (processedFile.size === 0) {
|
return false;
|
}
|
|
// Check if processing actually reduced file size (for large files)
|
if (originalFile.size > 2 * 1024 * 1024 && processedFile.size >= originalFile.size) {
|
console.warn(`Processing didn't reduce file size: ${originalFile.size} → ${processedFile.size}`);
|
// Still valid, just not optimal
|
}
|
|
return true;
|
}
|
|
|
/**
|
* Process image on main thread with better error handling
|
*/
|
async processOnMainThread(file, maxDimension, quality) {
|
return new Promise((resolve, reject) => {
|
const img = new Image();
|
const canvas = document.createElement('canvas');
|
const ctx = canvas.getContext('2d');
|
let objectUrl = null;
|
|
const cleanup = () => {
|
img.onload = null;
|
img.onerror = null;
|
if (objectUrl) {
|
URL.revokeObjectURL(objectUrl);
|
objectUrl = null;
|
}
|
// Explicitly clean up canvas
|
canvas.width = 1;
|
canvas.height = 1;
|
ctx.clearRect(0, 0, 1, 1);
|
};
|
|
img.onload = () => {
|
try {
|
const { width, height } = this.calculateOptimalDimensions(img, maxDimension);
|
canvas.width = width;
|
canvas.height = height;
|
|
// Enhanced image smoothing
|
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingQuality = 'high';
|
ctx.drawImage(img, 0, 0, width, height);
|
|
const outputFormat = this.getOptimalFormat(file);
|
const outputQuality = this.getOptimalQuality(file, quality);
|
|
canvas.toBlob(
|
(blob) => {
|
cleanup();
|
if (blob) {
|
const processedFile = new File(
|
[blob],
|
this.getProcessedFileName(file, outputFormat),
|
{ type: outputFormat, lastModified: Date.now() }
|
);
|
resolve(processedFile);
|
} else {
|
reject(new Error('Canvas toBlob failed'));
|
}
|
},
|
outputFormat,
|
outputQuality
|
);
|
|
} catch (error) {
|
cleanup();
|
reject(new Error(`Canvas processing failed: ${error.message}`));
|
}
|
};
|
|
img.onerror = () => {
|
cleanup();
|
reject(new Error(`Failed to load image: ${file.name}`));
|
};
|
|
try {
|
objectUrl = URL.createObjectURL(file);
|
img.src = objectUrl;
|
} catch (error) {
|
cleanup();
|
reject(new Error(`Failed to create object URL: ${error.message}`));
|
}
|
});
|
}
|
|
/**
|
* Get optimal output format
|
*/
|
getOptimalFormat(file) {
|
// Keep original format for certain types
|
if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
|
return file.type;
|
}
|
|
// Use WebP if supported, otherwise JPEG
|
return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
|
}
|
|
/**
|
* Get optimal quality setting
|
*/
|
getOptimalQuality(file, requestedQuality) {
|
// Higher quality for smaller files
|
if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
|
if (file.size < 2 * 1024 * 1024) return requestedQuality;
|
|
// Lower quality for very large files
|
return Math.min(requestedQuality, 0.8);
|
}
|
|
/**
|
* Generate processed file name
|
*/
|
getProcessedFileName(originalFile, outputFormat) {
|
const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
|
|
const extensions = {
|
'image/webp': '.webp',
|
'image/jpeg': '.jpg',
|
'image/png': '.png',
|
'image/gif': '.gif'
|
};
|
|
return baseName + (extensions[outputFormat] || '.jpg');
|
}
|
|
/**
|
* Get maximum dimension based on device capabilities
|
*/
|
getMaxDimension() {
|
const screenWidth = window.screen.width;
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
|
// Scale based on device capabilities
|
if (screenWidth * devicePixelRatio > 2560) return 2400;
|
if (screenWidth * devicePixelRatio > 1920) return 1920;
|
return 1200;
|
}
|
|
/**
|
* Determine if we should use Web Worker
|
*/
|
shouldUseWorker(file) {
|
// Use worker for large files or when available
|
return this.compressionWorker &&
|
file.size > 1024 * 1024 && // > 1MB
|
typeof OffscreenCanvas !== 'undefined';
|
}
|
|
async processWithWorker(file, maxDimension, quality) {
|
if (!this.compressionWorker) {
|
throw new Error('Worker not available');
|
}
|
|
return new Promise((resolve, reject) => {
|
const timeout = setTimeout(() => {
|
reject(new Error('Worker processing timeout'));
|
}, 30000); // 30 second timeout
|
|
this.compressionWorker.onmessage = (e) => {
|
clearTimeout(timeout);
|
|
if (e.data.success) {
|
const processedFile = new File(
|
[e.data.blob],
|
this.getProcessedFileName(file, e.data.format || 'image/webp'),
|
{ type: e.data.format || 'image/webp', lastModified: Date.now() }
|
);
|
resolve(processedFile);
|
} else {
|
reject(new Error(e.data.error || 'Worker processing failed'));
|
}
|
};
|
|
this.compressionWorker.onerror = (error) => {
|
clearTimeout(timeout);
|
reject(new Error(`Worker error: ${error.message}`));
|
};
|
|
// Send file to worker
|
this.compressionWorker.postMessage({
|
file: file,
|
maxDimension: maxDimension,
|
quality: quality,
|
outputFormat: this.getOptimalFormat(file)
|
});
|
});
|
}
|
|
/**
|
* Initialize Web Worker for image compression
|
*/
|
initCompressionWorker() {
|
if (this.compressionWorker || typeof Worker === 'undefined') return;
|
|
try {
|
const workerScript = `
|
self.onmessage = async function(e) {
|
const { file, maxDimension, quality, outputFormat } = e.data;
|
|
try {
|
// Create ImageBitmap from file
|
const bitmap = await createImageBitmap(file);
|
|
// Calculate dimensions
|
const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
|
const width = Math.round(bitmap.width * scale);
|
const height = Math.round(bitmap.height * scale);
|
|
// Create OffscreenCanvas
|
const canvas = new OffscreenCanvas(width, height);
|
const ctx = canvas.getContext('2d');
|
|
// Draw and resize
|
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingQuality = 'high';
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
// Convert to blob
|
const blob = await canvas.convertToBlob({
|
type: outputFormat,
|
quality: quality
|
});
|
|
self.postMessage({
|
success: true,
|
blob: blob,
|
format: outputFormat
|
});
|
|
} catch (error) {
|
self.postMessage({
|
success: false,
|
error: error.message
|
});
|
}
|
};
|
`;
|
|
const blob = new Blob([workerScript], { type: 'application/javascript' });
|
this.compressionWorker = new Worker(URL.createObjectURL(blob));
|
|
} catch (error) {
|
console.warn('Failed to initialize compression worker:', error);
|
this.compressionWorker = null;
|
}
|
}
|
|
async processBatch(fieldId, files, options = {}) {
|
const {
|
batchSize = 3,
|
showProgress = true,
|
onBatchComplete = null,
|
delayBetweenBatches = 100
|
} = options;
|
|
const results = [];
|
const totalFiles = files.length;
|
let processedCount = 0;
|
|
// Show initial progress
|
if (showProgress) {
|
this.updateUploadProgress(fieldId, 0, totalFiles, 'Starting batch processing...');
|
}
|
|
// Process files in batches
|
for (let i = 0; i < files.length; i += batchSize) {
|
const batch = files.slice(i, i + batchSize);
|
|
// Process current batch (parallel processing within batch)
|
const batchPromises = batch.map(async (file, index) => {
|
try {
|
const upload = await this.processFile(fieldId, file);
|
|
// Update progress for individual file
|
processedCount++;
|
if (showProgress) {
|
this.updateUploadProgress(
|
fieldId,
|
processedCount,
|
totalFiles,
|
`Processed ${file.name}`
|
);
|
}
|
|
return upload;
|
} catch (error) {
|
console.error(`Failed to process file ${file.name}:`, error);
|
processedCount++; // Still count as processed (failed)
|
|
if (showProgress) {
|
this.updateUploadProgress(
|
fieldId,
|
processedCount,
|
totalFiles,
|
`Failed: ${file.name}`
|
);
|
}
|
|
return null; // Return null for failed files
|
}
|
});
|
|
// Wait for current batch to complete
|
const batchResults = await Promise.all(batchPromises);
|
|
// Filter out null results (failed files)
|
const successfulUploads = batchResults.filter(upload => upload !== null);
|
results.push(...successfulUploads);
|
|
// Call batch completion callback
|
if (onBatchComplete) {
|
onBatchComplete(successfulUploads);
|
}
|
|
// Small delay between batches to prevent browser overload
|
if (i + batchSize < files.length && delayBetweenBatches > 0) {
|
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
|
}
|
}
|
|
// Final progress update
|
if (showProgress) {
|
this.updateUploadProgress(
|
fieldId,
|
totalFiles,
|
totalFiles,
|
`Completed! ${results.length}/${totalFiles} files processed successfully`
|
);
|
|
// Hide progress after a delay
|
setTimeout(() => {
|
this.hideUploadProgress(fieldId);
|
}, 2000);
|
}
|
|
return results;
|
}
|
|
updateUploadProgress(fieldId, current, total, message) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
let progressBar = field.container.querySelector('.progress');
|
|
// Create progress bar if it doesn't exist
|
if (!progressBar) {
|
progressBar = window.getTemplate('imageProgress');
|
|
// Insert after drop zone or at top of container
|
const insertAfter = field.dropZone || field.container.firstElementChild;
|
if (insertAfter) {
|
insertAfter.insertAdjacentElement('afterend', progressBar);
|
} else {
|
field.container.prepend(progressBar);
|
}
|
}
|
|
// Update progress bar
|
const progressPercent = total > 0 ? Math.round((current / total) * 100) : 0;
|
const progressFill = progressBar.querySelector('.fill');
|
const progressMessage = progressBar.querySelector('.details');
|
const progressCount = progressBar.querySelector('.count');
|
|
if (progressFill) {
|
progressFill.style.width = `${progressPercent}%`;
|
}
|
|
if (progressMessage) {
|
progressMessage.textContent = message;
|
}
|
|
if (progressCount) {
|
progressCount.textContent = `${current}/${total}`;
|
}
|
|
// Add completion styling
|
if (current === total) {
|
progressBar.classList.add('completed');
|
}
|
}
|
|
hideUploadProgress(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
const progressBar = field.container.querySelector('.progress');
|
if (progressBar) {
|
progressBar.style.opacity = '0';
|
setTimeout(() => {
|
progressBar.remove();
|
}, 300);
|
}
|
}
|
|
/**
|
* Calculate optimal dimensions with aspect ratio preservation
|
*/
|
calculateOptimalDimensions(img, maxDimension) {
|
let { width, height } = img;
|
|
// Don't upscale
|
if (width <= maxDimension && height <= maxDimension) {
|
return { width, height };
|
}
|
|
// Calculate scale factor
|
const scale = Math.min(maxDimension / width, maxDimension / height);
|
|
return {
|
width: Math.round(width * scale),
|
height: Math.round(height * scale)
|
};
|
}
|
|
|
/**
|
* Check WebP support
|
*/
|
supportsWebP() {
|
const canvas = document.createElement('canvas');
|
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
}
|
|
|
/*****************************************
|
*
|
* TOUCH, DRAG, and DROP
|
*
|
* Shared handlers, then individual listener handlers
|
*
|
****************************************/
|
startDragOperation(config) {
|
const {
|
primaryElement,
|
sourceType,
|
startPosition,
|
event
|
} = config;
|
|
const uploadId = this.getUploadId(primaryElement);
|
const fieldId = this.getFieldId(primaryElement);
|
|
// Determine what items to drag
|
const draggedItems = this.getDraggedItems(primaryElement);
|
|
// Initialize drag state
|
this.dragState = {
|
primaryItem: uploadId,
|
draggedItems: draggedItems,
|
isDragging: true,
|
isMultiDrag: draggedItems.length > 1,
|
fieldId: fieldId,
|
sourceType: sourceType,
|
startTime: Date.now(),
|
startPosition: startPosition,
|
currentPosition: startPosition,
|
currentTarget: null,
|
validTarget: null,
|
dragPreview: null,
|
touchId: sourceType === 'touch' ? event.touches[0]?.identifier : null,
|
touchMoved: false
|
};
|
|
// Create drag preview
|
this.createDragPreview(primaryElement);
|
|
// Apply dragging state
|
this.applyDraggingState(true);
|
|
const announceText = this.dragState.isMultiDrag
|
? `Started dragging ${draggedItems.length} items`
|
: 'Started dragging item';
|
|
this.a11y.announce(announceText);
|
this.provideDragFeedback('start');
|
|
return true;
|
}
|
|
updateDragOperation(position, elementUnderPointer) {
|
if (!this.dragState.isDragging) return;
|
|
const { sourceType, startPosition } = this.dragState;
|
|
// Update position
|
this.dragState.currentPosition = position;
|
|
// Check for significant movement (touch)
|
if (sourceType === 'touch' && !this.dragState.touchMoved) {
|
const deltaX = Math.abs(position.x - startPosition.x);
|
const deltaY = Math.abs(position.y - startPosition.y);
|
|
if (deltaX > 10 || deltaY > 10) {
|
this.dragState.touchMoved = true;
|
}
|
}
|
|
// Update preview and target
|
this.updateDragPreview(position);
|
this.updateDropTarget(elementUnderPointer);
|
}
|
|
endDragOperation(elementUnderPointer = null) {
|
if (!this.dragState.isDragging) return;
|
|
const wasSuccessful = (this.dragState.sourceType === 'drag' || this.dragState.touchMoved) &&
|
this.dragState.validTarget;
|
|
// Process drop if valid - but only here, not in handleDrop
|
if (wasSuccessful && this.dragState.validTarget) {
|
this.processItemDrop({
|
itemIds: this.dragState.draggedItems,
|
targetElement: this.dragState.validTarget,
|
fieldId: this.dragState.fieldId,
|
dropType: this.dragState.isMultiDrag ? 'multiple' : 'single',
|
sourceType: this.dragState.sourceType
|
});
|
}
|
|
// Cleanup
|
this.cleanupDragOperation();
|
|
const announceText = wasSuccessful
|
? (this.dragState.isMultiDrag ? `Moved ${this.dragState.draggedItems.length} items` : 'Item moved')
|
: 'Drag cancelled';
|
|
this.a11y.announce(announceText);
|
}
|
|
/**
|
* Shared method to process any drop operation (drag or touch)
|
* @param {Object} dropData - Standardized drop data
|
* @returns {boolean} Success status
|
*/
|
processItemDrop(dropData) {
|
const {
|
itemIds,
|
targetElement,
|
fieldId,
|
dropType,
|
sourceType
|
} = dropData;
|
|
if (!itemIds?.length || !targetElement || !fieldId) {
|
return false;
|
}
|
|
// Determine if it's a preview drop
|
let isPreviewDrop = targetElement.classList.contains('item-grid') && targetElement.classList.contains('preview');
|
|
// Handle empty group drops by creating the group element
|
let actualTarget = targetElement;
|
if (targetElement.classList.contains('empty-group')) {
|
actualTarget = this.createPostElement(fieldId);
|
isPreviewDrop = false;
|
}
|
|
// Use existing addImageToPost method for each item
|
// This method already handles:
|
// - removeImageFromCurrentLocation (cleanup of old location)
|
// - Adding to new location
|
// - Updating field.posts data structure
|
// - Caching the data
|
itemIds.forEach(uploadId => {
|
this.addImageToPost(uploadId, actualTarget, isPreviewDrop);
|
});
|
|
// Clear selections for multi-drops
|
if (dropType === 'multiple') {
|
const field = this.fields.get(fieldId);
|
this.clearAllSelections(field);
|
}
|
|
// Announce completion
|
const announceText = dropType === 'multiple'
|
? `Moved ${itemIds.length} images to ${isPreviewDrop ? 'main area' : 'group'}`
|
: `Image moved to ${isPreviewDrop ? 'main area' : 'group'}`;
|
|
this.a11y.announce(announceText);
|
this.provideFeedback(sourceType, 'success', {
|
count: itemIds.length,
|
isMultiple: dropType === 'multiple'
|
});
|
|
return true;
|
}
|
|
clearAllSelections(field) {
|
// Clear all selection checkboxes in the entire field container
|
const allCheckboxes = field.container.querySelectorAll('[name*="select-item"]');
|
allCheckboxes.forEach(checkbox => {
|
checkbox.checked = false;
|
});
|
|
// Update the select all state
|
if (field.selectAll) {
|
field.selectAll.checked = false;
|
const label = field.selectAll.nextElementSibling;
|
if (label) {
|
label.textContent = 'Select All';
|
}
|
}
|
|
// Hide selection controls
|
if (field.selectActions) field.selectActions.hidden = true;
|
if (field.selectInfo) field.selectInfo.hidden = true;
|
}
|
|
cleanupDragOperation() {
|
if (this.dragState.dragPreview) {
|
this.dragState.dragPreview.remove();
|
}
|
|
this.applyDraggingState(false);
|
this.clearDropTargetStates();
|
|
// Reset state
|
this.dragState.isDragging = false;
|
this.dragState.dragPreview = null;
|
this.dragState.draggedItems = [];
|
}
|
|
/**
|
* Validate if drag can start
|
*/
|
validateDragStart(element) {
|
//TODO: Likely remove. We are already only listening to [draggable] items
|
return { canDrag: true };
|
}
|
|
/**
|
* Determine what items to drag (single or multiple selection)
|
*/
|
getDraggedItems(element) {
|
const selectedUploads = this.getSelectedUploads(element);
|
const primaryUploadId = element.dataset.uploadId;
|
|
// If we have multiple selections and primary is selected, drag all
|
if (selectedUploads.length > 1 && selectedUploads.includes(primaryUploadId)) {
|
return selectedUploads;
|
}
|
|
// Otherwise, just drag the primary item
|
return [primaryUploadId];
|
}
|
|
/**
|
* Apply/remove dragging visual state to items
|
*/
|
applyDraggingState(isDragging) {
|
this.dragState.draggedItems.forEach(uploadId => {
|
const element = document.querySelector(`[data-upload-id="${uploadId}"]`);
|
if (element) {
|
element.classList.toggle('dragging', isDragging);
|
}
|
});
|
}
|
|
/**
|
* Create drag preview element
|
*/
|
createDragPreview(originalElement) {
|
const { isMultiDrag, draggedItems } = this.dragState;
|
|
if (isMultiDrag) {
|
this.dragState.dragPreview = this.createMultiDragPreview(originalElement, draggedItems);
|
} else {
|
this.dragState.dragPreview = this.createSingleDragPreview(originalElement);
|
}
|
|
this.updateDragPreview(this.dragState.startPosition);
|
document.body.appendChild(this.dragState.dragPreview);
|
}
|
|
/**
|
* Create single item drag preview
|
*/
|
createSingleDragPreview(originalElement) {
|
const preview = originalElement.cloneNode(true);
|
preview.dataset.uploadId = preview.dataset.uploadId+'-dragging';
|
this.styleDragPreview(preview, false);
|
return preview;
|
}
|
|
/**
|
* Create multiple items drag preview
|
*/
|
createMultiDragPreview(originalElement, draggedItems) {
|
const container = document.createElement('div');
|
container.className = 'drag-preview multi-item';
|
|
// Create stacked effect with up to 3 items
|
const displayCount = Math.min(draggedItems.length, 3);
|
|
for (let i = 0; i < displayCount; i++) {
|
const uploadId = draggedItems[i];
|
const uploadElement = document.querySelector(`[data-upload-id="${uploadId}"]`);
|
|
if (uploadElement) {
|
const stackedItem = uploadElement.cloneNode(true);
|
stackedItem.dataset.uploadId = uploadId + '_dragging';
|
|
stackedItem.style.cssText = `
|
position: absolute;
|
top: ${i * 4}px;
|
left: ${i * 4}px;
|
width: calc(100% - ${i * 4}px);
|
height: calc(100% - ${i * 4}px);
|
opacity: ${1 - (i * 0.15)};
|
transform: rotate(${(i - 1) * 2}deg);
|
z-index: ${10 - i};
|
border-radius: 4px;
|
overflow: hidden;
|
`;
|
container.appendChild(stackedItem);
|
}
|
}
|
|
// Add count badge
|
if (draggedItems.length > 1) {
|
const badge = this.createCountBadge(draggedItems.length);
|
container.appendChild(badge);
|
}
|
|
this.styleDragPreview(container, true);
|
return container;
|
}
|
|
isElementInViewport(element) {
|
const rect = element.getBoundingClientRect();
|
return (
|
rect.top >= 0 &&
|
rect.left >= 0 &&
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
);
|
}
|
|
|
/**
|
* Style drag preview element
|
*/
|
styleDragPreview(element, isMulti) {
|
const primaryItem = document.querySelector(`[data-upload-id="${this.dragState.primaryItem}"]`);
|
const rect = primaryItem?.getBoundingClientRect();
|
|
element.className = `drag-preview${isMulti ? ' multi-item' : ''}`;
|
|
// For multi-item previews, use a consistent size rather than original element size
|
const previewSize = isMulti ? { width: 120, height: 120 } : {
|
width: rect?.width || 100,
|
height: rect?.height || 100
|
};
|
|
element.style.cssText = `
|
position: fixed;
|
top: ${rect?.top || 0}px;
|
left: ${rect?.left || 0}px;
|
width: ${previewSize.width}px;
|
height: ${previewSize.height}px;
|
pointer-events: none;
|
z-index: 9999;
|
opacity: 0.8;
|
transform: scale(1.05);
|
transition: none;
|
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
border-radius: 8px;
|
`;
|
}
|
/**
|
* Update drag preview position
|
*/
|
updateDragPreview(position) {
|
if (!this.dragState.dragPreview) return;
|
|
// Calculate offset based on preview type and source
|
let offset;
|
if (this.dragState.sourceType === 'touch') {
|
offset = this.dragState.isMultiDrag ? { x: -60, y: -80 } : { x: -50, y: -60 };
|
} else {
|
offset = this.dragState.isMultiDrag ? { x: 15, y: 15 } : { x: 10, y: 10 };
|
}
|
|
const deltaX = position.x - this.dragState.startPosition.x;
|
const deltaY = position.y - this.dragState.startPosition.y;
|
|
this.dragState.dragPreview.style.transform = `translate(${deltaX + offset.x}px, ${deltaY + offset.y}px) scale(1.05)`;
|
}
|
|
/**
|
* Update drop target highlighting
|
*/
|
updateDropTarget(elementUnderPointer) {
|
// Clear previous target
|
if (this.dragState.currentTarget) {
|
this.clearDropTargetState(this.dragState.currentTarget);
|
}
|
|
// Find valid drop target
|
const validTarget = this.findValidDropTarget(elementUnderPointer);
|
|
// Update state
|
this.dragState.currentTarget = elementUnderPointer;
|
this.dragState.validTarget = validTarget;
|
|
// Apply visual feedback
|
if (validTarget) {
|
this.applyDropTargetState(validTarget);
|
|
// Haptic feedback for touch
|
if (this.dragState.sourceType === 'touch' && navigator.vibrate) {
|
const pattern = this.dragState.isMultiDrag ? [25, 10, 25] : [25];
|
navigator.vibrate(pattern);
|
}
|
}
|
}
|
|
/**
|
* Find valid drop target from element
|
*/
|
findValidDropTarget(element) {
|
if (!element) return null;
|
|
const postContainer = element.closest('.item-grid.group, .empty-group, .item-grid.preview');
|
if (postContainer) {
|
const fieldId = this.getFieldId(postContainer);
|
if (fieldId === this.dragState.fieldId) {
|
return postContainer;
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* Apply drop target visual state
|
*/
|
applyDropTargetState(target) {
|
target.classList.add('dragover');
|
|
if (this.dragState.isMultiDrag) {
|
target.classList.add('multi-drop');
|
target.setAttribute('data-item-count', this.dragState.draggedItems.length);
|
}
|
}
|
|
/**
|
* Clear drop target state from element
|
*/
|
clearDropTargetState(target) {
|
target.classList.remove('dragover', 'multi-drop');
|
target.removeAttribute('data-item-count');
|
}
|
|
/**
|
* Clear all drop target states
|
*/
|
clearDropTargetStates() {
|
document.querySelectorAll('.dragover').forEach(el => {
|
el.classList.remove('dragover', 'multi-drop');
|
el.removeAttribute('data-item-count');
|
});
|
}
|
|
/**
|
* Create count badge for multi-item preview
|
*/
|
createCountBadge(count) {
|
const badge = document.createElement('div');
|
badge.className = 'selection-count-badge';
|
badge.textContent = count.toString();
|
badge.style.cssText = `
|
position: absolute;
|
top: -8px;
|
right: -8px;
|
background: var(--accent-primary);
|
color: white;
|
border-radius: 50%;
|
width: 24px;
|
height: 24px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 12px;
|
font-weight: bold;
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
z-index: 20;
|
`;
|
return badge;
|
}
|
|
/**
|
* Provide feedback for drag operations
|
*/
|
provideDragFeedback(type) {
|
const hapticPatterns = {
|
start: [50],
|
success: this.dragState.isMultiDrag ? [50, 25, 50, 25, 50] : [50, 25, 50],
|
cancel: [100]
|
};
|
|
if (this.dragState.sourceType === 'touch' && navigator.vibrate && hapticPatterns[type]) {
|
navigator.vibrate(hapticPatterns[type]);
|
}
|
}
|
|
/**
|
* Provide consistent feedback for different input methods
|
*/
|
provideFeedback(sourceType, feedbackType, data = {}) {
|
const hapticPatterns = {
|
success: data.isMultiple ? [50, 25, 50, 25, 50] : [50, 25, 50],
|
error: [100, 50, 100]
|
};
|
|
if (sourceType === 'touch' && navigator.vibrate && hapticPatterns[feedbackType]) {
|
navigator.vibrate(hapticPatterns[feedbackType]);
|
}
|
}
|
|
handleExternalFileDrop(e, files) {
|
const uploadContainer = e.target.closest('.file-upload-container');
|
if (uploadContainer && files.length > 0) {
|
const fieldId = this.getFieldId(uploadContainer);
|
if (fieldId) {
|
this.processFiles(fieldId, files);
|
this.a11y.announce(`${files.length} file(s) dropped for upload`);
|
}
|
}
|
}
|
|
clearDragoverStates() {
|
document.querySelectorAll('.dragover').forEach(el => {
|
el.classList.remove('dragover', 'multi-drop');
|
el.removeAttribute('data-item-count');
|
});
|
}
|
/*********
|
* DRAG HANDLERS
|
********/
|
handleDragEnter(e) {
|
if (!window.targetCheck(e, '.image.field')) return;
|
|
// Only handle external files
|
if (e.dataTransfer.types.includes('Files')) {
|
e.preventDefault();
|
const uploadContainer = e.target.closest('.file-upload-container');
|
if (uploadContainer) {
|
uploadContainer.classList.add('dragover');
|
}
|
}
|
}
|
handleDragLeave(e) {
|
if (!window.targetCheck(e, '.image.field')) return;
|
|
const uploadContainer = e.target.closest('.file-upload-container');
|
if (uploadContainer && !uploadContainer.contains(e.relatedTarget)) {
|
uploadContainer.classList.remove('dragover');
|
}
|
}
|
handleDragStart(e) {
|
if (!window.targetCheck(e, '.image.field')) return;
|
|
const uploadItem = e.target.closest('[data-upload-id]');
|
if (!uploadItem) return;
|
|
const result = this.startDragOperation({
|
primaryElement: uploadItem,
|
sourceType: 'drag',
|
startPosition: { x: e.clientX, y: e.clientY },
|
event: e
|
});
|
|
if (result) {
|
e.dataTransfer.setData('text/plain', this.dragState.primaryItem);
|
e.dataTransfer.effectAllowed = 'move';
|
} else {
|
e.preventDefault();
|
}
|
}
|
|
handleDragOver(e) {
|
if (!this.dragState.isDragging) return;
|
if (!window.targetCheck(e, '.image.field')) return;
|
|
e.preventDefault();
|
this.updateDragOperation({ x: e.clientX, y: e.clientY }, e.target);
|
}
|
|
handleDrop(e) {
|
if (!window.targetCheck(e, '.image.field')) return;
|
|
e.preventDefault();
|
this.clearDragoverStates();
|
|
// Handle external files (new uploads)
|
const uploadContainer = e.target.closest('.file-upload-container');
|
if (uploadContainer) {
|
const files = Array.from(e.dataTransfer.files);
|
if (files.length > 0) {
|
const fieldId = this.getFieldId(uploadContainer);
|
if (fieldId) {
|
this.processFiles(fieldId, files);
|
this.a11y.announce(`${files.length} file(s) dropped for upload`);
|
}
|
}
|
return;
|
}
|
|
// DON'T handle internal drops here - let endDragOperation handle them
|
// This prevents double processing
|
}
|
|
handleDragEnd(e) {
|
if (!this.dragState.isDragging) return;
|
|
// Find the element under the final drop position
|
const elementUnderDrop = document.elementFromPoint(
|
this.dragState.currentPosition?.x || e.clientX,
|
this.dragState.currentPosition?.y || e.clientY
|
);
|
|
this.endDragOperation(elementUnderDrop);
|
}
|
/*********
|
* TOUCH HANDLERS
|
********/
|
handleTouchStart(e) {
|
if (!window.targetCheck(e, '.image.field')) return;
|
if (this.isTouchOnFormElement(e.target)) {
|
return;
|
}
|
|
const uploadItem = e.target.closest('[data-upload-id]');
|
if (!uploadItem) return;
|
|
const touch = e.touches[0];
|
|
const result = this.startDragOperation({
|
primaryElement: uploadItem,
|
sourceType: 'touch',
|
startPosition: { x: touch.clientX, y: touch.clientY },
|
event: e
|
});
|
|
if (result) {
|
e.preventDefault(); // Prevent scrolling
|
}
|
}
|
|
isTouchOnFormElement(target) {
|
// Check if target is a form element or inside one
|
const formElements = [
|
'input', 'button', 'label', 'select', 'textarea',
|
'.upload-select', '.item-select', '[type="checkbox"]',
|
'[type="radio"]', '.form-control'
|
];
|
|
return formElements.some(selector => {
|
return target.matches(selector) || target.closest(selector);
|
});
|
}
|
|
handleTouchMove(e) {
|
if (!this.dragState.isDragging) return;
|
|
e.preventDefault();
|
const touch = e.touches[0];
|
const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
this.updateDragOperation({ x: touch.clientX, y: touch.clientY }, elementUnderTouch);
|
}
|
|
handleTouchEnd(e) {
|
if (!this.dragState.isDragging) return;
|
|
e.preventDefault();
|
const touch = e.changedTouches[0];
|
const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
this.endDragOperation(elementUnderTouch);
|
}
|
|
handleTouchCancel(e) {
|
if (this.dragState.isDragging) {
|
this.cleanupDragOperation();
|
this.a11y.announce('Drag cancelled');
|
}
|
}
|
|
|
/*****************************************************
|
*
|
* Handle upload selection
|
*
|
****************************************************/
|
/**
|
* Handle select all functionality
|
*/
|
handleSelectAll(element, checked = null) {
|
const field = this.getField(element);
|
if (!field) return;
|
|
// Use element's checked state if not provided
|
if (checked === null) {
|
checked = element.checked;
|
}
|
|
const target = field.previewGrid;
|
const previewItems = target.querySelectorAll('[data-upload-id]') || [];
|
|
previewItems.forEach(item => {
|
const checkbox = item.querySelector('[name*="select-item"]');
|
if (checkbox) {
|
checkbox.checked = checked;
|
}
|
});
|
|
this.updateSelectAll(element);
|
this.a11y.announce(checked ? 'All uploads selected' : 'All uploads deselected');
|
|
// Clear last clicked since we're selecting/deselecting all
|
this.lastClickedUpload = null;
|
}
|
|
updateSelectAll(element) {
|
const field = this.getField(element);
|
if (!field) return;
|
|
const selected = this.getSelectedUploads(element);
|
if (selected.length > 0 ) {
|
field.selectActions.hidden = false;
|
field.selectInfo.hidden = false;
|
field.selectCount.textContent = `${selected.length}`;
|
} else {
|
field.selectActions.hidden = true;
|
field.selectInfo.hidden = true;
|
}
|
let selectAll = selected.length === field.container.querySelectorAll('.item-grid.preview .upload-item').length;
|
field.selectAll.checked = selectAll;
|
field.selectAll.nextElementSibling.textContent = (selectAll) ? 'Clear Selection' : 'Select All';
|
}
|
|
getSelectedUploads(element) {
|
// Starting from the provided element, we get the closest container of items
|
let grid = element.closest(':has(.item-grid)')?.querySelector('.item-grid') ??
|
(element.classList.contains('item-grid') ? element : this.getField(element).previewGrid);
|
|
// We check for any selected checkboxes, and return an array of uploadIds
|
let uploads = [];
|
grid.querySelectorAll('[name*="select-item"]:checked').forEach(checkbox => {
|
let uploadItem = checkbox.closest('[data-upload-id]'); // FIX 6: Get the element, not ID
|
if (uploadItem) {
|
uploads.push(uploadItem.dataset.uploadId); // FIX 7: Get the ID from dataset
|
}
|
});
|
return uploads;
|
}
|
|
/**
|
* Handle individual upload selection
|
*/
|
handleUploadSelection(element) {
|
// Update the select all state
|
this.updateSelectAll(element);
|
|
// Track this as the last clicked upload
|
this.lastClickedUpload = this.getUploadId(element);
|
|
}
|
|
handleRangeSelection(currentElement, event) {
|
const field = this.getField(currentElement);
|
if (!field) return;
|
|
const currentUploadId = this.getUploadId(currentElement);
|
if (!currentUploadId || !this.lastClickedUpload) return;
|
|
// Get all upload items in the preview grid
|
const previewGrid = field.previewGrid;
|
const allItems = Array.from(previewGrid.querySelectorAll('[data-upload-id]'));
|
|
// Find indices of first and current items
|
const firstIndex = allItems.findIndex(item =>
|
item.dataset.uploadId === this.lastClickedUpload
|
);
|
const currentIndex = allItems.findIndex(item =>
|
item.dataset.uploadId === currentUploadId
|
);
|
|
if (firstIndex === -1 || currentIndex === -1) return;
|
|
// Determine range (handle both directions)
|
const startIndex = Math.min(firstIndex, currentIndex);
|
const endIndex = Math.max(firstIndex, currentIndex);
|
|
// Select all items in range (including the clicked one!)
|
for (let i = startIndex; i <= endIndex; i++) {
|
const item = allItems[i];
|
const checkbox = item.querySelector('[name*="select-item"]');
|
if (checkbox) {
|
checkbox.checked = true;
|
}
|
}
|
|
currentElement.checked = true;
|
// Update selection UI
|
this.updateSelectAll(currentElement);
|
|
// Announce the range selection
|
const selectedCount = endIndex - startIndex + 1;
|
this.a11y.announce(`Selected ${selectedCount} items in range`);
|
|
// Update the last clicked item to the current one
|
this.lastClickedUpload = currentUploadId;
|
}
|
|
removeSelection(button) {
|
let fieldId = this.getFieldId(button);
|
|
const selectedUploads = this.getSelectedUploads(button);
|
if (selectedUploads.length === 0) {
|
this.notify('No uploads selected', 'warning');
|
return;
|
}
|
|
selectedUploads.forEach(upload => {
|
this.removeUpload(fieldId, upload);
|
});
|
}
|
|
|
/*****************************************
|
*
|
* META
|
*
|
****************************************/
|
/**
|
* Schedule debounced metadata update
|
*/
|
|
/**
|
* Send metadata update to server
|
*/
|
getUploadMeta() {
|
let meta = {};
|
this.uploads.forEach(upload => {
|
let item = {
|
id: upload.id, //either the generated ID, or the attachment id
|
meta: upload.meta
|
};
|
if (upload.operationId) {
|
item.operationId = upload.operationId;
|
}
|
meta[upload.id] = item;
|
});
|
return meta;
|
}
|
async maybeUpdateImageMeta() {
|
let metaData = this.getUploadMeta();
|
let changes = window.getDifferences.map(this.oldUploads, metaData);
|
|
|
if (window.isEmptyObject(changes)) return;
|
|
try {
|
const operation = {
|
endpoint: 'uploads/meta',
|
method: 'POST',
|
title: 'Saving image meta',
|
popup: `Sending ${changes.length} changes to server...`,
|
canMerge: true,
|
type: 'image_meta',
|
headers: { 'action_nonce': jvbSettings.dash},
|
user: jvbSettings.currentUser,
|
field: field.id,
|
data: changes,
|
}
|
|
let dependencies = new Set();
|
for (const [uploadId, settings] of metaData) {
|
if (settings.operationId) {
|
dependencies.add(settings.operationId);
|
}
|
}
|
if (dependencies.size > 0) {
|
operation['depends_on'] = [ ... dependencies];
|
}
|
|
let operationId = this.queue.addToQueue(operation);
|
|
} catch (error) {
|
throw error;
|
} finally {
|
this.oldUploads = this.uploads;
|
}
|
}
|
|
/**
|
* Process pending metadata when upload completes
|
*/
|
processPendingMetadata(uploadId) {
|
if (!this.metadataPending.has(uploadId)) return;
|
|
const pendingMetadata = this.metadataPending.get(uploadId);
|
if (Object.keys(pendingMetadata).length === 0) return;
|
|
// Send all pending metadata at once
|
this.sendMetadataUpdate(uploadId, pendingMetadata);
|
this.metadataPending.delete(uploadId);
|
}
|
/********************************************************************
|
*
|
* Queueing and Updates
|
*
|
*******************************************************************/
|
|
async queueUpload(fieldId) {
|
// Cache data before queuing
|
this.cachePostData(fieldId);
|
|
const field = this.fields.get(fieldId);
|
if (!field?.uploads) return;
|
|
const operationData = this.prepareUploadData(fieldId);
|
|
if (!operationData || !operationData.data) {
|
console.warn('No operation data prepared for field:', fieldId);
|
return;
|
}
|
|
// At this point, we're in the initial upload phase - just use field.uploads
|
let uploadIds = [];
|
|
if (field.uploads && field.uploads.size > 0) {
|
uploadIds = Array.from(field.uploads);
|
} else {
|
console.warn('No uploads found in field.uploads for field:', fieldId);
|
}
|
|
// Validate we have uploads to process
|
if (uploadIds.length === 0) {
|
console.warn('No uploads found to queue for field:', fieldId);
|
return;
|
}
|
|
uploadIds.forEach(upload => {
|
this.updateUploadStatus(upload, 'uploading', 'Uploading image');
|
});
|
this.a11y.announce(`Queuing for upload`);
|
|
const operation = {
|
endpoint: "uploads",
|
method: "POST",
|
data: operationData.data,
|
title: this.getOperationTitle(field, uploadIds),
|
popup: `Uploading ${uploadIds.length} file(s)...`,
|
canMerge: false,
|
type: field.groupable ? 'batch_creation' : 'image_upload',
|
headers: { 'action_nonce': jvbSettings.dash },
|
user: jvbSettings.currentUser,
|
append: '_upload',
|
onUpdate: (operation) => this.handleUpdate(operation),
|
onComplete: (operation) => this.handleCompletion(operation)
|
};
|
|
try {
|
const operationId = await this.queue.addToQueue(operation);
|
|
this.activeOperations.set(operationId, {
|
uploadIds: uploadIds,
|
fieldId: field.id,
|
status: 'queued',
|
type: field.groupable ? 'batch_creation' : 'image_upload',
|
startTime: Date.now(),
|
retryCount: 0,
|
});
|
|
// Store operation ID in uploads
|
for (const uploadId of uploadIds) {
|
const upload = this.uploads.get(uploadId);
|
upload.operationId = operationId;
|
upload.attachmentId = null;
|
this.cacheUpload(upload);
|
}
|
|
if (!field.operationId) {
|
field.operationId = new Set();
|
}
|
field.operationId.add(operationId);
|
this.fields.set(field.id, field);
|
this.cachePostData(fieldId);
|
|
this.notify(`Queued ${uploadIds.length} file(s) for upload`, 'info');
|
return operationId;
|
} catch (error) {
|
throw error;
|
}
|
}
|
|
prepareUploadData(fieldId) {
|
const field = this.fields.get(fieldId);
|
const formData = new FormData();
|
|
// Standard field metadata
|
formData.append('content', field.content);
|
formData.append('mode', field.mode);
|
formData.append('field_name', field.container.dataset.field);
|
formData.append('field_id', field.id);
|
formData.append('type', field.type);
|
if (field.container.dataset.postId) {
|
formData.append('post_id', field.container.dataset.postId);
|
} else if (field.container.dataset.termId) {
|
formData.append('term_id', field.container.dataset.termId);
|
}
|
|
let index = 0;
|
|
let uploadMap = [];
|
if (field.uploads && field.uploads.size > 0) {
|
field.uploads.forEach(uploadId => {
|
const upload = this.uploads.get(uploadId);
|
if (upload) {
|
const file = upload.processedFile || upload.originalFile;
|
formData.append(`files[${index}]`, file);
|
uploadMap.push(uploadId);
|
|
index++;
|
}
|
});
|
} else {
|
console.warn('No uploads found in field.uploads for field:', fieldId);
|
}
|
|
formData.append('upload_map', uploadMap);
|
console.log('Gathered data:');
|
for (var [key, value] of formData.entries()) {
|
console.log(key, value);
|
}
|
return {data: formData};
|
}
|
|
/**
|
* Handle queue updates
|
*/
|
handleUpdate(queueItem) {
|
console.log('Updating item in Uploader...', queueItem);
|
|
const operation = this.activeOperations.get(queueItem.id);
|
|
if (queueItem.status !== operation.status) {
|
const mapping = this.statusMapping[queueItem.status] || {
|
status: queueItem.status,
|
message: queueItem.status
|
};
|
|
// Update progress calculation
|
const progress = this.calculateProgress(queueItem);
|
operation.status = queueItem.status;
|
operation.uploadIds.forEach(id => {
|
const upload = this.uploads.get(id);
|
upload.status = mapping.status;
|
upload.progress = {
|
percent: progress,
|
message: mapping.message,
|
serverStatus: queueItem.status
|
};
|
|
this.uploads.set(id, upload);
|
this.cacheUpload(upload);
|
this.updateImageUI(id);
|
});
|
|
if (operation.type === 'image_upload') {
|
//TODO: Gather any metadata changes
|
//TODO: Add changes to queue
|
//TODO: Remove preview items
|
//TODO: Send onUpdate to caller
|
}
|
}
|
}
|
|
|
/**
|
* Handle operation completion
|
*/
|
handleCompletion(queueItem) {
|
console.log('Completion item: ', queueItem);
|
const operation = this.activeOperations.get(queueItem.id);
|
const mapping = this.statusMapping['completed'] || {
|
status: 'completed',
|
message: 'Everything\'s ready!'
|
};
|
|
console.log('Grabbed operation: ', operation);
|
// Update progress calculation
|
const progress = this.calculateProgress(queueItem);
|
operation.status = 'completed';
|
operation.uploadIds.forEach(id => {
|
const upload = this.uploads.get(id);
|
|
upload.status = mapping.status;
|
upload.progress = {
|
percent: progress,
|
message: mapping.message,
|
serverStatus: 'completed'
|
};
|
|
this.uploads.set(id, upload);
|
this.cacheUpload(upload);
|
this.updateImageUI(id);
|
});
|
|
|
if (operation.type === 'image_upload') {
|
//TODO: Gather any metadata changes
|
//TODO: Add changes to queue
|
//TODO: Remove preview items
|
//TODO: Send onUpdate to caller
|
}
|
// const operation = this.activeOperations.get(item.id);
|
// if (!operation || operation.type !== 'batch') return;
|
//
|
// if (item.status === 'completed' && item.result?.success) {
|
// const responseData = item.result.data || [];
|
// const isArray = Array.isArray(responseData);
|
//
|
// operation.uploadIds.forEach((uploadId, index) => {
|
// const upload = this.uploads.get(uploadId);
|
// if (upload) {
|
// const fileData = isArray ? responseData[index] : responseData;
|
//
|
// if (fileData) {
|
// upload.uploadedData = {
|
// attachment_id: fileData.attachment_id,
|
// url: fileData.url,
|
// file_path: fileData.file_path,
|
// metadata: fileData.metadata || {}
|
// };
|
//
|
// // Store attachment ID and clear operation ID
|
// upload.attachmentId = fileData.attachment_id;
|
// upload.operationId = null; // Clear since upload is complete
|
//
|
// this.updateUploadStatus(upload.id, 'uploaded', 'Upload complete!');
|
// this.updateFieldValue(operation.fieldId, upload);
|
//
|
// // Process any pending metadata updates
|
// this.processPendingMetadata(uploadId);
|
// } else {
|
// this.updateUploadStatus(upload.id, 'error', 'No data received from server');
|
// }
|
// }
|
// });
|
//
|
// const successCount = operation.uploadIds.filter(id =>
|
// this.uploads.get(id)?.status === 'uploaded'
|
// ).length;
|
//
|
// this.a11y.announce(`Successfully uploaded ${successCount} of ${operation.uploadIds.length} files`);
|
// this.notify(`Successfully uploaded ${successCount} file(s)`, 'success');
|
//
|
// const field = this.fields.get(operation.fieldId);
|
// if (field?.onUploadComplete) {
|
// field.onUploadComplete({
|
// ...item.result,
|
// uploadedFiles: responseData,
|
// fieldId: operation.fieldId
|
// });
|
// }
|
// }
|
//
|
this.activeOperations.delete(queueItem.id);
|
// this.updateFieldUI(operation.fieldId);
|
}
|
|
|
|
/**
|
* Calculate progress from queue item
|
*/
|
calculateProgress(queueItem) {
|
const progressMap = {
|
'queued': 5,
|
'pending': 15,
|
'processing': 50,
|
'uploading': 75,
|
'completed': 100,
|
'failed': 0,
|
'failed_permanent': 0
|
};
|
|
let baseProgress = progressMap[queueItem.status] || 0;
|
|
// Add incremental progress if available
|
if (queueItem.progress) {
|
const increment = queueItem.progress.percentage || 0;
|
baseProgress = Math.min(100, baseProgress + (increment * 0.8));
|
}
|
|
return Math.round(baseProgress);
|
}
|
|
/**
|
* Generate operation title for UI
|
*/
|
getOperationTitle(field, uploads) {
|
const fileCount = uploads.length;
|
const fieldTypeNames = {
|
'single': 'Image',
|
'gallery': 'Gallery',
|
};
|
|
const typeName = fieldTypeNames[field.type] || 'File';
|
|
if (fileCount === 1) {
|
return `Uploading ${typeName}`;
|
} else {
|
return `Uploading ${fileCount} ${typeName}s`;
|
}
|
}
|
|
|
/******************************************************************
|
*
|
* UI
|
*
|
*****************************************************************/
|
|
|
|
/**
|
* Update upload status
|
*/
|
updateUploadStatus(uploadId, status, message = '') {
|
console.log('Updating upload status');
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
console.log('Updating ', upload);
|
console.log('Status: ', status);
|
console.log('Message: ', message);
|
|
upload.status = status;
|
upload.progress = upload.progress || {};
|
upload.progress.message = message;
|
|
if (status === 'uploaded') {
|
upload.progress.percent = 100;
|
} else if (status === 'error') {
|
upload.error = message;
|
}
|
|
this.updateImageUI(uploadId);
|
}
|
|
/**
|
* Get status icon
|
*/
|
getStatusIcon(status) {
|
return window.getIcon(this.queue.icons[status]);
|
}
|
|
/*******************************************************************
|
*
|
* Cache Handling
|
*
|
******************************************************************/
|
|
/**
|
* Resume pending uploads from previous session
|
*/
|
async resumePendingUploads() {
|
return;
|
//TODO: Old system
|
try {
|
console.log('Checking for pending uploads...');
|
const pendingUploads = await this.cache.getImagesPendingByOperation();
|
|
if (pendingUploads.length > 0) {
|
console.log(`Found ${pendingUploads.length} pending uploads`);
|
|
// Group by field
|
const fieldGroups = new Map();
|
pendingUploads.forEach(upload => {
|
const fieldId = upload.metadata?.uploadConfig?.fieldId;
|
if (fieldId) {
|
if (!fieldGroups.has(fieldId)) {
|
fieldGroups.set(fieldId, []);
|
}
|
fieldGroups.get(fieldId).push(upload);
|
}
|
});
|
|
// Show selective restore for each field
|
for (const [fieldId, uploads] of fieldGroups) {
|
await this.showRestoreNotification(fieldId, uploads);
|
}
|
}
|
} catch (error) {
|
console.error('Failed to resume pending uploads:', error);
|
}
|
}
|
|
async showRestoreNotification(fieldId, cachedUploads) {
|
const field = this.fields.get(fieldId);
|
if (!field) {
|
console.warn(`Cannot show restore for unknown field: ${fieldId}`);
|
return;
|
}
|
|
// Pre-fetch preview images for all cached uploads
|
console.log(`Pre-fetching ${cachedUploads.length} preview images...`);
|
const uploadsWithPreviews = await this.fetchPreviewsForCachedUploads(cachedUploads);
|
|
// Create restore notification with actual preview images
|
const notification = this.createSelectiveRestoreNotification(fieldId, uploadsWithPreviews);
|
|
// Insert into field container
|
field.container.insertBefore(notification, field.container.firstChild);
|
|
// Auto-hide after 60 seconds if no interaction
|
// setTimeout(() => {
|
// if (notification.parentNode) {
|
// this.dismissCacheCheck(fieldId);
|
// }
|
// }, 60000);
|
}
|
|
async fetchPreviewsForCachedUploads(cachedUploads) {
|
const uploadsWithPreviews = [];
|
|
for (const upload of cachedUploads) {
|
const uploadWithPreview = { ...upload };
|
|
try {
|
// Get cached image data
|
const cachedFile = await this.cache.getImagePending(upload.id);
|
|
if (cachedFile && cachedFile.imageData) {
|
// Create blob from cached data
|
const blob = new Blob([cachedFile.imageData], {
|
type: cachedFile.metadata?.type || 'image/jpeg'
|
});
|
|
// Create preview URL
|
uploadWithPreview.previewUrl = URL.createObjectURL(blob);
|
|
console.log(`Created preview for ${upload.metadata?.originalName || upload.id}`);
|
} else {
|
console.warn(`No cached image data found for upload ${upload.id}`);
|
uploadWithPreview.previewUrl = null;
|
}
|
} catch (error) {
|
console.error(`Failed to fetch preview for upload ${upload.id}:`, error);
|
uploadWithPreview.previewUrl = null;
|
}
|
|
uploadsWithPreviews.push(uploadWithPreview);
|
}
|
|
return uploadsWithPreviews;
|
}
|
|
createSelectiveRestoreNotification(fieldId, cachedUploads) {
|
let notification = window.getTemplate('restoreNotification');
|
notification.dataset.fieldId = fieldId;
|
|
notification.querySelector('.restore-details').textContent = `We found ${cachedUploads.length} image(s) from your previous session. You can restore them here if you'd like, or you can start over.`;
|
|
let field = this.fields.get(fieldId);
|
|
let container = notification.querySelector('.item-grid');
|
// ${cachedUploads.map((upload, index) => this.createRestoreItemHTML(upload, index)).join('')}
|
cachedUploads.forEach((upload, index) => {
|
let item = window.getTemplate('restoreItem');
|
|
const fileName = upload.metadata?.originalName || `Upload ${index + 1}`;
|
const fileSize = upload.metadata?.originalSize || 0;
|
const hasPreview = upload.previewUrl;
|
|
let preview = item.querySelector('.preview');
|
if (hasPreview) {
|
preview.querySelector('div').remove();
|
let img = preview.querySelector('img');
|
[
|
img.src,
|
img.alt
|
] = [
|
upload.previewUrl,
|
fileName
|
];
|
} else {
|
preview.querySelector('img').remove();
|
}
|
|
[
|
item.dataset.id,
|
item.querySelector('.name').textContent,
|
item.querySelector('input').id,
|
item.querySelector('input').name,
|
item.htmlFor
|
] = [
|
upload.id,
|
fileName,
|
`restore-${upload.id}`,
|
`restore-${upload.id}`,
|
`restore-${upload.id}`,
|
];
|
|
container.append(item);
|
});
|
|
// Attach event listeners
|
this.attachRestoreEventListeners(notification, fieldId, cachedUploads);
|
|
return notification;
|
}
|
|
|
attachRestoreEventListeners(notification, fieldId, cachedUploads) {
|
const selectAll = notification.querySelector('.select-all-restore');
|
const selectNone = notification.querySelector('.select-none-restore');
|
const restoreSelected = notification.querySelector('.restore-selected');
|
const deleteCache = notification.querySelector('.delete-cache');
|
const dismiss = notification.querySelector('.dismiss-cache-check');
|
|
// Cleanup function for preview URLs
|
const cleanupPreviewUrls = () => {
|
cachedUploads.forEach(upload => {
|
if (upload.previewUrl && upload.previewUrl.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.previewUrl);
|
}
|
});
|
};
|
|
// Select all/none functionality
|
selectAll?.addEventListener('click', () => {
|
notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = true);
|
});
|
|
selectNone?.addEventListener('click', () => {
|
notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = false);
|
});
|
|
// Restore selected items
|
restoreSelected?.addEventListener('click', async () => {
|
const selectedCheckboxes = notification.querySelectorAll('.restore-checkbox:checked');
|
const selectedIds = Array.from(selectedCheckboxes).map(cb =>
|
cb.id.replace('restore-', '')
|
);
|
|
if (selectedIds.length === 0) {
|
this.notify('No items selected for restore', 'warning');
|
return;
|
}
|
|
// Restore selected uploads
|
const selectedUploads = cachedUploads.filter(upload =>
|
selectedIds.includes(upload.id)
|
);
|
|
await this.restoreSelectedUploads(fieldId, selectedUploads);
|
|
// Cleanup preview URLs
|
cleanupPreviewUrls();
|
|
// Remove notification
|
notification.remove();
|
|
this.notify(`Restored ${selectedIds.length} item(s)`, 'success');
|
});
|
|
// Delete all cache
|
deleteCache?.addEventListener('click', async () => {
|
if (confirm('This will permanently delete all cached data. Are you sure?')) {
|
await this.clearFieldCache(fieldId);
|
|
// Cleanup preview URLs
|
cleanupPreviewUrls();
|
|
notification.remove();
|
this.notify('Cache cleared', 'info');
|
}
|
});
|
|
// Dismiss notification
|
dismiss?.addEventListener('click', () => {
|
// Cleanup preview URLs
|
cleanupPreviewUrls();
|
|
notification.remove();
|
});
|
}
|
|
async restoreSelectedUploads(fieldId, selectedUploads) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
let operations = new Set();
|
for (const cachedUpload of selectedUploads) {
|
try {
|
console.log('upload', cachedUpload);
|
const upload = await this.getCachedUpload(fieldId, cachedUpload);
|
operations.add(upload.operationId);
|
|
// Add to field
|
if (!field.uploads) field.uploads = new Set();
|
field.uploads.add(upload.id);
|
|
|
// Add to main preview (not auto-grouped)
|
this.addImageToPost(upload.id, field.previewGrid, true);
|
|
} catch (error) {
|
console.error(`Failed to restore upload ${cachedUpload.id}:`, error);
|
}
|
}
|
field.operationId = operations;
|
this.fields.set(fieldId, field);
|
|
// Update field UI
|
this.maybeLockUploads(fieldId);
|
|
if (field.type === 'groupable') {
|
field.container.querySelector('.group-display').hidden = false;
|
}
|
}
|
|
async clearFieldCache(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
try {
|
// 1. Clear IndexedDB stores for this field
|
await this.clearFieldIndexedDB(fieldId);
|
|
// 2. Clear memory cache for field-related data
|
this.clearFieldMemoryCache(fieldId);
|
|
// 3. Clear HTTP headers related to field uploads
|
this.clearFieldHttpHeaders(fieldId);
|
|
// 4. Clear any pending metadata updates
|
this.clearFieldMetadata(fieldId);
|
|
// 5. Clear performance monitoring data
|
this.clearFieldPerformanceData(fieldId);
|
|
console.log(`Comprehensive cache clearing completed for field: ${fieldId}`);
|
|
} catch (error) {
|
console.error(`Failed to clear field cache for ${fieldId}:`, error);
|
throw error;
|
}
|
}
|
|
/**
|
* Clear IndexedDB data specific to a field
|
*/
|
async clearFieldIndexedDB(fieldId) {
|
if (!this.cache.imageDB) return;
|
|
try {
|
// Clear pending images for this field
|
const pendingImages = await this.cache.getImagesPendingByField(fieldId);
|
|
for (const image of pendingImages) {
|
await this.cache.removeImagePending(image.id);
|
}
|
|
// Clear group data for this field
|
await this.cache.clearGroupsForField(fieldId);
|
|
// Clear any cached field groups
|
const cacheKey = `field_groups_${fieldId}`;
|
await this.cache.removeCacheItem(cacheKey);
|
|
console.log(`Cleared IndexedDB data for field: ${fieldId}`);
|
|
} catch (error) {
|
console.error(`Failed to clear IndexedDB for field ${fieldId}:`, error);
|
}
|
}
|
|
/**
|
* Clear memory cache data related to a field
|
*/
|
clearFieldMemoryCache(fieldId) {
|
if (!this.cache._memoryCache) return;
|
|
const keysToRemove = [];
|
|
// Find all memory cache keys related to this field
|
for (const [key, value] of this.cache._memoryCache) {
|
// Check for field-specific cache keys
|
if (key.includes(fieldId) ||
|
key.startsWith(`pending_image_`) ||
|
key.startsWith(`post_${fieldId}_`) ||
|
key.startsWith(`upload_${fieldId}_`) ||
|
(value && value.fieldId === fieldId)) {
|
keysToRemove.push(key);
|
}
|
}
|
|
// Remove identified keys
|
keysToRemove.forEach(key => {
|
this.cache._memoryCache.delete(key);
|
});
|
|
console.log(`Cleared ${keysToRemove.length} memory cache entries for field: ${fieldId}`);
|
}
|
|
/**
|
* Clear HTTP headers related to field operations
|
*/
|
clearFieldHttpHeaders(fieldId) {
|
if (!this.cache.httpHeaders) return;
|
|
const headersToRemove = [];
|
|
// Find HTTP headers related to upload operations for this field
|
for (const [key, headerData] of this.cache.httpHeaders) {
|
if (key.includes('uploads') ||
|
key.includes(fieldId) ||
|
headerData.url?.includes('uploads')) {
|
headersToRemove.push(key);
|
}
|
}
|
|
// Remove identified headers
|
headersToRemove.forEach(key => {
|
this.cache.httpHeaders.delete(key);
|
});
|
|
// Force save the updated headers
|
if (headersToRemove.length > 0) {
|
this.cache.saveHttpHeaders();
|
console.log(`Cleared ${headersToRemove.length} HTTP headers for field: ${fieldId}`);
|
}
|
}
|
|
/**
|
* Clear pending metadata updates for a field
|
*/
|
clearFieldMetadata(fieldId) {
|
if (!this.metadataUpdateTimers || !this.metadataPending || !this.metadataQueue) return;
|
|
const field = this.fields.get(fieldId);
|
if (!field || !field.uploads) return;
|
|
// Clear metadata timers for uploads in this field
|
for (const uploadId of field.uploads) {
|
// Clear update timers
|
for (const [timerKey, timer] of this.metadataUpdateTimers) {
|
if (timerKey.startsWith(uploadId)) {
|
clearTimeout(timer);
|
this.metadataUpdateTimers.delete(timerKey);
|
}
|
}
|
|
// Clear pending metadata
|
this.metadataPending.delete(uploadId);
|
this.metadataQueue.delete(uploadId);
|
}
|
|
console.log(`Cleared metadata updates for field: ${fieldId}`);
|
}
|
|
/**
|
* Clear performance monitoring data for a field
|
*/
|
clearFieldPerformanceData(fieldId) {
|
if (!this.performanceMonitor || !this.performanceMonitor.metrics) return;
|
|
const field = this.fields.get(fieldId);
|
if (!field || !field.uploads) return;
|
|
// Clear performance metrics for uploads in this field
|
for (const uploadId of field.uploads) {
|
this.performanceMonitor.metrics.delete(uploadId);
|
}
|
|
console.log(`Cleared performance data for field: ${fieldId}`);
|
}
|
|
/**
|
* Reset field to its initial state
|
*/
|
resetFieldToInitialState(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Reset field properties to initial values
|
field.uploads = new Set();
|
field.posts = new Map(); // Replace groups with posts
|
field.status = 'ready';
|
|
// Clear any operation IDs
|
for (const [operationId, operation] of this.activeOperations) {
|
if (operation.fieldId === fieldId) {
|
this.activeOperations.delete(operationId);
|
}
|
}
|
|
// Reset file input
|
if (field.input) {
|
field.input.value = '';
|
}
|
|
// Reset hidden value input
|
if (field.hiddenValue) {
|
field.hiddenValue.value = '';
|
}
|
|
// Clear any progress indicators
|
const progressBars = field.container.querySelectorAll('.progress');
|
progressBars.forEach(bar => bar.remove());
|
|
// Reset drag state for this field
|
this.initializeDragState();
|
|
console.log(`Reset field ${fieldId} to initial state`);
|
}
|
|
|
/**
|
* Handle start over action
|
*/
|
async clearCache(fieldId) {
|
// Show confirmation dialog
|
const confirmed = await this.showStartOverConfirmation();
|
|
if (!confirmed) return;
|
|
try {
|
this.a11y.announce('Starting over - clearing all cached data');
|
|
// Clear all uploads for this field (existing functionality)
|
this.cleanField(fieldId);
|
|
// Remove notification
|
this.dismissCacheCheck(fieldId);
|
|
// Hide group display
|
const field = this.fields.get(fieldId);
|
const groupDisplay = field?.container.querySelector('.group-display');
|
if (groupDisplay) {
|
groupDisplay.hidden = true;
|
}
|
|
// Reset field to initial state
|
this.resetFieldToInitialState(fieldId);
|
|
this.notify('Started fresh - all cached data cleared', 'success');
|
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'clearCache',
|
fieldId: fieldId
|
});
|
|
this.notify('Failed to clear cache - please try again', 'error');
|
}
|
}
|
|
/**
|
* Show confirmation dialog for start over
|
*/
|
async showStartOverConfirmation() {
|
return new Promise((resolve) => {
|
let dialog;
|
|
if (window.getTemplate) {
|
dialog = window.getTemplate('startOverConfirmation');
|
} else {
|
// Fallback if template system not available
|
dialog = document.createElement('dialog');
|
dialog.className = 'start-over-confirmation';
|
dialog.innerHTML = `
|
<div class="confirmation-content">
|
<h3>Start Over?</h3>
|
<p>This will permanently delete:</p>
|
<ul>
|
<li>All uploaded images</li>
|
<li>All created groups</li>
|
<li>All metadata and settings</li>
|
</ul>
|
<p><strong>This cannot be undone!</strong></p>
|
<div class="confirmation-actions">
|
<button type="button" class="cancel-start-over">Cancel</button>
|
<button type="button" class="confirm-start-over danger">Yes, Start Over</button>
|
</div>
|
</div>
|
`;
|
}
|
|
document.body.appendChild(dialog);
|
dialog.showModal();
|
|
// Handle buttons
|
dialog.querySelector('.confirm-start-over').addEventListener('click', () => {
|
dialog.close();
|
dialog.remove();
|
resolve(true);
|
});
|
|
dialog.querySelector('.cancel-start-over').addEventListener('click', () => {
|
dialog.close();
|
dialog.remove();
|
resolve(false);
|
});
|
|
// Handle escape key and backdrop click
|
dialog.addEventListener('close', () => {
|
dialog.remove();
|
resolve(false);
|
});
|
|
// Focus the cancel button by default (safer option)
|
dialog.querySelector('.cancel-start-over').focus();
|
});
|
}
|
|
/**
|
* Dismiss restore notification
|
*/
|
dismissCacheCheck(fieldId) {
|
const field = this.fields.get(fieldId);
|
const notification = field?.container.querySelector('.restore-notification');
|
|
if (notification) {
|
// Cleanup any preview URLs before removing
|
const previewImages = notification.querySelectorAll('.restore-preview-image');
|
previewImages.forEach(img => {
|
if (img.src && img.src.startsWith('blob:')) {
|
URL.revokeObjectURL(img.src);
|
}
|
});
|
|
// Fade out animation
|
notification.style.opacity = '0';
|
notification.style.transform = 'translateY(-10px)';
|
|
setTimeout(() => {
|
notification.remove();
|
}, 300);
|
}
|
}
|
/**
|
* Cache upload
|
*/
|
async cacheUpload(upload) {
|
return;
|
//TODO: from old cache
|
try {
|
// Store group relationships alongside upload
|
const fieldId = upload.fieldId;
|
const field = this.fields.get(fieldId);
|
|
const cacheData = {
|
...upload.metadata,
|
uploadConfig: {
|
fieldId: upload.fieldId,
|
content: field?.content,
|
isGroupable: field?.type === 'groupable'
|
},
|
originalName: upload.originalFile?.name || 'unknown_file',
|
originalType: upload.originalFile?.type || 'image/jpeg',
|
originalSize: upload.originalFile?.size || 0
|
};
|
|
return await this.cache.storeImagePending(
|
upload.id,
|
upload.processedFile,
|
cacheData,
|
upload.operationId
|
);
|
} catch (error) {
|
console.error('Failed to cache upload:', error);
|
return false;
|
}
|
}
|
async getCachedUpload(fieldId, cachedUpload) {
|
return;
|
//TODO: from old cache
|
try {
|
let operation;
|
if (this.queue.queue.has(cachedUpload.operationId)) {
|
operation = this.queue.queue.get(cachedUpload.operationId);
|
} else {
|
operation = await this.queue.fetchOperation(cachedUpload.operationId);
|
}
|
|
// Recreate upload object with proper structure
|
const upload = {
|
id: cachedUpload.id,
|
fieldId: fieldId,
|
originalFile: null, // Will be set below
|
status: operation?.status ?? 'cached',
|
progress: { percent: 100, message: 'Restored from cache' },
|
meta: cachedUpload.meta || {}, // Use 'meta' instead of 'metadata'
|
preview: null, // Will be regenerated
|
createdAt: cachedUpload.timestamp || Date.now(),
|
cachedData: cachedUpload,
|
operationId: cachedUpload.operationId
|
};
|
|
// Retrieve actual file data from cache
|
const cachedFile = await this.cache.getImagePending(cachedUpload.id);
|
|
if (cachedFile && cachedFile.imageData) {
|
const fileName = cachedUpload.metadata?.originalName ||
|
cachedFile.metadata?.originalName ||
|
'restored_file';
|
const fileType = cachedFile.metadata?.type || 'image/jpeg';
|
|
// Convert cached data back to File object
|
upload.processedFile = new File(
|
[cachedFile.imageData],
|
fileName,
|
{
|
type: fileType,
|
lastModified: cachedUpload.timestamp || Date.now()
|
}
|
);
|
|
// Create a minimal originalFile object for UI compatibility
|
upload.originalFile = {
|
name: fileName,
|
type: fileType,
|
size: cachedFile.imageData.byteLength || cachedFile.imageData.size || 0,
|
lastModified: cachedUpload.timestamp || Date.now()
|
};
|
|
// Create preview URL for immediate display
|
upload.preview = URL.createObjectURL(upload.processedFile);
|
} else {
|
// If we can't get the cached file, create minimal fallback
|
const fileName = cachedUpload.metadata?.originalName || 'restored_file';
|
upload.originalFile = {
|
name: fileName,
|
type: 'image/jpeg',
|
size: 0,
|
lastModified: cachedUpload.timestamp || Date.now()
|
};
|
|
// Create a placeholder preview
|
upload.preview = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2Y1ZjVmNSIvPjx0ZXh0IHg9IjUwIiB5PSI1MCIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjEyIiBmaWxsPSIjOTk5IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+Tm8gUHJldmlldz48L3RleHQ+PC9zdmc+';
|
}
|
|
// Store upload in the uploads map
|
this.uploads.set(upload.id, upload);
|
|
return upload;
|
} catch (error) {
|
console.error('Failed to get cached upload:', error);
|
throw error;
|
}
|
}
|
|
async cachePostData(fieldId) {
|
return;
|
//TODO: From old cache
|
let field = this.fields.get(fieldId);
|
if (!field || field.type !== 'groupable') return;
|
|
const key = `image_field_${fieldId}`;
|
await this.cache.setItem(key, field.posts);
|
}
|
|
async getCachedPostData(fieldId) {
|
const key = `image_field_${fieldId}`;
|
return await this.cache.getItem(key);
|
}
|
|
async deleteCachedPostData(fieldId) {
|
const key = `image_field_${fieldId}`;
|
return await this.cache.removeCacheItem(key);
|
}
|
|
/**
|
* Cleanup upload
|
*/
|
async cleanupUpload(upload) {
|
try {
|
if (this.cache) {
|
await this.cache.removeImagePending(upload.id);
|
|
// Clear field groups cache if needed
|
if (upload.fieldId) {
|
const field = this.fields.get(upload.fieldId);
|
if (field?.type === 'groupable') {
|
await this.cacheFieldGroups(upload.fieldId);
|
}
|
}
|
}
|
} catch (error) {
|
console.error('Failed to cleanup upload:', error);
|
}
|
}
|
|
/******************************************************************
|
*
|
* Utility Methods
|
*
|
*****************************************************************/
|
|
/**
|
* Helper: Check if an upload item is currently selected
|
*/
|
isUploadSelected(uploadId) {
|
return document.querySelector(`[data-upload-id="${uploadId}"]`).checked;
|
}
|
|
getField(element) {
|
return this.fields.get(this.getFieldId(element));
|
}
|
|
/**
|
* Get field ID from any element within the field
|
*/
|
getFieldId(element) {
|
// Try multiple approaches to find the field ID
|
|
// 1. Direct data attribute
|
if (element.dataset.fieldId) {
|
return element.dataset.fieldId;
|
}
|
|
// 2. Look up the DOM tree for field containers
|
const fieldContainer = element.closest('[data-field-id]');
|
if (fieldContainer) {
|
return fieldContainer.dataset.fieldId ||
|
fieldContainer.dataset.field ||
|
fieldContainer.dataset.name;
|
}
|
return null;
|
}
|
|
getFieldStatus(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return null;
|
|
const uploads = Array.from(field.uploads || []).map(id => this.uploads.get(id));
|
|
return {
|
fieldId,
|
type: field.type,
|
uploadCount: uploads.length,
|
ready: uploads.filter(u => u.status === 'processed').length,
|
uploading: uploads.filter(u => u.status === 'uploading').length,
|
completed: uploads.filter(u => u.status === 'uploaded').length,
|
failed: uploads.filter(u => u.status === 'error').length
|
};
|
}
|
|
/**
|
* Get upload ID from element
|
*/
|
getUploadId(element) {
|
const uploadItem = element.closest('[data-upload-id]');
|
return uploadItem?.dataset.uploadId;
|
}
|
|
createFieldId(field) {
|
let input = field.querySelector('input[type=file]');
|
return input.id || input.name || `img_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
}
|
|
generateUploadId() {
|
return `upload_${this.generateID()}`;
|
}
|
generateID() {
|
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
}
|
|
validateFile(file, field) {
|
// Type validation
|
if (!this.settings.allowedTypes.includes(file.type)) {
|
this.notify(`Invalid file type: ${file.type}`, 'error');
|
return false;
|
}
|
|
// Size validation
|
if (file.size > this.settings.maxFileSize) {
|
this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error');
|
return false;
|
}
|
|
return true;
|
}
|
|
checkFieldLimits(fieldId, newFileCount) {
|
const field = this.fields.get(fieldId);
|
const currentCount = field.uploads ? field.uploads.size : 0;
|
|
return (currentCount + newFileCount) <= field.maxFiles;
|
}
|
|
/**
|
* Show notification
|
*/
|
notify(message, type = 'info') {
|
this.notifications.showToast(message, type);
|
}
|
|
formatBytes(bytes) {
|
const sizes = ['Bytes', 'KB', 'MB'];
|
if (bytes === 0) return '0 Bytes';
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
}
|
|
/**
|
* Check if element is an upload trigger
|
*/
|
isUploadTrigger(element) {
|
if (element.type === 'file' || element.tagName === 'LABEL') {
|
return false;
|
}
|
|
if (element.matches('.upload-trigger, .file-upload-button, [data-upload-trigger]')) {
|
return true;
|
}
|
|
const uploadContainer = element.closest('.file-upload-container');
|
if (uploadContainer) {
|
return !element.closest('input, label, button, a');
|
}
|
|
return false;
|
}
|
|
|
/**
|
* Trigger file selection for a field
|
*/
|
triggerFileSelection(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) {
|
console.error('Field not found:', fieldId);
|
return;
|
}
|
|
if (!field.input) {
|
console.error('Field input not found for field:', fieldId, field);
|
return;
|
}
|
|
// Check if field is at capacity
|
if (field.uploads && field.uploads.size >= field.maxFiles) {
|
this.notify(`Maximum ${field.maxFiles} files allowed for this field`, 'warning');
|
return;
|
}
|
|
console.log('Triggering file selection for field:', fieldId, field.input);
|
|
// Make sure the input is not disabled or hidden
|
if (field.input.disabled) {
|
console.warn('Cannot trigger disabled input:', field.input);
|
return;
|
}
|
|
// Trigger the click
|
try {
|
field.input.click();
|
} catch (error) {
|
console.error('Failed to trigger file input click:', error);
|
}
|
}
|
|
/**
|
* Remove an upload
|
*/
|
removeUpload(fieldId, uploadId) {
|
const field = this.fields.get(fieldId);
|
const upload = this.uploads.get(uploadId);
|
|
if (!field || !upload) return;
|
|
// Remove from field's upload set
|
field.uploads.delete(uploadId);
|
|
// CRITICAL FIX: Clean up ALL object URLs
|
if (upload.preview && upload.preview.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
|
// Remove from cache
|
this.cleanupUpload(upload);
|
|
// Remove from uploads map
|
this.uploads.delete(uploadId);
|
|
// Remove from UI
|
this.removeUploadFromUI(uploadId);
|
|
// Update field UI
|
this.maybeLockUploads(fieldId);
|
|
console.log(`Removed upload ${uploadId} from field ${fieldId}`);
|
}
|
|
/********************************************************************
|
*
|
* Cleanup
|
*
|
*******************************************************************/
|
destroy() {
|
// Remove event listeners
|
document.removeEventListener('click', this.handleClick);
|
document.removeEventListener('change', this.handleChange);
|
document.removeEventListener('paste', this.paste);
|
document.removeEventListener('dragstart', this.handleDragStart);
|
document.removeEventListener('dragend', this.handleDragEnd);
|
document.removeEventListener('dragenter', this.handleDragEnter);
|
document.removeEventListener('dragover', this.handleDragOver);
|
document.removeEventListener('dragleave', this.handleDragLeave);
|
document.removeEventListener('drop', this.handleDrop);
|
document.removeEventListener('touchstart', this.handleTouchStart);
|
document.removeEventListener('touchmove', this.handleTouchMove);
|
document.removeEventListener('touchend', this.handleTouchEnd);
|
document.removeEventListener('touchcancel', this.handleTouchCancel);
|
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
|
this.uploads.forEach(upload => {
|
if (upload.preview && upload.preview.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
});
|
|
// Terminate and cleanup worker
|
if (this.compressionWorker) {
|
this.compressionWorker.terminate();
|
this.compressionWorker = null;
|
}
|
|
// Clear performance monitor metrics to prevent memory accumulation
|
if (this.performanceMonitor && this.performanceMonitor.metrics) {
|
this.performanceMonitor.metrics.clear();
|
}
|
|
// Clear drag state
|
this.initializeDragState();
|
|
// Clear all maps
|
this.fields.clear();
|
this.uploads.clear();
|
this.activeOperations.clear();
|
this.oldUploads.clear();
|
|
// Force garbage collection if available
|
if (window.gc) {
|
window.gc();
|
}
|
}
|
|
recordUploadAnalytics(uploadId, success, processingTime = 0) {
|
if (!this.settings.analyticsEnabled) return;
|
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
const analytics = {
|
fieldType: this.fields.get(upload.fieldId)?.type,
|
fileName: upload.originalFile.name,
|
fileSize: upload.originalFile.size,
|
processedSize: upload.processedFile?.size || upload.originalFile.size,
|
processingTime,
|
success,
|
compressionRatio: upload.processedFile ?
|
upload.originalFile.size / upload.processedFile.size : 1,
|
timestamp: Date.now()
|
};
|
|
// Send analytics (could be batched)
|
this.sendUploadAnalytics(analytics);
|
}
|
|
async sendUploadAnalytics(analytics) {
|
try {
|
await fetch(`${jvbSettings.api}analytics/uploads`, {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
'action_nonce': jvbSettings.dash
|
},
|
body: JSON.stringify({ analytics })
|
});
|
} catch (error) {
|
console.warn('Failed to send upload analytics:', error);
|
}
|
}
|
|
}
|
|
// Initialize singleton
|
document.addEventListener('DOMContentLoaded', () => {
|
if (!window.jvbUploadManager) {
|
window.jvbUploadManager = new UploadManager();
|
console.log('Centralized Upload Manager initialized');
|
}
|
});
|
|
// Export for use
|
window.UploadManager = UploadManager;
|
|
|
class UploadPerformanceMonitor {
|
constructor() {
|
this.metrics = new Map();
|
this.averages = {
|
processingTime: 0,
|
uploadSpeed: 0,
|
successRate: 95
|
};
|
}
|
|
startTiming(uploadId, phase) {
|
if (!this.metrics.has(uploadId)) {
|
this.metrics.set(uploadId, {});
|
}
|
this.metrics.get(uploadId)[`${phase}_start`] = performance.now();
|
}
|
|
endTiming(uploadId, phase) {
|
const metrics = this.metrics.get(uploadId);
|
if (metrics && metrics[`${phase}_start`]) {
|
metrics[`${phase}_duration`] = performance.now() - metrics[`${phase}_start`];
|
}
|
}
|
|
recordUploadMetrics(uploadId, fileSize, success) {
|
const metrics = this.metrics.get(uploadId);
|
if (!metrics) return;
|
|
// Calculate upload speed (bytes per second)
|
const totalTime = (metrics.upload_duration || 0) / 1000; // Convert to seconds
|
const uploadSpeed = totalTime > 0 ? fileSize / totalTime : 0;
|
|
// Update running averages
|
this.updateAverages(metrics, uploadSpeed, success);
|
|
// Clean up old metrics (keep last 100)
|
if (this.metrics.size > 100) {
|
const oldestKey = this.metrics.keys().next().value;
|
this.metrics.delete(oldestKey);
|
}
|
}
|
|
updateAverages(metrics, uploadSpeed, success) {
|
// Exponential moving average for responsiveness
|
const alpha = 0.1;
|
|
if (metrics.processing_duration) {
|
this.averages.processingTime = (1 - alpha) * this.averages.processingTime +
|
alpha * metrics.processing_duration;
|
}
|
|
if (uploadSpeed > 0) {
|
this.averages.uploadSpeed = (1 - alpha) * this.averages.uploadSpeed +
|
alpha * uploadSpeed;
|
}
|
|
this.averages.successRate = (1 - alpha) * this.averages.successRate +
|
alpha * (success ? 100 : 0);
|
}
|
|
getOptimalBatchSize() {
|
// Adjust batch size based on performance
|
if (this.averages.uploadSpeed > 1000000) { // > 1MB/s
|
return 5;
|
} else if (this.averages.uploadSpeed > 500000) { // > 500KB/s
|
return 3;
|
} else {
|
return 1; // Slow connection, upload one at a time
|
}
|
}
|
|
/**
|
* Clean up resources
|
*/
|
destroy() {
|
// Clean up blob URLs
|
for (const [uploadId, upload] of this.uploads) {
|
if (upload.preview && upload.preview.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
}
|
|
// Clear maps
|
this.uploads.clear();
|
this.fields.clear();
|
this.subscribers.clear();
|
|
// Remove event listeners
|
document.removeEventListener('click', this.clickHandler);
|
document.removeEventListener('change', this.handleChange);
|
document.removeEventListener('paste', this.paste);
|
document.removeEventListener('dragstart', this.handleDragStart);
|
document.removeEventListener('dragend', this.handleDragEnd);
|
document.removeEventListener('dragenter', this.handleDragEnter);
|
document.removeEventListener('dragover', this.handleDragOver);
|
document.removeEventListener('dragleave', this.handleDragLeave);
|
document.removeEventListener('drop', this.handleDrop);
|
}
|
}
|