// @ts-nocheck
import * as feather from "feather-icons";
const CATEGORY_SCALE = {
"0": { label: "(100% Original Strength)", className: "bg-green-100 text-green-800 border-green-200" },
"1": { label: "(100% Original Strength)", className: "bg-green-200 text-green-800 border-green-200" },
"2": { label: "(95-100% Original Strength)", className: "bg-yellow-100 text-yellow-800 border-yellow-200" },
"3": { label: "(75-95% Original Strength)", className: "bg-yellow-200 text-yellow-800 border-yellow-200" },
"4": { label: "(50-75% Original Strength)", className: "bg-orange-200 text-orange-800 border-orange-200" },
"5": { label: "(<50% Original Strength)", className: "bg-red-200 text-red-800 border-red-200" },
};
const PRIORITY_SCALE = {
"1": { label: "(Immediate)", className: "bg-red-200 text-red-800 border-red-200" },
"2": { label: "(1 Year)", className: "bg-orange-200 text-orange-800 border-orange-200" },
"3": { label: "(3 Years)", className: "bg-green-200 text-green-800 border-green-200" },
X: { label: "(At Use)", className: "bg-purple-200 text-purple-800 border-purple-200" },
M: { label: "(Monitor)", className: "bg-blue-200 text-blue-800 border-blue-200" },
};
const MAX_PHOTOS_PRIMARY_PAGE = 2;
const MAX_PHOTOS_CONTINUATION_PAGE = 6;
function parseScaleCode(value) {
const match = String(value || "").trim().match(/^[A-Za-z0-9]+/);
return match ? match[0].toUpperCase() : String(value || "").trim().toUpperCase();
}
function buildScaleBadge(value, scale) {
const raw = String(value || "").trim();
if (!raw) {
return { text: "", className: "bg-gray-50 text-gray-700 border-gray-200" };
}
const code = parseScaleCode(raw);
const tone = scale[code];
if (!tone) {
return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
}
return { text: `${code} ${tone.label}`, className: tone.className };
}
class ReportEditor extends HTMLElement {
constructor() {
super();
this._mounted = false;
this.mode = "overlay";
this.BASE_W = 595; // A4 points-ish (screen independent model)
this.BASE_H = 842;
this.state = {
isOpen: false,
zoom: 1,
activePage: 0,
pages: [], // flattened pages
sections: [], // [{ id, title?, pages: [...] }]
selectedId: null,
tool: "select", // select | text | rect
dragging: null, // { id, startX, startY, origX, origY }
resizing: null, // { id, handle, startX, startY, orig }
undo: [], // stack of serialized states (current page)
redo: [],
payload: null,
};
this.sessionId = null;
this.apiBase = null;
this._saveTimer = null;
this._savingPromise = null;
this._photoRatios = new Map();
this._indexMap = [];
}
connectedCallback() {
if (this._mounted) return;
this._mounted = true;
this.render();
this.bind();
this.setAttribute("data-mode", this.mode);
this.hide();
}
disconnectedCallback() {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
}
// Public API
open({
payload,
pageIndex = 0,
totalPages = 6,
sessionId = null,
apiBase = null,
mode = "overlay",
} = {}) {
this.mode = mode === "page" ? "page" : "overlay";
this.setAttribute("data-mode", this.mode);
this.state.payload = payload ?? null;
this.state.isOpen = true;
this.sessionId =
sessionId ||
(window.REPEX && typeof window.REPEX.getSessionId === "function"
? window.REPEX.getSessionId()
: null);
this.apiBase =
apiBase ||
window.REPEX_API_BASE ||
(window.REPEX && window.REPEX.apiBase ? window.REPEX.apiBase : null);
const initialCount = Math.max(Number(totalPages) || 1, 1);
// Load existing editor sections from storage, else initialize
const stored = this._loadPages();
if (stored && Array.isArray(stored.sections) && stored.sections.length) {
this._setSections(stored.sections);
} else if (stored && Array.isArray(stored.pages) && stored.pages.length) {
this._setSections([
{ id: this._sectionId(), title: "Section 1", pages: stored.pages },
]);
} else {
this._setSections([
{
id: this._sectionId(),
title: "Section 1",
pages: Array.from({ length: initialCount }, () => ({ items: [] })),
},
]);
this._savePages();
}
this._ensurePageCount(initialCount);
this.state.activePage = Math.min(Math.max(0, pageIndex), this.state.pages.length - 1);
this.state.selectedId = null;
this.state.tool = "select";
this.state.undo = [];
this.state.redo = [];
if (!this.$overlay) {
this.render();
this.bind();
}
this.show();
this.updateAll();
requestAnimationFrame(() => this.updateAll());
setTimeout(() => this.updateAll(), 0);
if (this.sessionId) {
this._loadPagesFromServer().then((sections) => {
if (sections && sections.length) {
this._setSections(sections);
this._ensurePageCount(Math.max(initialCount, this.state.pages.length));
this.state.activePage = Math.min(
Math.max(0, pageIndex),
this.state.pages.length - 1
);
this.updateAll();
}
});
}
}
close() {
this.state.isOpen = false;
this.hide();
this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
}
async flushSave() {
if (this._saveTimer) {
clearTimeout(this._saveTimer);
this._saveTimer = null;
}
return this._savePagesToServer();
}
// ---------- Rendering ----------
render() {
this.innerHTML = `
A4
Edit Report
Drag, resize, format and arrange elements
Auto-saved
Drag elements to move. Drag corner handles to resize. Double-click text to edit.
`;
}
bind() {
this.$overlay = this.querySelector("[data-overlay]");
this.$pageList = this.querySelector("[data-page-list]");
this.$canvas = this.querySelector("[data-canvas]");
this.$zoomLabel = this.querySelector("[data-zoom-label]");
this.$emptyProps = this.querySelector("[data-empty-props]");
this.$props = this.querySelector("[data-props]");
this.$propsText = this.querySelector("[data-props-text]");
this.$propsRect = this.querySelector("[data-props-rect]");
this.$propsImage = this.querySelector("[data-props-image]");
this.$templateLogoSelect = this.querySelector("[data-prop-template-logo]");
this.$imgFile = this.querySelector('[data-file="image"]');
this.$replaceFile = this.querySelector('[data-file="replace"]');
this.$templateLogoFile = this.querySelector('[data-file="template-logo"]');
if (this.$canvas && "ResizeObserver" in window) {
this._resizeObserver = new ResizeObserver(() => {
if (this.state.isOpen) {
this.renderCanvas();
this.updateCanvasScale();
}
});
this._resizeObserver.observe(this.$canvas);
}
// header buttons
this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
// tools
this.querySelectorAll(".toolBtn").forEach(btn => {
btn.addEventListener("click", () => {
this.state.tool = btn.dataset.tool;
this.updateToolbar();
});
});
// toolbar buttons
this.querySelector('[data-btn="add-image"]').addEventListener("click", () => this.$imgFile.click());
this.$imgFile.addEventListener("change", (e) => this._handleImageUpload(e, "add"));
this.querySelector('[data-btn="zoom-in"]').addEventListener("click", () => this.setZoom(this.state.zoom + 0.1));
this.querySelector('[data-btn="zoom-out"]').addEventListener("click", () => this.setZoom(this.state.zoom - 0.1));
// pages
this.querySelector('[data-btn="add-page"]').addEventListener("click", () => this.addPage());
// properties buttons
this.querySelector('[data-btn="delete"]').addEventListener("click", () => this.deleteSelected());
this.querySelector('[data-btn="duplicate"]').addEventListener("click", () => this.duplicateSelected());
this.querySelector('[data-btn="bring-front"]').addEventListener("click", () => this.bringFront());
this.querySelector('[data-btn="send-back"]').addEventListener("click", () => this.sendBack());
// text props
this.querySelector('[data-btn="bold"]').addEventListener("click", () => this.toggleTextStyle("bold"));
this.querySelector('[data-btn="italic"]').addEventListener("click", () => this.toggleTextStyle("italic"));
this.querySelector('[data-btn="underline"]').addEventListener("click", () => this.toggleTextStyle("underline"));
this.querySelector('[data-btn="align-left"]').addEventListener("click", () => this.setTextAlign("left"));
this.querySelector('[data-btn="align-center"]').addEventListener("click", () => this.setTextAlign("center"));
this.querySelector('[data-btn="align-right"]').addEventListener("click", () => this.setTextAlign("right"));
this.querySelector('[data-prop="fontSize"]').addEventListener("input", (e) => this.setProp("fontSize", Number(e.target.value || 12)));
this.querySelector('[data-prop="color"]').addEventListener("input", (e) => this.setProp("color", e.target.value));
// rect props
this.querySelector('[data-prop="fill"]').addEventListener("input", (e) => this.setProp("fill", e.target.value));
this.querySelector('[data-prop="stroke"]').addEventListener("input", (e) => this.setProp("stroke", e.target.value));
this.querySelector('[data-prop="strokeWidth"]').addEventListener("input", (e) => this.setProp("strokeWidth", Number(e.target.value || 0)));
// image replace
this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
// company logo controls
this.querySelector('[data-btn="upload-template-logo"]').addEventListener("click", () => {
this.$templateLogoFile.click();
});
this.$templateLogoFile.addEventListener("change", (e) => {
const file = e.target.files && e.target.files[0];
if (file) {
this._uploadTemplateLogo(file);
}
e.target.value = "";
});
this.$templateLogoSelect.addEventListener("change", () => {
const template = this._getTemplate();
template.company_logo = this.$templateLogoSelect.value;
this._savePages();
this.renderCanvas();
});
// canvas interactions
this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
window.addEventListener("pointermove", (e) => this.onPointerMove(e));
window.addEventListener("pointerup", () => this.onPointerUp());
// keyboard shortcuts
window.addEventListener("keydown", (e) => {
if (!this.state.isOpen) return;
if (e.key === "Escape") {
e.preventDefault();
this.close();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
this.undo();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
this.redo();
}
if (e.key === "Delete" || e.key === "Backspace") {
// avoid deleting while typing in contenteditable
const active = document.activeElement;
const isEditingText = active && active.getAttribute && active.getAttribute("contenteditable") === "true";
if (!isEditingText) this.deleteSelected();
}
});
}
// ---------- Core helpers ----------
show() {
this.$overlay.classList.remove("hidden");
this.state.isOpen = true;
this.setAttribute("data-mode", this.mode);
this.updateAll();
}
hide() {
this.$overlay.classList.add("hidden");
this.state.isOpen = false;
}
setZoom(z) {
const clamped = Math.max(0.6, Math.min(1.4, Number(z.toFixed(2))));
this.state.zoom = clamped;
this.updateCanvasScale();
}
get activePage() {
return this.state.pages[this.state.activePage];
}
updateAll() {
this.updateToolbar();
this.renderPageList();
this.renderCanvas();
this.updateCanvasScale();
this.updatePropsPanel();
this._syncTemplateLogoOptions();
this.updateUndoRedoButtons();
this._refreshIcons();
}
updateToolbar() {
this.querySelectorAll(".toolBtn").forEach(btn => {
const active = btn.dataset.tool === this.state.tool;
btn.classList.toggle("bg-gray-900", active);
btn.classList.toggle("text-white", active);
btn.classList.toggle("border-gray-900", active);
if (!active) {
btn.classList.add("bg-white", "text-gray-800", "border-gray-200");
btn.classList.remove("bg-gray-900", "text-white", "border-gray-900");
} else {
btn.classList.remove("bg-white", "text-gray-800", "border-gray-200");
}
});
}
updateCanvasScale() {
if (!this.$canvas) return;
this._syncCanvasSize();
this.$canvas.style.transformOrigin = "top center";
this.$canvas.style.transform = `scale(${this.state.zoom})`;
this.$zoomLabel.textContent = `${Math.round(this.state.zoom * 100)}%`;
}
_refreshIcons() {
if (feather && typeof feather.replace === "function") {
feather.replace();
}
}
_getTemplate() {
if (!this.state.pages.length) return {};
const page = this.state.pages[this.state.activePage] || this.state.pages[0];
if (!page) return {};
if (!Array.isArray(page.items)) page.items = [];
if (!page.template || typeof page.template !== "object") {
page.template = {};
}
return page.template;
}
_bindTemplateFields() {
if (!this.$canvas) return;
const template = this._getTemplate();
this.$canvas.querySelectorAll("[data-template-field]").forEach((el) => {
const key = el.dataset.templateField;
if (!key) return;
const isScale = key === "category" || key === "priority";
const rawValue = template[key] || "";
const displayValue = isScale
? buildScaleBadge(
rawValue,
key === "category" ? CATEGORY_SCALE : PRIORITY_SCALE,
).text
: rawValue;
if (document.activeElement !== el) {
if (displayValue) {
if (el.textContent !== displayValue) {
el.textContent = displayValue;
}
}
}
const commitValue = () => {
const nextText = el.textContent || "";
if (isScale) {
const code = parseScaleCode(nextText);
template[key] = code || nextText;
} else {
template[key] = nextText;
}
if (this.$canvas) {
const nextDisplay = isScale
? buildScaleBadge(
template[key],
key === "category" ? CATEGORY_SCALE : PRIORITY_SCALE,
).text
: template[key] || "";
if (nextDisplay) {
this.$canvas
.querySelectorAll(`[data-template-field="${key}"]`)
.forEach((node) => {
if (node === el || document.activeElement === node) return;
if (node.textContent !== nextDisplay) {
node.textContent = nextDisplay;
}
});
}
}
this._savePages();
};
el.oninput = () => {
commitValue();
};
el.onblur = () => {
commitValue();
if (isScale) {
this.renderCanvas();
}
};
el.onpointerdown = (e) => {
e.stopPropagation();
};
el.onkeydown = (e) => {
if (e.key === "Enter" && el.dataset.multiline !== "true") {
e.preventDefault();
}
};
});
this._bindTemplateSelects();
}
_bindTemplateSelects() {
if (!this.$canvas) return;
const template = this._getTemplate();
this.$canvas.querySelectorAll("[data-template-select]").forEach((el) => {
const key = el.dataset.templateSelect;
if (!key) return;
const current = template[key] || "";
if (el.value !== String(current)) {
el.value = String(current);
}
el.onchange = () => {
template[key] = el.value;
this._savePages();
this.renderCanvas();
};
el.onpointerdown = (e) => {
e.stopPropagation();
};
});
}
_escape(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
_selectedPhotos(session) {
const uploads = (session && session.uploads && session.uploads.photos) || [];
const selectedOrder = (session && session.selected_photo_ids) || [];
const byId = new Map(uploads.map((photo) => [photo.id, photo]));
const selected = selectedOrder.map((id) => byId.get(id)).filter(Boolean);
return selected.length ? selected : uploads;
}
_normalizeKey(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
_resolveLogoUrl(session, rawValue) {
const value = String(rawValue || "").trim();
if (!value) return "";
if (/^(https?:|data:|\/)/i.test(value)) return value;
const uploads = (session && session.uploads && session.uploads.photos) || [];
const key = this._normalizeKey(value);
for (const photo of uploads) {
if (photo && photo.id && value === photo.id) {
return photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : "");
}
const name = photo && photo.name ? photo.name : "";
if (!name) continue;
const nameKey = this._normalizeKey(name);
const stemKey = this._normalizeKey(name.replace(/\.[^/.]+$/, ""));
if (key === nameKey || key === stemKey) {
return photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : "");
}
}
return "";
}
_syncTemplateLogoOptions() {
if (!this.$templateLogoSelect) return;
const session = this.state.payload || {};
const template = this._getTemplate();
const current = String(template.company_logo || "");
const uploads = (session && session.uploads && session.uploads.photos) || [];
const previousValue = this.$templateLogoSelect.value;
this.$templateLogoSelect.innerHTML = "";
const noneOption = document.createElement("option");
noneOption.value = "";
noneOption.textContent = "No logo (show placeholder)";
this.$templateLogoSelect.appendChild(noneOption);
uploads.forEach((photo) => {
const option = document.createElement("option");
option.value = String(photo.name || photo.id || "");
option.textContent = String(photo.name || photo.id || "Unnamed image");
if (option.value) {
this.$templateLogoSelect.appendChild(option);
}
});
const nextValue = current || previousValue || "";
this.$templateLogoSelect.value = nextValue;
}
async _uploadTemplateLogo(file) {
const base = this._apiRoot();
if (!base || !this.sessionId) {
this._toast("Missing session");
return;
}
const payload = this.state.payload || {};
const existing = new Set(
((payload.uploads && payload.uploads.photos) || []).map((photo) => photo.id),
);
try {
this._toast("Uploading logo...");
const form = new FormData();
form.append("file", file);
const res = await fetch(`${base}/sessions/${this.sessionId}/uploads`, {
method: "POST",
body: form,
});
if (!res.ok) {
throw new Error("Upload failed");
}
const updated = await res.json();
this.state.payload = updated;
const photos = (updated.uploads && updated.uploads.photos) || [];
const uploaded =
photos.find((photo) => !existing.has(photo.id)) ||
photos.find((photo) => photo.name === file.name);
const template = this._getTemplate();
template.company_logo = uploaded
? String(uploaded.name || uploaded.id || "")
: template.company_logo || "";
this._savePages();
this.renderCanvas();
this._syncTemplateLogoOptions();
this._toast("Logo uploaded");
} catch {
this._toast("Logo upload failed");
}
}
_photoKey(photo) {
if (!photo) return "";
return photo.id || photo.url || photo.name || "";
}
_photoUrl(photo) {
if (!photo) return "";
if (photo.url) return photo.url;
if (this.sessionId && photo.id) {
return `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}`;
}
return "";
}
_photoRatio(photo) {
const key = this._photoKey(photo);
if (!key) return 1;
const ratio = this._photoRatios.get(key);
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
_ensurePhotoRatios(photos) {
photos.forEach((photo) => {
const key = this._photoKey(photo);
const url = this._photoUrl(photo);
if (!key || !url || this._photoRatios.has(key)) return;
const img = new Image();
img.onload = () => {
const ratio = img.naturalWidth ? img.naturalHeight / img.naturalWidth : 1;
this._photoRatios.set(key, ratio || 1);
if (this.state.isOpen) {
this.renderCanvas();
}
};
img.onerror = () => {
this._photoRatios.set(key, 1);
};
img.src = url;
});
}
_computePhotoLayout(photos) {
const entries = photos.map((photo) => ({
photo,
ratio: this._photoRatio(photo),
}));
const memo = new Map();
const solve = (remaining) => {
if (remaining.length === 0) return { cost: 0, rows: [] };
const cacheKey = remaining.join(",");
const cached = memo.get(cacheKey);
if (cached) return cached;
const [first, ...rest] = remaining;
let bestCost = Number.POSITIVE_INFINITY;
let bestRows = [];
const single = solve(rest);
const singleCost = 2 * entries[first].ratio + single.cost;
if (singleCost < bestCost) {
bestCost = singleCost;
bestRows = [[first], ...single.rows];
}
for (let i = 0; i < rest.length; i += 1) {
const pair = rest[i];
const next = rest.filter((_, idx) => idx !== i);
const result = solve(next);
const pairCost = Math.max(entries[first].ratio, entries[pair].ratio) + result.cost;
if (pairCost < bestCost) {
bestCost = pairCost;
bestRows = [[first, pair], ...result.rows];
}
}
const value = { cost: bestCost, rows: bestRows };
memo.set(cacheKey, value);
return value;
};
const indices = entries.map((_, index) => index);
const solution = solve(indices);
const layout = [];
solution.rows.forEach((row) => {
if (row.length === 1) {
layout.push({ photo: entries[row[0]].photo, span: true });
} else {
layout.push({ photo: entries[row[0]].photo, span: false });
layout.push({ photo: entries[row[1]].photo, span: false });
}
});
return layout;
}
_photosForActivePage(session) {
const uploads = (session && session.uploads && session.uploads.photos) || [];
const byId = new Map(uploads.map((photo) => [photo.id, photo]));
const page = this.activePage || {};
const explicit = page.photo_ids || [];
if (explicit.length) {
return explicit.map((id) => byId.get(id)).filter(Boolean);
}
return [];
}
_photoSlot(photo, fallbackLabel) {
const url = this._photoUrl(photo);
if (!photo || !url) {
return `
No photo selected
`;
}
const label = this._escape(fallbackLabel || photo.name || "");
const safeUrl = this._escape(url);
const caption = this._tplField(
"figure_caption",
fallbackLabel || photo.name || "",
"Figure caption",
"text-[10px] text-gray-600 text-center w-full break-all leading-tight",
false,
false,
);
return `
${caption}
`;
}
_tplField(key, value, placeholder, className = "", multiline = false, inline = false) {
const safeValue = this._escape(value || "");
const safePlaceholder = this._escape(placeholder || "");
const multiAttr = multiline ? ' data-multiline="true"' : "";
const inlineAttr = inline ? ' style="display:inline-block;"' : "";
return `${safeValue}
`;
}
_tplSelectField(key, value, options, className = "") {
const safeValue = this._escape(value || "");
const optionHtml = options
.map((option) => {
const optValue = this._escape(option.value);
const optLabel = this._escape(option.label);
const selected = optValue === safeValue ? " selected" : "";
return ``;
})
.join("");
return ``;
}
_templateMarkup() {
const session = this.state.payload || {};
const template = this._getTemplate();
const sectionLabel = this._getActiveSectionLabel();
const inspectionDate =
template.inspection_date || session.inspection_date || "";
const inspector = template.inspector || "";
const docNumber =
template.document_no ||
session.document_no ||
(session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
const companyLogo = template.company_logo || "";
const figureCaption = template.figure_caption || "";
const reference = template.reference || "";
const area = template.area || "";
const itemDescription = template.item_description || "";
const functionalLocation = template.functional_location || "";
const categoryRaw = template.category || "";
const priorityRaw = template.priority || "";
const category = parseScaleCode(categoryRaw) || categoryRaw;
const priority = parseScaleCode(priorityRaw) || priorityRaw;
const requiredAction = template.required_action || "";
const categoryScale = {
"0": { label: "(100% Original Strength)", bg: "bg-green-100", text: "text-green-800", border: "border-green-200" },
"1": { label: "(100% Original Strength)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
"2": { label: "(95-100% Original Strength)", bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200" },
"3": { label: "(75-95% Original Strength)", bg: "bg-yellow-200", text: "text-yellow-800", border: "border-yellow-200" },
"4": { label: "(50-75% Original Strength)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
"5": { label: "(<50% Original Strength)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
};
const priorityScale = {
"1": { label: "(Immediate)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
"2": { label: "(1 Year)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
"3": { label: "(3 Years)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
X: { label: "(At Use)", bg: "bg-purple-200", text: "text-purple-800", border: "border-purple-200" },
M: { label: "(Monitor)", bg: "bg-blue-200", text: "text-blue-800", border: "border-blue-200" },
};
const categoryBadge = this._ratingBadge(category, categoryScale);
const priorityBadge = this._ratingBadge(priority, priorityScale);
const categoryOptions = ["0", "1", "2", "3", "4", "5"].map((key) => ({
value: key,
label: `${key} ${categoryScale[key].label}`,
}));
const priorityOptions = ["1", "2", "3", "X", "M"].map((key) => ({
value: key,
label: `${key} ${priorityScale[key].label}`,
}));
const variant =
(this.activePage && this.activePage.variant) || "full";
const photos = this._photosForActivePage(session).slice(0, 6);
this._ensurePhotoRatios(photos);
const orderLocked = !!(this.activePage && this.activePage.photo_order_locked);
const orderedPhotos = orderLocked
? photos
: this._computePhotoLayout(photos).map((entry) => entry.photo);
const displayedPhotos =
variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
const photoLayout =
(this.activePage && this.activePage.photo_layout) || "auto";
const normalizedLayout = String(photoLayout).toLowerCase();
const layoutMode =
normalizedLayout === "stacked" || normalizedLayout === "two-column"
? normalizedLayout
: "auto";
const photoColumnsClass =
layoutMode === "stacked"
? "columns-1"
: layoutMode === "two-column"
? "columns-2"
: displayedPhotos.length <= 1
? "columns-1"
: "columns-2";
const photoSlots = displayedPhotos.length
? displayedPhotos
.map((photo, idx) =>
this._photoSlot(photo, figureCaption || `Figure ${idx + 1}`),
)
.join("")
: this._photoSlot(null, "No photo selected");
const pageNum = this.state.activePage + 1;
const pageCount = this.state.pages.length || 1;
const observationsHtml =
variant === "full"
? `
Observations and Findings
Ref
${this._tplField("reference", reference, "Ref", "text-[10px] font-semibold text-gray-900")}
Area
${this._tplField("area", area, "Area", "text-[10px] font-semibold text-gray-900")}
Location
${this._tplField("functional_location", functionalLocation, "Location", "text-[10px] font-semibold text-gray-900")}
Category
${this._tplSelectField(
"category",
category,
categoryOptions,
`min-w-[140px] rounded-md border px-3 py-1 text-[10px] font-semibold text-center ${categoryBadge.className}`,
)}
Priority
${this._tplSelectField(
"priority",
priority,
priorityOptions,
`min-w-[140px] rounded-md border px-3 py-1 text-[10px] font-semibold text-center ${priorityBadge.className}`,
)}
Condition Description
${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-gray-700 text-[9px] font-medium leading-snug", true)}
Action Required
${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-gray-700 text-[9px] font-medium leading-snug", true)}
`
: "";
const photoTitle =
variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation";
return `
${observationsHtml}
${photoTitle}
${photoSlots}
`;
}
// ---------- Storage ----------
_storageKey() {
if (this.sessionId) {
return `repex_report_sections_v1_${this.sessionId}`;
}
return "repex_report_sections_v1";
}
_loadPages() {
try {
const raw = localStorage.getItem(this._storageKey());
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
_savePages(showToast = false) {
try {
this._syncSectionsFromPages();
localStorage.setItem(
this._storageKey(),
JSON.stringify({ sections: this.state.sections }),
);
this._scheduleServerSave();
if (showToast) this._toast("Saved");
} catch {
if (showToast) this._toast("Save failed");
}
}
_apiRoot() {
if (this.apiBase) return this.apiBase.replace(/\/$/, "");
if (window.REPEX && window.REPEX.apiBase) return window.REPEX.apiBase.replace(/\/$/, "");
return "";
}
async _loadPagesFromServer() {
const base = this._apiRoot();
if (!base || !this.sessionId) return null;
try {
const res = await fetch(`${base}/sessions/${this.sessionId}/sections`);
if (!res.ok) return null;
const data = await res.json();
if (data && Array.isArray(data.sections)) {
return data.sections;
}
} catch {}
return null;
}
_scheduleServerSave() {
if (!this.sessionId) return;
if (this._saveTimer) clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
this._savePagesToServer();
}, 800);
this.dispatchEvent(new CustomEvent("editor-save-queued", { bubbles: true }));
}
async _savePagesToServer() {
const base = this._apiRoot();
if (!base || !this.sessionId) return;
if (this._savingPromise) {
return this._savingPromise;
}
const promise = (async () => {
this.dispatchEvent(new CustomEvent("editor-save-start", { bubbles: true }));
let ok = false;
try {
this._syncSectionsFromPages();
const res = await fetch(`${base}/sessions/${this.sessionId}/sections`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sections: this.state.sections }),
});
if (!res.ok) {
throw new Error("Failed");
}
ok = true;
} catch {
this._toast("Sync failed");
} finally {
this.dispatchEvent(
new CustomEvent("editor-save-end", {
bubbles: true,
detail: { ok },
}),
);
}
return ok;
})();
this._savingPromise = promise;
try {
return await promise;
} finally {
this._savingPromise = null;
}
}
_toast(text) {
const el = document.createElement("div");
el.className = "fixed z-[60] bottom-5 left-1/2 -translate-x-1/2 rounded-lg bg-gray-900 text-white text-sm font-semibold px-4 py-2 shadow";
el.textContent = text;
document.body.appendChild(el);
setTimeout(() => el.remove(), 1200);
}
_sectionId() {
return `sec_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
}
_ratingBadge(value, scale) {
const raw = String(value || "").trim();
if (!raw) {
return { text: "", className: "bg-gray-50 text-gray-700 border-gray-200" };
}
const match = raw.match(/^([0-9]|[xXmM])/);
const key = match ? match[1].toUpperCase() : raw.split("")[0].trim().toUpperCase();
const tone = scale[key];
if (!tone) {
return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
}
return {
text: `${key} ${tone.label}`,
className: `${tone.bg} ${tone.text} ${tone.border}`,
};
}
_buildPhotoContinuation(source, photoIds) {
return {
items: [],
template: source.template ? { ...source.template } : undefined,
photo_ids: photoIds,
photo_layout: source.photo_layout,
photo_order_locked: source.photo_order_locked,
variant: "photos",
};
}
_splitPagePhotos(page) {
const normalized = {
...page,
items: Array.isArray(page.items) ? page.items : [],
};
if (normalized.blank) return [normalized];
const photoIds = Array.isArray(normalized.photo_ids)
? normalized.photo_ids.filter(Boolean)
: [];
if (!photoIds.length) return [normalized];
if (normalized.variant === "photos") {
const chunks = [];
for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
chunks.push(photoIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
}
if (chunks.length <= 1) {
return [{ ...normalized, photo_ids: chunks[0] || [], variant: "photos" }];
}
return chunks.map((chunk, idx) => {
if (idx === 0) {
return { ...normalized, photo_ids: chunk, variant: "photos" };
}
return this._buildPhotoContinuation(normalized, chunk);
});
}
const baseChunk = photoIds.slice(0, MAX_PHOTOS_PRIMARY_PAGE);
const extraIds = photoIds.slice(MAX_PHOTOS_PRIMARY_PAGE);
const continuationChunks = [];
for (let i = 0; i < extraIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
continuationChunks.push(extraIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
}
const basePage = {
...normalized,
photo_ids: baseChunk,
variant: normalized.variant || "full",
};
if (!continuationChunks.length) return [basePage];
const extraPages = continuationChunks.map((chunk) =>
this._buildPhotoContinuation(normalized, chunk),
);
return [basePage, ...extraPages];
}
_normalizeSections(sections) {
const source = Array.isArray(sections) ? sections : [];
if (!source.length) {
return [{ id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] }];
}
return source.map((section) => {
const basePages =
Array.isArray(section.pages) && section.pages.length
? section.pages
: [{ items: [] }];
const normalizedPages = basePages.flatMap((page) => this._splitPagePhotos(page));
return {
id: section.id || this._sectionId(),
title: section.title ?? "Section",
pages: normalizedPages.length ? normalizedPages : [{ items: [] }],
};
});
}
_rebuildFlatPages() {
this._indexMap = [];
this.state.pages = [];
const sections = Array.isArray(this.state.sections) ? this.state.sections : [];
sections.forEach((section, sectionIndex) => {
const pages = Array.isArray(section.pages) && section.pages.length
? section.pages
: [{ items: [] }];
section.pages = pages;
pages.forEach((page, pageIndex) => {
this.state.pages.push(page);
this._indexMap.push({ sectionIndex, pageIndex });
});
});
if (!this.state.pages.length) {
this.state.sections = [
{ id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
];
this._rebuildFlatPages();
}
}
_setSections(sections) {
this.state.sections = this._normalizeSections(sections);
this._rebuildFlatPages();
}
_syncSectionsFromPages() {
if (!this._indexMap || this._indexMap.length !== this.state.pages.length) {
this._rebuildFlatPages();
}
const sections = this.state.sections.map((section) => ({
...section,
pages: Array.isArray(section.pages) ? [...section.pages] : [{ items: [] }],
}));
this.state.pages.forEach((page, idx) => {
const map = this._indexMap[idx];
if (!map) return;
if (!sections[map.sectionIndex]) return;
sections[map.sectionIndex].pages[map.pageIndex] = page;
});
this.state.sections = sections;
}
_getActiveSectionLabel() {
const map = this._indexMap?.[this.state.activePage];
if (!map || !this.state.sections?.[map.sectionIndex]) return "";
const section = this.state.sections[map.sectionIndex] || {};
const title = section.title || "";
if (title) return `Section ${map.sectionIndex + 1} - ${title}`;
return `Section ${map.sectionIndex + 1}`;
}
// ---------- Page list ----------
renderPageList() {
this.$pageList.innerHTML = "";
this.state.pages.forEach((_, idx) => {
const map = this._indexMap?.[idx] || { sectionIndex: 0, pageIndex: idx };
const section =
(this.state.sections && this.state.sections[map.sectionIndex]) || {};
const sectionLabel =
section.title || `Section ${map.sectionIndex + 1}`;
const active = idx === this.state.activePage;
const row = document.createElement("div");
row.className = "flex items-center gap-2";
const btn = document.createElement("button");
btn.type = "button";
btn.className =
"flex-1 text-left rounded-lg border px-3 py-2 transition " +
(active
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
btn.innerHTML = `
${sectionLabel}
Page ${map.pageIndex + 1}
${this.state.pages[idx].items.length} items
`;
btn.addEventListener("click", () => {
this.state.activePage = idx;
this.state.selectedId = null;
this.state.undo = [];
this.state.redo = [];
this.updateAll();
});
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className =
"shrink-0 inline-flex items-center justify-center rounded-lg border border-red-200 bg-red-50 text-red-700 px-2 py-2 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed";
removeBtn.innerHTML = ``;
removeBtn.disabled = this.state.pages.length <= 1;
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.removePage(idx);
});
row.appendChild(btn);
row.appendChild(removeBtn);
this.$pageList.appendChild(row);
});
}
addPage() {
this._pushUndoSnapshot();
const map = this._indexMap?.[this.state.activePage] || {
sectionIndex: 0,
pageIndex: this.state.activePage,
};
if (!this.state.sections[map.sectionIndex]) {
this.state.sections = [
{ id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
];
}
const section = this.state.sections[map.sectionIndex];
section.pages = section.pages || [];
section.pages.splice(map.pageIndex + 1, 0, { items: [] });
this._setSections(this.state.sections);
const newIndex = this._indexMap.findIndex(
(entry) =>
entry.sectionIndex === map.sectionIndex &&
entry.pageIndex === map.pageIndex + 1
);
this.state.activePage =
newIndex >= 0 ? newIndex : this.state.pages.length - 1;
this.state.selectedId = null;
this._savePages();
this.updateAll();
}
removePage(index) {
if (this.state.pages.length <= 1) return;
const idx = typeof index === "number" ? index : this.state.activePage;
if (idx < 0 || idx >= this.state.pages.length) return;
const map = this._indexMap?.[idx];
if (!map || !this.state.sections[map.sectionIndex]) return;
const section = this.state.sections[map.sectionIndex];
const pages = section.pages || [];
if (pages.length <= 1) return;
pages.splice(map.pageIndex, 1);
section.pages = pages.length ? pages : [{ items: [] }];
this._setSections(this.state.sections);
if (this.state.activePage >= this.state.pages.length) {
this.state.activePage = this.state.pages.length - 1;
} else if (this.state.activePage > idx) {
this.state.activePage -= 1;
}
this.state.selectedId = null;
this.state.undo = [];
this.state.redo = [];
this._savePages();
this.updateAll();
}
_ensurePageCount(count) {
const target = Math.max(Number(count) || 1, 1);
if (!this.state.sections || !this.state.sections.length) {
this.state.sections = [
{ id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
];
}
while (this.state.pages.length < target) {
const lastSection = this.state.sections[this.state.sections.length - 1];
lastSection.pages = lastSection.pages || [];
lastSection.pages.push({ items: [] });
this._setSections(this.state.sections);
}
}
// ---------- Canvas rendering ----------
renderCanvas() {
if (!this.$canvas) return;
this._syncCanvasSize();
this.$canvas.innerHTML = "";
// Click-away surface
const surface = document.createElement("div");
surface.className = "absolute inset-0";
surface.addEventListener("pointerdown", (e) => {
// only clear selection if clicking empty space
if (e.target === surface) {
this.state.selectedId = null;
this.updatePropsPanel();
this.renderCanvas();
}
});
this.$canvas.appendChild(surface);
const scale = this._canvasScale() || 1;
const template = document.createElement("div");
template.className = "absolute inset-0";
const active = this.activePage || {};
let templateHtml = "";
if (active.blank) {
templateHtml = ``;
} else {
try {
templateHtml = this._templateMarkup();
} catch (err) {
console.error("Template render failed", err);
templateHtml = `
Template failed to render. Check console for details.
`;
}
}
template.innerHTML = `
${templateHtml}
`;
this.$canvas.appendChild(template);
if (!active.blank) {
this._bindTemplateFields();
}
const page = this.activePage || { items: [] };
if (!Array.isArray(page.items)) page.items = [];
const items = page.items;
const selectedId = this.state.selectedId;
items
.slice()
.sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
.forEach(item => {
const wrapper = document.createElement("div");
wrapper.dataset.itemId = item.id;
wrapper.className = "absolute";
// scaled px placement based on model units
wrapper.style.left = `${item.x * scale}px`;
wrapper.style.top = `${item.y * scale}px`;
wrapper.style.width = `${item.w * scale}px`;
wrapper.style.height = `${item.h * scale}px`;
wrapper.style.zIndex = String(item.z ?? 0);
const isSelected = selectedId === item.id;
if (isSelected) wrapper.classList.add("ring-2", "ring-blue-300");
// content
if (item.type === "text") {
const content = document.createElement("div");
content.className = "w-full h-full p-2 overflow-hidden";
content.setAttribute("contenteditable", "true");
content.style.fontSize = `${(item.style?.fontSize ?? 14) * scale}px`;
content.style.fontWeight = item.style?.bold ? "700" : "400";
content.style.fontStyle = item.style?.italic ? "italic" : "normal";
content.style.textDecoration = item.style?.underline ? "underline" : "none";
content.style.color = item.style?.color ?? "#111827";
content.style.textAlign = item.style?.align ?? "left";
content.style.whiteSpace = "pre-wrap";
content.style.outline = "none";
content.innerText = item.content ?? "Double-click to edit";
// update model when typing (debounced)
let t = null;
content.addEventListener("input", () => {
clearTimeout(t);
t = setTimeout(() => {
const it = this._findItem(item.id);
if (!it) return;
it.content = content.innerText;
this._savePages();
}, 250);
});
wrapper.appendChild(content);
}
if (item.type === "image") {
const img = document.createElement("img");
img.className = "w-full h-full object-contain bg-white";
img.src = item.src;
img.alt = item.name ?? "Image";
img.draggable = false;
wrapper.appendChild(img);
}
if (item.type === "rect") {
const box = document.createElement("div");
box.className = "w-full h-full";
box.style.background = item.style?.fill ?? "#ffffff";
box.style.borderColor = item.style?.stroke ?? "#111827";
box.style.borderWidth = `${(item.style?.strokeWidth ?? 1) * scale}px`;
box.style.borderStyle = "solid";
wrapper.appendChild(box);
}
// wrapper drag handler
wrapper.addEventListener("pointerdown", (e) => this.onItemPointerDown(e, item.id));
// resize handles (selected only)
if (isSelected) {
["nw", "ne", "sw", "se"].forEach(handle => {
const h = document.createElement("div");
h.dataset.handle = handle;
h.className =
"absolute w-3 h-3 bg-white border border-blue-300 rounded-sm";
if (handle === "nw") { h.style.left = "-6px"; h.style.top = "-6px"; }
if (handle === "ne") { h.style.right = "-6px"; h.style.top = "-6px"; }
if (handle === "sw") { h.style.left = "-6px"; h.style.bottom = "-6px"; }
if (handle === "se") { h.style.right = "-6px"; h.style.bottom = "-6px"; }
h.style.cursor = `${handle}-resize`;
h.addEventListener("pointerdown", (e) => {
e.stopPropagation();
this.startResize(e, item.id, handle);
});
wrapper.appendChild(h);
});
}
this.$canvas.appendChild(wrapper);
});
}
_canvasScale() {
// actual displayed width divided by model width
if (!this.$canvas) return 1;
const width =
this.$canvas.clientWidth ||
(this.$canvas.parentElement ? this.$canvas.parentElement.clientWidth : 0) ||
this.BASE_W;
return width / this.BASE_W;
}
_syncCanvasSize() {
if (!this.$canvas) return;
const width =
this.$canvas.clientWidth ||
(this.$canvas.parentElement ? this.$canvas.parentElement.clientWidth : 0) ||
this.BASE_W;
const height = (width / this.BASE_W) * this.BASE_H;
const nextHeight = `${height}px`;
const nextMinHeight = `${Math.max(320, Math.min(height, this.BASE_H))}px`;
if (this.$canvas.style.height !== nextHeight) {
this.$canvas.style.height = nextHeight;
}
if (this.$canvas.style.minHeight !== nextMinHeight) {
this.$canvas.style.minHeight = nextMinHeight;
}
}
// ---------- Item creation ----------
onCanvasPointerDown(e) {
// prevent adding when clicking existing item
const hit = e.target.closest("[data-item-id]");
if (hit) return;
const { x, y } = this._eventToModelPoint(e);
if (this.state.tool === "text") {
this._pushUndoSnapshot();
const id = this._id();
this.activePage.items.push({
id,
type: "text",
x: this._clamp(x, 0, this.BASE_W - 200),
y: this._clamp(y, 0, this.BASE_H - 80),
w: 220,
h: 80,
z: this._maxZ() + 1,
content: "New text",
style: { fontSize: 14, bold: false, italic: false, underline: false, color: "#111827", align: "left" }
});
this.selectItem(id);
this._savePages();
this.renderCanvas();
this.updatePropsPanel();
return;
}
if (this.state.tool === "rect") {
this._pushUndoSnapshot();
const id = this._id();
this.activePage.items.push({
id,
type: "rect",
x: this._clamp(x, 0, this.BASE_W - 200),
y: this._clamp(y, 0, this.BASE_H - 120),
w: 220,
h: 120,
z: this._maxZ() + 1,
style: { fill: "#ffffff", stroke: "#111827", strokeWidth: 1 }
});
this.selectItem(id);
this._savePages();
this.renderCanvas();
this.updatePropsPanel();
return;
}
// select tool clicking empty space clears selection
if (this.state.tool === "select") {
this.state.selectedId = null;
this.updatePropsPanel();
this.renderCanvas();
}
}
_eventToModelPoint(e) {
const canvasRect = this.$canvas.getBoundingClientRect();
const scale = canvasRect.width / this.BASE_W;
const xPx = e.clientX - canvasRect.left;
const yPx = e.clientY - canvasRect.top;
return { x: xPx / scale, y: yPx / scale };
}
// ---------- Selection / Drag / Resize ----------
selectItem(id) {
this.state.selectedId = id;
this.updatePropsPanel();
this.renderCanvas();
}
onItemPointerDown(e, id) {
// ignore if resizing handle
if (e.target && e.target.dataset && e.target.dataset.handle) return;
// select
this.selectItem(id);
const isEditingText =
e.target &&
e.target.getAttribute &&
e.target.getAttribute("contenteditable") === "true";
if (isEditingText) return;
// start drag only when using select tool
if (this.state.tool !== "select") return;
this._pushUndoSnapshot();
const it = this._findItem(id);
if (!it) return;
const { x, y } = this._eventToModelPoint(e);
this.state.dragging = {
id,
startX: x,
startY: y,
origX: it.x,
origY: it.y
};
e.preventDefault();
}
startResize(e, id, handle) {
this._pushUndoSnapshot();
const it = this._findItem(id);
if (!it) return;
const { x, y } = this._eventToModelPoint(e);
this.state.resizing = {
id,
handle,
startX: x,
startY: y,
orig: { x: it.x, y: it.y, w: it.w, h: it.h }
};
e.preventDefault();
}
onPointerMove(e) {
if (!this.state.isOpen) return;
if (this.state.dragging) {
const d = this.state.dragging;
const it = this._findItem(d.id);
if (!it) return;
const { x, y } = this._eventToModelPoint(e);
const dx = x - d.startX;
const dy = y - d.startY;
it.x = this._clamp(d.origX + dx, 0, this.BASE_W - it.w);
it.y = this._clamp(d.origY + dy, 0, this.BASE_H - it.h);
this._savePages();
this.renderCanvas();
return;
}
if (this.state.resizing) {
const r = this.state.resizing;
const it = this._findItem(r.id);
if (!it) return;
const { x, y } = this._eventToModelPoint(e);
const dx = x - r.startX;
const dy = y - r.startY;
const o = r.orig;
const minW = 40, minH = 30;
let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
if (r.handle.includes("e")) nw = this._clamp(o.w + dx, minW, this.BASE_W - o.x);
if (r.handle.includes("s")) nh = this._clamp(o.h + dy, minH, this.BASE_H - o.y);
if (r.handle.includes("w")) {
nw = this._clamp(o.w - dx, minW, o.w + o.x);
nx = this._clamp(o.x + dx, 0, o.x + o.w - minW);
}
if (r.handle.includes("n")) {
nh = this._clamp(o.h - dy, minH, o.h + o.y);
ny = this._clamp(o.y + dy, 0, o.y + o.h - minH);
}
it.x = nx; it.y = ny; it.w = nw; it.h = nh;
this._savePages();
this.renderCanvas();
}
}
onPointerUp() {
if (!this.state.isOpen) return;
if (this.state.dragging) {
this.state.dragging = null;
this.updateUndoRedoButtons();
}
if (this.state.resizing) {
this.state.resizing = null;
this.updateUndoRedoButtons();
}
}
// ---------- Properties panel ----------
updatePropsPanel() {
const it = this._findItem(this.state.selectedId);
const has = !!it;
this.$emptyProps.classList.toggle("hidden", has);
this.$props.classList.toggle("hidden", !has);
// hide all groups first
this.$propsText.classList.add("hidden");
this.$propsRect.classList.add("hidden");
this.$propsImage.classList.add("hidden");
if (!it) return;
if (it.type === "text") {
this.$propsText.classList.remove("hidden");
this.querySelector('[data-prop="fontSize"]').value = it.style?.fontSize ?? 14;
this.querySelector('[data-prop="color"]').value = it.style?.color ?? "#111827";
}
if (it.type === "rect") {
this.$propsRect.classList.remove("hidden");
this.querySelector('[data-prop="fill"]').value = it.style?.fill ?? "#ffffff";
this.querySelector('[data-prop="stroke"]').value = it.style?.stroke ?? "#111827";
this.querySelector('[data-prop="strokeWidth"]').value = it.style?.strokeWidth ?? 1;
}
if (it.type === "image") {
this.$propsImage.classList.remove("hidden");
}
this._refreshIcons();
}
setProp(key, value) {
const it = this._findItem(this.state.selectedId);
if (!it) return;
this._pushUndoSnapshot();
it.style = it.style || {};
it.style[key] = value;
this._savePages();
this.renderCanvas();
this.updatePropsPanel();
this.updateUndoRedoButtons();
}
toggleTextStyle(which) {
const it = this._findItem(this.state.selectedId);
if (!it || it.type !== "text") return;
this._pushUndoSnapshot();
it.style = it.style || {};
if (which === "bold") it.style.bold = !it.style.bold;
if (which === "italic") it.style.italic = !it.style.italic;
if (which === "underline") it.style.underline = !it.style.underline;
this._savePages();
this.renderCanvas();
this.updateUndoRedoButtons();
}
setTextAlign(align) {
const it = this._findItem(this.state.selectedId);
if (!it || it.type !== "text") return;
this.setProp("align", align);
}
// ---------- Arrange ----------
bringFront() {
const it = this._findItem(this.state.selectedId);
if (!it) return;
this._pushUndoSnapshot();
it.z = this._maxZ() + 1;
this._savePages();
this.renderCanvas();
this.updateUndoRedoButtons();
}
sendBack() {
const it = this._findItem(this.state.selectedId);
if (!it) return;
this._pushUndoSnapshot();
it.z = this._minZ() - 1;
this._savePages();
this.renderCanvas();
this.updateUndoRedoButtons();
}
duplicateSelected() {
const it = this._findItem(this.state.selectedId);
if (!it) return;
this._pushUndoSnapshot();
const copy = JSON.parse(JSON.stringify(it));
copy.id = this._id();
copy.x = this._clamp(copy.x + 12, 0, this.BASE_W - copy.w);
copy.y = this._clamp(copy.y + 12, 0, this.BASE_H - copy.h);
copy.z = this._maxZ() + 1;
this.activePage.items.push(copy);
this.state.selectedId = copy.id;
this._savePages();
this.updateAll();
this.updateUndoRedoButtons();
}
deleteSelected() {
const id = this.state.selectedId;
if (!id) return;
this._pushUndoSnapshot();
this.activePage.items = this.activePage.items.filter(x => x.id !== id);
this.state.selectedId = null;
this._savePages();
this.updateAll();
this.updateUndoRedoButtons();
}
// ---------- Images ----------
_handleImageUpload(e, mode) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (mode === "add") {
this._pushUndoSnapshot();
const id = this._id();
const w = 260, h = 180;
this.activePage.items.push({
id,
type: "image",
x: (this.BASE_W - w) / 2,
y: (this.BASE_H - h) / 2,
w, h,
z: this._maxZ() + 1,
src: reader.result,
name: file.name
});
this.selectItem(id);
this._savePages();
this.updateAll();
this.updateUndoRedoButtons();
}
if (mode === "replace") {
const it = this._findItem(this.state.selectedId);
if (!it || it.type !== "image") return;
this._pushUndoSnapshot();
it.src = reader.result;
it.name = file.name;
this._savePages();
this.updateAll();
this.updateUndoRedoButtons();
}
};
reader.readAsDataURL(file);
// reset input
e.target.value = "";
}
// ---------- Undo / redo ----------
_pushUndoSnapshot() {
// store snapshot of active page items
const snap = JSON.stringify(this.activePage.items);
const last = this.state.undo[this.state.undo.length - 1];
if (last !== snap) this.state.undo.push(snap);
// clear redo on new change
this.state.redo = [];
this.updateUndoRedoButtons();
}
undo() {
if (!this.state.undo.length) return;
const current = JSON.stringify(this.activePage.items);
const prev = this.state.undo.pop();
this.state.redo.push(current);
// restore prev
try {
this.activePage.items = JSON.parse(prev);
} catch {}
this.state.selectedId = null;
this._savePages();
this.updateAll();
}
redo() {
if (!this.state.redo.length) return;
const current = JSON.stringify(this.activePage.items);
const next = this.state.redo.pop();
this.state.undo.push(current);
try {
this.activePage.items = JSON.parse(next);
} catch {}
this.state.selectedId = null;
this._savePages();
this.updateAll();
}
updateUndoRedoButtons() {
const undoBtn = this.querySelector('[data-btn="undo"]');
const redoBtn = this.querySelector('[data-btn="redo"]');
if (undoBtn) undoBtn.disabled = this.state.undo.length === 0;
if (redoBtn) redoBtn.disabled = this.state.redo.length === 0;
}
// ---------- Utils ----------
_findItem(id) {
if (!id) return null;
return this.activePage.items.find(x => x.id === id) || null;
}
_maxZ() {
const items = this.activePage.items;
return items.length ? Math.max(...items.map(i => i.z ?? 0)) : 0;
}
_minZ() {
const items = this.activePage.items;
return items.length ? Math.min(...items.map(i => i.z ?? 0)) : 0;
}
_id() {
return "it_" + Math.random().toString(16).slice(2) + "_" + Date.now().toString(16);
}
_clamp(n, a, b) {
return Math.max(a, Math.min(b, n));
}
}
if (!customElements.get("report-editor")) {
customElements.define("report-editor", ReportEditor);
}