📜
cutout_copy3.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// cutout.js — Image tabs (thumbnails) → Canvas → Groups → Grid Size → Tiles // - Reads SelectionStore from Files (read-only) // - For each image URL: tileSize + groups[{id,name,tiles}] + activeGroupId // - Click grid toggles tile in ACTIVE group // - Persists in localStorage (v4 schema). All listeners scoped to Cutout only. (function () { // ---------- Read-only SelectionStore adapter ---------- const Sel = (() => { const s = window.SelectionStore; const noop = () => {}; if (s && typeof s.getAll === 'function' && typeof s.subscribe === 'function') { return { getAll: () => s.getAll(), subscribe: (fn) => s.subscribe(fn) || noop }; } console.warn('[cutout] SelectionStore not found yet — tabs will populate once Files initializes it.'); return { getAll: () => [], subscribe: () => noop }; })(); // ---------- TileStore (v4): per-image groups ---------- // state: { activeUrl, images: { [url]: { tileSize:number, activeGroupId:string, groups:[{id,name,tiles:[{col,row,spriteId,spriteNumber}]}] } }, nextSpriteId: number } const TileStore = (() => { const KEY = 'tile_holders_v4'; const listeners = new Set(); let state = { activeUrl: null, images: {}, nextSpriteId: 1 }; // Load try { const raw = localStorage.getItem(KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { state.activeUrl = parsed.activeUrl || null; state.images = parsed.images && typeof parsed.images === 'object' ? parsed.images : {}; state.nextSpriteId = parsed.nextSpriteId || 1; } } } catch (e) { console.warn('[cutout] TileStore load failed:', e); } // Save/notify function save() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { console.warn('[cutout] save failed', e);} } function notify() { listeners.forEach(fn => { try { fn(snapshot()); } catch {} }); } function snapshot() { const out = { activeUrl: state.activeUrl, images: {}, nextSpriteId: state.nextSpriteId }; for (const url in state.images) { const im = state.images[url]; out.images[url] = { tileSize: im.tileSize, activeGroupId: im.activeGroupId || null, groups: (im.groups || []).map(g => ({ id: g.id, name: g.name, tiles: (g.tiles || []).map(t => ({ col: t.col, row: t.row, spriteId: t.spriteId, spriteNumber: t.spriteNumber })) })) }; } return out; } // Helpers function uid() { return 'g_' + Math.random().toString(36).slice(2, 10); } function calculateSpriteNumber(col, row, imageWidth, tileSize) { const tilesPerRow = Math.floor(imageWidth / tileSize); return row * tilesPerRow + col; } function detectTileSizeFromFolder(folderName) { if (!folderName) return 32; // Extract common tile size patterns from folder name const lowerName = folderName.toLowerCase(); // Look for explicit size patterns like "32x32", "64px", "16_16", etc. const sizePatterns = [ /(\d+)x\1/i, // 32x32, 64x64 /(\d+)px/i, // 32px, 64px /(\d+)_\1/i, // 32_32, 64_64 /_(\d+)/i, // _32, _64 /(\d+)$/i // folder ending in number ]; for (const pattern of sizePatterns) { const match = lowerName.match(pattern); if (match) { const size = parseInt(match[1]); // Only accept common tile sizes if ([8, 16, 24, 32, 48, 64, 96, 128].includes(size)) { return size; } } } // Default fallback return 32; } function requireImage(url, folderName) { if (!url) return null; if (!state.images[url]) { // Detect tile size from folder name, fallback to 32 const detectedSize = detectTileSizeFromFolder(folderName); const gid = uid(); state.images[url] = { tileSize: detectedSize, activeGroupId: gid, groups: [{ id: gid, name: 'Group 1', tiles: [] }] }; } else if (!state.images[url].groups || !state.images[url].groups.length) { const gid = uid(); state.images[url].groups = [{ id: gid, name: 'Group 1', tiles: [] }]; state.images[url].activeGroupId = gid; } else if (!state.images[url].activeGroupId) { state.images[url].activeGroupId = state.images[url].groups[0].id; } return state.images[url]; } function img(url) { return state.images[url] || null; } function active() { const url = state.activeUrl; if (!url) return { url: null, rec: null, group: null }; const rec = img(url); if (!rec) return { url, rec: null, group: null }; const g = rec.groups.find(g => g.id === rec.activeGroupId) || rec.groups[0] || null; return { url, rec, group: g }; } // API function ensure(url, defaults, folderName) { const rec = requireImage(url, folderName); // Only apply defaults if this is a brand new image record // For existing images, keep their remembered tile size if (defaults && typeof defaults.tileSize === 'number' && !state.images[url]) { rec.tileSize = defaults.tileSize; } if (!state.activeUrl) state.activeUrl = url; save(); notify(); return rec; } function setActive(url) { if (state.activeUrl !== url) { state.activeUrl = url || null; save(); notify(); } } function setTileSize(url, size) { const rec = requireImage(url); rec.tileSize = size; save(); notify(); return true; } function setActiveGroup(url, groupId) { const rec = requireImage(url); if (rec.groups.find(g => g.id === groupId)) { rec.activeGroupId = groupId; save(); notify(); } } function addGroup(url, name) { const rec = requireImage(url); const gid = uid(); rec.groups.push({ id: gid, name: name || `Group ${rec.groups.length + 1}`, tiles: [] }); rec.activeGroupId = gid; save(); notify(); return gid; } function renameGroup(url, groupId, name) { const rec = requireImage(url); const g = rec.groups.find(g => g.id === groupId); if (!g) return false; g.name = name || g.name; save(); notify(); return true; } function removeGroup(url, groupId) { const rec = requireImage(url); const i = rec.groups.findIndex(g => g.id === groupId); if (i === -1) return false; rec.groups.splice(i, 1); if (!rec.groups.length) { const gid = uid(); rec.groups.push({ id: gid, name: 'Group 1', tiles: [] }); rec.activeGroupId = gid; } else if (rec.activeGroupId === groupId) { rec.activeGroupId = rec.groups[0].id; } save(); notify(); return true; } function toggleTile(url, groupId, col, row, skipNotify = false) { const rec = requireImage(url); const g = rec.groups.find(g => g.id === groupId); if (!g) return false; const i = g.tiles.findIndex(t => t.col === col && t.row === row); let wasSelected = i >= 0; if (i >= 0) { g.tiles.splice(i, 1); } else { // Get image dimensions for sprite number calculation const img = document.getElementById('sprite-sheet'); const imageWidth = img ? img.naturalWidth : 1000; // fallback const spriteNumber = calculateSpriteNumber(col, row, imageWidth, rec.tileSize); const spriteId = state.nextSpriteId++; g.tiles.push({ col, row, spriteId, spriteNumber }); } save(); if (!skipNotify) notify(); return !wasSelected; // return new selection state } function removeTile(url, groupId, col, row) { const rec = requireImage(url); const g = rec.groups.find(g => g.id === groupId); if (!g) return false; const i = g.tiles.findIndex(t => t.col === col && t.row === row); if (i >= 0) { g.tiles.splice(i, 1); save(); notify(); return true; } return false; } function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } return { snapshot, ensure, setActive, active, setTileSize, setActiveGroup, addGroup, renameGroup, removeGroup, toggleTile, removeTile, subscribe }; })(); // ---------- Local UI state ---------- let currentIndex = 0; // which selected image tab let currentZoom = 1; // preserve zoom level across redraws function selectedImages() { return Sel.getAll(); } // [{url,name}] function currentImage() { return selectedImages()[currentIndex] || null; } // ---------- Thumbnail generation ---------- function generateTileThumbnail(imageUrl, col, row, tileSize, callback) { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const thumbSize = 20; // thumbnail size in pixels canvas.width = thumbSize; canvas.height = thumbSize; // Draw the tile portion of the image scaled to thumbnail size const srcX = col * tileSize; const srcY = row * tileSize; const srcW = Math.min(tileSize, img.naturalWidth - srcX); const srcH = Math.min(tileSize, img.naturalHeight - srcY); if (srcW > 0 && srcH > 0) { ctx.imageSmoothingEnabled = false; // Keep pixels crisp for pixel art ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, thumbSize, thumbSize); } callback(canvas.toDataURL()); }; img.onerror = () => callback(null); img.src = imageUrl; } // ---------- Render helpers ---------- function renderImageTabs(list, activeUrl) { if (!list.length) { return `<div style="margin-bottom:.5rem;padding:.5rem;border:1px dashed rgba(71,85,105,0.5);border-radius:.5rem;color:#94a3b8;font-size:0.875rem;">No image tabs</div>`; } const idx = list.findIndex(i => i.url === activeUrl); if (idx >= 0) currentIndex = idx; return ` <div style="margin-bottom:.5rem;padding:.5rem;background:rgba(30,41,59,0.6);border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.25rem;text-transform:uppercase;letter-spacing:0.05em;">Images</label> <div id="cutout-tabs" style="display:flex;gap:.375rem;overflow:auto;-webkit-overflow-scrolling:touch;"> ${list.map((img, i) => { const active = img.url === activeUrl; return ` <button class="img-tab" data-index="${i}" title="${img.name || 'image'}" style="flex:0 0 auto;border:1px solid ${active?'rgba(59,130,246,0.6)':'rgba(71,85,105,0.4)'};background:${active?'rgba(30,58,138,0.45)':'rgba(30,41,59,0.6)'};padding:.2rem;border-radius:.375rem;cursor:pointer;"> <div style="width:36px;height:36px;border-radius:.25rem;overflow:hidden;background:#0a0f1c;display:flex;align-items:center;justify-content:center;"> <img src="${img.url}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover;"> </div> </button> `; }).join('')} </div> </div> `; } function renderGroupTabs(rec) { if (!rec) return `<div style="margin-bottom:.5rem;padding:.5rem;border:1px dashed rgba(71,85,105,0.5);border-radius:.5rem;color:#94a3b8;font-size:0.875rem;">Select image for groups</div>`; const groups = rec.groups || []; const activeId = rec.activeGroupId; return ` <div style="margin-bottom:.5rem;padding:.5rem;background:rgba(30,41,59,0.6);border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.25rem;text-transform:uppercase;letter-spacing:0.05em;">Groups</label> <div id="group-bar" style="display:flex;align-items:center;gap:.375rem;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;white-space:nowrap;"> ${groups.map(g => ` <button class="group-tab" data-id="${g.id}" style="flex:0 0 auto;padding:.25rem .5rem;border-radius:.375rem;cursor:pointer;border:1px solid ${g.id===activeId?'rgba(59,130,246,0.6)':'rgba(71,85,105,0.4)'};background:${g.id===activeId?'rgba(30,58,138,0.45)':'rgba(30,41,59,0.6)'};color:#e2e8f0;font-size:0.8rem;"> ${g.name} </button> `).join('')} <button id="group-add" type="button" style="flex:0 0 auto;padding:.25rem .5rem;border-radius:.375rem;border:1px solid rgba(71,85,105,0.4);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;font-size:0.8rem;"> + </button> ${groups.length ? ` <button id="group-rename" type="button" style="flex:0 0 auto;padding:.25rem .5rem;border-radius:.375rem;border:1px solid rgba(71,85,105,0.4);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;font-size:0.8rem;"> ✏️ </button> <button id="group-remove" type="button" style="flex:0 0 auto;padding:.25rem .5rem;border-radius:.375rem;border:1px solid rgba(71,85,105,0.4);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;font-size:0.8rem;"> 🗑️ </button>` : ''} </div> </div> `; } function renderGridSize(size) { const sizes = [8, 16, 32, 64, 128]; const zooms = [0.25, 0.5, 1, 1.5, 2, 3, 4]; return ` <div style="margin-bottom:.5rem;padding:.5rem;background:rgba(30,41,59,0.6);border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);"> <div style="display:flex;gap:.5rem;"> <div style="flex:1;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.25rem;text-transform:uppercase;letter-spacing:0.05em;">Grid Size</label> <select id="grid-size-select" style="width:100%;background:rgba(15,23,42,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.5rem;border-radius:.375rem;font-size:0.875rem;cursor:pointer;"> ${sizes.map(s => `<option value="${s}" ${s===size?'selected':''}>${s}×${s}px</option>`).join('')} </select> </div> <div style="flex:1;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.25rem;text-transform:uppercase;letter-spacing:0.05em;">Zoom</label> <select id="zoom-select" style="width:100%;background:rgba(15,23,42,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.5rem;border-radius:.375rem;font-size:0.875rem;cursor:pointer;"> ${zooms.map(z => `<option value="${z}" ${z===1?'selected':''}>${z === 1 ? '100%' : Math.round(z*100)+'%'}</option>`).join('')} </select> </div> </div> </div> `; } function renderCanvas(url) { if (!url) { return `<div style="display:flex;align-items:center;justify-content:center;height:200px;color:#94a3b8;border:1px dashed rgba(71,85,105,0.5);border-radius:.5rem;font-size:0.875rem;">No image selected</div>`; } return ` <div style="padding:.5rem;background:rgba(30,41,59,0.6);border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.375rem;text-transform:uppercase;letter-spacing:0.05em;">Sprite Sheet</label> <div id="cutout-canvas-wrap" style="position:relative;overflow:auto;max-height:50vh;background:#0a0f1c;border-radius:.375rem;border:1px solid rgba(71,85,105,0.4);"> <div id="canvas-container" style="position:relative;display:inline-block;"> <img id="sprite-sheet" src="${url}" crossorigin="anonymous" style="display:block;max-width:none;image-rendering:pixelated;"> <canvas id="grid-overlay" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas> <canvas id="tiles-overlay" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas> <div id="tile-divs-container" style="position:absolute;top:0;left:0;pointer-events:auto;"></div> </div> </div> <div style="margin-top:.375rem;padding:.375rem;background:rgba(30,41,59,0.4);border-radius:.375rem;font-size:0.8rem;color:#94a3b8;"> Click grid cells to toggle tiles </div> </div> `; } function renderTilesList(group, imageUrl, tileSize) { const tiles = group?.tiles || []; if (!tiles.length) { return `<div style="margin-bottom:.5rem;padding:.5rem;border:1px dashed rgba(71,85,105,0.5);border-radius:.5rem;color:#94a3b8;font-size:0.875rem;">No tiles selected</div>`; } return ` <div style="margin-bottom:.5rem;padding:.5rem;background:rgba(30,41,59,0.6);border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.25rem;text-transform:uppercase;letter-spacing:0.05em;">Tiles (${tiles.length})</label> <div id="tiles-container" style="display:flex;gap:.375rem;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;white-space:nowrap;"> ${tiles.map(t => ` <button class="tile-chip" data-col="${t.col}" data-row="${t.row}" style="flex:0 0 auto;display:flex;align-items:center;gap:.25rem;padding:.25rem .375rem;border-radius:.375rem;border:1px solid rgba(148,163,184,0.25);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;font-size:0.8rem;"> <div class="tile-thumbnail" data-col="${t.col}" data-row="${t.row}" style="width:20px;height:20px;background:#0a0f1c;border-radius:2px;image-rendering:pixelated;flex-shrink:0;border:1px solid rgba(71,85,105,0.4);display:flex;align-items:center;justify-content:center;font-size:8px;color:#64748b;"> … </div> <span>#${t.spriteNumber} (ID:${t.spriteId})</span> <span style="color:#f87171;">✕</span> </button>`).join('')} </div> </div> `; } // ---------- Generate thumbnails for tiles ---------- function generateTileThumbnails(imageUrl, tiles, tileSize) { if (!imageUrl || !tiles || !tiles.length) return; tiles.forEach(tile => { const thumbEl = document.querySelector(`.tile-thumbnail[data-col="${tile.col}"][data-row="${tile.row}"]`); if (!thumbEl) return; generateTileThumbnail(imageUrl, tile.col, tile.row, tileSize, (dataUrl) => { if (dataUrl) { thumbEl.style.backgroundImage = `url(${dataUrl})`; thumbEl.style.backgroundSize = 'cover'; thumbEl.style.backgroundPosition = 'center'; thumbEl.textContent = ''; } else { thumbEl.textContent = '?'; } }); }); } // ---------- Drawing ---------- function createTileDivs(tileSize, group) { const img = document.getElementById('sprite-sheet'); const container = document.getElementById('tile-divs-container'); if (!img || !container) return; container.innerHTML = ''; const imgWidth = img.clientWidth; const imgHeight = img.clientHeight; const cols = Math.floor(img.naturalWidth / tileSize); const rows = Math.floor(img.naturalHeight / tileSize); const displayTileWidth = (imgWidth / img.naturalWidth) * tileSize; const displayTileHeight = (imgHeight / img.naturalHeight) * tileSize; // Create a div for each tile position for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const tileDiv = document.createElement('div'); tileDiv.className = 'tile-div'; tileDiv.dataset.col = col; tileDiv.dataset.row = row; // Check if this tile is selected const isSelected = group && group.tiles && group.tiles.some(t => t.col === col && t.row === row); tileDiv.style.cssText = ` position: absolute; left: ${col * displayTileWidth}px; top: ${row * displayTileHeight}px; width: ${displayTileWidth}px; height: ${displayTileHeight}px; border: 1px solid transparent; cursor: crosshair; ${isSelected ? 'background: rgba(34,197,94,0.25); border-color: rgba(34,197,94,0.8);' : ''} `; // Store selection state tileDiv.dataset.selected = isSelected; // Hover effects tileDiv.addEventListener('mouseenter', () => { if (tileDiv.dataset.selected !== 'true') { tileDiv.style.background = 'rgba(59,130,246,0.15)'; tileDiv.style.borderColor = 'rgba(59,130,246,0.6)'; } }); tileDiv.addEventListener('mouseleave', () => { if (tileDiv.dataset.selected !== 'true') { tileDiv.style.background = 'transparent'; tileDiv.style.borderColor = 'transparent'; } }); // Click handler tileDiv.addEventListener('click', () => { const s = TileStore.active(); if (s.url && s.group) { // Toggle tile without triggering full interface update const newIsSelected = TileStore.toggleTile(s.url, s.group.id, col, row, true); // Update this specific tile's appearance immediately if (newIsSelected) { tileDiv.style.background = 'rgba(34,197,94,0.25)'; tileDiv.style.borderColor = 'rgba(34,197,94,0.8)'; tileDiv.dataset.selected = 'true'; } else { tileDiv.style.background = 'transparent'; tileDiv.style.borderColor = 'transparent'; tileDiv.dataset.selected = 'false'; } // Update only the tiles list section without redrawing canvas updateTilesList(); } }); container.appendChild(tileDiv); } } } function redrawAll(tileSize, group) { const img = document.getElementById('sprite-sheet'); const grid = document.getElementById('grid-overlay'); const tiles = document.getElementById('tiles-overlay'); if (!img || !grid || !tiles) return; [grid, tiles].forEach(c => { c.width = img.naturalWidth; c.height = img.naturalHeight; c.style.width = img.clientWidth + 'px'; c.style.height = img.clientHeight + 'px'; }); const g = grid.getContext('2d'); g.clearRect(0,0,grid.width,grid.height); g.strokeStyle = 'rgba(59,130,246,0.6)'; g.lineWidth = 1; g.beginPath(); for (let x=0; x<=grid.width; x+=tileSize) { g.moveTo(x,0); g.lineTo(x,grid.height); } for (let y=0; y<=grid.height; y+=tileSize) { g.moveTo(0,y); g.lineTo(grid.width,y); } g.stroke(); // Clear tiles overlay since divs handle selection display const t = tiles.getContext('2d'); t.clearRect(0,0,tiles.width,tiles.height); // Create tile divs for interaction createTileDivs(tileSize, group); } function pointToCell(evt, tileSize) { const img = document.getElementById('sprite-sheet'); const hit = document.getElementById('hit-overlay'); if (!img || !hit) return null; const rect = hit.getBoundingClientRect(); const rx = evt.clientX - rect.left, ry = evt.clientY - rect.top; const sx = img.naturalWidth / img.clientWidth, sy = img.naturalHeight / img.clientHeight; const px = rx * sx, py = ry * sy; const col = Math.floor(px / tileSize), row = Math.floor(py / tileSize); if (col<0 || row<0 || col*tileSize>=img.naturalWidth || row*tileSize>=img.naturalHeight) return null; return { col, row }; } // ---------- Mount / Update ---------- function updateContent() { const container = document.getElementById('cutout-content'); if (!container) return; const list = selectedImages(); // Ensure active image URL let { url: activeUrl, rec, group } = TileStore.active(); if (!activeUrl && list.length) { activeUrl = list[Math.max(0, Math.min(currentIndex, list.length-1))].url; rec = TileStore.ensure(activeUrl, { tileSize: 32 }); TileStore.setActive(activeUrl); ({ url: activeUrl, rec, group } = TileStore.active()); } const idx = list.findIndex(i => i.url === activeUrl); if (idx >= 0) currentIndex = idx; const size = rec ? rec.tileSize : 32; // ORDER: Grid Size → Tabs → Groups → Tiles → Canvas (picture) container.innerHTML = ` ${renderGridSize(size)} ${renderImageTabs(list, activeUrl)} ${renderGroupTabs(rec)} ${renderTilesList(group, activeUrl, size)} ${renderCanvas(activeUrl)} `; wireUpControls(); // Generate thumbnails after the DOM is updated if (activeUrl && group && group.tiles) { generateTileThumbnails(activeUrl, group.tiles, size); } } function wireUpControls() { const list = selectedImages(); const { url: activeUrl, rec, group } = TileStore.active(); const size = rec ? rec.tileSize : 32; // Image tab clicks - don't override tile size, use remembered size document.querySelectorAll('.img-tab').forEach(tab => { tab.addEventListener('click', () => { const idx = parseInt(tab.getAttribute('data-index'), 10) || 0; currentIndex = idx; const img = list[idx]; if (!img) return; TileStore.ensure(img.url); // Remove tileSize inheritance TileStore.setActive(img.url); }); }); // Group tab clicks document.querySelectorAll('.group-tab').forEach(btn => { btn.addEventListener('click', () => { const gid = btn.getAttribute('data-id'); if (activeUrl && gid) TileStore.setActiveGroup(activeUrl, gid); }); }); // Add / Rename / Delete group const addBtn = document.getElementById('group-add'); if (addBtn) addBtn.addEventListener('click', () => { if (!activeUrl) return; const name = prompt('New group name?', `Group ${(rec?.groups?.length || 0) + 1}`); TileStore.addGroup(activeUrl, name || undefined); }); const renBtn = document.getElementById('group-rename'); if (renBtn) renBtn.addEventListener('click', () => { if (!activeUrl || !group) return; const name = prompt('Rename group:', group.name); if (name && name.trim()) TileStore.renameGroup(activeUrl, group.id, name.trim()); }); const delBtn = document.getElementById('group-remove'); if (delBtn) delBtn.addEventListener('click', () => { if (!activeUrl || !group) return; if (confirm(`Delete group "${group.name}"?`)) { TileStore.removeGroup(activeUrl, group.id); } }); // Grid size per image const gridSel = document.getElementById('grid-size-select'); if (gridSel) { gridSel.addEventListener('change', () => { const newSize = parseInt(gridSel.value, 10) || 32; if (activeUrl) TileStore.setTileSize(activeUrl, newSize); }); } // Zoom control (visual only) const zoomSel = document.getElementById('zoom-select'); if (zoomSel) { // Restore saved zoom level zoomSel.value = currentZoom; zoomSel.addEventListener('change', () => { currentZoom = parseFloat(zoomSel.value) || 1; // Save zoom level applyZoom(currentZoom); }); // Apply current zoom level after render if (currentZoom !== 1) { setTimeout(() => applyZoom(currentZoom), 0); } } function applyZoom(zoomLevel) { const img = document.getElementById('sprite-sheet'); const gridCanvas = document.getElementById('grid-overlay'); const tilesCanvas = document.getElementById('tiles-overlay'); if (img && gridCanvas && tilesCanvas) { const naturalWidth = img.naturalWidth; const naturalHeight = img.naturalHeight; const displayWidth = naturalWidth * zoomLevel; const displayHeight = naturalHeight * zoomLevel; // Set display size for image img.style.width = displayWidth + 'px'; img.style.height = displayHeight + 'px'; // Set display size for all canvases [gridCanvas, tilesCanvas].forEach(canvas => { canvas.style.width = displayWidth + 'px'; canvas.style.height = displayHeight + 'px'; }); // Recreate tile divs at correct size const { url: activeUrl, rec, group } = TileStore.active(); if (rec) { createTileDivs(rec.tileSize, group); } } } // Draw when image loads const imgEl = document.getElementById('sprite-sheet'); if (imgEl) { if (imgEl.complete) redrawAll(size, group); else imgEl.onload = () => redrawAll(size, group); } // Remove tile via chip document.querySelectorAll('.tile-chip').forEach(ch => { ch.addEventListener('click', () => { const col = parseInt(ch.getAttribute('data-col'), 10); const row = parseInt(ch.getAttribute('data-row'), 10); const s = TileStore.active(); if (s.url && s.group) TileStore.removeTile(s.url, s.group.id, col, row); }); }); } // ---------- Initial mount & subscriptions ---------- const initialHtml = ` <div id="cutout-content" style="height:100%;overflow-y:auto;padding:1rem;"> <div style="margin-bottom:.75rem;color:#94a3b8;">Welcome to the Sprite Sheet Tile Cutter. The workflow is: Select files → Set grid size → Choose image tab → Create groups → Click tiles → View results.</div> </div> `; window.AppItems = window.AppItems || []; window.AppItems.push({ title: '✂️ Cut', html: initialHtml, onRender() { updateContent(); } }); // Keep UI in sync with external changes Sel.subscribe(() => updateContent()); TileStore.subscribe(() => updateContent()); // Also accept open-from-Files (imageSelected event) window.addEventListener('imageSelected', (e) => { let list = e.detail?.images; let idx = e.detail?.index ?? 0; let folderName = e.detail?.folder || ''; // Get folder name from Files if (!list && e.detail?.url) list = [{ url: e.detail.url, name: e.detail.name || 'image' }]; if (Array.isArray(list) && list.length) { const url = list[Math.max(0, Math.min(idx, list.length - 1))].url; TileStore.ensure(url, null, folderName); // Pass folder name for size detection TileStore.setActive(url); } }); })();