first commit

This commit is contained in:
2025-05-24 22:25:49 +08:00
commit bcdeef8892
150 changed files with 9734 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
async function preProcessImage(file) {
let preProcessedImage = null;
let preProcessedNewFileType = null;
if (file.type === "image/heic" || file.type === "image/heif" || isHeicExt(file)) {
console.log('Pre-processing HEIC image...')
preProcessedImage = await HeicTo({
blob: file,
type: "image/jpeg",
quality: 0.9,
});
console.log("preProcessedImage: ", preProcessedImage);
preProcessedNewFileType = "image/jpeg";
}
if (file.type === "image/avif") {
console.log('Pre-processing AVIF image...')
setTimeout(() => {
ui.progress.text.innerHTML = `Please wait. AVIF files may take longer to prepare<span class="loading-dots">`;
}, 5000);
preProcessedImage = await imageCompression(file, {
quality: 0.8,
fileType: "image/jpeg",
useWebWorker: true,
preserveExif: false,
libURL: "./browser-image-compression.js",
alwaysKeepResolution: true,
});
preProcessedNewFileType = "image/jpeg";
}
return { preProcessedImage, preProcessedNewFileType };
}
async function createCompressionOptions(onProgress, file) {
const compressMethod = getCheckedValue(ui.inputs.compressMethod);
const dimensionMethod = getCheckedValue(ui.inputs.dimensionMethod);
const maxWeight = parseFloat(ui.inputs.limitWeight.value);
const { selectedFormat } = getFileType(file);
quality = Math.min(Math.max(parseFloat(ui.inputs.quality.value) / 100, 0), 1);
console.log("Input image file size: ", (file.size / 1024 / 1024).toFixed(3), "MB");
let maxWeightMB = ui.inputs.limitWeightUnit.value.toUpperCase() === "KB" ?
ui.inputs.limitWeight.value / 1024 :
ui.inputs.limitWeight.value;
let limitDimensionsValue = undefined;
if (file.type === "image/heif" || file.type === "image/heic" || isHeicExt(file)) {
if (getCheckedValue(ui.inputs.dimensionMethod) === "limit") {
limitDimensionsValue = (ui.inputs.limitDimensions.value > 50) ? ui.inputs.limitDimensions.value : 50;
}
else {
limitDimensionsValue = undefined;
}
}
else {
limitDimensionsValue = dimensionMethod === "limit" ?
await getAdjustedDimensions(file, ui.inputs.limitDimensions.value) :
undefined;
}
const options = {
maxSizeMB: maxWeight && compressMethod === "limitWeight" ? maxWeightMB : (file.size / 1024 / 1024).toFixed(3),
initialQuality: quality && compressMethod === "quality" ? quality : undefined,
maxWidthOrHeight: limitDimensionsValue,
useWebWorker: true,
onProgress,
preserveExif: false,
fileType: selectedFormat || undefined,
libURL: "./browser-image-compression.js",
alwaysKeepResolution: true,
};
if (state.controller) {
options.signal = state.controller.signal;
}
console.log("Settings:", options);
return options;
}
async function compressImageQueue() {
if (!state.compressQueue.length) {
resetCompressionState(true);
return;
}
const file = state.compressQueue[0];
const i = state.compressProcessedCount;
console.log('Input file: ', file);
if (!isFileTypeSupported(file.type, file)) {
console.error(`Unsupported file type: ${file.type}. Skipping "${file.name}".`);
ui.progress.text.innerHTML = `Unsupported file "<div class='progress-file-name'>${file.name}</div>"`;
state.compressQueue.shift();
await compressImageQueue();
return;
}
const options = await createCompressionOptions((p) => onProgress(p, i, file.name), file);
const { preProcessedImage, preProcessedNewFileType } = await preProcessImage(file);
if (preProcessedImage) {
options.fileType = preProcessedNewFileType;
}
imageCompression(preProcessedImage || file, options)
.then((output) => handleCompressionResult(file, output))
.catch((error) => console.error(error.message))
.finally(() => {
state.compressProcessedCount++;
state.compressQueue.shift();
resetCompressionState(state.compressProcessedCount === state.compressQueueTotal);
if (state.compressProcessedCount < state.compressQueueTotal) {
compressImageQueue();
}
});
function onProgress(p, index, fileName) {
const overallProgress = calculateOverallProgress(
state.fileProgressMap,
state.compressQueueTotal
);
const fileNameShort =
fileName.length > 15 ? fileName.slice(0, 12) + "..." : fileName;
state.fileProgressMap[index] = p;
ui.progress.queueCount.textContent = `${
state.compressProcessedCount + 1
} / ${state.compressQueueTotal}`;
ui.progress.text.dataset.progress = overallProgress;
ui.progress.text.innerHTML = `Optimizing "<div class='progress-file-name'>${fileName}</div>"`;
ui.progress.bar.style.width = overallProgress + "%";
console.log(`Optimizing "${fileNameShort}" (${overallProgress}%)`);
if (p === 100 && state.compressProcessedCount === state.compressQueueTotal - 1) {
ui.progress.text.innerHTML = `
<div class="badge badge--success pt-2xs pb-2xs bg:surface">
<div class="badge-text flex items-center gap-3xs">
<svg height="16" stroke-linejoin="round" viewBox="0 0 16 16" width="16" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM11.5303 6.53033L12.0607 6L11 4.93934L10.4697 5.46967L6.5 9.43934L5.53033 8.46967L5 7.93934L3.93934 9L4.46967 9.53033L5.96967 11.0303C6.26256 11.3232 6.73744 11.3232 7.03033 11.0303L11.5303 6.53033Z" fill="currentColor"></path></svg>
<span>Done!</span>
</div>
<div>
`;
}
}
}
function compressImage(event) {
state.controller = new AbortController();
state.compressQueue = Array.from(event.target.files);
state.compressQueueTotal = state.compressQueue.length;
state.compressProcessedCount = 0;
state.fileProgressMap = {};
state.isCompressing = true;
document.body.classList.add("compressing--is-active");
ui.actions.dropZone.classList.add("hidden");
ui.actions.abort.classList.remove("hidden");
ui.progress.container.classList.remove("hidden");
ui.progress.text.innerHTML = `Preparing<span class="loading-dots">`;
compressImageQueue();
}
function handleCompressionResult(file, output) {
const { outputFileExtension, selectedFormat } = getFileType(file);
const outputImageBlob = URL.createObjectURL(output);
const { renamedFileName, isBrowserDefaultFileName } = renameBrowserDefaultFileName(file.name);
const outputFileNameText = updateFileExtension(
isBrowserDefaultFileName ? renamedFileName : file.name,
outputFileExtension,
selectedFormat
);
const inputFileSize = parseFloat((file.size / 1024 / 1024).toFixed(3));
const outputFileSize = parseFloat((output.size / 1024 / 1024).toFixed(3));
const fileSizeSaved = inputFileSize - outputFileSize;
const fileSizeSavedPercentage =
inputFileSize > 0
? Math.abs(((fileSizeSaved / inputFileSize) * 100).toFixed(2))
: "0.000";
const fileSizeSavedTrend =
fileSizeSaved < 0 ? "+" : fileSizeSaved > 0 ? "-" : "";
const fileSizeSavedClass =
fileSizeSaved <= 0 ? "badge--error" : "badge--success";
imageCompression(output, config.thumbnailOptions).then((thumbnailBlob) => {
const thumbnailDataURL = URL.createObjectURL(thumbnailBlob);
getImageDimensions(outputImageBlob, ({ width, height }) => {
const outputHTML = buildOutputItemHTML({
outputImageBlob,
thumbnailDataURL,
outputFileNameText,
outputFileExtension,
width,
height,
fileSize: output.size,
fileSizeSavedTrend,
fileSizeSavedPercentage,
fileSizeSavedClass,
});
const wrapper = document.createElement("div");
wrapper.innerHTML = outputHTML.trim();
ui.output.content.prepend(wrapper.firstChild);
state.outputImageCount++;
ui.output.container.dataset.count = state.outputImageCount;
ui.output.subpageOutput.dataset.count = state.outputImageCount;
ui.output.imageCount.dataset.count = state.outputImageCount;
ui.output.imageCount.textContent = state.outputImageCount;
if (state.compressProcessedCount === 1) {
selectSubpage("output");
}
});
});
}
function calculateOverallProgress(progressMap, totalFiles) {
const sum = Object.values(progressMap).reduce((acc, val) => acc + val, 0);
return Math.round(sum / totalFiles);
}
function resetCompressionState(isAllProcessed, aborted) {
const resetState = () => {
state.compressProcessedCount = 0;
state.compressQueueTotal = 0;
ui.progress.queueCount.textContent = "";
state.compressQueue = [];
state.isCompressing = false;
};
if (aborted) {
resetUI();
resetState();
return;
}
if (isAllProcessed) {
ui.actions.abort.classList.add("hidden");
ui.progress.bar.style.width = "100%";
setTimeout(() => {
// Delay state reset to allow "Done" message to remain
resetUI();
state.isCompressing = false;
}, 1000);
return;
}
if (state.isCompressing && state.compressProcessedCount === 0) {
ui.progress.text.dataset.progress = 0;
ui.progress.text.textContent = "Preparing 0%";
ui.progress.bar.style.width = "0%";
}
}

