feat: annotate mobile evidence before filing (Closes #831)
All checks were successful
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 14:57:17 +00:00
parent f44fa6e579
commit 6ba823abd2
6 changed files with 171 additions and 16 deletions

View File

@ -32,7 +32,7 @@ bundle when the operator later chooses a repository. A scrollable thumbnail tray
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected** review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
change the durable evidence order without changing the issue title or note. The active screenshot's change the durable evidence order without changing the issue title or note. The active screenshot's
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery, optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The source Draft remains and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The selected screenshot can be cropped, privacy-redacted, highlighted, and marked with touch-drawn arrows before a flattened derivative replaces it; undo, reset, and cancel keep editing reversible without changing its note or bundle position. The source Draft remains
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob. available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background

View File

@ -641,7 +641,7 @@ textarea { resize: vertical; min-height: 120px; }
.issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; } .issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; }
.issue-evidence-editor-panel h3 { margin:0 0 4px; } .issue-evidence-editor-panel h3 { margin:0 0 4px; }
.issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; } .issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; }
.issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; } .issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-editor button { min-width:44px; min-height:44px; } .issue-evidence-editor button { min-width:44px; min-height:44px; }
.issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; } .issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
.issue-evidence-editor-apply { width:100%; } .issue-evidence-editor-apply { width:100%; }

View File

