📜
cutout.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 { rec: null, created: false }; let created = false; if (!state.images[url]) { created = true; // 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 { rec: state.images[url], created }; } 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, created } = requireImage(url, folderName); if (!rec) return null; // Only apply defaults if this is a brand new image record if (created && defaults && typeof defaults.tileSize === 'number') { rec.tileSize = defaults.tileSize; } if (!state.activeUrl) state.activeUrl = url; save(); notify(); return rec; } function setActive(url) { // Visual debug indicator const debugDiv = document.getElementById('debug-indicator') || (() => { const div = document.createElement('div'); div.id = 'debug-indicator'; div.style.cssText = 'position:fixed;top:10px;right:10px;background:red;color:white;padding:5px;border-radius:3px;z-index:9999;font-size:12px;'; document.body.appendChild(div); return div; })(); if (state.activeUrl !== url) { debugDiv.textContent = `Switching to: ${url?.slice(-10) || 'null'}`; state.activeUrl = url || null; save(); // Clear any existing grid when switching images const gridOverlay = document.getElementById('grid-overlay'); if (gridOverlay) { gridOverlay.innerHTML = ''; gridOverlay.onclick = null; } notify(); // Clear debug after 2 seconds setTimeout(() => { if (debugDiv.parentNode) debugDiv.parentNode.removeChild(debugDiv); }, 2000); } else { debugDiv.textContent = 'Same URL - no change'; setTimeout(() => { if (debugDiv.parentNode) debugDiv.parentNode.removeChild(debugDiv); }, 1000); } } function setTileSize(url, size) { const { rec } = requireImage(url); if (!rec) return false; rec.tileSize = size; save(); notify(); return true; } function setActiveGroup(url, groupId) { const { rec } = requireImage(url); if (!rec) return; if (rec.groups.find(g => g.id === groupId)) { rec.activeGroupId = groupId; save(); // Update tiles display and UI without full re-render const group = rec.groups.find(g => g.id === groupId); updateAllTiles(group); updateGroupTabs(rec); updateTilesList(group, url, rec.tileSize); // Generate thumbnails for the new group if (url && group && group.tiles) { generateTileThumbnails(url, group.tiles, rec.tileSize); } } } function addGroup(url, name) { const { rec } = requireImage(url); if (!rec) return null; 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); if (!rec) return false; 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); if (!rec) return false; 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) { const { rec } = requireImage(url); if (!rec) return false; 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 wasAdded = false; if (i >= 0) { g.tiles.splice(i, 1); console.log('Removed tile:', col, row); // Debug log } 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 }); wasAdded = true; console.log('Added tile:', col, row, 'spriteId:', spriteId); // Debug log } save(); // Immediately update just the specific tile div and tile list, skip notify to prevent full re-render updateSingleTile(col, row, wasAdded); updateTilesList(g, url, rec.tileSize); // Generate thumbnails for the updated tile list if (url && g && g.tiles) { generateTileThumbnails(url, g.tiles, rec.tileSize); } return true; } function removeTile(url, groupId, col, row) { const { rec } = requireImage(url); if (!rec) return false; 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(); // Immediately update just the specific tile div and tile list, skip notify to prevent full re-render updateSingleTile(col, row, false); updateTilesList(g, url, rec.tileSize); // Generate thumbnails for the updated tile list if (url && g && g.tiles) { generateTileThumbnails(url, g.tiles, rec.tileSize); } 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 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 = [16, 32, 64, 128]; 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;">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> `; } 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;"> <div id="grid-overlay" 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 = '?'; } }); }); } // ---------- Grid System (Div-based) ---------- function createGrid(tileSize, group) { const img = document.getElementById('sprite-sheet'); const gridOverlay = document.getElementById('grid-overlay'); if (!img || !gridOverlay) return; const imageWidth = img.naturalWidth; const imageHeight = img.naturalHeight; const cols = Math.floor(imageWidth / tileSize); const rows = Math.floor(imageHeight / tileSize); // Set grid overlay size to match image gridOverlay.style.width = img.clientWidth + 'px'; gridOverlay.style.height = img.clientHeight + 'px'; // Clear existing grid gridOverlay.innerHTML = ''; // Create tile divs for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const tileDiv = document.createElement('div'); tileDiv.className = 'grid-tile'; tileDiv.dataset.col = col; tileDiv.dataset.row = row; const left = (col * tileSize / imageWidth) * 100; const top = (row * tileSize / imageHeight) * 100; const width = (tileSize / imageWidth) * 100; const height = (tileSize / imageHeight) * 100; // 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: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%; border: 1px solid rgba(59,130,246,0.6); box-sizing: border-box; cursor: crosshair; background: ${isSelected ? 'rgba(34,197,94,0.25)' : 'transparent'}; border-color: ${isSelected ? 'rgba(34,197,94,0.8)' : 'rgba(59,130,246,0.6)'}; `; gridOverlay.appendChild(tileDiv); } } // Use direct event handler assignment (replaces old handler if any) gridOverlay.onclick = handleGridClick; } // Handle grid clicks with event delegation function handleGridClick(e) { const tileDiv = e.target.closest('.grid-tile'); if (!tileDiv) { console.log('Click not on tile'); // Debug log return; } const col = parseInt(tileDiv.dataset.col, 10); const row = parseInt(tileDiv.dataset.row, 10); console.log('Grid click:', col, row); // Debug log const s = TileStore.active(); console.log('Active state:', s); // Debug log if (!s.url || !s.group) { console.log('No active URL or group'); // Debug log return; } TileStore.toggleTile(s.url, s.group.id, col, row); } // Update a single tile without rebuilding the entire grid function updateSingleTile(col, row, isSelected) { const tileDiv = document.querySelector(`#grid-overlay .grid-tile[data-col="${col}"][data-row="${row}"]`); if (!tileDiv) return; tileDiv.style.background = isSelected ? 'rgba(34,197,94,0.25)' : 'transparent'; tileDiv.style.borderColor = isSelected ? 'rgba(34,197,94,0.8)' : 'rgba(59,130,246,0.6)'; } // Update all tiles when switching groups (without recreating grid) function updateAllTiles(group) { const gridOverlay = document.getElementById('grid-overlay'); if (!gridOverlay) return; const tiles = gridOverlay.querySelectorAll('.grid-tile'); tiles.forEach(tileDiv => { const col = parseInt(tileDiv.dataset.col, 10); const row = parseInt(tileDiv.dataset.row, 10); const isSelected = group && group.tiles && group.tiles.some(t => t.col === col && t.row === row); tileDiv.style.background = isSelected ? 'rgba(34,197,94,0.25)' : 'transparent'; tileDiv.style.borderColor = isSelected ? 'rgba(34,197,94,0.8)' : 'rgba(59,130,246,0.6)'; }); } function pointToCell(evt, tileSize) { // Not needed with div-based grid, but keeping for compatibility return null; } // ---------- 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; // Always do a full re-render for now to fix image switching 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); } } // Partial update functions function updateImageTabs(list, activeUrl) { const tabsContainer = document.getElementById('cutout-tabs'); if (!tabsContainer) return; tabsContainer.innerHTML = 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(''); // Re-wire image tab listeners document.querySelectorAll('.img-tab').forEach(tab => { tab.addEventListener('click', () => { const idx = parseInt(tab.getAttribute('data-index'), 10) || 0; currentIndex = idx; const img = selectedImages()[idx]; if (!img) return; console.log('Switching to image:', img.url); // Debug log TileStore.ensure(img.url); TileStore.setActive(img.url); }); }); } function updateGroupTabs(rec) { const groupBar = document.getElementById('group-bar'); if (!groupBar) return; const groups = rec.groups || []; const activeId = rec.activeGroupId; groupBar.innerHTML = ` ${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>` : ''} `; // Re-wire group listeners wireUpGroupControls(); } function updateTilesList(group, imageUrl, tileSize) { const tilesContainer = document.getElementById('tiles-container'); const tilesSection = tilesContainer ? tilesContainer.parentElement : null; if (!tilesSection) return; const tiles = group?.tiles || []; if (!tiles.length) { tilesSection.innerHTML = ` <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.25rem;text-transform:uppercase;letter-spacing:0.05em;">Tiles (0)</label> <div style="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; } tilesSection.innerHTML = ` <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> `; // Re-wire tile chip listeners 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); }); }); } function wireUpControls() { const list = selectedImages(); const { url: activeUrl, rec, group } = TileStore.active(); const size = rec ? rec.tileSize : 32; // Image tab clicks document.querySelectorAll('.img-tab').forEach((tab, index) => { tab.addEventListener('click', () => { // Visual indicator for tab click tab.style.transform = 'scale(0.95)'; setTimeout(() => tab.style.transform = '', 100); const idx = parseInt(tab.getAttribute('data-index'), 10) || 0; currentIndex = idx; const img = list[idx]; if (!img) return; // Clear existing grid before switching const gridOverlay = document.getElementById('grid-overlay'); if (gridOverlay) { gridOverlay.innerHTML = ''; gridOverlay.onclick = null; } TileStore.ensure(img.url); TileStore.setActive(img.url); }); }); // Group controls wireUpGroupControls(); // 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) { // Clear grid before changing size const gridOverlay = document.getElementById('grid-overlay'); if (gridOverlay) { gridOverlay.innerHTML = ''; gridOverlay.onclick = null; } TileStore.setTileSize(activeUrl, newSize); } }); } // Create grid when image loads const imgEl = document.getElementById('sprite-sheet'); if (imgEl) { if (imgEl.complete && imgEl.naturalWidth > 0) { createGrid(size, group); } else { imgEl.onload = () => createGrid(size, group); } } // Tile chips - use event delegation on container const tilesContainer = document.getElementById('tiles-container'); if (tilesContainer) { tilesContainer.onclick = (e) => { const tileChip = e.target.closest('.tile-chip'); if (!tileChip) return; // Visual feedback tileChip.style.transform = 'scale(0.9)'; setTimeout(() => tileChip.style.transform = '', 100); const col = parseInt(tileChip.getAttribute('data-col'), 10); const row = parseInt(tileChip.getAttribute('data-row'), 10); const s = TileStore.active(); if (s.url && s.group) { TileStore.removeTile(s.url, s.group.id, col, row); } }; } } function wireUpGroupControls() { const { url: activeUrl, rec, group } = TileStore.active(); // 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); } }); } // ---------- 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); } }); })();