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