(function(root, factory) { const api = factory(); if (typeof module === 'object' && module.exports) module.exports = api; else root.issueEvidenceReview = api; })(typeof self !== 'undefined' ? self : this, function() { 'use strict'; const MAX_BYTES = 2 * 1024 * 1024; const MAX_PIXELS = 12 * 1024 * 1024; const MAX_DIMENSION = 8192; 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; } async function optimizeImage(file, environment = {}) { const decode = environment.createImageBitmap || globalThis.createImageBitmap; const makeCanvas = environment.createCanvas || (() => document.createElement('canvas')); if (typeof decode !== 'function') throw new Error('This browser cannot safely inspect the screenshot. Try cropping it and choose it again.'); let bitmap; try { bitmap = await decode(file); const width = Number(bitmap.width); const height = Number(bitmap.height); if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { throw new Error('decode'); } const pixelScale = Math.min(1, Math.sqrt(MAX_PIXELS / (width * height))); const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height)); const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1; let scale = Math.min(pixelScale, dimensionScale, byteScale); const canvas = makeCanvas(); const context = canvas && canvas.getContext && canvas.getContext('2d'); if (!context) throw new Error('decode'); for (let attempt = 0; attempt < 10; attempt += 1) { canvas.width = Math.max(1, Math.floor(width * scale)); canvas.height = Math.max(1, Math.floor(height * scale)); context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86)); if (!blob) throw new Error('encode'); if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name); scale *= 0.8; } } catch (_error) { throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.'); } finally { if (bitmap && typeof bitmap.close === 'function') bitmap.close(); } throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.'); } function create(options) { const controller = options.controller; const documentRef = options.document || document; let activeIndex = 0; let busy = false; let previewUrl = ''; let thumbnailUrls = []; function revoke(url) { if (url && !url.startsWith('data:')) options.revokeObjectURL(url); } function attachmentUrl(attachment) { return attachment?.blob ? options.createObjectURL(attachment.blob) : 'data:' + attachment.contentType + ';base64,' + attachment.data; } function count() { const value = controller.state(); return Array.isArray(value) ? value.length : (value ? 1 : 0); } function setBusy(value) { busy = Boolean(value); options.earlier.disabled = busy || activeIndex <= 0; options.later.disabled = busy || activeIndex >= count() - 1; if (options.note) options.note.disabled = busy; if (options.edit) options.edit.disabled = busy || !count(); Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; }); } function updateNote(values) { if (!options.note) return; options.note.value = controller.note(activeIndex); options.note.disabled = busy; if (options.noteLabel) { options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' (optional)'; } } function update(values, optimized = false) { if (!values.length) return; activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1)); revoke(previewUrl); previewUrl = attachmentUrl(values[activeIndex]); options.image.src = previewUrl; const current = values[activeIndex]; const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4)); options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' + current.filename + ' · ' + Math.ceil(size / 1024) + ' KB'; options.preview.hidden = false; if (options.edit) options.edit.disabled = busy; Array.from(options.tray.children || []).forEach((button, index) => button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false')); options.earlier.disabled = busy || activeIndex === 0; options.later.disabled = busy || activeIndex === values.length - 1; updateNote(values); options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' : values.length + ' screenshots ready to file in this order.'; } function render(value, index = activeIndex, optimized = false) { const values = (Array.isArray(value) ? value : [value]).filter(Boolean); activeIndex = Math.max(0, Math.min(index, values.length - 1)); thumbnailUrls.forEach(revoke); thumbnailUrls = []; options.tray.replaceChildren(...values.map((item, itemIndex) => { const button = documentRef.createElement('button'); button.type = 'button'; button.className = 'issue-evidence-thumbnail'; button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename); const thumbnail = documentRef.createElement('img'); thumbnail.alt = ''; thumbnail.src = attachmentUrl(item); thumbnailUrls.push(thumbnail.src); button.appendChild(thumbnail); const position = documentRef.createElement('span'); position.textContent = String(itemIndex + 1); button.appendChild(position); button.addEventListener('click', () => { activeIndex = itemIndex; update(values); }); return button; })); options.tray.hidden = !values.length; if (values.length) update(values, optimized); } function clear() { revoke(previewUrl); thumbnailUrls.forEach(revoke); previewUrl = ''; thumbnailUrls = []; activeIndex = 0; options.tray.replaceChildren(); options.tray.hidden = true; options.earlier.disabled = true; options.later.disabled = true; if (options.note) { options.note.value = ''; options.note.disabled = true; } if (options.edit) options.edit.disabled = true; if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)'; } function afterRemoval(removedIndex, previousCount) { activeIndex = Math.min(removedIndex, previousCount - 2); } function move(offset) { const total = count(); const destination = activeIndex + offset; if (destination < 0 || destination >= total) return Promise.resolve(); controller.move(activeIndex, destination); activeIndex = destination; return controller.serialize().then(value => { render(value, activeIndex); options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.'; }); } options.earlier.addEventListener('click', () => move(-1)); options.later.addEventListener('click', () => move(1)); options.note?.addEventListener('input', event => { controller.setNote(activeIndex, event.target.value); }); clear(); return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy }; } return { create, optimizeImage, MAX_PIXELS }; });