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:
Kunthawat Greethong
2026-01-06 08:49:28 +07:00
parent 87dd2931fa
commit f490c63632
23 changed files with 4586 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

71
CHANGELOG.md Normal file
View File

@@ -0,0 +1,71 @@
# Update Log - Text Position & Font Type Features
## เวอร์ชัน 1.2.0 (2025-12-28)
### ✨ ฟีเจอร์ใหม่
#### 1. ตำแหน่งข้อความ (Text Position)
เลือกได้ 3 ตำแหน่ง:
- ⬆️ **บน** - วางข้อความด้านบนของปก
- ↔️ **กลาง** - วางข้อความตรงกลางปก (ค่าเริ่มต้น)
- ⬇️ **ล่าง** - วางข้อความด้านล่างของปก
#### 2. ประเภทฟอนต์ (Font Type)
เลือกได้ 4 แบบ:
- **ไม่มีหัว** (Sans-serif) - ฟอนต์สะอาด ทันสมัย (ค่าเริ่มต้น)
- **มีหัว** (Serif) - ฟอนต์มีหัวตกแต่ง คลาสสิก
- **ตรง** (Normal) - ฟอนต์ตั้งตรง
- **เฉียง** (Italic) - ฟอนต์เอียง
### 🔧 การทำงาน
**JSON Structure:**
```json
{
"text": {
"position": "center",
"fontType": "sans-serif",
...
}
}
```
**Prompt Generation:**
```
Include prominent headline text: "ข่าวด่วน!",
centered in the middle of the cover,
using sans-serif font (clean, modern, without serifs)
in bold, thick, impactful typography...
```
### 📊 ตัวอย่างการใช้งาน
**ข่าวด่วน - ข้อความบน:**
- ตำแหน่ง: บน
- ฟอนต์: ไม่มีหัว (Sans-serif)
- สไตล์: ตัวหนา 3D
- → ข้อความจะอยู่ด้านบนของปก ใช้ฟอนต์สะอาดทันสมัย
**โปสเตอร์ภาพยนต์ - ข้อความล่าง:**
- ตำแหน่ง: ล่าง
- ฟอนต์: มีหัว (Serif) + เฉียง
- สไตล์: มีเส้นขอบ
- → ข้อความจะอยู่ด้านล่างของปก ใช้ฟอนต์คลาสสิกเอียง
### 📝 สรุปฟีเจอร์ทั้งหมด
Extension ตอนนี้มี:
1. ✅ อัปโหลดรูปภาพ
2. ✅ เลือกสีหน้า/อารมณ์ของภาพ (8 แบบ)
3. ✅ เลือกสไตล์ปก (5 แบบ)
4. ✅ เลือกขนาดปก (6 แบบ)
5. ✅ พิมพ์หัวข้อหลายบรรทัด
6. ✅ เลือกสไตล์ตัวหนังสือ (4 แบบ)
7.**เลือกตำแหน่งข้อความ (3 แบบ)** ⭐ NEW
8.**เลือกประเภทฟอนต์ (4 แบบ)** ⭐ NEW
9. ✅ กำหนดสีพื้นหลังและเส้นขอบ
10. ✅ Prompt เพิ่มเติม
11. ✅ กดส่งอัตโนมัติ
12. ✅ ดาวน์โหลดรูป
Reload extension แล้วลองใช้งานได้เลยครับ! 🎉

262
JSON_STRUCTURE.md Normal file
View File

