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