@ -495,6 +495,8 @@
exportCanvas: qs('#issue-evidence-editor-export'), exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), crop: qs('#crop-issue-evidence'),
redact: qs('#redact-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'),
arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), undo: qs('#undo-issue-evidence-edit'),
reset: qs('#reset-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), cancel: qs('#cancel-issue-evidence-edit'),

View File

@ -830,14 +830,16 @@
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden> <section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
<div class="issue-evidence-editor-panel"> <div class="issue-evidence-editor-panel">
<header> <header>
<div><h3 id="issue-evidence-editor-heading">Crop &amp; redact screenshot</h3><span class="small">Edits stay on this device until you file.</span></div> <div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
<button id="cancel-issue-evidence-edit" type="button">Cancel</button> <button id="cancel-issue-evidence-edit" type="button">Cancel</button>
</header> </header>
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop or hide an area."></canvas> <canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
<canvas id="issue-evidence-editor-export" hidden></canvas> <canvas id="issue-evidence-editor-export" hidden></canvas>
<div class="issue-evidence-editor-tools" role="toolbar" aria-label="Screenshot editing tools"> <div class="issue-evidence-editor-tools" role="toolbar" aria-label="Screenshot editing tools">
<button id="crop-issue-evidence" type="button" aria-pressed="false">Crop</button> <button id="crop-issue-evidence" type="button" aria-pressed="false">Crop</button>
<button id="redact-issue-evidence" type="button" aria-pressed="true">Redact</button> <button id="redact-issue-evidence" type="button" aria-pressed="true">Redact</button>
<button id="highlight-issue-evidence" type="button" aria-pressed="false">Highlight</button>
<button id="arrow-issue-evidence" type="button" aria-pressed="false">Arrow</button>
<button id="undo-issue-evidence-edit" type="button">Undo</button> <button id="undo-issue-evidence-edit" type="button">Undo</button>
<button id="reset-issue-evidence-edit" type="button">Reset</button> <button id="reset-issue-evidence-edit" type="button">Reset</button>
</div> </div>

View File

@ -13,23 +13,53 @@
return { x, y, width, height }; return { x, y, width, height };
} }
function boundedPoint(value, bounds) {
return {
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, Math.round(Number(value.x) || 0))),
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, Math.round(Number(value.y) || 0))),
};
}
function createModel(size) { function createModel(size) {
const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) }; const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
let crop = { ...full }; let crop = { ...full };
let redactions = []; let redactions = [];
let annotations = [];
let history = []; let history = [];
function remember() { function remember() {
history.push({ crop:{...crop}, redactions:redactions.map(value => ({...value})) }); history.push({
crop:{...crop}, redactions:redactions.map(value => ({...value})),
annotations:annotations.map(value => ({...value})),
});
}
function intersects(annotation, bounds) {
const left = annotation.type === 'arrow' ? Math.min(annotation.startX, annotation.endX) : annotation.x;
const top = annotation.type === 'arrow' ? Math.min(annotation.startY, annotation.endY) : annotation.y;
const right = annotation.type === 'arrow' ? Math.max(annotation.startX, annotation.endX) : annotation.x + annotation.width;
const bottom = annotation.type === 'arrow' ? Math.max(annotation.startY, annotation.endY) : annotation.y + annotation.height;
return left < bounds.x + bounds.width && top < bounds.y + bounds.height && right > bounds.x && bottom > bounds.y;
}
function clampAnnotation(annotation) {
if (annotation.type === 'highlight') {
const x = Math.max(crop.x, annotation.x);
const y = Math.max(crop.y, annotation.y);
const right = Math.min(crop.x + crop.width, annotation.x + annotation.width);
const bottom = Math.min(crop.y + crop.height, annotation.y + annotation.height);
return { type:'highlight', x, y, width:Math.max(1, right - x), height:Math.max(1, bottom - y) };
}
const start = boundedPoint({x:annotation.startX, y:annotation.startY}, crop);
const end = boundedPoint({x:annotation.endX, y:annotation.endY}, crop);
return { type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y };
} }
function setCrop(value) { function setCrop(value) {
remember(); remember();
const next = boundedRect(value, crop); crop = boundedRect(value, crop);
crop = next;
redactions = redactions.filter(rectangle => redactions = redactions.filter(rectangle =>
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height && rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
).map(rectangle => boundedRect(rectangle, crop)); ).map(rectangle => boundedRect(rectangle, crop));
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
return snapshot(); return snapshot();
} }
function addRedaction(value) { function addRedaction(value) {
@ -37,11 +67,24 @@
redactions.push(boundedRect(value, crop)); redactions.push(boundedRect(value, crop));
return snapshot(); return snapshot();
} }
function addHighlight(value) {
remember();
annotations.push({ type:'highlight', ...boundedRect(value, crop) });
return snapshot();
}
function addArrow(value) {
remember();
const start = boundedPoint({x:value.startX, y:value.startY}, crop);
const end = boundedPoint({x:value.endX, y:value.endY}, crop);
annotations.push({ type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y });
return snapshot();
}
function undo() { function undo() {
const previous = history.pop(); const previous = history.pop();
if (previous) { if (previous) {
crop = previous.crop; crop = previous.crop;
redactions = previous.redactions; redactions = previous.redactions;
annotations = previous.annotations;
} }
return snapshot(); return snapshot();
} }
@ -49,12 +92,16 @@
remember(); remember();
crop = { ...full }; crop = { ...full };
redactions = []; redactions = [];
annotations = [];
return snapshot(); return snapshot();
} }
function snapshot() { function snapshot() {
return { crop:{...crop}, redactions:redactions.map(value => ({...value})) }; return {
crop:{...crop}, redactions:redactions.map(value => ({...value})),
annotations:annotations.map(value => ({...value})),
};
} }
return { addRedaction, reset, setCrop, snapshot, undo }; return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
} }
function namedBlob(blob, name) { function namedBlob(blob, name) {
@ -81,6 +128,37 @@
Math.round(rectangle.width * scale), Math.round(rectangle.width * scale),
Math.round(rectangle.height * scale), Math.round(rectangle.height * scale),
)); ));
state.annotations.forEach(annotation => {
if (annotation.type === 'highlight') {
context.fillStyle = 'rgba(250, 204, 21, 0.38)';
context.fillRect(
Math.round((annotation.x - state.crop.x) * scale),
Math.round((annotation.y - state.crop.y) * scale),
Math.round(annotation.width * scale),
Math.round(annotation.height * scale),
);
return;
}
const startX = (annotation.startX - state.crop.x) * scale;
const startY = (annotation.startY - state.crop.y) * scale;
const endX = (annotation.endX - state.crop.x) * scale;
const endY = (annotation.endY - state.crop.y) * scale;
const angle = Math.atan2(endY - startY, endX - startX);
const head = Math.max(10, Math.min(24, 16 * scale));
context.save();
context.strokeStyle = '#facc15';
context.lineWidth = Math.max(4, 6 * scale);
context.lineCap = 'round';
context.lineJoin = 'round';
context.beginPath();
context.moveTo(Math.round(startX), Math.round(startY));
context.lineTo(Math.round(endX), Math.round(endY));
context.lineTo(endX - head * Math.cos(angle - Math.PI / 6), endY - head * Math.sin(angle - Math.PI / 6));
context.moveTo(Math.round(endX), Math.round(endY));
context.lineTo(endX - head * Math.cos(angle + Math.PI / 6), endY - head * Math.sin(angle + Math.PI / 6));
context.stroke();
context.restore();
});
return state; return state;
} }
@ -111,7 +189,12 @@
mode = value; mode = value;
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false'); options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false'); options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
status(value === 'crop' ? 'Drag around the part to keep.' : 'Drag over private information to hide it.'); options.highlight.setAttribute('aria-pressed', value === 'highlight' ? 'true' : 'false');
options.arrow.setAttribute('aria-pressed', value === 'arrow' ? 'true' : 'false');
status({
crop:'Drag around the part to keep.', redact:'Drag over private information to hide it.',
highlight:'Drag around the detail to highlight.', arrow:'Drag toward the detail you want to point out.',
}[value]);
} }
function render() { function render() {
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600}); if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
@ -162,25 +245,35 @@
options.canvas.addEventListener('pointerup', event => { options.canvas.addEventListener('pointerup', event => {
if (!model || !start) return; if (!model || !start) return;
const end = point(event); const end = point(event);
const dragStart = start;
const rectangle = { const rectangle = {
x:Math.min(start.x, end.x), y:Math.min(start.y, end.y), x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
width:Math.abs(end.x - start.x), height:Math.abs(end.y - start.y), width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
}; };
start = null; start = null;
if (rectangle.width < 3 || rectangle.height < 3) { const tooSmall = mode === 'arrow' ? Math.hypot(end.x - dragStart.x, end.y - dragStart.y) < 8 :
rectangle.width < 3 || rectangle.height < 3;
if (tooSmall) {
status('Drag a larger area on the screenshot.'); status('Drag a larger area on the screenshot.');
return; return;
} }
if (mode === 'crop') model.setCrop(rectangle); if (mode === 'crop') model.setCrop(rectangle);
else model.addRedaction(rectangle); else if (mode === 'redact') model.addRedaction(rectangle);
else if (mode === 'highlight') model.addHighlight(rectangle);
else model.addArrow({startX:dragStart.x, startY:dragStart.y, endX:end.x, endY:end.y});
render(); render();
status(mode === 'crop' ? 'Crop applied. Undo or reset if needed.' : 'Private area hidden with an opaque redaction.'); status({
crop:'Crop applied. Undo or reset if needed.', redact:'Private area hidden with an opaque redaction.',
highlight:'Highlight added.', arrow:'Arrow added.',
}[mode]);
}); });
options.edit.addEventListener('click', open); options.edit.addEventListener('click', open);
options.crop.addEventListener('click', () => setMode('crop')); options.crop.addEventListener('click', () => setMode('crop'));
options.redact.addEventListener('click', () => setMode('redact')); options.redact.addEventListener('click', () => setMode('redact'));
options.highlight.addEventListener('click', () => setMode('highlight'));
options.arrow.addEventListener('click', () => setMode('arrow'));
options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } }); options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop and redactions reset.'); } }); options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop, redactions, and annotations reset.'); } });
options.cancel.addEventListener('click', close); options.cancel.addEventListener('click', close);
options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } }); options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
options.apply.addEventListener('click', async () => { options.apply.addEventListener('click', async () => {

View File

@ -41,6 +41,7 @@ const canvas={{width:0,height:0,getContext:()=>({{
assert output["snapshot"] == { assert output["snapshot"] == {
"crop": {"x": 40, "y": 30, "width": 240, "height": 180}, "crop": {"x": 40, "y": 30, "width": 240, "height": 180},
"redactions": [{"x": 70, "y": 60, "width": 80, "height": 40}], "redactions": [{"x": 70, "y": 60, "width": 80, "height": 40}],
"annotations": [],
} }
assert output["width"] == 240 assert output["width"] == 240
assert output["height"] == 180 assert output["height"] == 180
@ -77,6 +78,60 @@ controller.replace(1,image('private-edited.png'));
assert [item["uploaded"] for item in output["state"]] == [False, False, False] assert [item["uploaded"] for item in output["state"]] == [False, False, False]
def test_editor_adds_undoes_and_flattens_highlight_and_arrow_annotations():
script = f"""
const editor = require({json.dumps(str(EDITOR))});
const model = editor.createModel({{width:400,height:300}});
model.setCrop({{x:40,y:30,width:240,height:180}});
model.addHighlight({{x:60,y:50,width:100,height:60}});
model.addArrow({{startX:80,startY:150,endX:220,endY:80}});
const beforeUndo=model.snapshot();
model.undo();
const afterUndo=model.snapshot();
model.addArrow({{startX:80,startY:150,endX:220,endY:80}});
const calls=[];
const context={{
drawImage:()=>calls.push(['draw']), fillRect:(...args)=>calls.push(['fill',...args]),
save:()=>calls.push(['save']), restore:()=>calls.push(['restore']),
beginPath:()=>calls.push(['begin']), moveTo:(...args)=>calls.push(['move',...args]),
lineTo:(...args)=>calls.push(['line',...args]), stroke:()=>calls.push(['stroke']),
set fillStyle(value){{calls.push(['fillStyle',value]);}},
set strokeStyle(value){{calls.push(['strokeStyle',value]);}},
set lineWidth(value){{calls.push(['lineWidth',value]);}},
set lineCap(value){{calls.push(['lineCap',value]);}},
set lineJoin(value){{calls.push(['lineJoin',value]);}},
}};
const canvas={{width:0,height:0,getContext:()=>context}};
editor.paint({{source:{{}},model,canvas}});
process.stdout.write(JSON.stringify({{beforeUndo,afterUndo,calls}}));
"""
output = run_node(script)
assert [item["type"] for item in output["beforeUndo"]["annotations"]] == ["highlight", "arrow"]
assert [item["type"] for item in output["afterUndo"]["annotations"]] == ["highlight"]
assert ["fillStyle", "rgba(250, 204, 21, 0.38)"] in output["calls"]
assert ["fill", 20, 20, 100, 60] in output["calls"]
assert ["strokeStyle", "#facc15"] in output["calls"]
assert ["move", 40, 120] in output["calls"]
assert ["line", 180, 50] in output["calls"]
assert output["calls"].count(["stroke"]) == 1
def test_crop_clips_annotations_to_the_retained_image_area():
script = f"""
const editor = require({json.dumps(str(EDITOR))});
const model = editor.createModel({{width:300,height:200}});
model.addHighlight({{x:20,y:30,width:100,height:80}});
model.addArrow({{startX:10,startY:90,endX:120,endY:90}});
model.setCrop({{x:50,y:20,width:100,height:120}});
process.stdout.write(JSON.stringify(model.snapshot()));
"""
output = run_node(script)
assert output["annotations"] == [
{"type": "highlight", "x": 50, "y": 30, "width": 70, "height": 80},
{"type": "arrow", "startX": 50, "startY": 90, "endX": 120, "endY": 90},
]
def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline(): def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
html = INDEX.read_text() html = INDEX.read_text()
css = CSS.read_text() css = CSS.read_text()
@ -87,6 +142,9 @@ def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
assert 'role="dialog"' in html assert 'role="dialog"' in html
assert 'aria-labelledby="issue-evidence-editor-heading"' in html assert 'aria-labelledby="issue-evidence-editor-heading"' in html
assert 'id="issue-evidence-editor-canvas"' in html assert 'id="issue-evidence-editor-canvas"' in html
assert 'id="highlight-issue-evidence"' in html
assert 'id="arrow-issue-evidence"' in html
assert 'aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."' in html
assert 'aria-pressed="true"' in html assert 'aria-pressed="true"' in html
assert "issue-evidence-editor.js" in worker assert "issue-evidence-editor.js" in worker
assert ".issue-evidence-editor" in css assert ".issue-evidence-editor" in css