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