mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-11-03 12:10:29 +00:00
feat: frontend associate matching, badges, and priority sorting
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
1 parent
4bfe7c85a7
commit
dfe78a58f2
2 files changed
+313
-75
No files matched your search
+257
-70
@@ -39,11 +39,13 @@ 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");
|
||||
const solveFilter = document.getElementById("solve-filter");
|
||||
const solveFilterPrefix = document.getElementById("solve-filter-prefix");
|
||||
const solveFilterSuffix = document.getElementById("solve-filter-suffix");
|
||||
const solveAccept = document.getElementById("solve-accept");
|
||||
|
||||
// State
|
||||
const state = {
|
||||
@@ -52,7 +54,7 @@ const state = {
|
||||
currentPage: 1,
|
||||
pageData: {}, // page -> {lines: [...]}
|
||||
selectedLine: null, // index into current page's lines
|
||||
lineOverrides: {}, // "page-lineIdx" -> {fontId, fontSize, segments: [{text, offsetX, offsetY}]}
|
||||
lineOverrides: {}, // "page-lineIdx" -> {fontId, fontSize, segments: [{text, offsetX}], gapWidths: [px, ...], gapPreviews: [str|null, ...]}
|
||||
activeSegment: 0, // which segment the d-pad / focus applies to
|
||||
fonts: [], // [{name, id, available}]
|
||||
fontsReady: false,
|
||||
@@ -60,6 +62,7 @@ const state = {
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
associates: null, // {names: {str: [...]}, persons: {str: {...}}}
|
||||
};
|
||||
|
||||
// ── Font loading ──
|
||||
@@ -95,6 +98,17 @@ async function loadFonts() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssociates() {
|
||||
try {
|
||||
const resp = await fetch("/api/associates");
|
||||
state.associates = await resp.json();
|
||||
console.log(`Loaded ${Object.keys(state.associates.names).length} associate lookups`);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load associates data:", e);
|
||||
state.associates = { names: {}, persons: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag and drop ──
|
||||
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
@@ -117,8 +131,9 @@ fileInput.addEventListener("change", () => {
|
||||
async function uploadFile(file) {
|
||||
uploadSection.innerHTML = '<p class="loading">Analyzing document...</p>';
|
||||
|
||||
// Start font loading in parallel with upload
|
||||
// Start font + associates loading in parallel with upload
|
||||
const fontPromise = loadFonts();
|
||||
const assocPromise = loadAssociates();
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
@@ -129,7 +144,7 @@ async function uploadFile(file) {
|
||||
state.pageCount = data.page_count;
|
||||
state.currentPage = 1;
|
||||
|
||||
await fontPromise; // ensure fonts are ready before rendering
|
||||
await Promise.all([fontPromise, assocPromise]); // ensure both are ready before rendering
|
||||
|
||||
uploadSection.hidden = true;
|
||||
viewerSection.hidden = false;
|
||||
@@ -267,10 +282,17 @@ sizeUp.addEventListener("click", () => {
|
||||
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 (state.activeSegment === 0) {
|
||||
// First segment: nudge its position (line alignment)
|
||||
override.segments[0].offsetX += dx;
|
||||
} else {
|
||||
// Any other segment: adjust the gap before it
|
||||
const gapIdx = state.activeSegment - 1;
|
||||
if (override.gapWidths[gapIdx] !== undefined) {
|
||||
override.gapWidths[gapIdx] = Math.max(1, override.gapWidths[gapIdx] + dx);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dy !== 0) {
|
||||
override.offsetY = (override.offsetY || 0) + dy;
|
||||
@@ -282,10 +304,18 @@ function nudge(dx, dy) {
|
||||
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}`;
|
||||
if (state.activeSegment > 0) {
|
||||
// Non-first segment: show gap before it
|
||||
const gapIdx = state.activeSegment - 1;
|
||||
const gw = override?.gapWidths?.[gapIdx] ?? 0;
|
||||
posDisplay.textContent = `gap: ${gw}px, y: ${y}`;
|
||||
} else {
|
||||
// First segment: show offset
|
||||
const seg = override?.segments?.[0];
|
||||
const x = seg?.offsetX ?? 0;
|
||||
posDisplay.textContent = `${x}, ${y}`;
|
||||
}
|
||||
}
|
||||
|
||||
posUp.addEventListener("click", () => nudge(0, -1));
|
||||
@@ -295,8 +325,17 @@ 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;
|
||||
if (state.activeSegment > 0) {
|
||||
// Non-first segment: reset gap before it to default (fontSize * 2)
|
||||
const gapIdx = state.activeSegment - 1;
|
||||
if (override.gapWidths[gapIdx] !== undefined) {
|
||||
override.gapWidths[gapIdx] = override.fontSize * 2;
|
||||
}
|
||||
} else {
|
||||
// First segment: reset offset
|
||||
const seg = override.segments[0];
|
||||
if (seg) seg.offsetX = 0;
|
||||
}
|
||||
override.offsetY = 0;
|
||||
updatePosDisplay();
|
||||
renderOverlay();
|
||||
@@ -323,6 +362,8 @@ function ensureOverride() {
|
||||
fontSize: line.font.size,
|
||||
offsetY: 0,
|
||||
segments: [{ text: line.text, offsetX: 0 }],
|
||||
gapWidths: [], // one entry per gap between segments
|
||||
gapPreviews: [], // one entry per gap: null or preview text
|
||||
};
|
||||
}
|
||||
return state.lineOverrides[key];
|
||||
@@ -344,13 +385,17 @@ function renderSegmentInputs() {
|
||||
// If no override yet, show single input with original text
|
||||
const segs = segments || [{ text: line.text, offsetX: 0, offsetY: 0 }];
|
||||
|
||||
const key = `${state.currentPage}-${state.selectedLine}`;
|
||||
const overrideForSegs = state.lineOverrides[key];
|
||||
|
||||
segs.forEach((seg, i) => {
|
||||
if (i > 0) {
|
||||
// Redaction marker between segments
|
||||
// Redaction marker between segments — show preview text if set
|
||||
const preview = overrideForSegs?.gapPreviews?.[i - 1] ?? null;
|
||||
const marker = document.createElement("span");
|
||||
marker.className = "redaction-marker";
|
||||
marker.textContent = "???";
|
||||
marker.title = "Redaction gap";
|
||||
marker.className = preview ? "redaction-marker preview" : "redaction-marker";
|
||||
marker.textContent = preview || "???";
|
||||
marker.title = preview ? `Preview: ${preview}` : "Redaction gap";
|
||||
segmentInputs.appendChild(marker);
|
||||
}
|
||||
|
||||
@@ -411,6 +456,12 @@ function splitSegmentAtCursor(segIdx, inputEl) {
|
||||
{ text: after, offsetX: 0 },
|
||||
);
|
||||
|
||||
// Initialize gap width and preview for the new gap
|
||||
const override = ensureOverride();
|
||||
const fontSize = override.fontSize;
|
||||
override.gapWidths.splice(segIdx, 0, fontSize * 2);
|
||||
override.gapPreviews.splice(segIdx, 0, null);
|
||||
|
||||
// Focus the new segment after the gap
|
||||
state.activeSegment = segIdx + 1;
|
||||
renderSegmentInputs();
|
||||
@@ -434,7 +485,14 @@ function updateLineListPreview() {
|
||||
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(" [???] ");
|
||||
const key = `${state.currentPage}-${state.selectedLine}`;
|
||||
const ovr = state.lineOverrides[key];
|
||||
const parts = segs.map((s, i) => {
|
||||
if (i === 0) return s.text;
|
||||
const preview = ovr?.gapPreviews?.[i - 1];
|
||||
return (preview ? `[${preview}]` : "[???]") + s.text;
|
||||
});
|
||||
textEl.textContent = parts.join(" ");
|
||||
} else if (segs) {
|
||||
textEl.textContent = segs[0].text;
|
||||
} else {
|
||||
@@ -450,6 +508,8 @@ textReset.addEventListener("click", () => {
|
||||
const pd = state.pageData[state.currentPage];
|
||||
const line = pd.lines[state.selectedLine];
|
||||
override.segments = [{ text: line.text, offsetX: 0 }];
|
||||
override.gapWidths = [];
|
||||
override.gapPreviews = [];
|
||||
override.offsetY = 0;
|
||||
}
|
||||
state.activeSegment = 0;
|
||||
@@ -533,32 +593,46 @@ function renderOverlay() {
|
||||
const textWidth = ctx.measureText(seg.text).width;
|
||||
cursorX = sx + textWidth;
|
||||
|
||||
// If there's a next segment, draw the redaction gap
|
||||
// If there's a next segment, draw the redaction gap or preview
|
||||
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
|
||||
const gapWidth = override?.gapWidths?.[i] ?? fontSize * 2;
|
||||
const preview = override?.gapPreviews?.[i] ?? null;
|
||||
|
||||
// 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);
|
||||
if (preview) {
|
||||
// Draw preview text in the gap area
|
||||
ctx.font = fontStr;
|
||||
ctx.fillStyle = "rgba(255, 200, 0, 0.85)";
|
||||
ctx.fillText(preview, gapStart, line.y + sharedY);
|
||||
const previewWidth = ctx.measureText(preview).width;
|
||||
|
||||
// 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
|
||||
// Subtle highlight behind preview text
|
||||
const pad = fontSize * 0.1;
|
||||
ctx.fillStyle = "rgba(255, 200, 0, 0.12)";
|
||||
ctx.fillRect(gapStart, line.y - pad, gapWidth, line.h + pad * 2);
|
||||
|
||||
// Advance cursor past the gap
|
||||
cursorX = gapStart + gapWidth;
|
||||
// Advance cursor past the original gap width (keeps alignment stable)
|
||||
cursorX = gapStart + gapWidth;
|
||||
} else {
|
||||
// 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 gap width label centered in the gap
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
|
||||
ctx.font = `bold ${Math.min(fontSize * 0.5, 16)}px sans-serif`;
|
||||
const label = `${Math.round(gapWidth)}px`;
|
||||
const labelWidth = ctx.measureText(label).width;
|
||||
ctx.fillText(label, gapStart + (gapWidth - labelWidth) / 2, line.y + line.h * 0.3);
|
||||
ctx.font = fontStr; // restore
|
||||
|
||||
// Advance cursor past the gap
|
||||
cursorX = gapStart + gapWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,7 +714,7 @@ zoomFitBtn.addEventListener("click", zoomToFit);
|
||||
|
||||
// Mouse-wheel zoom toward cursor
|
||||
rightPanel.addEventListener("wheel", (e) => {
|
||||
if (fontControls.contains(e.target) || textEditBar.contains(e.target)) return;
|
||||
if (fontControls.contains(e.target) || textEditBar.contains(e.target) || solvePanel.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
const rect = rightPanel.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
@@ -652,7 +726,7 @@ rightPanel.addEventListener("wheel", (e) => {
|
||||
|
||||
// Double-click to zoom in
|
||||
rightPanel.addEventListener("dblclick", (e) => {
|
||||
if (fontControls.contains(e.target) || textEditBar.contains(e.target)) return;
|
||||
if (fontControls.contains(e.target) || textEditBar.contains(e.target) || solvePanel.contains(e.target)) return;
|
||||
const rect = rightPanel.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
@@ -665,8 +739,8 @@ 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;
|
||||
// Don't start panning when interacting with toolbars or solve panel
|
||||
if (fontControls.contains(e.target) || textEditBar.contains(e.target) || solvePanel.contains(e.target)) return;
|
||||
drag = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
@@ -761,6 +835,7 @@ docImage.addEventListener("load", () => {
|
||||
// ── Solve panel ──
|
||||
|
||||
let activeEventSource = null;
|
||||
let activeSolveGapIdx = null;
|
||||
|
||||
function updateSolveButton() {
|
||||
const segs = getSegments();
|
||||
@@ -769,6 +844,7 @@ function updateSolveButton() {
|
||||
|
||||
solveBtn.addEventListener("click", () => {
|
||||
solvePanel.hidden = false;
|
||||
solveAccept.hidden = true;
|
||||
});
|
||||
|
||||
solveClose.addEventListener("click", () => {
|
||||
@@ -780,6 +856,49 @@ solveTolerance.addEventListener("input", () => {
|
||||
solveTolValue.textContent = solveTolerance.value;
|
||||
});
|
||||
|
||||
// ── Associate matching ──
|
||||
|
||||
const MATCH_TYPE_WEIGHTS = {
|
||||
full: 4,
|
||||
nickname_full: 3,
|
||||
initial_last: 2,
|
||||
last: 2,
|
||||
first: 1,
|
||||
nickname: 1,
|
||||
};
|
||||
|
||||
function matchAssociates(text) {
|
||||
if (!state.associates?.names) return [];
|
||||
const key = text.toLowerCase().trim();
|
||||
const matches = state.associates.names[key];
|
||||
if (!matches) return [];
|
||||
|
||||
return matches.map(m => {
|
||||
const person = state.associates.persons[m.person_id];
|
||||
const weight = MATCH_TYPE_WEIGHTS[m.match_type] || 1;
|
||||
return {
|
||||
personId: m.person_id,
|
||||
personName: person?.name || "Unknown",
|
||||
category: person?.category || "other",
|
||||
tier: m.tier,
|
||||
matchType: m.match_type,
|
||||
score: m.tier * weight,
|
||||
};
|
||||
}).sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
function tierBadgeClass(tier) {
|
||||
if (tier === 1) return "tier-1";
|
||||
if (tier === 2) return "tier-2";
|
||||
return "tier-3";
|
||||
}
|
||||
|
||||
function tierLabel(tier) {
|
||||
if (tier === 1) return "T1";
|
||||
if (tier === 2) return "T2";
|
||||
return "T3";
|
||||
}
|
||||
|
||||
solveStart.addEventListener("click", startSolve);
|
||||
solveStop.addEventListener("click", stopSolve);
|
||||
|
||||
@@ -795,33 +914,28 @@ function startSolve() {
|
||||
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;
|
||||
// Find the gap before the active segment
|
||||
if (state.activeSegment === 0) return; // no gap before first segment
|
||||
const gapIdx = state.activeSegment - 1;
|
||||
|
||||
// Compute gap width using canvas measurement
|
||||
const fontStr = `${fontSize}px "${fontName}"`;
|
||||
ctx.font = fontStr;
|
||||
// Get gap width from the override's gapWidths array
|
||||
const gapWidth = override?.gapWidths?.[gapIdx] ?? fontSize * 2;
|
||||
|
||||
// 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: segment before the gap, segment after the gap
|
||||
const segBefore = segs[gapIdx];
|
||||
const segAfter = segs[gapIdx + 1];
|
||||
const leftCtx = segBefore.text.length > 0 ? segBefore.text[segBefore.text.length - 1] : "";
|
||||
const rightCtx = segAfter.text.length > 0 ? segAfter.text[0] : "";
|
||||
|
||||
// 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] : "";
|
||||
// Track which gap we're solving
|
||||
activeSolveGapIdx = gapIdx;
|
||||
|
||||
// Clear previous results
|
||||
solveResults.innerHTML = "";
|
||||
solveStatus.textContent = "Starting...";
|
||||
solveStart.hidden = true;
|
||||
solveStop.hidden = false;
|
||||
solveAccept.hidden = true;
|
||||
|
||||
const body = {
|
||||
font_id: fontId,
|
||||
@@ -832,10 +946,11 @@ function startSolve() {
|
||||
right_context: rightCtx,
|
||||
hints: {
|
||||
charset: solveCharset.value,
|
||||
min_length: parseInt(solveMinLen.value),
|
||||
max_length: parseInt(solveMaxLen.value),
|
||||
},
|
||||
mode: solveMode.value,
|
||||
word_filter: solveFilter.value,
|
||||
filter_prefix: solveFilterPrefix.value,
|
||||
filter_suffix: solveFilterSuffix.value,
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -885,22 +1000,51 @@ function startSolve() {
|
||||
|
||||
function handleSolveEvent(data, gapIdx) {
|
||||
if (data.status === "match") {
|
||||
const assocMatches = matchAssociates(data.text);
|
||||
const topMatch = assocMatches.length > 0 ? assocMatches[0] : null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "solve-result";
|
||||
if (topMatch) div.dataset.assocScore = topMatch.score;
|
||||
|
||||
let badgeHtml = "";
|
||||
if (topMatch) {
|
||||
const cls = tierBadgeClass(topMatch.tier);
|
||||
const tooltip = `${topMatch.personName} (${topMatch.category}) — ${topMatch.matchType} match`;
|
||||
badgeHtml = `<span class="assoc-badge ${cls}" title="${escapeHtml(tooltip)}">${tierLabel(topMatch.tier)}</span>`;
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
${badgeHtml}
|
||||
<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();
|
||||
const override = ensureOverride();
|
||||
override.gapPreviews[gapIdx] = data.text;
|
||||
renderOverlay();
|
||||
updateLineListPreview();
|
||||
renderSegmentInputs();
|
||||
solveResults.querySelectorAll(".solve-result").forEach(el => el.classList.remove("active"));
|
||||
div.classList.add("active");
|
||||
solveAccept.hidden = false;
|
||||
});
|
||||
solveResults.appendChild(div);
|
||||
|
||||
// Insert sorted: associates first, by score descending
|
||||
if (topMatch) {
|
||||
let inserted = false;
|
||||
for (const existing of solveResults.children) {
|
||||
const existingScore = parseFloat(existing.dataset.assocScore || "0");
|
||||
if (topMatch.score > existingScore) {
|
||||
solveResults.insertBefore(div, existing);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inserted) solveResults.appendChild(div);
|
||||
} else {
|
||||
solveResults.appendChild(div);
|
||||
}
|
||||
|
||||
solveStatus.textContent = `Found ${solveResults.children.length} matches`;
|
||||
} else if (data.status === "running") {
|
||||
solveStatus.textContent = `Checked ${data.checked}, found ${data.found}...`;
|
||||
@@ -922,6 +1066,49 @@ function stopSolve() {
|
||||
solveStatus.textContent = "Stopped.";
|
||||
}
|
||||
|
||||
solveAccept.addEventListener("click", acceptSolution);
|
||||
|
||||
function acceptSolution() {
|
||||
if (state.selectedLine === null || activeSolveGapIdx === null) return;
|
||||
const key = `${state.currentPage}-${state.selectedLine}`;
|
||||
const override = state.lineOverrides[key];
|
||||
if (!override) return;
|
||||
|
||||
const gapIdx = activeSolveGapIdx;
|
||||
const previewText = override.gapPreviews[gapIdx];
|
||||
if (!previewText) return;
|
||||
|
||||
// Merge: segments[gapIdx].text + previewText + segments[gapIdx+1].text → single segment
|
||||
const leftSeg = override.segments[gapIdx];
|
||||
const rightSeg = override.segments[gapIdx + 1];
|
||||
leftSeg.text = leftSeg.text + previewText + rightSeg.text;
|
||||
|
||||
// Remove the right segment
|
||||
override.segments.splice(gapIdx + 1, 1);
|
||||
|
||||
// Remove the gap entry
|
||||
override.gapWidths.splice(gapIdx, 1);
|
||||
override.gapPreviews.splice(gapIdx, 1);
|
||||
|
||||
// Reset active segment to the merged segment
|
||||
state.activeSegment = gapIdx;
|
||||
activeSolveGapIdx = null;
|
||||
|
||||
// Stop any running solve and close panel
|
||||
stopSolve();
|
||||
solvePanel.hidden = true;
|
||||
solveResults.innerHTML = "";
|
||||
solveStatus.textContent = "";
|
||||
solveAccept.hidden = true;
|
||||
|
||||
// Re-render everything
|
||||
renderSegmentInputs();
|
||||
updatePosDisplay();
|
||||
renderOverlay();
|
||||
updateLineListPreview();
|
||||
updateSolveButton();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
|
||||
@@ -335,6 +335,11 @@ main {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.redaction-marker.preview {
|
||||
background: #b8860b;
|
||||
color: #ffe082;
|
||||
}
|
||||
|
||||
.seg-hint {
|
||||
font-size: 0.6rem;
|
||||
color: #666;
|
||||
@@ -354,14 +359,16 @@ main {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
right: 12px;
|
||||
bottom: 52px;
|
||||
width: 300px;
|
||||
max-height: calc(100% - 100px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(20, 20, 40, 0.95);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 30;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -370,6 +377,7 @@ main {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.solve-title {
|
||||
@@ -382,6 +390,7 @@ main {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.solve-controls label {
|
||||
@@ -401,15 +410,13 @@ main {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.solve-num {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.solve-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.solve-btn {
|
||||
@@ -426,6 +433,15 @@ main {
|
||||
background: #00ff8a;
|
||||
}
|
||||
|
||||
.solve-btn.accept {
|
||||
background: #ffc800;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.solve-btn.accept:hover {
|
||||
background: #ffda44;
|
||||
}
|
||||
|
||||
#solve-status {
|
||||
font-size: 0.75rem;
|
||||
color: #aaa;
|
||||
@@ -435,6 +451,9 @@ main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.solve-result {
|
||||
@@ -454,6 +473,11 @@ main {
|
||||
background: rgba(0, 212, 116, 0.2);
|
||||
}
|
||||
|
||||
.solve-result.active {
|
||||
border-color: #ffc800;
|
||||
background: rgba(255, 200, 0, 0.2);
|
||||
}
|
||||
|
||||
.solve-result .result-text {
|
||||
color: #00d474;
|
||||
}
|
||||
@@ -462,3 +486,30 @@ main {
|
||||
color: #888;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Associate badges */
|
||||
.assoc-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: default;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.assoc-badge.tier-1 {
|
||||
background: #d32f2f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.assoc-badge.tier-2 {
|
||||
background: #e65100;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.assoc-badge.tier-3 {
|
||||
background: #f9a825;
|
||||
color: #000;
|
||||
}
|
||||
Reference in new issue
Block a user