// 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}]}] } } }
const TileStore = (() => {
const KEY = 'tile_holders_v4';
const listeners = new Set();
let state = { activeUrl: null, images: {} };
// 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 : {};
}
}
} 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: {} };
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 })) }))
};
}
return out;
}
// Helpers
function uid() { return 'g_' + Math.random().toString(36).slice(2, 10); }
function requireImage(url) {
if (!url) return null;
if (!state.images[url]) {
// default image record with one group
const gid = uid();
state.images[url] = { tileSize: 32, 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) {
const rec = requireImage(url);
if (defaults && typeof defaults.tileSize === 'number') 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) {
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); else g.tiles.push({ col, row });
save(); notify(); return true;
}
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
function selectedImages() { return Sel.getAll(); } // [{url,name}]
function currentImage() { return selectedImages()[currentIndex] || null; }
// ---------- Render helpers ----------
function renderImageTabs(list, activeUrl) {
if (!list.length) {
return `<div style="margin-bottom:.75rem;color:#94a3b8;">No selected images. Pick some in Files.</div>`;
}
const idx = list.findIndex(i => i.url === activeUrl);
if (idx >= 0) currentIndex = idx;
return `
<div id="cutout-tabs" style="display:flex;gap:.5rem;overflow:auto;margin-bottom:.75rem;-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:.25rem;border-radius:.55rem;cursor:pointer;">
<div style="width:44px;height:44px;border-radius:.45rem;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>
`;
}
function renderGroupTabs(rec) {
if (!rec) return '';
const groups = rec.groups || [];
const activeId = rec.activeGroupId;
return `
<div id="group-bar" style="display:flex;align-items:center;gap:.5rem;margin:.75rem 0;flex-wrap:wrap;">
${groups.map(g => `
<button class="group-tab" data-id="${g.id}"
style="padding:.35rem .6rem;border-radius:.5rem;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;">
${g.name}
</button>
`).join('')}
<button id="group-add" type="button"
style="padding:.35rem .6rem;border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;">
+ New Group
</button>
${groups.length ? `
<button id="group-rename" type="button"
style="padding:.35rem .6rem;border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;">
Rename
</button>
<button id="group-remove" type="button"
style="padding:.35rem .6rem;border-radius:.5rem;border:1px solid rgba(71,85,105,0.4);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;">
Delete
</button>` : ''}
</div>
`;
}
function renderGridSize(size) {
const sizes = [16, 32, 64, 128];
return `
<div style="margin: .5rem 0 1rem 0; padding: 1rem; background: rgba(30, 41, 59, 0.6); border-radius: 0.75rem; border: 1px solid rgba(71, 85, 105, 0.4);">
<label style="display:block;color:#94a3b8;font-size:0.875rem;margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em;">Grid Size (per image)</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:0.75rem;border-radius:0.5rem;font-size:1rem;font-weight:600;cursor:pointer;">
${sizes.map(s => `<option value="${s}" ${s===size?'selected':''}>${s}×${s} pixels</option>`).join('')}
</select>
</div>
`;
}
function renderCanvas(url) {
if (!url) {
return `<div style="display:flex;align-items:center;justify-content:center;height:300px;color:#94a3b8;">No image selected.</div>`;
}
return `
<div id="cutout-canvas-wrap" style="position:relative;overflow:auto;max-height:60vh;background:#0a0f1c;border-radius:0.75rem;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>
<canvas id="hit-overlay" style="position:absolute;top:0;left:0;pointer-events:auto;cursor:crosshair;"></canvas>
</div>
</div>
<div style="margin-top:.75rem;padding:0.75rem;background:rgba(30,41,59,0.4);border-radius:0.5rem;font-size:0.875rem;color:#94a3b8;">
<strong style="color:#f1f5f9;">Tip:</strong> Click a grid cell to toggle it in the <em>active group</em>.
</div>
`;
}
function renderTilesList(group) {
const tiles = group?.tiles || [];
if (!tiles.length) {
return `<div style="margin-top:.75rem;padding:.75rem;border:1px dashed rgba(71,85,105,0.5);border-radius:.5rem;color:#94a3b8;">No tiles yet.</div>`;
}
return `
<div style="margin-top:.75rem;padding:.75rem;border:1px solid rgba(71,85,105,0.4);border-radius:.5rem;">
<div style="color:#94a3b8;margin-bottom:.35rem;">Tiles (${tiles.length})</div>
<div style="display:flex;flex-wrap:wrap;gap:.5rem;">
${tiles.map(t => `
<button class="tile-chip" data-col="${t.col}" data-row="${t.row}"
style="padding:.35rem .5rem;border-radius:.5rem;border:1px solid rgba(148,163,184,0.25);background:rgba(30,41,59,0.6);color:#e2e8f0;cursor:pointer;">
${t.col},${t.row} ✕
</button>`).join('')}
</div>
</div>
`;
}
// ---------- Drawing ----------
function redrawAll(tileSize, group) {
const img = document.getElementById('sprite-sheet');
const grid = document.getElementById('grid-overlay');
const tiles = document.getElementById('tiles-overlay');
const hit = document.getElementById('hit-overlay');
if (!img || !grid || !tiles || !hit) return;
[grid, tiles, hit].forEach(c => {
c.width = img.naturalWidth; c.height = img.naturalHeight;
c.style.width = img.width + 'px'; c.style.height = img.height + '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();
const t = tiles.getContext('2d');
t.clearRect(0,0,tiles.width,tiles.height);
if (group && group.tiles) {
t.fillStyle = 'rgba(34,197,94,0.25)';
t.strokeStyle = 'rgba(34,197,94,0.8)';
group.tiles.forEach(({col,row})=>{
t.beginPath();
t.rect(col*tileSize + 0.5, row*tileSize + 0.5, tileSize-1, tileSize-1);
t.fill(); t.stroke();
});
}
}
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: Tabs → Canvas (picture) → Groups → Grid Size → Tiles
container.innerHTML = `
${renderImageTabs(list, activeUrl)}
${renderCanvas(activeUrl)}
${renderGroupTabs(rec)}
${renderGridSize(size)}
${renderTilesList(group)}
`;
wireUpControls();
}
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 => {
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, { tileSize: size }); // inherit last size if new
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);
});
}
// Draw when image loads
const imgEl = document.getElementById('sprite-sheet');
if (imgEl) {
if (imgEl.complete) redrawAll(size, group);
else imgEl.onload = () => redrawAll(size, group);
}
// Hit overlay toggle
const hit = document.getElementById('hit-overlay');
if (hit) {
hit.addEventListener('click', (e) => {
const s = TileStore.active();
if (!s.url || !s.group) return;
const cell = pointToCell(e, s.rec.tileSize);
if (!cell) return;
TileStore.toggleTile(s.url, s.group.id, cell.col, cell.row);
});
}
// 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;">Select images in Files (they appear as tabs here), then create groups per image.</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;
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, { tileSize: 32 });
TileStore.setActive(url);
}
});
})();