Annotate mobile evidence before filing #832
|
|
@ -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**
|
||||
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,
|
||||
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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 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-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[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
|
||||
.issue-evidence-editor-apply { width:100%; }
|
||||
|
|
|
|||
|
|
@ -495,6 +495,8 @@
|
|||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'),
|
||||
redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'),
|
||||
arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'),
|
||||
reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<div class="issue-evidence-editor-panel">
|
||||
<header>
|
||||
<div><h3 id="issue-evidence-editor-heading">Crop & 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>
|
||||
</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>
|
||||
<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="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="reset-issue-evidence-edit" type="button">Reset</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,23 +13,53 @@
|
|||
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) {
|
||||
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 redactions = [];
|
||||
let annotations = [];
|
||||
let history = [];
|
||||
|
||||
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) {
|
||||
remember();
|
||||
const next = boundedRect(value, crop);
|
||||
crop = next;
|
||||
crop = boundedRect(value, crop);
|
||||
redactions = redactions.filter(rectangle =>
|
||||
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
|
||||
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
|
||||
).map(rectangle => boundedRect(rectangle, crop));
|
||||
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
|
||||
return snapshot();
|
||||
}
|
||||
function addRedaction(value) {
|
||||
|
|
@ -37,11 +67,24 @@
|
|||
redactions.push(boundedRect(value, crop));
|
||||
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() {
|
||||
const previous = history.pop();
|
||||
if (previous) {
|
||||
crop = previous.crop;
|
||||
redactions = previous.redactions;
|
||||
annotations = previous.annotations;
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
|
@ -49,12 +92,16 @@
|
|||
remember();
|
||||
crop = { ...full };
|
||||
redactions = [];
|
||||
annotations = [];
|
||||
return 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) {
|
||||
|
|
@ -81,6 +128,37 @@
|
|||
Math.round(rectangle.width * 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;
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +189,12 @@
|
|||
mode = value;
|
||||
options.crop.setAttribute('aria-pressed', value === 'crop' ? '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() {
|
||||
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
|
||||
|
|
@ -162,25 +245,35 @@
|
|||
options.canvas.addEventListener('pointerup', event => {
|
||||
if (!model || !start) return;
|
||||
const end = point(event);
|
||||
const dragStart = start;
|
||||
const rectangle = {
|
||||
x:Math.min(start.x, end.x), y:Math.min(start.y, end.y),
|
||||
width:Math.abs(end.x - start.x), height:Math.abs(end.y - start.y),
|
||||
x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
|
||||
width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
|
||||
};
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
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();
|
||||
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.crop.addEventListener('click', () => setMode('crop'));
|
||||
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.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.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
|
||||
options.apply.addEventListener('click', async () => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const canvas={{width:0,height:0,getContext:()=>({{
|
|||
assert output["snapshot"] == {
|
||||
"crop": {"x": 40, "y": 30, "width": 240, "height": 180},
|
||||
"redactions": [{"x": 70, "y": 60, "width": 80, "height": 40}],
|
||||
"annotations": [],
|
||||
}
|
||||
assert output["width"] == 240
|
||||
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]
|
||||
|
||||
|
||||
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():
|
||||
html = INDEX.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 'aria-labelledby="issue-evidence-editor-heading"' 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 "issue-evidence-editor.js" in worker
|
||||
assert ".issue-evidence-editor" in css
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user