@@ -0,0 +1,262 @@
# JSON Prompt Structure Documentation
## ภาพรวม
Extension ใช้ระบบ JSON structure เพื่อสร้าง prompt ที่มีความแม่นยำและควบคุมได้ดีขึ้น โดยจะแปลงข้อมูลจากผู้ใช้เป็น JSON ก่อน แล้วจึงแปลงเป็น text prompt เพื่อส่งไปยัง Gemini
## โครงสร้าง JSON
```json
{
"style": {
"type": "breaking-news",
"name": "ข่าวด่วน",
"description": "Breaking news cover design, urgent dramatic style, bold red theme..."
},
"aspectRatio": {
"ratio": "16:9",
"description": "16:9 aspect ratio"
},
"text": {
"enabled": true,
"content": "ข่าวด่วน! เหตุการณ์สำคัญ",
"style": "3d",
"backgroundColor": "#ff0000",
"borderColor": "#ffffff"
},
"image": {
"hasUploadedImage": true,
"integration": "Incorporate and integrate the uploaded image seamlessly..."
},
"customInstructions": "เพิ่มเอฟเฟกต์แสงสว่าง",
"quality": {
"level": "professional",
"requirements": [
"high resolution",
"magazine cover standard",
"visually stunning"
]
},
"metadata": {
"timestamp": "2025-12-28T06:51:56.000Z",
"version": "1.0"
}
}
```
## คำอธิบายแต่ละส่วน
### 1. style
- **type**: รหัสสไตล์ (breaking-news, political, movie-poster, drama, action)
- **name**: ชื่อสไตล์ภาษาไทย
- **description**: คำอธิบายสไตล์แบบละเอียด
### 2. aspectRatio
- **ratio**: อัตราส่วนที่เลือก (1:1, 3:4, 4:3, 2:3, 9:16, 16:9)
- **description**: คำอธิบายอัตราส่วน
### 3. text
- **enabled**: เปิด/ปิดการใส่ข้อความ
- **content**: ข้อความที่ต้องการแสดง
- **style**: สไตล์ตัวหนังสือ (normal, bold, 3d, outlined)
- **backgroundColor**: สีพื้นหลังข้อความ (hex color)
- **borderColor**: สีเส้นขอบ (hex color)
### 4. image
- **hasUploadedImage**: มีรูปภาพที่อัปโหลดหรือไม่
- **integration**: คำสั่งการผสานรูปภาพเข้ากับดีไซน์
### 5. customInstructions
- คำสั่งเพิ่มเติมจากผู้ใช้ (string หรือ null)
### 6. quality
- **level**: ระดับคุณภาพ
- **requirements**: ข้อกำหนดคุณภาพ (array)
### 7. metadata
- **timestamp**: เวลาที่สร้าง (ISO 8601)
- **version**: เวอร์ชันของ JSON structure
## กระบวนการทำงาน
### 1. สร้าง JSON Structure
```javascript
function generatePromptJSON() {
const promptData = {
style: { ... },
aspectRatio: { ... },
text: { ... },
// ...
};
return promptData;
}
```
### 2. แปลง JSON เป็น Text Prompt
```javascript
function jsonToPrompt(promptData) {
const parts = [];
// ประมวลผลแต่ละส่วนของ JSON
parts.push(promptData.style.description);
parts.push(`Create the image in ${promptData.aspectRatio.ratio} aspect ratio`);
// ... ประมวลผลต่อ
return parts.join('. ') + '.';
}
```
### 3. รวมทั้งสองฟังก์ชัน
```javascript
function generatePrompt() {
const jsonStructure = generatePromptJSON();
const textPrompt = jsonToPrompt(jsonStructure);
return {
json: jsonStructure,
text: textPrompt
};
}
```
## ตัวอย่างการใช้งาน
### ตัวอย่างที่ 1: ข่าวด่วนแบบมีข้อความ
**Input:**
- สไตล์: ข่าวด่วน
- ขนาด: 16:9
- ข้อความ: "ข่าวด่วน!"
- สไตล์ข้อความ: 3D
- สีพื้นหลัง: #ff0000
- สีขอบ: #ffffff
**JSON Output:**
```json
{
"style": {
"type": "breaking-news",
"name": "ข่าวด่วน",
"description": "Breaking news cover design, urgent dramatic style, bold red theme, high impact typography, professional news broadcast aesthetic, dramatic lighting, sense of urgency"
},
"aspectRatio": {
"ratio": "16:9",
"description": "16:9 aspect ratio"
},
"text": {
"enabled": true,
"content": "ข่าวด่วน!",
"style": "3d",
"backgroundColor": "#ff0000",
"borderColor": "#ffffff"
},
"image": {
"hasUploadedImage": true,
"integration": "Incorporate and integrate the uploaded image seamlessly into the cover design as the main visual element"
},
"customInstructions": null,
"quality": {
"level": "professional",
"requirements": ["high resolution", "magazine cover standard", "visually stunning"]
}
}
```
**Text Prompt Output:**
```
Breaking news cover design, urgent dramatic style, bold red theme, high impact typography, professional news broadcast aesthetic, dramatic lighting, sense of urgency. Create the image in 16:9 aspect ratio. Include prominent headline text: "ข่าวด่วน!" with 3D effect, depth, and dramatic shadows. Text background color: #ff0000. Text border/outline color: #ffffff. Incorporate and integrate the uploaded image seamlessly into the cover design as the main visual element. high resolution, magazine cover standard, visually stunning.
```
### ตัวอย่างที่ 2: โปสเตอร์ภาพยนต์ไม่มีข้อความ
**Input:**
- สไตล์: โปสเตอร์ภาพยนต์
- ขนาด: 2:3
- ไม่ใส่ข้อความ
**JSON Output:**
```json
{
"style": {
"type": "movie-poster",
"name": "โปสเตอร์ภาพยนต์",
"description": "Cinematic movie poster style, dramatic composition, theatrical lighting, bold title treatment, Hollywood blockbuster aesthetic, epic scale, professional film poster design"
},
"aspectRatio": {
"ratio": "2:3",
"description": "2:3 aspect ratio"
},
"text": {
"enabled": false,
"content": "",
"style": "bold",
"backgroundColor": "#ff0000",
"borderColor": "#ffffff"
},
"image": {
"hasUploadedImage": true,
"integration": "Incorporate and integrate the uploaded image seamlessly into the cover design as the main visual element"
},
"customInstructions": null,
"quality": {
"level": "professional",
"requirements": ["high resolution", "magazine cover standard", "visually stunning"]
}
}
```
**Text Prompt Output:**
```
Cinematic movie poster style, dramatic composition, theatrical lighting, bold title treatment, Hollywood blockbuster aesthetic, epic scale, professional film poster design. Create the image in 2:3 aspect ratio. No text or headlines in the image, focus on visual composition only. Incorporate and integrate the uploaded image seamlessly into the cover design as the main visual element. high resolution, magazine cover standard, visually stunning.
```
## ข้อดีของ JSON Structure
### 1. ความแม่นยำ
- ข้อมูลถูกจัดโครงสร้างอย่างชัดเจน
- ง่ายต่อการตรวจสอบและ debug
- ลดโอกาสเกิดข้อผิดพลาดในการสร้าง prompt
### 2. ความยืดหยุ่น
- สามารถเพิ่ม/ลดฟีลด์ได้ง่าย
- แยกส่วนการประมวลผลได้ชัดเจน
- รองรับการขยายฟีเจอร์ในอนาคต
### 3. การ Debug
- Log JSON structure เพื่อดูข้อมูลที่ส่ง
- เปรียบเทียบ JSON กับ text prompt ได้ง่าย
- ตรวจสอบค่าแต่ละฟีลด์ได้ทันที
### 4. การประมวลผลต่อ
- สามารถนำ JSON ไปประมวลผลเพิ่มเติมได้
- อาจใช้ AI อื่นๆ ประมวลผล JSON ก่อนส่งไปยัง Gemini
- เก็บ history ของ prompt ได้ง่าย
## การดู JSON ใน Console
เมื่อใช้งาน Extension สามารถเปิด DevTools และดู Console เพื่อดู JSON structure:
```javascript
// ใน panel.js
console.log('Prompt JSON Structure:', JSON.stringify(jsonStructure, null, 2));
console.log('Generated Prompt:', textPrompt);
// ใน content.js
console.log('Received Prompt JSON:', JSON.stringify(currentPromptJSON, null, 2));
console.log('Converted to Text:', currentPromptText);
```
## การขยายฟีเจอร์ในอนาคต
ด้วย JSON structure นี้ สามารถเพิ่มฟีเจอร์ได้ง่าย เช่น:
1. **AI Pre-processing**: ใช้ AI วิเคราะห์ JSON ก่อนสร้าง prompt
2. **Template System**: บันทึก JSON เป็น template
3. **Batch Generation**: สร้างหลายๆ รูปจาก JSON array
4. **A/B Testing**: ทดสอบ prompt หลายๆ แบบ
5. **Analytics**: วิเคราะห์ว่า JSON แบบไหนให้ผลลัพธ์ดีที่สุด
## สรุป
การใช้ JSON structure ทำให้ระบบมีความแม่นยำและควบคุมได้ดีขึ้น เหมาะสำหรับการพัฒนาต่อยอดและการ debug ในอนาคต

