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

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();
}