Spaces:
Sleeping
Sleeping
| function htrApp() { | |
| return { | |
| // ── UI state ────────────────────────────────────────────────────────── | |
| state: { | |
| settings: {}, | |
| images: {}, | |
| pages: [], | |
| icl_pool: [], | |
| mode: "ocr", | |
| curated_models: [], | |
| env_key_present: false, | |
| }, | |
| apiKey: "", | |
| modal: null, | |
| settingsTab: "general", | |
| busy: false, | |
| toast: null, | |
| keyTestMsg: "", | |
| activePageIdx: -1, | |
| activeImageDataUrl: "", | |
| zoom: 1, | |
| panning: false, | |
| _panStart: null, | |
| imgFitW: 0, | |
| imgFitH: 0, | |
| containerW: 0, | |
| containerH: 0, | |
| editorText: "", | |
| editing: false, | |
| quickIclText: "", | |
| quickIclMode: "text", // "text" | "json" | |
| quickIclJsonError: "", | |
| annotateMode: false, | |
| annotateDefaultLabel: "undefined", | |
| samBusy: false, | |
| showDetections: true, | |
| postCorrText: "", | |
| pcImageId: "", | |
| moeResult: null, | |
| ocrPreview: null, | |
| defaultPrompts: {}, | |
| presetNames: { ocr_system: [], ocr_user: [], expert_system: [], expert_user: [], judge_system: [], judge_user: [] }, | |
| citation: { bibtex: "", apa: "" }, | |
| _thumbCache: {}, | |
| _toastTimer: null, | |
| // ── Lifecycle ───────────────────────────────────────────────────────── | |
| async init() { | |
| try { this.apiKey = localStorage.getItem("htr_openrouter_key") || ""; } | |
| catch (e) { this.apiKey = ""; } | |
| try { | |
| await Promise.all([this.refreshState(), this.fetchPrompts(), this.fetchCitation()]); | |
| if (this.state.pages.length > 0) await this.selectPage(0); | |
| } catch (e) { | |
| console.error("init failed:", e); | |
| this.showToast("Could not contact backend: " + e.message, "error", 8000); | |
| } | |
| this.$watch("apiKey", (v) => { | |
| try { localStorage.setItem("htr_openrouter_key", v || ""); } catch (e) {} | |
| }); | |
| // Keep image fit-math in sync with window resizes. | |
| window.addEventListener("resize", () => { | |
| const c = this.$refs.imgScroll; | |
| if (!c) return; | |
| const img = c.querySelector("img"); | |
| if (img && img.complete && img.naturalWidth) this.onImgLoad({ target: img }); | |
| }); | |
| }, | |
| async refreshState() { | |
| const r = await fetch("/api/state"); | |
| if (!r.ok) throw new Error("HTTP " + r.status); | |
| this.state = await r.json(); | |
| }, | |
| async fetchPrompts() { | |
| try { | |
| const r = await fetch("/api/prompts/defaults"); | |
| const d = await r.json(); | |
| this.defaultPrompts = d.defaults || {}; | |
| this.presetNames = d.preset_names || this.presetNames; | |
| } catch (e) { console.warn("fetchPrompts failed", e); } | |
| }, | |
| async fetchCitation() { | |
| try { | |
| const r = await fetch("/api/citation"); | |
| this.citation = await r.json(); | |
| } catch (e) {} | |
| }, | |
| // ── Computed ────────────────────────────────────────────────────────── | |
| get activePage() { | |
| if (this.activePageIdx < 0 || this.activePageIdx >= this.state.pages.length) return null; | |
| return this.state.pages[this.activePageIdx]; | |
| }, | |
| // ICL pool filtered to what's actually relevant for the active page. | |
| // - Sandbox page: only entries whose language matches the page (the same | |
| // set the OCR call would inject). | |
| // - Uploaded page (or no active page): only user-added entries (corrected/pasted). | |
| get visibleIclPool() { | |
| const pool = this.state.icl_pool || []; | |
| const p = this.activePage; | |
| if (p && p.is_sandbox) { | |
| return pool.filter(ex => ex.language === p.language); | |
| } | |
| return pool.filter(ex => ex.source !== "sandbox"); | |
| }, | |
| // ── Helpers ─────────────────────────────────────────────────────────── | |
| headers(extra = {}) { | |
| const h = { "Content-Type": "application/json", ...extra }; | |
| if (this.apiKey) h["X-API-Key"] = this.apiKey; | |
| return h; | |
| }, | |
| showToast(message, kind = "info", ms = 3500) { | |
| this.toast = { message, kind }; | |
| clearTimeout(this._toastTimer); | |
| this._toastTimer = setTimeout(() => (this.toast = null), ms); | |
| }, | |
| statusLabel(s) { | |
| return ({ | |
| pending: "pending", ocr_running: "running OCR…", ocr_done: "OCR done", | |
| corrected: "corrected", moe_running: "MoE running…", moe_done: "MoE done", | |
| error: "error", | |
| })[s] || s || "—"; | |
| }, | |
| dotClass(s) { | |
| return ({ | |
| pending: "dot-pending", | |
| ocr_running: "dot-busy", moe_running: "dot-busy", | |
| ocr_done: "dot-ok", corrected: "dot-ok", moe_done: "dot-ok", | |
| error: "dot-error", | |
| })[s] || "dot-pending"; | |
| }, | |
| metricClass(v) { | |
| if (v == null) return ""; | |
| if (v < 0.05) return "accuracy-pill-high"; | |
| if (v < 0.15) return "accuracy-pill-mid"; | |
| return "accuracy-pill-low"; | |
| }, | |
| formatCost(v) { | |
| if (v == null || isNaN(v)) return "—"; | |
| if (v === 0) return "$0"; | |
| if (v < 0.001) return "$" + v.toExponential(2); | |
| if (v < 1) return "$" + v.toFixed(4); | |
| return "$" + v.toFixed(2); | |
| }, | |
| thumbDataUrl(imageId) { | |
| if (this._thumbCache[imageId]) return this._thumbCache[imageId]; | |
| this._thumbCache[imageId] = ""; | |
| fetch(`/api/image/${imageId}`).then(r => r.json()).then(d => { | |
| this._thumbCache[imageId] = `data:image/jpeg;base64,${d.b64}`; | |
| }).catch(() => {}); | |
| return ""; | |
| }, | |
| openSettings(tab) { | |
| this.settingsTab = tab || "general"; | |
| this.modal = "settings"; | |
| }, | |
| async applyPreset(family, name) { | |
| if (!name) return; | |
| const r = await fetch(`/api/prompts/preset?family=${encodeURIComponent(family)}&name=${encodeURIComponent(name)}`); | |
| if (!r.ok) { this.showToast("Preset load failed", "error"); return; } | |
| const d = await r.json(); | |
| const fieldMap = { | |
| guidelines: "guidelines", | |
| json_template: "output_json_template", | |
| ocr_system: "custom_ocr_system", ocr_user: "custom_ocr_user", | |
| expert_system: "custom_expert_system", expert_user: "custom_expert_user", | |
| judge_system: "custom_judge_system", judge_user: "custom_judge_user", | |
| }; | |
| const key = fieldMap[family]; | |
| if (key) this.state.settings[key] = d.body; | |
| this.showToast(`Loaded preset: ${family}/${name}`, "success"); | |
| }, | |
| copyCitation() { | |
| navigator.clipboard?.writeText(this.citation.bibtex || "").then(() => { | |
| this.showToast("BibTeX copied to clipboard.", "success"); | |
| }).catch(() => { | |
| this.showToast("Could not copy.", "error"); | |
| }); | |
| }, | |
| // ── Mode / selection ────────────────────────────────────────────────── | |
| async setMode(m) { | |
| this.state.mode = m; | |
| try { await fetch(`/api/mode/${m}`, { method: "POST" }); } catch (e) {} | |
| // When switching to post-corr, auto-fill the textarea from the active page's OCR text | |
| if (m === "post_correction" && this.activePage) { | |
| const p = this.activePage; | |
| this.postCorrText = p.ocr_text || p.corrected_text || this.postCorrText || ""; | |
| this.moeResult = p.moe || null; | |
| } | |
| }, | |
| // Compute the contain-fit size for the loaded image and capture the | |
| // current viewport size of the scroll container. The wrapper's CSS in | |
| // the template grows with `imgFit{W,H} * zoom + container{W,H}` so the | |
| // scroll container has real layout space matching the displayed image | |
| // — plus a viewport-sized margin of slack at zoom > 1 to allow panning | |
| // past any edge into empty canvas. | |
| onImgLoad(e) { | |
| const img = e.target; | |
| const container = this.$refs.imgScroll; | |
| if (!container || !img.naturalWidth) return; | |
| const cw = container.clientWidth; | |
| const ch = container.clientHeight; | |
| if (cw <= 0 || ch <= 0) return; | |
| this.containerW = cw; | |
| this.containerH = ch; | |
| const fit = Math.min(cw / img.naturalWidth, ch / img.naturalHeight, 1); | |
| this.imgFitW = img.naturalWidth * fit; | |
| this.imgFitH = img.naturalHeight * fit; | |
| }, | |
| // Click-and-drag panning of the zoomed image (scroll-based, no transform). | |
| startPan(e) { | |
| // Annotate mode owns clicks: never start panning while it's on. | |
| if (this.annotateMode) return; | |
| const el = this.$refs.imgScroll; | |
| if (!el) return; | |
| // Only pan when zoomed in (otherwise the image fits, nothing to pan). | |
| if (this.zoom <= 1) return; | |
| if (e.button !== 0) return; // left click only | |
| this.panning = true; | |
| this._panStart = { | |
| x: e.clientX, y: e.clientY, | |
| sx: el.scrollLeft, sy: el.scrollTop, | |
| }; | |
| e.preventDefault(); | |
| }, | |
| onPan(e) { | |
| if (!this.panning || !this._panStart) return; | |
| const el = this.$refs.imgScroll; | |
| if (!el) return; | |
| el.scrollLeft = this._panStart.sx - (e.clientX - this._panStart.x); | |
| el.scrollTop = this._panStart.sy - (e.clientY - this._panStart.y); | |
| }, | |
| endPan() { | |
| this.panning = false; | |
| this._panStart = null; | |
| }, | |
| async selectPage(idx) { | |
| this.activePageIdx = idx; | |
| this.editing = false; | |
| this.zoom = 1; | |
| this.imgFitW = 0; | |
| this.imgFitH = 0; | |
| const p = this.activePage; | |
| if (!p) { | |
| this.activeImageDataUrl = ""; | |
| this.editorText = ""; | |
| this.postCorrText = ""; | |
| this.moeResult = null; | |
| return; | |
| } | |
| // Restore per-page state: editor text, post-corr text, MoE result | |
| this.editorText = p.corrected_text || p.ocr_text || ""; | |
| this.postCorrText = p.ocr_text || p.corrected_text || ""; | |
| this.moeResult = p.moe || null; | |
| try { | |
| const r = await fetch(`/api/image/${p.image_id}`); | |
| const d = await r.json(); | |
| this.activeImageDataUrl = `data:image/jpeg;base64,${d.b64}`; | |
| } catch (e) { | |
| this.activeImageDataUrl = ""; | |
| } | |
| }, | |
| // ── Upload / sandbox ────────────────────────────────────────────────── | |
| async uploadFiles(files) { | |
| if (!files || files.length === 0) return; | |
| const list = Array.from(files).filter(f => f.type.startsWith("image/")); | |
| if (list.length === 0) { this.showToast("No image files in the drop.", "error"); return; } | |
| const fd = new FormData(); | |
| for (const f of list) fd.append("files", f); | |
| this.busy = true; | |
| try { | |
| const r = await fetch("/api/upload", { method: "POST", body: fd }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| // Jump to the first newly-created page so the user sees what they just uploaded. | |
| const newIds = d.created_pages || []; | |
| const firstNewIdx = newIds.length | |
| ? this.state.pages.findIndex(p => p.id === newIds[0]) | |
| : -1; | |
| if (firstNewIdx >= 0) await this.selectPage(firstNewIdx); | |
| else if (this.activePageIdx < 0 && this.state.pages.length > 0) await this.selectPage(0); | |
| this.showToast(`Uploaded ${list.length} image${list.length > 1 ? "s" : ""}.`, "success"); | |
| } catch (e) { | |
| console.error("upload failed:", e); | |
| this.showToast("Upload failed: " + e.message, "error"); | |
| } finally { this.busy = false; } | |
| }, | |
| async resetSession() { | |
| if (!confirm("Reset session?\n\nThis clears EVERYTHING: pages, ICL pool, cost totals, language, models, prompts, JSON template, guidelines, and all custom overrides. Sandbox samples are reloaded. Only your API key (stored locally in this browser) is kept.")) return; | |
| this.busy = true; | |
| try { | |
| const r = await fetch("/api/session/reset", { method: "POST" }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| this.state = await r.json(); | |
| // Wipe all per-page / per-session UI state so nothing lingers. | |
| this.activePageIdx = -1; | |
| this.activeImageDataUrl = ""; | |
| this.zoom = 1; | |
| this.panning = false; | |
| this._panStart = null; | |
| this.imgFitW = 0; | |
| this.imgFitH = 0; | |
| this.editorText = ""; | |
| this.editing = false; | |
| this.quickIclText = ""; | |
| this.quickIclMode = "text"; | |
| this.quickIclJsonError = ""; | |
| this.annotateMode = false; | |
| this.annotateDefaultLabel = "undefined"; | |
| this.samBusy = false; | |
| this.showDetections = true; | |
| this.postCorrText = ""; | |
| this.pcImageId = ""; | |
| this.moeResult = null; | |
| this.ocrPreview = null; | |
| this.settingsTab = "general"; | |
| this.keyTestMsg = ""; | |
| this.modal = null; | |
| this._thumbCache = {}; | |
| if (this.state.pages.length > 0) await this.selectPage(0); | |
| this.showToast("Session reset.", "success"); | |
| } catch (e) { | |
| this.showToast("Reset failed: " + e.message, "error"); | |
| } finally { this.busy = false; } | |
| }, | |
| async deletePage(idx) { | |
| if (!confirm("Remove this page?")) return; | |
| try { | |
| const r = await fetch(`/api/page/${idx}`, { method: "DELETE" }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| this.state = await r.json(); | |
| if (this.activePageIdx >= this.state.pages.length) this.activePageIdx = this.state.pages.length - 1; | |
| await this.selectPage(this.activePageIdx); | |
| } catch (e) { this.showToast("Delete failed: " + e.message, "error"); } | |
| }, | |
| // ── Settings & key ──────────────────────────────────────────────────── | |
| async saveSettings() { | |
| try { | |
| const r = await fetch("/api/settings", { | |
| method: "POST", | |
| headers: this.headers(), | |
| body: JSON.stringify(this.state.settings), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| this.state = await r.json(); | |
| this.modal = null; | |
| this.showToast("Settings saved.", "success"); | |
| } catch (e) { this.showToast("Save failed: " + e.message, "error"); } | |
| }, | |
| async testKey() { | |
| this.keyTestMsg = "Testing…"; | |
| try { | |
| const r = await fetch("/api/settings/test_key", { | |
| method: "POST", | |
| headers: this.headers(), | |
| body: JSON.stringify({ model: this.state.settings.moe_judge || null }), | |
| }); | |
| const d = await r.json(); | |
| this.keyTestMsg = (d.ok ? "OK · " : "FAIL · ") + d.message; | |
| } catch (e) { this.keyTestMsg = "FAIL · " + e.message; } | |
| }, | |
| // ── OCR mode actions ────────────────────────────────────────────────── | |
| async previewOcrPrompt() { | |
| if (!this.activePage) return; | |
| this.ocrPreview = null; | |
| this.modal = "preview"; | |
| try { | |
| const r = await fetch(`/api/ocr/preview/${this.activePageIdx}`); | |
| if (!r.ok) throw new Error(await r.text()); | |
| this.ocrPreview = await r.json(); | |
| } catch (e) { | |
| this.showToast("Preview failed: " + e.message, "error"); | |
| } | |
| }, | |
| async runOcr() { | |
| if (!this.activePage) return; | |
| this.busy = true; | |
| try { | |
| const r = await fetch("/api/ocr", { | |
| method: "POST", headers: this.headers(), | |
| body: JSON.stringify({ page_idx: this.activePageIdx }), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| this.editorText = d.ocr_text; | |
| this.editing = true; | |
| this.showToast(`OCR done in ${d.latency_s.toFixed(1)}s.`, "success"); | |
| } catch (e) { this.showToast("OCR failed: " + e.message, "error", 6000); } | |
| finally { this.busy = false; } | |
| }, | |
| async validateAndScore(addToIcl = false) { | |
| if (!this.activePage) return; | |
| try { | |
| const r = await fetch("/api/correction", { | |
| method: "POST", headers: this.headers(), | |
| body: JSON.stringify({ | |
| page_idx: this.activePageIdx, | |
| corrected_text: this.editorText, | |
| add_to_icl: addToIcl, | |
| }), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| this.editing = false; | |
| this.showToast( | |
| addToIcl | |
| ? `Saved · CER ${(d.cer*100).toFixed(2)}% · WER ${(d.wer*100).toFixed(2)}% · added to ICL.` | |
| : `Scored · CER ${(d.cer*100).toFixed(2)}% · WER ${(d.wer*100).toFixed(2)}%.`, | |
| "success", | |
| ); | |
| } catch (e) { this.showToast("Validate failed: " + e.message, "error"); } | |
| }, | |
| async addCurrentToIcl() { await this.validateAndScore(true); }, | |
| // Convert a click event on the <img> to natural-image pixel coords, then | |
| // ask FastSAM for the bbox of the mask under that pixel. | |
| async annotateClick(e) { | |
| if (!this.annotateMode || !this.activePage) return; | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| const img = e.currentTarget; | |
| const rect = img.getBoundingClientRect(); | |
| const nw = this.state.images?.[this.activePage.image_id]?.w || 0; | |
| const nh = this.state.images?.[this.activePage.image_id]?.h || 0; | |
| if (!rect.width || !rect.height || !nw || !nh) return; | |
| const x = Math.round((e.clientX - rect.left) * nw / rect.width); | |
| const y = Math.round((e.clientY - rect.top) * nh / rect.height); | |
| this.samBusy = true; | |
| try { | |
| const r = await fetch("/api/sam/point", { | |
| method: "POST", headers: this.headers(), | |
| body: JSON.stringify({ | |
| page_idx: this.activePageIdx, | |
| x, y, | |
| label: this.annotateDefaultLabel || "object", | |
| }), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| this.showDetections = true; | |
| } catch (err) { | |
| console.error("SAM failed:", err); | |
| this.showToast("Segment failed: " + err.message, "error"); | |
| } finally { this.samBusy = false; } | |
| }, | |
| async relabelAnnotation(id, label) { | |
| if (!this.activePage) return; | |
| try { | |
| const r = await fetch("/api/annotation/label", { | |
| method: "POST", headers: this.headers(), | |
| body: JSON.stringify({ page_idx: this.activePageIdx, annotation_id: id, label }), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| } catch (e) { this.showToast("Relabel failed: " + e.message, "error"); } | |
| }, | |
| async deleteAnnotation(id) { | |
| if (!this.activePage) return; | |
| try { | |
| const r = await fetch(`/api/page/${this.activePageIdx}/annotation/${id}`, { method: "DELETE" }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| this.state = await r.json(); | |
| } catch (e) { this.showToast("Delete failed: " + e.message, "error"); } | |
| }, | |
| async quickAddIcl() { | |
| if (!this.activePage || !this.quickIclText) return; | |
| this.quickIclJsonError = ""; | |
| let payloadText = this.quickIclText; | |
| if (this.quickIclMode === "json") { | |
| try { | |
| // Validate + pretty-print so the pool entry shows clean JSON | |
| // and the few-shot prompt injects it canonically. | |
| payloadText = JSON.stringify(JSON.parse(this.quickIclText), null, 2); | |
| } catch (err) { | |
| this.quickIclJsonError = "Invalid JSON: " + err.message; | |
| return; | |
| } | |
| } | |
| try { | |
| const r = await fetch("/api/icl/paste", { | |
| method: "POST", headers: this.headers(), | |
| body: JSON.stringify({ | |
| image_id: this.activePage.image_id, | |
| text: payloadText, | |
| language: this.state.settings.language || "", | |
| }), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| this.quickIclText = ""; | |
| this.showToast("Added to ICL pool.", "success"); | |
| } catch (e) { this.showToast("Quick add failed: " + e.message, "error"); } | |
| }, | |
| sendToPostCorr() { | |
| this.postCorrText = this.editorText; | |
| this.setMode("post_correction"); | |
| }, | |
| // ── Post-correction mode ────────────────────────────────────────────── | |
| async uploadForPostCorr(files) { | |
| if (!files || files.length === 0) return; | |
| const list = Array.from(files).filter(f => f.type.startsWith("image/")); | |
| if (list.length === 0) return; | |
| const fd = new FormData(); | |
| for (const f of list) fd.append("files", f); | |
| this.busy = true; | |
| try { | |
| const r = await fetch("/api/upload", { method: "POST", body: fd }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| const lastPage = this.state.pages[this.state.pages.length - 1]; | |
| this.pcImageId = lastPage?.image_id || ""; | |
| this.showToast("Image ready for post-correction.", "success"); | |
| } catch (e) { this.showToast("Upload failed: " + e.message, "error"); } | |
| finally { this.busy = false; } | |
| }, | |
| async runMoe() { | |
| const body = { | |
| ocr_text: this.postCorrText, | |
| page_idx: this.activePage ? this.activePageIdx : null, | |
| image_id: this.activePage ? null : (this.pcImageId || null), | |
| }; | |
| this.busy = true; | |
| this.moeResult = null; | |
| try { | |
| const r = await fetch("/api/post_correct", { | |
| method: "POST", headers: this.headers(), | |
| body: JSON.stringify(body), | |
| }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const d = await r.json(); | |
| this.state = d.state; | |
| delete d.state; | |
| this.moeResult = d; | |
| this.showToast(`MoE done · source ${d.source} · conf ${(d.confidence*100).toFixed(0)}%`, "success"); | |
| } catch (e) { this.showToast("MoE failed: " + e.message, "error", 6000); } | |
| finally { this.busy = false; } | |
| }, | |
| // ── ICL pool ────────────────────────────────────────────────────────── | |
| async removeIcl(itemId) { | |
| try { | |
| const r = await fetch(`/api/icl/remove/${itemId}`, { method: "POST" }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| this.state = await r.json(); | |
| } catch (e) { this.showToast("Remove failed: " + e.message, "error"); } | |
| }, | |
| // Per-bbox inline style. Coordinates are translated to percentages of the | |
| // natural image dimensions; the overlay div is the same size as the <img> | |
| // (both children of the same relative wrapper), so this scales with zoom | |
| // automatically without any JS bookkeeping. | |
| bboxStyle(d) { | |
| const img = this.state.images?.[this.activePage?.image_id]; | |
| if (!img || !d || !Array.isArray(d.bbox_px)) return "display:none;"; | |
| const iw = img.w || 1; | |
| const ih = img.h || 1; | |
| const [x1, y1, x2, y2] = d.bbox_px; | |
| const left = (x1 / iw) * 100; | |
| const top = (y1 / ih) * 100; | |
| const w = ((x2 - x1) / iw) * 100; | |
| const h = ((y2 - y1) / ih) * 100; | |
| const color = d.color || "#e11d48"; | |
| return `left:${left}%; top:${top}%; width:${w}%; height:${h}%; border: 2px solid ${color};`; | |
| }, | |
| // ── Rendering helpers ───────────────────────────────────────────────── | |
| renderDiffHtml(diff) { | |
| if (!diff || diff.length === 0) return ""; | |
| const html = []; | |
| let currentLine = []; | |
| const flushLine = (n) => { | |
| html.push(`<div class="line"><span class="ln">${n}</span><span class="lc">${currentLine.join("")}</span></div>`); | |
| currentLine = []; | |
| }; | |
| let lineNo = 1; | |
| for (const tok of diff) { | |
| if (tok.text.includes("\n")) { | |
| const parts = tok.text.split("\n"); | |
| for (let i = 0; i < parts.length; i++) { | |
| const piece = parts[i]; | |
| if (piece) currentLine.push(escapeHtml(piece)); | |
| if (i < parts.length - 1) { flushLine(lineNo); lineNo++; } | |
| } | |
| } else if (tok.edited) { | |
| currentLine.push(`<span class="edited">${escapeHtml(tok.text)}</span>`); | |
| } else { | |
| currentLine.push(escapeHtml(tok.text)); | |
| } | |
| } | |
| if (currentLine.length) flushLine(lineNo); | |
| return html.join(""); | |
| }, | |
| }; | |
| } | |
| function escapeHtml(s) { | |
| return (s || "").replace(/[&<>"']/g, (c) => ({ | |
| "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", | |
| }[c])); | |
| } | |
| window.htrApp = htrApp; | |
| window.escapeHtml = escapeHtml; | |
| // Global fallback: if Alpine doesn't fire `alpine:initialized` within 3s, warn. | |
| let __alpineReady = false; | |
| document.addEventListener("alpine:initialized", () => { __alpineReady = true; }); | |
| window.addEventListener("DOMContentLoaded", () => { | |
| setTimeout(() => { | |
| if (__alpineReady) return; | |
| const warn = document.createElement("div"); | |
| warn.style.cssText = "position:fixed;top:0;left:0;right:0;padding:.75rem 1rem;background:#b91c1c;color:#fff;font:14px system-ui;z-index:9999"; | |
| warn.textContent = "Alpine.js did not initialise — open the browser DevTools console for the error."; | |
| document.body.prepend(warn); | |
| }, 3000); | |
| }); | |