Spaces:
Sleeping
Sleeping
| 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 = """ | |
| <div class="se-wrap"> | |
| <div class="se-toolbar"> | |
| <button class="se-btn" data-action="smaller" type="button">Smaller</button> | |
| <button class="se-btn" data-action="bigger" type="button">Bigger</button> | |
| <button class="se-btn" data-action="delete-selected" type="button">Delete selected</button> | |
| <button class="se-btn" data-action="clear-selection" type="button">Clear selection</button> | |
| <button class="se-btn" data-action="clear-scene" type="button">Clear scene</button> | |
| </div> | |
| <div class="se-scene"></div> | |
| <div class="se-help"> | |
| Add selected asset view from the main panel. Tap/click a sprite to select it. Drag with finger or mouse. | |
| </div> | |
| </div> | |
| """ | |
| 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); | |
| """ |