717 lines
24 KiB
JavaScript
717 lines
24 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
||
const generateBtn = document.getElementById('generate-btn');
|
||
const promptInput = document.getElementById('prompt');
|
||
const aspectRatioInput = document.getElementById('aspect-ratio');
|
||
const resolutionInput = document.getElementById('resolution');
|
||
const apiKeyInput = document.getElementById('api-key');
|
||
|
||
// States
|
||
const placeholderState = document.getElementById('placeholder-state');
|
||
const loadingState = document.getElementById('loading-state');
|
||
const errorState = document.getElementById('error-state');
|
||
const resultState = document.getElementById('result-state');
|
||
|
||
const errorText = document.getElementById('error-text');
|
||
const generatedImage = document.getElementById('generated-image');
|
||
const downloadLink = document.getElementById('download-link');
|
||
const galleryGrid = document.getElementById('gallery-grid');
|
||
const imageInputGrid = document.getElementById('image-input-grid');
|
||
const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
|
||
const MAX_IMAGE_SLOTS = 16;
|
||
const INITIAL_IMAGE_SLOTS = 4;
|
||
const imageSlotState = [];
|
||
let cachedReferenceImages = [];
|
||
const imageDisplayArea = document.querySelector('.image-display-area');
|
||
const canvasToolbar = document.querySelector('.canvas-toolbar');
|
||
const popupOverlay = document.getElementById('popup-overlay');
|
||
const popupTitleEl = document.getElementById('popup-title');
|
||
const popupBodyEl = document.getElementById('popup-body');
|
||
const popupCloseBtn = document.getElementById('popup-close');
|
||
const popupButtons = document.querySelectorAll('[data-popup-target]');
|
||
const ZOOM_STEP = 0.1;
|
||
const MIN_ZOOM = 0.4;
|
||
const MAX_ZOOM = 4;
|
||
let zoomLevel = 1;
|
||
let panOffset = { x: 0, y: 0 };
|
||
let isPanning = false;
|
||
let lastPointer = { x: 0, y: 0 };
|
||
|
||
const infoContent = {
|
||
title: 'Thông tin',
|
||
sections: [
|
||
{
|
||
heading: 'Liên hệ',
|
||
items: [
|
||
'Người tạo: Phạm Hưng',
|
||
'Group: <a href="https://www.facebook.com/groups/stablediffusion.vn/" target="_blank" rel="noreferrer">SDVN - Cộng đồng AI Art</a>',
|
||
'Website: <a href="https://sdvn.vn" target="_blank" rel="noreferrer">sdvn.vn</a>',
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
const docsContent = {
|
||
title: 'Phím tắt và mẹo',
|
||
sections: [
|
||
{
|
||
heading: 'Phím tắt',
|
||
items: [
|
||
'Ctrl/Cmd + Enter → tạo ảnh mới',
|
||
'D → tải ảnh hiện tại',
|
||
'R → reset zoom/pan vùng hiển thị ảnh',
|
||
'Esc → đóng popup thông tin/docs',
|
||
],
|
||
},
|
||
{
|
||
heading: 'Thao tác nhanh',
|
||
items: [
|
||
'Kéo ảnh từ lịch sử vào ô tham chiếu để tái sử dụng',
|
||
'Tùy chỉnh tỉ lệ và độ phân giải trước khi nhấn Generate',
|
||
'API key và prompt được lưu để lần sau không phải nhập lại',
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
const POPUP_CONTENT = {
|
||
info: infoContent,
|
||
docs: docsContent,
|
||
};
|
||
|
||
// Load gallery on start
|
||
loadSettings();
|
||
initializeImageInputs();
|
||
loadGallery();
|
||
|
||
apiKeyInput.addEventListener('input', persistSettings);
|
||
promptInput.addEventListener('input', persistSettings);
|
||
aspectRatioInput.addEventListener('change', persistSettings);
|
||
resolutionInput.addEventListener('change', persistSettings);
|
||
|
||
generateBtn.addEventListener('click', async () => {
|
||
const prompt = promptInput.value.trim();
|
||
const aspectRatio = aspectRatioInput.value;
|
||
const resolution = resolutionInput.value;
|
||
const apiKey = apiKeyInput.value.trim();
|
||
|
||
if (!prompt) {
|
||
showError('Please enter a prompt.');
|
||
return;
|
||
}
|
||
|
||
// Set UI to loading
|
||
setViewState('loading');
|
||
generateBtn.disabled = true;
|
||
|
||
try {
|
||
const formData = buildGenerateFormData({
|
||
prompt,
|
||
aspect_ratio: aspectRatio,
|
||
resolution,
|
||
api_key: apiKey,
|
||
});
|
||
|
||
const response = await fetch('/generate', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to generate image');
|
||
}
|
||
|
||
if (data.image) {
|
||
displayImage(data.image, data.image_data);
|
||
// Refresh gallery to show new image
|
||
loadGallery();
|
||
} else {
|
||
throw new Error('No image data received');
|
||
}
|
||
|
||
} catch (error) {
|
||
showError(error.message);
|
||
} finally {
|
||
generateBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
function setViewState(state) {
|
||
placeholderState.classList.add('hidden');
|
||
loadingState.classList.add('hidden');
|
||
errorState.classList.add('hidden');
|
||
resultState.classList.add('hidden');
|
||
|
||
switch (state) {
|
||
case 'placeholder':
|
||
placeholderState.classList.remove('hidden');
|
||
break;
|
||
case 'loading':
|
||
loadingState.classList.remove('hidden');
|
||
break;
|
||
case 'error':
|
||
errorState.classList.remove('hidden');
|
||
break;
|
||
case 'result':
|
||
resultState.classList.remove('hidden');
|
||
break;
|
||
}
|
||
}
|
||
|
||
document.addEventListener('keydown', handleGenerateShortcut);
|
||
document.addEventListener('keydown', handleResetShortcut);
|
||
document.addEventListener('keydown', handleDownloadShortcut);
|
||
|
||
function handleGenerateShortcut(event) {
|
||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||
event.preventDefault();
|
||
if (generateBtn && !generateBtn.disabled) {
|
||
generateBtn.click();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (imageDisplayArea) {
|
||
imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false });
|
||
imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown);
|
||
}
|
||
|
||
if (canvasToolbar) {
|
||
canvasToolbar.addEventListener('click', handleCanvasToolbarClick);
|
||
}
|
||
|
||
document.addEventListener('pointermove', handleCanvasPointerMove);
|
||
document.addEventListener('pointerup', () => {
|
||
if (isPanning && imageDisplayArea) {
|
||
imageDisplayArea.style.cursor = 'grab';
|
||
}
|
||
isPanning = false;
|
||
});
|
||
document.addEventListener('pointerleave', () => {
|
||
if (isPanning && imageDisplayArea) {
|
||
imageDisplayArea.style.cursor = 'grab';
|
||
}
|
||
isPanning = false;
|
||
});
|
||
|
||
function showError(message) {
|
||
errorText.textContent = message;
|
||
setViewState('error');
|
||
}
|
||
|
||
function displayImage(imageUrl, imageData) {
|
||
const cacheBustedUrl = withCacheBuster(imageUrl);
|
||
|
||
if (imageData) {
|
||
generatedImage.src = `data:image/png;base64,${imageData}`;
|
||
} else {
|
||
generatedImage.src = cacheBustedUrl;
|
||
}
|
||
|
||
downloadLink.href = imageData ? generatedImage.src : cacheBustedUrl;
|
||
const filename = imageUrl.split('/').pop().split('?')[0];
|
||
downloadLink.setAttribute('download', filename);
|
||
|
||
generatedImage.onload = () => {
|
||
resetView();
|
||
};
|
||
|
||
setViewState('result');
|
||
}
|
||
|
||
async function loadGallery() {
|
||
try {
|
||
const response = await fetch(`/gallery?t=${new Date().getTime()}`);
|
||
const data = await response.json();
|
||
|
||
galleryGrid.innerHTML = '';
|
||
|
||
data.images.forEach(imageUrl => {
|
||
const div = document.createElement('div');
|
||
div.className = 'gallery-item';
|
||
div.onclick = () => {
|
||
displayImage(imageUrl);
|
||
// Update active state
|
||
document.querySelectorAll('.gallery-item').forEach(el => el.classList.remove('active'));
|
||
div.classList.add('active');
|
||
};
|
||
|
||
const img = document.createElement('img');
|
||
img.src = withCacheBuster(imageUrl);
|
||
img.loading = 'lazy';
|
||
img.draggable = true;
|
||
img.dataset.source = imageUrl;
|
||
img.addEventListener('dragstart', event => {
|
||
event.dataTransfer?.setData('text/uri-list', imageUrl);
|
||
event.dataTransfer?.setData('text/plain', imageUrl);
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'copy';
|
||
}
|
||
});
|
||
|
||
div.appendChild(img);
|
||
galleryGrid.appendChild(div);
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load gallery:', error);
|
||
}
|
||
}
|
||
|
||
function withCacheBuster(url) {
|
||
const separator = url.includes('?') ? '&' : '?';
|
||
return `${url}${separator}t=${new Date().getTime()}`;
|
||
}
|
||
|
||
function buildGenerateFormData(fields) {
|
||
const formData = new FormData();
|
||
|
||
Object.entries(fields).forEach(([key, value]) => {
|
||
if (value !== undefined && value !== null) {
|
||
formData.append(key, value);
|
||
}
|
||
});
|
||
|
||
imageSlotState.forEach(record => {
|
||
const slotFile = getSlotFile(record);
|
||
if (slotFile) {
|
||
formData.append('reference_images', slotFile, slotFile.name);
|
||
}
|
||
});
|
||
|
||
return formData;
|
||
}
|
||
|
||
function loadSettings() {
|
||
if (typeof localStorage === 'undefined') return;
|
||
try {
|
||
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||
if (!saved) return;
|
||
|
||
const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved);
|
||
if (apiKey) apiKeyInput.value = apiKey;
|
||
if (aspectRatio) aspectRatioInput.value = aspectRatio;
|
||
if (resolution) resolutionInput.value = resolution;
|
||
if (prompt) promptInput.value = prompt;
|
||
cachedReferenceImages = Array.isArray(referenceImages) ? referenceImages : [];
|
||
} catch (error) {
|
||
console.warn('Unable to load cached settings', error);
|
||
}
|
||
}
|
||
|
||
function persistSettings() {
|
||
if (typeof localStorage === 'undefined') return;
|
||
try {
|
||
const settings = {
|
||
apiKey: apiKeyInput.value.trim(),
|
||
aspectRatio: aspectRatioInput.value,
|
||
resolution: resolutionInput.value,
|
||
prompt: promptInput.value.trim(),
|
||
referenceImages: serializeReferenceImages(),
|
||
};
|
||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||
} catch (error) {
|
||
console.warn('Unable to persist settings', error);
|
||
}
|
||
}
|
||
|
||
function handleCanvasWheel(event) {
|
||
if (resultState.classList.contains('hidden')) return;
|
||
event.preventDefault();
|
||
const delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||
adjustZoom(delta);
|
||
}
|
||
|
||
function handleCanvasPointerDown(event) {
|
||
const result = document.getElementById('result-state');
|
||
if (result.classList.contains('hidden')) return;
|
||
isPanning = true;
|
||
lastPointer = { x: event.clientX, y: event.clientY };
|
||
imageDisplayArea.style.cursor = 'grabbing';
|
||
}
|
||
|
||
function handleCanvasPointerMove(event) {
|
||
if (!isPanning) return;
|
||
const dx = event.clientX - lastPointer.x;
|
||
const dy = event.clientY - lastPointer.y;
|
||
panOffset.x += dx;
|
||
panOffset.y += dy;
|
||
lastPointer = { x: event.clientX, y: event.clientY };
|
||
setImageTransform();
|
||
}
|
||
|
||
function handleCanvasToolbarClick(event) {
|
||
const action = event.target.closest('.canvas-btn')?.dataset.action;
|
||
if (!action) return;
|
||
switch (action) {
|
||
case 'zoom-in':
|
||
adjustZoom(ZOOM_STEP);
|
||
break;
|
||
case 'zoom-out':
|
||
adjustZoom(-ZOOM_STEP);
|
||
break;
|
||
case 'zoom-fit':
|
||
zoomLevel = getFitZoom();
|
||
panOffset = { x: 0, y: 0 };
|
||
setImageTransform();
|
||
break;
|
||
case 'zoom-reset':
|
||
resetView();
|
||
break;
|
||
}
|
||
}
|
||
|
||
function adjustZoom(delta) {
|
||
const prevZoom = zoomLevel;
|
||
zoomLevel = clamp(zoomLevel + delta, MIN_ZOOM, MAX_ZOOM);
|
||
const scale = zoomLevel / prevZoom;
|
||
panOffset.x *= scale;
|
||
panOffset.y *= scale;
|
||
setImageTransform();
|
||
}
|
||
|
||
function setImageTransform() {
|
||
generatedImage.style.transform = `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoomLevel})`;
|
||
}
|
||
|
||
function getFitZoom() {
|
||
if (!generatedImage.naturalWidth || !generatedImage.naturalHeight || !imageDisplayArea) {
|
||
return 1;
|
||
}
|
||
const rect = imageDisplayArea.getBoundingClientRect();
|
||
const scaleX = rect.width / generatedImage.naturalWidth;
|
||
const scaleY = rect.height / generatedImage.naturalHeight;
|
||
const fitZoom = Math.max(scaleX, scaleY);
|
||
return Math.max(fitZoom, MIN_ZOOM);
|
||
}
|
||
|
||
function resetView() {
|
||
zoomLevel = 1;
|
||
panOffset = { x: 0, y: 0 };
|
||
setImageTransform();
|
||
}
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|
||
|
||
function handleResetShortcut(event) {
|
||
if (event.key !== 'r') return;
|
||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||
const targetTag = event.target?.tagName;
|
||
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
|
||
if (event.target?.isContentEditable) return;
|
||
if (resultState.classList.contains('hidden')) return;
|
||
event.preventDefault();
|
||
resetView();
|
||
}
|
||
|
||
function handleDownloadShortcut(event) {
|
||
if (event.key !== 'd') return;
|
||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||
const targetTag = event.target?.tagName;
|
||
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
|
||
if (event.target?.isContentEditable) return;
|
||
if (resultState.classList.contains('hidden')) return;
|
||
event.preventDefault();
|
||
downloadLink.click();
|
||
}
|
||
|
||
function initializeImageInputs() {
|
||
if (!imageInputGrid) return;
|
||
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 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,
|
||
},
|
||
};
|
||
updateSlotVisual(index);
|
||
}
|
||
|
||
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 = '<span class="slot-icon">+</span>';
|
||
slot.appendChild(placeholder);
|
||
|
||
const preview = document.createElement('img');
|
||
preview.className = 'slot-preview hidden';
|
||
preview.alt = 'Uploaded reference';
|
||
slot.appendChild(preview);
|
||
|
||
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) 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);
|
||
}
|
||
});
|
||
|
||
removeBtn.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
clearSlot(index);
|
||
});
|
||
|
||
return slot;
|
||
}
|
||
|
||
function handleSlotFile(index, file) {
|
||
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,
|
||
};
|
||
updateSlotVisual(index);
|
||
persistSettings();
|
||
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 });
|
||
handleSlotFile(index, file);
|
||
} 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 removeBtn = slot.querySelector('.slot-remove');
|
||
|
||
if (slotRecord.data && slotRecord.data.preview) {
|
||
preview.src = slotRecord.data.preview;
|
||
preview.classList.remove('hidden');
|
||
placeholder.classList.add('hidden');
|
||
removeBtn.classList.remove('hidden');
|
||
slot.classList.add('filled');
|
||
slot.classList.remove('empty');
|
||
} else {
|
||
preview.src = '';
|
||
preview.classList.add('hidden');
|
||
placeholder.classList.remove('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);
|
||
persistSettings();
|
||
}
|
||
|
||
function maybeAddSlot() {
|
||
const hasEmpty = imageSlotState.some(record => !record.data);
|
||
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
|
||
addImageSlot();
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function dataUrlToBlob(dataUrl) {
|
||
try {
|
||
const [prefix, base64] = dataUrl.split(',');
|
||
const mimeMatch = prefix.match(/:(.*?);/);
|
||
const mime = mimeMatch ? mimeMatch[1] : 'image/png';
|
||
const binary = atob(base64);
|
||
const len = binary.length;
|
||
const buffer = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) {
|
||
buffer[i] = binary.charCodeAt(i);
|
||
}
|
||
return new Blob([buffer], { type: mime });
|
||
} catch (error) {
|
||
console.warn('Unable to convert cached image to blob', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
popupButtons.forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const target = button.dataset.popupTarget;
|
||
if (target) {
|
||
showPopup(target);
|
||
}
|
||
});
|
||
});
|
||
|
||
if (popupCloseBtn) {
|
||
popupCloseBtn.addEventListener('click', closePopup);
|
||
}
|
||
|
||
if (popupOverlay) {
|
||
popupOverlay.addEventListener('click', event => {
|
||
if (event.target === popupOverlay) {
|
||
closePopup();
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && popupOverlay && !popupOverlay.classList.contains('hidden')) {
|
||
event.preventDefault();
|
||
closePopup();
|
||
}
|
||
});
|
||
|
||
function showPopup(type) {
|
||
const content = POPUP_CONTENT[type];
|
||
if (!content || !popupOverlay || !popupBodyEl || !popupTitleEl) return;
|
||
|
||
popupTitleEl.textContent = content.title;
|
||
popupBodyEl.innerHTML = content.sections
|
||
.map(section => {
|
||
const items = (section.items || []).map(item => `<li>${item}</li>`).join('');
|
||
return `
|
||
<section class="popup-section">
|
||
<h3>${section.heading}</h3>
|
||
<ul>${items}</ul>
|
||
</section>
|
||
`;
|
||
})
|
||
.join('');
|
||
|
||
popupOverlay.classList.remove('hidden');
|
||
}
|
||
|
||
function closePopup() {
|
||
if (!popupOverlay) return;
|
||
popupOverlay.classList.add('hidden');
|
||
}
|
||
});
|