class UploadManager {
|
constructor() {
|
//Load dependencies
|
this.queue = window.jvbQueue;
|
this.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
this.notifications = window.jvbNotifications;
|
|
//Load Datastore
|
this.initDB();
|
|
//State management
|
this.fields = new Map();
|
this.uploads = new Map();
|
this.uploadBlobs = new Map();
|
this.timeouts = new Map();
|
this.selected = new Map();
|
|
//Worker
|
this.worker = {
|
worker: null,
|
timeout: null,
|
tasks: new Map(),
|
restart: {
|
count: 0,
|
max: 3,
|
},
|
settings: {
|
timeout: 10000, //10 seconds per image
|
batchSize: 1,
|
maxConcurrent: 3,
|
restartAfterTimeout: true
|
}
|
};
|
|
//Groups!
|
this.touch = {
|
x: null,
|
y: null
|
}
|
this.hasBulkContext = document.querySelector('details.uploader')!==null;
|
this.isTouching = false;
|
this.groups = new Map();
|
|
//Notification and Subscribers
|
this.subscribers = new Set();
|
|
this.settings = {
|
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'],
|
maxFileSize: 5242880,
|
maxProcessingTime: 120000, // 2 minutes max for processing
|
processingCheckInterval: 5000, // Check every 5 seconds
|
smartCompression: true,
|
fieldTypes: {
|
'single': { maxFiles: 1, allowMultiple: false },
|
'gallery': { maxFiles: 20, allowMultiple: true },
|
'groupable': { maxFiles: 20, allowMultiple: true }
|
}
|
};
|
|
this.statusMapping = {
|
'received': 'Image Received',
|
'local_processing': 'Processing Image...',
|
'queued': 'Waiting to upload...',
|
'uploading': 'Uploading to Server',
|
'pending': 'Successfully sent to server. In line for further processing.',
|
'processing': 'Processing on server...',
|
'completed': 'Upload complete!',
|
'failed': 'Upload failed (will retry)',
|
'failed_permanent': 'Upload failed permanently'
|
};
|
|
this.init();
|
}
|
|
async init() {
|
this.initElements();
|
this.initListeners();
|
this.initCompressionWorker();
|
this.queue.subscribe((event, operation) => {
|
console.log('Operation Endpoint: ', operation.endpoint);
|
if (operation.endpoint !== 'uploads') {
|
return;
|
}
|
switch(event) {
|
case 'cancel-operation':
|
this.clearField(operation.data.get('field_key'));
|
break;
|
case 'operation-status':
|
console.log('Operation Data: ',operation.data);
|
const fieldId = operation.data?.field_key ||
|
(operation.data instanceof FormData ?
|
operation.data.get('field_key') : null);
|
|
if (fieldId) {
|
console.log('Updating field status:', fieldId, operation.status);
|
this.updateFieldStatus(fieldId, operation.status);
|
}
|
break;
|
}
|
});
|
await this.checkPendingUploads();
|
this.scanFields();
|
}
|
|
initElements() {
|
this.selectors = {
|
field: {
|
field: '.field.image',
|
dropZone: '.file-upload-container',
|
preview: '.item-grid.preview',
|
hiddenValue: 'input[type="hidden"]',
|
progress: {
|
progress: '.progress',
|
details: '.progress .details',
|
fill: '.progress .fill',
|
count: '.progress .count'
|
},
|
},
|
item: {
|
img: 'img',
|
progress: {
|
progress: '.progress',
|
details: '.progress .details',
|
fill: '.progress .fill',
|
count: '.progress .count'
|
},
|
status: '.status',
|
select: '[name*="select-item"]',
|
actions: '.item-actions',
|
featured: '[name="featured"]',
|
meta: '.upload-meta'
|
},
|
groups: {
|
container: '.item-grid.groups',
|
display: '.group-display',
|
selectAll: '#select-all-uploads',
|
actions: '.selection-actions',
|
info: '.selection-controls .info',
|
count: '.selection-count',
|
group: '.upload-group',
|
empty: '.empty-group'
|
}
|
};
|
this.ui = {};
|
}
|
|
scanFields() {
|
document.querySelectorAll(this.selectors.field.field).forEach(uploader => {
|
this.registerUploader(uploader);
|
});
|
}
|
|
/**
|
*
|
* @param {HTMLElement} uploader
|
* @param {object} options
|
* @param {string} options.id Uploader field ID: defaults to uploader.dataset.fieldId
|
* @param {string} options.type Uploader type: defaults to uploader.dataset.type
|
* @param {number} options.maxFiles Maximum files to allow: defaults to type defaults
|
* @param {boolean} options.multiple Whether to allow multiple uploads
|
* @param {number} options.itemID The post or term ID this is for.
|
* @param {string} options.mode
|
* @returns {string}
|
*/
|
registerUploader(uploader, options = {}) {
|
//Determine if this is for a post, term, content uploader, or option
|
let key = uploader.dataset['uploader']??this.determineKey(uploader);
|
|
uploader.dataset['uploader'] = key;
|
|
if (!this.fields.has(key)) {
|
let type = uploader.dataset.type;
|
|
let typeConfig = this.settings.fieldTypes[type]??this.settings.fieldTypes['single'];
|
let config = {
|
key: key,
|
name: uploader.dataset.field,
|
ui: {},
|
type: type,
|
maxFiles: typeConfig.maxFiles,
|
multiple: typeConfig.allowMultiple,
|
content: uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??false,
|
itemID: uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??false,
|
context: uploader.dataset.context??uploader.closest('dialog')?.dataset.context??false,
|
mode: uploader.dataset.mode??'direct',
|
... options
|
};
|
|
config.ui = window.uiFromSelectors(this.selectors, uploader);
|
config.ui.groups.groups = new Map();
|
|
this.selected.set(key, new Set());
|
this.fields.set(key, config);
|
if(config.type === 'groupable' && !this.hasGroups) {
|
this.initGroupListeners();
|
}
|
}
|
return key;
|
}
|
|
/**
|
* Builds a key from the uploader, built from the Content Type, ItemID, and FieldName
|
* @param uploader
|
* @returns {string}
|
*/
|
determineKey(uploader) {
|
let content = uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??'';
|
let itemID = uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??'';
|
let field = uploader.dataset.field;
|
return `${content}_${itemID}_${field}`;
|
}
|
|
/**
|
*
|
* @param {HTMLElement} element
|
*/
|
getFieldIdFromElement(element) {
|
let field = element.closest('.field.image');
|
if (!field) {
|
return;
|
}
|
return field.dataset.uploader??this.determineKey(field);
|
}
|
|
getFieldFromElement(element) {
|
let id = this.getFieldIdFromElement(element);
|
return (this.fields.has(id)) ? this.fields.get(id) : false;
|
}
|
|
getUploadFromElement(element) {
|
let id = this.getUploadIdFromElement(element);
|
return (this.uploads.has(id)) ? this.uploads.get(id) : false;
|
}
|
|
getUploadIdFromElement(element) {
|
let upload = element.closest('[data-upload-id]');
|
return upload?.dataset.uploadId || null;
|
}
|
|
getGroupFromElement(element) {
|
let groupId = this.getGroupIdFromElement(element);
|
return (this.groups.has(groupId)) ? this.groups.get(groupId) : false;
|
}
|
getGroupIdFromElement(element) {
|
return element.dataset.groupId??element.closest('[data-group-id]')?.dataset.groupId??element.closest(':has([data-group-id])')?.querySelector('[data-group-id]')?.dataset.groupId??null;
|
}
|
|
getModalType(field) {
|
// Safety check for field.ui
|
if (!field || !field.ui || !field.ui.field || !field.ui.field.field) {
|
return null;
|
}
|
|
const dialog = field.ui.field.field.closest('dialog');
|
if (!dialog) return null;
|
|
if (dialog.classList.contains('edit')) return 'edit';
|
if (dialog.classList.contains('create')) return 'create';
|
if (dialog.classList.contains('bulkEdit')) return 'bulkEdit';
|
|
return dialog.className;
|
}
|
|
getStatusText(status) {
|
return this.statusMapping[status] || status;
|
}
|
|
getStatusIcon(status) {
|
return window.getIcon(this.queue.icons[status]);
|
}
|
getStatusProgress(status) {
|
console.log('Getting status progress for: ', status);
|
switch (status) {
|
case 'local_processing':
|
return 28;
|
case 'queued':
|
return 50;
|
case 'uploading':
|
return 66;
|
case 'pending':
|
return 75;
|
case 'processing':
|
return 89;
|
case 'completed':
|
return 100;
|
default:
|
return 0;
|
}
|
}
|
|
/******************************************************************************
|
LISTENERS
|
******************************************************************************/
|
initListeners() {
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
|
if (this.hasBulkContext) {
|
this.pasteHandler = this.handlePaste.bind(this);
|
document.addEventListener('paste', this.pasteHandler);
|
}
|
|
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
|
}
|
clearListeners() {
|
document.removeEventListener('click', this.clickHandler);
|
document.removeEventListener('change', this.changeHandler);
|
if (this.hasBulkContext) {
|
document.removeEventListener('paste', this.pasteHandler);
|
}
|
}
|
|
initGroupListeners() {
|
this.hasGroups = true;
|
|
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('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);
|
document.addEventListener('touchmove', this.touchMoveHandler);
|
document.addEventListener('touchend', this.touchEndHandler);
|
document.addEventListener('touchcancel', this.touchCancelHandler);
|
}
|
clearGroupListeners() {
|
document.removeEventListener('dragstart', this.dragStartHandler);
|
document.removeEventListener('dragend', this.dragEndHandler);
|
document.removeEventListener('dragenter', this.dragEnterHandler);
|
document.removeEventListener('dragover', this.dragOverHandler);
|
document.removeEventListener('dragleave', this.dragLeaveHandler);
|
document.removeEventListener('drop', this.dropHandler);
|
|
document.removeEventListener('touchstart', this.touchStartHandler);
|
document.removeEventListener('touchmove', this.touchMoveHandler);
|
document.removeEventListener('touchend', this.touchEndHandler);
|
document.removeEventListener('touchcancel', this.touchCancelHandler);
|
}
|
|
handleClick(e) {
|
if (!e.target.closest(this.selectors.field.field)) {
|
return;
|
}
|
|
if (window.targetCheck(e, '.restart-uploads')) {
|
e.preventDefault();
|
const fieldId = this.getFieldIdFromElement(e.target);
|
this.restartUploads(fieldId);
|
} else if (window.targetCheck(e, '.dismiss-cache-restore')) {
|
e.preventDefault();
|
const notification = e.target.closest('.upload-recovery-notification');
|
if (notification) notification.remove();
|
} else if (window.targetCheck(e, '#select-all-uploads')) {
|
e.preventDefault();
|
this.handleSelectAll(e.target);
|
} else if (window.targetCheck(e, '.upload-select')) {
|
const isShiftClick = e.shiftKey && this.lastClickedUpload;
|
if (isShiftClick) {
|
e.preventDefault();
|
this.handleRangeSelection(e.target, e);
|
} else {
|
this.updateSelection(e);
|
}
|
} else if (window.targetCheck(e, '.create-from-selection')) {
|
e.preventDefault();
|
let group = this.createGroup(this.getFieldFromElement(e.target));
|
this.addSelectionToGroup(group);
|
} else if (window.targetCheck(e, '.remove-selection')) {
|
e.preventDefault();
|
this.removeSelection(e.target);
|
} else if (window.targetCheck(e, '.add-to-group, .add-selection-to-group')) {
|
e.preventDefault();
|
this.addSelectionToGroup(e.target);
|
} else if (window.targetCheck(e, '.remove-group')) {
|
e.preventDefault();
|
const groupElement = e.target.closest('.upload-group');
|
if (groupElement) {
|
let field = this.getFieldFromElement(groupElement);
|
this.removeGroup(groupElement, true);
|
}
|
} else if (window.targetCheck(e, '.remove')) {
|
e.preventDefault();
|
const uploadId = this.getUploadIdFromElement(e.target);
|
const fieldId = this.getFieldIdFromElement(e.target);
|
if (uploadId && fieldId) {
|
this.removeUpload(fieldId, uploadId);
|
}
|
} else if (window.targetCheck(e, '.submit-uploads')) {
|
e.preventDefault();
|
const fieldId = this.getFieldIdFromElement(e.target);
|
this.submitUploads(fieldId);
|
} else if (window.targetCheck(e, '.retry-upload')) {
|
e.preventDefault();
|
const uploadId = this.getUploadIdFromElement(e.target);
|
this.retryUpload(uploadId);
|
}
|
}
|
handleChange(e) {
|
if (!e.target.closest(this.selectors.field.field) || e.target.classList.contains(this.selectors.field.hiddenValue)) {
|
return;
|
}
|
e.preventDefault();
|
|
if (window.targetCheck(e, '[type="file"]')) {
|
console.log(this.fields);
|
let field = this.getFieldFromElement(e.target);
|
console.log(field);
|
if (!field) {
|
console.warn('File change on unregistered field: ', field.key)
|
return;
|
}
|
|
const files = Array.from(e.target.files);
|
if (files.length === 0) return;
|
|
this.processFiles(field.key, files);
|
e.target.value = '';
|
} else if (e.target.name.includes('select-')) {
|
this.updateSelection(e);
|
} else if (e.target.closest('.upload-meta')) {
|
e.preventDefault();
|
let name = e.target.name;
|
let value = e.target.value;
|
let upload = this.getUploadFromElement(e.target);
|
upload.changes[name] = value;
|
this.uploads.set(upload.id, upload);
|
this.persistFieldState(upload.fieldId);
|
|
//It's meta!
|
//TODO:
|
//Step 1) determine whether the images have already been sent to the server. If not, we must wait until they have been
|
//Step 2) Queue the Meta changes. No need to wait, the Queue.js will handle any debouncing/timeouts
|
//Ensure the dependencies have all operations stored to the field that the images were uploaded with (can be multiple)
|
//Send to server for processing
|
} else if (e.target.closest('.group.fields')) {
|
let group = this.getGroupFromElement(e.target);
|
let name = e.target.name;
|
group.changes[name] = e.target.value;
|
|
this.persistFieldState(group.fieldId);
|
this.groups.set(group.id, group);
|
}
|
}
|
|
handlePaste(e) {
|
window.debouncer.schedule(
|
'imagePaste',
|
() => {
|
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.getFieldIdFromElement(e.target);
|
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);
|
}
|
},
|
100
|
);
|
}
|
|
isTouchOnFormElement(target) {
|
// Check if target is a form element or inside one
|
const formElements = [
|
'input', 'button', 'label', 'select', 'textarea',
|
];
|
|
return formElements.some(selector => {
|
return target.matches(selector) || target.closest(selector);
|
});
|
}
|
/**** DRAG AND TOUCH *****/
|
startDragOperation(config) {
|
const {
|
primaryElement,
|
sourceType,
|
startPosition,
|
event
|
} = config;
|
|
const uploadId = this.getUploadIdFromElement(primaryElement);
|
const fieldId = this.getFieldIdFromElement(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')) {
|
let group = this.createGroup(fieldId);
|
actualTarget = group.querySelector('.item-grid');
|
isPreviewDrop = false;
|
}
|
|
// Use existing addImageToGroup 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.addImageToGroup(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 = [];
|
}
|
|
/**
|
* 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;
|
}
|
|
styleDragPreview(preview, isMulti = false) {
|
preview.style.cssText = `
|
position: fixed;
|
z-index: 10000;
|
pointer-events: none;
|
opacity: 0.9;
|
transform: scale(1.05);
|
transition: transform 0.2s ease;
|
${isMulti ? `
|
width: 120px;
|
height: 120px;
|
background: white;
|
border-radius: 8px;
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
padding: 4px;
|
` : `
|
border-radius: 4px;
|
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
`}
|
`;
|
|
// Add dragging class for additional styling
|
preview.classList.add('drag-preview', 'is-dragging');
|
if (isMulti) {
|
preview.classList.add('multi-item');
|
}
|
}
|
|
/**
|
* 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;
|
}
|
/**
|
* 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.getFieldIdFromElement(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]);
|
}
|
}
|
|
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.getFieldIdFromElement(uploadContainer);
|
if (fieldId) {
|
this.processFiles(fieldId, files);
|
this.a11y.announce(`${files.length} file(s) dropped for upload`);
|
}
|
}
|
}
|
}
|
|
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
|
}
|
}
|
|
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');
|
}
|
}
|
/*******************************************************************************
|
QUEUE INTEGRATION
|
*******************************************************************************/
|
async submitUploads(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Check if there are uploads to submit
|
const pendingUploads = Array.from(field.uploads || [])
|
.map(id => this.uploads.get(id))
|
.filter(upload => upload &&
|
(upload.status === 'processed' ||
|
upload.status === 'processed-original'));
|
|
if (pendingUploads.length === 0) {
|
this.notifications.add('No uploads ready to submit', 'warning');
|
return;
|
}
|
|
// Queue the uploads
|
try {
|
await this.queueUpload(fieldId);
|
this.notifications.add(`Submitting ${pendingUploads.length} upload(s)`, 'info');
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'submitUploads',
|
fieldId
|
});
|
this.notifications.add('Failed to submit uploads', 'error');
|
}
|
}
|
async retryUpload(uploadId) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
const field = this.fields.get(upload.fieldId);
|
if (!field) return;
|
|
try {
|
// Reset status
|
this.updateUploadStatus(uploadId, 'received');
|
|
// If we have the processed file, skip to queuing
|
if (upload.processedFile) {
|
this.updateUploadStatus(uploadId, 'processed');
|
await this.queueUpload(upload.fieldId);
|
} else if (upload.originalFile) {
|
// Reprocess the file
|
const reprocessed = await this.processFile(upload.fieldId, upload.originalFile);
|
if (reprocessed) {
|
await this.queueUpload(upload.fieldId);
|
}
|
} else {
|
throw new Error('No file data available for retry');
|
}
|
|
this.notifications.add('Retrying upload...', 'info');
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'retryUpload',
|
uploadId
|
});
|
this.notifications.add('Failed to retry upload', 'error');
|
}
|
}
|
async restartUploads(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field?.uploads) return;
|
|
const failedUploads = Array.from(field.uploads)
|
.map(id => this.uploads.get(id))
|
.filter(upload => upload && upload.status === 'failed');
|
|
if (failedUploads.length === 0) {
|
this.notifications.add('No failed uploads to restart', 'info');
|
return;
|
}
|
|
for (const upload of failedUploads) {
|
await this.retryUpload(upload.id);
|
}
|
|
this.notifications.add(`Restarting ${failedUploads.length} upload(s)`, 'info');
|
}
|
async queueUpload(fieldId) {
|
//Further cache it, or is it already cached at this point?
|
const field = this.fields.get(fieldId);
|
if (!field?.uploads) return;
|
|
const uploads = Array.from(field.uploads);
|
if (uploads.length === 0) {
|
return;
|
}
|
|
const data = this.prepareUploadData(field, uploads);
|
this.a11y.announce('Queuing for upload');
|
let img = (uploads.length === 1) ? 'image' : 'images';
|
const operation = {
|
endpoint: 'uploads',
|
method: 'POST',
|
data: data,
|
title: `Uploading ${uploads.length} ${img} to server...`,
|
popup: `Uploading ${uploads.length} ${img}...`,
|
canMerge: false,
|
headers: {
|
'action_nonce': jvbSettings.dash
|
},
|
append: '_upload'
|
}
|
try {
|
const operationId = await this.queue.addToQueue(operation);
|
|
uploads.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (!upload) {
|
return;
|
}
|
upload.operationId = operationId;
|
this.updateUploadStatus(uploadId, 'queued');
|
});
|
field.operationId = operationId;
|
|
return operationId;
|
} catch (error) {
|
throw error;
|
} finally {
|
this.persistFieldState(field.key);
|
}
|
}
|
|
prepareUploadData(field, uploads) {
|
console.log('Preparing Upload:', field);
|
const formData = new FormData();
|
formData.append('content', field.content);
|
formData.append('mode', field.mode);
|
formData.append('field_name', field.name);
|
formData.append('field_key', field.key);
|
formData.append('field_type', field.type);
|
formData.append('item_id', field.itemID); //post, term, or user id
|
formData.append('context', field.context); //post, term, or user
|
let uploadMap = [];
|
uploads.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (upload) {
|
const fileToUpload = upload.processedFile || upload.originalFile;
|
if (fileToUpload) {
|
formData.append('files[]', fileToUpload);
|
uploadMap.push(upload.id);
|
} else {
|
console.warn(`No file for upload ${uploadId}`);
|
}
|
} else {
|
console.warn(`Upload ${uploadId} not found in uploads map`);
|
}
|
});
|
formData.append('upload_map', uploadMap);
|
|
console.log('Final FormData:');
|
for (let pair of formData.entries()) {
|
console.log(pair[0], pair[1]);
|
}
|
|
return formData;
|
}
|
|
async queueImageMeta(e) {
|
const upload = this.getUploadFromElement(element);
|
if (!upload) return;
|
|
const field = this.fields.get(upload.fieldId);
|
if (!field) return;
|
|
// Collect meta data from the form
|
const metaContainer = element.closest('.upload-meta');
|
if (!metaContainer) return;
|
|
const metaData = {
|
title: metaContainer.querySelector('[name="title"]')?.value || '',
|
alt_text: metaContainer.querySelector('[name="alt_text"]')?.value || '',
|
caption: metaContainer.querySelector('[name="caption"]')?.value || '',
|
description: metaContainer.querySelector('[name="description"]')?.value || ''
|
};
|
|
// Update upload meta
|
upload.meta = { ...upload.meta, ...metaData };
|
this.uploads.set(upload.id, upload);
|
|
// Mark that we have meta changes
|
this.hasMetaChanges = true;
|
|
// Determine if upload has been sent to server
|
const isOnServer = upload.status === 'completed' && upload.attachmentId;
|
|
if (isOnServer) {
|
// Queue immediate update
|
await this.sendMetaUpdate(upload);
|
} else if (upload.operationId) {
|
// Wait for upload to complete, then send meta
|
this.queueDependentMetaUpdate(upload);
|
} else {
|
// Upload hasn't been queued yet, meta will be sent with initial upload
|
this.persistFieldState(field.key);
|
}
|
}
|
|
/**
|
* Send meta update to server
|
*/
|
async sendMetaUpdate(upload) {
|
const formData = new FormData();
|
formData.append('attachment_id', upload.attachmentId);
|
formData.append('title', upload.meta.title);
|
formData.append('alt_text', upload.meta.alt_text);
|
formData.append('caption', upload.meta.caption);
|
formData.append('description', upload.meta.description);
|
|
const operation = {
|
endpoint: 'uploads/meta',
|
method: 'POST',
|
data: formData,
|
title: `Updating metadata for ${upload.meta.originalName}`,
|
canMerge: true,
|
headers: {
|
'action_nonce': jvbSettings.dash
|
}
|
};
|
|
try {
|
await this.queue.addToQueue(operation);
|
this.notifications.add('Metadata updated', 'success');
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'sendMetaUpdate',
|
uploadId: upload.id
|
});
|
}
|
}
|
|
/**
|
* Queue meta update that depends on upload completion
|
*/
|
queueDependentMetaUpdate(upload) {
|
const operation = {
|
endpoint: 'uploads/meta',
|
method: 'POST',
|
dependencies: [upload.operationId],
|
data: () => {
|
// This function will be called when dependencies are resolved
|
const formData = new FormData();
|
formData.append('operation_id', upload.operationId);
|
formData.append('upload_id', upload.id);
|
formData.append('title', upload.meta.title);
|
formData.append('alt_text', upload.meta.alt_text);
|
formData.append('caption', upload.meta.caption);
|
formData.append('description', upload.meta.description);
|
return formData;
|
},
|
title: `Updating metadata after upload`,
|
canMerge: true,
|
headers: {
|
'action_nonce': jvbSettings.dash
|
}
|
};
|
|
this.queue.addToQueue(operation);
|
}
|
/*******************************************************************************
|
IMAGE PROCESSING
|
*******************************************************************************/
|
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;
|
|
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);
|
|
this.maybeLockUploads(fieldId);
|
|
if (field.groupDisplay) {
|
field.groupDisplay.hidden = false;
|
}
|
if (processedUploads.length > 0) {
|
await this.queueUpload(fieldId);
|
}
|
|
this.hideUploadProgress(fieldId);
|
|
this.a11y.announce(`Processed ${processedUploads.length} of ${validFiles.length} files`);
|
}
|
|
checkFieldLimits(fieldId, additionalFiles) {
|
const field = this.fields.get(fieldId);
|
if (!field) return false;
|
|
const currentCount = field.uploads?.size || 0;
|
const totalCount = currentCount + additionalFiles;
|
|
if (totalCount > field.maxFiles) {
|
this.notifications.add(
|
`Cannot add ${additionalFiles} files. Max ${field.maxFiles} allowed, currently have ${currentCount}.`,
|
'warning'
|
);
|
return false;
|
}
|
|
return true;
|
}
|
generateUploadId() {
|
return `upload_${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;
|
}
|
|
formatBytes(bytes, decimals = 2) {
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
const dm = decimals < 0 ? 0 : decimals;
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
}
|
|
async processBatch(fieldId, files) {
|
const results = [];
|
const processingQueue = [];
|
const maxConcurrent = this.worker.settings.maxConcurrent;
|
|
let total = files.length;
|
for (let i = 0; i < files.length; i++) {
|
this.updateUploadProgress(fieldId, i, total);
|
// Wait if we've reached max concurrent processing
|
if (processingQueue.length >= maxConcurrent) {
|
await Promise.race(processingQueue);
|
}
|
|
const processPromise = this.processFile(fieldId, files[i])
|
.then(upload => {
|
// Remove from processing queue
|
const index = processingQueue.indexOf(processPromise);
|
if (index > -1) processingQueue.splice(index, 1);
|
|
if (upload) results.push(upload);
|
return upload;
|
})
|
.catch(error => {
|
console.error(`Failed to process ${files[i].name}:`, error);
|
// Remove from processing queue
|
const index = processingQueue.indexOf(processPromise);
|
if (index > -1) processingQueue.splice(index, 1);
|
return null;
|
});
|
|
processingQueue.push(processPromise);
|
}
|
|
// Wait for remaining files
|
await Promise.all(processingQueue);
|
return results;
|
}
|
|
async processFile(fieldId, file) {
|
const field = this.fields.get(fieldId);
|
|
const upload = await this.setUpload(fieldId, file);
|
const uploadId = upload.id;
|
try {
|
// Update UI immediately
|
this.addImageToGroup(uploadId);
|
this.updateUploadStatus(uploadId, 'local_processing');
|
|
// Attempt to process the image
|
let processedFile = null;
|
let processingFailed = false;
|
|
try {
|
processedFile = await this.processImage(file, uploadId);
|
} catch (error) {
|
console.warn(`Processing failed for ${file.name}, using original:`, error);
|
processingFailed = true;
|
processedFile = file; // Use original
|
}
|
|
// Update upload with processed file
|
upload.processedFile = processedFile;
|
upload.processingFailed = processingFailed;
|
|
// Update status
|
this.updateUploadStatus(uploadId, 'processed');
|
|
// Save to uploads map
|
this.uploads.set(uploadId, upload);
|
|
// Persist state
|
if (field && field.key) {
|
await this.persistFieldState(field.key);
|
}
|
|
const message = processingFailed
|
? `${file.name} added (original format)`
|
: `${file.name} processed and ready`;
|
this.a11y.announce(message);
|
|
return upload;
|
|
} catch (error) {
|
// Clean up failed upload
|
this.cleanupFailedUpload(uploadId, field.key);
|
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'processFile',
|
uploadId,
|
fileName: file.name
|
});
|
|
return null;
|
}
|
}
|
|
async processImage(file, uploadId) {
|
const timeout = this.worker.settings.timeout;
|
|
return new Promise((resolve, reject) => {
|
let timeoutId;
|
let taskCompleted = false;
|
|
// Set timeout
|
timeoutId = setTimeout(() => {
|
if (!taskCompleted) {
|
taskCompleted = true;
|
|
// Remove from active tasks
|
this.worker.tasks.delete(uploadId);
|
|
// Maybe restart worker if configured
|
if (this.worker.settings.restartAfterTimeout) {
|
this.restartCompressionWorker();
|
}
|
|
reject(new Error(`Processing timeout for ${file.name}`));
|
}
|
}, timeout);
|
|
// Track this task
|
this.worker.tasks.set(uploadId, { file, timeoutId });
|
|
// Process image
|
this.handleProcess(file, uploadId)
|
.then(result => {
|
if (!taskCompleted) {
|
taskCompleted = true;
|
clearTimeout(timeoutId);
|
this.worker.tasks.delete(uploadId);
|
resolve(result);
|
}
|
})
|
.catch(error => {
|
if (!taskCompleted) {
|
taskCompleted = true;
|
clearTimeout(timeoutId);
|
this.worker.tasks.delete(uploadId);
|
reject(error);
|
}
|
});
|
});
|
}
|
|
async handleProcess(file, uploadId) {
|
// Skip non-images
|
if (!file.type.startsWith('image/')) {
|
return file;
|
}
|
|
const maxDimension = this.getMaxDimension();
|
const quality = 0.85;
|
|
// Try worker first if available
|
if (this.shouldUseWorker(file)) {
|
try {
|
// Ensure worker is initialized
|
if (!this.worker.worker) {
|
this.initCompressionWorker();
|
}
|
|
if (this.worker.worker) {
|
return await this.processWithWorker(file, uploadId, maxDimension, quality);
|
}
|
} catch (error) {
|
console.warn('Worker processing failed, falling back to main thread:', error);
|
}
|
}
|
|
// Fallback to main thread
|
return await this.processOnMainThread(file, maxDimension, quality);
|
}
|
|
/**
|
* 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.worker.worker &&
|
file.size > 1024 * 1024 && // > 1MB
|
typeof OffscreenCanvas !== 'undefined';
|
}
|
|
async processWithWorker(file, uploadId, maxDimension, quality) {
|
return new Promise((resolve, reject) => {
|
if (!this.worker.worker) {
|
reject(new Error('Worker not available'));
|
return;
|
}
|
|
// Create unique message ID for this task
|
const messageId = `${uploadId}_${Date.now()}`;
|
|
// Handler for this specific message
|
const messageHandler = (e) => {
|
if (e.data.messageId !== messageId) return;
|
|
// Remove handler
|
this.worker.worker.removeEventListener('message', messageHandler);
|
this.worker.worker.removeEventListener('error', errorHandler);
|
|
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'));
|
}
|
};
|
|
const errorHandler = (error) => {
|
this.worker.worker.removeEventListener('message', messageHandler);
|
this.worker.worker.removeEventListener('error', errorHandler);
|
reject(new Error(`Worker error: ${error.message}`));
|
};
|
|
// Add handlers
|
this.worker.worker.addEventListener('message', messageHandler);
|
this.worker.worker.addEventListener('error', errorHandler);
|
|
// Send message to worker
|
this.worker.worker.postMessage({
|
messageId,
|
file,
|
maxDimension,
|
quality,
|
outputFormat: this.getOptimalFormat(file)
|
});
|
});
|
}
|
|
/**
|
* Restart compression worker
|
*/
|
restartCompressionWorker() {
|
console.log('Restarting compression worker...');
|
|
// Terminate existing worker
|
if (this.worker.worker) {
|
this.worker.worker.terminate();
|
this.worker.worker = null;
|
}
|
|
// Clear active tasks
|
this.worker.tasks.clear();
|
|
// Check restart limit
|
if (this.worker.restart.count >= this.worker.restart.max) {
|
console.error('Max worker restarts reached, disabling worker');
|
return;
|
}
|
|
this.worker.restart.count++;
|
|
// Reinitialize
|
this.initCompressionWorker();
|
}
|
|
/**
|
* Initialize Web Worker for image compression
|
*/
|
initCompressionWorker() {
|
if (this.worker.worker || typeof Worker === 'undefined') return;
|
|
try {
|
const workerScript = `
|
self.onmessage = async function(e) {
|
const { messageId, 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);
|
|
// Clean up bitmap
|
bitmap.close();
|
|
// Convert to blob
|
const blob = await canvas.convertToBlob({
|
type: outputFormat,
|
quality: quality
|
});
|
|
self.postMessage({
|
messageId,
|
success: true,
|
blob: blob,
|
format: outputFormat
|
});
|
|
} catch (error) {
|
self.postMessage({
|
messageId,
|
success: false,
|
error: error.message
|
});
|
}
|
};
|
`;
|
|
const blob = new Blob([workerScript], { type: 'application/javascript' });
|
this.worker.worker = new Worker(URL.createObjectURL(blob));
|
|
} catch (error) {
|
console.warn('Failed to initialize compression worker:', error);
|
this.worker.worker = null;
|
}
|
}
|
|
/**
|
* 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;
|
}
|
|
/**
|
* Clean up failed upload
|
*/
|
cleanupFailedUpload(uploadId, fieldId) {
|
const field = this.fields.get(fieldId);
|
if (field?.uploads) {
|
field.uploads.delete(uploadId);
|
}
|
|
const upload = this.uploads.get(uploadId);
|
if (upload) {
|
// Clean up preview URL
|
if (upload.preview?.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
|
// Remove element
|
upload.element?.remove();
|
|
// Remove from uploads
|
this.uploads.delete(uploadId);
|
}
|
|
// Remove from active tasks
|
this.worker.tasks.delete(uploadId);
|
}
|
/*******************************************************************************
|
UI FUNCTIONALITY
|
*******************************************************************************/
|
/**
|
* Update upload status correctly
|
*/
|
updateUploadStatus(uploadId, status) {
|
console.log('Updating upload status for: ', uploadId);
|
let upload = this.uploads.get(uploadId);
|
if(!upload) {
|
return;
|
}
|
upload.status = status;
|
|
this.updateImageUI(upload.id);
|
this.persistFieldState(upload.fieldId);
|
}
|
updateImageUI(uploadId) {
|
console.log('Updating image UI: ', uploadId);
|
const upload = this.uploads.get(uploadId);
|
console.log(upload);
|
if (!upload?.element) return;
|
|
|
const progressEl = upload.element.querySelector('.progress');
|
const itemEl = upload.element;
|
|
console.log('Updating Upload UI:', upload);
|
// Update status class on item for CSS styling
|
if (itemEl) {
|
itemEl.className = itemEl.className.replace(/status-[\w-]+/g, '');
|
itemEl.classList.add(`status-${upload.status}`);
|
}
|
|
if (progressEl) {
|
let icon = this.getStatusIcon(upload.status);
|
let message = this.getStatusText(upload.status);
|
let progress = this.getStatusProgress(upload.status);
|
|
const fill = progressEl.querySelector('.fill');
|
const itemIcon = progressEl.querySelector('span.icon');
|
const itemMessage = progressEl.querySelector('span.details');
|
|
if (fill) {
|
fill.style.width = `${progress}%`;
|
}
|
if (itemMessage) itemMessage.textContent = message;
|
if (itemIcon) {
|
window.removeChildren(itemIcon);
|
itemIcon.append(icon);
|
}
|
|
if (upload.status === 'completed') {
|
setTimeout(() => {
|
if (progressEl) {
|
window.fade(progressEl, false);
|
}
|
}, 1000);
|
}
|
}
|
}
|
/**
|
* 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.ui.field.dropZone) {
|
field.ui.field.dropZone.hidden = field.uploads && field.uploads.size >= field.maxFiles;
|
}
|
}
|
createImageElement(upload, draggable = false) {
|
let image = window.getTemplate('uploadItem');
|
if (!image) {
|
console.error('Image template not found');
|
return;
|
}
|
image.dataset.uploadId = upload.id;
|
image.querySelector('[name="featured"]').value = upload.id;
|
let [
|
featured,
|
img,
|
details
|
] = [
|
image.querySelector('[name="featured"]'),
|
image.querySelector('img'),
|
image.querySelector('details')
|
];
|
[
|
featured.value,
|
img.src,
|
img.alt
|
] = [
|
upload.id,
|
upload.preview,
|
upload.originalFile?.name ?? upload.meta?.originalName ?? '',
|
];
|
if (details) {
|
let template = window.getTemplate('uploadMeta');
|
if (template){
|
details.append(template);
|
}
|
}
|
image.draggable = draggable;
|
|
// 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;
|
}
|
}
|
});
|
|
return image;
|
}
|
|
updateUploadProgress(fieldId, current, total, message) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
let progressBar = field.ui.field.progress.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 = field.ui.field.progress.fill;
|
const progressMessage = field.ui.field.progress.details;
|
const progressCount = field.ui.field.progress.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.ui.field.progress.progress;
|
if (progressBar) {
|
window.fade(progressBar, false);
|
}
|
}
|
/*******************************************************************************
|
INDEXEDDB CACHE FUNCTIONALITY
|
*******************************************************************************/
|
async initDB() {
|
if (!('indexedDB' in window)) return;
|
|
const request = indexedDB.open(`jvb_uploads_db`, 1);
|
|
request.onupgradeneeded = (e) => {
|
const db = e.target.result;
|
if (!db.objectStoreNames.contains('fieldStates')) {
|
const store = db.createObjectStore('fieldStates', { keyPath: 'fieldId' });
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
store.createIndex('content', 'content', { unique: false });
|
store.createIndex('itemId', 'itemId', { unique: false });
|
}
|
|
// Blob storage remains separate for performance
|
if (!db.objectStoreNames.contains('uploadBlobs')) {
|
db.createObjectStore('uploadBlobs', { keyPath: 'uploadId' });
|
}
|
};
|
|
request.onsuccess = (e) => {
|
this.db = e.target.result;
|
this.loadFields();
|
};
|
|
request.onerror = (e) => {
|
console.error('IndexedDB error:', e);
|
};
|
}
|
|
async loadFields() {
|
if (!this.db) return;
|
|
return new Promise((resolve) => {
|
const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readonly');
|
const fieldStates = tx.objectStore('fieldStates');
|
const blobStore = tx.objectStore('uploadBlobs');
|
const request = fieldStates.getAll();
|
|
request.onsuccess = (e) => {
|
e.target.result.forEach(field => {
|
let uploads = field.uploads;
|
let uploadIds = uploads.map(upload => upload.id);
|
field.uploads = new Set(uploadIds);
|
this.fields.set(field.key, field);
|
uploads.forEach(upload => {
|
this.uploads.set(upload.id, upload);
|
});
|
});
|
this.notify('uploads-loaded', { items: Array.from(this.uploads.values()) });
|
resolve();
|
};
|
|
const blobRequest = blobStore.getAll();
|
|
blobRequest.onsuccess = (e) => {
|
e.target.result.forEach(item => {
|
this.uploadBlobs.set(item.id, item);
|
});
|
this.notify('blobs-loaded', { items: Array.from(this.uploadBlobs.values()) });
|
resolve();
|
};
|
});
|
}
|
|
getUpload(uploadId) {
|
return this.uploads.get(uploadId);
|
}
|
|
clearField(fieldId) {
|
let uploads = Array.from(this.fields.uploads);
|
uploads.forEach(upload => {
|
this.uploads.delete(upload);
|
});
|
this.fields.delete(fieldId);
|
if (this.db) {
|
const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite');
|
tx.objectStore('fieldStates').delete(fieldId);
|
uploads.forEach(upload => {
|
tx.objectStore('uploadBlobs').delete(upload);
|
});
|
}
|
}
|
|
updateFieldStatus(fieldId, status) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
field.uploads.forEach(upload => {
|
console.log('Attempting to set upload to status: ', status);
|
this.updateUploadStatus(upload, status);
|
});
|
|
// Update UI based on status
|
const container = field.ui.field.field;
|
if (container) {
|
container.dataset.uploadStatus = status;
|
|
// Show/hide relevant UI elements
|
const submitBtn = container.querySelector('.submit-uploads');
|
if (submitBtn) {
|
submitBtn.disabled = status === 'uploading' || status === 'processing';
|
}
|
}
|
}
|
|
/**
|
* Handle successful upload completion
|
*/
|
handleUploadComplete(operation) {
|
const response = operation.response;
|
if (!response?.uploads) return;
|
|
// Map server IDs to uploads
|
response.uploads.forEach(serverUpload => {
|
const upload = this.uploads.get(serverUpload.upload_id);
|
if (upload) {
|
upload.attachmentId = serverUpload.attachment_id;
|
this.updateUploadStatus(serverUpload.upload_id, 'completed');
|
this.uploads.set(upload.id, upload);
|
|
// Clear from cache since it's now on server
|
this.clearUpload(upload.id);
|
}
|
});
|
|
// Persist updated field state
|
const fieldKey = operation.data.get('field_key');
|
if (fieldKey) {
|
this.persistFieldState(fieldKey);
|
}
|
}
|
|
/**
|
* Clear individual upload from cache after successful server upload
|
*/
|
async clearUpload(uploadId) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
// Clean up preview URL
|
if (upload.preview?.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
|
this.persistFieldState(upload.fieldId);
|
// Remove from memory
|
this.uploads.delete(uploadId);
|
this.uploadBlobs.delete(uploadId);
|
|
// Remove from IndexedDB
|
if (this.db) {
|
const tx = this.db.transaction(['uploadBlobs'], 'readwrite');
|
await tx.objectStore('uploadBlobs').delete(uploadId);
|
}
|
}
|
|
/**
|
* Store upload with DataStore integration
|
*/
|
async setUpload(fieldId, file, uploadId = null) {
|
if (!uploadId) {
|
uploadId = this.generateUploadId();
|
}
|
const upload = {
|
id: uploadId,
|
fieldId: fieldId,
|
groupId: null,
|
originalFile: file,
|
processedFile: null,
|
status: 'received',
|
progress: { percent: 0, message: 'Received...' },
|
preview: URL.createObjectURL(file),
|
createdAt: Date.now(),
|
meta: {
|
title: '',
|
alt_text: '',
|
caption: '',
|
originalName: file.name,
|
originalType: file.type,
|
originalSize: file.size
|
},
|
changes: {}
|
};
|
|
// Add to field
|
const field = this.fields.get(fieldId);
|
if (!field) {
|
console.error(`Field ${fieldId} not found`);
|
return null;
|
}
|
if (!field.uploads) field.uploads = new Set();
|
field.uploads.add(uploadId);
|
|
upload.element = this.createImageElement(upload, field.type==='groupable');
|
upload.ui = window.uiFromSelectors(this.selectors.item, upload.element);
|
|
// Store in memory
|
this.uploads.set(uploadId, upload);
|
this.updateImageUI(uploadId);
|
|
// Persist to DataStore
|
await this.persistFieldState(fieldId);
|
|
return upload;
|
}
|
|
getFieldUploads(fieldId, stripElements) {
|
const field = this.fields.get(fieldId);
|
console.log('Got field uploads: ', field);
|
if (!field?.uploads) return [];
|
|
return Array.from(field.uploads)
|
.map(id => {
|
let upload = this.uploads.get(id);
|
if (!upload) return null;
|
if (stripElements) {
|
// Create a clean copy without DOM references
|
const { element, ui, ...cleanUpload } = upload;
|
upload = cleanUpload;
|
}
|
return upload;
|
})
|
.filter(Boolean);
|
}
|
|
/**
|
* Persist upload to DataStore
|
*/
|
async persistFieldState(fieldId) {
|
if (!this.db) return;
|
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Create clean field config without UI references
|
const { ui, container, dropZone, previewGrid, selectAll, selectActions, selectInfo, selectCount, groupDisplay, ...cleanConfig } = field;
|
|
const fieldState = {
|
fieldId: fieldId,
|
timestamp: Date.now(),
|
|
config: {
|
key: cleanConfig.key,
|
id: cleanConfig.id,
|
name: cleanConfig.name,
|
type: cleanConfig.type,
|
content: cleanConfig.content,
|
itemID: cleanConfig.itemID,
|
context: cleanConfig.context,
|
mode: cleanConfig.mode,
|
maxFiles: cleanConfig.maxFiles,
|
multiple: cleanConfig.multiple
|
},
|
|
// Recovery context
|
context: {
|
url: window.location.href,
|
modalType: this.getModalType(field),
|
formId: field.formId
|
},
|
|
// Uploads with their group associations (cleaned)
|
uploads: this.getFieldUploads(fieldId, true),
|
|
// Groups structure (ensure these are also cleaned)
|
groups: Array.from(this.groups.entries())
|
.filter(([id, data]) => data.fieldId === fieldId && data.uploads.size > 0)
|
.map(([id, data]) => ({
|
id: data.id,
|
uploads: Array.from(data.uploads),
|
meta: data.meta,
|
changes: data.changes
|
}))
|
};
|
|
const tx = this.db.transaction(['fieldStates'], 'readwrite');
|
await tx.objectStore('fieldStates').put(fieldState);
|
}
|
/*******************************************************************************
|
RESTORE FUNCTIONALITY
|
*******************************************************************************/
|
async checkPendingUploads() {
|
if (!this.db) return;
|
|
const tx = this.db.transaction(['fieldStates'], 'readonly');
|
const fieldStore = tx.objectStore('fieldStates');
|
|
const allFieldStates = await new Promise(resolve => {
|
const request = fieldStore.getAll();
|
request.onsuccess = () => resolve(request.result);
|
});
|
|
// Filter for pending uploads (not yet sent to server)
|
const pendingFields = allFieldStates.filter(field =>
|
field.uploads.some(upload =>
|
upload.status === 'processing' ||
|
upload.status === 'processed' ||
|
upload.status === 'pending'
|
)
|
);
|
|
if (pendingFields.length === 0) return;
|
|
// Show recovery notification
|
this.showRecoveryNotification(pendingFields);
|
}
|
|
showRecoveryNotification(pendingFields) {
|
const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
|
|
let notification = window.getTemplate('restoreNotification');
|
[
|
notification.querySelector('.restore-details').textContent,
|
] = [
|
`${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`
|
];
|
|
pendingFields.forEach(field => {
|
console.log(field);
|
let template = window.getTemplate('restoreField');
|
field.uploads.forEach(upload => {
|
let uploadItem = window.getTemplate('restoreItem');
|
[
|
uploadItem.querySelector('img').src
|
] = [
|
upload.preview
|
];
|
template.append(uploadItem);
|
});
|
notification.append(template);
|
});
|
|
|
// Add event handlers
|
notification.querySelector('[data-action="restore"]').addEventListener('click', () => {
|
this.restoreFieldStates(pendingFields);
|
notification.remove();
|
});
|
|
notification.querySelector('[data-action="dismiss"]').addEventListener('click', () => {
|
this.notifications.add('Uploads saved for later restoration', 'info');
|
notification.remove();
|
});
|
|
notification.querySelector('[data-action="clear"]').addEventListener('click', () => {
|
this.clearCachedUploads(pendingFields);
|
notification.remove();
|
});
|
|
document.body.appendChild(notification);
|
}
|
|
async restoreFieldStates(fieldStates) {
|
// Group by URL
|
const byUrl = new Map();
|
fieldStates.forEach(field => {
|
if (!byUrl.has(field.context.url)) {
|
byUrl.set(field.context.url, []);
|
}
|
byUrl.get(field.context.url).push(field);
|
});
|
|
// If all on current page, restore directly
|
if (byUrl.size === 1 && byUrl.has(window.location.href)) {
|
for (const fieldState of fieldStates) {
|
await this.restoreField(fieldState);
|
}
|
this.notifications.add(`Restored ${fieldStates.length} field(s)`, 'success');
|
} else {
|
// Store intent to restore and navigate
|
sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(fieldStates));
|
|
// Navigate to first URL
|
const firstUrl = byUrl.keys().next().value;
|
if (window.location.href !== firstUrl) {
|
window.location.href = firstUrl;
|
}
|
}
|
}
|
|
async restoreField(fieldState) {
|
const { config, context, uploads, groups } = fieldState;
|
|
// If in a modal, open it first
|
if (context.modalType) {
|
await this.openModalForRestore(context);
|
}
|
|
// Find the field element
|
const fieldElement = document.querySelector(
|
`.field.image[data-field-id="${config.id}"]`
|
);
|
|
if (!fieldElement) {
|
console.warn(`Field ${config.id} not found for restoration`);
|
return;
|
}
|
|
// Register the field
|
const fieldKey = this.registerUploader(fieldElement, config);
|
const field = this.fields.get(fieldKey);
|
|
// Restore uploads
|
for (const uploadData of uploads) {
|
await this.restoreUpload(field, uploadData);
|
}
|
|
// Restore groups
|
if (groups && groups.length > 0) {
|
await this.restoreGroups(field, groups, uploads);
|
}
|
|
// Update UI
|
this.maybeLockUploads(fieldKey);
|
|
// Queue for upload if needed
|
if (config.mode === 'direct') {
|
await this.queueUpload(fieldKey);
|
}
|
}
|
|
async restoreUpload(field, uploadData) {
|
// Reconstruct the file from blob data
|
const blobData = await this.getBlobData(uploadData.id);
|
let file = null;
|
|
if (blobData) {
|
file = new File(
|
[blobData.data],
|
blobData.name,
|
{ type: blobData.type, lastModified: blobData.lastModified }
|
);
|
uploadData.processedFile = file;
|
}
|
|
// Add to field
|
if (!field.uploads) field.uploads = new Set();
|
field.uploads.add(uploadData.id);
|
|
// Recreate DOM element
|
uploadData.element = this.createImageElement(uploadData, field.type === 'groupable');
|
|
// Restore to correct location
|
const location = uploadData.groupId
|
? field.ui.groups.groups.get(uploadData.groupId)
|
: field.ui.field.preview;
|
|
if (location) {
|
location.append(uploadData.element);
|
uploadData.location = location;
|
}
|
|
// Store in memory
|
this.uploads.set(uploadData.id, uploadData);
|
}
|
|
async restoreGroups(field, groups, uploads) {
|
for (const groupData of groups) {
|
// Create group element
|
const groupElement = this.createGroupElement(groupData.id, field.key);
|
field.ui.groups.groups.set(groupData.id, groupElement);
|
field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
|
|
// Create group Set
|
const groupSet = new Set(groupData.uploadIds);
|
this.groups.set(groupData.id, groupSet);
|
|
// Restore group metadata
|
if (groupData.meta) {
|
this.groupsMeta.set(groupData.id, groupData.meta);
|
// TODO: Populate meta fields in groupElement
|
}
|
|
// Move uploads to group
|
groupData.uploadIds.forEach(uploadId => {
|
const upload = uploads.find(u => u.id === uploadId);
|
if (upload && upload.element) {
|
groupElement.querySelector('.item-grid').append(upload.element);
|
upload.location = groupElement.querySelector('.item-grid');
|
upload.groupId = groupData.id;
|
}
|
});
|
}
|
}
|
|
async getBlobData(uploadId) {
|
if (!this.db) return null;
|
|
const tx = this.db.transaction(['uploadBlobs'], 'readonly');
|
const request = tx.objectStore('uploadBlobs').get(uploadId);
|
|
return new Promise(resolve => {
|
request.onsuccess = () => resolve(request.result);
|
request.onerror = () => resolve(null);
|
});
|
}
|
|
async openModalForRestore(context) {
|
const { modalType, formId } = context;
|
|
// Find and click the appropriate button to open the modal
|
let trigger = null;
|
|
switch(modalType) {
|
case 'create':
|
trigger = document.querySelector('[data-action="create"]');
|
break;
|
case 'edit':
|
// Need to find the specific edit button
|
trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`);
|
break;
|
case 'bulkEdit':
|
trigger = document.querySelector('[data-action="bulk-edit"]');
|
break;
|
}
|
|
if (trigger) {
|
trigger.click();
|
|
// Wait for modal to open
|
await new Promise(resolve => setTimeout(resolve, 300));
|
}
|
}
|
|
async clearCachedUploads(fieldStates) {
|
if (!this.db) return;
|
|
const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite');
|
|
for (const field of fieldStates) {
|
// Delete field state
|
await tx.objectStore('fieldStates').delete(field.fieldId);
|
|
// Delete all associated blobs
|
for (const upload of field.uploads) {
|
await tx.objectStore('uploadBlobs').delete(upload.id);
|
|
// Clean up preview URLs
|
if (upload.preview?.startsWith('blob:')) {
|
URL.revokeObjectURL(upload.preview);
|
}
|
}
|
}
|
|
this.notifications.add('Cached uploads cleared', 'info');
|
}
|
|
// Check for restoration intent on page load
|
async checkRestorationIntent() {
|
const restoreData = sessionStorage.getItem('jvb_restore_uploads');
|
if (!restoreData) return;
|
|
const fieldStates = JSON.parse(restoreData);
|
const currentUrlFields = fieldStates.filter(f => f.context.url === window.location.href);
|
|
if (currentUrlFields.length > 0) {
|
for (const fieldState of currentUrlFields) {
|
await this.restoreField(fieldState);
|
}
|
|
// Remove restored fields from session storage
|
const remaining = fieldStates.filter(f => f.context.url !== window.location.href);
|
if (remaining.length > 0) {
|
sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(remaining));
|
} else {
|
sessionStorage.removeItem('jvb_restore_uploads');
|
}
|
|
this.notifications.add(`Restored ${currentUrlFields.length} field(s)`, 'success');
|
}
|
}
|
/*******************************************************************************
|
GROUP FUNCTIONALITY
|
Includes selection, dragging, and grouping logic
|
*******************************************************************************/
|
/**
|
*
|
* @param {string} uploadId as defined by setUpload
|
* @param {HTMLElement|null} target The target location
|
* @param {boolean} persist whethet to cache this change
|
*/
|
addImageToGroup(uploadId, target = null, persist = true) {
|
let upload = this.getUpload(uploadId);
|
if(!upload) {
|
return;
|
}
|
let field = this.fields.get(upload.fieldId);
|
if (!field) {
|
return;
|
}
|
//Already in the Preview Grid, or already in the group we're moving to
|
if (!target && upload.location === field.ui.field.preview || target === upload.location) {
|
return;
|
}
|
|
if (upload.location) {
|
let groupId = upload.location.dataset.groupId;
|
if (groupId) {
|
let group = this.groups.get(groupId);
|
if (group) {
|
group.delete(uploadId);
|
if (group.size === 0) {
|
this.removeGroup(groupId);
|
}
|
}
|
}
|
}
|
|
upload.element.querySelector('[name="featured"]').hidden = !target;
|
//If no target, it's going to the preview grid
|
if (!target) {
|
target = field.ui.field.preview;
|
} else {
|
let groupId = target.dataset.groupId;
|
let group = this.groups.get(groupId);
|
if (!group) {
|
group = this.createGroup(upload.fieldId);
|
}
|
group.uploads.add(uploadId);
|
}
|
|
|
target.append(upload.element);
|
if (persist) {
|
this.persistFieldState(field.key);
|
}
|
}
|
|
addSelectionToGroup(target) {
|
let field = this.getFieldFromElement(target);
|
if (!field) {
|
return;
|
}
|
if (this.selected.get(field.key).size === 0) {
|
return;
|
}
|
let group = this.getGroupFromElement(target);
|
if (!group) {
|
group = this.createGroup(field.key);
|
}
|
|
Array.from(this.selected).forEach(uploadId => {
|
this.addImageToGroup(uploadId, group.grid, false);
|
});
|
|
this.persistFieldState(group.fieldId);
|
}
|
|
|
/**
|
* Remove an empty group from the field
|
* @param {string} groupId - The group to remove
|
* @param {boolean} confirm - ask for confirmation
|
*/
|
removeGroup(groupId, confirm = false) {
|
let group = this.groups.get(groupId);
|
if (!group) {
|
return;
|
}
|
if (confirm) {
|
if(!window.confirm('This will delete this group. Any uploads in this group will return to the main grid. Are you sure?')){
|
return;
|
}
|
}
|
|
if (group.uploads.size > 0) {
|
Array.from(group.uploads).forEach(upload => {
|
this.addImageToGroup(upload);
|
});
|
}
|
|
let groupElement = group.element;
|
// Remove DOM element
|
if (groupElement) {
|
window.fade(groupElement, false);
|
this.a11y.announce('Empty group removed');
|
}
|
|
this.persistFieldState(group.fieldId);
|
}
|
|
createGroup(fieldId) {
|
let field = this.fields.get(fieldId);
|
if(!field) {
|
return;
|
}
|
let index = field.ui.groups.size;
|
field.ui.groups.groups.set(`group-${index}`, this.createGroupElement(`group-${index}`, fieldId));
|
let group = field.ui.groups.groups.get(`group-${index}`);
|
field.ui.groups.container.insertAfter(group, field.ui.groups.empty);
|
let groupConfig = {
|
fieldId: field.key,
|
id: `group-${index}`,
|
element: group,
|
grid: group.querySelector('.item-grid'),
|
uploads: new Set(),
|
meta: {
|
post_title: '',
|
post_excerpt: '',
|
},
|
changes: {},
|
};
|
this.groups.set(`group-${index}`, groupConfig);
|
return groupConfig;
|
}
|
|
createGroupElement(groupId, fieldId) {
|
let post = window.getTemplate('imageGroup');
|
if (!post) {
|
return;
|
}
|
post.dataset.groupId = groupId;
|
post.dataset.fieldId = fieldId;
|
let fields = window.getTemplate('groupMetaData');
|
post.querySelector('.fields')?.append(fields);
|
|
return post;
|
}
|
|
/**
|
* Handle select all functionality
|
*/
|
handleSelectAll(element, checked = null) {
|
const field = this.getFieldFromElement(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;
|
}
|
|
updateSelection(e) {
|
let field = this.getFieldFromElement(e.target);
|
let upload = this.getUploadFromElement(e.target);
|
if (!field || ! upload) {
|
console.log('No field or upload found...');
|
return;
|
}
|
|
this.lastClickedUpload = upload.id;
|
let action = e.target.checked;
|
if (action) {
|
this.selected.get(field.key).add(upload.id);
|
} else {
|
this.selected.get(field.key).delete(upload.id);
|
}
|
}
|
|
updateSelectAll(element) {
|
const field = this.getFieldFromElement(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) {
|
let field = this.getFieldFromElement(element);
|
if (!field) {
|
return;
|
}
|
return Array.from(this.selected.get(field.key)??[]);
|
}
|
|
|
handleRangeSelection(currentElement, event) {
|
const field = this.getFieldFromElement(currentElement);
|
if (!field) return;
|
|
const currentUploadId = this.getUploadIdFromElement(currentElement);
|
if (!currentUploadId || !this.lastClickedUpload) return;
|
|
// Get all upload items in the preview grid
|
const container = currentElement.closest('.item-grid');
|
const allItems = Array.from(container.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.getFieldIdFromElement(button);
|
|
const selectedUploads = this.getSelectedUploads(button);
|
if (selectedUploads.length === 0) {
|
this.notify('No uploads selected', 'warning');
|
return;
|
}
|
|
selectedUploads.forEach(upload => {
|
this.removeUpload(fieldId, upload);
|
});
|
}
|
|
removeUpload(fieldId, uploadId) {
|
const field = this.fields.get(fieldId);
|
const upload = this.uploads.get(uploadId);
|
|
if (!field || !upload) return;
|
|
// Remove from field
|
field.uploads?.delete(uploadId);
|
|
// Remove from group if grouped
|
if (upload.groupId) {
|
const group = this.groups.get(upload.groupId);
|
group?.delete(uploadId);
|
}
|
|
// Clean up element
|
upload.element?.remove();
|
|
// Clean up memory
|
this.clearUpload(uploadId);
|
|
// Update UI
|
this.maybeLockUploads(fieldId);
|
this.updateSelectAll(field.ui.field.field);
|
|
this.a11y.announce('Upload removed');
|
}
|
|
/**************************************************************************
|
META
|
Handled separately, in case it is edited in the middle of processing images
|
**************************************************************************/
|
|
/**************************************************************************
|
SUBSCRIBERS
|
**************************************************************************/
|
/**
|
* Event system
|
*/
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
|
notify(event, data) {
|
this.subscribers.forEach(cb => cb(event, data));
|
}
|
|
handleBeforeUnload(e) {
|
// Check for any uploads in processing or pending state
|
const unsavedUploads = Array.from(this.uploads.values()).filter(upload =>
|
upload.status === 'processing' ||
|
upload.status === 'pending' ||
|
upload.status === 'uploading'
|
);
|
|
if (unsavedUploads.length > 0) {
|
const message = 'You have uploads in progress. Are you sure you want to leave?';
|
e.preventDefault();
|
e.returnValue = message;
|
return message;
|
}
|
}
|
/**************************************************************************
|
CLEANUP
|
**************************************************************************/
|
cleanup() {
|
this.clearListeners();
|
if (this.hasGroups) {
|
this.clearGroupListeners();
|
}
|
this.compressionWorker = null;
|
this.subscribers.clear();
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', () => {
|
window.jvbUploads = new UploadManager();
|
});
|