769 lines
29 KiB
JavaScript
769 lines
29 KiB
JavaScript
"use strict";
|
||
|
||
// Tables tab (DAT Editor): the client's .dat tables (resource/tables in the packs) and the server's
|
||
// (ls_gamesvr\config\tables) as a spreadsheet, read and written by src/main/dattable.js. The columns
|
||
// come from the file and can't change (the game checks the header version); rows and values can.
|
||
// Grid: click / drag / shift to select, type or F2 / double-click to edit, Enter / Tab to move,
|
||
// Ctrl+C / Ctrl+V with Excel (tab-separated), Delete clears, Ctrl+Z / Ctrl+Y, Ctrl+S saves.
|
||
|
||
(() => {
|
||
const T = { lists: { client: [], server: [] }, cur: null, meta: null, busy: false };
|
||
window.DT = T; // (dev probes)
|
||
const LS = (k, v) => {
|
||
try {
|
||
if (v === undefined) return localStorage.getItem(k) || "";
|
||
localStorage.setItem(k, v);
|
||
} catch {}
|
||
return "";
|
||
};
|
||
const RH = 26;
|
||
|
||
// ---------- table list ----------
|
||
const clientSrc = () => T.src || profileSource();
|
||
|
||
async function listTables() {
|
||
const src = clientSrc();
|
||
const server = LS("dt:server");
|
||
try {
|
||
T.lists.client = src ? await call("dat:client", src) : [];
|
||
} catch (err) {
|
||
T.lists.client = [];
|
||
toast(err.message, true);
|
||
}
|
||
T.lists.server = server ? await call("dat:folder", server) : [];
|
||
$("dt-source").textContent = [src ? `client: ${src.folder}` : "no game folder in this profile", server ? `server: ${server}` : "no server folder"].join(" · ");
|
||
renderList();
|
||
}
|
||
|
||
function renderList() {
|
||
const group = (title, items, kind, empty) => [
|
||
el("div", { class: "dt-group", text: title }),
|
||
...(items.length
|
||
? items.map((it) =>
|
||
el(
|
||
"button",
|
||
{
|
||
class: `dt-item${T.cur && T.cur.refKey === `${kind}|${it.key}` ? " on" : ""}`,
|
||
title: it.path,
|
||
onclick: () => openTable(kind === "client" ? { kind: "pack", src: clientSrc(), key: it.key } : { kind: "file", file: it.path }, `${kind}|${it.key}`),
|
||
},
|
||
[el("span", { text: it.name.replace(/\.dat$/i, "") }), kind === "client" && it.pack ? el("small", { class: "muted", text: it.pack }) : null],
|
||
),
|
||
)
|
||
: [el("div", { class: "dt-none muted", text: empty })]),
|
||
];
|
||
const opened = T.cur && T.cur.ref.kind === "file" && !T.lists.server.some((x) => x.path === T.cur.ref.file) ? group("File", [{ key: T.cur.ref.file, name: T.cur.ref.file.split(/[\\/]/).pop(), path: T.cur.ref.file }], "file", "") : [];
|
||
$("dt-list").replaceChildren(...group("Client (packs)", T.lists.client, "client", "No resource/tables/*.dat in the packs."), ...group("Server", T.lists.server, "server", "Pick the server's tables folder."), ...opened);
|
||
}
|
||
|
||
// ---------- opening ----------
|
||
async function leave() {
|
||
if (!T.cur?.dirty()) return true;
|
||
return confirmBox("Unsaved changes", `${T.cur.name} has changes that aren't saved. Drop them?`, "Drop changes", "danger");
|
||
}
|
||
|
||
async function openTable(ref, refKey, { encoding = "auto" } = {}) {
|
||
if (T.cur && T.cur.refKey !== refKey && !(await leave())) return;
|
||
let d;
|
||
try {
|
||
d = await call("dat:open", ref, encoding);
|
||
} catch (err) {
|
||
return toast(err.message, true);
|
||
}
|
||
if (T.cur) call("dat:close", T.cur.id);
|
||
const folder = LS("dt:maker");
|
||
let meta = null;
|
||
try {
|
||
meta = folder ? await call("dat:meta", folder, d.name) : null;
|
||
} catch {}
|
||
T.cur = new Sheet(d, ref, refKey, meta);
|
||
$("dt-enc").value = d.encoding;
|
||
$("dt-search").value = "";
|
||
renderList();
|
||
T.cur.mount($("dt-grid"));
|
||
status();
|
||
}
|
||
|
||
// ---------- the sheet ----------
|
||
class Sheet {
|
||
constructor(d, ref, refKey, meta) {
|
||
this.id = d.id;
|
||
this.ref = ref;
|
||
this.refKey = refKey;
|
||
this.name = d.name;
|
||
this.info = d;
|
||
this.meta = meta;
|
||
this.cols = d.cols.map((c) => ({ ...c, meta: meta?.cols?.[c.name] || null, enums: meta?.enums?.[c.name] || null }));
|
||
this.setData(d.rows);
|
||
this.undo = [];
|
||
this.redo = [];
|
||
this.sort = null;
|
||
this.filter = "";
|
||
this.act = { r: 0, c: 0 };
|
||
this.anchor = { r: 0, c: 0 };
|
||
this.widths = this.cols.map((c, i) => this.guessWidth(c, i));
|
||
}
|
||
|
||
setData(rows) {
|
||
this.orig = rows;
|
||
this.rows = rows.map((v, o) => ({ o, v: [...v] }));
|
||
this.view = this.rows.map((_, i) => i);
|
||
}
|
||
|
||
guessWidth(c, i) {
|
||
let w = c.name.length * 7.5 + 26;
|
||
for (const r of this.rows.slice(0, 200)) w = Math.max(w, String(r.v[i]).length * 7 + 18);
|
||
return Math.round(Math.min(Math.max(w, 56), c.text ? 320 : 150));
|
||
}
|
||
|
||
dirty() {
|
||
if (this.rows.length !== this.orig.length) return true;
|
||
return this.rows.some((r, i) => r.o !== i || r.v.some((v, c) => v !== this.orig[i][c]));
|
||
}
|
||
|
||
cellDirty(row, c) {
|
||
return row.o < 0 || row.v[c] !== this.orig[row.o]?.[c];
|
||
}
|
||
|
||
// ----- view -----
|
||
applyView() {
|
||
const q = this.filter.toLowerCase();
|
||
let idx = this.rows.map((_, i) => i);
|
||
if (q) idx = idx.filter((i) => this.rows[i].v.some((v) => String(v).toLowerCase().includes(q)));
|
||
if (this.sort) {
|
||
const { c, desc } = this.sort;
|
||
const num = !this.cols[c].text;
|
||
idx.sort((a, b) => {
|
||
const x = this.rows[a].v[c];
|
||
const y = this.rows[b].v[c];
|
||
const r = num ? Number(x) - Number(y) : String(x).localeCompare(String(y));
|
||
return (desc ? -r : r) || a - b;
|
||
});
|
||
}
|
||
this.view = idx;
|
||
this.act.r = Math.min(this.act.r, Math.max(0, idx.length - 1));
|
||
this.anchor = { ...this.act };
|
||
}
|
||
|
||
// ----- DOM -----
|
||
mount(host) {
|
||
this.host = host;
|
||
host.replaceChildren();
|
||
host.tabIndex = 0;
|
||
this.head = el("div", { class: "dt-head" });
|
||
this.body = el("div", { class: "dt-rows" });
|
||
host.append(this.head, this.body);
|
||
host.onscroll = () => this.draw();
|
||
host.onkeydown = (e) => this.key(e);
|
||
host.onmousedown = (e) => this.mouse(e);
|
||
host.ondblclick = (e) => {
|
||
const cell = e.target.closest(".dt-cell");
|
||
if (cell) this.edit();
|
||
};
|
||
host.oncopy = (e) => this.copy(e);
|
||
host.onpaste = (e) => this.paste(e);
|
||
this.drawHead();
|
||
this.layout();
|
||
host.focus();
|
||
}
|
||
|
||
colLeft(c) {
|
||
let x = 56;
|
||
for (let i = 0; i < c; i++) x += this.widths[i];
|
||
return x;
|
||
}
|
||
|
||
layout() {
|
||
const total = this.colLeft(this.cols.length);
|
||
this.head.style.width = `${total}px`;
|
||
this.body.style.width = `${total}px`;
|
||
this.body.style.height = `${this.view.length * RH}px`;
|
||
this.draw();
|
||
}
|
||
|
||
drawHead() {
|
||
const cells = [el("div", { class: "dt-h dt-rn", text: "#", style: "width:56px" })];
|
||
this.cols.forEach((c, i) => {
|
||
const tip = [`${c.name} — ${c.typeName}`, c.meta?.desc, c.meta?.export ? `used by: ${c.meta.export}` : null, c.enums ? `enum: ${c.enums.map((e) => `${e.value} ${e.name}`).join(", ")}` : null].filter(Boolean).join("\n");
|
||
const arrow = this.sort?.c === i ? (this.sort.desc ? " ▼" : " ▲") : "";
|
||
const h = el("div", { class: "dt-h", title: tip, style: `width:${this.widths[i]}px` }, [
|
||
el("b", { text: c.name + arrow }),
|
||
el("small", { text: c.meta?.desc ? `${c.typeName} · ${c.meta.desc}` : c.typeName }),
|
||
el("i", { class: "dt-resize", onmousedown: (e) => this.resize(e, i) }),
|
||
]);
|
||
h.addEventListener("click", (e) => {
|
||
if (e.target.classList.contains("dt-resize")) return;
|
||
this.sort = this.sort?.c !== i ? { c: i, desc: false } : this.sort.desc ? null : { c: i, desc: true };
|
||
this.applyView();
|
||
this.drawHead();
|
||
this.layout();
|
||
status();
|
||
});
|
||
cells.push(h);
|
||
});
|
||
this.head.replaceChildren(...cells);
|
||
}
|
||
|
||
resize(e, i) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const x0 = e.clientX;
|
||
const w0 = this.widths[i];
|
||
const move = (ev) => {
|
||
this.widths[i] = Math.max(40, w0 + ev.clientX - x0);
|
||
this.drawHead();
|
||
this.layout();
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
}
|
||
|
||
sel() {
|
||
return { r0: Math.min(this.act.r, this.anchor.r), r1: Math.max(this.act.r, this.anchor.r), c0: Math.min(this.act.c, this.anchor.c), c1: Math.max(this.act.c, this.anchor.c) };
|
||
}
|
||
|
||
display(c, v) {
|
||
if (c.enums && v !== "") {
|
||
const e = c.enums.find((x) => String(x.value) === String(v));
|
||
if (e) return [el("span", { text: v }), el("small", { class: "dt-enum", text: e.name })];
|
||
}
|
||
return [document.createTextNode(v)];
|
||
}
|
||
|
||
draw() {
|
||
if (!this.host) return;
|
||
const top = this.host.scrollTop - 34;
|
||
const h = this.host.clientHeight;
|
||
const first = Math.max(0, Math.floor(top / RH) - 4);
|
||
const last = Math.min(this.view.length, Math.ceil((top + h) / RH) + 4);
|
||
const s = this.sel();
|
||
const frag = document.createDocumentFragment();
|
||
for (let vr = first; vr < last; vr++) {
|
||
const row = this.rows[this.view[vr]];
|
||
const line = el("div", { class: `dt-row${row.o < 0 ? " new" : ""}`, style: `top:${vr * RH}px` });
|
||
line.append(el("div", { class: "dt-cell dt-rn", text: String(this.view[vr] + 1), style: "width:56px", "data-r": vr, "data-c": -1 }));
|
||
this.cols.forEach((c, ci) => {
|
||
const inSel = vr >= s.r0 && vr <= s.r1 && ci >= s.c0 && ci <= s.c1;
|
||
const cls = ["dt-cell", c.text ? "txt" : "num", this.cellDirty(row, ci) ? "dirty" : "", inSel ? "sel" : "", vr === this.act.r && ci === this.act.c ? "act" : ""].filter(Boolean).join(" ");
|
||
line.append(el("div", { class: cls, style: `width:${this.widths[ci]}px`, "data-r": vr, "data-c": ci }, this.display(c, row.v[ci])));
|
||
});
|
||
frag.append(line);
|
||
}
|
||
this.body.replaceChildren(frag);
|
||
}
|
||
|
||
scrollTo(vr, c) {
|
||
const y = vr * RH;
|
||
const hh = 34;
|
||
if (y < this.host.scrollTop) this.host.scrollTop = y;
|
||
else if (y + RH > this.host.scrollTop + this.host.clientHeight - hh) this.host.scrollTop = y + RH - this.host.clientHeight + hh;
|
||
if (c >= 0) {
|
||
const x = this.colLeft(c);
|
||
const w = this.widths[c];
|
||
if (x - 56 < this.host.scrollLeft) this.host.scrollLeft = x - 56;
|
||
else if (x + w > this.host.scrollLeft + this.host.clientWidth) this.host.scrollLeft = x + w - this.host.clientWidth;
|
||
}
|
||
}
|
||
|
||
// ----- selection -----
|
||
cellAt(e) {
|
||
const cell = e.target.closest?.(".dt-cell");
|
||
if (!cell) return null;
|
||
return { r: Number(cell.dataset.r), c: Number(cell.dataset.c) };
|
||
}
|
||
|
||
mouse(e) {
|
||
if (e.button !== 0 || e.target.closest(".dt-h") || e.target.closest(".dt-editor")) return;
|
||
const at = this.cellAt(e);
|
||
if (!at) return;
|
||
this.commitEdit();
|
||
if (at.c < 0) {
|
||
// row number: whole rows
|
||
if (!e.shiftKey) this.anchor = { r: at.r, c: 0 };
|
||
this.act = { r: at.r, c: this.cols.length - 1 };
|
||
this.anchor.c = 0;
|
||
} else {
|
||
this.act = at;
|
||
if (!e.shiftKey) this.anchor = { ...at };
|
||
}
|
||
this.draw();
|
||
status();
|
||
const move = (ev) => {
|
||
const t = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.(".dt-cell");
|
||
if (!t || !this.host.contains(t)) return;
|
||
const r = Number(t.dataset.r);
|
||
const c = Number(t.dataset.c);
|
||
if (r === this.act.r && (c === this.act.c || c < 0)) return;
|
||
this.act = { r, c: c < 0 ? this.act.c : c };
|
||
this.draw();
|
||
status();
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
}
|
||
|
||
move(dr, dc, extend) {
|
||
this.act = { r: Math.max(0, Math.min(this.view.length - 1, this.act.r + dr)), c: Math.max(0, Math.min(this.cols.length - 1, this.act.c + dc)) };
|
||
if (!extend) this.anchor = { ...this.act };
|
||
this.scrollTo(this.act.r, this.act.c);
|
||
this.draw();
|
||
status();
|
||
}
|
||
|
||
key(e) {
|
||
if (this.editor) return;
|
||
const ctrl = e.ctrlKey || e.metaKey;
|
||
const k = e.key;
|
||
const page = Math.max(1, Math.floor(this.host.clientHeight / RH) - 2);
|
||
if (k === "ArrowDown") this.move(ctrl ? this.view.length : 1, 0, e.shiftKey);
|
||
else if (k === "ArrowUp") this.move(ctrl ? -this.view.length : -1, 0, e.shiftKey);
|
||
else if (k === "ArrowRight") this.move(0, ctrl ? this.cols.length : 1, e.shiftKey);
|
||
else if (k === "ArrowLeft") this.move(0, ctrl ? -this.cols.length : -1, e.shiftKey);
|
||
else if (k === "PageDown") this.move(page, 0, e.shiftKey);
|
||
else if (k === "PageUp") this.move(-page, 0, e.shiftKey);
|
||
else if (k === "Home") this.move(ctrl ? -this.view.length : 0, -this.cols.length, e.shiftKey);
|
||
else if (k === "End") this.move(ctrl ? this.view.length : 0, this.cols.length, e.shiftKey);
|
||
else if (k === "Tab") this.move(0, e.shiftKey ? -1 : 1, false);
|
||
else if (k === "Enter" && ctrl) addRow();
|
||
else if (k === "Enter" || k === "F2") this.edit();
|
||
else if (k === "Delete" && ctrl) deleteRows();
|
||
else if (k === "Delete" || k === "Backspace") this.clear();
|
||
else if (ctrl && k.toLowerCase() === "a") {
|
||
this.anchor = { r: 0, c: 0 };
|
||
this.act = { r: this.view.length - 1, c: this.cols.length - 1 };
|
||
this.draw();
|
||
status();
|
||
} else if (ctrl && k.toLowerCase() === "z") this.doUndo();
|
||
else if (ctrl && k.toLowerCase() === "y") this.doRedo();
|
||
else if (ctrl && k.toLowerCase() === "s") save();
|
||
else if (ctrl && k.toLowerCase() === "d") duplicateRows();
|
||
else if (ctrl && k.toLowerCase() === "f") $("dt-search").focus();
|
||
else if (!ctrl && !e.altKey && k.length === 1) {
|
||
this.edit(k);
|
||
e.preventDefault();
|
||
return;
|
||
} else return;
|
||
e.preventDefault();
|
||
}
|
||
|
||
// ----- editing -----
|
||
edit(start) {
|
||
if (!this.view.length) return;
|
||
const vr = this.act.r;
|
||
const c = this.act.c;
|
||
const row = this.rows[this.view[vr]];
|
||
this.scrollTo(vr, c);
|
||
const col = this.cols[c];
|
||
const input = el("input", { class: "dt-editor", type: "text", spellcheck: "false" });
|
||
input.value = start ?? row.v[c];
|
||
input.style.left = `${this.colLeft(c)}px`;
|
||
input.style.top = `${vr * RH}px`;
|
||
input.style.width = `${Math.max(this.widths[c], col.text ? 260 : 120)}px`;
|
||
if (col.enums) input.setAttribute("list", this.enumList(col));
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
this.commitEdit();
|
||
this.move(e.shiftKey ? -1 : 1, 0, false);
|
||
this.host.focus();
|
||
} else if (e.key === "Tab") {
|
||
e.preventDefault();
|
||
this.commitEdit();
|
||
this.move(0, e.shiftKey ? -1 : 1, false);
|
||
this.host.focus();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
this.cancelEdit();
|
||
}
|
||
e.stopPropagation();
|
||
});
|
||
input.addEventListener("blur", () => this.commitEdit());
|
||
this.body.append(input);
|
||
this.editor = { input, row, c };
|
||
input.focus();
|
||
if (start === undefined) input.select();
|
||
}
|
||
|
||
enumList(col) {
|
||
const id = `dt-enum-${col.name}`;
|
||
if (!document.getElementById(id)) document.body.append(el("datalist", { id }, col.enums.map((e) => el("option", { value: String(e.value), label: e.name }))));
|
||
return id;
|
||
}
|
||
|
||
commitEdit() {
|
||
const ed = this.editor;
|
||
if (!ed) return;
|
||
this.editor = null;
|
||
let v = ed.input.value;
|
||
const col = this.cols[ed.c];
|
||
// an enum can be typed by its name
|
||
if (col.enums && !/^-?\d+$/.test(v.trim())) {
|
||
const e = col.enums.find((x) => x.name.toLowerCase() === v.trim().toLowerCase());
|
||
if (e) v = String(e.value);
|
||
}
|
||
if (!col.text) v = v.trim();
|
||
ed.input.remove();
|
||
if (v !== ed.row.v[ed.c]) this.change([{ row: ed.row, c: ed.c, v }]);
|
||
else this.draw();
|
||
}
|
||
|
||
cancelEdit() {
|
||
const ed = this.editor;
|
||
if (!ed) return;
|
||
this.editor = null;
|
||
ed.input.remove();
|
||
this.host.focus();
|
||
this.draw();
|
||
}
|
||
|
||
/** cell changes [{ row, c, v }] as one undo step */
|
||
change(list) {
|
||
const step = { cells: list.map((x) => ({ row: x.row, c: x.c, old: x.row.v[x.c], v: x.v })) };
|
||
for (const x of step.cells) x.row.v[x.c] = x.v;
|
||
this.undo.push(step);
|
||
this.redo = [];
|
||
this.draw();
|
||
status();
|
||
}
|
||
|
||
/** a row operation: the rows array before and after */
|
||
rowsStep(after) {
|
||
this.undo.push({ before: this.rows, after });
|
||
this.redo = [];
|
||
this.rows = after;
|
||
this.applyView();
|
||
this.layout();
|
||
status();
|
||
}
|
||
|
||
doUndo() {
|
||
const s = this.undo.pop();
|
||
if (!s) return;
|
||
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.old;
|
||
else {
|
||
this.rows = s.before;
|
||
this.applyView();
|
||
this.layout();
|
||
}
|
||
this.redo.push(s);
|
||
this.draw();
|
||
status();
|
||
}
|
||
|
||
doRedo() {
|
||
const s = this.redo.pop();
|
||
if (!s) return;
|
||
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.v;
|
||
else {
|
||
this.rows = s.after;
|
||
this.applyView();
|
||
this.layout();
|
||
}
|
||
this.undo.push(s);
|
||
this.draw();
|
||
status();
|
||
}
|
||
|
||
clear() {
|
||
const s = this.sel();
|
||
const list = [];
|
||
for (let vr = s.r0; vr <= s.r1; vr++) for (let c = s.c0; c <= s.c1; c++) list.push({ row: this.rows[this.view[vr]], c, v: this.cols[c].text ? "" : "0" });
|
||
this.change(list);
|
||
}
|
||
|
||
// ----- clipboard (tab-separated, like Excel) -----
|
||
copy(e) {
|
||
if (this.editor) return;
|
||
const s = this.sel();
|
||
const lines = [];
|
||
for (let vr = s.r0; vr <= s.r1; vr++) {
|
||
const row = this.rows[this.view[vr]];
|
||
lines.push(row.v.slice(s.c0, s.c1 + 1).map((v) => (/[\t\n"]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v)).join("\t"));
|
||
}
|
||
e.clipboardData.setData("text/plain", lines.join("\r\n"));
|
||
e.preventDefault();
|
||
toast(`Copied ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} cells.`);
|
||
}
|
||
|
||
paste(e) {
|
||
if (this.editor) return;
|
||
e.preventDefault();
|
||
const text = e.clipboardData.getData("text/plain");
|
||
if (!text) return;
|
||
const grid = parseTsv(text);
|
||
if (!grid.length) return;
|
||
const s = this.sel();
|
||
// one value into a bigger selection fills it; otherwise the block starts at the top-left cell
|
||
const fill = grid.length === 1 && grid[0].length === 1 && (s.r1 > s.r0 || s.c1 > s.c0);
|
||
const list = [];
|
||
let after = null;
|
||
const need = fill ? 0 : s.r0 + grid.length - this.view.length;
|
||
if (need > 0) {
|
||
// more rows than there are below: new rows at the end
|
||
after = [...this.rows];
|
||
for (let i = 0; i < need; i++) after.push({ o: -1, v: this.cols.map((c) => (c.text ? "" : "0")) });
|
||
this.rows = after;
|
||
this.applyView();
|
||
}
|
||
const rowsR = fill ? s.r1 - s.r0 + 1 : grid.length;
|
||
const colsR = fill ? s.c1 - s.c0 + 1 : Math.min(grid[0].length, this.cols.length - s.c0);
|
||
for (let i = 0; i < rowsR; i++) {
|
||
const row = this.rows[this.view[s.r0 + i]];
|
||
if (!row) break;
|
||
for (let j = 0; j < colsR; j++) {
|
||
const c = s.c0 + j;
|
||
if (c >= this.cols.length) break;
|
||
const v = fill ? grid[0][0] : grid[i][j] ?? "";
|
||
list.push({ row, c, v: this.cols[c].text ? v : v.trim() });
|
||
}
|
||
}
|
||
if (after) {
|
||
// rows added and filled: one undo step for both
|
||
const before = this.rows.slice(0, this.rows.length - need);
|
||
for (const x of list) x.row.v[x.c] = x.v;
|
||
this.undo.push({ before, after: this.rows });
|
||
this.redo = [];
|
||
this.layout();
|
||
} else this.change(list);
|
||
this.anchor = { r: s.r0, c: s.c0 };
|
||
this.act = { r: s.r0 + rowsR - 1, c: s.c0 + colsR - 1 };
|
||
this.draw();
|
||
status();
|
||
toast(`Pasted ${rowsR} × ${colsR} cells${need > 0 ? ` (${need} new rows)` : ""}.`);
|
||
}
|
||
|
||
selectedRows() {
|
||
const s = this.sel();
|
||
return this.view.slice(s.r0, s.r1 + 1);
|
||
}
|
||
}
|
||
|
||
function parseTsv(text) {
|
||
const rows = [];
|
||
let row = [];
|
||
let cell = "";
|
||
let q = false;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
if (q) {
|
||
if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++;
|
||
else if (ch === '"') q = false;
|
||
else cell += ch;
|
||
} else if (ch === '"' && cell === "") q = true;
|
||
else if (ch === "\t") row.push(cell), (cell = "");
|
||
else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = "");
|
||
else cell += ch;
|
||
}
|
||
if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row);
|
||
return rows;
|
||
}
|
||
|
||
// ---------- row commands ----------
|
||
function addRow() {
|
||
const S = T.cur;
|
||
if (!S) return;
|
||
const at = S.view.length ? S.view[S.sel().r1] + 1 : S.rows.length;
|
||
const blank = { o: -1, v: S.cols.map((c) => (c.text ? "" : "0")) };
|
||
// a new key: the first column one above the largest when it's a number
|
||
const key = S.cols[0];
|
||
if (key && !key.text) blank.v[0] = String(Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1);
|
||
const after = [...S.rows.slice(0, at), blank, ...S.rows.slice(at)];
|
||
S.rowsStep(after);
|
||
const vr = S.view.indexOf(at);
|
||
if (vr >= 0) {
|
||
S.act = { r: vr, c: 0 };
|
||
S.anchor = { ...S.act };
|
||
S.scrollTo(vr, 0);
|
||
S.draw();
|
||
}
|
||
}
|
||
|
||
function duplicateRows() {
|
||
const S = T.cur;
|
||
if (!S || !S.view.length) return;
|
||
const pick = S.selectedRows().sort((a, b) => a - b);
|
||
const at = pick[pick.length - 1] + 1;
|
||
let next = Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1;
|
||
const copies = pick.map((i) => {
|
||
const v = [...S.rows[i].v];
|
||
if (!S.cols[0].text) v[0] = String(next++);
|
||
return { o: -1, v };
|
||
});
|
||
S.rowsStep([...S.rows.slice(0, at), ...copies, ...S.rows.slice(at)]);
|
||
toast(`Duplicated ${copies.length} row(s)${!S.cols[0].text ? ` with new ${S.cols[0].name} values` : ""}.`);
|
||
}
|
||
|
||
async function deleteRows() {
|
||
const S = T.cur;
|
||
if (!S || !S.view.length) return;
|
||
const pick = new Set(S.selectedRows());
|
||
if (pick.size > 1 && !(await confirmBox("Delete rows", `Delete ${pick.size} rows?`, "Delete", "danger"))) return;
|
||
S.rowsStep(S.rows.filter((_, i) => !pick.has(i)));
|
||
}
|
||
|
||
// ---------- save / import / export ----------
|
||
async function save() {
|
||
const S = T.cur;
|
||
if (!S || T.busy || !S.dirty()) return;
|
||
S.commitEdit();
|
||
T.busy = true;
|
||
try {
|
||
const enums = Object.fromEntries(S.cols.filter((c) => c.enums).map((c) => [c.name, c.enums]));
|
||
const r = await call(
|
||
"dat:save",
|
||
S.id,
|
||
S.rows.map((x) => ({ o: x.o, v: x.v })),
|
||
{ enums },
|
||
);
|
||
S.info = r;
|
||
S.setData(r.rows);
|
||
S.undo = [];
|
||
S.redo = [];
|
||
S.applyView();
|
||
S.layout();
|
||
toast(`Saved ${S.name}: ${r.rows.length} rows${r.backup ? ` (backup: ${r.backup.split(/[\\/]/).pop()})` : ""}.`);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
} finally {
|
||
T.busy = false;
|
||
status();
|
||
}
|
||
}
|
||
|
||
async function exportTable() {
|
||
const S = T.cur;
|
||
if (!S) return;
|
||
const file = await call("dialog:saveAs", `${S.name}.xlsx`, "xlsx");
|
||
if (!file) return;
|
||
try {
|
||
const rows = S.rows.map((r) => r.v);
|
||
const out = /\.csv$/i.test(file) ? await call("dat:exportCsv", S.id, rows, file) : await call("dat:exportXlsx", S.id, rows, file);
|
||
toast(`Exported ${rows.length} rows to ${out}`);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
}
|
||
|
||
async function importTable() {
|
||
const S = T.cur;
|
||
if (!S) return;
|
||
const file = await call("dat:pickFile", "Import rows from .xlsx / .csv", "sheet");
|
||
if (!file) return;
|
||
let r;
|
||
try {
|
||
r = await call("dat:import", S.id, file);
|
||
} catch (err) {
|
||
return toast(err.message, true);
|
||
}
|
||
const notes = [r.missing.length ? `Columns not in the file (left empty / 0): ${r.missing.join(", ")}.` : null, r.extra.length ? `Columns in the file that the table doesn't have (ignored): ${r.extra.join(", ")}.` : null].filter(Boolean);
|
||
const ok = await confirmBox("Import", [`Replace the ${S.rows.length} rows of ${S.name} with the ${r.rows.length} rows of ${file.split(/[\\/]/).pop()}? You can undo it, and nothing is written until you Save.`, ...notes].join("\n\n"), "Replace");
|
||
if (!ok) return;
|
||
// rows that match an existing key keep their original (so unchanged cells stay byte-identical)
|
||
const byKey = new Map(S.rows.map((x) => [x.v[0], x.o]));
|
||
S.rowsStep(r.rows.map((v) => ({ o: byKey.has(v[0]) ? byKey.get(v[0]) : -1, v: v.map((x, i) => (S.cols[i].text ? x : x.trim() || "0")) })));
|
||
}
|
||
|
||
// ---------- status ----------
|
||
function status() {
|
||
const S = T.cur;
|
||
$("dt-save").disabled = !S || !S.dirty() || T.busy;
|
||
$("dt-undo").disabled = !S?.undo.length;
|
||
$("dt-redo").disabled = !S?.redo.length;
|
||
for (const id of ["dt-add", "dt-dup", "dt-del", "dt-import", "dt-export", "dt-enc", "dt-search"]) $(id).disabled = !S;
|
||
if (!S) return ($("dt-status").textContent = "");
|
||
const s = S.sel();
|
||
const changed = S.rows.filter((r) => r.o < 0 || r.v.some((v, c) => v !== S.orig[r.o]?.[c])).length;
|
||
const col = S.cols[S.act.c];
|
||
const row = S.rows[S.view[S.act.r]];
|
||
$("dt-status").textContent = [
|
||
`${S.name} · ${S.info.label}`,
|
||
`${S.rows.length} rows${S.view.length !== S.rows.length ? ` (${S.view.length} shown)` : ""} × ${S.cols.length} columns · ${S.info.rowSize} bytes/row · header ${S.info.headerVersion}`,
|
||
changed || S.rows.length !== S.orig.length ? `${changed} changed row(s)${S.rows.length !== S.orig.length ? `, ${S.rows.length - S.orig.length >= 0 ? "+" : ""}${S.rows.length - S.orig.length} rows` : ""}` : "no changes",
|
||
col && row ? `${col.name} (${col.typeName})${col.meta?.desc ? ` — ${col.meta.desc}` : ""}${s.r1 > s.r0 || s.c1 > s.c0 ? ` · ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} selected` : ""}` : "",
|
||
S.meta?.source ? `source: ${S.meta.source}` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
}
|
||
|
||
// ---------- wiring ----------
|
||
$("dt-add").addEventListener("click", addRow);
|
||
$("dt-dup").addEventListener("click", duplicateRows);
|
||
$("dt-del").addEventListener("click", deleteRows);
|
||
$("dt-undo").addEventListener("click", () => T.cur?.doUndo());
|
||
$("dt-redo").addEventListener("click", () => T.cur?.doRedo());
|
||
$("dt-save").addEventListener("click", save);
|
||
$("dt-export").addEventListener("click", exportTable);
|
||
$("dt-import").addEventListener("click", importTable);
|
||
$("dt-search").addEventListener("input", () => {
|
||
if (!T.cur) return;
|
||
T.cur.filter = $("dt-search").value.trim();
|
||
T.cur.applyView();
|
||
T.cur.layout();
|
||
status();
|
||
});
|
||
$("dt-enc").addEventListener("change", async () => {
|
||
const S = T.cur;
|
||
if (!S) return;
|
||
if (S.dirty() && !(await confirmBox("Code page", "Reading the texts in another code page drops the unsaved changes.", "Switch", "danger"))) {
|
||
$("dt-enc").value = S.info.encoding;
|
||
return;
|
||
}
|
||
const d = await call("dat:encoding", S.id, $("dt-enc").value);
|
||
S.info = { ...S.info, ...d };
|
||
S.setData(d.rows);
|
||
S.undo = [];
|
||
S.redo = [];
|
||
S.applyView();
|
||
S.layout();
|
||
status();
|
||
});
|
||
$("dt-client").addEventListener("click", async () => {
|
||
const dir = await call("dialog:folder", "Game folder (with the .iop packs)");
|
||
if (!dir) return;
|
||
T.src = { kind: "packs", folder: dir, name: dir };
|
||
listTables();
|
||
});
|
||
$("dt-server").addEventListener("click", async () => {
|
||
const dir = await call("dialog:folder", "The game server's tables folder (ls_gamesvr\\config\\tables)");
|
||
if (!dir) return;
|
||
LS("dt:server", dir);
|
||
listTables();
|
||
});
|
||
$("dt-file").addEventListener("click", async () => {
|
||
const file = await call("dat:pickFile", "Open a .dat table", "dat");
|
||
if (file) openTable({ kind: "file", file }, `file|${file}`);
|
||
});
|
||
$("dt-maker").addEventListener("click", async () => {
|
||
const dir = await call("dialog:folder", "ioDataMaker folder (with Config.xlsx and ExcelData)");
|
||
if (!dir) return;
|
||
LS("dt:maker", dir);
|
||
toast("Column descriptions and enum names come from there now (reopen the table).");
|
||
});
|
||
$("dt-reload").addEventListener("click", listTables);
|
||
window.addEventListener("beforeunload", () => T.cur && call("dat:close", T.cur.id));
|
||
document.querySelector('#tabs [data-view="tables"]').addEventListener("click", () => {
|
||
if (!T.listed) {
|
||
T.listed = true;
|
||
listTables();
|
||
status();
|
||
}
|
||
});
|
||
window.addEventListener("profile-changed", () => {
|
||
T.listed = false;
|
||
T.src = null;
|
||
if ($("view-tables").classList.contains("on")) {
|
||
T.listed = true;
|
||
listTables();
|
||
}
|
||
});
|
||
})();
|