View File

@@ -0,0 +1,99 @@
async function downloadAllImages() {
const GB = 1024 * 1024 * 1024;
const chunkSize = 1 * GB;
const zipFileName = appendFileNameId("mazanoke-images");
try {
if (state.isDownloadingAll) return;
state.isDownloadingAll = true;
ui.actions.downloadAll.setAttribute("aria-busy", "true");
const compressedImages = document.querySelectorAll(
'a.image-output__item-download-button[href^="blob:"]'
);
const blobs = await Promise.all(
Array.from(compressedImages).map(async (link, index) => {
try {
const response = await fetch(link.href);
if (!response.ok)
throw new Error(`Failed to fetch image ${index + 1}`);
return await response.blob();
} catch (error) {
console.error(`Error downloading image ${index + 1}:`, error);
return null;
}
})
);
const validBlobs = blobs.filter((blob) => blob !== null);
if (validBlobs.length === 0) {
throw new Error("No valid images to download");
}
let currentZip = zip;
let totalSize = 0;
let zipIndex = 1;
for (let i = 0; i < validBlobs.length; i++) {
const fileSize = parseInt(compressedImages[i].dataset.filesize, 10);
if (totalSize + fileSize > chunkSize) {
const zipBlob = await currentZip.generateAsync({ type: "blob" });
await triggerDownload(
zipBlob,
`${zipFileName}-${zipIndex.toString().padStart(3, "0")}.zip`
);
currentZip = new JSZip();
totalSize = 0;
zipIndex++;
}
currentZip.file(compressedImages[i].download, validBlobs[i]);
totalSize += fileSize;
}
if (totalSize > 0) {
const finalName =
zipIndex === 1
? `${zipFileName}.zip`
: `${zipFileName}-${zipIndex.toString().padStart(3, "0")}.zip`;
const zipBlob = await currentZip.generateAsync({ type: "blob" });
await triggerDownload(zipBlob, finalName);
}
}
catch (error) {
console.error("Download all images as zip failed:", error);
}
finally {
ui.actions.downloadAll.setAttribute("aria-busy", "false");
state.isDownloadingAll = false;
}
}
async function triggerDownload(blob, filename) {
return new Promise((resolve) => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
resolve();
}, 100);
});
}
function deleteAllImages() {
ui.output.content.innerHTML = "";
ui.output.container.dataset.count = 0;
ui.output.subpageOutput.dataset.count = 0;
ui.output.imageCount.dataset.count = 0;
ui.output.imageCount.textContent = 0;
state.outputImageCount = 0;
}

