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,63 @@
@font-face {
font-family: 'geist';
src: url('../fonts/geist/geist-regular.woff2') format('woff2'),
url('../fonts/geist/geist-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'geist';
src: url('../fonts/geist/geist-medium.woff2') format('woff2'),
url('../fonts/geist/geist-medium.woff') format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'geist';
src: url('../fonts/geist/geist-semibold.woff2') format('woff2'),
url('../fonts/geist/geist-semibold.woff') format('woff');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'geist';
src: url('../fonts/geist/geist-bold.woff2') format('woff2'),
url('../fonts/geist/geist-bold.woff') format('woff');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'geistmono';
src: url('../fonts/geistmono/geistmono-regular.woff2') format('woff2'),
url('../fonts/geistmono/geistmono-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'geistmono';
src: url('../fonts/geistmono/geistmono-medium.woff2') format('woff2'),
url('../fonts/geistmono/geistmono-medium.woff') format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'geistmono';
src: url('../fonts/geistmono/geistmono-semibold.woff2') format('woff2'),
url('../fonts/geistmono/geistmono-semibold.woff') format('woff');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'geistmono';
src: url('../fonts/geistmono/geistmono-bold.woff2') format('woff2'),
url('../fonts/geistmono/geistmono-bold.woff') format('woff');
font-weight: 700;
font-style: normal;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
:root {
--text-font: 'geist', sans-serif, arial;
--breakpoint-2xs: 480px;
--breakpoint-xs: 640px;
--breakpoint-sm: 768px;
--breakpoint-md: 1024px;
--breakpoint-lg: 1280px;
--breakpoint-xl: 1536px;
--breakpoint-2xl: 1920px;
--content-width: 100%;
--content-width-gutter: var(--space-lg);
--duration-multiplier: 1; /* Allow disabling or increase animation speed */
--duration-75: calc(75ms * var(--duration-multiplier));
--duration-100: calc(100ms * var(--duration-multiplier));
--duration-150: calc(150ms * var(--duration-multiplier));
--duration-200: calc(200ms * var(--duration-multiplier));
--duration-300: calc(300ms * var(--duration-multiplier));
--duration-400: calc(400ms * var(--duration-multiplier));
--duration-500: calc(500ms * var(--duration-multiplier));
--duration-600: calc(600ms * var(--duration-multiplier));
--duration-700: calc(700ms * var(--duration-multiplier));
--duration-800: calc(800ms * var(--duration-multiplier));
--duration-900: calc(900ms * var(--duration-multiplier));
--duration-1000: calc(1000ms * var(--duration-multiplier));
--duration-1200: calc(1200ms * var(--duration-multiplier));
--duration-1400: calc(1400ms * var(--duration-multiplier));
--duration-2000: calc(2000ms * var(--duration-multiplier));
--duration-hover: var(--duration-200);
/* 12.5px → 12.8px */
--text-xs: clamp(0.7813rem, 0.7747rem + 0.0326vw, 0.8rem);
/* 15px → 16px */
--text-sm: clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem);
/* 18px → 20px */
--text-md: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
/* 21.6px → 25px */
--text-lg: clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem);
/* 25.92px → 31.25px */
--text-xl: clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem);
/* 31.104px → 39.0625px */
--text-2xl: clamp(1.944rem, 1.771rem + 0.8651vw, 2.4414rem);
/* 37.3248px → 48.8281px */
--text-3xl: clamp(2.3328rem, 2.0827rem + 1.2504vw, 3.0518rem);
/* 44.7898px → 61.0352px */
--text-4xl: clamp(2.7994rem, 2.4462rem + 1.7658vw, 3.8147rem);
/* Space 3xs: 5px → 5px */
--space-3xs: clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem);
/* Space 2xs: 9px → 10px */
--space-2xs: clamp(0.5625rem, 0.5408rem + 0.1087vw, 0.625rem);
/* Space xs: 14px → 15px */
--space-xs: clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem);
/* Space sm: 18px → 20px */
--space-sm: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
/* Space md: 27px → 30px */
--space-md: clamp(1.6875rem, 1.6223rem + 0.3261vw, 1.875rem);
/* Space lg: 36px → 40px */
--space-lg: clamp(2.25rem, 2.163rem + 0.4348vw, 2.5rem);
/* Space xl: 54px → 60px */
--space-xl: clamp(3.375rem, 3.2446rem + 0.6522vw, 3.75rem);
/* Space 2xl: 72px → 80px */
--space-2xl: clamp(4.5rem, 4.3261rem + 0.8696vw, 5rem);
/* Space 3xl: 108px → 120px */
--space-3xl: clamp(6.75rem, 6.4891rem + 1.3043vw, 7.5rem);
/* One-up pairs */
/* Space 3xs-2xs: 5px → 10px */
--space-3xs-2xs: clamp(0.3125rem, 0.2038rem + 0.5435vw, 0.625rem);
/* Space 2xs-xs: 9px → 15px */
--space-2xs-xs: clamp(0.5625rem, 0.4321rem + 0.6522vw, 0.9375rem);
/* Space xs-sm: 14px → 20px */
--space-xs-sm: clamp(0.875rem, 0.7446rem + 0.6522vw, 1.25rem);
/* Space s-m: 18px → 30px */
--space-sm-md: clamp(1.125rem, 0.8641rem + 1.3043vw, 1.875rem);
/* Space md-lg: 27px → 40px */
--space-md-lg: clamp(1.6875rem, 1.4049rem + 1.413vw, 2.5rem);
/* Space lg-xl: 36px → 60px */
--space-lg-xl: clamp(2.25rem, 1.7283rem + 2.6087vw, 3.75rem);
/* Space xl-2xl: 54px → 80px */
--space-xl-2xl: clamp(3.375rem, 2.8098rem + 2.8261vw, 5rem);
/* Space 2xl-3xl: 72px → 120px */
--space-2xl-3xl: clamp(4.5rem, 3.4565rem + 5.2174vw, 7.5rem);
/* Rounded borders */
--rounded-sm: 4px;
--rounded-md: 8px;
--rounded-lg: 16px;
--rounded-xl: 32px;
--rounded-full: 9999px;
}
:root {
/* Brand */
--color-primary: #6d2eb8;
--color-primary-variant: #d9bbff;
--color-primary-hover: #8848d6;
--color-accent: #bb86fc;
--color-white: #ffffff;
--color-black: #000000;
/* Surfaces */
--color-surface: #0b0b0b; /* Elevation 1 */
--color-surface-variant: #0b0b0b; /* Elevation 1 */
--color-surface-high: #191919; /* Elevation 2 */
--color-surface-high-hover: var(--color-surface-higher);
--color-surface-higher: #333333; /* Elevation 3 */
--color-surface-higher-hover: #484848; /* Elevation 3 */
/* Glyphs */
--color-on-surface: rgb(221, 221, 221);
--color-on-surface-high: rgb(236, 236, 236);
--color-on-surface-higher: #ffffff;
--color-border: rgba(125, 125, 125, 0.9);
--color-border-hover: rgba(137, 137, 137, 0.9);
--color-border-selected: rgba(161, 161, 161, 0.9);
/* States */
--color-success: #199b1e;
--color-error: #d24340;
--color-warning: #d59600;
--color-info: #1888e4;
/* Shadows */
--shadow-1: rgba(0, 0, 0, 0.1) 0px 20px 25px -5px, rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
--shadow-2: rgba(0, 0, 0, 0.1) 0px 20px 29px -4px, rgba(0, 0, 0, 0.04) 0px 10px 60px -5px;
--shadow-3: rgba(0, 0, 0, 0.09) 0px 2px 1px, rgba(0, 0, 0, 0.09) 0px 4px 8px, rgb(0 0 0 / 3%) 0px 8px 11px, rgb(0 0 0 / 5%) 0px 11px 12px 4px, rgba(0, 0, 0, 0.09) 0px 32px 16px;
/* Misc. */
--filter-invert: 0; /* For non-variable shades applied */
--subpage-shade-x-color: var(--color-surface); /* The shade around the subpage container */
--subpage-title-shade: linear-gradient(275deg, rgb(0 0 0 / 17%) 0%, rgba(0, 0, 0, 0.01) 75%, rgba(0, 0, 0, 0) 100%);
--surface-shade: linear-gradient(
to bottom right,
hsl(0deg 0% 50% / 2%),
hsl(0deg 0% 0% / 39%)
); /* Subpage, dialog, toast container surface */
}
html.theme-light {
/* Brand */
--color-primary: #007f6e;
--color-primary-variant: #015a4e;
--color-primary-hover: #005b4f;
--color-accent: #0bb79f;
/* Surfaces */
--color-surface: #fff;
--color-surface-variant: #e6e6e6;
--color-surface-high: #f5f5f5;
--color-surface-higher: #e3e3e3;
--color-surface-higher-hover: #cfcfcf;
/* Glyphs */
--color-on-surface: #333333;
--color-on-surface-high: #2b2b2b;
--color-on-surface-higher: #121212;
--color-border: rgba(90, 90, 90, 0.9);
--color-border-hover: rgba(121, 121, 121, 0.9);
--color-border-selected: rgba(114, 114, 114, 0.9);
/* States */
--color-success: #388e3c;
--color-error: #d32f2f;
--color-warning: #fbc02d;
--color-info: #0288d1;
/* Misc. */
--filter-invert: 1;
--subpage-shade-x-color: var(--color-surface-variant);
--subpage-title-shade: linear-gradient(275deg, rgb(247 247 247 / 39%) 0%, rgb(255 255 255 / 65%) 75%, rgb(255 255 255) 100%);
--surface-shade: linear-gradient(to bottom right, hsl(0deg 0% 100%), hsl(0deg 0% 93.94%));
}

View File

@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

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);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long