(function(root, factory) { const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview; const api = factory(review); if (typeof module === 'object' && module.exports) module.exports = api; else root.issueAttachment = api; })(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview) { '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 normalizeNote(value) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240); } function escapeMarkdown(value) { return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1'); } const optimizeImage = issueEvidenceReview.optimizeImage; 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, note:'', 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 move(fromIndex, toIndex) { const from = Number(fromIndex); const to = Number(toIndex); if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || from >= selected.length || to < 0 || to >= selected.length || from === to) return state(); const [item] = selected.splice(from, 1); selected.splice(to, 0, item); return state(); } function setNote(index, value) { const position = Number(index); if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state(); selected[position].note = normalizeNote(value); selected[position].serialized = null; return state(); } function note(index) { const position = Number(index); return Number.isInteger(position) && selected[position] ? selected[position].note : ''; } 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 } : {}) }); const item = selected[selected.length - 1]; item.note = normalizeNote(value?.note); item.serialized = { ...(blob ? { filename, contentType, blob } : { filename, contentType, data }), ...(item.note ? {note:item.note} : {}), }; } 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, ...(item.note ? {note:item.note} : {}), }; 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.'); } } if (evidence.note) { markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' + evidence.confirmed.markdown); } else markdown.push(evidence.confirmed.markdown); } const evidence = markdown.join('\n\n'); return text ? text + '\n\n' + evidence : evidence; } return { select, restore, remove, move, setNote, note, 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; const reviewEnabled = Boolean(options.tray && options.earlier && options.later); let previewUrl = ''; let selectionSequence = 0; const review = reviewEnabled ? issueEvidenceReview.create({ ...options, controller }) : null; function setBusy(value) { options.input.disabled = Boolean(value); options.remove.disabled = Boolean(value); review?.setBusy(value); } function clearPreview() { selectionSequence += 1; if (previewUrl) options.revokeObjectURL(previewUrl); previewUrl = ''; review?.clear(); 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 (!reviewEnabled && 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]; if (reviewEnabled) { review.render(values, values.length - 1, optimized); } else { 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(); options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.'; return Promise.resolve(); } const removedIndex = reviewEnabled ? review.activeIndex() : count - 1; controller.remove(removedIndex); selectionSequence += 1; review?.afterRemoval(removedIndex, count); return controller.serialize().then(value => { const values = Array.isArray(value) ? value : [value]; if (reviewEnabled) { review.render(values, review.activeIndex()); } else { 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 || 'Latest screenshot removed. Your text is unchanged.'; }); }); function restorePreview(value) { clearPreview(); const restored = restoreSelection(value); const values = Array.isArray(value) ? value : [value]; if (reviewEnabled) { review.render(values, values.length - 1); return restored; } 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 }; });