View File

@@ -0,0 +1,128 @@
initApp();
function initApp() {
// Initialize the app
initDropZone();
initInputValidation();
initClipboardPaste();
initBackToTop();
setConfigForm();
restoreConfigForm();
}
function initDropZone() {
const dropZone = ui.groups.dropZone;
const fileInput = ui.inputs.file;
const compressingGuard = (handler) => (e) => {
// Prevent adding more to compression queue when isCompressing.
if (state.isCompressing) return;
handler(e);
};
dropZone.addEventListener("click", compressingGuard(() => fileInput.click()));
fileInput.addEventListener("change", compressingGuard((e) => {
if (fileInput.files?.length) {
compressImage(e);
fileInput.value = "";
}
}));
const toggleDragging = (add) => dropZone.classList.toggle("drop-zone--is-dragging", add);
dropZone.addEventListener("dragenter", compressingGuard((e) => {
e.preventDefault();
toggleDragging(true);
}));
dropZone.addEventListener("dragover", compressingGuard((e) => {
e.preventDefault();
toggleDragging(true);
}));
dropZone.addEventListener("dragleave", compressingGuard((e) => {
e.preventDefault();
toggleDragging(false);
}));
dropZone.addEventListener("drop", compressingGuard((e) => {
e.preventDefault();
toggleDragging(false);
if (e.dataTransfer.files?.length) {
fileInput.files = e.dataTransfer.files;
compressImage({ target: fileInput }, true);
fileInput.value = "";
}
}));
}
function initInputValidation() {
ui.inputs.quality.addEventListener("change", () => {
setQuality(ui.inputs.quality.value);
});
ui.inputs.limitDimensions.addEventListener("change", (e) => {
setLimitDimensions(ui.inputs.limitDimensions.value);
});
ui.inputs.limitWeight.addEventListener("change", (e) => {
setWeight(ui.inputs.limitWeight.value, ui.inputs.limitWeightUnit.value);
});
ui.inputs.limitWeightUnit.addEventListener("change", (e) => {
setWeightUnit(e.target.value);
});
}
function initClipboardPaste() {
document.addEventListener("paste", handlePasteImage);
}
function initBackToTop() {
ui.actions.backToTop.addEventListener("click", function () {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
}
function setConfigForm() {
// Default values of form fields, or for restoring local storage values.
setQuality(config.form.quality.value);
setLimitDimensions(config.form.limitDimensions.value);
setWeightUnit(config.form.limitWeightUnit.value);
setWeight(config.form.limitWeight.value, config.form.limitWeightUnit.value);
setCompressMethod(config.form.compressMethod.value);
setDimensionMethod(config.form.dimensionMethod.value);
setConvertMethod(config.form.convertMethod.value);
}
function handlePasteImage(e) {
if (!e.clipboardData || state.isCompressing) return;
const items = e.clipboardData.items;
const files = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
files.push(item.getAsFile());
}
}
if (files.length) {
compressImage({ target: { files } });
}
}
function abort(event) {
// Cancel on-going compression.
event.stopPropagation();
if (!state.controller) return;
resetCompressionState(false, true);
state.controller.abort(new Error("Image compression cancelled"));
}

