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:
464
sidepanel/grid_ui.js
Normal file
464
sidepanel/grid_ui.js
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user