"use strict"; // UI Image Set editor (uiimageset.xml): browse imagesets, see every image as a box on its texture, // move/resize boxes, draw new ones, add/duplicate/delete images and imagesets, then save the XML // into the pack or as a file. Lines that weren't edited are written back exactly as they were. const IMS = { doc: null, // { eol, head: [lines], tail: [lines], sets: [set] } source: null, // { kind: "pack", sessionId, entry, file } | { kind: "file", file } texSource: null, // where textures come from (see setTexSource) dirty: false, set: -1, img: -1, undo: [], redo: [], zoom: 1, panX: 20, panY: 20, showAll: true, draw: false, tex: new Map(), // file → Promise<{ bitmap, w, h, source } | { error }> }; // ---------- XML model ---------- const parseAttrs = (s) => [...s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)].map((m) => [m[1], m[2]]); const attr = (o, k) => o.attrs.find((a) => a[0] === k)?.[1] ?? ""; const num = (o, k) => Number(attr(o, k)) || 0; function setAttr(o, k, v) { const a = o.attrs.find((x) => x[0] === k); if (a) a[1] = String(v); else o.attrs.push([k, String(v)]); o.raw = null; // this line is written again } // Lines keep their own line break: the file mixes CRLF with bare CR in places, and untouched lines // must come back byte for byte. `raw` = line text + its break; edited lines get the file's usual one. function splitLines(text) { const out = []; const re = /([^\r\n]*)(\r\n|\r|\n|$)/g; let m; while ((m = re.exec(text)) && m.index < text.length) { out.push({ text: m[1], raw: m[0] }); if (!m[2]) break; } return out; } function parseDoc(text) { const crlf = (text.match(/\r\n/g) || []).length; const eol = crlf || !text.includes("\n") ? "\r\n" : "\n"; const doc = { eol, head: [], tail: [], sets: [] }; let cur = null; let pending = []; // lines between two imagesets (blank lines): they stay after the previous one for (const { text: line, raw } of splitLines(text)) { let m; if ((m = /^\s*\s*$/.exec(line))) { if (doc.sets.length) doc.sets[doc.sets.length - 1].after = pending; pending = []; const set = { attrs: parseAttrs(m[1]), raw, images: [], rawClose: null, selfClosing: m[2] === "/", after: [] }; doc.sets.push(set); cur = set.selfClosing ? null : set; } else if (cur && (m = /^\s*\s*$/.exec(line))) { cur.images.push({ attrs: parseAttrs(m[1]), raw }); } else if (cur && /^\s*<\/Imageset>\s*$/.test(line)) { cur.rawClose = raw; cur = null; } else if (cur) { cur.images.push({ keep: true, raw }); // blank line etc. inside a set, kept as is } else if (!doc.sets.length) doc.head.push(raw); else pending.push(raw); } doc.tail = pending; // whatever follows the last imageset () if (!doc.head.length) doc.head = [`${eol}`, `${eol}`]; if (!doc.tail.some((l) => /<\/ImagesetLayout>/.test(l))) doc.tail.unshift(`${eol}`); return doc; } const attrText = (attrs) => attrs.map(([k, v]) => `${k}="${v}"`).join(" "); function serialize(doc) { const e = doc.eol; const out = [...doc.head]; // a line before a generated one must end with a break const push = (s) => { const last = out.length - 1; if (last >= 0 && !/[\r\n]$/.test(out[last])) out[last] += e; out.push(s); }; for (const s of doc.sets) { const selfClose = s.selfClosing && !s.images.length; push(s.raw ?? ` ${e}`); if (!selfClose) { for (const i of s.images) push(i.raw ?? ` ${e}`); push(s.rawClose ?? ` ${e}`); } for (const a of s.after || []) push(a); } for (const t of doc.tail) push(t); return out.join(""); } const images = (set) => set.images.filter((i) => !i.keep); const curSet = () => IMS.doc?.sets[IMS.set] || null; const curImg = () => (curSet() ? images(curSet())[IMS.img] || null : null); const rectOf = (i) => ({ x: num(i, "X"), y: num(i, "Y"), w: num(i, "Width"), h: num(i, "Height") }); const clone = (o) => JSON.parse(JSON.stringify(o)); const validName = (s) => /^[\x21-\x7e]+$/.test(s) && !/["<>&]/.test(s); // ---------- undo ---------- function snapshot() { IMS.undo.push({ sets: IMS.doc.sets.map((s) => s), setCopy: IMS.set >= 0 ? { i: IMS.set, data: clone(curSet()) } : null, set: IMS.set, img: IMS.img }); if (IMS.undo.length > 200) IMS.undo.shift(); IMS.redo = []; markDirty(); } function restore(from, to) { const s = from.pop(); if (!s) return; to.push({ sets: IMS.doc.sets.map((x) => x), setCopy: s.setCopy ? { i: s.setCopy.i, data: clone(IMS.doc.sets[s.setCopy.i] || s.setCopy.data) } : null, set: IMS.set, img: IMS.img }); IMS.doc.sets = s.sets.slice(); if (s.setCopy) IMS.doc.sets[s.setCopy.i] = clone(s.setCopy.data); IMS.set = s.set; IMS.img = s.img; markDirty(); renderAll(); } const undo = () => restore(IMS.undo, IMS.redo); const redo = () => restore(IMS.redo, IMS.undo); function markDirty() { IMS.dirty = true; $("ims-dirty").hidden = false; } // ---------- loading ---------- async function imsSessions() { const list = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); const sel = $("ims-session"); sel.replaceChildren(...(list.length ? list.map((s) => el("option", { value: s.id, text: s.name, title: s.file })) : [el("option", { value: "", text: "Open rs.iop in IOP Manager first" })])); const rs = list.find((s) => s.name.toLowerCase() === "rs.iop"); if (rs) sel.value = rs.id; sel.disabled = !list.length; $("ims-load").disabled = !list.length; return list; } async function confirmDiscard() { if (!IMS.dirty) return true; return confirmBox("Unsaved changes", "The current imageset file has changes that aren't saved. Discard them?", "Discard", "danger"); } // ---------- texture source ---------- // session = the packs next to a file open in IOP Manager; packs = a folder of .iop files; // dir = a folder of extracted files (e.g. map.iop extracted) async function setTexSource(src) { try { const d = await call("ims:describeSource", src); IMS.texSource = { ...src, label: d.label }; } catch (err) { return toast(err.message, true); } $("ims-texsrc-label").textContent = IMS.texSource.kind === "dir" ? "Extracted folder" : IMS.texSource.kind === "packs" ? "Pack folder" : "Packs next to " + IMS.texSource.name; $("ims-texsrc").title = `Textures: ${IMS.texSource.label}`; IMS.tex.clear(); if (curSet()) { await loadTexture(); drawCanvas(); drawCrop(); } } /** Asks where the textures are; resolves true when a source was set. */ async function chooseTexSource() { const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); const pick = (value) => (choice = value); let choice = null; const options = [ ...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), { value: "packs", title: "A folder with .iop packs…", sub: "map.iop, map2.iop, sakura.iop… — passwords found from the profiles" }, { value: "dir", title: "A folder of extracted files…", sub: "e.g. map.iop extracted: resource/texture/*.dds" }, ]; const list = el( "div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [ el("input", { type: "radio", name: "texsrc", checked: i === 0, onchange: () => pick(o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), ]), ), ); choice = options[0].value; const ok = await modal("Where are the textures?", ["The imageset XML only names its textures (ui_icon_event1.dds). Pick where to read them from so the boxes match the right pictures.", list], [ { label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }, ]); if (!ok) return false; let src = choice; if (choice === "packs" || choice === "dir") { const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); if (!folder) return false; src = { kind: choice, folder }; } await setTexSource(src); return true; } $("ims-texsrc").onclick = () => chooseTexSource(); async function loadDoc(result) { IMS.doc = parseDoc(result.text); IMS.source = result.source; IMS.dirty = false; IMS.undo = []; IMS.redo = []; IMS.set = -1; IMS.img = -1; IMS.tex.clear(); $("ims-dirty").hidden = true; $("ims-title").textContent = result.source.kind === "pack" ? `${result.source.file.split(/[\\/]/).pop()} › ${result.source.entry}` : result.source.file; $("ims-empty").hidden = true; renderAll(); if (IMS.doc.sets.length) selectSet(0); const n = IMS.doc.sets.reduce((a, s) => a + images(s).length, 0); toast(`${IMS.doc.sets.length.toLocaleString()} imagesets, ${n.toLocaleString()} images.`); } $("ims-load").onclick = async () => { if (!(await confirmDiscard())) return; const id = Number($("ims-session").value); try { const entry = $("ims-entry").value.trim() || "xml/uiimageset.xml"; const r = await call("ims:loadPack", id, entry); // Textures default to the packs next to this one (can be changed with the Textures button) const s = (await call("iop:sessions")).find((x) => x.id === id); await setTexSource({ kind: "session", sessionId: id, name: s?.name || "pack" }); await loadDoc(r); } catch (err) { toast(err.message, true); } }; $("ims-open-file").onclick = async () => { if (!(await confirmDiscard())) return; const file = await call("dialog:openXml"); if (!file) return; try { const r = await call("ims:loadFile", file); // A loose XML says nothing about where its textures are: ask if (!(await chooseTexSource())) toast("No texture source: the boxes show without pictures. Use the Textures button to pick one.", true); await loadDoc(r); } catch (err) { toast(err.message, true); } }; // ---------- saving ---------- async function saveToPack() { if (IMS.source?.kind !== "pack") return toast("This XML came from a file. Use “Save as file…”, or load it from a pack.", true); try { await call("ims:savePack", IMS.source.sessionId, IMS.source.entry, serialize(IMS.doc)); IMS.dirty = false; $("ims-dirty").hidden = true; toast(`Saved into ${IMS.source.file.split(/[\\/]/).pop()} (${IMS.source.entry}).`); } catch (err) { toast(err.message, true); } } async function saveToFile() { const name = (IMS.source?.entry || IMS.source?.file || "uiimageset.xml").split(/[\\/]/).pop(); const file = await call("dialog:saveXml", name); if (!file) return; try { await call("ims:saveFile", file, serialize(IMS.doc)); if (IMS.source?.kind === "file") { IMS.source.file = file; IMS.dirty = false; $("ims-dirty").hidden = true; } toast(`Saved ${file}.`); } catch (err) { toast(err.message, true); } } $("ims-save").onclick = (e) => showMenu( [ { label: "Save into the pack", icon: "box", disabled: IMS.source?.kind !== "pack", hint: "Ctrl+S", run: saveToPack }, { label: "Save as file…", icon: "download", disabled: !IMS.doc, run: saveToFile }, "-", { label: "Export this image as PNG…", icon: "external", disabled: !curImg(), run: exportPng }, { label: "Reload textures", icon: "refresh", disabled: !IMS.texSource, run: async () => { await call("ims:reloadTextures", IMS.texSource); IMS.tex.clear(); await loadTexture(); drawCanvas(); drawCrop(); }, }, ], { anchor: e.currentTarget }, ); $("ims-undo").onclick = undo; $("ims-redo").onclick = redo; // ---------- imageset list ---------- function renderSets() { const q = $("ims-search").value.trim().toLowerCase(); const list = $("ims-sets"); if (!IMS.doc) return list.replaceChildren(); const frag = document.createDocumentFragment(); IMS.doc.sets.forEach((s, i) => { const name = attr(s, "Name"); const file = attr(s, "File"); if (q && !name.toLowerCase().includes(q) && !file.toLowerCase().includes(q)) return; frag.append( el("div", { class: `ims-set${i === IMS.set ? " on" : ""}`, "data-i": i, onclick: () => selectSet(i) }, [ el("b", { text: name || "(no name)" }), el("span", { text: `${file} · ${images(s).length}` }), ]), ); }); list.replaceChildren(frag); $("ims-set-count").textContent = `${IMS.doc.sets.length.toLocaleString()}`; } $("ims-search").addEventListener("input", renderSets); async function selectSet(i, { keepView = false } = {}) { IMS.set = i; IMS.img = images(curSet()).length ? 0 : -1; for (const n of $("ims-sets").children) n.classList.toggle("on", Number(n.dataset.i) === i); renderSide(); await loadTexture(); if (!keepView) fitView(); drawCanvas(); } function selectImg(i) { IMS.img = i; renderImages(); renderImgForm(); drawCanvas(); } // ---------- textures ---------- function textureFor(file) { const key = file.toLowerCase(); if (!IMS.tex.has(key)) { IMS.tex.set( key, (async () => { if (!IMS.texSource) return { error: "No texture source — use the Textures button" }; const t = await call("ims:texture", IMS.texSource, file); if (!t) return { error: `${file} isn't in ${IMS.texSource.label}` }; try { const bitmap = await TexDecode.decode(t.bytes, t.ext); return { bitmap, w: bitmap.width, h: bitmap.height, source: t.source }; } catch (err) { return { error: `${file}: ${err.message}` }; } })(), ); } return IMS.tex.get(key); } async function loadTexture() { const s = curSet(); IMS.cur = null; $("ims-texinfo").textContent = "Loading texture…"; if (!s) return; const t = await textureFor(attr(s, "File")); if (curSet() !== s) return; IMS.cur = t.error ? null : t; $("ims-texinfo").textContent = t.error ? t.error : `${t.w} × ${t.h} · ${t.source}`; $("ims-texinfo").classList.toggle("bad", !!t.error); } // ---------- side panel ---------- function field(label, value, onCommit, { type = "text", width } = {}) { const input = el("input", { type, value, spellcheck: "false" }); if (width) input.style.width = width; input.addEventListener("change", () => onCommit(input.value.trim(), input)); input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur()); return el("label", { class: "fld" }, [el("span", { text: label }), input]); } function renderSide() { const s = curSet(); $("ims-side").hidden = !s; if (!s) return; $("ims-setform").replaceChildren( field("Name", attr(s, "Name"), (v, inp) => { if (!validName(v)) return ((inp.value = attr(s, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true)); snapshot(); setAttr(s, "Name", v); renderSets(); }), field("Texture", attr(s, "File"), async (v, inp) => { if (!validName(v)) return ((inp.value = attr(s, "File")), toast("Use a plain file name, e.g. ui_icon_event1.dds.", true)); snapshot(); setAttr(s, "File", v); renderSets(); await loadTexture(); drawCanvas(); }), ...s.attrs.filter(([k]) => k !== "Name" && k !== "File").map(([k, v]) => field(k, v, (nv) => { snapshot(); setAttr(s, k, nv); }), ), ); renderImages(); renderImgForm(); } function renderImages() { const s = curSet(); if (!s) return; const q = $("ims-img-search").value.trim().toLowerCase(); const frag = document.createDocumentFragment(); images(s).forEach((im, i) => { const name = attr(im, "Name"); if (q && !name.toLowerCase().includes(q)) return; const r = rectOf(im); frag.append(el("div", { class: `ims-img${i === IMS.img ? " on" : ""}`, onclick: () => selectImg(i) }, [el("span", { text: name }), el("em", { text: `${r.w}×${r.h}` })])); }); $("ims-images").replaceChildren(frag); $("ims-img-count").textContent = images(s).length.toLocaleString(); $("ims-images").querySelector(".on")?.scrollIntoView({ block: "nearest" }); } $("ims-img-search").addEventListener("input", renderImages); function renderImgForm() { const im = curImg(); $("ims-imgbox").hidden = !im; if (!im) return; const intField = (k) => field(k, attr(im, k), (v, inp) => { if (v !== "" && !/^-?\d+$/.test(v)) return ((inp.value = attr(im, k)), toast("Whole numbers only.", true)); snapshot(); if (v === "" && k.startsWith("Offset")) { im.attrs = im.attrs.filter((a) => a[0] !== k); im.raw = null; } else setAttr(im, k, v || "0"); renderImages(); drawCanvas(); drawCrop(); }, { type: "text", width: "100%" }); $("ims-imgform").replaceChildren( field("Name", attr(im, "Name"), (v, inp) => { if (!validName(v)) return ((inp.value = attr(im, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true)); if (images(curSet()).some((x) => x !== im && attr(x, "Name") === v)) return ((inp.value = attr(im, "Name")), toast("Another image in this imageset has that name.", true)); snapshot(); setAttr(im, "Name", v); renderImages(); }), el("div", { class: "fld-grid" }, ["X", "Y", "Width", "Height", "OffsetX", "OffsetY"].map(intField)), ); drawCrop(); } /** The selected image at its real size (2× when small). */ function drawCrop() { const im = curImg(); const c = $("ims-crop"); const ctx = c.getContext("2d"); if (!im || !IMS.cur) { c.width = c.height = 0; return; } const r = rectOf(im); const scale = Math.max(1, Math.min(4, Math.floor(160 / Math.max(r.w, r.h, 1)))); c.width = Math.max(1, r.w * scale); c.height = Math.max(1, r.h * scale); ctx.imageSmoothingEnabled = false; ctx.drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w * scale, r.h * scale); } async function exportPng() { const im = curImg(); if (!im || !IMS.cur) return; const r = rectOf(im); const c = document.createElement("canvas"); c.width = r.w; c.height = r.h; c.getContext("2d").drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h); const file = await call("dialog:savePng", `${attr(im, "Name")}.png`); if (!file) return; await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]); toast(`Saved ${file}.`); } // ---------- add / duplicate / delete ---------- function uniqueName(set, base) { const names = new Set(images(set).map((i) => attr(i, "Name"))); if (!names.has(base)) return base; for (let n = 2; ; n++) if (!names.has(`${base}_${n}`)) return `${base}_${n}`; } function addImage(rect) { const s = curSet(); if (!s) return; snapshot(); const r = rect || { x: 0, y: 0, w: 32, h: 32 }; const im = { attrs: [["Name", uniqueName(s, "new_image")], ["X", r.x], ["Y", r.y], ["Width", r.w], ["Height", r.h]].map(([k, v]) => [k, String(v)]), raw: null }; s.images.push(im); s.selfClosing = false; if (!s.rawClose) s.raw = null; IMS.img = images(s).length - 1; renderSets(); renderImages(); renderImgForm(); drawCanvas(); } $("ims-add-img").onclick = () => addImage(); $("ims-dup-img").onclick = () => { const im = curImg(); if (!im) return; const r = rectOf(im); addImage({ x: r.x + 4, y: r.y + 4, w: r.w, h: r.h }); const copy = curImg(); for (const [k, v] of im.attrs) if (!["Name", "X", "Y"].includes(k)) setAttr(copy, k, v); setAttr(copy, "Name", uniqueName(curSet(), attr(im, "Name") + "_copy")); renderImages(); renderImgForm(); }; function deleteImage() { const s = curSet(); const im = curImg(); if (!im) return; snapshot(); s.images.splice(s.images.indexOf(im), 1); IMS.img = Math.min(IMS.img, images(s).length - 1); renderSets(); renderImages(); renderImgForm(); drawCanvas(); } $("ims-del-img").onclick = deleteImage; $("ims-add-set").onclick = async () => { if (!IMS.doc) return; const name = await prompt("New imageset", "Imageset name (letters, digits, _)", "NewImageset"); if (!name) return; if (!validName(name)) return toast("Use letters, digits and _ (no spaces or quotes).", true); if (IMS.doc.sets.some((s) => attr(s, "Name") === name)) return toast("An imageset with that name already exists.", true); const file = await prompt("Texture", "Texture file name (in resource/texture of the packs)", curSet() ? attr(curSet(), "File") : "ui_new.dds"); if (!file) return; snapshot(); IMS.doc.sets.push({ attrs: [["Name", name], ["File", file]], raw: null, images: [], rawClose: null, selfClosing: false }); renderSets(); await selectSet(IMS.doc.sets.length - 1); $("ims-sets").lastChild?.scrollIntoView({ block: "nearest" }); }; $("ims-del-set").onclick = async () => { const s = curSet(); if (!s) return; if (!(await confirmBox("Delete imageset", `Delete the imageset “${attr(s, "Name")}” and its ${images(s).length} images?`, "Delete", "danger"))) return; snapshot(); IMS.doc.sets.splice(IMS.set, 1); IMS.set = Math.min(IMS.set, IMS.doc.sets.length - 1); renderSets(); if (IMS.set >= 0) selectSet(IMS.set); else renderAll(); }; // ---------- canvas ---------- const canvas = $("ims-canvas"); const ctx = canvas.getContext("2d"); let checker = null; function resizeCanvas() { const r = canvas.parentElement.getBoundingClientRect(); const dpr = devicePixelRatio || 1; canvas.width = Math.max(1, Math.round(r.width * dpr)); canvas.height = Math.max(1, Math.round(r.height * dpr)); canvas.style.width = `${r.width}px`; canvas.style.height = `${r.height}px`; drawCanvas(); } new ResizeObserver(resizeCanvas).observe(canvas.parentElement); function fitView() { const t = IMS.cur; const r = canvas.parentElement.getBoundingClientRect(); if (!t) { IMS.zoom = 1; IMS.panX = IMS.panY = 20; return updateZoomLabel(); } IMS.zoom = Math.max(0.05, Math.min(8, Math.min((r.width - 40) / t.w, (r.height - 40) / t.h))); IMS.panX = (r.width - t.w * IMS.zoom) / 2; IMS.panY = (r.height - t.h * IMS.zoom) / 2; updateZoomLabel(); } function setZoom(z, cx, cy) { const r = canvas.getBoundingClientRect(); const x = cx ?? r.width / 2; const y = cy ?? r.height / 2; const nz = Math.max(0.05, Math.min(16, z)); IMS.panX = x - ((x - IMS.panX) * nz) / IMS.zoom; IMS.panY = y - ((y - IMS.panY) * nz) / IMS.zoom; IMS.zoom = nz; updateZoomLabel(); drawCanvas(); } const updateZoomLabel = () => ($("ims-zoom").textContent = `${Math.round(IMS.zoom * 100)}%`); $("ims-zoom-in").onclick = () => setZoom(IMS.zoom * 1.25); $("ims-zoom-out").onclick = () => setZoom(IMS.zoom / 1.25); $("ims-zoom").onclick = () => setZoom(1); $("ims-fit").onclick = () => (fitView(), drawCanvas()); $("ims-showall").onclick = () => { IMS.showAll = !IMS.showAll; $("ims-showall").classList.toggle("on", IMS.showAll); drawCanvas(); }; $("ims-drawmode").onclick = () => { IMS.draw = !IMS.draw; $("ims-drawmode").classList.toggle("on", IMS.draw); canvas.classList.toggle("drawing", IMS.draw); }; function drawCanvas() { const dpr = devicePixelRatio || 1; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.setTransform(dpr * IMS.zoom, 0, 0, dpr * IMS.zoom, dpr * IMS.panX, dpr * IMS.panY); const t = IMS.cur; const s = curSet(); if (t) { if (!checker) { const c = document.createElement("canvas"); c.width = c.height = 16; const g = c.getContext("2d"); g.fillStyle = "#2a2830"; g.fillRect(0, 0, 16, 16); g.fillStyle = "#34313b"; g.fillRect(0, 0, 8, 8); g.fillRect(8, 8, 8, 8); checker = ctx.createPattern(c, "repeat"); } ctx.fillStyle = checker; ctx.fillRect(0, 0, t.w, t.h); ctx.imageSmoothingEnabled = IMS.zoom < 1; ctx.drawImage(t.bitmap, 0, 0); } if (!s) return; const px = 1 / IMS.zoom; const list = images(s); list.forEach((im, i) => { if (i === IMS.img || (!IMS.showAll && i !== IMS.hover)) return; const r = rectOf(im); ctx.lineWidth = px; ctx.strokeStyle = i === IMS.hover ? "rgba(255,255,255,.95)" : "rgba(233,180,60,.55)"; ctx.strokeRect(r.x + px / 2, r.y + px / 2, r.w - px, r.h - px); }); const sel = curImg(); if (sel) { const r = rectOf(sel); ctx.fillStyle = "rgba(233,180,60,.18)"; ctx.fillRect(r.x, r.y, r.w, r.h); ctx.lineWidth = 2 * px; ctx.strokeStyle = "#f4c95a"; ctx.strokeRect(r.x, r.y, r.w, r.h); // offset point (OffsetX/Y: the image's pivot) if (attr(sel, "OffsetX") !== "" || attr(sel, "OffsetY") !== "") { const ox = r.x + num(sel, "OffsetX"); const oy = r.y + num(sel, "OffsetY"); ctx.strokeStyle = "#ff6b5e"; ctx.beginPath(); ctx.moveTo(ox - 5 * px, oy); ctx.lineTo(ox + 5 * px, oy); ctx.moveTo(ox, oy - 5 * px); ctx.lineTo(ox, oy + 5 * px); ctx.stroke(); } ctx.fillStyle = "#f4c95a"; for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px); } if (IMS.drawRect) { const r = IMS.drawRect; ctx.lineWidth = px; ctx.setLineDash([4 * px, 3 * px]); ctx.strokeStyle = "#3ec07a"; ctx.strokeRect(r.x, r.y, r.w, r.h); ctx.setLineDash([]); } } const handles = (r) => [ [r.x, r.y, "nw"], [r.x + r.w / 2, r.y, "n"], [r.x + r.w, r.y, "ne"], [r.x + r.w, r.y + r.h / 2, "e"], [r.x + r.w, r.y + r.h, "se"], [r.x + r.w / 2, r.y + r.h, "s"], [r.x, r.y + r.h, "sw"], [r.x, r.y + r.h / 2, "w"], ]; const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" }; function toTex(e) { const b = canvas.getBoundingClientRect(); return { x: (e.clientX - b.left - IMS.panX) / IMS.zoom, y: (e.clientY - b.top - IMS.panY) / IMS.zoom }; } function hitHandle(p) { const sel = curImg(); if (!sel) return null; const tol = 6 / IMS.zoom; return handles(rectOf(sel)).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null; } /** Smallest box under the point (so small icons inside bigger ones can be picked). */ function hitImage(p) { const s = curSet(); if (!s) return -1; let best = -1; let area = Infinity; images(s).forEach((im, i) => { const r = rectOf(im); if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h < area) { best = i; area = r.w * r.h; } }); return best; } let drag = null; canvas.addEventListener("mousedown", (e) => { if (!curSet()) return; canvas.focus(); const p = toTex(e); if (e.button === 1 || e.button === 2 || (e.button === 0 && e.altKey)) { drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY }; } else if (IMS.draw) { drag = { mode: "draw", start: { x: Math.round(p.x), y: Math.round(p.y) } }; } else { const h = hitHandle(p); if (h) { snapshot(); drag = { mode: "resize", h, start: p, r: rectOf(curImg()) }; } else { const i = hitImage(p); if (i >= 0) { if (i !== IMS.img) selectImg(i); snapshot(); drag = { mode: "move", start: p, r: rectOf(curImg()), moved: false }; } else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY }; } } e.preventDefault(); }); canvas.addEventListener("contextmenu", (e) => e.preventDefault()); window.addEventListener("mousemove", (e) => { if (!$("view-ui").classList.contains("on")) return; if (!drag) { if (e.target !== canvas || !curSet()) return; const p = toTex(e); const h = !IMS.draw && hitHandle(p); canvas.style.cursor = IMS.draw ? "crosshair" : h ? CURSOR[h] : hitImage(p) >= 0 ? "move" : "grab"; const hov = hitImage(p); if (hov !== IMS.hover) { IMS.hover = hov; $("ims-hover").textContent = hov >= 0 ? `${attr(images(curSet())[hov], "Name")} ${Math.floor(p.x)}, ${Math.floor(p.y)}` : `${Math.floor(p.x)}, ${Math.floor(p.y)}`; drawCanvas(); } else $("ims-hover").textContent = `${hov >= 0 ? attr(images(curSet())[hov], "Name") + " " : ""}${Math.floor(p.x)}, ${Math.floor(p.y)}`; return; } if (drag.mode === "pan") { IMS.panX = drag.px + e.clientX - drag.sx; IMS.panY = drag.py + e.clientY - drag.sy; canvas.style.cursor = "grabbing"; return drawCanvas(); } const p = toTex(e); if (drag.mode === "draw") { const x = Math.round(p.x); const y = Math.round(p.y); IMS.drawRect = { x: Math.min(x, drag.start.x), y: Math.min(y, drag.start.y), w: Math.abs(x - drag.start.x), h: Math.abs(y - drag.start.y) }; return drawCanvas(); } const im = curImg(); const dx = Math.round(p.x - drag.start.x); const dy = Math.round(p.y - drag.start.y); let { x, y, w, h } = drag.r; if (drag.mode === "move") { x += dx; y += dy; drag.moved = drag.moved || dx || dy; } else { if (drag.h.includes("w")) (x += dx), (w -= dx); if (drag.h.includes("e")) w += dx; if (drag.h.includes("n")) (y += dy), (h -= dy); if (drag.h.includes("s")) h += dy; if (w < 1) (x += w - 1), (w = 1); if (h < 1) (y += h - 1), (h = 1); } setAttr(im, "X", x); setAttr(im, "Y", y); setAttr(im, "Width", w); setAttr(im, "Height", h); drawCanvas(); $("ims-hover").textContent = `${attr(im, "Name")} X ${x} Y ${y} ${w} × ${h}`; }); window.addEventListener("mouseup", () => { if (!drag) return; const d = drag; drag = null; canvas.style.cursor = ""; if (d.mode === "draw" && IMS.drawRect) { const r = IMS.drawRect; IMS.drawRect = null; if (r.w >= 2 && r.h >= 2) addImage(r); else drawCanvas(); } else if (d.mode === "move" || d.mode === "resize") { if (d.mode === "move" && !d.moved) IMS.undo.pop(); // just a click: nothing changed renderImages(); renderImgForm(); } }); canvas.addEventListener( "wheel", (e) => { e.preventDefault(); const b = canvas.getBoundingClientRect(); setZoom(IMS.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15), e.clientX - b.left, e.clientY - b.top); }, { passive: false }, ); // Keyboard: arrows nudge (Shift = 10 px), Delete removes, Ctrl+Z / Ctrl+Y, Ctrl+S saves document.addEventListener("keydown", (e) => { if (!$("view-ui").classList.contains("on") || !IMS.doc) return; if (e.target.matches?.("input, textarea, select")) return; if (e.ctrlKey && e.key.toLowerCase() === "z") return e.preventDefault(), undo(); if (e.ctrlKey && e.key.toLowerCase() === "y") return e.preventDefault(), redo(); if (e.ctrlKey && e.key.toLowerCase() === "s") return e.preventDefault(), IMS.source?.kind === "pack" ? saveToPack() : saveToFile(); const im = curImg(); if (!im) return; const step = e.shiftKey ? 10 : 1; const move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key]; if (move) { e.preventDefault(); if (!e.repeat) snapshot(); setAttr(im, "X", num(im, "X") + move[0]); setAttr(im, "Y", num(im, "Y") + move[1]); drawCanvas(); renderImgForm(); } else if (e.key === "Delete") deleteImage(); }); function renderAll() { renderSets(); renderSide(); drawCanvas(); } // Refresh the pack list whenever the view opens document.querySelector('[data-view="ui"]').addEventListener("click", () => imsSessions());