152
README.md Normal file
View File

@@ -0,0 +1,152 @@
# Auto Cover Generator - Chrome Extension
## 🎨 คำอธิบาย
Extension สำหรับ Google Chrome ที่ช่วยสร้างหน้าปกข่าว/Youtube แบบอัตโนมัติด้วย Gemini AI โดยไม่ต้องใช้ API Key
## ✨ ฟีเจอร์
- 📱 **Side Panel** - แสดงผลด้านขวาของหน้า Gemini
- 🎭 **เลือกสไตล์ปก** - ข่าวด่วน, ข่าวการเมือง, โปสเตอร์ภาพยนต์, ดราม่า, แอคชั่น
- 📐 **เลือกขนาด** - 1:1, 3:4, 4:3, 2:3, 9:16, 16:9
- ✍️ **ปรับแต่งตัวหนังสือ** - 3D, ตัวหนา, มีเส้นขอบ
- 🎨 **กำหนดสี** - สีพื้นหลังข้อความ, สีเส้นขอบ
- 🖼️ **อัปโหลดรูปภาพ** - Drag & Drop หรือเลือกไฟล์
- 🤖 **สร้าง Prompt อัตโนมัติ** - ระบบสร้าง prompt ที่เหมาะสมตามการตั้งค่า
- 💾 **ดาวน์โหลด** - บันทึกรูปที่สร้างเสร็จลง Downloads
## 📦 การติดตั้ง
### วิธีที่ 1: โหลด Extension แบบ Unpacked (สำหรับพัฒนา/ทดสอบ)
1. เปิด Chrome แล้วไปที่ `chrome://extensions/`
2. เปิด **Developer mode** ที่มุมขวาบน
3. คลิก **Load unpacked**
4. เลือกโฟลเดอร์ `auto-cover-extension`
5. Extension จะถูกติดตั้งและพร้อมใช้งาน
### วิธีที่ 2: สร้าง Icons (ถ้าต้องการ)
1. เปิดไฟล์ `icons/create-icons.html` ในเบราว์เซอร์
2. รอให้ไฟล์ icon ดาวน์โหลดอัตโนมัติ (icon16.png, icon48.png, icon128.png)
3. ย้ายไฟล์ไปไว้ในโฟลเดอร์ `icons/`
หรือใช้ไอคอนชั่วคราวที่มีอยู่แล้ว
## 🚀 วิธีใช้งาน
1. **เปิดหน้า Gemini**
- ไปที่ https://gemini.google.com
- Extension จะเปิด Side Panel อัตโนมัติ
2. **อัปโหลดรูปภาพ**
- ลากไฟล์มาวางในพื้นที่อัปโหลด
- หรือคลิกเพื่อเลือกไฟล์
3. **เลือกสไตล์และขนาด**
- คลิกเลือกสไตล์ที่ต้องการ (ข่าวด่วน, การเมือง, ฯลฯ)
- เลือกขนาดหน้าปก (16:9, 1:1, ฯลฯ)
4. **ปรับแต่งข้อความ** (ถ้าต้องการ)
- พิมพ์หัวข้อข่าว
- เลือกสไตล์ตัวหนังสือ (ปกติ, ตัวหนา, 3D, มีเส้นขอบ)
- กำหนดสีพื้นหลังและสีเส้นขอบ
- หรือติ๊กเอาออกถ้าไม่ต้องการข้อความ
5. **เพิ่ม Prompt เพิ่มเติม** (ถ้ามี)
- พิมพ์รายละเอียดเพิ่มเติมที่ต้องการ
6. **สร้างหน้าปก**
- คลิกปุ่ม **"สร้างหน้าปก"**
- ระบบจะส่ง prompt ไปยัง Gemini อัตโนมัติ
- รอ Gemini สร้างรูปภาพ
7. **ดาวน์โหลด**
- เมื่อรูปสร้างเสร็จ คลิกปุ่ม **"ดาวน์โหลด"**
- รูปจะถูกบันทึกลงโฟลเดอร์ Downloads
## 🎯 ตัวอย่างสไตล์
### ข่าวด่วน (Breaking News)
- ธีมสีแดงเร่งด่วน
- ตัวหนังสือโดดเด่น
- บรรยากาศเร้าใจ
### ข่าวการเมือง (Political)
- ธีมสีน้ำเงินเป็นทางการ
- ดีไซน์มืออาชีพ
- บรรยากาศจริงจัง
### โปสเตอร์ภาพยนต์ (Movie Poster)
- สไตล์ซีเนมาติก
- แสงเงาดราม่า
- ดีไซน์แบบ Hollywood
### ดราม่า (Drama)
- บรรยากาศอารมณ์
- สีโทนอบอุ่น
- เน้นตัวละคร
### แอคชั่น (Action)
- องค์ประกอบระเบิด
- พลังงานสูง
- สีสันเข้มข้น
## 🛠️ โครงสร้างโปรเจค
```
auto-cover-extension/
├── manifest.json # Extension configuration
├── icons/ # Extension icons
│ ├── icon16.png
│ ├── icon48.png
│ ├── icon128.png
│ └── create-icons.html
├── sidepanel/ # Side panel UI
│ ├── panel.html
│ ├── panel.css
│ └── panel.js
└── scripts/ # Background & content scripts
├── background.js
└── content.js
```
## ⚙️ เทคโนโลยี
- **Manifest V3** - Chrome Extension API ล่าสุด
- **Side Panel API** - แสดงผล UI ด้านข้าง
- **Content Scripts** - โต้ตอบกับหน้า Gemini
- **Chrome Storage API** - บันทึกการตั้งค่า
- **Chrome Downloads API** - ดาวน์โหลดรูปภาพ
## 📝 หมายเหตุ
- Extension ต้องใช้งานบนหน้า https://gemini.google.com เท่านั้น
- ต้องเข้าสู่ระบบ Google Account ที่มีสิทธิ์ใช้ Gemini
- การสร้างรูปภาพขึ้นอยู่กับความสามารถของ Gemini
- DOM selectors อาจต้องปรับปรุงถ้า Gemini เปลี่ยนโครงสร้างหน้าเว็บ
## 🐛 การแก้ไขปัญหา
### Extension ไม่แสดง Side Panel
- ตรวจสอบว่าอยู่ในหน้า gemini.google.com
- ลองคลิกที่ไอคอน Extension
- Reload หน้าเว็บ
### ไม่สามารถส่ง Prompt ได้
- ตรวจสอบว่า Gemini โหลดเสร็จแล้ว
- ลองรีเฟรชหน้าเว็บ
- เช็ค Console สำหรับ error messages
### รูปภาพไม่ถูกดาวน์โหลด
- ตรวจสอบว่า Gemini สร้างรูปเสร็จแล้ว
- ตรวจสอบ permissions ของ Chrome
- ลองคลิกขวาที่รูปแล้ว Save Image As
## 📄 License
MIT License - ใช้งานได้อย่างอิสระ
## 👨‍💻 ผู้พัฒนา
สร้างด้วย ❤️ โดยใช้ Gemini AI

