260 lines
9.6 KiB
JavaScript
260 lines
9.6 KiB
JavaScript
(function(root, factory) {
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.issueAttachment = api;
|
|
})(typeof self !== 'undefined' ? self : this, function() {
|
|
'use strict';
|
|
|
|
const MAX_BYTES = 2 * 1024 * 1024;
|
|
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
|
|
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 = {}) {
|
|
if (file.size <= MAX_BYTES) return file;
|
|
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
|
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
|
if (typeof decode !== 'function') {
|
|
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
|
}
|
|
|
|
let bitmap;
|
|
try {
|
|
bitmap = await decode(file);
|
|
const canvas = makeCanvas();
|
|
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
|
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
|
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
canvas.height = Math.max(1, Math.round(bitmap.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 multipart(attachment) {
|
|
let blob = attachment?.blob;
|
|
if (!blob && attachment?.data) {
|
|
const binary = atob(String(attachment.data));
|
|
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
|
}
|
|
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
|
const form = new FormData();
|
|
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
|
return form;
|
|
}
|
|
|
|
function create(options) {
|
|
const upload = options.upload;
|
|
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
|
const createOperationId = options.createOperationId || (() => {
|
|
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
|
});
|
|
let selected = null;
|
|
let confirmed = null;
|
|
let serialized = null;
|
|
let operationId = null;
|
|
let selectionGeneration = 0;
|
|
|
|
function commitSelection(file) {
|
|
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
|
file.size <= 0 || file.size > MAX_BYTES) {
|
|
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
|
}
|
|
selected = file;
|
|
confirmed = null;
|
|
serialized = null;
|
|
operationId = createOperationId();
|
|
return state();
|
|
}
|
|
|
|
function select(file) {
|
|
const generation = ++selectionGeneration;
|
|
if (!file || !IMAGE_TYPES.has(file.type)) {
|
|
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
|
}
|
|
if (!Number.isFinite(file.size) || file.size <= 0) {
|
|
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
|
}
|
|
if (file.size > MAX_BYTES) {
|
|
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
|
if (generation !== selectionGeneration) return state();
|
|
return commitSelection(optimized);
|
|
});
|
|
}
|
|
return commitSelection(file);
|
|
}
|
|
|
|
function clear() {
|
|
selectionGeneration += 1;
|
|
selected = null;
|
|
confirmed = null;
|
|
serialized = null;
|
|
operationId = null;
|
|
}
|
|
|
|
function restore(value) {
|
|
const contentType = String(value?.contentType || '');
|
|
const filename = String(value?.filename || '');
|
|
const blob = value?.blob;
|
|
const data = String(value?.data || '');
|
|
if (!blob && !data) {
|
|
clear();
|
|
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
|
}
|
|
const padding = (data.match(/=*$/) || [''])[0].length;
|
|
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
|
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
|
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
|
return state();
|
|
}
|
|
|
|
function state() {
|
|
return selected ? {
|
|
name: selected.name,
|
|
size: selected.size,
|
|
uploaded: Boolean(confirmed),
|
|
} : null;
|
|
}
|
|
|
|
async function serialize() {
|
|
if (!selected) return null;
|
|
if (!serialized) {
|
|
serialized = {
|
|
filename: selected.name,
|
|
contentType: selected.type,
|
|
blob: selected.blob || selected,
|
|
};
|
|
}
|
|
return { ...serialized };
|
|
}
|
|
|
|
async function prepareComment(item, body) {
|
|
const text = String(body || '').trim();
|
|
if (!selected) return text;
|
|
if (!confirmed) {
|
|
const attachment = await serialize();
|
|
confirmed = await upload({
|
|
repository: item.repository,
|
|
number: item.number,
|
|
filename: attachment.filename,
|
|
content_type: attachment.contentType,
|
|
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
|
operation_id: operationId,
|
|
});
|
|
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
|
confirmed = null;
|
|
throw new Error('The server did not confirm the screenshot upload.');
|
|
}
|
|
}
|
|
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
|
}
|
|
|
|
return { select, restore, clear, state, serialize, prepareComment };
|
|
}
|
|
|
|
function mount(options) {
|
|
const controller = create(options);
|
|
const clearSelection = controller.clear;
|
|
let previewUrl = '';
|
|
let selectionSequence = 0;
|
|
|
|
function setBusy(busy) {
|
|
options.input.disabled = Boolean(busy);
|
|
options.remove.disabled = Boolean(busy);
|
|
}
|
|
|
|
function clearPreview() {
|
|
selectionSequence += 1;
|
|
if (previewUrl) options.revokeObjectURL(previewUrl);
|
|
previewUrl = '';
|
|
options.image.src = '';
|
|
options.preview.hidden = true;
|
|
options.input.value = '';
|
|
options.input.disabled = false;
|
|
clearSelection();
|
|
}
|
|
|
|
function showPreview(file, optimized) {
|
|
if (previewUrl) options.revokeObjectURL(previewUrl);
|
|
previewUrl = options.createObjectURL(file);
|
|
options.image.src = previewUrl;
|
|
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
|
options.preview.hidden = false;
|
|
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
|
|
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
|
}
|
|
|
|
options.input.addEventListener('change', event => {
|
|
const file = event.target.files && event.target.files[0];
|
|
const sequence = ++selectionSequence;
|
|
let result;
|
|
try {
|
|
result = controller.select(file);
|
|
} catch (error) {
|
|
options.status.textContent = error.message;
|
|
options.input.value = '';
|
|
return;
|
|
}
|
|
if (!result || typeof result.then !== 'function') {
|
|
showPreview(file, false);
|
|
return;
|
|
}
|
|
options.status.textContent = 'Optimizing screenshot…';
|
|
options.input.disabled = true;
|
|
return result.then(() => controller.serialize()).then(value => {
|
|
if (sequence === selectionSequence) showPreview(value.blob, true);
|
|
}).catch(error => {
|
|
if (sequence === selectionSequence) {
|
|
options.status.textContent = error.message;
|
|
options.input.value = '';
|
|
}
|
|
}).finally(() => {
|
|
if (sequence === selectionSequence) options.input.disabled = false;
|
|
});
|
|
});
|
|
options.remove.addEventListener('click', () => {
|
|
clearPreview();
|
|
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
|
});
|
|
|
|
function restorePreview(value) {
|
|
clearPreview();
|
|
const restored = controller.restore(value);
|
|
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
|
'data:' + value.contentType + ';base64,' + value.data;
|
|
options.image.src = previewUrl;
|
|
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
|
options.preview.hidden = false;
|
|
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
|
return restored;
|
|
}
|
|
|
|
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
|
}
|
|
|
|
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
|
});
|