import json import os import time from urllib.parse import quote EXPORT_TEXTBOX_ID = "scene_png_data" def file_url(path: str) -> str: abs_path = os.path.abspath(path) return f"/gradio_api/file={quote(abs_path, safe='/')}" def build_payload(files, mode="append"): items = [] for path in (files or []): if not path: continue items.append( { "name": os.path.basename(path), "url": file_url(path), } ) return json.dumps( { "render_id": time.time(), "mode": mode, # "append" or "reset" "items": items, }, ensure_ascii=False, ) def payload_append(files): return build_payload(files, mode="append") def payload_reset(files): return build_payload(files, mode="reset") def empty_canvas_payload(): return build_payload([], mode="reset") HTML_TEMPLATE = """
Add selected asset view from the main panel. Tap/click a sprite to select it. Drag with finger or mouse.
""" CSS_TEMPLATE = """ .se-wrap { width: 100%; } .se-toolbar { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; } .se-btn { border: 1px solid #4b5563; background: #111827; color: #f3f4f6; border-radius: 8px; padding: 8px 12px; cursor: pointer; font-size: 14px; } .se-btn:hover { background: #1f2937; } .se-scene { position: relative; width: 100%; aspect-ratio: 16 / 9; border: 1px solid #374151; border-radius: 12px; overflow: hidden; touch-action: none; background: linear-gradient(45deg, #1f2937 25%, transparent 25%), linear-gradient(-45deg, #1f2937 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #1f2937 75%), linear-gradient(-45deg, transparent 75%, #1f2937 75%); background-size: 28px 28px; background-position: 0 0, 0 14px, 14px -14px, -14px 0px; } .se-help { margin-top: 8px; font-size: 13px; color: #9ca3af; } .se-sprite { position: absolute; max-width: none; user-select: none; -webkit-user-drag: none; touch-action: none; cursor: grab; border: 2px solid transparent; border-radius: 8px; box-sizing: border-box; display: block; } .se-sprite.selected { border-color: #60a5fa; } .se-sprite.dragging { cursor: grabbing; } """ JS_ON_LOAD = rf""" const scene = element.querySelector(".se-scene"); const btnSmaller = element.querySelector('[data-action="smaller"]'); const btnBigger = element.querySelector('[data-action="bigger"]'); const btnDelete = element.querySelector('[data-action="delete-selected"]'); const btnClear = element.querySelector('[data-action="clear-selection"]'); const btnClearScene = element.querySelector('[data-action="clear-scene"]'); let lastRenderId = null; let selected = null; let spriteCounter = 0; function getExportInput() {{ return document.querySelector("#{EXPORT_TEXTBOX_ID} textarea, #{EXPORT_TEXTBOX_ID} input"); }} function setExportValue(value) {{ const target = getExportInput(); if (!target) return; if (target.value === value) return; target.value = value; target.dispatchEvent(new Event("input", {{ bubbles: true }})); target.dispatchEvent(new Event("change", {{ bubbles: true }})); }} function exportSceneToDataUrl() {{ const sprites = Array.from(scene.querySelectorAll(".se-sprite")); if (!sprites.length) return ""; const scale = 2; const width = Math.max(1, Math.round(scene.clientWidth)); const height = Math.max(1, Math.round(scene.clientHeight)); const canvas = document.createElement("canvas"); canvas.width = width * scale; canvas.height = height * scale; const ctx = canvas.getContext("2d"); ctx.scale(scale, scale); ctx.clearRect(0, 0, width, height); const orderedSprites = sprites.slice().sort((a, b) => {{ const za = parseInt(a.style.zIndex || "0", 10); const zb = parseInt(b.style.zIndex || "0", 10); return za - zb; }}); orderedSprites.forEach((node) => {{ const left = parseFloat(node.style.left || "0"); const top = parseFloat(node.style.top || "0"); const widthPx = parseFloat(node.style.width || "100"); let ratio = 1.0; if (node.naturalWidth && node.naturalHeight) {{ ratio = node.naturalHeight / node.naturalWidth; }} else {{ const rect = node.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) {{ ratio = rect.height / rect.width; }} }} const heightPx = widthPx * ratio; ctx.drawImage(node, left, top, widthPx, heightPx); }}); return canvas.toDataURL("image/png"); }} function syncExport() {{ const dataUrl = exportSceneToDataUrl(); setExportValue(dataUrl); }} function clearSelection() {{ if (selected) selected.classList.remove("selected"); selected = null; }} function selectSprite(node) {{ clearSelection(); selected = node; selected.classList.add("selected"); }} function clampWidth(newWidth, sceneWidth) {{ const minW = 40; const maxW = Math.max(minW, sceneWidth * 1.5); return Math.max(minW, Math.min(maxW, newWidth)); }} function resizeSelected(multiplier) {{ if (!selected) return; const sceneRect = scene.getBoundingClientRect(); const currentWidth = selected.getBoundingClientRect().width; const newWidth = clampWidth(currentWidth * multiplier, sceneRect.width); selected.style.width = `${{newWidth}}px`; syncExport(); }} function deleteSelected() {{ if (!selected) return; const node = selected; clearSelection(); node.remove(); syncExport(); }} function makeDraggable(node) {{ let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; let dragging = false; node.addEventListener("pointerdown", (e) => {{ e.preventDefault(); e.stopPropagation(); selectSprite(node); dragging = true; node.classList.add("dragging"); node.style.zIndex = String(++spriteCounter); node.setPointerCapture(e.pointerId); startX = e.clientX; startY = e.clientY; startLeft = parseFloat(node.style.left || "0"); startTop = parseFloat(node.style.top || "0"); }}); node.addEventListener("pointermove", (e) => {{ if (!dragging) return; e.preventDefault(); const dx = e.clientX - startX; const dy = e.clientY - startY; node.style.left = `${{startLeft + dx}}px`; node.style.top = `${{startTop + dy}}px`; }}); function endDrag(e) {{ if (!dragging) return; dragging = false; node.classList.remove("dragging"); try {{ node.releasePointerCapture(e.pointerId); }} catch (_) {{}} syncExport(); }} node.addEventListener("pointerup", endDrag); node.addEventListener("pointercancel", endDrag); }} function createSprite(item, indexOffset = 0) {{ const sceneRect = scene.getBoundingClientRect(); const baseW = Math.max(140, sceneRect.width * 0.28); const img = document.createElement("img"); img.className = "se-sprite"; img.alt = item.name || `sprite-${{spriteCounter + 1}}`; img.dataset.assetUrl = item.url; img.dataset.assetName = item.name || ""; img.style.left = `${{30 + indexOffset * 24}}px`; img.style.top = `${{30 + indexOffset * 24}}px`; img.style.width = `${{baseW}}px`; img.style.zIndex = String(++spriteCounter); img.addEventListener("click", (e) => {{ e.stopPropagation(); selectSprite(img); }}); img.addEventListener("load", () => {{ syncExport(); }}); img.addEventListener("error", () => {{ console.error("Failed to load sprite:", item.url); }}); img.src = item.url; makeDraggable(img); scene.appendChild(img); }} function render() {{ const raw = props.value || '{{"render_id": null, "mode": "append", "items": []}}'; let payload; try {{ payload = JSON.parse(raw); }} catch (e) {{ payload = {{ render_id: null, mode: "append", items: [] }}; }} const renderId = payload.render_id; const mode = payload.mode || "append"; const items = Array.isArray(payload.items) ? payload.items : []; if (renderId === lastRenderId) return; lastRenderId = renderId; if (mode === "reset") {{ scene.innerHTML = ""; clearSelection(); setExportValue(""); }} const existingUrls = new Set( Array.from(scene.querySelectorAll(".se-sprite")).map((node) => node.dataset.assetUrl) ); items.forEach((item, i) => {{ if (!item.url) return; if (mode === "append" && existingUrls.has(item.url)) {{ return; }} createSprite(item, i + scene.querySelectorAll(".se-sprite").length); }}); syncExport(); }} scene.addEventListener("click", () => {{ clearSelection(); }}); if (btnSmaller) {{ btnSmaller.addEventListener("click", () => {{ resizeSelected(0.85); }}); }} if (btnBigger) {{ btnBigger.addEventListener("click", () => {{ resizeSelected(1.15); }}); }} if (btnDelete) {{ btnDelete.addEventListener("click", () => {{ deleteSelected(); }}); }} if (btnClear) {{ btnClear.addEventListener("click", () => {{ clearSelection(); }}); }} if (btnClearScene) {{ btnClearScene.addEventListener("click", () => {{ scene.innerHTML = ""; clearSelection(); setExportValue(""); }}); }} render(); setInterval(render, 200); """