LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+866
View File
@@ -0,0 +1,866 @@
"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*<Imageset\b(.*?)(\/?)>\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*<Image\b(.*?)\/>\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 (</ImagesetLayout>)
if (!doc.head.length) doc.head = [`<?xml version="1.0" encoding="euc-kr" ?>${eol}`, `<ImagesetLayout>${eol}`];
if (!doc.tail.some((l) => /<\/ImagesetLayout>/.test(l))) doc.tail.unshift(`</ImagesetLayout>${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 ?? ` <Imageset ${attrText(s.attrs)}${selfClose ? " /" : ""}>${e}`);
if (!selfClose) {
for (const i of s.images) push(i.raw ?? ` <Image ${attrText(i.attrs)} />${e}`);
push(s.rawClose ?? ` </Imageset>${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());