From f490c636325dfdda0e9bbe84cf35b06110c1ba8d Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Tue, 6 Jan 2026 08:49:28 +0700 Subject: [PATCH] feat: add extension implementation and docs Add manifest.json, sidepanel components, and scripts. Include project assets and documentation files. Remove placeholder blank file. --- .DS_Store | Bin 0 -> 6148 bytes CHANGELOG.md | 71 +++ JSON_STRUCTURE.md | 262 ++++++++ README.md | 152 +++++ TROUBLESHOOTING.md | 21 + assets/.DS_Store | Bin 0 -> 6148 bytes blank | 0 icons/create-icons.html | 55 ++ icons/icon128.png | Bin 0 -> 861 bytes icons/icon16.png | Bin 0 -> 196 bytes icons/icon48.png | Bin 0 -> 378 bytes manifest.json | 46 ++ scripts/background.js | 117 ++++ scripts/content.js | 662 ++++++++++++++++++++ scripts/grid_generator.js | 112 ++++ scripts/grid_templates.js | 144 +++++ sidepanel/grid_ui.js | 464 ++++++++++++++ sidepanel/panel-compact.css | 177 ++++++ sidepanel/panel.css | 1138 +++++++++++++++++++++++++++++++++++ sidepanel/panel.html | 314 ++++++++++ sidepanel/panel.js | 778 ++++++++++++++++++++++++ sidepanel/warning.html | 64 ++ verification.txt | 9 + 23 files changed, 4586 insertions(+) create mode 100644 .DS_Store create mode 100644 CHANGELOG.md create mode 100644 JSON_STRUCTURE.md create mode 100644 README.md create mode 100644 TROUBLESHOOTING.md create mode 100644 assets/.DS_Store delete mode 100644 blank create mode 100644 icons/create-icons.html create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 scripts/background.js create mode 100644 scripts/content.js create mode 100644 scripts/grid_generator.js create mode 100644 scripts/grid_templates.js create mode 100644 sidepanel/grid_ui.js create mode 100644 sidepanel/panel-compact.css create mode 100644 sidepanel/panel.css create mode 100644 sidepanel/panel.html create mode 100644 sidepanel/panel.js create mode 100644 sidepanel/warning.html create mode 100644 verification.txt diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1843289b9f659c8ad13283c3e8e759d76e512e4c GIT binary patch literal 6148 zcmeHKJ5EC}5S)b+k!Vs-Q2GKXxWQ8tPQV3#Bn48GClL}+zKU~kG-f{q(Ss%uO*AX5 z$6oK)@)U310@4tiWrg3Gv-*K#}OO6jk13aIQJIMIN+Vk zpZqyb`CxQ|9rk$T?ryzbZD0C+>m->JkOERb3P=GdaFzm9MyEGtu|<*sQs9ym@b5#T zJ9dRbVthI{L<>NiFdW8t^b*A80b*A;Br-y?q!N>A)nZuE8E=)>6%L6>hgI`ob+c86 zVsSgqZ;=k`5;aNzDR8R5buMRK{~zdo^#7+Mt)zeyxF`i|vV2@F_@t_>qsMuzZS*I) u=X}xKI1dVkD96Mo$6R5Ukb<4g|T-aDKqRKNybl3;Y4mGY}R|JUubn$nWygtbQOYi+G8NR6}>o z^mNU#6>M(PVQzlzKC_d`7?I999921ictKq}y$yO7J#nX9zi*i^` zRFnczV5-1rZdcy_ujxO`|5K87Qa}p)D+O$^x!J7wO4VCuFXz3s(QoNq^GSE(Iw%a$ lj)~EZx$$;<5k* + + + Generate Icons + + + + + + diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..11817c4b0dafcd6ad2f27f552c520f84e968f52b GIT binary patch literal 861 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^U{>*TaSW-L^LFmp+&cy$Et-z$ z*C%-YD&SwKutBhVqVp~e0fk%^%boo3cbsbY4#=>u@XZlDv0Sk|s#d<_Q`D5QWBGC? z|9&j_{xY0N{`8d1zvcYS8maOWG%==4J6X%F-Y%EW#$n{lGIJr*nG4Kk1RT>k6pSSj z(q>+`&m{_^3=eQ^Rxoy%ASsrVwt~U<3xjb;gJe~Mq|yl0*ta&RrW#LL`tf@g;6;W_+M^VTpotte4$YT~G7a0z(n#>%p@E%L(qf*6|;=*kNubS*Jad5hwM5V?6qp~1m3T!>yCGOZK?E%ES++veCMX->IFh; zS2J#%)%T|HT*I7@<7S4Y?Oz>Kquz78I{f#r_iJw%ou+Sp6tBQoD1qAwn?5D!ac=h`%r0Vb%8q zt-n?Ymau)f)f27xeZkjkBWqiW4{0%-n+_e@eQ@4FK1s*=$h`S_o_jt9WEsrm=U*Xo z_UE=Kw6~eecwiE-R0XXkiBcGq(sa%f!V=fvbswmUw&Zneyf_Yt9QTT zb;rL4TF-3~dbh0Z?bDLY3mU5((!Z|OtJ-jON14->1y3%8%Q?Dz<<55ycYG|F5MH19 z+pLAHN7S)-#r{o&##$HKes=^V1ZFudm6pH20gfCF-Ube>yKS!--5bI2uiUM$Z-;4{ zmwA)%Sn0A93l(ZC^&IB-~864p8y}|SU*ePJbVPihC zK{6evx0x|5f^&AFES5xA6LgP9G@w7j!yI*;tTtST`!kUj<@vNAfZ*6$ zgCYbmq<_gX(09y~1x`45a{4I?rypF literal 0 HcmV?d00001 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3d72a48 --- /dev/null +++ b/manifest.json @@ -0,0 +1,46 @@ +{ + "manifest_version": 3, + "name": "Auto Cover Generator", + "version": "1.0.0", + "description": "สร้างหน้าปกข่าว/Youtube อัตโนมัติด้วย Gemini AI", + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "permissions": [ + "activeTab", + "sidePanel", + "downloads", + "storage", + "scripting" + ], + "host_permissions": [ + "*://gemini.google.com/*" + ], + "background": { + "service_worker": "scripts/background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://gemini.google.com/*" + ], + "js": [ + "scripts/content.js" + ], + "run_at": "document_idle" + } + ], + "side_panel": { + "default_path": "sidepanel/warning.html" + }, + "action": { + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "default_title": "Auto Cover Generator" + } +} \ No newline at end of file diff --git a/scripts/background.js b/scripts/background.js new file mode 100644 index 0000000..cbaa5ea --- /dev/null +++ b/scripts/background.js @@ -0,0 +1,117 @@ +// Background service worker for Auto Cover Generator + +// 1. Native Click Behavior +chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + +const openTabs = new Set(); + +// 2. Click Handler (Native behavior handles opening, we just manage content) +// Note: chrome.action.onClicked is ignored when openPanelOnActionClick is true. + +// 3. Tab State Management +// Monitor all tab changes to ensure correct panel is shown +chrome.runtime.onInstalled.addListener(() => { + initializeAllTabs(); +}); + +chrome.runtime.onStartup.addListener(() => { + initializeAllTabs(); +}); + +async function initializeAllTabs() { + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + await checkAndSetSidePanel(tab.id); + } +} + +// 4. Tab Activation / Isolation Logic +chrome.tabs.onActivated.addListener(async (activeInfo) => { + const tabId = activeInfo.tabId; + await checkAndSetSidePanel(tabId); +}); + +chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' || changeInfo.url) { + await checkAndSetSidePanel(tabId); + } +}); + +async function checkAndSetSidePanel(tabId) { + try { + const tab = await chrome.tabs.get(tabId); + if (!tab) return; // Tab doesn't exist + + // If we don't have permission to see the URL, it's definitely not Gemini (since we have host_permissions for Gemini). + // So undefined tab.url means "Restricted/Other Site". + const isGemini = tab.url && tab.url.startsWith('https://gemini.google.com/'); + + if (isGemini) { + // Enable and set path for Gemini + await chrome.sidePanel.setOptions({ + tabId: tabId, + path: 'sidepanel/panel.html', + enabled: true + }); + } else { + // Disable for all other sites - this CLOSES the panel if open + // and ensures it stays closed when returning to the tab (until clicked manually) + await chrome.sidePanel.setOptions({ + tabId: tabId, + enabled: false + }); + } + + } catch (e) { + // Tab might be closed or we heavily failed access + console.log('Cannot access tab or set panel', e); + } +} + +chrome.runtime.onInstalled.addListener(() => { + chrome.sidePanel.setOptions({ enabled: false }); +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + openTabs.delete(tabId); +}); + +// 4. Message Routing +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Forward messages to content script + if (message.action === 'generateCover' || message.action === 'removeObjectFromImage') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.sendMessage(tabs[0].id, message, (response) => { + sendResponse(response); + }); + } + }); + return true; + } + + if (message.action === 'downloadImage') { + const url = message.url; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `gemini_gen_${timestamp}.png`; + + chrome.downloads.download({ + url: url, + filename: filename, + conflictAction: 'uniquify' + }, (downloadId) => { + if (chrome.runtime.lastError) { + console.error('Download failed:', chrome.runtime.lastError); + } else { + console.log('Download started, ID:', downloadId); + } + }); + return; + } + + if (message.action === 'generationError') { + chrome.runtime.sendMessage(message).catch(() => { }); + } +}); + +console.log('Auto Cover Generator: Background Service Ready'); diff --git a/scripts/content.js b/scripts/content.js new file mode 100644 index 0000000..479f0f1 --- /dev/null +++ b/scripts/content.js @@ -0,0 +1,662 @@ +// Content script for Gemini integration +console.log('Auto Cover Generator content script loaded on Gemini'); + +// Selectors for Gemini UI elements (may need adjustment based on Gemini's actual DOM) +const SELECTORS = { + inputArea: 'rich-textarea[placeholder*="Enter"], textarea[placeholder*="Enter"], div[contenteditable="true"]', + sendButton: 'button[aria-label*="Send"], button[type="submit"]', + imageUploadButton: 'button[aria-label*="Add"], input[type="file"]', + generatedImage: 'img[src*="googleusercontent"], img[src*="generated"], img.image.loaded, img[alt="รูปภาพ"]', + responseContainer: 'div[data-message-author-role="model"], .model-response' +}; + +let isGenerating = false; +let currentPrompt = null; +let currentImage = null; +let lastGenerationRequestTime = 0; // Timestamp of last extension-triggered generation + +// Listen for messages from side panel +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === 'generateCover' || message.action === 'removeObjectFromImage') { + // Reuse the generation handler but adapt inputs + const isRemover = message.action === 'removeObjectFromImage'; + + // For remover, the 'image' field is actually the 'imageData' (base64) + // and we wrap it in an array to match logic. + // For cover, 'images' or 'image' is used. + const msgAdapter = isRemover ? { + prompt: message.prompt, + images: [message.imageData] // Specific for remover + } : message; + + handleGenerateCover(msgAdapter) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Keep channel open for async response + } +}); + +// Main generation handler +async function handleGenerateCover(message) { + if (isGenerating) { + throw new Error('กำลังสร้างอยู่แล้ว กรุณารอสักครู่'); + } + + isGenerating = true; + lastGenerationRequestTime = Date.now(); // Start the "Auto Download" window + currentPrompt = message.prompt; + currentImage = message.image; + + try { + // Step 1: Find input area + const inputElement = await waitForElement(SELECTORS.inputArea, 5000); + if (!inputElement) { + throw new Error('ไม่พบช่องกรอกข้อความของ Gemini'); + } + + // Count existing images to differentiate new generation + const existingImages = document.querySelectorAll(SELECTORS.generatedImage); + const initialImageCount = existingImages.length; + console.log(`Initial image count: ${initialImageCount}`); + + // Step 2: Upload images if provided + const images = message.images || (message.image ? [message.image] : []); + if (images.length > 0) { + await uploadImagesToGemini(images, inputElement); + + // Wait for image upload processing + // User requested delay consistent with number of attached images + // Optimized: Reduced from 3000ms to 1000ms per image for speed + const uploadDelay = Math.min(images.length * 1000, 12000); // Max 12s + console.log(`Waiting ${uploadDelay}ms for ${images.length} images to upload/process...`); + await sleep(uploadDelay); + } + + // Step 3: Insert prompt + await insertPrompt(inputElement, currentPrompt); + await sleep(100); // Reduced from 500 + + // Step 3.5: Press Enter key + await pressEnter(inputElement); + await sleep(200); // Reduced from 500 + + // Step 4: Click send button with retry + const sent = await clickSendButton(); + if (sent) { + // Wait a bit for Gemini to process the prompt + await sleep(1000); // Reduced from 2000 + + // Step 4.5: Auto-click generate/create button if it appears + await autoClickGenerateButton(); + + // Step 5: Wait for and capture generated image + // Network Listener in background.js now handles detection and download! + // await waitForGeneratedImage(initialImageCount); + console.log('Content: Waiting for background network detection...'); + } else { + throw new Error('ไม่สามารถกดปุ่มส่งข้อความได้'); + } + + } catch (error) { + console.error('Generation error:', error); + chrome.runtime.sendMessage({ + action: 'generationError', + error: error.message + }); + } finally { + isGenerating = false; + } +} + +// Click send button with retry +async function clickSendButton() { + const maxRetries = 10; + for (let i = 0; i < maxRetries; i++) { + const sendButton = document.querySelector(SELECTORS.sendButton); + if (sendButton && !sendButton.disabled && sendButton.getAttribute('aria-disabled') !== 'true') { + console.log('Found enabled send button, clicking...'); + sendButton.click(); + return true; + } + console.log('Waiting for send button to be enabled...'); + await sleep(500); + } + return false; +} + +// Simulate Enter key press +async function pressEnter(element) { + console.log('Simulating Enter key...'); + const events = ['keydown', 'keypress', 'keyup']; + for (const type of events) { + const event = new KeyboardEvent(type, { + bubbles: true, + cancelable: true, + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + char: '\r', + view: window + }); + element.dispatchEvent(event); + await sleep(50); + } +} + +// Insert prompt into Gemini input +async function insertPrompt(element, prompt) { + element.focus(); + + // Clear existing content if possible + if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') { + element.value = ''; + element.value = prompt; + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + } else { + // ContentEditable div + element.textContent = prompt; + + // Dispatch complex event sequence for rich text editors + const events = ['keydown', 'keypress', 'textInput', 'input', 'keyup']; + events.forEach(eventType => { + const event = new Event(eventType, { bubbles: true, cancelable: true }); + element.dispatchEvent(event); + }); + + // Trigger specific InputEvent for modern frameworks + try { + const inputEvent = new InputEvent('input', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: prompt + }); + element.dispatchEvent(inputEvent); + } catch (e) { + // Fallback for older browsers + } + } + + // Small delay to let framework validation run + await sleep(200); + element.blur(); + await sleep(100); + element.focus(); +} + +// Super Robust Upload Function: Tries Paste -> DragDrop -> Classical Input +async function uploadImagesToGemini(imagesDatBase64, inputElement) { + console.log('Starting hybrid upload sequence...'); + + // Helper: Base64 to Blob + const base64ToBlob = (base64) => { + const arr = base64.split(','); + const mime = arr[0].match(/:(.*?);/)[1]; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new File([new Uint8Array(u8arr)], 'image.png', { type: mime }); + }; + + // Prepare files + const dataTransfer = new DataTransfer(); + for (let i = 0; i < imagesDatBase64.length; i++) { + const file = base64ToBlob(imagesDatBase64[i]); + dataTransfer.items.add(file); + } + const fileList = dataTransfer.files; + + // STRATEGY 1: DISPATCH PASTE EVENT + try { + console.log('Strategy 1: Simulating Paste Event...'); + const pasteEvent = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: dataTransfer + }); + + // Target the specific input (textarea/div) first + inputElement.dispatchEvent(pasteEvent); + await sleep(500); + + } catch (e) { + console.warn('Strategy 1 failed', e); + } + + // STRATEGY 2: CLICK ADD BUTTON & ATTACH TO INPUT (Fall back to native interaction) + console.log('Strategy 2: UI Interaction (Find & Click Add Button)...'); + + // 2.1 Find the Add Button + // Scoped search near input + let addBtn = null; + let inputContainer = document.body; + + if (inputElement) { + inputContainer = inputElement.closest('.textarea-wrapper, .input-area, .text-input-field, footer, .main-footer') || + inputElement.parentElement.parentElement; + } + + if (inputContainer) { + // Updated selectors based on common Gemini UI and Material Design + const selectors = [ + 'button[aria-label*="Upload"]', + 'button[aria-label*="Add"]', + 'button[aria-label*="เลือกรูป"]', + 'button[aria-label*="เพิ่ม"]', + '.mat-mdc-button-touch-target', // Material touch target often overlays the icon + 'button:has(mat-icon[data-mat-icon-name="add"])', + 'button:has(svg)', // Risky but constrained to inputContainer + ]; + + for (const sel of selectors) { + const found = inputContainer.querySelectorAll(sel); + for (const btn of found) { + // Check if it looks like the plus button (usually first button on left) + const rect = btn.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + addBtn = btn; + break; + } + } + if (addBtn) break; + } + } + + if (addBtn) { + console.log('Found Add Button, clicking...', addBtn); + addBtn.click(); + await sleep(800); // Wait for menu animation + } else { + console.log('Could not pinpoint Add Button, skipping click step.'); + } + + // 2.2 Find File Input (It should exist now if click worked, or be always present) + const fileInput = document.querySelector('input[type="file"]'); + if (fileInput) { + console.log('Found File Input, attaching files...'); + fileInput.files = fileList; + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + fileInput.dispatchEvent(new Event('input', { bubbles: true })); + console.log('Files attached to input.'); + return true; + } else { + console.warn('Strategy 2 failed: No file input found even after clicking.'); + } + + // STRATEGY 3: DRAG & DROP (Last Resort) + console.log('Strategy 3: Drag & Drop Simulation...'); + const dropEventInit = { + bubbles: true, + cancelable: true, + view: window, + dataTransfer: dataTransfer + }; + + // Try to find specific dropzone if inputElement is just a text area + const dropzone = document.querySelector('xapfileselectordropzone') || + document.querySelector('.drop-zone') || + document.body; + + let dropTarget = (dropzone && dropzone !== document.body) ? dropzone : inputElement; + + dropTarget.dispatchEvent(new DragEvent('dragenter', dropEventInit)); + await sleep(50); + dropTarget.dispatchEvent(new DragEvent('dragover', dropEventInit)); + await sleep(50); + dropTarget.dispatchEvent(new DragEvent('drop', dropEventInit)); + + console.log('Hybrid upload sequence completed.'); + // We return true blindly because we tried everything. + return true; +} + +// Auto-click generate/create button +async function autoClickGenerateButton() { + // Common selectors for Gemini's generate/create button + const generateButtonSelectors = [ + 'button[aria-label*="Generate"]', + 'button[aria-label*="Create"]', + 'button:has-text("Generate")', + 'button:has-text("Create")', + 'button[data-test-id*="generate"]', + 'button[data-test-id*="create"]', + '.generate-button', + '.create-button' + ]; + + // Try to find and click the generate button in a loop for a short period + const maxAttempts = 5; + for (let i = 0; i < maxAttempts; i++) { + for (const selector of generateButtonSelectors) { + try { + const button = document.querySelector(selector); + if (button && button.offsetParent !== null) { // Check if visible + console.log('Found generate button, clicking...', selector); + button.click(); + await sleep(500); + return true; + } + } catch (e) { + // Continue to next selector + } + } + await sleep(500); + } + // Alternative: Look for buttons with specific text content + const allButtons = document.querySelectorAll('button'); + for (const button of allButtons) { + const text = button.textContent.toLowerCase(); + if ((text.includes('generate') || text.includes('create') || text.includes('สร้าง')) && + button.offsetParent !== null) { + console.log('Found generate button by text, clicking...', button.textContent); + button.click(); + await sleep(500); + return true; + } + } + + console.log('Generate button not found, image may need manual generation'); + return false; +} + +// Wait for generated image to appear +async function waitForGeneratedImage(initialCount = 0) { + const maxWaitTime = 60000; // 60 seconds + const checkInterval = 1000; // Check every second + let elapsed = 0; + + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + elapsed += checkInterval; + + // Look for generated images + const images = document.querySelectorAll(SELECTORS.generatedImage); + + if (images.length > initialCount) { + // Get the most recent image + const latestImage = images[images.length - 1]; + const imageUrl = latestImage.src; + + if (imageUrl && !imageUrl.includes('data:image')) { + clearInterval(interval); + + // Send image URL back to side panel + chrome.runtime.sendMessage({ + action: 'imageGenerated', + imageUrl: imageUrl + }); + + resolve(imageUrl); + } + } + + if (elapsed >= maxWaitTime) { + clearInterval(interval); + reject(new Error('หมดเวลารอรูปภาพจาก Gemini')); + } + }, checkInterval); + }); +} + +// Utility: Wait for element to appear +function waitForElement(selector, timeout = 5000) { + return new Promise((resolve) => { + const element = document.querySelector(selector); + if (element) { + resolve(element); + return; + } + + const observer = new MutationObserver(() => { + const element = document.querySelector(selector); + if (element) { + observer.disconnect(); + resolve(element); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + + setTimeout(() => { + observer.disconnect(); + resolve(null); + }, timeout); + }); +} + +// Utility: Sleep +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Monitor for manual image generation (if user generates without extension) +// ----------------------------------------------------------- +// ----------------------------------------------------------- +// Auto Download Logic (Persistent) +// ----------------------------------------------------------- + +let isAutoDownloadEnabled = false; // Default off, will sync with storage +const downloadedImages = new Set(); // Local cache of what we've seen/downloaded + +// Initialize state from storage +chrome.storage.local.get(['autoDownload', 'downloadedHistory'], (result) => { + isAutoDownloadEnabled = result.autoDownload || false; + + // 1. Load history into Set + if (result.downloadedHistory && Array.isArray(result.downloadedHistory)) { + result.downloadedHistory.forEach(url => downloadedImages.add(url)); + } + + // 2. Scan Existing DOM for images (The "Reload Extension" case) + // Any image currently visible is "old" and shouldn't be auto-downloaded again this session + // We add EVERYTHING currently on screen to the history to be safe. + const existingImages = document.querySelectorAll('img'); + let addedCount = 0; + existingImages.forEach(img => { + if (img.src && img.src.startsWith('http')) { + // Just add it blindly to history so we don't process it again + if (!downloadedImages.has(img.src)) { + downloadedImages.add(img.src); + addedCount++; + } + } + }); + + if (addedCount > 0) { + console.log(`Marked ${addedCount} existing images as seen (skipped download).`); + saveDownloadHistory(); + } + + console.log('Auto Download status:', isAutoDownloadEnabled); + console.log(`Loaded ${downloadedImages.size} total images in history.`); + + // Start observing ONLY if enabled + if (isAutoDownloadEnabled) { + startObserving(); + } +}); + +// Listen for storage changes +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.autoDownload) { + isAutoDownloadEnabled = changes.autoDownload.newValue; + console.log('Auto Download status changed to:', isAutoDownloadEnabled); + + if (isAutoDownloadEnabled) { + startObserving(); + } else { + stopObserving(); + } + } +}); + +// ... (previous code) + +let observerActive = false; +let isInitialLoad = true; + +// Set a grace period for the initial load +// This prevents the extension from downloading existing chat history as "new" images +// when the user reloads the page. +setTimeout(() => { + isInitialLoad = false; + console.log('Initial load grace period ended. Ready to capture NEW images.'); +}, 5000); + +const autoDownloadObserver = new MutationObserver((mutations) => { + // Only run if we are generally interested, though checking inside loop is safer for dynamic toggling + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + // Specific validaiton for IMG tag + if (node.tagName === 'IMG') { + checkAndDownloadImage(node); + } + // Sometimes images are nested in added containers + if (node.querySelectorAll) { + const images = node.querySelectorAll('img'); + images.forEach(checkAndDownloadImage); + } + } + } +}); + +let saveTimeout; +function debouncedSaveHistory() { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(saveDownloadHistory, 1000); +} + +function checkAndDownloadImage(img) { + // 1. Check if it's already processed (in local Set) + // Safety: Duplicate Check using src as key + if (downloadedImages.has(img.src)) return; + + // 2. Wait for image to load to check dimensions + if (!img.complete) { + img.onload = () => checkAndDownloadImage(img); + return; + } + + if (!img.src) return; + + // --- STRICT FILTERING LOGIC --- + + // Condition A (Size): Width OR Height > 300px + // Prevents icons, avatars, stickers + const isBigEnough = img.naturalWidth > 300 || img.naturalHeight > 300; + if (!isBigEnough) { + return; + } + + // Condition B (Source): + // - URL must not be small Data URI (handled partly by size check, but user wants strict source check) + // - Must not be Blob from user upload + // - If possible, check if in model-response container + + // Check ancestors for container type + const messageContainer = img.closest('[data-message-author-role], .model-response, .user-message, .query-container'); + let isModelResponse = false; + let isUserUpload = false; + + if (messageContainer) { + const role = messageContainer.getAttribute('data-message-author-role'); + if (role === 'user') { + isUserUpload = true; + } else if (role === 'model') { + isModelResponse = true; // High confidence + } else if (messageContainer.classList.contains('user-message') || messageContainer.classList.contains('query-container')) { + isUserUpload = true; + } else if (messageContainer.classList.contains('model-response')) { + isModelResponse = true; + } + } + + // B.1: Reject Explicit User Uploads + if (isUserUpload) { + console.log('Ignored: Image is in User message', img.src); + downloadedImages.add(img.src); + return; + } + + // B.2: Source URL Check + const src = img.src; + const isBlob = src.startsWith('blob:'); + const isData = src.startsWith('data:'); + + if ((isBlob || isData) && !isModelResponse) { + console.log('Ignored: Blob/Data URI without Model context (Likely user upload)', src); + return; + } + + // B.3: Input Area Check (Composing phrase) + if (img.closest('div[contenteditable="true"], .input-area, textarea, .text-input-field')) { + return; + } + + // B.4: Avatar Check (googleusercontent specific) + if (src.includes('googleusercontent.com/a/')) { + return; + } + + // If we passed all filters: + // console.log('Pass: Valid Generated Image detected:', src); + + // Add to handled set immediately + downloadedImages.add(src); + debouncedSaveHistory(); // Use debounced save + + // CRITICAL: Check if we are in the initial load phase (Reload protection) + if (isInitialLoad) { + console.log('Skipped Download (Initial Load):', src); + return; + } + + if (isAutoDownloadEnabled) { + console.log('Auto-downloading image...'); + chrome.runtime.sendMessage({ + action: 'downloadImage', + url: src + }); + } +} + +function saveDownloadHistory() { + // Convert Set to Array + const historyArray = Array.from(downloadedImages); + + // Limit history size to prevent storage bloat (e.g., last 500 images) + if (historyArray.length > 500) { + historyArray.splice(0, historyArray.length - 500); + } + + chrome.storage.local.set({ downloadedHistory: historyArray }); +} + +function startObserving() { + if (observerActive) return; // Prevent double attach + + // Start observing specifically for images appearing in the chat + autoDownloadObserver.observe(document.body, { + childList: true, + subtree: true + }); + + observerActive = true; + console.log('Auto Download Observer STARTED'); +} + +function stopObserving() { + if (!observerActive) return; + + autoDownloadObserver.disconnect(); + observerActive = false; + console.log('Auto Download Observer STOPPED (Resource Saved)'); +} diff --git a/scripts/grid_generator.js b/scripts/grid_generator.js new file mode 100644 index 0000000..a0d374c --- /dev/null +++ b/scripts/grid_generator.js @@ -0,0 +1,112 @@ +// Grid Generator logic +// Ported from python engine/grid_generator.py + +class GridGenerator { + static BORDER_WIDTH = 5; + static BORDER_COLOR = '#FFFFFF'; + + /** + * Generates a grid image + * @param {Array} images - Array of loaded image elements + * @param {GridTemplate} template - The template to use + * @param {Object} offsets - Optional offsets map { slotIndex: {x, y} } + * @param {string} fakeNumber - Optional text to display on the last slot (e.g. "+5") + * @returns {HTMLCanvasElement} The canvas with the generated grid + */ + static generate(images, template, offsets = {}, fakeNumber = null) { + const canvas = document.createElement('canvas'); + canvas.width = template.canvasSize[0]; + canvas.height = template.canvasSize[1]; + const ctx = canvas.getContext('2d'); + + // Fill background with border color + ctx.fillStyle = this.BORDER_COLOR; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const count = Math.min(images.length, template.slots.length); + + for (let i = 0; i < count; i++) { + const slot = template.slots[i]; + const img = images[i]; + + // Calculate the actual image size (subtract borders) + const border = this.BORDER_WIDTH; + const actualW = slot.w - (border * 2); + const actualH = slot.h - (border * 2); + const actualX = slot.x + border; + const actualY = slot.y + border; + + // Get offset for this slot (default to center 0,0) + // Range [-1, 1] + const offset = offsets[i] || { x: 0, y: 0 }; + + // Calculate crop + const imgAspect = img.naturalWidth / img.naturalHeight; + const slotAspect = actualW / actualH; + + let sourceX, sourceY, sourceW, sourceH; + + if (imgAspect > slotAspect) { + // Image is wider - fit to height + sourceH = img.naturalHeight; + sourceW = sourceH * slotAspect; + + const maxOffset = (img.naturalWidth - sourceW) / 2; + const panX = maxOffset * offset.x; + + sourceX = (img.naturalWidth - sourceW) / 2 + panX; + sourceY = 0; + } else { + // Image is taller - fit to width + sourceW = img.naturalWidth; + sourceH = sourceW / slotAspect; + + const maxOffset = (img.naturalHeight - sourceH) / 2; + const panY = maxOffset * offset.y; + + sourceX = 0; + sourceY = (img.naturalHeight - sourceH) / 2 + panY; + } + + // Draw image to canvas + ctx.drawImage( + img, + sourceX, sourceY, sourceW, sourceH, // Source + actualX, actualY, actualW, actualH // Destination + ); + } + + // Draw fake number in last slot if provided + if (template.slots.length > 0 && typeof fakeNumber === 'string' && fakeNumber.length > 0) { + const lastSlotIndex = template.slots.length - 1; + const lastSlot = template.slots[lastSlotIndex]; + + // Calculate slot dimensions (including border logic) + const border = this.BORDER_WIDTH; + const actualW = lastSlot.w - (border * 2); + const actualH = lastSlot.h - (border * 2); + const actualX = lastSlot.x + border; + const actualY = lastSlot.y + border; + + // 1. Draw Semi-Transparent Overlay + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; // 50% opacity black + ctx.fillRect(actualX, actualY, actualW, actualH); + + // 2. Draw Text centered + // Responsive font size: 20% of slot height + const fontSize = Math.floor(actualH * 0.2); + ctx.font = `bold ${fontSize}px Arial, sans-serif`; + ctx.fillStyle = 'white'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Calculate center of slot + const centerX = actualX + (actualW / 2); + const centerY = actualY + (actualH / 2); + + ctx.fillText(fakeNumber, centerX, centerY); + } + + return canvas; + } +} diff --git a/scripts/grid_templates.js b/scripts/grid_templates.js new file mode 100644 index 0000000..d50b4be --- /dev/null +++ b/scripts/grid_templates.js @@ -0,0 +1,144 @@ +// Grid Templates definition +// Ported from python engine/templates.py + +class Slot { + constructor(x, y, w, h, description = "") { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.description = description; + } +} + +class GridTemplate { + constructor(id, name, canvasSize, slots, expectedCount) { + this.id = id; + this.name = name; + this.canvasSize = canvasSize; // [width, height] + this.slots = slots; + this.expectedCount = expectedCount; + } +} + +class TemplateManager { + static getTemplates() { + const templates = []; + + // Template 1: 3 images - Left portrait + 2 stacked squares + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 1, + "Portrait + 2 Squares", + [1920, 1920], + [ + new Slot(0, 0, 960, 1920, "Left Portrait"), + new Slot(960, 0, 960, 960, "Right Top"), + new Slot(960, 960, 960, 960, "Right Bottom") + ], + 3 + )); + + // Template 2: 5 images - 2 squares left + 3 stacked right + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 2, + "2 Squares + 3 Stacked", + [1920, 1920], + [ + // Left Column + new Slot(0, 0, 960, 960, "Left Top"), + new Slot(0, 960, 960, 960, "Left Bottom"), + // Right Column + new Slot(960, 0, 960, 640, "Right Top"), + new Slot(960, 640, 960, 640, "Right Mid"), + new Slot(960, 1280, 960, 640, "Right Bot"), + ], + 5 + )); + + // Template 3: 4 images - Main left + 3 stacked squares right + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 3, + "Main + 3 Squares", + [1920, 1920], + [ + new Slot(0, 0, 1280, 1920, "Left Main"), + new Slot(1280, 0, 640, 640, "Right Top"), + new Slot(1280, 640, 640, 640, "Right Mid"), + new Slot(1280, 1280, 640, 640, "Right Bot"), + ], + 4 + )); + + // Template 4: 3 images - Top wide + 2 squares bottom + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 4, + "Wide + 2 Squares", + [1920, 1920], + [ + new Slot(0, 0, 1920, 960, "Top Wide"), + new Slot(0, 960, 960, 960, "Bottom Left"), + new Slot(960, 960, 960, 960, "Bottom Right"), + ], + 3 + )); + + // Template 5: 4 images - Top main + 3 squares bottom + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 5, + "Main + 3 Bottom Squares", + [1920, 1920], + [ + new Slot(0, 0, 1920, 1280, "Top Main"), + new Slot(0, 1280, 640, 640, "Bot Left"), + new Slot(640, 1280, 640, 640, "Bot Mid"), + new Slot(1280, 1280, 640, 640, "Bot Right"), + ], + 4 + )); + + // Template 6: 4 images - Perfect 2x2 grid + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 6, + "2x2 Grid", + [1920, 1920], + [ + new Slot(0, 0, 960, 960, "Top Left"), + new Slot(960, 0, 960, 960, "Top Right"), + new Slot(0, 960, 960, 960, "Bottom Left"), + new Slot(960, 960, 960, 960, "Bottom Right"), + ], + 4 + )); + + // Template 7: 5 images - 2 large top + 3 small bottom + // Canvas: 1920 x 1920 (1:1) + templates.push(new GridTemplate( + 7, + "2 Large + 3 Small", + [1920, 1920], + [ + // Top Row - 2 large squares + new Slot(0, 0, 960, 960, "Top Left"), + new Slot(960, 0, 960, 960, "Top Right"), + // Bottom Row - 3 smaller rectangles + new Slot(0, 960, 640, 960, "Bottom Left"), + new Slot(640, 960, 640, 960, "Bottom Center"), + new Slot(1280, 960, 640, 960, "Bottom Right"), + ], + 5 + )); + + return templates; + } + + static getTemplateById(id) { + const templates = this.getTemplates(); + return templates.find(t => t.id === id) || templates[0]; + } +} diff --git a/sidepanel/grid_ui.js b/sidepanel/grid_ui.js new file mode 100644 index 0000000..1abc7fc --- /dev/null +++ b/sidepanel/grid_ui.js @@ -0,0 +1,464 @@ + +// Grid UI Logic + +document.addEventListener('DOMContentLoaded', () => { + // Determine context + const tabBtns = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + // Remove active class from all + tabBtns.forEach(b => b.classList.remove('active')); + tabContents.forEach(c => c.classList.remove('active')); + + // Add active class to clicked button and target content + btn.classList.add('active'); + const targetId = `tab-${btn.dataset.tab}`; + const targetContent = document.getElementById(targetId); + if (targetContent) { + targetContent.classList.add('active'); + } + }); + }); + + initializeGridUI(); +}); + +let gridImages = []; // Stores Image objects +let currentTemplateId = 1; +// Offsets for panning: map of slotIndex -> { x: float, y: float } (range -1.0 to 1.0) +let gridOffsets = {}; + +function initializeGridUI() { + const gridUploadInput = document.getElementById('gridImageInput'); + const gridDropZone = document.getElementById('gridDropZone'); + + if (!gridUploadInput) return; // Not in tab mode or elements missing + + // File Upload Handlers + gridDropZone.addEventListener('click', () => gridUploadInput.click()); + gridUploadInput.addEventListener('change', (e) => handleGridFiles(e.target.files)); + + gridDropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + gridDropZone.classList.add('drag-over'); + }); + gridDropZone.addEventListener('dragleave', () => gridDropZone.classList.remove('drag-over')); + gridDropZone.addEventListener('drop', (e) => { + e.preventDefault(); + gridDropZone.classList.remove('drag-over'); + handleGridFiles(e.dataTransfer.files); + }); + + // Template Selection + renderTemplateOptions(); + + // Generate Button + document.getElementById('gridGenerateBtn').addEventListener('click', generateGrid); + + // Download Button + document.getElementById('gridDownloadBtn').addEventListener('click', downloadGrid); + + // Fake Number Controls + const fakeNumberToggle = document.getElementById('fakeNumberToggle'); + const fakeNumberInput = document.getElementById('fakeNumberInput'); + const fakeNumberContainer = document.getElementById('fakeNumberInputContainer'); + + fakeNumberToggle.addEventListener('change', (e) => { + fakeNumberContainer.style.display = e.target.checked ? 'block' : 'none'; + updateGridPreview(); + }); + + fakeNumberInput.addEventListener('input', () => { + updateGridPreview(); + }); + + // Initialize Canvas Interaction + initCanvasInteraction(); +} + +function renderTemplateOptions() { + const container = document.getElementById('gridTemplateList'); + const templates = TemplateManager.getTemplates(); + + container.innerHTML = ''; + + templates.forEach(t => { + const btn = document.createElement('button'); + btn.className = 'template-btn'; + if (t.id === currentTemplateId) btn.classList.add('active'); + + // Create Dynamic Preview + const canvasW = t.canvasSize[0]; + const canvasH = t.canvasSize[1]; + + let slotsHtml = ''; + t.slots.forEach(slot => { + const left = (slot.x / canvasW) * 100; + const top = (slot.y / canvasH) * 100; + const width = (slot.w / canvasW) * 100; + const height = (slot.h / canvasH) * 100; + + slotsHtml += `
`; + }); + + btn.innerHTML = ` +
+ ${slotsHtml} +
+ ${t.expectedCount} ภาพ + `; + + btn.onclick = () => { + document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentTemplateId = t.id; + gridOffsets = {}; + updateGridPreview(); + }; + + container.appendChild(btn); + }); +} + +function handleGridFiles(fileList) { + if (!fileList.length) return; + + // Load images + Array.from(fileList).forEach(file => { + if (!file.type.startsWith('image/')) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + gridImages.push(img); + renderGridImagesList(); + updateGridPreview(); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); + }); +} + +// Drag & Drop Reordering State +let draggedItemIndex = null; + +function renderGridImagesList() { + const list = document.getElementById('gridImagesList'); + list.innerHTML = ''; + + gridImages.forEach((img, index) => { + const div = document.createElement('div'); + div.className = 'grid-thumb-item'; + div.draggable = true; // Enable drag + + // Drag Events + div.addEventListener('dragstart', (e) => { + draggedItemIndex = index; + e.dataTransfer.effectAllowed = 'move'; + div.style.opacity = '0.5'; + }); + + div.addEventListener('dragend', () => { + div.style.opacity = '1'; + draggedItemIndex = null; + document.querySelectorAll('.grid-thumb-item').forEach(item => item.classList.remove('drag-over-target')); + }); + + div.addEventListener('dragover', (e) => { + e.preventDefault(); // Allow drop + e.dataTransfer.dropEffect = 'move'; + if (draggedItemIndex !== index) { + div.classList.add('drag-over-target'); + } + }); + + div.addEventListener('dragleave', () => { + div.classList.remove('drag-over-target'); + }); + + div.addEventListener('drop', (e) => { + e.preventDefault(); + if (draggedItemIndex !== null && draggedItemIndex !== index) { + // Reorder array + const movedItem = gridImages[draggedItemIndex]; + gridImages.splice(draggedItemIndex, 1); + gridImages.splice(index, 0, movedItem); + + // Also reorder offsets if we want to be fancy, but resetting is safer/simpler for now + // or we could map them. Let's just reset offsets for simplicity as image moved slots + gridOffsets = {}; + + renderGridImagesList(); + updateGridPreview(); + } + }); + + const thumb = img.cloneNode(); + div.appendChild(thumb); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'remove-btn'; + removeBtn.innerHTML = '×'; + removeBtn.onclick = (e) => { + e.stopPropagation(); + gridImages.splice(index, 1); + gridOffsets = {}; + renderGridImagesList(); + updateGridPreview(); + }; + div.appendChild(removeBtn); + + list.appendChild(div); + }); + + // Update count hint + const template = TemplateManager.getTemplateById(currentTemplateId); + const countSpan = document.getElementById('gridImageCount'); + if (countSpan) countSpan.textContent = `${gridImages.length} / ${template.expectedCount}`; +} + +function updateGridPreview() { + const previewContainer = document.getElementById('gridCanvasPreview'); + + // If no images, clear the preview + if (gridImages.length === 0) { + previewContainer.innerHTML = ''; + return; + } + + const template = TemplateManager.getTemplateById(currentTemplateId); + + // Get fake number if enabled + let fakeNumber = null; + const fakeNumberToggle = document.getElementById('fakeNumberToggle'); + if (fakeNumberToggle && fakeNumberToggle.checked) { + fakeNumber = document.getElementById('fakeNumberInput').value || ""; + } + + // Pass the offsets and fakeNumber + const canvas = GridGenerator.generate(gridImages, template, gridOffsets, fakeNumber); + + previewContainer.innerHTML = ''; + + // Scale for display + canvas.style.width = '100%'; + canvas.style.height = 'auto'; + + // Add class for cursor interaction + canvas.classList.add('interactive-canvas'); + + previewContainer.appendChild(canvas); +} + +// Canvas Interaction (Panning) +let isDragging = false; +let startX, startY; +let activeSlotIndex = -1; +let initialOffset = { x: 0, y: 0 }; + +function initCanvasInteraction() { + const container = document.getElementById('gridCanvasPreview'); + + container.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); // Document level for smooth drag outside + document.addEventListener('mouseup', onMouseUp); +} + +function onMouseDown(e) { + if (e.target.tagName !== 'CANVAS') return; + + e.preventDefault(); + const canvas = e.target; + // Get mouse pos relative to canvas + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + const mouseX = (e.clientX - rect.left) * scaleX; + const mouseY = (e.clientY - rect.top) * scaleY; + + // Find clicked slot + const template = TemplateManager.getTemplateById(currentTemplateId); + const count = Math.min(gridImages.length, template.slots.length); + + activeSlotIndex = -1; + + for (let i = 0; i < count; i++) { + const slot = template.slots[i]; + // Simple hit test (ignoring borders for simplicity, or include them) + if (mouseX >= slot.x && mouseX <= slot.x + slot.w && + mouseY >= slot.y && mouseY <= slot.y + slot.h) { + activeSlotIndex = i; + break; + } + } + + if (activeSlotIndex !== -1) { + isDragging = true; + startX = e.clientX; + startY = e.clientY; + initialOffset = { ... (gridOffsets[activeSlotIndex] || { x: 0, y: 0 }) }; + canvas.style.cursor = 'grabbing'; + } +} + +function onMouseMove(e) { + if (!isDragging || activeSlotIndex === -1) return; + e.preventDefault(); + + const canvas = document.querySelector('#gridCanvasPreview canvas'); + if (!canvas) return; + + // Calculate Delta + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + // Convert delta to offset range (-1 to 1) manually? + // This is tricky because the sensitivity depends on how much "extra" image there is. + // If the image fits perfectly, maxOffset is 0, so panning does nothing (correct). + // If we move 100px, how much logic offset is that? + // We need to know the max pan capabilities of the current image in the current slot. + // Ideally, we move pixel-for-pixel. + + // Let's retrieve image and slot info + const template = TemplateManager.getTemplateById(currentTemplateId); + const slot = template.slots[activeSlotIndex]; + const img = gridImages[activeSlotIndex]; + if (!img || !slot) return; + + const border = 5; // Hardcoded border width from generator + const actualW = slot.w - (border * 2); + const actualH = slot.h - (border * 2); + const slotAspect = actualW / actualH; + const imgAspect = img.naturalWidth / img.naturalHeight; + + let maxOffsetX_px = 0; + let maxOffsetY_px = 0; + + // Calculate how the image is scaled in the slot + // Logic duplicated from GridGenerator... ideally we refactor shared math but let's recompute. + if (imgAspect > slotAspect) { + // Image is wider - fit height + // rendered height = actualH + // rendered width = actualH * imgAspect + const renderedW = actualH * imgAspect; + maxOffsetX_px = (renderedW - actualW) / 2; + } else { + // Image is taller - fit width + // rendered width = actualW + // rendered height = actualW / imgAspect + const renderedH = actualW / imgAspect; + maxOffsetY_px = (renderedH - actualH) / 2; + } + + // Map pixels to -1..1 range + // delta 100px means we want to shift offset. + // current_pixel_offset = initial_pixel_offset + delta + // new_normalized = current_pixel_offset / max_offset_px + + // Since offset 1.0 = max_px, offset 0.0 = 0px + // initial_pixel_val = initialOffset.x * maxOffsetX_px; + // new_pixel_val = initial_pixel_val - deltaX (Mouse move right -> pan image right -> seeing left part? + // Wait, usually panning: move mouse right = image moves right. + // In our generator: + // sourceX = center + (maxOffset * offset.x) + // If offset.x is positive, sourceX increases -> we crop from the right -> image appears to move LEFT? + // Let's verify: + // Center = 500. MaxOffset = 100. + // Offset 0 -> sourceX = 500. + // Offset 1 -> sourceX = 600. Viewport shows 600+. Image shifted LEFT relative to frame. + // So to move image RIGHT (mouse right), we need to DECREASE sourceX. + // So we need to DECREASE offset. + + // X Axis + let newOffsetX = initialOffset.x; + if (maxOffsetX_px > 0) { + // Sensitivity factor? Pixel to pixel mapping + // To move image by deltaX pixels visually: + // shift sourceX by -deltaX * (sourceScale / destScale) ? + // Actually we are mapping screen pixels to canvas pixels. + // Canvas is scaled via CSS. + const rect = canvas.getBoundingClientRect(); + const screenToCanvasRatio = canvas.width / rect.width; + + // We want the image to move with the mouse pointer exactly. + // The image is drawn at 'actualW' size (destination). + // If we move mouse 10px right, we want image to shift 10px right. + // Drawing: ctx.drawImage(img, srcX, ...) + // If we change srcX by -10, the image drawn shifts right by 10 (if scale is 1). + + // Calculate scale between Source Image and Destination Canvas Slot + let scale = 1; + if (imgAspect > slotAspect) { + scale = img.naturalHeight / actualH; // Source / Dest + } else { + scale = img.naturalWidth / actualW; + } + + const moveX_src = -deltaX * screenToCanvasRatio * scale; + const moveY_src = -deltaY * screenToCanvasRatio * scale; + + // Convert src movement to offset change + // offset = src_shift / max_offset + if (maxOffsetX_px > 0) { + // maxOffsetX_px is in Destination pixels... wait. + // maxOffsetX defined above was: (renderedW - actualW) / 2 + // renderedW IS destination size. + // So maxOffsetX_px is in Desintation pixels (Canvas coords). + const moveX_dest = deltaX * screenToCanvasRatio; + // Move right = positive delta. + // We want image to move right = shift left side in view = sourceX decreases. + // offset factor = move_dest / max_offset_dest + + // BUT, verify direction again. + // offset 1 => shift LEFT. + // we want shift RIGHT => decrease offset. + // So minus sign. + newOffsetX = initialOffset.x - (moveX_dest / maxOffsetX_px); + } + } + + // Y Axis + let newOffsetY = initialOffset.y; + if (maxOffsetY_px > 0) { + const moveY_dest = deltaY * (canvas.height / canvas.getBoundingClientRect().height); + newOffsetY = initialOffset.y - (moveY_dest / maxOffsetY_px); + } + + // Clamp + newOffsetX = Math.max(-1, Math.min(1, newOffsetX)); + newOffsetY = Math.max(-1, Math.min(1, newOffsetY)); + + // Update State + gridOffsets[activeSlotIndex] = { x: newOffsetX, y: newOffsetY }; + + // Redraw (Throttle?) + // For now direct redraw + updateGridPreview(); +} + +function onMouseUp() { + isDragging = false; + activeSlotIndex = -1; + const canvas = document.querySelector('#gridCanvasPreview canvas'); + if (canvas) canvas.style.cursor = 'default'; +} + +function generateGrid() { + updateGridPreview(); // Force refresh +} + +function downloadGrid() { + const previewContainer = document.getElementById('gridCanvasPreview'); + const canvas = previewContainer.querySelector('canvas'); + if (!canvas) return; + + const link = document.createElement('a'); + link.download = `autogrid-${Date.now()}.png`; + link.href = canvas.toDataURL('image/png'); + link.click(); +} diff --git a/sidepanel/panel-compact.css b/sidepanel/panel-compact.css new file mode 100644 index 0000000..276cc83 --- /dev/null +++ b/sidepanel/panel-compact.css @@ -0,0 +1,177 @@ +/* Compact CSS - Overrides for smaller UI */ + +.container { + padding: 10px 10px 250px 10px !important; + /* Ensure bottom padding for fixed footer */ +} + +/* ... existing styles ... */ + +/* Buttons */ +.action-buttons { + gap: 8px !important; + padding: 12px 16px !important; + /* Adjust padding for compact fixed footer */ + background: rgba(15, 23, 42, 0.95) !important; + backdrop-filter: blur(10px) !important; + border-top: 1px solid var(--border) !important; + margin: 0 !important; + /* Reset margin for fixed position */ + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + position: fixed !important; + z-index: 100 !important; +} + +.header { + padding: 12px 10px !important; + margin-bottom: 10px !important; + border-radius: 10px !important; +} + +.title { + font-size: 18px !important; + margin-bottom: 2px !important; +} + +.subtitle { + font-size: 11px !important; +} + +.section { + padding: 10px !important; + margin-bottom: 8px !important; + border-radius: 8px !important; +} + +.section-title { + font-size: 13px !important; + margin-bottom: 8px !important; +} + +/* Grids */ +.style-grid, +.ratio-grid, +.mood-grid, +.position-grid, +.font-type-grid { + gap: 6px !important; +} + +.style-card { + padding: 8px 6px !important; + border-radius: 8px !important; +} + +.style-icon { + font-size: 24px !important; + margin-bottom: 4px !important; +} + +.style-name { + font-size: 12px !important; + margin-bottom: 2px !important; +} + +.style-desc { + font-size: 9px !important; +} + +.ratio-btn, +.position-btn, +.font-type-btn, +.image-position-btn { + padding: 8px 4px !important; + gap: 4px !important; +} + +.ratio-btn span, +.position-btn span, +.font-type-btn span, +.image-position-btn span { + font-size: 10px !important; +} + +.mood-btn { + padding: 6px 4px !important; +} + +.mood-icon { + font-size: 20px !important; + margin-bottom: 2px !important; +} + +.mood-name { + font-size: 9px !important; +} + +/* Forms */ +.form-group { + margin-bottom: 8px !important; +} + +.form-group label { + font-size: 12px !important; + margin-bottom: 4px !important; +} + +.text-input, +.custom-prompt { + padding: 8px 10px !important; + font-size: 12px !important; +} + +.headline-textarea { + min-height: 40px !important; +} + +.custom-prompt { + min-height: 60px !important; +} + +.checkbox-style { + padding: 6px 8px !important; + gap: 6px !important; +} + +.checkbox-style span { + font-size: 12px !important; +} + +.checkbox-style input[type="checkbox"] { + width: 16px !important; + height: 16px !important; +} + +.color-picker-wrapper { + padding: 6px 8px !important; + gap: 8px !important; +} + +.color-picker-wrapper input[type="color"] { + width: 32px !important; + height: 32px !important; +} + +.color-label { + font-size: 11px !important; +} + +/* Buttons */ +.action-buttons { + gap: 8px !important; + margin-top: 10px !important; + margin-bottom: 10px !important; +} + +.btn { + padding: 10px 14px !important; + font-size: 13px !important; + border-radius: 8px !important; +} + +.status-message { + padding: 8px 12px !important; + font-size: 12px !important; +} \ No newline at end of file diff --git a/sidepanel/panel.css b/sidepanel/panel.css new file mode 100644 index 0000000..d8b529d --- /dev/null +++ b/sidepanel/panel.css @@ -0,0 +1,1138 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-dark: #0f0f1e; + --bg-card: #1a1a2e; + --bg-hover: #252540; + --primary: #6366f1; + --primary-light: #818cf8; + --primary-dark: #4f46e5; + --secondary: #ec4899; + --accent: #14b8a6; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --border: #334155; + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; +} + +body { + font-family: 'Sarabun', -apple-system, BlinkMacSystemFont, sans-serif; + background: linear-gradient(135deg, var(--bg-dark) 0%, #1a1a2e 100%); + color: var(--text-primary); + line-height: 1.6; + overflow-x: hidden; +} + +.container { + width: 100%; + max-width: 420px; + padding: 16px; + padding-bottom: 180px; + margin: 0 auto; +} + +/* Navigation Tabs */ +.tabs { + display: flex; + gap: 2px; + background: var(--bg-card); + padding: 4px; + border-radius: 12px; + margin-bottom: 16px; + border: 1px solid var(--border); +} + +.tab-btn { + flex: 1; + padding: 10px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-secondary); + font-family: 'Prompt', sans-serif; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.tab-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.tab-btn.active { + background: var(--primary); + color: white; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); +} + +/* Tab Content */ +.tab-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ +.header { + position: relative; + text-align: center; + padding: 16px; + margin-bottom: 16px; + border-radius: 12px; + background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); + overflow: hidden; +} + +.header-gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%); + animation: shimmer 3s infinite; +} + +@keyframes shimmer { + + 0%, + 100% { + transform: translateX(-100%); + } + + 50% { + transform: translateX(100%); + } +} + +.title { + position: relative; + font-family: 'Prompt', sans-serif; + font-size: 22px; + font-weight: 700; + margin-bottom: 4px; + text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); +} + +.subtitle { + position: relative; + font-size: 12px; + opacity: 0.95; + font-weight: 300; +} + +/* Section */ +.section { + background: var(--bg-card); + border-radius: 10px; + padding: 14px; + margin-bottom: 12px; + border: 1px solid var(--border); + transition: all 0.3s ease; +} + +.section:hover { + border-color: var(--primary); + box-shadow: 0 4px 20px rgba(99, 102, 241, 0.15); +} + +.section-title { + font-family: 'Prompt', sans-serif; + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text-primary); +} + +/* Upload Area */ +.upload-area { + position: relative; + border: 2px dashed var(--border); + border-radius: 12px; + padding: 32px 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: rgba(99, 102, 241, 0.05); +} + +.upload-area:hover { + border-color: var(--primary); + background: rgba(99, 102, 241, 0.1); +} + +.upload-area.drag-over { + border-color: var(--accent); + background: rgba(20, 184, 166, 0.1); + transform: scale(1.02); +} + +.upload-placeholder svg { + color: var(--primary); + margin-bottom: 12px; +} + +.upload-placeholder p { + font-size: 16px; + margin-bottom: 8px; + color: var(--text-primary); +} + +.upload-hint { + font-size: 13px; + color: var(--text-secondary); +} + +.image-preview-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + width: 100%; + margin-top: 12px; +} + +.image-preview-item { + position: relative; + border-radius: 8px; + overflow: hidden; + padding-bottom: 100%; + /* Square aspect ratio */ + background: var(--bg-card); + border: 1px solid var(--border); +} + +.image-preview-item img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove-btn { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(239, 68, 68, 0.9); + color: white; + border: none; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + z-index: 10; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.remove-btn:hover { + background: var(--error); + transform: scale(1.1); +} + +/* Style Grid */ +.style-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.style-card { + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 10px; + padding: 14px 12px; + cursor: pointer; + transition: all 0.3s ease; + text-align: center; +} + +.style-card:hover { + border-color: var(--primary); + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.2); +} + +.style-card.active { + border-color: var(--primary); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(236, 72, 153, 0.2) 100%); +} + +.style-icon { + font-size: 28px; + margin-bottom: 6px; +} + +.style-name { + font-family: 'Prompt', sans-serif; + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; + color: var(--text-primary); +} + +.style-desc { + font-size: 11px; + color: var(--text-secondary); +} + +/* Ratio Grid */ +.ratio-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.ratio-btn { + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 10px; + padding: 12px 8px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.ratio-btn:hover { + border-color: var(--primary); + transform: translateY(-2px); +} + +.ratio-btn.active { + border-color: var(--accent); + background: rgba(20, 184, 166, 0.15); +} + +.ratio-box { + background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); + border-radius: 4px; +} + +.ratio-box.square { + width: 32px; + height: 32px; +} + +.ratio-box.portrait-34 { + width: 28px; + height: 38px; +} + +.ratio-box.portrait-45 { + width: 30px; + height: 38px; +} + +.ratio-box.landscape-43 { + width: 38px; + height: 28px; +} + +.ratio-box.portrait-23 { + width: 26px; + height: 38px; +} + +.ratio-box.portrait-916 { + width: 22px; + height: 38px; +} + +.ratio-box.landscape-169 { + width: 44px; + height: 26px; +} + +.ratio-btn span { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +/* Emotion Grid */ +.emotion-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.emotion-btn { + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; + transition: all 0.3s ease; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + text-align: center; +} + +.emotion-btn:hover { + border-color: var(--primary); + transform: translateY(-2px); +} + +.emotion-btn.active { + border-color: var(--secondary); + background: rgba(236, 72, 153, 0.2); +} + +/* Form Elements */ +.form-group { + margin-bottom: 12px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + color: var(--text-primary); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 400; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--primary); +} + +.text-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-hover); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-family: 'Sarabun', sans-serif; + transition: all 0.3s ease; + resize: vertical; +} + +.text-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.text-style-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +/* Position Grid */ +.position-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.position-btn { + padding: 8px 12px; + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.position-btn:hover { + border-color: var(--primary); +} + +.position-btn.active { + border-color: var(--accent); + background: rgba(20, 184, 166, 0.2); +} + +/* Checkbox Style */ +.checkbox-style { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; +} + +.checkbox-style:hover { + border-color: var(--primary); +} + +.checkbox-style input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--primary); +} + +.checkbox-style span { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +/* Font Style Grid */ +.font-style-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.font-style-btn { + padding: 8px 12px; + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.font-style-btn:hover { + border-color: var(--primary); +} + +.font-style-btn.active { + border-color: var(--primary); + background: rgba(99, 102, 241, 0.2); +} + +.color-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.color-picker-wrapper { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-hover); + border: 2px solid var(--border); + border-radius: 8px; + transition: all 0.3s ease; +} + +.color-picker-wrapper:focus-within { + border-color: var(--primary); +} + +.color-picker-wrapper input[type="color"] { + width: 40px; + height: 40px; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.color-label { + font-size: 13px; + font-family: 'Courier New', monospace; + color: var(--text-secondary); +} + +.custom-prompt { + width: 100%; + padding: 10px 12px; + background: var(--bg-hover); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-family: 'Sarabun', sans-serif; + resize: vertical; + min-height: 80px; + transition: all 0.3s ease; +} + +.custom-prompt:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +/* Action Buttons */ +.action-buttons { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + background: rgba(15, 15, 30, 0.95); + backdrop-filter: blur(10px); + border-top: 1px solid var(--border); + z-index: 100; + display: flex; + justify-content: center; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.2); + margin: 0; +} + +.action-buttons .btn { + max-width: 420px; + width: 100%; +} + +.btn { + padding: 12px 20px; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + font-family: 'Prompt', sans-serif; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-full { + width: 100%; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: white; + box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +#generateBtn { + background: linear-gradient(135deg, #ef4444 0%, #d50000 100%); + box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4); +} + +#generateBtn:hover:not(:disabled) { + background: linear-gradient(135deg, #f87171 0%, #b71c1c 100%); + box-shadow: 0 8px 24px rgba(239, 68, 68, 0.5); + transform: translateY(-2px); +} + +.btn-secondary { + background: linear-gradient(135deg, var(--accent) 0%, #0d9488 100%); + color: white; + box-shadow: 0 4px 16px rgba(20, 184, 166, 0.3); +} + +.btn-secondary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(20, 184, 166, 0.4); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Status Message */ +.status-message { + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + text-align: center; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.status-message.success { + background: rgba(16, 185, 129, 0.2); + border: 1px solid var(--success); + color: var(--success); +} + +.status-message.error { + background: rgba(239, 68, 68, 0.2); + border: 1px solid var(--error); + color: var(--error); +} + +.status-message.info { + background: rgba(99, 102, 241, 0.2); + border: 1px solid var(--primary); + color: var(--primary-light); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-dark); +} + +::-webkit-scrollbar-thumb { + background: var(--primary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--primary-light); +} + +/* Loading Animation */ +.loading { + position: relative; + pointer-events: none; +} + +.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* History Section */ +.history-section { + margin-top: 16px; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.history-header .section-title { + margin-bottom: 0; +} + +.btn-small { + padding: 6px 12px; + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-small:hover { + border-color: var(--primary); + background: rgba(99, 102, 241, 0.1); +} + +.history-stats { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--bg-hover); + border-radius: 8px; + margin-bottom: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +.history-list { + max-height: 300px; + overflow-y: auto; +} + +.history-item { + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 8px; + font-size: 12px; + transition: all 0.3s ease; +} + +.history-item:hover { + border-color: var(--primary); +} + +.history-item.success { + border-left: 3px solid var(--success); +} + +.history-item.error { + border-left: 3px solid var(--error); +} + +.history-date { + color: var(--text-secondary); + font-size: 11px; + margin-bottom: 6px; +} + +.history-settings { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + margin-top: 6px; + font-size: 11px; + color: var(--text-secondary); +} + +.history-headline { + color: var(--text-primary); + font-weight: 600; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + +/* Remover Tool Styles */ +.canvas-container { + border: 2px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-bottom: 0 !important; +} + +#maskCanvas { + opacity: 1; + touch-action: none; +} + +.brush-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +#brushSize { + height: 6px; + -webkit-appearance: none; + background: var(--bg-hover); + border-radius: 3px; + outline: none; +} + +#brushSize::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: var(--primary); + border-radius: 50%; + cursor: pointer; + transition: transform 0.2s; +} + +#brushSize::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +#brushSizeVal { + font-size: 13px; + color: var(--text-secondary); + min-width: 40px; +} + +.preset-controls { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.preset-controls .text-input { + margin-bottom: 0 !important; +} + +.preset-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-hover); +} + +.preset-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: all 0.2s ease; +} + +.preset-item:last-child { + border-bottom: none; +} + +.preset-item:hover { + background: rgba(99, 102, 241, 0.1); +} + +.preset-name { + flex-grow: 1; + font-size: 13px; + color: var(--text-primary); + font-weight: 500; +} + +.preset-delete-btn { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 4px; + opacity: 0.6; + transition: all 0.2s ease; +} + +.preset-delete-btn:hover { + opacity: 1; + transform: scale(1.1); +} + +/* Tabs */ +.tabs { + display: flex; + margin-bottom: 20px; + background: var(--bg-hover); + padding: 4px; + border-radius: 12px; + position: relative; + z-index: 10; +} + +.tab-btn { + flex: 1; + padding: 8px 12px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-secondary); + font-family: 'Prompt', sans-serif; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.tab-btn.active { + background: var(--bg-card); + color: var(--primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.tab-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Grid Creator Styles */ +.grid-images-list { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-top: 12px; + min-height: 40px; +} + +.grid-thumb-item { + position: relative; + aspect-ratio: 1; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--bg-hover); +} + +.grid-thumb-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.section-status { + text-align: right; + margin-top: 5px; + color: var(--text-secondary); +} + +.template-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + max-height: 300px; + overflow-y: auto; + padding-right: 4px; +} + +.template-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 10px; + background: var(--bg-hover); + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + color: var(--text-secondary); + font-size: 12px; + transition: all 0.2s; +} + +.template-btn:hover { + border-color: var(--primary-light); + transform: translateY(-2px); +} + +.template-btn.active { + border-color: var(--primary); + background: rgba(99, 102, 241, 0.1); + color: var(--primary); +} + +.template-preview { + width: 100%; + aspect-ratio: 1; + position: relative; + background-color: transparent; + border-radius: 6px; + overflow: hidden; +} + +.preview-slot { + position: absolute; + background-color: var(--bg-card); + border: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; + /* Simulate gap */ + transform: scale(0.96); + border-radius: 4px; + transition: all 0.3s ease; +} + +.template-btn:hover .preview-slot { + border-color: var(--primary); + background-color: var(--bg-hover); +} + +.template-btn.active .preview-slot { + background-color: var(--primary); + /* Active Color */ + border-color: var(--primary-light); + box-shadow: 0 0 8px rgba(99, 102, 241, 0.4); +} + +/* Just basic styling for now, user will see the output */ + +.canvas-preview-container { + width: 100%; + min-height: 200px; + background: #111; + border: 1px solid var(--border); + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; + overflow: hidden; + padding: 10px; +} \ No newline at end of file diff --git a/sidepanel/panel.html b/sidepanel/panel.html new file mode 100644 index 0000000..b37739d --- /dev/null +++ b/sidepanel/panel.html @@ -0,0 +1,314 @@ + + + + + + + Auto Cover Generator + + + + + + + + +
+ + + + + +
+ +
+
+

🎨 Auto Cover Generator

+

สร้างหน้าปกสุดเจ๋งด้วย Gemini AI

+
+ + +
+

⚙️ การตั้งค่า

+
+ +

+ เมื่อเปิดใช้งาน รูปที่ Gen เสร็จจะถูกดาวน์โหลดลงเครื่องทันที +

+
+
+ + +
+

🖼️ เพิ่มรูปภาพ (สูงสุด 10 รูป)

+
+
+ + + + + +

คลิกหรือลากรูปมาวางที่นี่

+ รองรับ JPG, PNG (Max 10) +
+ + +
+
+ + +
+

🎭 เลือกสไตล์ปก

+
+ + + + + + +
+
+ + +
+

📐 ขนาดหน้าปก

+
+ + + + + + + +
+
+ + +
+

😊 อารมณ์ของตัวแบบ

+
+ + + + + + + +
+
+ + +
+

✍️ ปรับแต่งตัวหนังสือ

+ +
+ +
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + +
+
+ +
+ +
+ + + + + + +
+
+ +
+
+ + +
+

💾 บันทึกการตั้งค่า (Presets)

+
+ + +
+
+ +
+
+ + +
+

🎨 Prompt เพิ่มเติม (ถ้ามี)

+ +
+ + +
+ +
+ + +
+
+

📜 ประวัติการสร้าง

+ +
+
+ ทั้งหมด: 0 +
+
+

ยังไม่มีประวัติ

+
+
+
+ + +
+
+
+

🧩 Grid Creator

+

รวมภาพหลายช่องในสไตล์คุณ

+
+ +
+
+
+

+ เพิ่มรูปภาพ

+ ลาก หรือ คลิก +
+ +
+
+

0 ภาพ

+
+ +
+

Layout

+
+
+ + +
+

🔢 Fake Number Overlay

+
+ + +
+
+ +
+
+ +
+
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/sidepanel/panel.js b/sidepanel/panel.js new file mode 100644 index 0000000..df3784a --- /dev/null +++ b/sidepanel/panel.js @@ -0,0 +1,778 @@ +// State management +const state = { + selectedStyle: 'breaking-news', + selectedRatio: '16:9', + selectedEmotion: 'serious', // happy, serious, sad, angry, surprised, confident + includeText: true, + headlineText: '', + fontStyles: ['thai-news', 'bold'], // Default multiple + textPosition: 'center', // top, center, bottom, etc. + customPrompt: '', + selectedImages: [], // Array of { id, file, base64 } + generatedImageUrl: null, + generationHistory: [], // Array to store generation logs with timestamps + presets: [], // Saved presets + autoDownload: false // Auto download setting +}; + +// Style templates +const styleTemplates = { + 'breaking-news': { + name: 'ข่าวด่วน', + prompt: 'Breaking news cover design, urgent dramatic style, bold red theme, high impact typography, professional news broadcast aesthetic, dramatic lighting, sense of urgency' + }, + 'political': { + name: 'ข่าวการเมือง', + prompt: 'Political news cover, professional authoritative design, blue and white color scheme, formal typography, government/political aesthetic, serious tone, clean layout' + }, + 'movie-poster': { + name: 'โปสเตอร์ภาพยนต์', + prompt: 'Cinematic movie poster style, dramatic composition, theatrical lighting, bold title treatment, Hollywood blockbuster aesthetic, epic scale, professional film poster design' + }, + 'drama': { + name: 'ดราม่า', + prompt: 'Drama series poster, emotional atmosphere, rich warm colors, character-focused composition, TV drama aesthetic, compelling visual storytelling, dramatic mood' + }, + 'action': { + name: 'แอคชั่น', + prompt: 'Action movie poster, dynamic explosive composition, high energy, intense colors, motion and movement, adrenaline-pumping design, bold graphics, powerful visual impact' + }, + 'original-image': { + name: 'ภาพต้นแบบเหมือนเดิม', + prompt: 'Use the original uploaded image exactly as provided, preserve all original details, colors, composition, and quality. Do not modify, enhance, or alter the image in any way. Keep the image completely unchanged from the original' + } +}; + +// DOM Elements +const elements = { + styleCards: document.querySelectorAll('.style-card'), + ratioButtons: document.querySelectorAll('.ratio-btn'), + emotionButtons: document.querySelectorAll('.emotion-btn'), + includeTextCheckbox: document.getElementById('includeText'), + textOptions: document.getElementById('textOptions'), + headlineInput: document.getElementById('headlineText'), + positionButtons: document.querySelectorAll('.position-btn'), + fontStyleButtons: document.querySelectorAll('.font-style-btn'), + customPromptInput: document.getElementById('customPrompt'), + generateBtn: document.getElementById('generateBtn'), + statusMessage: document.getElementById('statusMessage'), + historyList: document.getElementById('historyList'), + historyCount: document.getElementById('historyCount'), + exportHistoryBtn: document.getElementById('exportHistoryBtn'), + presetNameInput: document.getElementById('presetName'), + savePresetBtn: document.getElementById('savePresetBtn'), + presetList: document.getElementById('presetList'), + // New Image Handling Elements + dropZone: document.getElementById('dropZone'), + imageInput: document.getElementById('imageInput'), + imagePreviewGrid: document.getElementById('imagePreviewGrid'), + uploadPlaceholder: document.getElementById('uploadPlaceholder'), + autoDownloadToggle: document.getElementById('autoDownloadToggle') +}; + +// Initialize +function init() { + setupEventListeners(); + loadSavedState(); + updateUI(); +} + +// Event Listeners +function setupEventListeners() { + // Style selection + elements.styleCards.forEach(card => { + card.addEventListener('click', () => { + const style = card.dataset.style; + selectStyle(style); + }); + }); + + // Ratio selection + elements.ratioButtons.forEach(btn => { + btn.addEventListener('click', () => { + const ratio = btn.dataset.ratio; + selectRatio(ratio); + }); + }); + + // Emotion selection + elements.emotionButtons.forEach(btn => { + btn.addEventListener('click', () => { + const emotion = btn.dataset.emotion; + selectEmotion(emotion); + }); + }); + + // Text options + elements.includeTextCheckbox.addEventListener('change', (e) => { + state.includeText = e.target.checked; + elements.textOptions.style.display = state.includeText ? 'block' : 'none'; + saveState(); + }); + + elements.headlineInput.addEventListener('input', (e) => { + state.headlineText = e.target.value; + saveState(); + }); + + // Position selection + elements.positionButtons.forEach(btn => { + btn.addEventListener('click', () => { + const position = btn.dataset.position; + selectPosition(position); + }); + }); + + // Font style selection (Multi-select) + elements.fontStyleButtons.forEach(btn => { + btn.addEventListener('click', () => { + const style = btn.dataset.fontStyle; + const index = state.fontStyles.indexOf(style); + + if (index === -1) { + state.fontStyles.push(style); + } else { + state.fontStyles.splice(index, 1); + } + updateUI(); + saveState(); + }); + }); + + // Custom prompt + elements.customPromptInput.addEventListener('input', (e) => { + state.customPrompt = e.target.value; + saveState(); + }); + + // Preset management + elements.savePresetBtn.addEventListener('click', savePreset); + + elements.generateBtn.addEventListener('click', generateCover); + elements.exportHistoryBtn.addEventListener('click', exportHistory); + + // Image Upload Handlers + elements.dropZone.addEventListener('click', () => { + elements.imageInput.click(); + }); + + elements.dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + elements.dropZone.classList.add('drag-over'); + }); + + elements.dropZone.addEventListener('dragleave', () => { + elements.dropZone.classList.remove('drag-over'); + }); + + elements.dropZone.addEventListener('drop', handleDrop); + elements.imageInput.addEventListener('change', handleImageSelect); + + // Auto Download Toggle + if (elements.autoDownloadToggle) { + elements.autoDownloadToggle.addEventListener('change', (e) => { + state.autoDownload = e.target.checked; + + // Save specific setting for content script to digest easily + chrome.storage.local.set({ autoDownload: state.autoDownload }); + + // Also save full state + saveState(); + + showStatus(state.autoDownload ? 'เปิดใช้งาน Auto Download' : 'ปิดใช้งาน Auto Download', 'info'); + }); + } + + // --- Tab Switching Logic (Centralized) --- + const tabBtns = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + // Remove active class from all + tabBtns.forEach(b => b.classList.remove('active')); + tabContents.forEach(c => c.classList.remove('active')); + + // Add active class to clicked button and target content + btn.classList.add('active'); + const targetId = `tab-${btn.dataset.tab}`; + const targetContent = document.getElementById(targetId); + if (targetContent) { + targetContent.classList.add('active'); + } + }); + }); +} + +// Selection handlers +function selectStyle(style) { + state.selectedStyle = style; + elements.styleCards.forEach(card => { + card.classList.toggle('active', card.dataset.style === style); + }); + saveState(); +} + +function selectRatio(ratio) { + state.selectedRatio = ratio; + elements.ratioButtons.forEach(btn => { + btn.classList.toggle('active', btn.dataset.ratio === ratio); + }); + saveState(); +} + +function selectEmotion(emotion) { + state.selectedEmotion = emotion; + elements.emotionButtons.forEach(btn => { + btn.classList.toggle('active', btn.dataset.emotion === emotion); + }); + saveState(); +} + +// Helper to select position +function selectPosition(position) { + state.textPosition = position; + elements.positionButtons.forEach(btn => { + if (btn.dataset.position === position) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + saveState(); +} + +// Prompt generation (Structured JSON) +function generatePrompt() { + // Base style template + const styleTemplate = styleTemplates[state.selectedStyle]; + + // Map emotions to detailed descriptions + const emotionMap = { + 'none': null, + 'happy': 'Happy, cheerful, smiling expressions, conveying positivity', + 'serious': 'Serious, professional, focused expressions, conveying authority', + 'sad': 'Sad, melancholic, emotional expressions, conveying deep feeling', + 'angry': 'Angry, intense, fierce expressions, conveying power and aggression', + 'surprised': 'Surprised, shocked, amazed expressions, conveying sudden impact', + 'confident': 'Confident, powerful, strong expressions, conveying leadership' + }; + + // Map text positions to detailed specs + const positionMap = { + 'top': { region: 'Top', description: 'Placed prominently at the top of the layout' }, + 'center': { region: 'Center', description: 'Placed prominently in the center, potentially overlaying the subject with blending' }, + 'bottom': { region: 'Bottom', description: 'Placed at the bottom, anchoring the layout' }, + 'top-left': { region: 'Top-Left', description: 'Aligned to the top-left corner' }, + 'top-right': { region: 'Top-Right', description: 'Aligned to the top-right corner' }, + 'center-left': { region: 'Center-Left', description: 'Aligned to the middle-left side' }, + 'center-right': { region: 'Center-Right', description: 'Aligned to the middle-right side' }, + 'bottom-left': { region: 'Bottom-Left', description: 'Aligned to the bottom-left corner' }, + 'bottom-right': { region: 'Bottom-Right', description: 'Aligned to the bottom-right corner' } + }; + + // Map font styles + const fontStyleMap = { + '3d': '3D effect typography with depth and shadows', + 'thai-news-loop': 'Traditional Thai formal serif font with loops (Thai Chatuchak, TH Sarabun, or similar government/formal style)', + 'bold': 'Extra Bold, heavy weight for maximum impact', + 'thin': 'Thin, minimalist, elegant line width', + 'italic': 'Italicized, slanted for dynamic movement', + 'thai-news': 'Standard Modern Thai News font (sans-serif or slab-serif)', + 'serif': 'Classic Serif font, elegant and readable', + 'sans-serif': 'Modern Sans-serif font, clean and geometric' + }; + + // Build the JSON object + const promptJson = { + action: "generate_cover_image", + context: "Professional magazine/news cover generation", + visual_style: { + style_name: styleTemplate.name, + artistic_description: styleTemplate.prompt, + quality_standards: ["High Resolution", "Professional Photography", "Magazine Standard", "Visually Stunning"] + }, + composition: { + aspect_ratio: state.selectedRatio, + framing: "Optimized for cover layout, leaving space for text where specified" + }, + subject_elements: { + emotion_mood: emotionMap[state.selectedEmotion] || "Neutral or context-appropriate", + focus: "Clear subject focus with professional lighting" + }, + text_overlay_specification: { + enabled: state.includeText && !!state.headlineText.trim(), + content: state.headlineText ? state.headlineText.trim() : null, + placement: state.includeText ? positionMap[state.textPosition] : null, + orientation: "Strictly Horizontal (0 degrees). Text must be perfectly straight, no tilting, no perspective slant.", + typography: { + font_styles: state.fontStyles.map(s => fontStyleMap[s]).filter(Boolean), + readability: "Must be highly legible against the background", + integration: "Text should be seamlessly integrated but MUST remain perfectly horizontal." + } + }, + additional_instructions: state.customPrompt.trim() || null + }; + + // Wrap in strict instructions + const instruction = ` +You are an expert AI Art Director and Image Generator. Your task is to generate a cover image based EXACTLY on the following JSON specification. + +CRITICAL INSTRUCTIONS: +1. Analyze the JSON object below. +2. Generate an image that visually represents all parameters in 'visual_style', 'composition', and 'subject_elements'. +3. IF 'text_overlay_specification.enabled' is true, you MUST attempt to render the text provided in 'content' clearly and legibly at the specified 'placement'. +4. STRICTLY follow the 'typography.font_styles'. If 'Traditional Thai formal serif font with loops' is requested, ensure the Thai characters have the correct terminal loops and traditional structure. +5. The 'additional_instructions' field (if present) overrides standard style settings if there is a conflict. + +JSON SPECIFICATION: +\`\`\`json +${JSON.stringify(promptJson, null, 2)} +\`\`\` +`.trim(); + + return instruction; +} + +// Image Handling Functions +async function handleImageSelect(e) { + const files = Array.from(e.target.files); + await processFiles(files); + // Reset input + e.target.value = ''; +} + +async function handleDrop(e) { + e.preventDefault(); + elements.dropZone.classList.remove('drag-over'); + const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/')); + await processFiles(files); +} + +async function processFiles(files) { + const remainingSlots = 10 - state.selectedImages.length; + if (remainingSlots <= 0) { + showStatus('ครบ 10 รูปแล้ว ไม่สามารถเพิ่มได้อีก', 'error'); + return; + } + + const filesToProcess = files.slice(0, remainingSlots); + + if (files.length > remainingSlots) { + showStatus(`เพิ่มได้อีกเพียง ${remainingSlots} รูป`, 'info'); + } + + for (const file of filesToProcess) { + try { + const base64 = await readFileAsBase64(file); + state.selectedImages.push({ + id: Date.now() + Math.random(), + file: file, + base64: base64 + }); + } catch (error) { + console.error('Error reading file:', error); + } + } + + updateImageUI(); +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function removeImage(id) { + state.selectedImages = state.selectedImages.filter(img => img.id !== id); + updateImageUI(); +} + +function updateImageUI() { + const hasImages = state.selectedImages.length > 0; + + elements.uploadPlaceholder.style.display = hasImages ? 'none' : 'block'; + elements.imagePreviewGrid.style.display = hasImages ? 'grid' : 'none'; + + elements.imagePreviewGrid.innerHTML = state.selectedImages.map(img => ` +
+ Preview + +
+ `).join(''); + + // Re-attach event listeners for remove buttons (since inline onclick with CustomEvent is tricky in extensions) + // Better way: + const removeBtns = elements.imagePreviewGrid.querySelectorAll('.remove-btn'); + removeBtns.forEach((btn, index) => { + btn.onclick = (e) => { + e.stopPropagation(); // Prevent triggering dropZone click + removeImage(state.selectedImages[index].id); + }; + }); +} + +// Generate cover +async function generateCover() { + + const prompt = generatePrompt(); + + // Log generation attempt + logGeneration(prompt); + + // Show loading + elements.generateBtn.classList.add('loading'); + elements.generateBtn.disabled = true; + showStatus('กำลังสร้างหน้าปก...', 'info'); + + // Send to content script + try { + const response = await chrome.runtime.sendMessage({ + action: 'generateCover', + prompt: prompt, + ratio: state.selectedRatio, + images: state.selectedImages.map(img => img.base64) // Send array of base64 images + }); + + if (response.success) { + showStatus('กำลังรอ Gemini สร้างรูปภาพ...', 'info'); + } else { + throw new Error(response.error || 'Failed to send to Gemini'); + } + } catch (error) { + console.error('Error:', error); + showStatus('เกิดข้อผิดพลาด: ' + error.message, 'error'); + elements.generateBtn.classList.remove('loading'); + elements.generateBtn.disabled = false; + } +} + +// Download cover +function downloadCover() { + if (!state.generatedImageUrl) { + showStatus('ยังไม่มีรูปภาพให้ดาวน์โหลด', 'error'); + return; + } + + const timestamp = new Date().getTime(); + const filename = `cover_${state.selectedStyle}_${state.selectedRatio.replace(':', 'x')}_${timestamp}.png`; + + chrome.downloads.download({ + url: state.generatedImageUrl, + filename: filename, + saveAs: false + }, (downloadId) => { + if (downloadId) { + showStatus('ดาวน์โหลดสำเร็จ!', 'success'); + } else { + showStatus('เกิดข้อผิดพลาดในการดาวน์โหลด', 'error'); + } + }); +} + +// Log generation with timestamp +function logGeneration(prompt) { + const now = new Date(); + const logEntry = { + timestamp: now.toISOString(), + date: now.toLocaleDateString('th-TH', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }), + settings: { + style: state.selectedStyle, + ratio: state.selectedRatio, + emotion: state.selectedEmotion, + fontStyles: state.fontStyles, + textPosition: state.textPosition, + headline: state.headlineText + }, + prompt: prompt + }; + + // Add to history (keep last 50 entries) + state.generationHistory.unshift(logEntry); + if (state.generationHistory.length > 50) { + state.generationHistory = state.generationHistory.slice(0, 50); + } + + saveState(); + console.log('Generation logged:', logEntry); + + // Update history display + renderHistory(); +} + +// Presets Management +function savePreset() { + const name = elements.presetNameInput.value.trim(); + if (!name) { + showStatus('กรุณาตั้งชื่อ Preset', 'error'); + return; + } + + const newPreset = { + id: Date.now(), + name: name, + settings: { + selectedStyle: state.selectedStyle, + selectedRatio: state.selectedRatio, + selectedEmotion: state.selectedEmotion, + includeText: state.includeText, + headlineText: state.headlineText, + fontStyles: [...state.fontStyles], + textPosition: state.textPosition, + customPrompt: state.customPrompt + } + }; + + state.presets = state.presets || []; + state.presets.push(newPreset); + saveState(); + + elements.presetNameInput.value = ''; + renderPresets(); + showStatus('บันทึก Preset เรียบร้อย', 'success'); +} + +function loadPreset(id) { + const preset = state.presets.find(p => p.id === id); + if (preset) { + Object.assign(state, preset.settings); + updateUI(); + saveState(); + showStatus(`โหลด Preset "${preset.name}" เรียบร้อย`, 'success'); + } +} + +function deletePreset(id) { + state.presets = state.presets.filter(p => p.id !== id); + saveState(); + renderPresets(); + showStatus('ลบ Preset เรียบร้อย', 'info'); +} + +function renderPresets() { + const presets = state.presets || []; + + if (presets.length === 0) { + elements.presetList.innerHTML = '

ยังไม่มี Preset ที่บันทึกไว้

'; + return; + } + + elements.presetList.innerHTML = ''; + presets.forEach(preset => { + const item = document.createElement('div'); + item.className = 'preset-item'; + + const nameSpan = document.createElement('span'); + nameSpan.textContent = preset.name; + nameSpan.className = 'preset-name'; + nameSpan.addEventListener('click', () => loadPreset(preset.id)); + + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = '🗑️'; + deleteBtn.className = 'preset-delete-btn'; + deleteBtn.title = 'ลบ Preset'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + deletePreset(preset.id); + }); + + item.appendChild(nameSpan); + item.appendChild(deleteBtn); + elements.presetList.appendChild(item); + }); +} + +// Message listener for content script responses +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Check if AI Cover tab is active + const aiCoverTab = document.getElementById('tab-ai-cover'); + if (!aiCoverTab || !aiCoverTab.classList.contains('active')) { + return; // Ignore messages if not on AI Cover tab + } + + if (message.action === 'imageGenerated') { + state.generatedImageUrl = message.imageUrl; + + // Update last log entry with success status + if (state.generationHistory.length > 0) { + state.generationHistory[0].success = true; + state.generationHistory[0].imageUrl = message.imageUrl; + saveState(); + renderHistory(); + } + + elements.generateBtn.classList.remove('loading'); + elements.generateBtn.disabled = false; + showStatus('สร้างหน้าปกสำเร็จ!', 'success'); + } else if (message.action === 'generationError') { + // Update last log entry with error status + if (state.generationHistory.length > 0) { + state.generationHistory[0].success = false; + state.generationHistory[0].error = message.error; + saveState(); + renderHistory(); + } + + elements.generateBtn.classList.remove('loading'); + elements.generateBtn.disabled = false; + showStatus('เกิดข้อผิดพลาด: ' + message.error, 'error'); + } +}); + +// Status message +function showStatus(message, type = 'info') { + elements.statusMessage.textContent = message; + elements.statusMessage.className = `status-message ${type}`; + elements.statusMessage.style.display = 'block'; + + setTimeout(() => { + elements.statusMessage.style.display = 'none'; + }, 5000); +} + +// State persistence +function saveState() { + chrome.storage.local.set({ coverGeneratorState: state }); +} + +function loadSavedState() { + chrome.storage.local.get('coverGeneratorState', (result) => { + if (result.coverGeneratorState) { + // Ensure presets array exists + if (!result.coverGeneratorState.presets) { + result.coverGeneratorState.presets = []; + } + Object.assign(state, result.coverGeneratorState); + updateUI(); + renderPresets(); // Render presets after loading + } + + // Check independent key as fallback or source of truth + chrome.storage.local.get('autoDownload', (res) => { + if (res.autoDownload !== undefined) { + state.autoDownload = res.autoDownload; + updateUI(); + } + }); + }); +} + +function updateUI() { + // Set active style + selectStyle(state.selectedStyle); + + // Set active ratio + selectRatio(state.selectedRatio); + + // Set active emotion + selectEmotion(state.selectedEmotion); + + // Set active position + selectPosition(state.textPosition); + + // Set text options + elements.includeTextCheckbox.checked = state.includeText; + elements.textOptions.style.display = state.includeText ? 'block' : 'none'; + elements.headlineInput.value = state.headlineText; + + // Set font styles (Multi-select) + elements.fontStyleButtons.forEach(btn => { + const style = btn.dataset.fontStyle; + if (state.fontStyles.includes(style)) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Set custom prompt + elements.customPromptInput.value = state.customPrompt; + + // Render history + renderHistory(); + + // Update image preview grid + updateImageUI(); + + // Set Auto Download Toggle + if (elements.autoDownloadToggle) { + elements.autoDownloadToggle.checked = state.autoDownload; + } +} + +// Render history list +function renderHistory() { + const history = state.generationHistory || []; + + // Update stats + // Update stats + const successCount = history.filter(h => h.success === true).length; + elements.historyCount.textContent = `ทั้งหมด: ${history.length}`; + + // Render list + if (history.length === 0) { + elements.historyList.innerHTML = '

ยังไม่มีประวัติ

'; + return; + } + + elements.historyList.innerHTML = history.map(entry => { + const statusClass = entry.success === true ? 'success' : entry.success === false ? 'error' : ''; + const statusIcon = entry.success === true ? '✅' : entry.success === false ? '❌' : '⏳'; + + return ` +
+
${statusIcon} ${entry.date}
+
${entry.settings.headline || '(ไม่มีข้อความ)'}
+
+ 📐 ${entry.settings.ratio} + 🎭 ${entry.settings.style} + 😊 ${entry.settings.emotion} + 🔤 ${Array.isArray(entry.settings.fontStyles) ? entry.settings.fontStyles.join(', ') : entry.settings.fontStyle} +
+
+ `; + }).join(''); +} + +// Export history to JSON file +function exportHistory() { + const history = state.generationHistory || []; + + if (history.length === 0) { + showStatus('ไม่มีประวัติให้ส่งออก', 'error'); + return; + } + + const dataStr = JSON.stringify(history, null, 2); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const now = new Date(); + const filename = `cover-generation-log-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}.json`; + + chrome.downloads.download({ + url: url, + filename: filename, + saveAs: true + }, (downloadId) => { + if (downloadId) { + showStatus('ส่งออก Log สำเร็จ!', 'success'); + } else { + showStatus('เกิดข้อผิดพลาดในการส่งออก', 'error'); + } + URL.revokeObjectURL(url); + }); +} + +// Initialize on load +init(); diff --git a/sidepanel/warning.html b/sidepanel/warning.html new file mode 100644 index 0000000..51cba23 --- /dev/null +++ b/sidepanel/warning.html @@ -0,0 +1,64 @@ + + + + + + + Warning + + + + +
⚠️
+

ใช้งานไม่ได้ในหน้านี้

+

Extension นี้ใช้งานได้เฉพาะบน
gemini.google.com เท่านั้น

+ ไปที่ Gemini + + + \ No newline at end of file diff --git a/verification.txt b/verification.txt new file mode 100644 index 0000000..17011af --- /dev/null +++ b/verification.txt @@ -0,0 +1,9 @@ +# Verification Check +1. Go to chrome://extensions +2. Find "Auto Cover Generator" +3. Click "Reload" (circular arrow) +4. Open the extension side panel +5. Click "Grid Creator" tab +6. Verify the view switches to the grid creator UI +7. Drag/Drop 2 images +8. Basic grid should generate