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.
This commit is contained in:
+123
@@ -854,6 +854,55 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.video-picker-slot:not(:empty) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: #141516;
|
||||
}
|
||||
|
||||
.video-preview[hidden] { display: none; }
|
||||
|
||||
.video-preview-media {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.video-preview-media video,
|
||||
.post-video video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 20rem;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.post-video {
|
||||
margin: 16px 0 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
background: #141516;
|
||||
}
|
||||
|
||||
.post-video video { max-height: 32rem; }
|
||||
|
||||
.post-video figcaption {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.image-preview-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -962,6 +1011,18 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
|
||||
background: #141516;
|
||||
}
|
||||
|
||||
.post-image-zoom {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.post-image-zoom:focus-visible {
|
||||
outline: 2px solid var(--signal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.post-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -976,6 +1037,68 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-zoom {
|
||||
width: min(100vw - 24px, 1100px);
|
||||
max-width: none;
|
||||
height: min(100vh - 24px, 100dvh);
|
||||
max-height: none;
|
||||
margin: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #101113;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.image-zoom::backdrop {
|
||||
background: rgba(10, 11, 12, 0.88);
|
||||
}
|
||||
|
||||
.image-zoom-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.image-zoom-close {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-zoom-close:hover { border-color: var(--zinc); }
|
||||
|
||||
.image-zoom-close:focus-visible {
|
||||
outline: 2px solid var(--signal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.image-zoom img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: calc(100% - 4.5rem);
|
||||
max-height: calc(100dvh - 8rem);
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.image-zoom-caption {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.post-image figcaption {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
+176
-36
@@ -2,7 +2,9 @@
|
||||
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() {
|
||||
@@ -29,12 +31,20 @@
|
||||
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 count = existingImageCount(picker) + state.entries.length;
|
||||
const photos = existingImageCount(picker) + state.entries.length;
|
||||
const videos = existingVideoCount(picker) + (state.video ? 1 : 0);
|
||||
const status = picker.querySelector("[data-image-count]");
|
||||
if (status) {
|
||||
status.textContent = `${count} of ${state.max}`;
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.textContent = videos
|
||||
? `${photos} of ${state.max} · 1 video`
|
||||
: `${photos} of ${state.max}`;
|
||||
}
|
||||
|
||||
function showImageError(picker, message) {
|
||||
@@ -56,6 +66,16 @@
|
||||
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 &&
|
||||
@@ -64,6 +84,9 @@
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -81,54 +104,108 @@
|
||||
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.entries.some((entry) => sameImageFile(entry.file, file)) &&
|
||||
!(state.video && sameImageFile(state.video.file, file))
|
||||
);
|
||||
const available = state.max - existingImageCount(picker) - state.entries.length;
|
||||
if (uniqueFiles.length > available) {
|
||||
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,
|
||||
available > 0
|
||||
? `You can add ${available} more ${available === 1 ? "image" : "images"}.`
|
||||
availablePhotos > 0
|
||||
? `You can add ${availablePhotos} more ${availablePhotos === 1 ? "image" : "images"}.`
|
||||
: "You already have 4 images selected."
|
||||
);
|
||||
syncImageInput(state);
|
||||
return;
|
||||
}
|
||||
for (const file of uniqueFiles) {
|
||||
if (!imageFileAllowed(file)) {
|
||||
showImageError(picker, "Images must be JPEG, PNG, or WebP.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
uniqueFiles.forEach((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;
|
||||
for (const file of videos) {
|
||||
if (file.size > maxVideoBytes) {
|
||||
showImageError(picker, `${file.name} is larger than 25 MB.`);
|
||||
syncImageInput(state);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
videos.forEach((file) => addNewVideo(picker, state, file));
|
||||
images.forEach((file) => addNewImage(picker, state, file));
|
||||
syncImageInput(state);
|
||||
updateImageCount(picker, state);
|
||||
}
|
||||
@@ -143,8 +220,13 @@
|
||||
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]").forEach((card) => {
|
||||
picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => {
|
||||
card.hidden = false;
|
||||
card.querySelectorAll("input").forEach((input) => {
|
||||
input.disabled = false;
|
||||
@@ -166,15 +248,21 @@
|
||||
const dropzone = picker.querySelector("[data-image-dropzone]");
|
||||
const list = picker.querySelector("[data-image-list]");
|
||||
const template = picker.querySelector("[data-image-template]");
|
||||
if (!input || !dropzone || !list || !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);
|
||||
@@ -182,7 +270,7 @@
|
||||
input.addEventListener("change", () => {
|
||||
addImageFiles(picker, state, Array.from(input.files));
|
||||
});
|
||||
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
|
||||
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) => {
|
||||
@@ -242,6 +330,9 @@
|
||||
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;
|
||||
@@ -249,4 +340,53 @@
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user