21
TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,21 @@
# วิธีแก้ปัญหา "กดปุ่มแล้วเงียบ" (v1.3.0)
## สาเหตุ
Gemini มีระบบป้องกันบอท หรือมีการอัปเดตช่องกรอกข้อความใหม่ ทำให้การ "ยัดเยียด" ข้อความแบบเดิม (Paste) ไม่ทำงาน เพราะระบบมองไม่เห็นว่ามีการพิมพ์จริงๆ ปุ่มส่งเลยไม่ทำงาน (เป็นสีเทา)
## การแก้ไข (v1.3.0)
เราเปลี่ยนวิธีการพิมพ์ข้อความใหม่:
1. ⌨️ **Simulate Typing**: ใช้คำสั่ง `execCommand('insertText')` ซึ่งเหมือนกับเวลาคนพิมพ์จริงๆ มากที่สุด
2. 🔄 **Auto-Retry**: ถ้าระบบเห็นว่าปุ่มส่งยัง "ปิด/เทา" อยู่ จะพยายามเคาะ spacebar 1 ทีเพื่อปลุกให้ตื่น
3. 🖱️ **Focus**: บังคับให้ Cursor ไปกระพริบในช่องก่อนพิมพ์เสมอ
## วิธีทดสอบ
1. **Reload Extension**
2. เปิดหน้า Gemini ใหม่
3. กด "สร้างหน้าปก"
4. สังเกตว่า:
- Cursor กระพริบในช่องไหม?
- ข้อความถูกพิมพ์ลงไปไหม?
- ปุ่มส่งเปลี่ยนเป็นสีฟ้า (Active) ไหม?
ถ้ายังไม่ได้อีก ให้ลอง **พิมพ์อะไรก็ได้ 1 ตัวอักษรลงใน Gemini ด้วยตัวเองก่อน** แล้วค่อยกดสร้างหน้าปกครับ

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