View File

@@ -0,0 +1,106 @@
window.App = window.App || {};
App.ui = {
dialogs: {
installPWA: document.getElementById("installPWADialog"),
updateToast: document.getElementById("updateToast"),
updateToastRefreshButton: document.getElementById("updateToastRefreshButton"),
},
inputs: {
quality: document.getElementById("quality"),
limitDimensions: document.getElementById("limitDimensions"),
limitWeight: document.getElementById("limitWeight"),
limitWeightUnit: document.getElementById("limitWeightUnit"),
compressMethod: document.querySelectorAll('[name="compressMethod"]'),
dimensionMethod: document.querySelectorAll('[name="dimensionMethod"]'),
formatSelect: document.querySelectorAll('[name="formatSelect"]'),
file: document.getElementById("compress"),
settingsSubpage: document.querySelectorAll('[name="settingsSubpage"]'),
},
labels: {
limitWeightSuffix: document.querySelector('label[for="limitWeight"][data-suffix]'),
},
progress: {
container: document.querySelector(".progress-container"),
queueCount: document.getElementById("compressProgressQueueCount"),
track: document.getElementById("compressProgressTrack"),
bar: document.getElementById("compressProgressBar"),
text: document.getElementById("compressProgressText"),
},
output: {
container: document.getElementById("outputDownloadContainer"),
content: document.getElementById("outputDownloadContent"),
downloadAllBtn: document.getElementById("downloadAllImagesButton"),
subpageOutput: document.getElementById("subpageOutput"),
imageCount: document.getElementById("compressedImageCount"),
outputFileType: 'image/png',
},
actions: {
abort: document.getElementById("compressAbort"),
dropZone: document.getElementById("dropZoneActions"),
backToTop: document.getElementById("backToTop"),
downloadAll: document.getElementById("downloadAllImagesButton"),
},
groups: {
formatMethod: document.getElementById("formatMethodGroup"),
settingsSubpage: document.getElementById("selectSettingsSubpage"),
dropZone: document.getElementById("compressDropZone"),
limitWeight: document.getElementById("limitWeightField"),
quality: document.getElementById("qualityField"),
compressMethod: document.getElementById("compressMethodGroup"),
},
};
App.config = {
form: {
// Default form settings
quality: {value: 80},
limitDimensions: {value: 1200},
limitWeightUnit: {value: "MB"},
limitWeight: {value: 2},
compressMethod: {value: "quality"},
dimensionMethod: {value: "original"},
convertMethod: {value: "default"},
},
thumbnailOptions: {
initialQuality: 0.8,
maxWidthOrHeight: 70,
usecompress: true,
preserveExif: false,
fileType: "image/png",
libURL: "./browser-image-compression.js",
alwaysKeepResolution: true,
},
qualityLimit: {
min: 0,
max: 100,
},
weightLimit: {
min: 0.01,
max: 100,
},
dimensionLimit: {
min: 1,
max: 30000,
},
};
App.state = {
controller: null,
compressQueue: [],
compressQueueTotal: 0,
compressProcessedCount: 0,
compressMethod: null,
isCompressing: false,
isDownloadingAll: false,
inputFileSize: null,
outputImageCount: 0,
fileProgressMap: {},
limitWeightUnit: "MB",
};
const ui = App.ui;
const config = App.config;
const state = App.state;
zip = new JSZip();

