381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
(function(root, factory) {
|
|
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
|
|
const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
|
|
const api = factory(review, editor);
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.issueAttachment = api;
|
|
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
|
|
'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 replace(index, file) {
|
|
const position = Number(index);
|
|
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
|
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
|
file.size <= 0 || file.size > MAX_BYTES) {
|
|
throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
|
|
}
|
|
selectionGeneration += 1;
|
|
selected[position] = {
|
|
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
|
|
};
|
|
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, replace, 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, edit:options.editor?.edit, controller,
|
|
}) : null;
|
|
|
|
function setBusy(value) {
|
|
options.input.disabled = Boolean(value);
|
|
options.remove.disabled = Boolean(value);
|
|
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
|
|
review?.setBusy(value);
|
|
}
|
|
|
|
function clearPreview() {
|
|
selectionSequence += 1;
|
|
if (previewUrl) options.revokeObjectURL(previewUrl);
|
|
previewUrl = '';
|
|
review?.clear();
|
|
if (options.editor?.edit) options.editor.edit.disabled = true;
|
|
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;
|
|
if (options.editor?.edit) options.editor.edit.disabled = 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;
|
|
}
|
|
|
|
if (review && options.editor && issueEvidenceEditor) {
|
|
issueEvidenceEditor.mount({
|
|
...options.editor,
|
|
controller,
|
|
getActiveIndex:review.activeIndex,
|
|
optimizeImage,
|
|
onApplied: async index => {
|
|
const value = await controller.serialize();
|
|
review.render(value, index);
|
|
options.status.textContent = 'Edited screenshot flattened and ready to file.';
|
|
},
|
|
});
|
|
options.editor.edit.disabled = !controller.state();
|
|
}
|
|
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
|
}
|
|
|
|
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
|
});
|