0
blank
View File

55
icons/create-icons.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Icons</title>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
const sizes = [16, 48, 128];
sizes.forEach(size => {
const canvas = document.getElementById('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Gradient background
const gradient = ctx.createLinearGradient(0, 0, size, size);
gradient.addColorStop(0, '#6366f1');
gradient.addColorStop(1, '#ec4899');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
// White image icon
ctx.fillStyle = 'white';
const margin = size * 0.2;
const iconSize = size - (margin * 2);
ctx.fillRect(margin, margin, iconSize, iconSize * 0.7);
// Triangle (mountain)
ctx.beginPath();
ctx.moveTo(margin + iconSize * 0.3, margin + iconSize * 0.5);
ctx.lineTo(margin + iconSize * 0.6, margin + iconSize * 0.2);
ctx.lineTo(margin + iconSize * 0.9, margin + iconSize * 0.5);
ctx.closePath();
ctx.fillStyle = '#6366f1';
ctx.fill();
// Circle (sun)
ctx.beginPath();
ctx.arc(margin + iconSize * 0.25, margin + iconSize * 0.25, size * 0.08, 0, Math.PI * 2);
ctx.fillStyle = '#fbbf24';
ctx.fill();
// Download
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `icon${size}.png`;
a.click();
});
});
</script>
</body>
</html>

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

