// 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);
}
});
})();