import { dataUrlToBlob, withCacheBuster } from './utils.js';
import { ImageEditor } from '../image_editor_modules/editor.js';
import { injectImageEditorStyles } from '../image_editor_modules/styles.js';
export function createReferenceSlotManager(imageInputGrid, options = {}) {
const MAX_IMAGE_SLOTS = 16;
const INITIAL_IMAGE_SLOTS = 2;
const onChange = options.onChange;
const imageSlotState = [];
let cachedReferenceImages = [];
// Inject image editor styles once
injectImageEditorStyles();
function initialize(initialCached = []) {
cachedReferenceImages = Array.isArray(initialCached) ? initialCached : [];
const requiredSlots = Math.min(
MAX_IMAGE_SLOTS,
Math.max(INITIAL_IMAGE_SLOTS, cachedReferenceImages.length + 1)
);
for (let i = 0; i < requiredSlots; i++) {
addImageSlot();
}
cachedReferenceImages.forEach((cached, index) => applyCachedImageToSlot(index, cached));
maybeAddSlot();
}
function getReferenceFiles() {
return imageSlotState
.filter(record => record.data && !record.data.sourceUrl) // Only return files if no sourceUrl
.map(record => getSlotFile(record))
.filter(Boolean);
}
function getReferencePaths() {
return imageSlotState.map(record => {
if (!record.data) return null;
return record.data.sourceUrl || record.data.file?.name || null;
});
}
function serializeReferenceImages() {
return imageSlotState
.map((record, index) => {
if (!record.data || !record.data.preview) return null;
const name = record.data.cached?.name || record.data.file?.name || `reference-${index + 1}.png`;
const type = record.data.cached?.type || record.data.file?.type || 'image/png';
return {
name,
type,
dataUrl: record.data.preview,
sourceUrl: record.data.sourceUrl,
};
})
.filter(Boolean);
}
function addImageSlot() {
if (!imageInputGrid || imageSlotState.length >= MAX_IMAGE_SLOTS) return;
const index = imageSlotState.length;
const slotElement = createImageSlotElement(index);
imageSlotState.push({ slot: slotElement, data: null });
imageInputGrid.appendChild(slotElement);
}
function createImageSlotElement(index) {
const slot = document.createElement('div');
slot.className = 'image-slot empty';
slot.dataset.index = index;
const placeholder = document.createElement('div');
placeholder.className = 'slot-placeholder';
placeholder.innerHTML = '+';
slot.appendChild(placeholder);
const preview = document.createElement('img');
preview.className = 'slot-preview hidden';
preview.alt = 'Uploaded reference';
slot.appendChild(preview);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'slot-edit hidden';
editBtn.setAttribute('aria-label', 'Edit image');
editBtn.innerHTML = '';
slot.appendChild(editBtn);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'slot-remove hidden';
removeBtn.setAttribute('aria-label', 'Remove image');
removeBtn.textContent = '×';
slot.appendChild(removeBtn);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.className = 'slot-input';
slot.appendChild(input);
slot.addEventListener('click', event => {
if (event.target === removeBtn || event.target === editBtn) return;
input.click();
});
input.addEventListener('change', () => {
if (input.files && input.files.length) {
handleSlotFile(index, input.files[0]);
}
});
slot.addEventListener('dragenter', event => {
event.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragover', event => {
event.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragleave', () => {
slot.classList.remove('drag-over');
});
slot.addEventListener('drop', async event => {
event.preventDefault();
slot.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) {
handleSlotFile(index, file);
return;
}
const imageUrl = event.dataTransfer?.getData('text/uri-list')
|| event.dataTransfer?.getData('text/plain');
if (imageUrl) {
await handleSlotDropFromHistory(index, imageUrl);
}
});
editBtn.addEventListener('click', event => {
event.stopPropagation();
handleEditImage(index);
});
removeBtn.addEventListener('click', event => {
event.stopPropagation();
clearSlot(index);
});
return slot;
}
function handleSlotFile(index, file, sourceUrl = null) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const previewUrl = reader.result;
if (typeof previewUrl !== 'string') return;
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = {
file,
preview: previewUrl,
cached: null,
sourceUrl: sourceUrl,
};
updateSlotVisual(index);
onChange?.();
maybeAddSlot();
};
reader.readAsDataURL(file);
}
async function handleSlotDropFromHistory(index, imageUrl) {
try {
const response = await fetch(withCacheBuster(imageUrl));
if (!response.ok) {
console.warn('Failed to fetch history image', response.statusText);
return;
}
const blob = await response.blob();
const name = imageUrl.split('/').pop()?.split('?')[0] || `history-${index + 1}.png`;
const type = blob.type || 'image/png';
const file = new File([blob], name, { type });
// Extract relative path if possible, or use full URL
// Assuming imageUrl is like http://localhost:8888/static/generated/uuid.png
// We want /static/generated/uuid.png or just generated/uuid.png if that's how we store it.
// The app.py stores 'generated/filename.png' in metadata if we send it.
// But wait, app.py constructs image_url using url_for('static', filename=rel_path).
// Let's just store the full URL or relative path.
// If we store the full URL, we can fetch it back.
let sourceUrl = imageUrl;
try {
const urlObj = new URL(imageUrl, window.location.origin);
if (urlObj.origin === window.location.origin) {
sourceUrl = urlObj.pathname;
}
} catch (e) {
// ignore
}
handleSlotFile(index, file, sourceUrl);
} catch (error) {
console.error('Unable to import history image', error);
}
}
function updateSlotVisual(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
const slot = slotRecord.slot;
const placeholder = slot.querySelector('.slot-placeholder');
const preview = slot.querySelector('.slot-preview');
const editBtn = slot.querySelector('.slot-edit');
const removeBtn = slot.querySelector('.slot-remove');
if (slotRecord.data && slotRecord.data.preview) {
preview.src = slotRecord.data.preview;
preview.classList.remove('hidden');
placeholder.classList.add('hidden');
editBtn.classList.remove('hidden');
removeBtn.classList.remove('hidden');
slot.classList.add('filled');
slot.classList.remove('empty');
} else {
preview.src = '';
preview.classList.add('hidden');
placeholder.classList.remove('hidden');
editBtn.classList.add('hidden');
removeBtn.classList.add('hidden');
slot.classList.add('empty');
slot.classList.remove('filled');
}
}
function clearSlot(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = null;
const input = slotRecord.slot.querySelector('.slot-input');
if (input) input.value = '';
updateSlotVisual(index);
onChange?.();
}
function handleEditImage(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord || !slotRecord.data || !slotRecord.data.preview) return;
const imageSrc = slotRecord.data.preview;
new ImageEditor(imageSrc, async (blob) => {
// Convert blob to file
const fileName = slotRecord.data.file?.name || slotRecord.data.cached?.name || `edited-${index + 1}.png`;
const file = new File([blob], fileName, { type: blob.type || 'image/png' });
// Update the slot with the edited image
// Treat edited images as new uploads so they are sent as files (not paths)
handleSlotFile(index, file, null);
});
}
function maybeAddSlot() {
const hasEmpty = imageSlotState.some(record => !record.data);
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
addImageSlot();
}
}
function applyCachedImageToSlot(index, cached) {
if (!cached || !cached.dataUrl) return;
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = {
file: null,
preview: cached.dataUrl,
cached: {
name: cached.name,
type: cached.type,
dataUrl: cached.dataUrl,
},
sourceUrl: cached.sourceUrl || null,
};
updateSlotVisual(index);
}
async function setReferenceImages(paths) {
if (!Array.isArray(paths)) return;
// Clear existing slots first? Or overwrite?
// Let's overwrite from the beginning.
// Ensure we have enough slots
while (imageSlotState.length < paths.length && imageSlotState.length < MAX_IMAGE_SLOTS) {
addImageSlot();
}
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
if (!path) {
clearSlot(i);
continue;
}
// Check if it looks like a path we can fetch
if (path.startsWith('/') || path.startsWith('http')) {
await handleSlotDropFromHistory(i, path);
} else {
// It's likely just a filename of a local file we can't restore
// So we clear the slot
clearSlot(i);
}
}
// Clear remaining slots
for (let i = paths.length; i < imageSlotState.length; i++) {
clearSlot(i);
}
maybeAddSlot();
}
function getSlotFile(record) {
if (!record.data) return null;
if (record.data.file) return record.data.file;
if (record.data.cached && record.data.cached.dataUrl) {
const blob = dataUrlToBlob(record.data.cached.dataUrl);
if (!blob) return null;
const fileName = record.data.cached.name || `reference.png`;
const fileType = record.data.cached.type || 'image/png';
return new File([blob], fileName, { type: fileType });
}
return null;
}
return {
initialize,
getReferenceFiles,
getReferencePaths,
serializeReferenceImages,
setReferenceImages,
};
}