// @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 = ` `; } 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 `
${label}
${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 `
Prosento logo
${this._tplField( "document_no", docNumber, "Document No", "text-base font-semibold text-gray-900 whitespace-nowrap", )}
${(() => { const logoUrl = this._resolveLogoUrl(session, companyLogo); if (logoUrl) { return `Company logo`; } return `
Company Logo not found
`; })()}
${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); }