View File

@@ -0,0 +1,49 @@
function buildOutputItemHTML({
outputImageBlob,
thumbnailDataURL,
outputFileNameText,
outputFileExtension,
width,
height,
fileSize,
fileSizeSavedTrend,
fileSizeSavedPercentage,
fileSizeSavedClass,
}) {
// Create the output dom for compressed images.
const fileSizeInMB = fileSize / 1024 / 1024;
let fileSizeDisplay;
if (fileSizeInMB < 1) {
fileSizeDisplay = Math.round(fileSizeInMB * 1024) + " KB";
} else {
fileSizeDisplay = fileSizeInMB.toFixed(2) + " MB";
}
return `
<div class="image-output__item file-format--${outputFileExtension}" data-elevation="3">
<img src="${thumbnailDataURL}" class="image-output__item-thumbnail" loading="lazy">
<div class="image-output__item-text">
<div class="image-output__item-filename">
<span class="image-output__item-filename-start">${outputFileNameText.slice(0, -8)}</span>
<span class="image-output__item-filename-end">${outputFileNameText.slice(-8)}</span>
</div>
<div class="image-output__item-dimensions">
<div class="image-output__item-dimensions">${width}x${height}</div>
</div>
</div>
<div class="image-output__item-stats">
<span class="image-output__item-filesize" data-filesize="${fileSize}">${fileSizeDisplay}</span>
<span class="image-output__item-filesize-saved badge ${fileSizeSavedClass}">
<span class="badge-text">${fileSizeSavedTrend}${fileSizeSavedPercentage}%</span>
</span>
<span class="image-output__item-fileformat badge file-format--${outputFileExtension}">${outputFileExtension.toUpperCase()}</span>
</div>
<a class="image-output__item-download-button button-cta button-secondary"
data-filesize="${fileSize}"
href="${outputImageBlob}"
download="${outputFileNameText}">
<svg height="16" stroke-linejoin="round" viewBox="0 0 16 16" width="16" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M8.75 1V1.75V8.68934L10.7197 6.71967L11.25 6.18934L12.3107 7.25L11.7803 7.78033L8.70711 10.8536C8.31658 11.2441 7.68342 11.2441 7.29289 10.8536L4.21967 7.78033L3.68934 7.25L4.75 6.18934L5.28033 6.71967L7.25 8.68934V1.75V1H8.75ZM13.5 9.25V13.5H2.5V9.25V8.5H1V9.25V14C1 14.5523 1.44771 15 2 15H14C14.5523 15 15 14.5523 15 14V9.25V8.5H13.5V9.25Z" fill="currentColor"></path></svg>
<span class="xs:hidden">Download</span>
</a>
</div>
`;
}

View File

