(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 MAX_FILES = 5; const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.'; 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 maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1; 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 = []; 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.'); } if (selected.length >= maxFiles) { if (maxFiles === 1) selected = []; else throw new Error(MAX_FILES_MESSAGE); } selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()}); return state(); } function select(file) { if (selected.length >= maxFiles && maxFiles > 1) { throw new Error(MAX_FILES_MESSAGE); } 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 = []; } function remove(index) { const position = Number(index); if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state(); selectionGeneration += 1; selected.splice(position, 1); return state(); } function restore(value) { if (Array.isArray(value)) { clear(); if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE); value.forEach(attachment => restoreOne(attachment)); return state(); } clear(); restoreOne(value); return state(); } function restoreOne(value) { const contentType = String(value?.contentType || ''); const filename = String(value?.filename || ''); const blob = value?.blob; const data = String(value?.data || ''); if (!blob && !data) { 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); commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) }); selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data }; } function state() { const values = selected.map(item => ({ name: item.file.name, size: item.file.size, uploaded: Boolean(item.confirmed), })); return values.length > 1 ? values : (values[0] || null); } function serializeItem(item) { if (!item.serialized) item.serialized = { filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file, }; return { ...item.serialized }; } async function serialize() { if (!selected.length) return null; const values = selected.map(serializeItem); return values.length > 1 ? values : values[0]; } async function prepareComment(target, body) { const text = String(body || '').trim(); if (!selected.length) return text; const markdown = []; for (const evidence of selected) { if (!evidence.confirmed) { const attachment = serializeItem(evidence); evidence.confirmed = await upload({ repository: target.repository, number: target.number, filename: attachment.filename, content_type: attachment.contentType, ...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }), operation_id: evidence.operationId, }); if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) { evidence.confirmed = null; throw new Error('The server did not confirm the screenshot upload.'); } } markdown.push(evidence.confirmed.markdown); } const evidence = markdown.join('\n\n'); return text ? text + '\n\n' + evidence : evidence; } return { select, restore, remove, clear, state, serialize, prepareComment }; } function mount(options) { const controller = create({ ...options, maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1), }); const clearSelection = controller.clear; const restoreSelection = controller.restore; 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 files = Array.from(event.target.files || []); const sequence = ++selectionSequence; if (!files.length) return; let first; try { first = controller.select(files[0]); } catch (error) { options.status.textContent = error.message; options.input.value = ''; return; } if (files.length === 1 && (!first || typeof first.then !== 'function')) { showPreview(files[0], false); return; } let optimized = Boolean(first && typeof first.then === 'function'); if (optimized) { options.status.textContent = 'Optimizing screenshot…'; options.input.disabled = true; } const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => { const result = controller.select(file); if (result && typeof result.then === 'function') { optimized = true; options.status.textContent = 'Optimizing screenshot…'; options.input.disabled = true; await result; } }), Promise.resolve(first)); return selectAll.then(() => controller.serialize()).then(value => { if (sequence !== selectionSequence) return; const values = Array.isArray(value) ? value : [value]; const latest = values[values.length - 1]; showPreview(latest.blob, optimized); if (values.length > 1) { options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename; options.status.textContent = values.length + ' screenshots ready to file in this order.'; } }).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', () => { const current = controller.state(); const count = Array.isArray(current) ? current.length : (current ? 1 : 0); if (count <= 1) { clearPreview(); } else { controller.remove(count - 1); selectionSequence += 1; controller.serialize().then(value => { const values = Array.isArray(value) ? value : [value]; const latest = values[values.length - 1]; showPreview(latest.blob, false); options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename; }); } options.status.textContent = options.removedMessage || (count > 1 ? 'Latest screenshot removed. Your text is unchanged.' : 'Screenshot removed. Your comment is unchanged.'); }); function restorePreview(value) { clearPreview(); const restored = restoreSelection(value); const values = Array.isArray(value) ? value : [value]; const latest = values[values.length - 1]; previewUrl = latest.blob ? options.createObjectURL(latest.blob) : 'data:' + latest.contentType + ';base64,' + latest.data; options.image.src = previewUrl; options.meta.textContent = values.length > 1 ? values.length + ' screenshots ready · latest: ' + latest.filename : restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB'; options.preview.hidden = false; options.status.textContent = values.length > 1 ? values.length + ' screenshots ready to file in this order.' : (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 }; });