Files
gemini-extension/sidepanel/panel.js
Kunthawat Greethong f490c63632 feat: add extension implementation and docs
Add manifest.json, sidepanel components, and scripts.
Include project assets and documentation files.
Remove placeholder blank file.
2026-01-06 08:49:28 +07:00

779 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 => `
<div class="image-preview-item">
<img src="${img.base64}" alt="Preview">
<button class="remove-btn">×</button>
</div>
`).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 = '<p style="text-align: center; color: var(--text-secondary); padding: 10px;">ยังไม่มี Preset ที่บันทึกไว้</p>';
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 = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">ยังไม่มีประวัติ</p>';
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 `
<div class="history-item ${statusClass}">
<div class="history-date">${statusIcon} ${entry.date}</div>
<div class="history-headline">${entry.settings.headline || '(ไม่มีข้อความ)'}</div>
<div class="history-settings">
<span>📐 ${entry.settings.ratio}</span>
<span>🎭 ${entry.settings.style}</span>
<span>😊 ${entry.settings.emotion}</span>
<span>🔤 ${Array.isArray(entry.settings.fontStyles) ? entry.settings.fontStyles.join(', ') : entry.settings.fontStyle}</span>
</div>
</div>
`;
}).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();