@@ -0,0 +1,291 @@
/**
* TODO:
* - Refactor toast to reusable component to show error messages.
* - Allow clear individual items and all items.
*/
let storeConfigDebounceTimer;
function resetUI() {
// Resets the UI primarily around the dropzone area.
ui.actions.abort.classList.add("hidden");
document.body.classList.remove("compressing--is-active");
ui.actions.dropZone.classList.remove("hidden");
ui.progress.container.classList.add("hidden");
ui.progress.text.dataset.progress = 0;
ui.progress.bar.style.width = "0%";
}
function storeConfigForm() {
// Store form fields values to local storage.
const configForm = {
quality: ui.inputs.quality.value,
limitDimensions: ui.inputs.limitDimensions.value,
limitWeightUnit: ui.inputs.limitWeightUnit.value,
limitWeight: ui.inputs.limitWeight.value,
compressMethod: getCheckedValue(ui.inputs.compressMethod),
dimensionMethod: getCheckedValue(ui.inputs.dimensionMethod),
convertMethod: getCheckedValue(ui.inputs.formatSelect),
};
localStorage.setItem("configForm", JSON.stringify(configForm));
}
function storeConfigFormDebounce() {
// Debounce the storage of form fields values to local storage, to prevent excessive.
clearTimeout(storeConfigDebounceTimer);
storeConfigDebounceTimer = setTimeout(() => {
storeConfigForm();
}, 300);
}
function restoreConfigForm() {
// Restore form fields values from local storage.
const configForm = JSON.parse(localStorage.getItem("configForm"));
if (configForm) {
setQuality(configForm.quality);
setLimitDimensions(configForm.limitDimensions);
setWeightUnit(configForm.limitWeightUnit);
setWeight(configForm.limitWeight, configForm.limitWeightUnit);
setCompressMethod(configForm.compressMethod);
setDimensionMethod(configForm.dimensionMethod);
setConvertMethod(configForm.convertMethod);
}
}
function setCompressMethod(value) {
// Form group: Optimization method.
const compressMethod = value;
document.querySelector(
`input[name="compressMethod"][value="${compressMethod}"]`
).checked = true;
document
.querySelectorAll("#compressMethodGroup .button-card-radio")
.forEach((el) => {
el.classList.remove("button-card-radio--is-selected");
});
document
.querySelector(
`#compressMethodGroup input[name="compressMethod"][value="${compressMethod}"]`
)
.closest(".button-card-radio")
.classList.add("button-card-radio--is-selected");
if (compressMethod === "limitWeight") {
ui.groups.limitWeight.classList.remove("hidden");
ui.groups.quality.classList.add("hidden");
}
else {
ui.groups.limitWeight.classList.add("hidden");
ui.groups.quality.classList.remove("hidden");
}
storeConfigFormDebounce();
}
function setDimensionMethod(value) {
// Form group: Dimensions method.
document.querySelector(
`input[name="dimensionMethod"][value="${value}"]`
).checked = true;
document
.querySelectorAll("#dimensionsMethodGroup .button-card-radio")
.forEach((el) => {
el.classList.remove("button-card-radio--is-selected");
});
document
.querySelector(`input[name="dimensionMethod"][value="${value}"]`)
.closest(".button-card-radio")
.classList.add("button-card-radio--is-selected");
const resizeDimensionsField = document.getElementById(
"resizeDimensionsField"
);
if (value === "limit") {
resizeDimensionsField.classList.remove("hidden");
} else {
resizeDimensionsField.classList.add("hidden");
}
storeConfigFormDebounce();
}
function setQuality(value) {
// Form group: Quality.
let quality = Number(value);
const min = config.qualityLimit.min;
const max = config.qualityLimit.max;
if (quality > max) {
quality = max;
setSlider(max, "qualitySlider");
}
if (quality < min || isNaN(quality) || quality === "") {
quality = min;
setSlider(min, "qualitySlider");
}
else {
quality = Math.round(quality);
setSlider(quality, "qualitySlider");
}
ui.inputs.quality.value = quality;
storeConfigFormDebounce();
}
function setSlider(value, sliderId) {
// Form group: Slider.
// Update input field and slider elements
const slider = document.getElementById(sliderId);
const fill = slider.querySelector(".slider-fill");
const thumb = slider.querySelector(".slider-thumb");
let percentage = value;
if (value < 0 || isNaN(value) || value === "") {
percentage = 0;
} else if (value > 100) {
percentage = 100;
}
fill.style.width = percentage + "%";
thumb.style.left = Math.min(percentage, 100) + "%";
}
function startSliderDrag(event, inputId) {
const slider = event.currentTarget;
const input = document.getElementById(inputId);
const setSliderPosition = (e) => {
const rect = slider.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const percentage = Math.min(Math.max((offsetX / rect.width) * 100, 0), 100);
input.value = Math.round(Math.min(percentage, 100));
setSlider(percentage, slider.id);
};
const onMouseMove = (e) => {
setSliderPosition(e);
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
setSliderPosition(event);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
storeConfigFormDebounce();
}
function setLimitDimensions(value) {
// Form group: Limit dimensions.
let selectedDimension = Number(value);
const min = config.dimensionLimit.min;
const max = config.dimensionLimit.max;
if (selectedDimension > max) {
selectedDimension = max;
}
else if (selectedDimension <= 0 || isNaN(selectedDimension) || selectedDimension === "") {
selectedDimension = min;
}
else {
selectedDimension = Math.round(selectedDimension);
}
ui.inputs.limitDimensions.value = selectedDimension;
storeConfigFormDebounce();
}
function setConvertMethod(value) {
// Form group: Convert to format.
ui.inputs.formatSelect.forEach(input => {
input.checked = input.value === value;
});
ui.groups.formatMethod.querySelectorAll(".button-card-radio").forEach(el => {
el.classList.remove("button-card-radio--is-selected");
});
const selectedInput = Array.from(ui.inputs.formatSelect).find(input => input.value === value);
if (selectedInput) {
selectedInput.closest(".button-card-radio").classList.add("button-card-radio--is-selected");
}
storeConfigFormDebounce();
}
function setWeightUnit(value) {
// Form group: Limit weight (unit)
const previousUnit = state.limitWeightUnit.toUpperCase();
if (previousUnit === value) return;
Array.from(ui.inputs.limitWeightUnit.options).forEach(option => {
option.selected = option.value === value;
});
if (previousUnit === "KB") {
const kbToMb = Number(ui.inputs.limitWeight.value / 1000);
if (kbToMb < ui.inputs.limitWeight.value) {
ui.inputs.limitWeight.value = kbToMb;
ui.inputs.limitWeight.step = 0.1;
}
}
else if (previousUnit === "MB") {
const mbToKb = Number(ui.inputs.limitWeight.value * 1000);
if (mbToKb > ui.inputs.limitWeight.value) {
ui.inputs.limitWeight.min = 0;
ui.inputs.limitWeight.step = 50;
ui.inputs.limitWeight.value = mbToKb;
}
}
state.limitWeightUnit = ui.inputs.limitWeightUnit.value.toUpperCase();
ui.labels.limitWeightSuffix.textContent = ui.inputs.limitWeightUnit.value.toUpperCase();
ui.labels.limitWeightSuffix.dataset.suffix = ui.inputs.limitWeightUnit.value.toUpperCase();
storeConfigFormDebounce();
}
function setWeight(weight, unit) {
// Form group: Limit weight
const { value, message } = validateWeight(
weight, unit
);
if (!value) {
}
else if (value && message) {
ui.inputs.limitWeight.value = value;
}
else if (value) {
ui.inputs.limitWeight.value = value;
}
ui.inputs.limitWeight.value = value;
storeConfigFormDebounce();
}
function selectSubpage(value) {
// Switch between "Settings", "Images".
ui.inputs.settingsSubpage.forEach(input => {
input.checked = input.value === value;
});
ui.groups.settingsSubpage.querySelectorAll(".segmented-control").forEach(el => {
el.classList.remove("segmented-control--is-selected");
});
const selectedInput = Array.from(ui.inputs.settingsSubpage).find(input => input.value === value);
if (selectedInput) {
selectedInput.closest(".segmented-control").classList.add("segmented-control--is-selected");
}
document.body.className = document.body.className.replace(/\bsubpage--\S+/g, "");
document.body.classList.add(`subpage--${value}`);
storeConfigFormDebounce();
}

View File

@@ -0,0 +1,206 @@
function isFileTypeSupported(fileType, file) {
// Check for supported file types
if (HeicTo.isHeic(file) && isHeicExt(file)) {
fileType = "image/heic";
ui.outputFileType = "image/heic";
console.log('File type is HEIC: ', fileType)
}
const supportedFileTypes = [
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
"image/avif",
"image/gif",
"image/svg+xml",
"image/jxl",
];
return supportedFileTypes.includes(fileType);
}
function mimeToExtension(mimeType) {
const fileExtensionMap = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "heic",
"image/heic": "heif",
"image/avif": "avif",
"image/gif": "gif",
"image/svg+xml": "svg",
"image/jxl": "jxl",
};
return (
fileExtensionMap[mimeType] || mimeType.replace("image/", "").split("+")[0]
);
}
function defaultConversionMapping(mimeType) {
const conversionMap = {
// Image file types that cannot be compressed to its original file format
// are converted to a relevant counterpart.
"image/heic": "image/png",
"image/heif": "image/png",
"image/avif": "image/png",
"image/gif": "image/png",
"image/svg+xml": "image/png",
"image/jxl": "image/png",
};
console.log('Input mimeType ', mimeType);
console.log('Mapped mimeType ', conversionMap[mimeType]);
return conversionMap[mimeType] || mimeType;
}
function isHeicExt(file) {
// Checks if file name ending with `.heic` or `.heif`.
const fileName = file.name.toLowerCase();
return fileName.endsWith('.heic') || fileName.endsWith('.heif');
}
function isFileExt(file, extension = "") {
// Checks if file name ending with the passed string argument.
const fileName = file.name.toLowerCase();
return fileName.endsWith(`.${extension}`);
}
function getFileType(file) {
let selectedFormat = document.querySelector('input[name="formatSelect"]:checked').value; // User-selected format to convert to, e.g. "image/jpeg".
let inputFileExtension = ""; // User-uploaded image's file extension, e.g. `.jpg`.
let outputFileExtension = ""; // The processed image's file extension, based on `defaultConversionMapping()`.
if (selectedFormat && selectedFormat !== "default") {
// The user-selected format to convert to.
const extension = mimeToExtension(selectedFormat);
inputFileExtension = extension;
outputFileExtension = extension;
} else {
// User has not selected a file format, use the input image's file type.
selectedFormat = file.type ? file.type : "png";
file.type = !file.type && isHeicExt(file) ? "image/heic" : "";
inputFileExtension = mimeToExtension(file.type) || "";
console.log("inputFileExtension: ", inputFileExtension);
outputFileExtension = mimeToExtension(defaultConversionMapping(file.type));
console.log("outputFileExtension: ", outputFileExtension);
}
return {
inputFileExtension,
outputFileExtension,
selectedFormat,
};
}
function updateFileExtension(originalName, fileExtension, selectedFormat) {
const baseName = originalName.replace(/\.[^/.]+$/, "");
const newExtension = selectedFormat
? mimeToExtension(fileExtension)
: fileExtension;
console.log('New image extension: ', newExtension);
return `${baseName}.${newExtension}`;
}
function appendFileNameId(fileName = "image") {
if (typeof fileName !== 'string') return null;
const lastDotIndex = fileName.lastIndexOf('.');
const fileExt = (lastDotIndex === -1 || lastDotIndex === 0) ? '' : fileName.slice(lastDotIndex).toLowerCase();
const baseFileName = (lastDotIndex === -1) ? fileName : fileName.slice(0, lastDotIndex);
const fileId = Math.random().toString(36).substring(2, 6).toUpperCase();
return baseFileName + "-" + fileId + fileExt;
}
function renameBrowserDefaultFileName(fileName) {
// Naive approach to check if an image was pasted from clipboard and received a default name by the browser,
// e.g., `image.png`. This method is potentially browser and language-dependent, if naming conventions vary.
// `HEIF Image.heic` concerns iOS devices, e.g. when drag-and-dropping a subject cut-out.
const defaultNames = [/^image\.\w+$/i, /^heif image\.heic$/i];
if (defaultNames.some(regex => regex.test(fileName))) {
return { renamedFileName: appendFileNameId(fileName), isBrowserDefaultFileName: true };
}
return { renamedFileName: fileName, isBrowserDefaultFileName: false };
}
function validateWeight(value, unit = "MB") {
value = Number(value);
let [min, max] = [config.weightLimit.min, config.weightLimit.max];
min = unit.toUpperCase() === "KB" ? min * 1000 : min;
max = unit.toUpperCase() === "KB" ? max * 1000 : max;
if (typeof value !== 'number' || isNaN(value) || !Number.isFinite(value)) {
const message = "Invalid value, not a number.";
return {value: null, message}
}
else if (value < min) {
const message = `Minimum file size is ${min * 1000}KB or ${max}MB.`;
return {value: min, message}
}
else if (value > max) {
const message = `Max file size is ${max}MB.`;
return {value: max, message}
}
return {value, message: null}
}
function getCheckedValue(nodeList) {
// Find the currently select radio button value.
return [...nodeList].find((el) => el.checked)?.value || null;
}
function getImageDimensions(imageInput, callback) {
const img = new Image();
if (imageInput instanceof Blob) {
img.src = URL.createObjectURL(imageInput);
}
else if (typeof imageInput === "string") {
img.src = imageInput;
}
else {
console.error("Invalid input provided to getImageDimensions.");
callback(null);
return;
}
img.onload = () => callback({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => callback(null);
}
function getAdjustedDimensions(imageBlob, desiredLimitDimensions) {
// Adjusts image dimensions to prevent the short edge from being 0.
// Calculates the minimum long edge based on a 1px short edge while keeping aspect ratio.
return new Promise((resolve) => {
getImageDimensions(imageBlob, ({ width, height }) => {
if (!width || !height) {
resolve(undefined);
return;
}
const shortEdge = Math.min(width, height);
const longEdge = Math.max(width, height);
const shortEdgeMin = 1;
const minAllowedDimension = longEdge * (shortEdgeMin / shortEdge);
const limitDimensionsValue = desiredLimitDimensions > Math.ceil(minAllowedDimension) ? desiredLimitDimensions : Math.ceil(minAllowedDimension);
resolve(limitDimensionsValue);
});
});
}
function debugBlobImageOutput(blob) {
const blobURL = URL.createObjectURL(blob);
const img = document.createElement("img");
img.src = blobURL;
img.style.maxWidth = "100%";
img.style.display = "block";
document.body.prepend(img);
}