46
manifest.json Normal file
View File

@@ -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"
}
}

117
scripts/background.js Normal file
View 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
View 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
View 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
View 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];
}
}

464
sidepanel/grid_ui.js Normal file
View File

@@ -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 += `<div class="preview-slot" style="left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
});
btn.innerHTML = `
<div class="template-preview">
${slotsHtml}
</div>
<span>${t.expectedCount} ภาพ</span>
`;
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();
}

177
sidepanel/panel-compact.css Normal file
View File

@@ -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;
}

1138
sidepanel/panel.css Normal file

File diff suppressed because it is too large Load Diff

314
sidepanel/panel.html Normal file
View File

@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto Cover Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;600;700;800&family=Prompt:wght@400;600;700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="panel.css">
</head>
<body>
<div class="container">
<!-- Tab Navigation -->
<nav class="tabs">
<button class="tab-btn active" data-tab="ai-cover">AI Cover (Gemini)</button>
<button class="tab-btn" data-tab="grid-creator">Grid Creator</button>
</nav>
<!-- TAB 1: AI Cover (Original Content Restored) -->
<div id="tab-ai-cover" class="tab-content active">
<!-- Header -->
<header class="header">
<div class="header-gradient"></div>
<h1 class="title">🎨 Auto Cover Generator</h1>
<p class="subtitle">สร้างหน้าปกสุดเจ๋งด้วย Gemini AI</p>
</header>
<!-- Settings Section -->
<section class="section" style="padding-bottom: 10px;">
<h2 class="section-title" style="margin-bottom: 10px;">⚙️ การตั้งค่า</h2>
<div class="form-group" style="margin-bottom: 0;">
<label class="checkbox-label">
<input type="checkbox" id="autoDownloadToggle">
<span>ดาวน์โหลดรูปภาพอัตโนมัติ (Auto Download)</span>
</label>
<p style="font-size: 11px; color: var(--text-secondary); margin-left: 28px; margin-top: 4px;">
เมื่อเปิดใช้งาน รูปที่ Gen เสร็จจะถูกดาวน์โหลดลงเครื่องทันที
</p>
</div>
</section>
<!-- Image Upload Section -->
<section class="section">
<h2 class="section-title">🖼️ เพิ่มรูปภาพ (สูงสุด 10 รูป)</h2>
<div class="upload-area" id="dropZone">
<div class="upload-placeholder" id="uploadPlaceholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<p>คลิกหรือลากรูปมาวางที่นี่</p>
<span class="upload-hint">รองรับ JPG, PNG (Max 10)</span>
</div>
<input type="file" id="imageInput" multiple accept="image/*" style="display: none;">
<div class="image-preview-grid" id="imagePreviewGrid" style="display: none;"></div>
</div>
</section>
<!-- Style Selector -->
<section class="section">
<h2 class="section-title">🎭 เลือกสไตล์ปก</h2>
<div class="style-grid">
<button class="style-card active" data-style="breaking-news">
<div class="style-icon">🚨</div>
<div class="style-name">ข่าวด่วน</div>
<div class="style-desc">Breaking News</div>
</button>
<button class="style-card" data-style="political">
<div class="style-icon">🏛️</div>
<div class="style-name">ข่าวการเมือง</div>
<div class="style-desc">Political</div>
</button>
<button class="style-card" data-style="movie-poster">
<div class="style-icon">🎬</div>
<div class="style-name">โปสเตอร์ภาพยนต์</div>
<div class="style-desc">Movie Poster</div>
</button>
<button class="style-card" data-style="drama">
<div class="style-icon">🎭</div>
<div class="style-name">ดราม่า</div>
<div class="style-desc">Drama</div>
</button>
<button class="style-card" data-style="action">
<div class="style-icon">💥</div>
<div class="style-name">แอคชั่น</div>
<div class="style-desc">Action</div>
</button>
<button class="style-card" data-style="original-image">
<div class="style-icon">🖼️</div>
<div class="style-name">ภาพต้นแบบเหมือนเดิม</div>
<div class="style-desc">ไม่แก้ไขภาพ</div>
</button>
</div>
</section>
<!-- Aspect Ratio Selector -->
<section class="section">
<h2 class="section-title">📐 ขนาดหน้าปก</h2>
<div class="ratio-grid">
<button class="ratio-btn" data-ratio="1:1">
<div class="ratio-box square"></div>
<span>1:1</span>
</button>
<button class="ratio-btn" data-ratio="4:5">
<div class="ratio-box portrait-45"></div>
<span>4:5</span>
</button>
<button class="ratio-btn" data-ratio="3:4">
<div class="ratio-box portrait-34"></div>
<span>3:4</span>
</button>
<button class="ratio-btn" data-ratio="4:3">
<div class="ratio-box landscape-43"></div>
<span>4:3</span>
</button>
<button class="ratio-btn" data-ratio="2:3">
<div class="ratio-box portrait-23"></div>
<span>2:3</span>
</button>
<button class="ratio-btn" data-ratio="9:16">
<div class="ratio-box portrait-916"></div>
<span>9:16</span>
</button>
<button class="ratio-btn active" data-ratio="16:9">
<div class="ratio-box landscape-169"></div>
<span>16:9</span>
</button>
</div>
</section>
<!-- Emotion/Mood Selector -->
<section class="section">
<h2 class="section-title">😊 อารมณ์ของตัวแบบ</h2>
<div class="emotion-grid">
<button class="emotion-btn" data-emotion="none">🚫 ไม่ใส่</button>
<button class="emotion-btn" data-emotion="happy">😊 ยิ้มแย้ม</button>
<button class="emotion-btn active" data-emotion="serious">😐 จริงจัง</button>
<button class="emotion-btn" data-emotion="sad">😢 เศร้า</button>
<button class="emotion-btn" data-emotion="angry">😠 โกรธ</button>
<button class="emotion-btn" data-emotion="surprised">😲 ตกใจ</button>
<button class="emotion-btn" data-emotion="confident">😎 มั่นใจ</button>
</div>
</section>
<!-- Text Customization -->
<section class="section">
<h2 class="section-title">✍️ ปรับแต่งตัวหนังสือ</h2>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="includeText" checked>
<span>ใส่ข้อความในหน้าปก</span>
</label>
</div>
<div id="textOptions">
<div class="form-group">
<label for="headlineText">หัวข้อข่าว / ข้อความ</label>
<textarea id="headlineText" placeholder="พิมพ์หัวข้อที่ต้องการ..." class="text-input" rows="3"></textarea>
</div>
<div class="form-group">
<label>ตำแหน่งตัวหนังสือ</label>
<div class="position-grid">
<button class="position-btn" data-position="top-left">บนซ้าย</button>
<button class="position-btn" data-position="top">บน</button>
<button class="position-btn" data-position="top-right">บนขวา</button>
<button class="position-btn" data-position="center-left">ซ้าย</button>
<button class="position-btn active" data-position="center">กลาง</button>
<button class="position-btn" data-position="center-right">ขวา</button>
<button class="position-btn" data-position="bottom-left">ล่างซ้าย</button>
<button class="position-btn" data-position="bottom">ล่าง</button>
<button class="position-btn" data-position="bottom-right">ล่างขวา</button>
</div>
</div>
<div class="form-group">
<label>สไตล์ฟอนต์ (เลือกได้มากกว่า 1)</label>
<div class="font-style-grid" id="fontStyleGrid">
<button class="font-style-btn" data-font-style="3d">3D</button>
<button class="font-style-btn active" data-font-style="thai-news-loop">ข่าวไทยมีหัว</button>
<button class="font-style-btn active" data-font-style="bold">ตัวหนา</button>
<button class="font-style-btn" data-font-style="thin">ตัวบาง</button>
<button class="font-style-btn" data-font-style="italic">ตัวเอียง</button>
<button class="font-style-btn" data-font-style="thai-news">ฟอนต์ข่าวไทย</button>
</div>
</div>
</div>
</section>
<!-- Preset Management -->
<section class="section">
<h2 class="section-title">💾 บันทึกการตั้งค่า (Presets)</h2>
<div class="preset-controls">
<input type="text" id="presetName" class="text-input" placeholder="ตั้งชื่อ Preset..."
style="margin-bottom: 8px;">
<button class="btn btn-secondary btn-full" id="savePresetBtn">บันทึกการตั้งค่า</button>
</div>
<div id="presetList" class="preset-list">
<!-- Presets will be loaded here -->
</div>
</section>
<!-- Custom Prompt -->
<section class="section">
<h2 class="section-title">🎨 Prompt เพิ่มเติม (ถ้ามี)</h2>
<textarea id="customPrompt" class="custom-prompt"
placeholder="เพิ่มรายละเอียดเพิ่มเติมที่ต้องการ...&#10;เช่น: เพิ่มเอฟเฟกต์แสง, เปลี่ยนบรรยากาศ, ฯลฯ"
rows="4"></textarea>
</section>
<!-- Action Buttons -->
<div class="action-buttons">
<button class="btn btn-primary btn-full" id="generateBtn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 12h8"></path>
<path d="M12 8v8"></path>
</svg>
สร้างหน้าปก
</button>
</div>
<!-- History Section -->
<div class="history-section section">
<div class="history-header">
<h2 class="section-title">📜 ประวัติการสร้าง</h2>
<button class="btn-small" id="exportHistoryBtn">ส่งออก Log</button>
</div>
<div class="history-stats">
<span id="historyCount">ทั้งหมด: 0</span>
</div>
<div class="history-list" id="historyList">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">ยังไม่มีประวัติ</p>
</div>
</div>
</div>
<!-- TAB 2: Grid Creator (Original Content Restored) -->
<div id="tab-grid-creator" class="tab-content">
<header class="header">
<div class="header-gradient"></div>
<h1 class="title">🧩 Grid Creator</h1>
<p class="subtitle">รวมภาพหลายช่องในสไตล์คุณ</p>
</header>
<section class="section">
<div class="upload-area" id="gridDropZone">
<div class="upload-placeholder">
<p>+ เพิ่มรูปภาพ</p>
<span class="upload-hint">ลาก หรือ คลิก</span>
</div>
<input type="file" id="gridImageInput" multiple accept="image/*" style="display: none;">
</div>
<div id="gridImagesList" class="grid-images-list"></div>
<p class="section-status"><small id="gridImageCount">0 ภาพ</small></p>
</section>
<section class="section">
<h2 class="section-title">Layout</h2>
<div id="gridTemplateList" class="template-list"></div>
</section>
<!-- Fake Number Settings -->
<section class="section">
<h2 class="section-title">🔢 Fake Number Overlay</h2>
<div class="form-group" style="margin-bottom: 0;">
<label class="checkbox-label">
<input type="checkbox" id="fakeNumberToggle">
<span>แสดงตัวเลขมุมขวาล่าง</span>
</label>
<div id="fakeNumberInputContainer" style="margin-top: 8px; display: none;">
<input type="text" id="fakeNumberInput" class="text-input" value="+5" placeholder="e.g. +5">
</div>
</div>
</section>
<div class="preview-section">
<div id="gridCanvasPreview" class="canvas-preview-container">
<!-- Canvas will be here -->
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary btn-full" id="gridGenerateBtn">Generate Grid</button>
<button class="btn btn-secondary btn-full" id="gridDownloadBtn" style="margin-top: 8px;">Download</button>
</div>
</div>
</div> <!-- Container End -->
<div class="status-message" id="statusMessage" style="display: none;"></div>
<script src="../scripts/grid_templates.js"></script>
<script src="../scripts/grid_generator.js"></script>
<script src="grid_ui.js"></script>
<script src="panel.js"></script>
</body>
</html>

778
sidepanel/panel.js Normal file
View File

@@ -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 => `
<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();

64
sidepanel/warning.html Normal file
View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warning</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #1e1e1e;
color: #e0e0e0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 20px;
}
h2 {
margin: 0 0 10px 0;
font-weight: 500;
color: #ff6b6b;
}
p {
font-size: 14px;
line-height: 1.5;
color: #b0b0b0;
}
.link {
margin-top: 20px;
padding: 10px 20px;
background-color: #4a90e2;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 14px;
transition: background-color 0.2s;
}
.link:hover {
background-color: #357abd;
}
</style>
</head>
<body>
<div class="icon">⚠️</div>
<h2>ใช้งานไม่ได้ในหน้านี้</h2>
<p>Extension นี้ใช้งานได้เฉพาะบน<br>gemini.google.com เท่านั้น</p>
<a href="https://gemini.google.com/" target="_blank" class="link">ไปที่ Gemini</a>
</body>
</html>

9
verification.txt Normal file
View File

@@ -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