187 lines
7.9 KiB
JavaScript
187 lines
7.9 KiB
JavaScript
(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);
|
|
if (scale === 1 && file.size <= MAX_BYTES) return file;
|
|
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 };
|
|
});
|