867 lines
31 KiB
JavaScript
867 lines
31 KiB
JavaScript
"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());
|