<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spritesheets Cutter (Holders + Grid + Inventory)</title>
<style>
:root { --bg:#f6f6f7; --ink:#222; --bar:#333; --chip:#eee; --chipb:#ccc; --accent:#4a7; --accent2:#268; --danger:#e33; }
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif;color:var(--ink);display:flex;flex-direction:column;height:100vh;background:var(--bg); overscroll-behavior-y: none;}
header{display:flex;gap:6px;background:var(--bar);padding:10px}
header button{flex:1;padding:10px 12px;font-size:16px;color:#fff;background:#444;border:0;border-radius:10px;cursor:pointer}
header button:active{background:#555}
#content{flex:1;position:relative;overflow:hidden}
.pane{padding:12px;height:100%;overflow:auto}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.btn{padding:8px 12px;background:var(--chip);border:1px solid var(--chipb);border-radius:10px;cursor:pointer;display:inline-flex;gap:8px;align-items:center}
input[type="file"]{display:block;margin-top:8px}
.lists{display:flex;gap:16px;flex-wrap:wrap;margin-top:12px}
.list{flex:1 1 260px;background:#fff;border:1px solid #ddd;border-radius:12px;padding:10px;max-height:230px;overflow:auto}
.list h4{margin:4px 0 8px;font-size:14px;color:#555}
ul{list-style:none;padding:0;margin:0}
li{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px;border-bottom:1px solid #eee;font-size:13px}
li:last-child{border-bottom:0}
.count{font-weight:600}
.xbtn{
flex:0 0 auto; width:22px; height:22px; line-height:20px; text-align:center; border-radius:50%;
border:1px solid #ddd; background:#fff; color:#900; font-weight:700; cursor:pointer;
}
.xbtn:active{transform:translateY(1px)}
/* CUT pane */
.cutPane{padding:10px;display:flex;flex-direction:column;gap:10px;height:100%}
.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.controls input[type="number"]{width:80px;padding:6px;border:1px solid #ccc;border-radius:8px}
.stage{position:relative;flex:1;background:#eaeaea;border:1px solid #ddd;border-radius:12px;overflow:hidden;touch-action:none}
.panLayer{position:absolute;left:0;top:0;will-change:transform}
img.tile{display:block;max-width:none;height:auto;image-rendering:pixelated;background:#fff}
/* Grid overlay (inside panLayer so it pans) */
#gridOverlay{
position:absolute; left:0; top:0; width:0; height:0; pointer-events:none;
background-position: 0 0;
}
/* Selector */
#selector{position:absolute;border:2px dashed #e22;background:rgba(255,0,0,.18);resize:both;overflow:hidden;min-width:16px;min-height:16px;cursor:move;display:none;aspect-ratio:1/1;touch-action:none}
/* Holders (tabs) */
.tabs{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.tab{min-width:28px;height:28px;padding:0 8px;border:1px solid #ccc;border-radius:999px;background:#fff;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center}
.tab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.addHolder{width:28px;height:28px;border-radius:50%;padding:0;background:var(--accent2);color:#fff;border:1px solid var(--accent2);font-weight:700;display:flex;align-items:center;justify-content:center}
/* Cuts */
.cutsWrap{display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap}
.cutsRow{display:flex;gap:8px;padding:8px;overflow:auto;background:#fff;border:1px solid #ddd;border-radius:12px;max-height:160px}
.cutThumb{position:relative; display:inline-block}
.cutThumb canvas{image-rendering:pixelated;border:1px solid #ccc;border-radius:6px;background:#fafafa}
.cutThumb .del{
position:absolute; top:-8px; right:-8px; width:22px; height:22px; border-radius:50%;
background:#fff; border:1px solid #ddd; cursor:pointer; display:flex; align-items:center; justify-content:center; box-shadow:0 1px 4px rgba(0,0,0,.15);
}
.cutThumb .del::before{content:"Γ"; color:var(--danger); font-weight:800; line-height:1; font-size:16px}
/* Inventory */
.invWrap{display:flex;flex-direction:column;gap:10px}
.invRow{display:flex;gap:8px;padding:8px;overflow:auto;background:#fff;border:1px solid #ddd;border-radius:12px;max-height:200px}
.invThumb{position:relative; display:inline-block}
.invThumb canvas{image-rendering:pixelated;border:1px solid #ccc;border-radius:6px;background:#fafafa}
.invThumb .del{ position:absolute; top:-8px; right:-8px; width:22px; height:22px; border-radius:50%;
background:#fff; border:1px solid #ddd; cursor:pointer; display:flex; align-items:center; justify-content:center; box-shadow:0 1px 4px rgba(0,0,0,.15);}
.invThumb .del::before{content:"Γ"; color:var(--danger); font-weight:800; line-height:1; font-size:16px}
/* Grid Styles for Add Menu */
.gridWrap {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
flex: 1;
}
.gridControls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.gridControls input[type="number"] {
width: 80px;
padding: 6px;
border: 1px solid #ccc;
border-radius: 8px;
}
.gridContainer {
display: grid;
gap: 1px;
background: #ccc;
border: 1px solid #ddd;
border-radius: 12px;
overflow: auto;
flex: 1;
}
.gridTile {
background: #fff;
border: 1px solid #eee;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
position: relative;
}
.gridTile:hover {
background: #f0f0f0;
}
.gridTile.empty::before {
content: '';
display: block;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
45deg,
#f6f6f7,
#f6f6f7 5px,
#e8e8e8 5px,
#e8e8e8 10px
);
}
.gridTile canvas {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
}
.hint{font-size:12px;color:#666}
</style>
</head>
<body>
<header>
<button id="fileBtn">File</button>
<button id="cutBtn">Cut</button>
<button id="addBtn">Add</button>
</header>
<div id="content">
<div class="pane">
<p>File β load Images & Spritesheets (multi-select). Cut β pick a spritesheet, pan it, drag/resize the red square. Click Snapshot or double-tap / double-click the red square to add to holder and inventory. Add β build grid, add holder tiles, and save grid as PNG.</p>
</div>
</div>
<script>
/* ===================== State ===================== */
const state = {
items: new Map(), // id -> {img, type:'image'|'spritesheet', name}
activeItemId: null,
holders: [], // [{id,name,cuts:[{w,h,canvas}]}]
activeHolderId: null,
inventory: [], // all cuts globally
grid: {
enabled: false,
size: 32,
color: 'rgba(0,0,0,0.25)',
rows: 5, // Default rows
cols: 5, // Default columns
tiles: [] // Array of null or {w, h, canvas}
}
};
let idCounter = 1;
let holderCounter = 1;
let currentView = 'home'; // 'file' | 'cut' | 'add'
/* Pan state (Cut view) */
let panX = 0, panY = 0, isPanning = false, panStartX = 0, panStartY = 0, startPanX = 0, startPanY = 0;
/* Double snapshot cooldown */
let lastSnapshotAt = 0;
/* UI refs */
const content = document.getElementById('content');
document.getElementById('fileBtn').onclick = openFileMenu;
document.getElementById('cutBtn').onclick = openCutMenu;
document.getElementById('addBtn').onclick = openAddMenu;
/* Ensure at least one holder exists */
function ensureDefaultHolder(){
if (state.holders.length === 0) {
const id = `h_${holderCounter++}`;
state.holders.push({ id, name: 'Holder 1', cuts: [] });
state.activeHolderId = id;
}
}
/* ===================== FILE MENU ===================== */
function openFileMenu(){
currentView = 'file';
content.innerHTML = `
<div class="pane">
<div class="row">
<div>
<div class="btn" id="pickImagesBtn">π Pick Images (multi)</div>
<input type="file" id="imagesInput" multiple />
</div>
<div>
<div class="btn" id="pickSheetsBtn">π§© Pick Spritesheets (multi)</div>
<input type="file" id="sheetsInput" multiple />
</div>
</div>
<div class="hint" style="margin-top:6px;">Use either inputβboth allow multi-select. Pick multiple times; selections accumulate. Click Γ to remove.</div>
<div class="lists">
<div class="list">
<h4>Loaded Images <span class="count" id="imgCount">0</span></h4>
<ul id="imageList"></ul>
</div>
<div class="list">
<h4>Loaded Spritesheets <span class="count" id="sheetCount">0</span></h4>
<ul id="sheetList"></ul>
</div>
</div>
</div>
`;
const imagesInput = content.querySelector('#imagesInput');
const sheetsInput = content.querySelector('#sheetsInput');
document.getElementById('pickImagesBtn').onclick = () => imagesInput.click();
document.getElementById('pickSheetsBtn').onclick = () => sheetsInput.click();
imagesInput.addEventListener('change', e => { handleFiles(e.target.files, 'image'); e.target.value = ''; });
sheetsInput.addEventListener('change', e => { handleFiles(e.target.files, 'spritesheet'); e.target.value = ''; });
renderLists();
}
function handleFiles(fileList, type){
const files = Array.from(fileList || []);
for (const file of files) {
const id = `f_${idCounter++}`;
const img = new Image();
img.className = 'tile';
img.onload = () => {
state.items.set(id, { img, type, name: file.name || id });
if (!state.activeItemId) state.activeItemId = id;
if (currentView === 'file') renderLists();
};
img.onerror = () => console.warn('Skipped (failed to load as image):', file.name || id);
img.src = URL.createObjectURL(file) + `#${Date.now()}`; // cache-bust
}
}
function renderLists(){
const imageList = content.querySelector('#imageList');
const sheetList = content.querySelector('#sheetList');
const imgCount = content.querySelector('#imgCount');
const sheetCount= content.querySelector('#sheetCount');
if (!imageList || !sheetList) return;
imageList.innerHTML = ''; sheetList.innerHTML = '';
let iCount = 0, sCount = 0;
for (const [id, it] of state.items.entries()) {
const li = document.createElement('li');
const label = document.createElement('span');
label.textContent = `${it.name} [${it.type}]`;
li.appendChild(label);
const x = document.createElement('button');
x.className = 'xbtn'; x.title = 'Remove'; x.textContent = 'Γ';
x.onclick = () => removeItem(id);
li.appendChild(x);
if (it.type === 'image') { imageList.appendChild(li); iCount++; }
else { sheetList.appendChild(li); sCount++; }
}
imgCount.textContent = iCount;
sheetCount.textContent = sCount;
}
function removeItem(id){
const wasActive = state.activeItemId === id;
state.items.delete(id);
if (currentView === 'file') renderLists();
if (currentView === 'cut') openCutMenu();
if (wasActive) {
const first = [...state.items.keys()][0] || null;
state.activeItemId = first;
}
}
/* ===================== CUT MENU ===================== */
function openCutMenu(){
currentView = 'cut';
ensureDefaultHolder();
const hasSheets = [...state.items.values()].some(it => it.type === 'spritesheet');
content.innerHTML = `
<div class="cutPane">
<div class="controls">
<strong>Sheets:</strong>
<div id="sheetButtons" class="row"></div>
</div>
<div class="controls">
<strong>Square:</strong>
<button class="btn" id="sizeDec">β</button>
<input type="number" id="sizeInput" value="64" min="8" step="8" />
<button class="btn" id="sizeInc">+</button>
<button class="btn" id="snapshotBtn" style="margin-left:10px;">Snapshot</button>
<strong style="margin-left:10px;">Nudge:</strong>
<input type="number" id="nudgeStep" value="1" min="1" step="1" />
<button class="btn" id="nL">β</button>
<button class="btn" id="nU">β</button>
<button class="btn" id="nD">β</button>
<button class="btn" id="nR">β</button>
<label class="btn" style="margin-left:10px;">
<input type="checkbox" id="useTileStep" /> Use tile step
</label>
<input type="number" id="tileStep" value="32" min="1" step="1" title="Tile width (px)" />
<span class="hint">Arrows nudge; Shift = Γ10.</span>
</div>
<div class="stage" id="stage">
<div class="panLayer" id="panLayer">
<div id="gridOverlay"></div>
</div>
</div>
<div class="controls">
<strong>Holders:</strong>
<div id="holdersTabs" class="tabs"></div>
<button class="btn addHolder" id="addHolderBtn" title="Add Holder">+</button>
</div>
<div class="cutsWrap">
<strong>Cuts:</strong>
<div class="cutsRow" id="cutsRow"></div>
</div>
<div class="hint">Drag gray area to pan. Drag the red square to move/resize. Click Snapshot or double-tap / double-click the red square to add to holder and inventory. Click Γ to remove.</div>
</div>
`;
// Sheet buttons
const row = document.getElementById('sheetButtons');
let firstSheetId = null;
for (const [id, it] of state.items.entries()) {
if (it.type !== 'spritesheet') continue;
if (!firstSheetId) firstSheetId = id;
const b = document.createElement('button');
b.className = 'btn';
b.textContent = it.name;
b.onclick = () => setActiveItem(id);
row.appendChild(b);
}
if (!hasSheets) {
const none = document.createElement('span');
none.className = 'hint';
none.textContent = 'No spritesheets loaded yet.';
row.appendChild(none);
}
// Holder tabs
document.getElementById('addHolderBtn').onclick = addHolder;
renderHolderTabs();
// Controls
document.getElementById('sizeDec').onclick = () => changeSquareSize(-1);
document.getElementById('sizeInc').onclick = () => changeSquareSize(+1);
document.getElementById('snapshotBtn').onclick = () => guardedSnapshot();
document.getElementById('nL').onclick = () => nudge(-getStep(),0);
document.getElementById('nR').onclick = () => nudge( getStep(),0);
document.getElementById('nU').onclick = () => nudge(0,-getStep());
document.getElementById('nD').onclick = () => nudge(0, getStep());
window.onkeydown = (e) => {
if (currentView !== 'cut') return;
if (['INPUT','TEXTAREA'].includes(document.activeElement.tagName)) return;
let step = getStep(); if (e.shiftKey) step *= 10;
if (e.key === 'ArrowLeft'){e.preventDefault();nudge(-step,0)}
if (e.key === 'ArrowRight'){e.preventDefault();nudge(step,0)}
if (e.key === 'ArrowUp'){e.preventDefault();nudge(0,-step)}
if (e.key === 'ArrowDown'){e.preventDefault();nudge(0,step)}
};
// Init stage with first spritesheet if any
if (firstSheetId) setActiveItem(firstSheetId);
else initStageHandlers();
renderCutsRow();
applyGrid(); // apply current grid settings (if enabled)
}
function renderHolderTabs(){
ensureDefaultHolder();
const tabs = document.getElementById('holdersTabs');
if (!tabs) return;
tabs.innerHTML = '';
state.holders.forEach((h, idx) => {
const t = document.createElement('div');
t.className = 'tab' + (h.id === state.activeHolderId ? ' active' : '');
t.textContent = (idx + 1); // just numbers to save space
t.title = `${h.name} (${h.cuts.length} cuts)`;
t.onclick = () => { state.activeHolderId = h.id; renderHolderTabs(); renderCutsRow(); };
tabs.appendChild(t);
});
}
function addHolder(){
const id = `h_${holderCounter++}`;
const name = `Holder ${state.holders.length+1}`;
state.holders.push({ id, name, cuts: [] });
state.activeHolderId = id;
renderHolderTabs();
renderCutsRow();
}
function setActiveItem(id){
state.activeItemId = id;
panX = 0; panY = 0;
renderCutStage();
}
function renderCutStage(){
const stage = document.getElementById('stage');
const panLayer = document.getElementById('panLayer');
const grid = document.getElementById('gridOverlay');
if (!stage || !panLayer || !grid) return;
panLayer.innerHTML = ''; // reset
panLayer.appendChild(grid); // keep grid placeholder inside
const it = state.items.get(state.activeItemId);
if (!it || it.type !== 'spritesheet') { initStageHandlers(); applyGrid(); return; }
// Image (full/native)
panLayer.appendChild(it.img);
// Grid overlay sized to image
grid.style.width = it.img.naturalWidth + 'px';
grid.style.height = it.img.naturalHeight + 'px';
// Selector inside panLayer so it pans with image
let selector = document.getElementById('selector');
if (!selector) { selector = document.createElement('div'); selector.id = 'selector'; document.body.appendChild(selector); }
panLayer.appendChild(selector);
selector.style.display = 'block';
if (!selector.style.width) { selector.style.width='64px'; selector.style.height='64px'; selector.style.left='40px'; selector.style.top='40px'; }
applyPan();
initStageHandlers();
hookSelector(selector);
const sizeInput = document.getElementById('sizeInput');
if (sizeInput) sizeInput.value = parseInt(selector.style.width || 64, 10);
applyGrid(); // ensure grid is visible and using current size/color
}
/* ===================== Grid ===================== */
function applyGrid(){
const grid = document.getElementById('gridOverlay');
if (!grid) return;
if (!state.grid.enabled){
grid.style.display = 'none';
grid.style.backgroundImage = 'none';
return;
}
grid.style.display = 'block';
const s = Math.max(1, state.grid.size|0);
const col = state.grid.color;
// 1px lines every s pixels
grid.style.backgroundImage =
`linear-gradient(to right, ${col} 1px, transparent 1px),
linear-gradient(to bottom, ${col} 1px, transparent 1px)`;
grid.style.backgroundSize = `${s}px ${s}px, ${s}px ${s}px`;
grid.style.backgroundPosition = `0 0, 0 0`;
}
function setGridEnabled(v){ state.grid.enabled = !!v; applyGrid(); }
function setGridSize(px){
state.grid.size = Math.max(1, px|0);
applyGrid();
if (currentView === 'add') renderGrid(); // Update Add menu grid if open
}
/* ===================== Panning ===================== */
function applyPan(){
const panLayer = document.getElementById('panLayer');
if (panLayer) panLayer.style.transform = `translate(${panX}px,${panY}px)`;
}
function initStageHandlers(){
const stage = document.getElementById('stage');
const selector = document.getElementById('selector');
if (!stage) return;
stage.onmousedown = (e) => {
if (selector && (e.target === selector || selector.contains(e.target))) return;
isPanning = true; panStartX = e.clientX; panStartY = e.clientY; startPanX = panX; startPanY = panY;
};
window.onmousemove = (e) => { if (!isPanning) return; panX = startPanX + (e.clientX - panStartX); panY = startPanY + (e.clientY - panStartY); applyPan(); };
window.onmouseup = () => { isPanning = false; };
stage.ontouchstart = (e) => {
const t = e.touches[0]; if (!t) return;
if (selector && (e.target === selector || selector.contains(e.target))) return;
isPanning = true; panStartX = t.clientX; panStartY = t.clientY; startPanX = panX; startPanY = panY;
};
window.addEventListener('touchmove', (e) => {
if (!isPanning) return; const t = e.touches[0]; if (!t) return;
panX = startPanX + (t.clientX - panStartX); panY = startPanY + (t.clientY - panStartY); applyPan();
}, { passive:false });
window.addEventListener('touchend', () => { isPanning = false; });
}
/* ===================== Selector: drag, dblclick/double-tap, size ===================== */
function hookSelector(sel){
let dragging = false, moved = false, offX = 0, offY = 0, downX = 0, downY = 0, downTime = 0;
sel.onmousedown = (e) => {
dragging = true; moved = false; downTime = Date.now();
offX = e.clientX - sel.offsetLeft; offY = e.clientY - sel.offsetTop; downX = e.clientX; downY = e.clientY;
e.stopPropagation(); e.preventDefault();
};
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
if (Math.abs(e.clientX - downX) > 2 || Math.abs(e.clientY - downY) > 2) moved = true;
sel.style.left = (e.clientX - offX) + 'px';
sel.style.top = (e.clientY - offY) + 'px';
});
window.addEventListener('mouseup', () => { dragging = false; });
sel.ondblclick = (e) => { e.preventDefault(); e.stopPropagation(); guardedSnapshot(); };
sel.ontouchstart = (e) => {
const t = e.touches[0]; if (!t) return;
dragging = true; moved = false; downTime = Date.now();
offX = t.clientX - sel.offsetLeft; offY = t.clientY - sel.offsetTop; downX = t.clientX; downY = t.clientY;
e.stopPropagation();
};
window.addEventListener('touchmove', (e) => {
if (!dragging) return;
const t = e.touches[0]; if (!t) return;
if (Math.abs(t.clientX - downX) > 3 || Math.abs(t.clientY - downY) > 3) moved = true;
sel.style.left = (t.clientX - offX) + 'px';
sel.style.top = (t.clientY - offY) + 'px';
}, { passive:false });
window.addEventListener('touchend', (e) => {
if (!dragging) return;
const now = Date.now(); dragging = false;
if (!moved && (now - downTime) < 250) { guardedSnapshot(); }
});
}
function changeSquareSize(dir){
const input = document.getElementById('sizeInput');
const step = +input.step || 8;
const val = Math.max(+input.min || 8, (+input.value || 64) + dir*step);
input.value = val; applySquareSize();
}
function applySquareSize(){
const sel = document.getElementById('selector');
const val = +document.getElementById('sizeInput')?.value || 64;
if (sel){ sel.style.width = val+'px'; sel.style.height = val+'px'; }
}
function getStep(){
const useTile = document.getElementById('useTileStep')?.checked;
if (useTile) return +document.getElementById('tileStep')?.value || 32;
return +document.getElementById('nudgeStep')?.value || 1;
}
function nudge(dx,dy){
const sel = document.getElementById('selector'); if (!sel) return;
const x = (parseFloat(sel.style.left) || 0) + dx;
const y = (parseFloat(sel.style.top) || 0) + dy;
sel.style.left = x+'px'; sel.style.top = y+'px';
}
/* ===================== Snapshot & Holders & Inventory ===================== */
function currentHolder(){
ensureDefaultHolder();
return state.holders.find(h => h.id === state.activeHolderId);
}
function guardedSnapshot(){
const now = Date.now();
if (now - lastSnapshotAt < 400) return; // cooldown
lastSnapshotAt = now;
snapshotSelection();
}
function snapshotSelection(){
const it = state.items.get(state.activeItemId);
if (!it || it.type !== 'spritesheet') return;
const img = it.img;
const sel = document.getElementById('selector');
if (!sel) return;
const sx = Math.max(0, Math.floor(sel.offsetLeft));
const sy = Math.max(0, Math.floor(sel.offsetTop));
const sw = Math.max(1, Math.floor(sel.offsetWidth));
const sh = Math.max(1, Math.floor(sel.offsetHeight));
const maxW = Math.max(0, Math.min(sw, img.naturalWidth - sx));
const maxH = Math.max(0, Math.min(sh, img.naturalHeight - sy));
if (maxW <= 0 || maxH <= 0) return;
const c = document.createElement('canvas');
c.width = maxW; c.height = maxH;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, sx, sy, maxW, maxH, 0, 0, maxW, maxH);
const holder = currentHolder();
holder.cuts.push({ w:maxW, h:maxH, canvas:c });
state.inventory.push({ w:maxW, h:maxH, canvas: cloneCanvas(c) }); // also add to global inventory
renderHolderTabs();
renderCutsRow();
if (currentView === 'add') renderInventory(); // live update if Add view open
}
function cloneCanvas(src){
const dst = document.createElement('canvas');
dst.width = src.width; dst.height = src.height;
const dctx = dst.getContext('2d');
dctx.imageSmoothingEnabled = false;
dctx.drawImage(src, 0, 0);
return dst;
}
function createGridPNG(){
const tileSize = state.grid.size;
const cols = state.grid.cols;
const rows = state.grid.rows;
const totalTiles = rows * cols;
// Prompt for filename
let filename = prompt('Enter filename for the PNG (without extension):', `grid_snapshot_${Date.now()}`);
if (!filename) {
filename = `grid_snapshot_${Date.now()}`; // Fallback to default if canceled or empty
}
// Sanitize filename: remove invalid characters and ensure it ends with .png
filename = filename.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/.png$/, '') + '.png';
// Create a canvas for the entire grid
const canvas = document.createElement('canvas');
canvas.width = cols * tileSize;
canvas.height = rows * tileSize;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
// Fill with empty tile pattern for empty slots
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const index = row * cols + col;
const x = col * tileSize;
const y = row * tileSize;
if (state.grid.tiles[index]) {
// Draw tile image if present
const tile = state.grid.tiles[index];
ctx.drawImage(tile.canvas, 0, 0, tile.w, tile.h, x, y, tileSize, tileSize);
} else {
// Draw empty pattern (mimics .gridTile.empty::before)
ctx.fillStyle = 'repeating-linear-gradient(45deg, #f6f6f7, #f6f6f7 5px, #e8e8e8 5px, #e8e8e8 10px)';
ctx.fillRect(x, y, tileSize, tileSize);
}
}
}
// Convert canvas to Blob for better mobile compatibility
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = filename;
link.href = url;
link.click();
// Clean up the URL object after a short delay
setTimeout(() => URL.revokeObjectURL(url), 100);
}, 'image/png');
}
function renderCutsRow(){
const row = document.getElementById('cutsRow'); if (!row) return;
const holder = currentHolder();
row.innerHTML = '';
holder.cuts.forEach((cut, i) => {
const wrap = document.createElement('div');
wrap.className = 'cutThumb';
wrap.title = `${holder.name} β ${i+1} (${cut.w}Γ${cut.h})`;
wrap.appendChild(cut.canvas);
const del = document.createElement('button');
del.className = 'del'; del.title = 'Remove';
del.onclick = () => removeCut(holder.id, i);
wrap.appendChild(del);
row.appendChild(wrap);
});
}
function removeCut(holderId, index){
const h = state.holders.find(x => x.id === holderId);
if (!h) return;
h.cuts.splice(index, 1);
renderHolderTabs();
renderCutsRow();
}
/* ===================== ADD MENU (Grid + Inventory) ===================== */
function openAddMenu(){
currentView = 'add';
content.innerHTML = `
<div class="pane" style="display: flex; flex-direction: column; height: 100%;">
<h3 style="margin-top:0;">Add</h3>
<div class="list" style="max-height:none; flex: 1; display: flex; flex-direction: column;">
<h4>Grid</h4>
<div class="gridWrap" style="flex: 1; display: flex; flex-direction: column;">
<div class="gridControls">
<label class="btn"><input type="checkbox" id="gridEnable"> Show grid</label>
<span>Tile Size (px):</span>
<input type="number" id="gridSize" min="1" step="1" value="${state.grid.size}">
<span>Rows:</span>
<input type="number" id="gridRows" min="1" step="1" value="${state.grid.rows}">
<span>Cols:</span>
<input type="number" id="gridCols" min="1" step="1" value="${state.grid.cols}">
<button class="btn" id="applyGrid">Apply</button>
<button class="btn" id="snapshotGridBtn" style="margin-left:10px;">Snapshot</button>
</div>
<div class="gridControls" style="margin-top: 10px;">
<strong>Holders:</strong>
<select id="holderSelect"></select>
<button class="btn" id="addHolderToGrid">Add Holder to Grid</button>
<span class="hint">Select a holder to add its tiles to the grid. Click Snapshot to save grid as PNG.</span>
</div>
<div class="gridContainer" id="gridContainer" style="flex: 1;"></div>
</div>
</div>
<div class="invWrap">
<div class="row" style="justify-content:space-between;">
<h4 style="margin:8px 0;">My Inventory</h4>
<span class="hint">All snapshots land here too. Click Γ to remove.</span>
</div>
<div class="invRow" id="inventoryRow"></div>
</div>
</div>
`;
// Wire grid controls
const cb = document.getElementById('gridEnable');
const sz = document.getElementById('gridSize');
const rowsInput = document.getElementById('gridRows');
const colsInput = document.getElementById('gridCols');
const applyBtn = document.getElementById('applyGrid');
const snapshotBtn = document.getElementById('snapshotGridBtn');
const holderSelect = document.getElementById('holderSelect');
const addHolderBtn = document.getElementById('addHolderToGrid');
cb.checked = !!state.grid.enabled;
cb.onchange = () => setGridEnabled(cb.checked);
sz.onchange = () => setGridSize(+sz.value || 1);
rowsInput.onchange = () => { state.grid.rows = Math.max(1, +rowsInput.value || 5); renderGrid(); };
colsInput.onchange = () => { state.grid.cols = Math.max(1, +colsInput.value || 5); renderGrid(); };
applyBtn.onclick = () => renderGrid();
snapshotBtn.onclick = () => createGridPNG();
// Populate holder select
state.holders.forEach((h, idx) => {
const opt = document.createElement('option');
opt.value = h.id;
opt.textContent = `${idx + 1}: ${h.name} (${h.cuts.length} cuts)`;
holderSelect.appendChild(opt);
});
// Add holder to grid
addHolderBtn.onclick = () => {
const selectedId = holderSelect.value;
if (!selectedId) return;
const holder = state.holders.find(h => h.id === selectedId);
if (!holder || holder.cuts.length === 0) return;
let added = 0;
holder.cuts.forEach(cut => {
const emptyIndex = state.grid.tiles.findIndex(t => t === null);
if (emptyIndex !== -1) {
state.grid.tiles[emptyIndex] = { w: cut.w, h: cut.h, canvas: cloneCanvas(cut.canvas) };
added++;
}
});
renderGrid();
if (added < holder.cuts.length) {
alert(`Added ${added} tiles; not enough empty slots for all ${holder.cuts.length}.`);
}
};
renderGrid();
renderInventory();
applyGrid(); // Apply grid immediately if Cut view is open
}
function renderGrid(){
const container = document.getElementById('gridContainer');
if (!container) return;
// Reset tiles if size changed
const totalTiles = state.grid.rows * state.grid.cols;
if (state.grid.tiles.length !== totalTiles) {
state.grid.tiles = new Array(totalTiles).fill(null);
}
// Update grid container styles
container.style.gridTemplateRows = `repeat(${state.grid.rows}, ${state.grid.size}px)`;
container.style.gridTemplateColumns = `repeat(${state.grid.cols}, ${state.grid.size}px)`;
// Clear existing tiles
container.innerHTML = '';
// Create tiles
for (let i = 0; i < totalTiles; i++) {
const tile = document.createElement('div');
tile.className = 'gridTile';
tile.style.width = `${state.grid.size}px`;
tile.style.height = `${state.grid.size}px`;
tile.dataset.index = i;
if (state.grid.tiles[i]) {
const canvas = cloneCanvas(state.grid.tiles[i].canvas);
tile.appendChild(canvas);
} else {
tile.classList.add('empty');
}
tile.onclick = () => {
if (state.grid.tiles[i]) {
state.grid.tiles[i] = null;
renderGrid();
}
};
container.appendChild(tile);
}
}
function renderInventory(){
const row = document.getElementById('inventoryRow'); if (!row) return;
row.innerHTML = '';
state.inventory.forEach((cut, i) => {
const wrap = document.createElement('div');
wrap.className = 'invThumb';
wrap.title = `#${i+1} (${cut.w}Γ${cut.h})`;
wrap.appendChild(cut.canvas);
const del = document.createElement('button');
del.className = 'del'; del.title = 'Remove';
del.onclick = () => { state.inventory.splice(i,1); renderInventory(); };
wrap.appendChild(del);
row.appendChild(wrap);
});
}
</script>
</body>
</html>