From 556007f5fbc6cb30f7da01a8f2f6e5092d68f26b Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Sun, 15 Feb 2026 22:24:30 -0600 Subject: [PATCH] feat: frontend solve panel with SSE streaming results Add a solve panel UI that lets users attempt to recover redacted text by finding strings whose rendered width matches the redaction gap. Includes charset selection, length bounds, tolerance slider, and dictionary/enumerate/both mode controls. Results stream via SSE and can be clicked to insert into the gap. Co-Authored-By: Claude Opus 4.6 --- unredact/static/app.js | 683 +++++++++++++++++++++++++++++++++++-- unredact/static/index.html | 75 +++- unredact/static/style.css | 273 ++++++++++++++- 3 files changed, 982 insertions(+), 49 deletions(-) diff --git a/unredact/static/app.js b/unredact/static/app.js index c30346e..516e3c6 100644 --- a/unredact/static/app.js +++ b/unredact/static/app.js @@ -16,6 +16,34 @@ const sizeSlider = document.getElementById("size-slider"); const sizeValue = document.getElementById("size-value"); const sizeDown = document.getElementById("size-down"); const sizeUp = document.getElementById("size-up"); +const zoomInBtn = document.getElementById("zoom-in"); +const zoomOutBtn = document.getElementById("zoom-out"); +const zoomFitBtn = document.getElementById("zoom-fit"); +const zoomLevel = document.getElementById("zoom-level"); +const rightPanel = document.getElementById("right-panel"); +const docContainer = document.getElementById("doc-container"); +const posUp = document.getElementById("pos-up"); +const posDown = document.getElementById("pos-down"); +const posLeft = document.getElementById("pos-left"); +const posRight = document.getElementById("pos-right"); +const posReset = document.getElementById("pos-reset"); +const posDisplay = document.getElementById("pos-display"); +const textEditBar = document.getElementById("text-edit-bar"); +const segmentInputs = document.getElementById("segment-inputs"); +const textReset = document.getElementById("text-reset"); +const solveBtn = document.getElementById("solve-btn"); +const solvePanel = document.getElementById("solve-panel"); +const solveClose = document.getElementById("solve-close"); +const solveStart = document.getElementById("solve-start"); +const solveStop = document.getElementById("solve-stop"); +const solveStatus = document.getElementById("solve-status"); +const solveResults = document.getElementById("solve-results"); +const solveCharset = document.getElementById("solve-charset"); +const solveMinLen = document.getElementById("solve-min-len"); +const solveMaxLen = document.getElementById("solve-max-len"); +const solveTolerance = document.getElementById("solve-tolerance"); +const solveTolValue = document.getElementById("solve-tol-value"); +const solveMode = document.getElementById("solve-mode"); // State const state = { @@ -24,9 +52,14 @@ const state = { currentPage: 1, pageData: {}, // page -> {lines: [...]} selectedLine: null, // index into current page's lines - lineOverrides: {}, // "page-lineIdx" -> {fontId, fontSize} + lineOverrides: {}, // "page-lineIdx" -> {fontId, fontSize, segments: [{text, offsetX, offsetY}]} + activeSegment: 0, // which segment the d-pad / focus applies to fonts: [], // [{name, id, available}] fontsReady: false, + // Viewport: panX/panY are the document-space coords at the center of the panel + zoom: 1, + panX: 0, + panY: 0, }; // ── Font loading ── @@ -110,6 +143,7 @@ async function loadPage(page) { state.currentPage = page; state.selectedLine = null; fontControls.hidden = true; + textEditBar.hidden = true; updatePageControls(); // Load the original page image @@ -195,22 +229,21 @@ function selectLine(idx) { sizeSlider.value = fontSize; sizeValue.textContent = fontSize; fontControls.hidden = false; + textEditBar.hidden = false; + state.activeSegment = 0; + renderSegmentInputs(); + updatePosDisplay(); renderOverlay(); scrollToLine(line); + updateSolveButton(); } function scrollToLine(line) { - // Scroll the right panel so the selected line is visible - const rightPanel = document.getElementById("right-panel"); - const imgRect = docImage.getBoundingClientRect(); - const scale = docImage.clientHeight / docImage.naturalHeight; - const lineY = line.y * scale; - const panelRect = rightPanel.getBoundingClientRect(); - const lineScreenY = imgRect.top - panelRect.top + lineY; - // Center the line in the panel - const target = lineScreenY - rightPanel.clientHeight / 3; - rightPanel.scrollTop += target; + // Pan so the line is centered in the viewport + state.panX = line.x + line.w / 2; + state.panY = line.y + line.h / 2; + applyTransform(true); } // ── Font controls ── @@ -231,13 +264,209 @@ sizeUp.addEventListener("click", () => { saveOverrideAndRender(); }); +function nudge(dx, dy) { + if (state.selectedLine === null) return; + const override = ensureOverride(); + // X is per-segment, Y is shared across all segments + if (dx !== 0) { + const seg = override.segments[state.activeSegment]; + if (seg) seg.offsetX += dx; + } + if (dy !== 0) { + override.offsetY = (override.offsetY || 0) + dy; + } + updatePosDisplay(); + renderOverlay(); +} + +function updatePosDisplay() { + const key = `${state.currentPage}-${state.selectedLine}`; + const override = state.lineOverrides[key]; + const seg = override?.segments?.[state.activeSegment]; + const x = seg?.offsetX ?? 0; + const y = override?.offsetY ?? 0; + posDisplay.textContent = `${x}, ${y}`; +} + +posUp.addEventListener("click", () => nudge(0, -1)); +posDown.addEventListener("click", () => nudge(0, 1)); +posLeft.addEventListener("click", () => nudge(-1, 0)); +posRight.addEventListener("click", () => nudge(1, 0)); +posReset.addEventListener("click", () => { + if (state.selectedLine === null) return; + const override = ensureOverride(); + const seg = override.segments[state.activeSegment]; + if (seg) seg.offsetX = 0; + override.offsetY = 0; + updatePosDisplay(); + renderOverlay(); +}); + +// ── Segments model ── +// +// Each line override has a `segments` array: [{text, offsetX, offsetY}, ...] +// A line with no redactions has 1 segment. Ctrl+Space splits a segment, +// inserting a redaction gap. The d-pad affects state.activeSegment. + +function getSegments() { + const key = `${state.currentPage}-${state.selectedLine}`; + return state.lineOverrides[key]?.segments; +} + +function ensureOverride() { + const key = `${state.currentPage}-${state.selectedLine}`; + if (!state.lineOverrides[key]) { + const pd = state.pageData[state.currentPage]; + const line = pd.lines[state.selectedLine]; + state.lineOverrides[key] = { + fontId: line.font.id, + fontSize: line.font.size, + offsetY: 0, + segments: [{ text: line.text, offsetX: 0 }], + }; + } + return state.lineOverrides[key]; +} + +function ensureSegments() { + return ensureOverride().segments; +} + +// ── Segment UI ── + +function renderSegmentInputs() { + const segments = getSegments(); + const pd = state.pageData[state.currentPage]; + const line = pd.lines[state.selectedLine]; + + segmentInputs.innerHTML = ""; + + // If no override yet, show single input with original text + const segs = segments || [{ text: line.text, offsetX: 0, offsetY: 0 }]; + + segs.forEach((seg, i) => { + if (i > 0) { + // Redaction marker between segments + const marker = document.createElement("span"); + marker.className = "redaction-marker"; + marker.textContent = "???"; + marker.title = "Redaction gap"; + segmentInputs.appendChild(marker); + } + + const input = document.createElement("input"); + input.type = "text"; + input.className = "seg-input"; + input.value = seg.text; + input.spellcheck = false; + input.autocomplete = "off"; + input.dataset.segIdx = i; + if (i === state.activeSegment) input.classList.add("active-segment"); + + input.addEventListener("focus", () => { + state.activeSegment = i; + segmentInputs.querySelectorAll(".seg-input").forEach((el, j) => { + el.classList.toggle("active-segment", j === i); + }); + updatePosDisplay(); + }); + + input.addEventListener("input", () => { + const segs = ensureSegments(); + segs[i].text = input.value; + renderOverlay(); + updateLineListPreview(); + }); + + // Ctrl+Space: split this segment at cursor, inserting a redaction gap + input.addEventListener("keydown", (e) => { + if (e.ctrlKey && e.code === "Space") { + e.preventDefault(); + splitSegmentAtCursor(i, input); + } + }); + + segmentInputs.appendChild(input); + }); + + // Hint if only one segment + if (segs.length === 1) { + const hint = document.createElement("span"); + hint.className = "seg-hint"; + hint.textContent = "Ctrl+Space to add redaction"; + segmentInputs.appendChild(hint); + } +} + +function splitSegmentAtCursor(segIdx, inputEl) { + const segs = ensureSegments(); + const seg = segs[segIdx]; + const pos = inputEl.selectionStart; + const before = seg.text.slice(0, pos); + const after = seg.text.slice(pos); + + // Replace this segment with two, separated by the new redaction gap + segs.splice(segIdx, 1, + { text: before, offsetX: seg.offsetX }, + { text: after, offsetX: 0 }, + ); + + // Focus the new segment after the gap + state.activeSegment = segIdx + 1; + renderSegmentInputs(); + renderOverlay(); + updateLineListPreview(); + updateSolveButton(); + + // Focus the new input + const inputs = segmentInputs.querySelectorAll(".seg-input"); + if (inputs[state.activeSegment]) { + inputs[state.activeSegment].focus(); + inputs[state.activeSegment].setSelectionRange(0, 0); + } +} + +function updateLineListPreview() { + const items = lineList.querySelectorAll(".line-item"); + if (!items[state.selectedLine]) return; + const segs = getSegments(); + const pd = state.pageData[state.currentPage]; + const line = pd.lines[state.selectedLine]; + const textEl = items[state.selectedLine].querySelector(".line-text"); + if (segs && segs.length > 1) { + textEl.textContent = segs.map((s) => s.text).join(" [???] "); + } else if (segs) { + textEl.textContent = segs[0].text; + } else { + textEl.textContent = line.text; + } +} + +textReset.addEventListener("click", () => { + if (state.selectedLine === null) return; + const key = `${state.currentPage}-${state.selectedLine}`; + const override = state.lineOverrides[key]; + if (override) { + const pd = state.pageData[state.currentPage]; + const line = pd.lines[state.selectedLine]; + override.segments = [{ text: line.text, offsetX: 0 }]; + override.offsetY = 0; + } + state.activeSegment = 0; + renderSegmentInputs(); + updatePosDisplay(); + renderOverlay(); + updateLineListPreview(); + updateSolveButton(); +}); + function saveOverrideAndRender() { if (state.selectedLine === null) return; const key = `${state.currentPage}-${state.selectedLine}`; - state.lineOverrides[key] = { - fontId: fontSelect.value, - fontSize: parseInt(sizeSlider.value), - }; + const prev = state.lineOverrides[key]; + const override = ensureOverride(); + override.fontId = fontSelect.value; + override.fontSize = parseInt(sizeSlider.value); renderOverlay(); // Update the meta text in the line list const items = lineList.querySelectorAll(".line-item"); @@ -267,38 +496,434 @@ function renderOverlay() { const fontId = override ? override.fontId : line.font.id; const fontSize = override ? override.fontSize : line.font.size; const fontName = state.fonts.find((f) => f.id === fontId)?.name || line.font.name; + const fontStr = `${fontSize}px "${fontName}"`; - // Match canvas resolution to native image resolution + // Canvas resolution matches native image; CSS size matches natural size canvas.width = docImage.naturalWidth; canvas.height = docImage.naturalHeight; - // Match display size to image display size - canvas.style.width = docImage.clientWidth + "px"; - canvas.style.height = docImage.clientHeight + "px"; + canvas.style.width = docImage.naturalWidth + "px"; + canvas.style.height = docImage.naturalHeight + "px"; ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.font = `${fontSize}px "${fontName}"`; - ctx.fillStyle = "rgba(0, 200, 0, 0.6)"; + ctx.font = fontStr; ctx.textBaseline = "top"; - ctx.fillText(line.text, line.x, line.y); - // Draw a subtle bounding box for reference + // Draw bounding box for reference (at original position) ctx.strokeStyle = "rgba(0, 200, 0, 0.3)"; ctx.lineWidth = 1; ctx.strokeRect(line.x, line.y, line.w, line.h); + + const segments = override?.segments || [{ text: line.text, offsetX: 0 }]; + const sharedY = override?.offsetY || 0; + + // Render each segment; track x cursor for positioning + let cursorX = line.x; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const sx = cursorX + seg.offsetX; + const sy = line.y + sharedY; + + // Draw segment text + const isActive = i === state.activeSegment; + ctx.fillStyle = isActive ? "rgba(0, 230, 0, 0.7)" : "rgba(0, 200, 0, 0.5)"; + ctx.font = fontStr; + ctx.fillText(seg.text, sx, sy); + + const textWidth = ctx.measureText(seg.text).width; + cursorX = sx + textWidth; + + // If there's a next segment, draw the redaction gap + if (i < segments.length - 1) { + const nextSeg = segments[i + 1]; + // The gap extends from end of this segment to start of next + // Next segment starts at cursorX + nextSeg.offsetX + const gapStart = cursorX; + const gapEnd = cursorX + nextSeg.offsetX; + const gapWidth = Math.max(gapEnd - gapStart, fontSize * 2); // at least 2em + + // Draw redaction indicator — full line height, strong red + const pad = fontSize * 0.15; + ctx.fillStyle = "rgba(211, 47, 47, 0.5)"; + ctx.fillRect(gapStart, line.y - pad, gapWidth, line.h + pad * 2); + ctx.strokeStyle = "rgba(211, 47, 47, 0.8)"; + ctx.lineWidth = 2; + ctx.strokeRect(gapStart, line.y - pad, gapWidth, line.h + pad * 2); + + // Draw "?" centered in the gap at the same font size + ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + ctx.font = `bold ${fontSize}px sans-serif`; + const qWidth = ctx.measureText("?").width; + ctx.fillText("?", gapStart + (gapWidth - qWidth) / 2, line.y); + ctx.font = fontStr; // restore + + // Advance cursor past the gap + cursorX = gapStart + gapWidth; + } + } } +// ── Viewport (Google Maps-style zoom & pan) ── +// +// The model: panX/panY are document-space coordinates at the center of the +// viewport. zoom is the scale factor. We compute a single CSS transform +// on #doc-container that maps document coords to screen coords. + +function applyTransform(smooth) { + const pw = rightPanel.clientWidth; + const ph = rightPanel.clientHeight; + // translate so that (panX, panY) lands at the center of the panel + const tx = pw / 2 - state.panX * state.zoom; + const ty = ph / 2 - state.panY * state.zoom; + + if (smooth) { + docContainer.style.transition = "transform 0.25s ease-out"; + } else { + docContainer.style.transition = "none"; + } + + docContainer.style.transform = + `translate(${tx}px, ${ty}px) scale(${state.zoom})`; + zoomLevel.textContent = `${Math.round(state.zoom * 100)}%`; +} + +// Convert screen coords (relative to panel) → document coords +function screenToDoc(sx, sy) { + const pw = rightPanel.clientWidth; + const ph = rightPanel.clientHeight; + return { + x: (sx - pw / 2) / state.zoom + state.panX, + y: (sy - ph / 2) / state.zoom + state.panY, + }; +} + +function zoomTo(newZoom, pivotSX, pivotSY, smooth) { + newZoom = Math.max(0.1, Math.min(20, newZoom)); + // If a screen-space pivot is given, keep that document point stationary + if (pivotSX !== undefined) { + const doc = screenToDoc(pivotSX, pivotSY); + state.panX = doc.x; + state.panY = doc.y; + // After zoom, (doc.x, doc.y) should still appear at (pivotSX, pivotSY). + // Since applyTransform centers panX/panY in the panel, we need to offset. + const pw = rightPanel.clientWidth; + const ph = rightPanel.clientHeight; + state.panX += (pw / 2 - pivotSX) / newZoom; + state.panY += (ph / 2 - pivotSY) / newZoom; + } + state.zoom = newZoom; + applyTransform(!!smooth); + renderOverlay(); +} + +function zoomToFit() { + if (!docImage.naturalWidth) return; + const pw = rightPanel.clientWidth; + const ph = rightPanel.clientHeight; + const iw = docImage.naturalWidth; + const ih = docImage.naturalHeight; + state.zoom = Math.min(pw / iw, ph / ih) * 0.95; // small margin + state.panX = iw / 2; + state.panY = ih / 2; + applyTransform(true); + renderOverlay(); +} + +// Button zoom (centered) +zoomInBtn.addEventListener("click", () => { + zoomTo(state.zoom * 1.3, undefined, undefined, true); +}); +zoomOutBtn.addEventListener("click", () => { + zoomTo(state.zoom / 1.3, undefined, undefined, true); +}); +zoomFitBtn.addEventListener("click", zoomToFit); + +// Mouse-wheel zoom toward cursor +rightPanel.addEventListener("wheel", (e) => { + if (fontControls.contains(e.target) || textEditBar.contains(e.target)) return; + e.preventDefault(); + const rect = rightPanel.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + // Continuous zoom: scale by small increments for smoothness + const factor = Math.pow(1.002, -e.deltaY); + zoomTo(state.zoom * factor, sx, sy, false); +}, { passive: false }); + +// Double-click to zoom in +rightPanel.addEventListener("dblclick", (e) => { + if (fontControls.contains(e.target) || textEditBar.contains(e.target)) return; + const rect = rightPanel.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + zoomTo(state.zoom * 2, sx, sy, true); +}); + +// ── Click-drag pan ── + +let drag = null; + +rightPanel.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + // Don't start panning when interacting with the font controls toolbar + if (fontControls.contains(e.target) || textEditBar.contains(e.target)) return; + drag = { + startX: e.clientX, + startY: e.clientY, + startPanX: state.panX, + startPanY: state.panY, + moved: false, + }; + rightPanel.classList.add("panning"); + e.preventDefault(); +}); + +window.addEventListener("mousemove", (e) => { + if (!drag) return; + const dx = e.clientX - drag.startX; + const dy = e.clientY - drag.startY; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) drag.moved = true; + // Translate screen deltas back to document space + state.panX = drag.startPanX - dx / state.zoom; + state.panY = drag.startPanY - dy / state.zoom; + applyTransform(false); +}); + +window.addEventListener("mouseup", () => { + if (drag) { + drag = null; + rightPanel.classList.remove("panning"); + } +}); + +// ── Touch: pinch-zoom + two-finger pan ── + +let lastTouches = null; + +rightPanel.addEventListener("touchstart", (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + lastTouches = Array.from(e.touches); + } +}, { passive: false }); + +rightPanel.addEventListener("touchmove", (e) => { + if (e.touches.length === 2 && lastTouches) { + e.preventDefault(); + const [t0, t1] = e.touches; + const [p0, p1] = lastTouches; + + // Distance change → zoom + const oldDist = Math.hypot(p1.clientX - p0.clientX, p1.clientY - p0.clientY); + const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); + const zoomDelta = newDist / oldDist; + + // Midpoint movement → pan + const oldMidX = (p0.clientX + p1.clientX) / 2; + const oldMidY = (p0.clientY + p1.clientY) / 2; + const newMidX = (t0.clientX + t1.clientX) / 2; + const newMidY = (t0.clientY + t1.clientY) / 2; + + state.panX -= (newMidX - oldMidX) / state.zoom; + state.panY -= (newMidY - oldMidY) / state.zoom; + + const rect = rightPanel.getBoundingClientRect(); + const sx = newMidX - rect.left; + const sy = newMidY - rect.top; + zoomTo(state.zoom * zoomDelta, sx, sy, false); + + lastTouches = Array.from(e.touches); + } +}, { passive: false }); + +rightPanel.addEventListener("touchend", () => { + lastTouches = null; +}); + // ── Resize handling ── const resizeObserver = new ResizeObserver(() => { + applyTransform(false); + if (state.selectedLine !== null) { + renderOverlay(); + } +}); +resizeObserver.observe(rightPanel); + +// On image load, fit to viewport +docImage.addEventListener("load", () => { + zoomToFit(); if (state.selectedLine !== null) { renderOverlay(); } }); -// Start observing once image loads -docImage.addEventListener("load", () => { - resizeObserver.observe(docImage); - if (state.selectedLine !== null) { - renderOverlay(); - } +// ── Solve panel ── + +let activeEventSource = null; + +function updateSolveButton() { + const segs = getSegments(); + solveBtn.hidden = !(segs && segs.length > 1); +} + +solveBtn.addEventListener("click", () => { + solvePanel.hidden = false; }); + +solveClose.addEventListener("click", () => { + solvePanel.hidden = true; + stopSolve(); +}); + +solveTolerance.addEventListener("input", () => { + solveTolValue.textContent = solveTolerance.value; +}); + +solveStart.addEventListener("click", startSolve); +solveStop.addEventListener("click", stopSolve); + +function startSolve() { + if (state.selectedLine === null) return; + const segs = getSegments(); + if (!segs || segs.length < 2) return; + + const pd = state.pageData[state.currentPage]; + const line = pd.lines[state.selectedLine]; + const override = state.lineOverrides[`${state.currentPage}-${state.selectedLine}`]; + const fontId = override ? override.fontId : line.font.id; + const fontSize = override ? override.fontSize : line.font.size; + const fontName = state.fonts.find(f => f.id === fontId)?.name || line.font.name; + + // Find the gap after the active segment + const gapIdx = state.activeSegment; + if (gapIdx >= segs.length - 1) return; + + // Compute gap width using canvas measurement + const fontStr = `${fontSize}px "${fontName}"`; + ctx.font = fontStr; + + // Calculate gap width the same way renderOverlay does + let cursorX = 0; + for (let i = 0; i <= gapIdx; i++) { + cursorX += segs[i].offsetX + ctx.measureText(segs[i].text).width; + } + const gapStart = cursorX; + const nextSeg = segs[gapIdx + 1]; + const gapEnd = cursorX + nextSeg.offsetX; + const gapWidth = Math.max(gapEnd - gapStart, fontSize * 2); + + // Context characters + const leftCtx = segs[gapIdx].text.length > 0 ? segs[gapIdx].text[segs[gapIdx].text.length - 1] : ""; + const rightCtx = nextSeg.text.length > 0 ? nextSeg.text[0] : ""; + + // Clear previous results + solveResults.innerHTML = ""; + solveStatus.textContent = "Starting..."; + solveStart.hidden = true; + solveStop.hidden = false; + + const body = { + font_id: fontId, + font_size: fontSize, + gap_width_px: gapWidth, + tolerance_px: parseFloat(solveTolerance.value), + left_context: leftCtx, + right_context: rightCtx, + hints: { + charset: solveCharset.value, + min_length: parseInt(solveMinLen.value), + max_length: parseInt(solveMaxLen.value), + }, + mode: solveMode.value, + }; + + const abortController = new AbortController(); + activeEventSource = abortController; + + fetch("/api/solve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: abortController.signal, + }).then(response => { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + function read() { + reader.read().then(({ done, value }) => { + if (done) { + solveStart.hidden = false; + solveStop.hidden = true; + return; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop(); + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + handleSolveEvent(data, gapIdx); + } catch (e) { /* skip malformed */ } + } + } + read(); + }); + } + read(); + }).catch(err => { + if (err.name !== "AbortError") { + solveStatus.textContent = "Error: " + err.message; + } + solveStart.hidden = false; + solveStop.hidden = true; + }); +} + +function handleSolveEvent(data, gapIdx) { + if (data.status === "match") { + const div = document.createElement("div"); + div.className = "solve-result"; + div.innerHTML = ` + ${escapeHtml(data.text)} + ${data.error_px.toFixed(1)}px ${data.source || ""} + `; + div.addEventListener("click", () => { + const segs = ensureSegments(); + // Insert text into gap: add a new segment between gapIdx and gapIdx+1 + segs.splice(gapIdx + 1, 0, { text: data.text, offsetX: 0 }); + state.activeSegment = gapIdx + 1; + renderSegmentInputs(); + renderOverlay(); + updateLineListPreview(); + }); + solveResults.appendChild(div); + solveStatus.textContent = `Found ${solveResults.children.length} matches`; + } else if (data.status === "running") { + solveStatus.textContent = `Checked ${data.checked}, found ${data.found}...`; + } else if (data.status === "done") { + solveStatus.textContent = `Done. ${data.total_found} total matches.`; + solveStart.hidden = false; + solveStop.hidden = true; + activeEventSource = null; + } +} + +function stopSolve() { + if (activeEventSource) { + activeEventSource.abort(); + activeEventSource = null; + } + solveStart.hidden = false; + solveStop.hidden = true; + solveStatus.textContent = "Stopped."; +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} diff --git a/unredact/static/index.html b/unredact/static/index.html index 83bd5a1..d328cf8 100644 --- a/unredact/static/index.html +++ b/unredact/static/index.html @@ -25,16 +25,25 @@ Page 1 / 1 + + + 100% + +
+
+ +
+ + -
- -
Document page diff --git a/unredact/static/style.css b/unredact/static/style.css index e7f5afc..6d90ff9 100644 --- a/unredact/static/style.css +++ b/unredact/static/style.css @@ -4,9 +4,10 @@ body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; - min-height: 100vh; + height: 100vh; display: flex; flex-direction: column; + overflow: hidden; } header { @@ -80,6 +81,20 @@ main { cursor: not-allowed; } +.controls-sep { + width: 1px; + height: 1.2rem; + background: #0f3460; + flex-shrink: 0; +} + +#zoom-level { + font-size: 0.8rem; + color: #00d474; + min-width: 3rem; + text-align: center; +} + /* Workspace: split panel */ #workspace { display: flex; @@ -88,20 +103,16 @@ main { overflow: hidden; } -/* Left panel */ +/* Left panel — scrollable line list only */ #left-panel { width: 350px; flex-shrink: 0; - display: flex; - flex-direction: column; background: #16213e; border-radius: 8px; - overflow: hidden; + overflow-y: auto; } #line-list { - flex: 1; - overflow-y: auto; padding: 0.5rem 0; } @@ -136,14 +147,21 @@ main { margin-top: 0.15rem; } -/* Font controls */ +/* Font controls — floating toolbar at top of right panel */ #font-controls { - flex-shrink: 0; - padding: 0.75rem; - border-top: 1px solid #0f3460; + position: absolute; + top: 0.5rem; + left: 0.5rem; + right: 0.5rem; + z-index: 10; display: flex; - flex-direction: column; + align-items: center; gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(22, 33, 62, 0.92); + backdrop-filter: blur(6px); + border-radius: 6px; + border: 1px solid #0f3460; } #font-controls label, @@ -153,6 +171,7 @@ main { gap: 0.5rem; font-size: 0.8rem; color: #aaa; + white-space: nowrap; } #font-select { @@ -191,23 +210,60 @@ main { text-align: right; } +/* Position controls */ +.pos-control { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: #aaa; +} + +.dpad { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; +} + +.dpad-mid { + display: flex; + align-items: center; + gap: 1px; +} + +.pos-display { + color: #00d474; + font-size: 0.7rem; + min-width: 3.5rem; + text-align: center; +} + /* Right panel */ #right-panel { flex: 1; - overflow: auto; + overflow: hidden; background: #111; border-radius: 8px; + cursor: grab; + position: relative; +} + +#right-panel.panning { + cursor: grabbing; + user-select: none; } #doc-container { - position: relative; - display: inline-block; + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + will-change: transform; } #doc-image { display: block; - max-width: 100%; - height: auto; } #overlay-canvas { @@ -217,9 +273,192 @@ main { pointer-events: none; } +/* Text edit bar — floating at bottom of right panel */ +#text-edit-bar { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + right: 0.5rem; + z-index: 10; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.6rem; + background: rgba(22, 33, 62, 0.92); + backdrop-filter: blur(6px); + border-radius: 6px; + border: 1px solid #0f3460; + font-size: 0.8rem; + color: #aaa; +} + +#segment-inputs { + flex: 1; + display: flex; + align-items: center; + gap: 0; + min-width: 0; +} + +.seg-input { + flex: 1; + min-width: 3rem; + background: #0a1628; + color: #e0e0e0; + border: 1px solid #1a3a6e; + border-radius: 4px; + padding: 0.3rem 0.4rem; + font-family: monospace; + font-size: 0.85rem; +} + +.seg-input:focus { + outline: none; + border-color: #00d474; +} + +.seg-input.active-segment { + border-color: #00d474; + box-shadow: 0 0 0 1px rgba(0, 212, 116, 0.3); +} + +.redaction-marker { + flex-shrink: 0; + padding: 0.25rem 0.4rem; + background: #d32f2f; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + border-radius: 3px; + cursor: default; + user-select: none; + letter-spacing: 0.05em; +} + +.seg-hint { + font-size: 0.6rem; + color: #666; + white-space: nowrap; + padding: 0 0.2rem; +} + .loading { color: #00d474; text-align: center; padding: 2rem; font-size: 1.2rem; } + +/* Solve panel */ +#solve-panel { + position: absolute; + top: 80px; + right: 12px; + width: 300px; + max-height: calc(100% - 100px); + background: rgba(20, 20, 40, 0.95); + border: 1px solid #333; + border-radius: 8px; + padding: 12px; + z-index: 30; + overflow-y: auto; + backdrop-filter: blur(10px); +} + +.solve-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.solve-title { + font-weight: bold; + color: #00d474; +} + +.solve-controls { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; +} + +.solve-controls label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: #aaa; +} + +.solve-controls select, +.solve-controls input { + background: #1a1a2e; + border: 1px solid #333; + color: #eee; + border-radius: 4px; + padding: 2px 4px; +} + +.solve-num { + width: 50px; +} + +.solve-actions { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.solve-btn { + background: #00d474; + color: #000; + border: none; + border-radius: 4px; + padding: 6px 16px; + cursor: pointer; + font-weight: bold; +} + +.solve-btn:hover { + background: #00ff8a; +} + +#solve-status { + font-size: 0.75rem; + color: #aaa; +} + +#solve-results { + display: flex; + flex-direction: column; + gap: 2px; +} + +.solve-result { + display: flex; + justify-content: space-between; + padding: 4px 8px; + background: rgba(0, 212, 116, 0.1); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + font-family: monospace; + font-size: 0.85rem; +} + +.solve-result:hover { + border-color: #00d474; + background: rgba(0, 212, 116, 0.2); +} + +.solve-result .result-text { + color: #00d474; +} + +.solve-result .result-error { + color: #888; + font-size: 0.75rem; +}