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: SDVN - Cộng đồng AI Art', 'Website: sdvn.vn', ], }, ], }; 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 = '+'; 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 => `
  • ${item}
  • `).join(''); return ` `; }) .join(''); popupOverlay.classList.remove('hidden'); } function closePopup() { if (!popupOverlay) return; popupOverlay.classList.add('hidden'); } });