feat: update frontend to use SSE analysis and pre-computed redaction data

Replace the click-to-analyze and spot-detection flows with an automatic
SSE-driven analysis pipeline. After upload, an EventSource connects to
/api/doc/{id}/analyze and reloads page data as each page completes,
consuming pre-computed analysis from the page data endpoint.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-17 15:38:13 -06:00
1 parent 1a65cb9a87
commit b577f2b52e
1 file changed
+75 -160
+75 -160
View File
@@ -5,7 +5,7 @@ import { state } from './state.js';
import {
dropZone, fileInput, uploadSection, viewerSection, docImage,
canvas, pageInfo, prevBtn, nextBtn, redactionListEl,
rightPanel, popoverEl, fontToolbar, textEditBar, fontSelect,
rightPanel, fontSelect,
solveAccept, gapValue, showToast,
} from './dom.js';
import { renderCanvas } from './canvas.js';
@@ -74,7 +74,7 @@ fileInput.addEventListener("change", () => {
});
async function uploadFile(file) {
uploadSection.innerHTML = '<p class="loading">Analyzing document...</p>';
uploadSection.innerHTML = '<p class="loading">Uploading document...</p>';
const fontPromise = loadFonts();
const assocPromise = loadAssociates();
@@ -93,7 +93,38 @@ async function uploadFile(file) {
uploadSection.hidden = true;
viewerSection.hidden = false;
// Load the first page image and controls immediately
await loadPage(1);
// Start background analysis via SSE
startAnalysisSSE();
}
/**
* Open an SSE connection to the analyze endpoint. As each page completes,
* reload its redaction data (which now includes pre-computed analysis).
*/
function startAnalysisSSE() {
const es = new EventSource(`/api/doc/${state.docId}/analyze`);
showToast("Analyzing document...");
es.addEventListener("message", (e) => {
/** @type {any} */
const data = JSON.parse(e.data);
if (data.event === "page_complete") {
// Reload the page data to pick up pre-computed analysis
loadPageData(data.page);
} else if (data.event === "done") {
es.close();
showToast("Analysis complete");
}
});
es.addEventListener("error", () => {
es.close();
showToast("Analysis connection lost", "error");
});
}
// ── Page loading ──
@@ -106,28 +137,53 @@ async function loadPage(page) {
docImage.src = `/api/doc/${state.docId}/page/${page}/original`;
const resp = await fetch(`/api/doc/${state.docId}/page/${page}/data`);
await loadPageData(page);
}
/**
* Fetch page data and populate redaction state. Called both on page
* navigation and when SSE signals a page analysis is complete.
* @param {number} pageNum
*/
async function loadPageData(pageNum) {
const resp = await fetch(`/api/doc/${state.docId}/page/${pageNum}/data`);
const data = await resp.json();
for (const r of data.redactions) {
if (!state.redactions[r.id]) {
state.redactions[r.id] = {
id: r.id,
x: r.x,
y: r.y,
w: r.w,
h: r.h,
page: page,
status: "unanalyzed",
analysis: null,
solution: null,
preview: null,
// Preserve existing solution/preview if the redaction was already loaded
const existing = state.redactions[r.id];
state.redactions[r.id] = {
id: r.id,
x: r.x,
y: r.y,
w: r.w,
h: r.h,
page: pageNum,
status: r.analysis ? "analyzed" : "unanalyzed",
analysis: r.analysis || null,
solution: existing?.solution || null,
preview: existing?.preview || null,
};
if (r.analysis) {
state.redactions[r.id].overrides = existing?.overrides || {
fontId: r.analysis.font.id,
fontSize: r.analysis.font.size,
offsetX: r.analysis.offset_x || 0,
offsetY: r.analysis.offset_y || 0,
gapWidth: r.analysis.gap.w,
leftText: r.analysis.segments[0]?.text || "",
rightText: r.analysis.segments[1]?.text || "",
};
}
}
renderRedactionList();
renderCanvas();
// Only re-render if this is the currently viewed page
if (pageNum === state.currentPage) {
renderRedactionList();
renderCanvas();
}
}
function updatePageControls() {
@@ -211,7 +267,7 @@ function redactionInfoText(r) {
return `${Math.round(r.w)} x ${Math.round(r.h)} px`;
}
// ── Activate & analyze redaction ──
// ── Activate redaction ──
function activateRedaction(id) {
const r = state.redactions[id];
@@ -226,64 +282,11 @@ function activateRedaction(id) {
renderRedactionList();
renderCanvas();
if (r.status === "unanalyzed") {
analyzeRedaction(id);
} else if (r.status === "analyzed" || r.status === "solved") {
if (r.status === "analyzed" || r.status === "solved") {
openPopover(id);
}
}
async function analyzeRedaction(id) {
const r = state.redactions[id];
if (!r) return;
r.status = "analyzing";
renderRedactionList();
try {
const resp = await fetch("/api/redaction/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
doc_id: state.docId,
page: r.page,
redaction: { x: r.x, y: r.y, w: r.w, h: r.h },
}),
});
if (!resp.ok) {
const err = await resp.json();
r.status = "error";
console.error("Analysis failed:", err);
renderRedactionList();
return;
}
const data = await resp.json();
r.status = "analyzed";
r.analysis = data;
r.overrides = {
fontId: data.font.id,
fontSize: data.font.size,
offsetX: data.offset_x || 0,
offsetY: data.offset_y || 0,
gapWidth: data.gap.w,
leftText: data.segments.length > 0 ? data.segments[0].text : "",
rightText: data.segments.length > 1 ? data.segments[1].text : "",
};
renderRedactionList();
renderCanvas();
if (state.activeRedaction === id) {
openPopover(id);
}
} catch (e) {
r.status = "error";
console.error("Analysis error:", e);
renderRedactionList();
}
}
// ── Canvas hit-testing ──
canvas.addEventListener("mousedown", (e) => {
@@ -311,94 +314,6 @@ canvas.addEventListener("mousemove", (e) => {
canvas.style.cursor = hit ? "pointer" : "";
});
// ── Double-click spot detection ──
rightPanel.addEventListener("dblclick", async (e) => {
if (popoverEl.contains(/** @type {Node} */ (e.target)) ||
fontToolbar.contains(/** @type {Node} */ (e.target)) ||
textEditBar.contains(/** @type {Node} */ (e.target))) return;
if (!state.docId) return;
const rect = rightPanel.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const doc = screenToDoc(sx, sy);
const hit = hitTestRedaction(doc.x, doc.y);
if (hit) return;
const clickX = Math.round(doc.x);
const clickY = Math.round(doc.y);
showToast("Detecting redaction...");
try {
const resp = await fetch("/api/redaction/spot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
doc_id: state.docId,
page: state.currentPage,
click_x: clickX,
click_y: clickY,
}),
});
if (!resp.ok) {
const err = await resp.json();
showToast(err.error === "no_redaction_found" ? "No redaction found at click point" : "Detection failed", "error");
return;
}
const data = await resp.json();
const box = data.box;
const existingDup = Object.values(state.redactions).find(r =>
r.page === state.currentPage &&
Math.abs(r.x - box.x) < 3 && Math.abs(r.y - box.y) < 3 &&
Math.abs(r.w - box.w) < 3 && Math.abs(r.h - box.h) < 3
);
if (existingDup) {
activateRedaction(existingDup.id);
return;
}
const id = "m" + Date.now().toString(36);
state.redactions[id] = {
id,
x: box.x,
y: box.y,
w: box.w,
h: box.h,
page: state.currentPage,
status: data.segments ? "analyzed" : "unanalyzed",
analysis: data.segments ? data : null,
solution: null,
preview: null,
};
if (data.segments) {
state.redactions[id].overrides = {
fontId: data.font.id,
fontSize: data.font.size,
offsetX: data.offset_x || 0,
offsetY: data.offset_y || 0,
gapWidth: data.gap.w,
leftText: data.segments.length > 0 ? data.segments[0].text : "",
rightText: data.segments.length > 1 ? data.segments[1].text : "",
};
}
renderRedactionList();
renderCanvas();
activateRedaction(id);
} catch (e) {
console.error("Spot detection error:", e);
showToast("Detection failed: " + e.message, "error");
}
});
// ── Ctrl+drag offset / Shift+drag gap width ──
let modDrag = null;