feat: add extension implementation and docs
Add manifest.json, sidepanel components, and scripts. Include project assets and documentation files. Remove placeholder blank file.
This commit is contained in:
117
scripts/background.js
Normal file
117
scripts/background.js
Normal file
@@ -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');
|
||||
662
scripts/content.js
Normal file
662
scripts/content.js
Normal file
@@ -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)');
|
||||
}
|
||||
112
scripts/grid_generator.js
Normal file
112
scripts/grid_generator.js
Normal file
@@ -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<HTMLImageElement>} 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;
|
||||
}
|
||||
}
|
||||
144
scripts/grid_templates.js
Normal file
144
scripts/grid_templates.js
Normal file
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user