📜
cutout_copy2.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// 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); } }); })();