306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
(function(root, factory) {
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.issueEvidenceEditor = api;
|
|
})(typeof self !== 'undefined' ? self : this, function() {
|
|
'use strict';
|
|
|
|
function boundedRect(value, bounds) {
|
|
const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
|
|
const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
|
|
const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
|
|
const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
|
|
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})),
|
|
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();
|
|
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) {
|
|
remember();
|
|
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();
|
|
}
|
|
function reset() {
|
|
remember();
|
|
crop = { ...full };
|
|
redactions = [];
|
|
annotations = [];
|
|
return snapshot();
|
|
}
|
|
function snapshot() {
|
|
return {
|
|
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
|
annotations:annotations.map(value => ({...value})),
|
|
};
|
|
}
|
|
return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
|
|
}
|
|
|
|
function namedBlob(blob, name) {
|
|
if (typeof File === 'function') return new File([blob], name, {type:blob.type});
|
|
Object.defineProperty(blob, 'name', {value:name, configurable:true});
|
|
return blob;
|
|
}
|
|
|
|
function paint(options) {
|
|
const state = options.model.snapshot();
|
|
const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
|
|
const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
|
|
const width = Math.max(1, Math.round(state.crop.width * scale));
|
|
const height = Math.max(1, Math.round(state.crop.height * scale));
|
|
options.canvas.width = width;
|
|
options.canvas.height = height;
|
|
const context = options.canvas.getContext('2d');
|
|
if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
|
|
context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
|
|
context.fillStyle = '#000000';
|
|
state.redactions.forEach(rectangle => context.fillRect(
|
|
Math.round((rectangle.x - state.crop.x) * scale),
|
|
Math.round((rectangle.y - state.crop.y) * scale),
|
|
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;
|
|
}
|
|
|
|
async function flatten(options) {
|
|
paint(options);
|
|
const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
|
|
const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
|
|
if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
|
|
return namedBlob(blob, options.name || 'edited-screenshot.png');
|
|
}
|
|
|
|
function attachmentBlob(value) {
|
|
if (value.blob) return value.blob;
|
|
const binary = atob(String(value.data || ''));
|
|
return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
|
|
}
|
|
|
|
function mount(options) {
|
|
let source = null;
|
|
let model = null;
|
|
let mode = 'redact';
|
|
let start = null;
|
|
let selectedIndex = -1;
|
|
let previousFocus = null;
|
|
|
|
function status(message) { options.status.textContent = message; }
|
|
function setMode(value) {
|
|
mode = value;
|
|
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
|
|
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
|
|
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});
|
|
}
|
|
function close() {
|
|
options.dialog.hidden = true;
|
|
if (source && typeof source.close === 'function') source.close();
|
|
source = null;
|
|
model = null;
|
|
start = null;
|
|
(previousFocus || options.edit).focus();
|
|
}
|
|
async function open() {
|
|
const value = await options.controller.serialize();
|
|
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
|
selectedIndex = options.getActiveIndex();
|
|
const selected = values[selectedIndex];
|
|
if (!selected) return;
|
|
previousFocus = options.document.activeElement;
|
|
status('Opening screenshot editor…');
|
|
try {
|
|
const decode = options.decode || globalThis.createImageBitmap;
|
|
if (typeof decode !== 'function') throw new Error('decode');
|
|
source = await decode(attachmentBlob(selected));
|
|
model = createModel({width:source.width, height:source.height});
|
|
options.dialog.hidden = false;
|
|
render();
|
|
setMode('redact');
|
|
options.redact.focus();
|
|
} catch (_error) {
|
|
source = null;
|
|
status('This browser cannot edit the screenshot. Your original is unchanged.');
|
|
}
|
|
}
|
|
function point(event) {
|
|
const box = options.canvas.getBoundingClientRect();
|
|
const state = model.snapshot();
|
|
return {
|
|
x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
|
|
y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
|
|
};
|
|
}
|
|
options.canvas.addEventListener('pointerdown', event => {
|
|
if (!model) return;
|
|
start = point(event);
|
|
options.canvas.setPointerCapture?.(event.pointerId);
|
|
});
|
|
options.canvas.addEventListener('pointerup', event => {
|
|
if (!model || !start) return;
|
|
const end = point(event);
|
|
const dragStart = start;
|
|
const rectangle = {
|
|
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;
|
|
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 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({
|
|
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, 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 () => {
|
|
if (!model || !source) return;
|
|
options.apply.disabled = true;
|
|
status('Applying flattened edit…');
|
|
try {
|
|
const current = await options.controller.serialize();
|
|
const values = Array.isArray(current) ? current : [current];
|
|
const selected = values[selectedIndex];
|
|
let derivative = await flatten({
|
|
source, model, canvas:options.exportCanvas, maxDimension:2048,
|
|
name:selected.filename, type:selected.contentType,
|
|
});
|
|
if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
|
|
options.controller.replace(selectedIndex, derivative);
|
|
await options.onApplied(selectedIndex);
|
|
close();
|
|
} catch (error) {
|
|
status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
|
|
} finally {
|
|
options.apply.disabled = false;
|
|
}
|
|
});
|
|
return { close, open };
|
|
}
|
|
|
|
return { createModel, flatten, mount, paint };
|
|
});
|