Add manifest.json, sidepanel components, and scripts. Include project assets and documentation files. Remove placeholder blank file.
465 lines
16 KiB
JavaScript
465 lines
16 KiB
JavaScript
|
||
// 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();
|
||
}
|