mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-11-05 21:20:30 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
d391bfa6d6
commit
556007f5fb
3 files changed
+982
-49
No files matched your search
+654
-29
@@ -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 = `
|
||||
<span class="result-text">${escapeHtml(data.text)}</span>
|
||||
<span class="result-error">${data.error_px.toFixed(1)}px ${data.source || ""}</span>
|
||||
`;
|
||||
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;
|
||||
}
|
||||
@@ -25,16 +25,25 @@
|
||||
<button id="prev-page" disabled>< Prev</button>
|
||||
<span id="page-info">Page 1 / 1</span>
|
||||
<button id="next-page" disabled>Next ></button>
|
||||
<span class="controls-sep"></span>
|
||||
<button id="zoom-out">-</button>
|
||||
<span id="zoom-level">100%</span>
|
||||
<button id="zoom-in">+</button>
|
||||
<button id="zoom-fit">Fit</button>
|
||||
</div>
|
||||
|
||||
<div id="workspace">
|
||||
<div id="left-panel">
|
||||
<div id="line-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="right-panel">
|
||||
<div id="font-controls" hidden>
|
||||
<label>
|
||||
Font
|
||||
<select id="font-select"></select>
|
||||
</label>
|
||||
<span class="controls-sep"></span>
|
||||
<div class="size-control">
|
||||
<span>Size</span>
|
||||
<button id="size-down" class="size-btn">-</button>
|
||||
@@ -42,10 +51,70 @@
|
||||
<button id="size-up" class="size-btn">+</button>
|
||||
<span id="size-value">32</span>px
|
||||
</div>
|
||||
<span class="controls-sep"></span>
|
||||
<div class="pos-control">
|
||||
<span>Pos</span>
|
||||
<div class="dpad">
|
||||
<button id="pos-up" class="size-btn dpad-up">▲</button>
|
||||
<div class="dpad-mid">
|
||||
<button id="pos-left" class="size-btn">◀</button>
|
||||
<span id="pos-display" class="pos-display">0, 0</span>
|
||||
<button id="pos-right" class="size-btn">▶</button>
|
||||
</div>
|
||||
<button id="pos-down" class="size-btn dpad-down">▼</button>
|
||||
</div>
|
||||
<button id="pos-reset" class="size-btn" style="font-size:0.65rem">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="text-edit-bar" hidden>
|
||||
<div id="segment-inputs"></div>
|
||||
<button id="text-reset" class="size-btn" title="Reset all">Reset</button>
|
||||
<button id="solve-btn" class="size-btn" title="Solve redaction gap" hidden>Solve</button>
|
||||
</div>
|
||||
<div id="solve-panel" hidden>
|
||||
<div class="solve-header">
|
||||
<span class="solve-title">Solve Redaction</span>
|
||||
<button id="solve-close" class="size-btn">X</button>
|
||||
</div>
|
||||
<div class="solve-controls">
|
||||
<label>
|
||||
Charset
|
||||
<select id="solve-charset">
|
||||
<option value="lowercase">lowercase</option>
|
||||
<option value="uppercase">UPPERCASE</option>
|
||||
<option value="alpha">Mixed Case</option>
|
||||
<option value="alphanumeric">Alphanumeric</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Min length
|
||||
<input type="number" id="solve-min-len" value="3" min="1" max="30" class="solve-num">
|
||||
</label>
|
||||
<label>
|
||||
Max length
|
||||
<input type="number" id="solve-max-len" value="8" min="1" max="30" class="solve-num">
|
||||
</label>
|
||||
<label>
|
||||
Tolerance
|
||||
<input type="range" id="solve-tolerance" min="0" max="5" step="0.5" value="0">
|
||||
<span id="solve-tol-value">0</span>px
|
||||
</label>
|
||||
<label>
|
||||
Mode
|
||||
<select id="solve-mode">
|
||||
<option value="enumerate">Enumerate</option>
|
||||
<option value="dictionary">Dictionary</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="solve-actions">
|
||||
<button id="solve-start" class="solve-btn">Solve</button>
|
||||
<button id="solve-stop" class="solve-btn" hidden>Stop</button>
|
||||
<span id="solve-status"></span>
|
||||
</div>
|
||||
<div id="solve-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="right-panel">
|
||||
<div id="doc-container">
|
||||
<img id="doc-image" alt="Document page">
|
||||
<canvas id="overlay-canvas"></canvas>
|
||||
|
||||
+256
-17
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user