Files

867 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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());