feat: annotate mobile evidence before filing (Closes #831)
This commit is contained in:
parent
f44fa6e579
commit
6ba823abd2
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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%; }
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user