458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
(() => {
|
|
const el = (id) => document.getElementById(id);
|
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
|
|
|
const state = {
|
|
mapId: null,
|
|
map: null,
|
|
callbacks: {},
|
|
readOnly: false,
|
|
dirty: false,
|
|
activeTool: "pan",
|
|
view: { scale: 1, panX: 0, panY: 0 },
|
|
panning: null,
|
|
tipVisible: true,
|
|
};
|
|
|
|
const titleEl = el("mapEditorTitle");
|
|
const dirtyEl = el("mapEditorDirty");
|
|
const canvasWrapEl = el("mapEditorCanvasWrap");
|
|
const canvasInnerEl = el("mapEditorCanvasInner");
|
|
const sheetEl = el("mapEditorSheet");
|
|
const imageEl = el("mapEditorImage");
|
|
const emptyEl = el("mapEditorEmpty");
|
|
const tipEl = el("mapEditorCanvasTip");
|
|
const uploadInputEl = el("mapEditorUploadInput");
|
|
const menuDialogEl = el("mapEditorMenuDialog");
|
|
const settingsDialogEl = el("mapEditorSettingsDialog");
|
|
const activateDialogEl = el("mapActivateDialog");
|
|
|
|
const toolBtnEls = () => document.querySelectorAll(".mapEditorMapTool[data-tool]");
|
|
|
|
const settingsFields = {
|
|
name: el("mapSettingsName"),
|
|
desc: el("mapSettingsDesc"),
|
|
resolution: el("mapSettingsResolution"),
|
|
originX: el("mapSettingsOriginX"),
|
|
originY: el("mapSettingsOriginY"),
|
|
originYaw: el("mapSettingsOriginYaw"),
|
|
};
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(path, { credentials: "include", ...opts });
|
|
if (!res.ok) {
|
|
let msg = res.statusText;
|
|
try {
|
|
const err = await res.json();
|
|
if (err.error) msg = err.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
function mapImageUrl(map) {
|
|
if (!map?.id || !map.image_file) return null;
|
|
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
|
|
}
|
|
|
|
function setDirty(flag) {
|
|
state.dirty = !!flag;
|
|
if (dirtyEl) dirtyEl.hidden = !state.dirty;
|
|
el("mapEditorSaveBtn")?.toggleAttribute("disabled", !state.dirty || state.readOnly);
|
|
}
|
|
|
|
function dismissCanvasTip() {
|
|
if (!state.tipVisible) return;
|
|
state.tipVisible = false;
|
|
if (tipEl) tipEl.hidden = true;
|
|
}
|
|
|
|
function updateCanvasCursor() {
|
|
if (!canvasWrapEl) return;
|
|
canvasWrapEl.classList.toggle("is-pan-tool", state.activeTool === "pan" && !state.panning);
|
|
canvasWrapEl.classList.toggle("is-panning", !!state.panning);
|
|
}
|
|
|
|
function setActiveTool(tool) {
|
|
if (tool !== "pan") return;
|
|
state.activeTool = tool;
|
|
toolBtnEls().forEach((btn) => {
|
|
btn.classList.toggle("is-active", btn.dataset.tool === tool);
|
|
});
|
|
updateCanvasCursor();
|
|
}
|
|
|
|
function centerSheetInView() {
|
|
if (!canvasWrapEl || !sheetEl) return;
|
|
const wrap = canvasWrapEl.getBoundingClientRect();
|
|
const sw = sheetEl.offsetWidth || 480;
|
|
const sh = sheetEl.offsetHeight || 360;
|
|
state.view.panX = Math.max(40, (wrap.width - sw * state.view.scale) / 2);
|
|
state.view.panY = Math.max(40, (wrap.height - sh * state.view.scale) / 2);
|
|
}
|
|
|
|
function applyViewTransform() {
|
|
if (!canvasInnerEl) return;
|
|
const { scale, panX, panY } = state.view;
|
|
canvasInnerEl.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
|
}
|
|
|
|
function fitToView() {
|
|
dismissCanvasTip();
|
|
if (!canvasWrapEl || !sheetEl) return;
|
|
const wrap = canvasWrapEl.getBoundingClientRect();
|
|
const sw = imageEl && !imageEl.hidden ? imageEl.naturalWidth || sheetEl.offsetWidth : sheetEl.offsetWidth;
|
|
const sh = imageEl && !imageEl.hidden ? imageEl.naturalHeight || sheetEl.offsetHeight : sheetEl.offsetHeight;
|
|
const pad = 48;
|
|
const scale = Math.min((wrap.width - pad) / sw, (wrap.height - pad) / sh, 4);
|
|
state.view.scale = Math.max(0.1, scale);
|
|
state.view.panX = (wrap.width - sw * state.view.scale) / 2;
|
|
state.view.panY = (wrap.height - sh * state.view.scale) / 2;
|
|
applyViewTransform();
|
|
updateCanvasCursor();
|
|
}
|
|
|
|
function zoomBy(factor) {
|
|
dismissCanvasTip();
|
|
state.view.scale = Math.min(8, Math.max(0.1, state.view.scale * factor));
|
|
applyViewTransform();
|
|
}
|
|
|
|
function updateSheetSize() {
|
|
if (!sheetEl || !imageEl) return;
|
|
if (!imageEl.hidden && imageEl.naturalWidth) {
|
|
sheetEl.style.width = `${imageEl.naturalWidth}px`;
|
|
sheetEl.style.minHeight = `${imageEl.naturalHeight}px`;
|
|
} else {
|
|
sheetEl.style.width = "";
|
|
sheetEl.style.minWidth = "480px";
|
|
sheetEl.style.minHeight = "360px";
|
|
}
|
|
}
|
|
|
|
function renderMapImage() {
|
|
const url = mapImageUrl(state.map);
|
|
if (url && imageEl) {
|
|
imageEl.src = url;
|
|
imageEl.hidden = false;
|
|
if (emptyEl) emptyEl.hidden = true;
|
|
} else {
|
|
if (imageEl) {
|
|
imageEl.hidden = true;
|
|
imageEl.removeAttribute("src");
|
|
}
|
|
if (emptyEl) emptyEl.hidden = false;
|
|
}
|
|
updateMenuActionsUi();
|
|
updateSheetSize();
|
|
imageEl?.addEventListener(
|
|
"load",
|
|
() => {
|
|
updateSheetSize();
|
|
fitToView();
|
|
},
|
|
{ once: true },
|
|
);
|
|
if (!url) {
|
|
centerSheetInView();
|
|
applyViewTransform();
|
|
}
|
|
}
|
|
|
|
function fillSettingsForm() {
|
|
const map = state.map;
|
|
if (!map) return;
|
|
if (settingsFields.name) settingsFields.name.value = map.name || "";
|
|
if (settingsFields.desc) settingsFields.desc.value = map.description || "";
|
|
if (settingsFields.resolution) settingsFields.resolution.value = map.resolution != null ? map.resolution : 0.05;
|
|
if (settingsFields.originX) settingsFields.originX.value = map.origin_x != null ? map.origin_x : 0;
|
|
if (settingsFields.originY) settingsFields.originY.value = map.origin_y != null ? map.origin_y : 0;
|
|
if (settingsFields.originYaw) settingsFields.originYaw.value = map.origin_yaw != null ? map.origin_yaw : 0;
|
|
}
|
|
|
|
function readSettingsPayload() {
|
|
return {
|
|
name: settingsFields.name?.value.trim() || "",
|
|
description: settingsFields.desc?.value.trim() || "",
|
|
resolution: Number(settingsFields.resolution?.value) || 0.05,
|
|
origin_x: Number(settingsFields.originX?.value) || 0,
|
|
origin_y: Number(settingsFields.originY?.value) || 0,
|
|
origin_yaw: Number(settingsFields.originYaw?.value) || 0,
|
|
};
|
|
}
|
|
|
|
function updateHeader() {
|
|
if (titleEl) titleEl.textContent = state.map?.name || "—";
|
|
}
|
|
|
|
function updateMenuActionsUi() {
|
|
const hasImage = !!(state.map?.image_file);
|
|
const ro = state.readOnly;
|
|
el("mapMenuUploadOverwrite")?.toggleAttribute("disabled", ro);
|
|
el("mapMenuUploadAppend")?.toggleAttribute("disabled", true);
|
|
el("mapMenuDownload")?.toggleAttribute("disabled", !hasImage);
|
|
el("mapMenuRecordOverwrite")?.toggleAttribute("disabled", true);
|
|
el("mapMenuRecordAppend")?.toggleAttribute("disabled", true);
|
|
}
|
|
|
|
function applyReadOnlyUi() {
|
|
const ro = state.readOnly;
|
|
el("mapEditorMenuBtn")?.toggleAttribute("disabled", ro);
|
|
el("mapEditorSaveBtn")?.toggleAttribute("disabled", ro || !state.dirty);
|
|
el("mapEditorSettingsBtn")?.toggleAttribute("disabled", ro);
|
|
updateMenuActionsUi();
|
|
}
|
|
|
|
async function reloadMap() {
|
|
if (!state.mapId) return;
|
|
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
|
|
updateHeader();
|
|
renderMapImage();
|
|
fillSettingsForm();
|
|
}
|
|
|
|
function open(mapId, callbacks = {}) {
|
|
state.mapId = mapId;
|
|
state.callbacks = callbacks;
|
|
state.readOnly = !!callbacks.readOnly || !callbacks.canWrite;
|
|
state.dirty = false;
|
|
state.tipVisible = true;
|
|
state.activeTool = "pan";
|
|
if (tipEl) {
|
|
tipEl.hidden = false;
|
|
tipEl.textContent = t("maps.editor.canvasTip");
|
|
}
|
|
setActiveTool("pan");
|
|
setDirty(false);
|
|
applyReadOnlyUi();
|
|
reloadMap().catch((e) => alert(e.message));
|
|
}
|
|
|
|
function close() {
|
|
state.mapId = null;
|
|
state.map = null;
|
|
state.callbacks = {};
|
|
menuDialogEl?.close();
|
|
settingsDialogEl?.close();
|
|
activateDialogEl?.close();
|
|
}
|
|
|
|
function loadImageDimensions(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error("invalid image"));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
async function uploadImage(file) {
|
|
if (!state.map || !file || state.readOnly) return;
|
|
if (!/\.png$/i.test(file.name)) {
|
|
alert(t("maps.error.pngOnly"));
|
|
return;
|
|
}
|
|
const dims = await loadImageDimensions(file);
|
|
const form = new FormData();
|
|
form.append("file", file, file.name.endsWith(".png") ? file.name : `${file.name}.png`);
|
|
const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/image`, {
|
|
credentials: "include",
|
|
method: "POST",
|
|
body: form,
|
|
});
|
|
if (!res.ok) {
|
|
let msg = res.statusText;
|
|
try {
|
|
const err = await res.json();
|
|
if (err.error) msg = err.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
let updated = await res.json();
|
|
updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...readSettingsPayload(),
|
|
width: dims.width,
|
|
height: dims.height,
|
|
}),
|
|
});
|
|
state.map = updated;
|
|
state.callbacks.onMapUpdated?.(updated);
|
|
setDirty(false);
|
|
renderMapImage();
|
|
menuDialogEl?.close();
|
|
promptActivate();
|
|
}
|
|
|
|
async function saveMap() {
|
|
if (!state.map || state.readOnly) return;
|
|
const payload = readSettingsPayload();
|
|
if (!payload.name) {
|
|
alert(t("maps.error.nameEmpty"));
|
|
return;
|
|
}
|
|
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
state.map = updated;
|
|
state.callbacks.onMapUpdated?.(updated);
|
|
setDirty(false);
|
|
updateHeader();
|
|
menuDialogEl?.close();
|
|
promptActivate();
|
|
}
|
|
|
|
function promptActivate() {
|
|
if (!state.map?.image_file) return;
|
|
if (state.callbacks.getActiveMapId?.() === state.map.id) return;
|
|
const textEl = el("mapActivateDialogText");
|
|
if (textEl) {
|
|
textEl.textContent = t("maps.activateDialog.text", { name: state.map.name });
|
|
}
|
|
activateDialogEl?.showModal();
|
|
}
|
|
|
|
async function activateCurrentMap() {
|
|
if (!state.map) return;
|
|
await api("/api/robot/active_map", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ map_id: state.map.id }),
|
|
});
|
|
state.callbacks.onActivated?.(state.map.id);
|
|
activateDialogEl?.close();
|
|
}
|
|
|
|
function bindCanvasPanZoom() {
|
|
canvasWrapEl?.addEventListener("wheel", (evt) => {
|
|
evt.preventDefault();
|
|
dismissCanvasTip();
|
|
const factor = evt.deltaY < 0 ? 1.1 : 0.9;
|
|
zoomBy(factor);
|
|
}, { passive: false });
|
|
|
|
canvasWrapEl?.addEventListener("mousedown", (evt) => {
|
|
if (evt.button !== 0 || state.activeTool !== "pan") return;
|
|
dismissCanvasTip();
|
|
state.panning = {
|
|
startX: evt.clientX,
|
|
startY: evt.clientY,
|
|
startPanX: state.view.panX,
|
|
startPanY: state.view.panY,
|
|
};
|
|
updateCanvasCursor();
|
|
});
|
|
|
|
window.addEventListener("mousemove", (evt) => {
|
|
if (!state.panning) return;
|
|
state.view.panX = state.panning.startPanX + (evt.clientX - state.panning.startX);
|
|
state.view.panY = state.panning.startPanY + (evt.clientY - state.panning.startY);
|
|
applyViewTransform();
|
|
});
|
|
|
|
window.addEventListener("mouseup", () => {
|
|
if (!state.panning) return;
|
|
state.panning = null;
|
|
updateCanvasCursor();
|
|
});
|
|
}
|
|
|
|
function bindEvents() {
|
|
el("mapEditorBackBtn")?.addEventListener("click", () => {
|
|
if (state.dirty && !confirm(t("maps.editor.unsavedLeave"))) return;
|
|
state.callbacks.onClose?.();
|
|
close();
|
|
});
|
|
|
|
el("mapEditorHelpBtn")?.addEventListener("click", () => alert(t("maps.editor.helpText")));
|
|
el("mapEditorMenuBtn")?.addEventListener("click", () => {
|
|
updateMenuActionsUi();
|
|
menuDialogEl?.showModal();
|
|
});
|
|
el("mapMenuCancelBtn")?.addEventListener("click", () => menuDialogEl?.close());
|
|
menuDialogEl?.addEventListener("cancel", (evt) => {
|
|
evt.preventDefault();
|
|
menuDialogEl?.close();
|
|
});
|
|
el("mapEditorSettingsBtn")?.addEventListener("click", () => {
|
|
fillSettingsForm();
|
|
settingsDialogEl?.showModal();
|
|
});
|
|
el("mapEditorSaveBtn")?.addEventListener("click", () => {
|
|
saveMap().catch((e) => alert(e.message));
|
|
});
|
|
el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan"));
|
|
el("mapEditorFitBtn")?.addEventListener("click", fitToView);
|
|
el("mapEditorCenterBtn")?.addEventListener("click", () => {
|
|
dismissCanvasTip();
|
|
centerSheetInView();
|
|
applyViewTransform();
|
|
});
|
|
el("mapEditorZoomInBtn")?.addEventListener("click", () => zoomBy(1.2));
|
|
el("mapEditorZoomOutBtn")?.addEventListener("click", () => zoomBy(1 / 1.2));
|
|
|
|
el("mapMenuUploadOverwrite")?.addEventListener("click", () => uploadInputEl?.click());
|
|
el("mapMenuDownload")?.addEventListener("click", () => {
|
|
const url = mapImageUrl(state.map);
|
|
if (!url) return;
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${state.map.name || "map"}.png`;
|
|
a.click();
|
|
menuDialogEl?.close();
|
|
});
|
|
|
|
uploadInputEl?.addEventListener("change", () => {
|
|
const file = uploadInputEl.files?.[0];
|
|
uploadInputEl.value = "";
|
|
if (!file) return;
|
|
uploadImage(file).catch((e) => alert(e.message));
|
|
});
|
|
|
|
el("mapEditorSettingsForm")?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
if (!state.map) return;
|
|
Object.assign(state.map, readSettingsPayload());
|
|
setDirty(true);
|
|
updateHeader();
|
|
settingsDialogEl?.close();
|
|
});
|
|
|
|
el("mapActivateYesBtn")?.addEventListener("click", () => {
|
|
activateCurrentMap().catch((e) => alert(e.message));
|
|
});
|
|
el("mapActivateNoBtn")?.addEventListener("click", () => activateDialogEl?.close());
|
|
|
|
Object.values(settingsFields).forEach((node) => {
|
|
node?.addEventListener("input", () => setDirty(true));
|
|
});
|
|
|
|
window.addEventListener("lm:locale-change", () => {
|
|
if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip");
|
|
updateHeader();
|
|
});
|
|
}
|
|
|
|
bindCanvasPanZoom();
|
|
bindEvents();
|
|
|
|
window.MapEditorApp = { open, close, reloadMap };
|
|
})();
|