docs: add text editing & green overlay implementation plan

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-16 15:18:00 -06:00
1 parent 5606157e37
commit 23cbfcc862
1 file changed
+609
@@ -0,0 +1,609 @@
# Text Editing & Green Overlay Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add green text overlay, editable segment inputs, and font/position controls to the redaction-centric UI so users can pixel-align the overlay and correct OCR before solving.
**Architecture:** Three frontend additions (font toolbar, text edit bar, enhanced canvas rendering) plus one backend enhancement (offset guess in analyze response). All state lives in `redactions[id].overrides` which is initialized from the analysis response and modified by UI controls.
**Tech Stack:** Python/FastAPI (backend), vanilla JS/CSS (frontend), Pillow `getlength` (offset computation)
**Design doc:** `docs/plans/2026-02-16-text-editing-overlay-design.md`
---
### Task 1: Backend — add offset guess to analyze response
**Files:**
- Modify: `unredact/app.py:150-219` (analyze_redaction endpoint)
- Modify: `tests/test_app.py` (update test_redaction_analyze)
**Step 1: Update the test**
In `tests/test_app.py`, update `test_redaction_analyze` to also check for offset fields:
Add these assertions after the existing ones:
```python
assert "offset_x" in data
assert "offset_y" in data
assert isinstance(data["offset_x"], (int, float))
assert isinstance(data["offset_y"], (int, float))
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_app.py::test_redaction_analyze -v`
Expected: FAIL — `"offset_x" not in data`
**Step 3: Add offset computation to the endpoint**
In `unredact/app.py`, in the `analyze_redaction` function, after computing `left_text` and before building `chars_json`, add:
```python
# Compute initial offset guess: align end of left text with gap start
pil_font = font_match.to_pil_font()
if left_text:
left_rendered_width = pil_font.getlength(left_text)
offset_x = float(rx - left_rendered_width - best_line.x)
else:
offset_x = 0.0
offset_y = 0.0
```
Then add to the return dict (alongside the existing `"segments"`, `"gap"`, etc.):
```python
"offset_x": round(offset_x, 1),
"offset_y": round(offset_y, 1),
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_app.py::test_redaction_analyze -v`
Expected: PASS
**Step 5: Commit**
```bash
git add unredact/app.py tests/test_app.py
git commit -m "feat: add offset_x/offset_y to analyze response for alignment guess"
```
---
### Task 2: Frontend — add overrides to state model and initialize on analysis
**Files:**
- Modify: `unredact/static/app.js` (analyzeRedaction, openPopover, state)
**Step 1: Initialize overrides when analysis completes**
In `analyzeRedaction()`, after `r.analysis = data;` (currently at line ~313), add:
```javascript
// Initialize overrides from analysis
r.overrides = {
fontId: data.font.id,
fontSize: data.font.size,
offsetX: data.offset_x || 0,
offsetY: data.offset_y || 0,
gapWidth: data.gap.w,
leftText: data.segments.length > 0 ? data.segments[0].text : "",
rightText: data.segments.length > 1 ? data.segments[1].text : "",
};
```
**Step 2: Update startSolve to read from overrides**
In `startSolve()` (line ~721), replace the section that reads from `a.font` and `a.gap`:
```javascript
const o = r.overrides || {};
const fontId = o.fontId || a.font.id;
const fontSize = o.fontSize || a.font.size;
const gapWidth = o.gapWidth || a.gap.w;
const leftText = o.leftText ?? (a.segments.length > 0 ? a.segments[0].text : "");
const rightText = o.rightText ?? (a.segments.length > 1 ? a.segments[1].text : "");
const leftCtx = leftText.length > 0 ? leftText[leftText.length - 1] : "";
const rightCtx = rightText.length > 0 ? rightText[0] : "";
```
**Step 3: Update preview/solution canvas rendering to use overrides**
In `drawRedactionPreview` and `drawRedactionSolution`, replace hardcoded `a.font.name`/`a.font.size` reads with override-aware reads:
```javascript
const o = r.overrides || {};
const fontName = state.fonts.find(f => f.id === (o.fontId || a.font.id))?.name || a.font.name;
const fontSize = o.fontSize || a.font.size;
const fontStr = `${fontSize}px "${fontName}"`;
const gapW = o.gapWidth || a.gap.w;
const renderX = a.line.x + (o.offsetX || 0);
const renderY = a.line.y + (o.offsetY || 0);
```
Use `gapW` instead of `a.gap.w` for box widths, and `renderX`/`renderY` for text positioning.
**Step 4: Commit**
```bash
git add unredact/static/app.js
git commit -m "feat: add overrides state model and wire into solver + canvas"
```
---
### Task 3: Frontend — green overlay rendering for active redaction
**Files:**
- Modify: `unredact/static/app.js` (drawRedactionAnalyzed, renderCanvas)
**Step 1: Rewrite drawRedactionAnalyzed for active redaction**
When the redaction is active and analyzed, draw the full green overlay with segments and gap. Replace the current `drawRedactionAnalyzed`:
```javascript
function drawRedactionAnalyzed(r, isActive) {
if (!isActive) {
// Non-active: just blue box like before
drawRedactionUnanalyzed(r, false);
return;
}
// Active: render green overlay with segments and gap
const a = r.analysis;
const o = r.overrides || {};
const fontName = state.fonts.find(f => f.id === (o.fontId || a.font.id))?.name || a.font.name;
const fontSize = o.fontSize || a.font.size;
const fontStr = `${fontSize}px "${fontName}"`;
const gapW = o.gapWidth || a.gap.w;
const startX = a.line.x + (o.offsetX || 0);
const startY = a.line.y + (o.offsetY || 0);
ctx.font = fontStr;
ctx.textBaseline = "top";
let cursorX = startX;
// Draw left segment in green
const leftText = o.leftText ?? "";
if (leftText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(leftText, cursorX, startY);
cursorX += ctx.measureText(leftText).width;
}
// Draw redaction gap as red box
const pad = fontSize * 0.15;
ctx.fillStyle = "rgba(211, 47, 47, 0.5)";
ctx.fillRect(cursorX, startY - pad, gapW, fontSize + pad * 2);
ctx.strokeStyle = "rgba(211, 47, 47, 0.8)";
ctx.lineWidth = 2;
ctx.strokeRect(cursorX, startY - pad, gapW, fontSize + pad * 2);
// Gap width label
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.font = `bold ${Math.min(fontSize * 0.5, 16)}px sans-serif`;
const label = `${Math.round(gapW)}px`;
const labelW = ctx.measureText(label).width;
ctx.fillText(label, cursorX + (gapW - labelW) / 2, startY + fontSize * 0.3);
ctx.font = fontStr; // restore
cursorX += gapW;
// Draw right segment in green
const rightText = o.rightText ?? "";
if (rightText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(rightText, cursorX, startY);
}
// Draw bounding box for the original line area
ctx.strokeStyle = "rgba(0, 200, 0, 0.3)";
ctx.lineWidth = 1;
ctx.strokeRect(a.line.x, a.line.y, a.line.w, a.line.h);
}
```
Also update `drawRedactionPreview` and `drawRedactionSolution` similarly — when active, draw the green segments around the gap, with preview/solution text inside the gap instead of the red box.
**Step 2: Commit**
```bash
git add unredact/static/app.js
git commit -m "feat: green overlay rendering with segments and gap for active redaction"
```
---
### Task 4: Frontend — floating font toolbar
**Files:**
- Modify: `unredact/static/index.html` (add toolbar HTML)
- Modify: `unredact/static/app.js` (toolbar logic)
- Modify: `unredact/static/style.css` (toolbar styles)
**Step 1: Add HTML**
In `index.html`, add inside `#right-panel` before `#popover`:
```html
<div id="font-toolbar" hidden>
<label>
Font
<select id="font-select"></select>
</label>
<span class="toolbar-sep"></span>
<div class="size-control">
<span>Size</span>
<button id="size-down" class="tb-btn">-</button>
<input type="range" id="size-slider" min="8" max="120" step="1">
<button id="size-up" class="tb-btn">+</button>
<span id="size-value">32</span>px
</div>
<span class="toolbar-sep"></span>
<div class="pos-control">
<span>Pos</span>
<div class="dpad">
<button id="pos-up" class="tb-btn dpad-up">&#9650;</button>
<div class="dpad-mid">
<button id="pos-left" class="tb-btn">&#9664;</button>
<span id="pos-display" class="pos-display">0, 0</span>
<button id="pos-right" class="tb-btn">&#9654;</button>
</div>
<button id="pos-down" class="tb-btn dpad-down">&#9660;</button>
</div>
<button id="pos-reset" class="tb-btn" style="font-size:0.65rem">Reset</button>
</div>
<span class="toolbar-sep"></span>
<div class="gap-control">
<span>Gap</span>
<button id="gap-down" class="tb-btn">-</button>
<span id="gap-value">0</span>px
<button id="gap-up" class="tb-btn">+</button>
</div>
</div>
```
**Step 2: Add JS logic**
In `app.js`, add DOM references and event handlers:
```javascript
const fontToolbar = document.getElementById("font-toolbar");
const fontSelect = document.getElementById("font-select");
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 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 gapDown = document.getElementById("gap-down");
const gapUp = document.getElementById("gap-up");
const gapValue = document.getElementById("gap-value");
```
Populate `fontSelect` in `loadFonts()` (after fonts are loaded):
```javascript
fontSelect.innerHTML = "";
for (const f of state.fonts.filter(f => f.available)) {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
fontSelect.appendChild(opt);
}
```
Show/hide toolbar in `openPopover`/`closePopover`:
```javascript
// In openPopover, after popover.hidden = false:
fontToolbar.hidden = false;
fontSelect.value = r.overrides.fontId;
sizeSlider.value = r.overrides.fontSize;
sizeValue.textContent = r.overrides.fontSize;
gapValue.textContent = Math.round(r.overrides.gapWidth);
updatePosDisplay();
// In closePopover:
fontToolbar.hidden = true;
```
Event handlers for all toolbar controls:
```javascript
fontSelect.addEventListener("change", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.fontId = fontSelect.value;
renderCanvas();
});
sizeSlider.addEventListener("input", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.fontSize = parseInt(sizeSlider.value);
sizeValue.textContent = sizeSlider.value;
renderCanvas();
});
sizeDown.addEventListener("click", () => adjustSize(-1));
sizeUp.addEventListener("click", () => adjustSize(1));
function adjustSize(delta) {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.fontSize = Math.max(8, Math.min(120, r.overrides.fontSize + delta));
sizeSlider.value = r.overrides.fontSize;
sizeValue.textContent = r.overrides.fontSize;
renderCanvas();
}
posUp.addEventListener("click", () => nudge(0, -1));
posDown.addEventListener("click", () => nudge(0, 1));
posLeft.addEventListener("click", () => nudge(-1, 0));
posRight.addEventListener("click", () => nudge(1, 0));
function nudge(dx, dy) {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.offsetX += dx;
r.overrides.offsetY += dy;
updatePosDisplay();
renderCanvas();
}
function updatePosDisplay() {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
posDisplay.textContent = `${Math.round(r.overrides.offsetX)}, ${Math.round(r.overrides.offsetY)}`;
}
posReset.addEventListener("click", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides || !r.analysis) return;
r.overrides.offsetX = r.analysis.offset_x || 0;
r.overrides.offsetY = r.analysis.offset_y || 0;
updatePosDisplay();
renderCanvas();
});
gapDown.addEventListener("click", () => adjustGap(-1));
gapUp.addEventListener("click", () => adjustGap(1));
function adjustGap(delta) {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.gapWidth = Math.max(1, r.overrides.gapWidth + delta);
gapValue.textContent = Math.round(r.overrides.gapWidth);
renderCanvas();
}
```
**Step 3: Add CSS**
```css
#font-toolbar {
position: absolute;
top: 0.5rem;
left: 0.5rem;
right: 0.5rem;
z-index: 10;
display: flex;
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-size: 0.8rem;
color: #aaa;
}
/* ... plus .tb-btn, .toolbar-sep, .size-control, .pos-control, .dpad, .gap-control styles
(same patterns as the old #font-controls styles from the original CSS) */
```
**Step 4: Commit**
```bash
git add unredact/static/app.js unredact/static/index.html unredact/static/style.css
git commit -m "feat: add floating font toolbar with size, position, and gap controls"
```
---
### Task 5: Frontend — bottom text edit bar
**Files:**
- Modify: `unredact/static/index.html` (add bar HTML)
- Modify: `unredact/static/app.js` (text input logic)
- Modify: `unredact/static/style.css` (bar styles)
**Step 1: Add HTML**
In `index.html`, add inside `#right-panel` after `#doc-container`:
```html
<div id="text-edit-bar" hidden>
<input type="text" id="left-text-input" class="seg-input" spellcheck="false" autocomplete="off" placeholder="left text">
<span id="redaction-marker" class="redaction-marker">???</span>
<input type="text" id="right-text-input" class="seg-input" spellcheck="false" autocomplete="off" placeholder="right text">
<button id="text-reset" class="tb-btn" title="Reset to OCR">Reset</button>
</div>
```
**Step 2: Add JS logic**
```javascript
const textEditBar = document.getElementById("text-edit-bar");
const leftTextInput = document.getElementById("left-text-input");
const rightTextInput = document.getElementById("right-text-input");
const redactionMarker = document.getElementById("redaction-marker");
const textReset = document.getElementById("text-reset");
```
Show/hide in `openPopover`/`closePopover`:
```javascript
// In openPopover:
textEditBar.hidden = false;
leftTextInput.value = r.overrides.leftText;
rightTextInput.value = r.overrides.rightText;
redactionMarker.textContent = r.preview || "???";
// In closePopover:
textEditBar.hidden = true;
```
Event handlers:
```javascript
leftTextInput.addEventListener("input", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.leftText = leftTextInput.value;
renderCanvas();
});
rightTextInput.addEventListener("input", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.rightText = rightTextInput.value;
renderCanvas();
});
textReset.addEventListener("click", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides || !r.analysis) return;
const a = r.analysis;
r.overrides.leftText = a.segments.length > 0 ? a.segments[0].text : "";
r.overrides.rightText = a.segments.length > 1 ? a.segments[1].text : "";
leftTextInput.value = r.overrides.leftText;
rightTextInput.value = r.overrides.rightText;
renderCanvas();
});
```
Update `redactionMarker` when preview changes (in `handleSolveEvent` where preview is set):
```javascript
redactionMarker.textContent = data.text;
redactionMarker.className = "redaction-marker preview";
```
**Step 3: Add CSS**
```css
#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;
}
.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;
}
.redaction-marker {
flex-shrink: 0;
padding: 0.25rem 0.4rem;
background: #d32f2f;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 3px;
}
.redaction-marker.preview {
background: #b8860b;
color: #ffe082;
}
```
**Step 4: Commit**
```bash
git add unredact/static/app.js unredact/static/index.html unredact/static/style.css
git commit -m "feat: add bottom text edit bar with editable segment inputs"
```
---
### Task 6: Wire everything together — popover context updates
**Files:**
- Modify: `unredact/static/app.js`
**Step 1: Remove static context display from popover**
The popover currently shows a static `popoverContext` display and `popoverFontInfo`. These are now redundant — the font toolbar and text edit bar serve those purposes. Remove or simplify the `popoverContext` and `popoverFontInfo` population in `openPopover()`. The popover should now just contain the solver controls.
Optionally keep `popoverContext` as a compact read-only summary: `"...left_text [GAP 150px] right_text..."` that updates when text inputs change.
**Step 2: Ensure closePopover hides all three UI elements**
```javascript
function closePopover() {
popover.hidden = true;
fontToolbar.hidden = true;
textEditBar.hidden = true;
stopSolve();
}
```
And when deactivating (clicking away, changing page):
```javascript
// In loadPage, after state.activeRedaction = null:
closePopover();
```
**Step 3: Ensure activateRedaction shows all controls for already-analyzed**
When clicking a redaction that's already analyzed, `openPopover` should also show the toolbar and text bar with the saved overrides.
**Step 4: Commit**
```bash
git add unredact/static/app.js
git commit -m "refactor: wire font toolbar and text bar into popover lifecycle"
```
---
## Execution Order
Tasks 1 is backend (independent).
Tasks 2-6 are frontend (sequential, each builds on previous).
Dependencies:
- Task 2 depends on Task 1 (uses offset_x/offset_y from response)
- Task 3 depends on Task 2 (uses overrides for rendering)
- Task 4 depends on Task 2 (modifies overrides)
- Task 5 depends on Task 2 (modifies overrides)
- Task 6 depends on Tasks 3-5 (wires them together)