(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 }; });