Spaces:
Sleeping
Sleeping
| // @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 = ` | |
| <div class="fixed inset-0 z-50 hidden" data-overlay> | |
| <div class="absolute inset-0 bg-black/30" data-backdrop></div> | |
| <div class="relative h-full w-full flex items-center justify-center p-4" data-shell-wrap> | |
| <div class="w-full max-w-6xl bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden" data-shell> | |
| <!-- Header --> | |
| <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200"> | |
| <div class="flex items-center gap-2"> | |
| <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gray-50 border border-gray-200"> | |
| <span class="text-xs font-bold text-gray-700">A4</span> | |
| </div> | |
| <div> | |
| <div class="text-sm font-semibold text-gray-900">Edit Report</div> | |
| <div class="text-xs text-gray-500">Drag, resize, format and arrange elements</div> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button data-btn="undo" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i data-feather="rotate-ccw" class="h-4 w-4"></i> Undo | |
| </button> | |
| <button data-btn="redo" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i data-feather="rotate-cw" class="h-4 w-4"></i> Redo | |
| </button> | |
| <div class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-semibold text-gray-600"> | |
| <i data-feather="check-circle" class="h-4 w-4"></i> Auto-saved | |
| </div> | |
| <button data-btn="close" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="x" class="h-4 w-4"></i> Done | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Body --> | |
| <div class="grid grid-cols-1 lg:grid-cols-[240px,1fr,280px] gap-0 min-h-[70vh]"> | |
| <!-- Pages sidebar --> | |
| <aside class="border-r border-gray-200 bg-white p-3"> | |
| <div class="flex items-center justify-between mb-3"> | |
| <div class="text-sm font-semibold text-gray-900">Pages</div> | |
| <button data-btn="add-page" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="plus" class="h-4 w-4"></i> Add | |
| </button> | |
| </div> | |
| <div class="space-y-2 max-h-[60vh] overflow-auto pr-1" data-page-list></div> | |
| <div class="mt-3 text-xs text-gray-500"> | |
| Tip: Click a page to edit. Your edits are saved to the server. | |
| </div> | |
| </aside> | |
| <!-- Canvas + toolbar --> | |
| <section class="bg-gray-50 p-3"> | |
| <!-- Toolbar --> | |
| <div class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 mb-3"> | |
| <div class="flex flex-wrap items-center gap-2"> | |
| <button data-tool="select" | |
| class="toolBtn inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="mouse-pointer" class="h-4 w-4"></i> Select | |
| </button> | |
| <button data-tool="text" | |
| class="toolBtn inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="type" class="h-4 w-4"></i> Text | |
| </button> | |
| <button data-tool="rect" | |
| class="toolBtn inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="square" class="h-4 w-4"></i> Shape | |
| </button> | |
| <button data-btn="add-image" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="image" class="h-4 w-4"></i> Image | |
| </button> | |
| <input data-file="image" type="file" accept="image/*" class="hidden" /> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <div class="text-xs font-semibold text-gray-600">Zoom</div> | |
| <button data-btn="zoom-out" | |
| class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition"> | |
| <i data-feather="minus" class="h-4 w-4"></i> | |
| </button> | |
| <div class="text-xs font-semibold text-gray-700 w-14 text-center" data-zoom-label>100%</div> | |
| <button data-btn="zoom-in" | |
| class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition"> | |
| <i data-feather="plus" class="h-4 w-4"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Canvas area --> | |
| <div class="flex justify-center"> | |
| <div class="relative w-full max-w-[700px]" data-canvas-wrap> | |
| <div | |
| data-canvas | |
| class="relative bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden select-none w-full" | |
| style="aspect-ratio: 210/297; background: #ffffff; border: 1px solid #e5e7eb;" | |
| aria-label="Editable A4 canvas" | |
| > | |
| <!-- items injected here --> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-3 text-xs text-gray-500"> | |
| Drag elements to move. Drag corner handles to resize. Double-click text to edit. | |
| </div> | |
| </section> | |
| <!-- Properties panel --> | |
| <aside class="border-l border-gray-200 bg-white p-3"> | |
| <div class="text-sm font-semibold text-gray-900 mb-2">Properties</div> | |
| <div data-empty-props class="text-sm text-gray-600 rounded-lg border border-gray-200 bg-gray-50 p-3"> | |
| Select an element to edit formatting and layout options. | |
| </div> | |
| <div data-props class="hidden space-y-4"> | |
| <!-- Arrange --> | |
| <div class="rounded-lg border border-gray-200 p-3"> | |
| <div class="text-xs font-semibold text-gray-600 mb-2">Arrange</div> | |
| <div class="flex flex-wrap gap-2"> | |
| <button data-btn="bring-front" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="chevrons-up" class="h-4 w-4"></i> Front | |
| </button> | |
| <button data-btn="send-back" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="chevrons-down" class="h-4 w-4"></i> Back | |
| </button> | |
| <button data-btn="duplicate" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="copy" class="h-4 w-4"></i> Duplicate | |
| </button> | |
| <button data-btn="delete" | |
| class="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-700 hover:bg-red-100 transition"> | |
| <i data-feather="trash-2" class="h-4 w-4"></i> Delete | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Text controls --> | |
| <div data-props-text class="rounded-lg border border-gray-200 p-3 hidden"> | |
| <div class="text-xs font-semibold text-gray-600 mb-2">Text</div> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <label class="text-xs text-gray-600"> | |
| Font size | |
| <input data-prop="fontSize" type="number" min="8" max="72" | |
| class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" /> | |
| </label> | |
| <label class="text-xs text-gray-600"> | |
| Color | |
| <input data-prop="color" type="color" | |
| class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" /> | |
| </label> | |
| </div> | |
| <div class="flex flex-wrap gap-2 mt-2"> | |
| <button data-btn="bold" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="bold" class="h-4 w-4"></i> | |
| </button> | |
| <button data-btn="italic" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="italic" class="h-4 w-4"></i> | |
| </button> | |
| <button data-btn="underline" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="underline" class="h-4 w-4"></i> | |
| </button> | |
| <button data-btn="align-left" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="align-left" class="h-4 w-4"></i> | |
| </button> | |
| <button data-btn="align-center" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="align-center" class="h-4 w-4"></i> | |
| </button> | |
| <button data-btn="align-right" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="align-right" class="h-4 w-4"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Shape controls --> | |
| <div data-props-rect class="rounded-lg border border-gray-200 p-3 hidden"> | |
| <div class="text-xs font-semibold text-gray-600 mb-2">Shape</div> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <label class="text-xs text-gray-600"> | |
| Fill | |
| <input data-prop="fill" type="color" | |
| class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" /> | |
| </label> | |
| <label class="text-xs text-gray-600"> | |
| Border | |
| <input data-prop="stroke" type="color" | |
| class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" /> | |
| </label> | |
| </div> | |
| <label class="text-xs text-gray-600 block mt-2"> | |
| Border width | |
| <input data-prop="strokeWidth" type="number" min="0" max="12" | |
| class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" /> | |
| </label> | |
| </div> | |
| <!-- Image controls --> | |
| <div data-props-image class="rounded-lg border border-gray-200 p-3 hidden"> | |
| <div class="text-xs font-semibold text-gray-600 mb-2">Image</div> | |
| <button data-btn="replace-image" | |
| class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="refresh-cw" class="h-4 w-4"></i> Replace image | |
| </button> | |
| <input data-file="replace" type="file" accept="image/*" class="hidden" /> | |
| </div> | |
| </div> | |
| <div class="mt-4 rounded-lg border border-gray-200 p-3"> | |
| <div class="text-xs font-semibold text-gray-600 mb-2">Company Logo (Top Right)</div> | |
| <p class="text-[11px] text-gray-500 mb-2"> | |
| Select an uploaded image or upload a new one for this page. | |
| </p> | |
| <select data-prop-template-logo | |
| class="w-full rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"> | |
| <option value="">No logo (show placeholder)</option> | |
| </select> | |
| <button data-btn="upload-template-logo" | |
| class="mt-2 inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"> | |
| <i data-feather="upload" class="h-4 w-4"></i> Upload logo | |
| </button> | |
| <input data-file="template-logo" type="file" accept="image/*" class="hidden" /> | |
| </div> | |
| <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600"> | |
| <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div> | |
| <ul class="list-disc pl-4 space-y-1"> | |
| <li><span class="font-semibold">Delete</span>: remove selected</li> | |
| <li><span class="font-semibold">Ctrl/Cmd+Z</span>: undo</li> | |
| <li><span class="font-semibold">Ctrl/Cmd+Y</span>: redo</li> | |
| <li><span class="font-semibold">Esc</span>: close editor</li> | |
| </ul> | |
| </div> | |
| </aside> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| 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, """) | |
| .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 ` | |
| <div class="min-h-[120px] w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500 break-inside-avoid mb-3"> | |
| No photo selected | |
| </div> | |
| `; | |
| } | |
| 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 ` | |
| <figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 pb-3 break-inside-avoid mb-3 flex flex-col gap-1 overflow-hidden"> | |
| <div class="w-full flex-1 flex items-center justify-center"> | |
| <img src="${safeUrl}" alt="${label}" class="w-full object-contain max-h-[240px]" /> | |
| </div> | |
| <figcaption class="text-[10px] text-gray-600 text-center break-all leading-tight">${caption}</figcaption> | |
| </figure> | |
| `; | |
| } | |
| _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 `<div class="template-field ${className}" data-template-field="${key}" contenteditable="true" data-placeholder="${safePlaceholder}"${multiAttr}${inlineAttr}>${safeValue}</div>`; | |
| } | |
| _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 `<option value="${optValue}"${selected}>${optLabel}</option>`; | |
| }) | |
| .join(""); | |
| return `<select class="template-select ${className}" data-template-select="${key}"><option value="">Select</option>${optionHtml}</select>`; | |
| } | |
| _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" | |
| ? ` | |
| <section class="mb-2" aria-labelledby="observations-title"> | |
| <h2 id="observations-title" class="text-[10px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-1"> | |
| Observations and Findings | |
| </h2> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> | |
| <div class="md:col-span-2"> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <div class="space-y-0.5"> | |
| <div class="text-[9px] font-medium text-gray-500">Ref</div> | |
| ${this._tplField("reference", reference, "Ref", "text-[10px] font-semibold text-gray-900")} | |
| </div> | |
| <div class="space-y-0.5"> | |
| <div class="text-[9px] font-medium text-gray-500">Area</div> | |
| ${this._tplField("area", area, "Area", "text-[10px] font-semibold text-gray-900")} | |
| </div> | |
| <div class="space-y-0.5"> | |
| <div class="text-[9px] font-medium text-gray-500">Location</div> | |
| ${this._tplField("functional_location", functionalLocation, "Location", "text-[10px] font-semibold text-gray-900")} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="md:col-span-2 flex justify-center"> | |
| <div class="inline-flex items-center gap-4"> | |
| <div class="text-center space-y-1"> | |
| <div class="text-[9px] font-medium text-gray-500">Category</div> | |
| ${this._tplSelectField( | |
| "category", | |
| category, | |
| categoryOptions, | |
| `min-w-[140px] rounded-md border px-3 py-1 text-[10px] font-semibold text-center ${categoryBadge.className}`, | |
| )} | |
| </div> | |
| <div class="text-center space-y-1"> | |
| <div class="text-[9px] font-medium text-gray-500">Priority</div> | |
| ${this._tplSelectField( | |
| "priority", | |
| priority, | |
| priorityOptions, | |
| `min-w-[140px] rounded-md border px-3 py-1 text-[10px] font-semibold text-center ${priorityBadge.className}`, | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="md:col-span-2 space-y-1"> | |
| <div class="text-[9px] font-medium text-gray-500">Condition Description</div> | |
| <div class="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm"> | |
| <div class="text-gray-700 text-[9px] font-medium leading-snug"> | |
| ${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-gray-700 text-[9px] font-medium leading-snug", true)} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="md:col-span-2 space-y-1"> | |
| <div class="text-[9px] font-medium text-gray-500">Action Required</div> | |
| <div class="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm"> | |
| <div class="text-gray-700 text-[9px] font-medium leading-snug"> | |
| ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-gray-700 text-[9px] font-medium leading-snug", true)} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| ` | |
| : ""; | |
| const photoTitle = | |
| variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"; | |
| return ` | |
| <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;"> | |
| <header class="mb-3 border-b border-gray-200 pb-2"> | |
| <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3"> | |
| <div class="flex items-center"> | |
| <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-14 w-auto object-contain" /> | |
| </div> | |
| <div class="text-center leading-tight"> | |
| ${this._tplField( | |
| "document_no", | |
| docNumber, | |
| "Document No", | |
| "text-base font-semibold text-gray-900 whitespace-nowrap", | |
| )} | |
| </div> | |
| <div class="flex items-center justify-end"> | |
| ${(() => { | |
| const logoUrl = this._resolveLogoUrl(session, companyLogo); | |
| if (logoUrl) { | |
| return `<img src="${this._escape(logoUrl)}" alt="Company logo" class="h-14 w-auto object-contain" />`; | |
| } | |
| return `<div class="h-14 min-w-[140px] rounded-md border border-dashed border-gray-300 px-2 text-[9px] font-semibold text-gray-400 flex items-center justify-center text-center">Company Logo not found</div>`; | |
| })()} | |
| </div> | |
| </div> | |
| </header> | |
| ${observationsHtml} | |
| <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title"> | |
| <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"> | |
| ${photoTitle} | |
| </h2> | |
| <div class="${photoColumnsClass}" style="column-gap:0.75rem;"> | |
| ${photoSlots} | |
| </div> | |
| </section> | |
| <footer class="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1"> | |
| <div class="flex flex-wrap items-center justify-center gap-3"> | |
| <div class="flex items-center gap-1"> | |
| <span>Date:</span> | |
| ${this._tplField( | |
| "inspection_date", | |
| inspectionDate, | |
| "Date", | |
| "text-[10px] text-gray-500", | |
| false, | |
| true, | |
| )} | |
| </div> | |
| <div class="flex items-center gap-1"> | |
| <span>Inspector:</span> | |
| ${this._tplField( | |
| "inspector", | |
| inspector, | |
| "Inspector", | |
| "text-[10px] text-gray-500", | |
| false, | |
| true, | |
| )} | |
| </div> | |
| <div class="flex items-center gap-1"> | |
| <span>Doc:</span> | |
| ${this._tplField( | |
| "document_no", | |
| docNumber, | |
| "Document No", | |
| "text-[10px] text-gray-500", | |
| false, | |
| true, | |
| )} | |
| </div> | |
| </div> | |
| <div class="text-[10px] font-semibold text-gray-600"> | |
| RepEx Inspection Job Sheet | |
| </div> | |
| <div class="text-[10px] text-gray-500"> | |
| ${sectionLabel ? `${this._escape(sectionLabel)} - ` : ""}Page ${pageNum} of ${pageCount} | |
| </div> | |
| </footer> | |
| </div> | |
| `; | |
| } | |
| // ---------- 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 = ` | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <div class="text-[11px] ${active ? "text-white/80" : "text-gray-500"}">${sectionLabel}</div> | |
| <div class="text-sm font-semibold">Page ${map.pageIndex + 1}</div> | |
| </div> | |
| <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div> | |
| </div> | |
| `; | |
| 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 = `<i data-feather="trash-2" class="h-4 w-4"></i>`; | |
| 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 = `<div class="w-full h-full bg-white"></div>`; | |
| } else { | |
| try { | |
| templateHtml = this._templateMarkup(); | |
| } catch (err) { | |
| console.error("Template render failed", err); | |
| templateHtml = ` | |
| <div class="p-4 text-sm text-red-600"> | |
| Template failed to render. Check console for details. | |
| </div> | |
| `; | |
| } | |
| } | |
| template.innerHTML = ` | |
| <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;"> | |
| ${templateHtml} | |
| </div> | |
| `; | |
| 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); | |
| } | |