📜
cutout_copy5.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// cutout.js — Image tabs (thumbnails) → Canvas → Groups → Grid Size → Tiles // - Enhanced with advanced grid settings: offset, spacing, custom pixel size // - Reads SelectionStore from Files (read-only) // - For each image URL: tileSize + gridSettings + groups[{id,name,tiles}] + activeGroupId // - Click grid toggles tile in ACTIVE group // - Persists in localStorage (v5 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 (v5): per-image groups + advanced grid settings ---------- // state: { activeUrl, images: { [url]: { tileSize:number, gridSettings:{offsetX,offsetY,spacingX,spacingY,customSize}, activeGroupId:string, groups:[{id,name,tiles:[{col,row,spriteId,spriteNumber}]}] } }, nextSpriteId: number } const TileStore = (() => { const KEY = 'tile_holders_v5'; 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, gridSettings: { ...(im.gridSettings || {}) }, 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; const lowerName = folderName.toLowerCase(); 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]); if ([8, 16, 24, 32, 48, 64, 96, 128].includes(size)) { return size; } } } return 32; } function requireImage(url, folderName) { if (!url) return null; if (!state.images[url]) { const detectedSize = detectTileSizeFromFolder(folderName); const gid = uid(); state.images[url] = { tileSize: detectedSize, gridSettings: { offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0, customSize: null }, 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; } // Ensure gridSettings exist if (!state.images[url].gridSettings) { state.images[url].gridSettings = { offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0, customSize: null }; } 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); 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 setGridSettings(url, settings) { const rec = requireImage(url); rec.gridSettings = { ...rec.gridSettings, ...settings }; 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 { const img = document.getElementById('sprite-sheet'); const imageWidth = img ? img.naturalWidth : 1000; 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; } 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, setGridSettings, setActiveGroup, addGroup, renameGroup, removeGroup, toggleTile, removeTile, subscribe }; })(); // ---------- Local UI state ---------- let currentIndex = 0; let currentZoom = 1; function selectedImages() { return Sel.getAll(); } function currentImage() { return selectedImages()[currentIndex] || null; } // ---------- Thumbnail generation ---------- function generateTileThumbnail(imageUrl, col, row, tileSize, gridSettings, callback) { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const thumbSize = 20; canvas.width = thumbSize; canvas.height = thumbSize; // Calculate actual tile position with grid settings const actualTileSize = gridSettings.customSize || tileSize; const srcX = gridSettings.offsetX + col * (actualTileSize + gridSettings.spacingX); const srcY = gridSettings.offsetY + row * (actualTileSize + gridSettings.spacingY); const srcW = Math.min(actualTileSize, img.naturalWidth - srcX); const srcH = Math.min(actualTileSize, img.naturalHeight - srcY); if (srcW > 0 && srcH > 0) { ctx.imageSmoothingEnabled = false; 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, gridSettings) { const sizes = [8, 16, 32, 64, 128]; const zooms = [0.25, 0.5, 1, 1.5, 2, 3, 4]; const settings = gridSettings || { offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0, customSize: null }; 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;align-items:end;"> <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 style="position:relative;"> <button id="grid-settings-btn" type="button" title="Advanced Grid Settings" style="padding:.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.875rem;height:42px;"> ⚙️ </button> <div id="grid-settings-menu" style="display:none;position:absolute;top:100%;right:0;z-index:1000;margin-top:.25rem;background:rgba(15,23,42,0.95);border:1px solid rgba(71,85,105,0.4);border-radius:.5rem;padding:.75rem;min-width:280px;box-shadow:0 10px 15px -3px rgba(0,0,0,0.3);"> <div style="margin-bottom:.75rem;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.375rem;">Offset X (px)</label> <input id="offset-x" type="number" value="${settings.offsetX}" min="0" max="1000" step="1" style="width:100%;background:rgba(30,41,59,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.375rem;border-radius:.375rem;font-size:0.875rem;"> </div> <div style="margin-bottom:.75rem;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.375rem;">Offset Y (px)</label> <input id="offset-y" type="number" value="${settings.offsetY}" min="0" max="1000" step="1" style="width:100%;background:rgba(30,41,59,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.375rem;border-radius:.375rem;font-size:0.875rem;"> </div> <div style="margin-bottom:.75rem;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.375rem;">Spacing X (px)</label> <input id="spacing-x" type="number" value="${settings.spacingX}" min="0" max="100" step="1" style="width:100%;background:rgba(30,41,59,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.375rem;border-radius:.375rem;font-size:0.875rem;"> </div> <div style="margin-bottom:.75rem;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.375rem;">Spacing Y (px)</label> <input id="spacing-y" type="number" value="${settings.spacingY}" min="0" max="100" step="1" style="width:100%;background:rgba(30,41,59,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.375rem;border-radius:.375rem;font-size:0.875rem;"> </div> <div style="margin-bottom:.75rem;"> <label style="display:block;color:#94a3b8;font-size:0.75rem;margin-bottom:.375rem;">Custom Size (px) - Leave empty to use Grid Size</label> <input id="custom-size" type="number" value="${settings.customSize || ''}" min="1" max="512" step="1" placeholder="Use grid size" style="width:100%;background:rgba(30,41,59,0.8);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;padding:.375rem;border-radius:.375rem;font-size:0.875rem;"> </div> <div style="display:flex;gap:.5rem;"> <button id="apply-settings" type="button" style="flex:1;padding:.5rem;border-radius:.375rem;border:1px solid rgba(34,197,94,0.6);background:rgba(34,197,94,0.15);color:#86efac;cursor:pointer;font-size:0.875rem;"> Apply </button> <button id="reset-settings" type="button" style="flex:1;padding:.5rem;border-radius:.375rem;border:1px solid rgba(239,68,68,0.6);background:rgba(239,68,68,0.15);color:#fca5a5;cursor:pointer;font-size:0.875rem;"> Reset </button> </div> </div> </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, gridSettings) { 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, gridSettings) { 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, gridSettings, (dataUrl) => { if (dataUrl) { thumbEl.style.backgroundImage = `url(${dataUrl})`; thumbEl.style.backgroundSize = 'cover'; thumbEl.style.backgroundPosition = 'center'; thumbEl.textContent = ''; } else { thumbEl.textContent = '?'; } }); }); } // ---------- Update tiles list only ---------- function updateTilesList() { const { url: activeUrl, rec, group } = TileStore.active(); if (!activeUrl || !rec || !group) return; const tilesContainer = document.getElementById('tiles-container'); if (!tilesContainer) return; const tiles = group.tiles || []; const tileSize = rec.tileSize; const gridSettings = rec.gridSettings || {}; // Update the tiles list content const tilesSection = tilesContainer.closest('div'); if (tilesSection) { const label = tilesSection.querySelector('label'); if (label) { label.textContent = `Tiles (${tiles.length})`; } tilesContainer.innerHTML = 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(''); // Regenerate thumbnails generateTileThumbnails(activeUrl, tiles, tileSize, gridSettings); // Re-wire remove tile events tilesContainer.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); }); }); } } // ---------- Drawing ---------- function createTileDivs(tileSize, gridSettings, 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 naturalWidth = img.naturalWidth; const naturalHeight = img.naturalHeight; // Use custom size if specified, otherwise use tileSize const actualTileSize = gridSettings.customSize || tileSize; const offsetX = gridSettings.offsetX || 0; const offsetY = gridSettings.offsetY || 0; const spacingX = gridSettings.spacingX || 0; const spacingY = gridSettings.spacingY || 0; // Calculate how many tiles fit const maxCols = Math.floor((naturalWidth - offsetX) / (actualTileSize + spacingX)); const maxRows = Math.floor((naturalHeight - offsetY) / (actualTileSize + spacingY)); // Scale factors for display const scaleX = imgWidth / naturalWidth; const scaleY = imgHeight / naturalHeight; // Create a div for each tile position for (let row = 0; row < maxRows; row++) { for (let col = 0; col < maxCols; col++) { const tileDiv = document.createElement('div'); tileDiv.className = 'tile-div'; tileDiv.dataset.col = col; tileDiv.dataset.row = row; // Calculate position in natural coordinates const naturalX = offsetX + col * (actualTileSize + spacingX); const naturalY = offsetY + row * (actualTileSize + spacingY); // Convert to display coordinates const displayX = naturalX * scaleX; const displayY = naturalY * scaleY; const displayWidth = actualTileSize * scaleX; const displayHeight = actualTileSize * scaleY; // 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: ${displayX}px; top: ${displayY}px; width: ${displayWidth}px; height: ${displayHeight}px; border: 1px solid transparent; cursor: crosshair; ${isSelected ? 'background: rgba(34,197,94,0.25); border-color: rgba(34,197,94,0.8);' : ''} `; 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) { const newIsSelected = TileStore.toggleTile(s.url, s.group.id, col, row, true); 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'; } updateTilesList(); } }); container.appendChild(tileDiv); } } } function redrawAll(tileSize, gridSettings, 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'; }); // Draw grid with advanced settings 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(); const actualTileSize = gridSettings.customSize || tileSize; const offsetX = gridSettings.offsetX || 0; const offsetY = gridSettings.offsetY || 0; const spacingX = gridSettings.spacingX || 0; const spacingY = gridSettings.spacingY || 0; // Draw vertical lines for (let col = 0; ; col++) { const x = offsetX + col * (actualTileSize + spacingX); if (x > grid.width) break; g.moveTo(x, 0); g.lineTo(x, grid.height); // Draw right edge of each tile const rightX = x + actualTileSize; if (rightX <= grid.width) { g.moveTo(rightX, 0); g.lineTo(rightX, grid.height); } } // Draw horizontal lines for (let row = 0; ; row++) { const y = offsetY + row * (actualTileSize + spacingY); if (y > grid.height) break; g.moveTo(0, y); g.lineTo(grid.width, y); // Draw bottom edge of each tile const bottomY = y + actualTileSize; if (bottomY <= grid.height) { g.moveTo(0, bottomY); g.lineTo(grid.width, bottomY); } } 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, gridSettings, group); } // ---------- 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; const gridSettings = rec ? rec.gridSettings : { offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0, customSize: null }; container.innerHTML = ` ${renderGridSize(size, gridSettings)} ${renderImageTabs(list, activeUrl)} ${renderGroupTabs(rec)} ${renderTilesList(group, activeUrl, size, gridSettings)} ${renderCanvas(activeUrl)} `; wireUpControls(); if (activeUrl && group && group.tiles) { generateTileThumbnails(activeUrl, group.tiles, size, gridSettings); } } function wireUpControls() { const list = selectedImages(); const { url: activeUrl, rec, group } = TileStore.active(); const size = rec ? rec.tileSize : 32; const gridSettings = rec ? rec.gridSettings : { offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0, customSize: null }; // Image tab clicks 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); 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) { zoomSel.value = currentZoom; zoomSel.addEventListener('change', () => { currentZoom = parseFloat(zoomSel.value) || 1; applyZoom(currentZoom); }); if (currentZoom !== 1) { setTimeout(() => applyZoom(currentZoom), 0); } } // Grid settings menu const settingsBtn = document.getElementById('grid-settings-btn'); const settingsMenu = document.getElementById('grid-settings-menu'); if (settingsBtn && settingsMenu) { settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = settingsMenu.style.display !== 'none'; settingsMenu.style.display = isOpen ? 'none' : 'block'; }); // Close menu when clicking outside document.addEventListener('click', (e) => { if (!settingsMenu.contains(e.target) && e.target !== settingsBtn) { settingsMenu.style.display = 'none'; } }); // Apply settings button const applyBtn = document.getElementById('apply-settings'); if (applyBtn) { applyBtn.addEventListener('click', () => { if (!activeUrl) return; const offsetX = parseInt(document.getElementById('offset-x').value) || 0; const offsetY = parseInt(document.getElementById('offset-y').value) || 0; const spacingX = parseInt(document.getElementById('spacing-x').value) || 0; const spacingY = parseInt(document.getElementById('spacing-y').value) || 0; const customSizeInput = document.getElementById('custom-size').value; const customSize = customSizeInput ? parseInt(customSizeInput) || null : null; TileStore.setGridSettings(activeUrl, { offsetX, offsetY, spacingX, spacingY, customSize }); settingsMenu.style.display = 'none'; }); } // Reset settings button const resetBtn = document.getElementById('reset-settings'); if (resetBtn) { resetBtn.addEventListener('click', () => { if (!activeUrl) return; TileStore.setGridSettings(activeUrl, { offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0, customSize: null }); settingsMenu.style.display = 'none'; }); } } 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; img.style.width = displayWidth + 'px'; img.style.height = displayHeight + 'px'; [gridCanvas, tilesCanvas].forEach(canvas => { canvas.style.width = displayWidth + 'px'; canvas.style.height = displayHeight + 'px'; }); const { url: activeUrl, rec, group } = TileStore.active(); if (rec) { createTileDivs(rec.tileSize, rec.gridSettings, group); } } } // Draw when image loads const imgEl = document.getElementById('sprite-sheet'); if (imgEl) { if (imgEl.complete) redrawAll(size, gridSettings, group); else imgEl.onload = () => redrawAll(size, gridSettings, 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 || ''; 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); TileStore.setActive(url); } }); })();