🌐
tileMaker_copy.html
← Back
πŸ“ Html ⚑ Executable Ctrl+S: Save β€’ Ctrl+R: Run β€’ Ctrl+F: Find
<!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>