CI / test (pull_request) Successful in 6m29s
The picker accepts one MP4 or WebM, the thread lightbox zooms photos, and Discord gets the video as a file on a follow-up message so photo embeds still render.
393 lines
12 KiB
JavaScript
393 lines
12 KiB
JavaScript
(() => {
|
|
const formSelector = "form[data-submit-once]";
|
|
const pickerSelector = "[data-image-picker]";
|
|
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
const allowedVideoTypes = new Set(["video/mp4", "video/webm"]);
|
|
const maxImageBytes = 5 * 1024 * 1024;
|
|
const maxVideoBytes = 25 * 1024 * 1024;
|
|
const pickerStates = new WeakMap();
|
|
|
|
function progressIndicator() {
|
|
return document.getElementById("submit-progress");
|
|
}
|
|
|
|
function resetForm(form) {
|
|
form.removeAttribute("aria-busy");
|
|
delete form.dataset.submitting;
|
|
|
|
const button = form.querySelector("[data-submit-button]");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-disabled");
|
|
if (button.dataset.idleLabel) {
|
|
button.textContent = button.dataset.idleLabel;
|
|
delete button.dataset.idleLabel;
|
|
}
|
|
}
|
|
|
|
function existingImageCount(picker) {
|
|
return picker.querySelectorAll("[data-existing-image]:not([hidden])").length;
|
|
}
|
|
|
|
function existingVideoCount(picker) {
|
|
return picker.querySelectorAll("[data-existing-video]:not([hidden])").length;
|
|
}
|
|
|
|
function updateImageCount(picker, state) {
|
|
const photos = existingImageCount(picker) + state.entries.length;
|
|
const videos = existingVideoCount(picker) + (state.video ? 1 : 0);
|
|
const status = picker.querySelector("[data-image-count]");
|
|
if (!status) {
|
|
return;
|
|
}
|
|
status.textContent = videos
|
|
? `${photos} of ${state.max} · 1 video`
|
|
: `${photos} of ${state.max}`;
|
|
}
|
|
|
|
function showImageError(picker, message) {
|
|
const error = picker.querySelector("[data-image-error]");
|
|
if (!error) {
|
|
return;
|
|
}
|
|
error.textContent = message;
|
|
error.hidden = !message;
|
|
}
|
|
|
|
function imageFileAllowed(file) {
|
|
if (allowedImageTypes.has(file.type)) {
|
|
return true;
|
|
}
|
|
if (file.type) {
|
|
return false;
|
|
}
|
|
return /\.(jpe?g|png|webp)$/i.test(file.name);
|
|
}
|
|
|
|
function videoFileAllowed(file) {
|
|
if (allowedVideoTypes.has(file.type)) {
|
|
return true;
|
|
}
|
|
if (file.type) {
|
|
return false;
|
|
}
|
|
return /\.(mp4|webm)$/i.test(file.name);
|
|
}
|
|
|
|
function sameImageFile(left, right) {
|
|
return left.name === right.name &&
|
|
left.size === right.size &&
|
|
left.lastModified === right.lastModified;
|
|
}
|
|
|
|
function syncImageInput(state) {
|
|
const transfer = new DataTransfer();
|
|
if (state.video) {
|
|
transfer.items.add(state.video.file);
|
|
}
|
|
state.entries.forEach((entry) => transfer.items.add(entry.file));
|
|
state.input.files = transfer.files;
|
|
}
|
|
|
|
function removeNewImage(picker, state, entry) {
|
|
const index = state.entries.indexOf(entry);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
state.entries.splice(index, 1);
|
|
URL.revokeObjectURL(entry.previewURL);
|
|
entry.card.remove();
|
|
syncImageInput(state);
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function removeNewVideo(picker, state) {
|
|
if (!state.video) {
|
|
return;
|
|
}
|
|
URL.revokeObjectURL(state.video.previewURL);
|
|
state.video.card.remove();
|
|
state.video = null;
|
|
syncImageInput(state);
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function addNewImage(picker, state, file) {
|
|
const fragment = state.template.content.cloneNode(true);
|
|
const card = fragment.querySelector("[data-new-image]");
|
|
const preview = fragment.querySelector("[data-image-preview]");
|
|
const name = fragment.querySelector("[data-image-name]");
|
|
const previewURL = URL.createObjectURL(file);
|
|
preview.src = previewURL;
|
|
if (name) {
|
|
name.textContent = file.name;
|
|
}
|
|
const entry = { file, card, previewURL };
|
|
const removeButton = card.querySelector("[data-remove-image]");
|
|
removeButton.setAttribute("aria-label", `Remove selected image: ${file.name}`);
|
|
removeButton.addEventListener("click", () => {
|
|
removeNewImage(picker, state, entry);
|
|
});
|
|
state.list.appendChild(fragment);
|
|
state.entries.push(entry);
|
|
}
|
|
|
|
function addNewVideo(picker, state, file) {
|
|
const fragment = state.videoTemplate.content.cloneNode(true);
|
|
const card = fragment.querySelector("[data-new-video]");
|
|
const preview = fragment.querySelector("[data-video-preview]");
|
|
const name = fragment.querySelector("[data-image-name]");
|
|
const previewURL = URL.createObjectURL(file);
|
|
preview.src = previewURL;
|
|
preview.setAttribute("aria-label", file.name);
|
|
if (name) {
|
|
name.textContent = file.name;
|
|
}
|
|
const entry = { file, card, previewURL };
|
|
const removeButton = card.querySelector("[data-remove-image]");
|
|
removeButton.setAttribute("aria-label", `Remove selected video: ${file.name}`);
|
|
removeButton.addEventListener("click", () => {
|
|
removeNewVideo(picker, state);
|
|
});
|
|
state.videoSlot.appendChild(fragment);
|
|
state.video = entry;
|
|
}
|
|
|
|
function addImageFiles(picker, state, files) {
|
|
showImageError(picker, "");
|
|
const uniqueFiles = files.filter((file) =>
|
|
!state.entries.some((entry) => sameImageFile(entry.file, file)) &&
|
|
!(state.video && sameImageFile(state.video.file, file))
|
|
);
|
|
const videos = uniqueFiles.filter(videoFileAllowed);
|
|
const images = uniqueFiles.filter(imageFileAllowed);
|
|
if (videos.length + images.length !== uniqueFiles.length) {
|
|
showImageError(picker, "Use JPEG, PNG, or WebP photos and one MP4 or WebM video.");
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
const availablePhotos = state.max - existingImageCount(picker) - state.entries.length;
|
|
if (images.length > availablePhotos) {
|
|
showImageError(
|
|
picker,
|
|
availablePhotos > 0
|
|
? `You can add ${availablePhotos} more ${availablePhotos === 1 ? "image" : "images"}.`
|
|
: "You already have 4 images selected."
|
|
);
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
const availableVideos = state.maxVideos - existingVideoCount(picker) - (state.video ? 1 : 0);
|
|
if (videos.length > availableVideos) {
|
|
showImageError(
|
|
picker,
|
|
availableVideos > 0 ? "You can attach one video." : "You already have a video selected."
|
|
);
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
for (const file of images) {
|
|
if (file.size > maxImageBytes) {
|
|
showImageError(picker, `${file.name} is larger than 5 MB.`);
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
}
|
|
for (const file of videos) {
|
|
if (file.size > maxVideoBytes) {
|
|
showImageError(picker, `${file.name} is larger than 25 MB.`);
|
|
syncImageInput(state);
|
|
return;
|
|
}
|
|
}
|
|
videos.forEach((file) => addNewVideo(picker, state, file));
|
|
images.forEach((file) => addNewImage(picker, state, file));
|
|
syncImageInput(state);
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function resetImagePicker(picker) {
|
|
const state = pickerStates.get(picker);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
state.entries.forEach((entry) => {
|
|
URL.revokeObjectURL(entry.previewURL);
|
|
entry.card.remove();
|
|
});
|
|
state.entries = [];
|
|
if (state.video) {
|
|
URL.revokeObjectURL(state.video.previewURL);
|
|
state.video.card.remove();
|
|
state.video = null;
|
|
}
|
|
state.input.value = "";
|
|
picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => {
|
|
card.hidden = false;
|
|
card.querySelectorAll("input").forEach((input) => {
|
|
input.disabled = false;
|
|
});
|
|
});
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
}
|
|
|
|
function initializeImagePicker(picker) {
|
|
if (
|
|
pickerStates.has(picker) ||
|
|
typeof DataTransfer === "undefined" ||
|
|
typeof URL.createObjectURL !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
const input = picker.querySelector("[data-image-input]");
|
|
const dropzone = picker.querySelector("[data-image-dropzone]");
|
|
const list = picker.querySelector("[data-image-list]");
|
|
const template = picker.querySelector("[data-image-template]");
|
|
const videoSlot = picker.querySelector("[data-video-slot]");
|
|
const videoTemplate = picker.querySelector("[data-video-template]");
|
|
if (!input || !dropzone || !list || !template || !videoSlot || !videoTemplate) {
|
|
return;
|
|
}
|
|
const state = {
|
|
input,
|
|
list,
|
|
template,
|
|
videoSlot,
|
|
videoTemplate,
|
|
entries: [],
|
|
video: null,
|
|
max: Number.parseInt(picker.dataset.maxImages, 10) || 4,
|
|
maxVideos: Number.parseInt(picker.dataset.maxVideos, 10) || 1,
|
|
};
|
|
pickerStates.set(picker, state);
|
|
updateImageCount(picker, state);
|
|
|
|
input.addEventListener("change", () => {
|
|
addImageFiles(picker, state, Array.from(input.files));
|
|
});
|
|
picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => {
|
|
card.querySelector("[data-remove-image]").addEventListener("click", () => {
|
|
card.hidden = true;
|
|
card.querySelectorAll("input").forEach((existingInput) => {
|
|
existingInput.disabled = true;
|
|
});
|
|
showImageError(picker, "");
|
|
updateImageCount(picker, state);
|
|
});
|
|
});
|
|
["dragenter", "dragover"].forEach((eventName) => {
|
|
dropzone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
dropzone.classList.add("is-dragging");
|
|
});
|
|
});
|
|
["dragleave", "drop"].forEach((eventName) => {
|
|
dropzone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
dropzone.classList.remove("is-dragging");
|
|
});
|
|
});
|
|
dropzone.addEventListener("drop", (event) => {
|
|
addImageFiles(picker, state, Array.from(event.dataTransfer.files));
|
|
});
|
|
picker.closest("form")?.addEventListener("reset", () => {
|
|
window.setTimeout(() => resetImagePicker(picker), 0);
|
|
});
|
|
}
|
|
|
|
document.addEventListener("submit", (event) => {
|
|
const form = event.target.closest(formSelector);
|
|
if (!form) {
|
|
return;
|
|
}
|
|
if (form.dataset.submitting === "true") {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
form.dataset.submitting = "true";
|
|
form.setAttribute("aria-busy", "true");
|
|
|
|
const button = event.submitter || form.querySelector("[data-submit-button]");
|
|
if (button) {
|
|
button.dataset.idleLabel = button.textContent;
|
|
button.textContent = form.dataset.submittingLabel || "Posting…";
|
|
button.disabled = true;
|
|
button.setAttribute("aria-disabled", "true");
|
|
}
|
|
|
|
const progress = progressIndicator();
|
|
if (progress) {
|
|
progress.hidden = false;
|
|
}
|
|
});
|
|
|
|
window.addEventListener("pageshow", () => {
|
|
document.querySelectorAll(formSelector).forEach(resetForm);
|
|
document.querySelectorAll(pickerSelector).forEach(resetImagePicker);
|
|
if (zoomDialog?.open) {
|
|
zoomDialog.close();
|
|
}
|
|
const progress = progressIndicator();
|
|
if (progress) {
|
|
progress.hidden = true;
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll(pickerSelector).forEach(initializeImagePicker);
|
|
|
|
const zoomDialog = document.querySelector("[data-image-zoom-dialog]");
|
|
const zoomImage = zoomDialog?.querySelector("[data-image-zoom-img]");
|
|
const zoomCaption = zoomDialog?.querySelector("[data-image-zoom-caption]");
|
|
let zoomTrigger = null;
|
|
|
|
function openImageZoom(link) {
|
|
if (!zoomDialog || !zoomImage || typeof zoomDialog.showModal !== "function") {
|
|
return false;
|
|
}
|
|
const photo = link.querySelector("img");
|
|
if (!photo) {
|
|
return false;
|
|
}
|
|
zoomTrigger = link;
|
|
zoomImage.src = link.href;
|
|
zoomImage.alt = photo.alt || "";
|
|
if (zoomCaption) {
|
|
zoomCaption.textContent = photo.alt || "";
|
|
}
|
|
if (!zoomDialog.open) {
|
|
zoomDialog.showModal();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const link = event.target.closest("[data-image-zoom]");
|
|
if (!link) {
|
|
return;
|
|
}
|
|
if (openImageZoom(link)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
zoomDialog?.addEventListener("click", (event) => {
|
|
if (event.target === zoomDialog) {
|
|
zoomDialog.close();
|
|
}
|
|
});
|
|
|
|
zoomDialog?.addEventListener("close", () => {
|
|
if (zoomImage) {
|
|
zoomImage.removeAttribute("src");
|
|
}
|
|
zoomTrigger?.focus();
|
|
zoomTrigger = null;
|
|
});
|
|
})();
|