mevebu 1.7.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -128
- package/mevebu/app/mevebu.js +111 -23
- package/mevebu/app/tailwind.css +65 -6
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/icons.js +6 -1
- package/mevebu/libs/js/registry.js +19 -0
- package/mevebu/libs/js/tools/image-converter.js +643 -0
- package/package.json +1 -1
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
// Image to image conversion, in batch and entirely on the device. Canvas writes
|
|
2
|
+
// JPEG, PNG, WebP and AVIF; TIFF and BMP come from the two small encoders below,
|
|
3
|
+
// because no browser can write either of them.
|
|
4
|
+
import {
|
|
5
|
+
el, icon, toast, download, bench, stack, panel, selectInput, rangeInput, numberInput,
|
|
6
|
+
button, actions, toolcard, blank, notice,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
const FORMATS = [
|
|
10
|
+
{ id: "jpeg", label: "JPEG", mime: "image/jpeg", ext: "jpg", quality: true },
|
|
11
|
+
{ id: "png", label: "PNG", mime: "image/png", ext: "png", quality: false },
|
|
12
|
+
{ id: "webp", label: "WebP", mime: "image/webp", ext: "webp", quality: true },
|
|
13
|
+
{ id: "avif", label: "AVIF", mime: "image/avif", ext: "avif", quality: true },
|
|
14
|
+
{ id: "tiff", label: "TIFF", mime: "image/tiff", ext: "tiff", quality: false, encode: encodeTiff },
|
|
15
|
+
{ id: "bmp", label: "BMP", mime: "image/bmp", ext: "bmp", quality: false, encode: encodeBmp },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const MAX_SIDE = 16384;
|
|
19
|
+
const MAX_PIXELS = 80_000_000;
|
|
20
|
+
|
|
21
|
+
function writable(mime) {
|
|
22
|
+
const canvas = document.createElement("canvas");
|
|
23
|
+
canvas.width = 1;
|
|
24
|
+
canvas.height = 1;
|
|
25
|
+
return canvas.toDataURL(mime).startsWith(`data:${mime}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const AVAILABLE = FORMATS.filter((format) => format.encode || writable(format.mime));
|
|
29
|
+
|
|
30
|
+
function formatBytes(bytes) {
|
|
31
|
+
if (!bytes) return "0 B";
|
|
32
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
33
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
|
|
34
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function baseName(name) {
|
|
38
|
+
return name.replace(/\.[^.]+$/, "") || "image";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inputFormat(file) {
|
|
42
|
+
const type = (file.type || "").split("/")[1];
|
|
43
|
+
if (type) return type.replace("svg+xml", "SVG").replace("vnd.microsoft.icon", "ICO").toUpperCase();
|
|
44
|
+
const ext = (file.name.split(".").pop() || "").toUpperCase();
|
|
45
|
+
return ext || "Image";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function looksLikeImage(file) {
|
|
49
|
+
if (file.type.startsWith("image/")) return true;
|
|
50
|
+
return /\.(jpe?g|png|webp|avif|gif|bmp|tiff?|svg|ico)$/i.test(file.name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function decode(file) {
|
|
54
|
+
if (typeof createImageBitmap === "function") {
|
|
55
|
+
try {
|
|
56
|
+
return await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
57
|
+
} catch {
|
|
58
|
+
// Some formats, SVG among them, only decode through an <img>.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const url = URL.createObjectURL(file);
|
|
62
|
+
try {
|
|
63
|
+
const image = new Image();
|
|
64
|
+
image.decoding = "sync";
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
image.onload = resolve;
|
|
67
|
+
image.onerror = () => reject(new Error("This file is not an image the browser can read."));
|
|
68
|
+
image.src = url;
|
|
69
|
+
});
|
|
70
|
+
return image;
|
|
71
|
+
} finally {
|
|
72
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function convert(file, format, quality) {
|
|
77
|
+
const source = await decode(file);
|
|
78
|
+
const width = source.width || source.naturalWidth;
|
|
79
|
+
const height = source.height || source.naturalHeight;
|
|
80
|
+
if (!width || !height) throw new Error("This image has no pixel size to read.");
|
|
81
|
+
if (width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_PIXELS) {
|
|
82
|
+
throw new Error("This image is too large to convert here.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const canvas = document.createElement("canvas");
|
|
86
|
+
canvas.width = width;
|
|
87
|
+
canvas.height = height;
|
|
88
|
+
const context = canvas.getContext("2d", { alpha: true });
|
|
89
|
+
// JPEG and BMP have no usable alpha, so a transparent pixel would turn black.
|
|
90
|
+
if (format.id === "jpeg" || format.id === "bmp") {
|
|
91
|
+
context.fillStyle = "#ffffff";
|
|
92
|
+
context.fillRect(0, 0, width, height);
|
|
93
|
+
}
|
|
94
|
+
context.drawImage(source, 0, 0);
|
|
95
|
+
if (source.close) source.close();
|
|
96
|
+
|
|
97
|
+
if (format.encode) {
|
|
98
|
+
return format.encode(context.getImageData(0, 0, width, height));
|
|
99
|
+
}
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
canvas.toBlob(
|
|
102
|
+
(blob) => (blob ? resolve(blob) : reject(new Error("The browser could not encode this image."))),
|
|
103
|
+
format.mime,
|
|
104
|
+
format.quality ? quality / 100 : undefined
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Baseline TIFF, uncompressed RGBA. Every tag is written by hand because the
|
|
110
|
+
// format needs strip offsets and rational resolutions that a canvas never gives.
|
|
111
|
+
function encodeTiff(image) {
|
|
112
|
+
const { data, width, height } = image;
|
|
113
|
+
const samples = 4;
|
|
114
|
+
const pixels = new Uint8Array(width * height * samples);
|
|
115
|
+
for (let i = 0, j = 0; i < data.length; i += 4, j += samples) {
|
|
116
|
+
pixels[j] = data[i];
|
|
117
|
+
pixels[j + 1] = data[i + 1];
|
|
118
|
+
pixels[j + 2] = data[i + 2];
|
|
119
|
+
pixels[j + 3] = data[i + 3];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const entries = [
|
|
123
|
+
[256, 4, 1, width],
|
|
124
|
+
[257, 4, 1, height],
|
|
125
|
+
[258, 3, samples, 0],
|
|
126
|
+
[259, 3, 1, 1],
|
|
127
|
+
[262, 3, 1, 2],
|
|
128
|
+
[273, 4, 1, 0],
|
|
129
|
+
[277, 3, 1, samples],
|
|
130
|
+
[278, 4, 1, height],
|
|
131
|
+
[279, 4, 1, pixels.length],
|
|
132
|
+
[282, 5, 1, 0],
|
|
133
|
+
[283, 5, 1, 0],
|
|
134
|
+
[296, 3, 1, 2],
|
|
135
|
+
[338, 3, 1, 1],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const ifdSize = 2 + entries.length * 12 + 4;
|
|
139
|
+
const bitsAt = 8 + ifdSize;
|
|
140
|
+
const xResAt = bitsAt + samples * 2;
|
|
141
|
+
const yResAt = xResAt + 8;
|
|
142
|
+
const pixelsAt = yResAt + 8;
|
|
143
|
+
const buffer = new ArrayBuffer(pixelsAt + pixels.length);
|
|
144
|
+
const view = new DataView(buffer);
|
|
145
|
+
const bytes = new Uint8Array(buffer);
|
|
146
|
+
|
|
147
|
+
view.setUint16(0, 0x4949, true);
|
|
148
|
+
view.setUint16(2, 42, true);
|
|
149
|
+
view.setUint32(4, 8, true);
|
|
150
|
+
view.setUint16(8, entries.length, true);
|
|
151
|
+
|
|
152
|
+
let at = 10;
|
|
153
|
+
for (const [tag, type, count, value] of entries) {
|
|
154
|
+
view.setUint16(at, tag, true);
|
|
155
|
+
view.setUint16(at + 2, type, true);
|
|
156
|
+
view.setUint32(at + 4, count, true);
|
|
157
|
+
let inline = value;
|
|
158
|
+
if (tag === 258) inline = bitsAt;
|
|
159
|
+
else if (tag === 273) inline = pixelsAt;
|
|
160
|
+
else if (tag === 282) inline = xResAt;
|
|
161
|
+
else if (tag === 283) inline = yResAt;
|
|
162
|
+
if (type === 3 && count === 1) view.setUint16(at + 8, inline, true);
|
|
163
|
+
else view.setUint32(at + 8, inline, true);
|
|
164
|
+
at += 12;
|
|
165
|
+
}
|
|
166
|
+
view.setUint32(at, 0, true);
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < samples; i++) view.setUint16(bitsAt + i * 2, 8, true);
|
|
169
|
+
view.setUint32(xResAt, 72, true);
|
|
170
|
+
view.setUint32(xResAt + 4, 1, true);
|
|
171
|
+
view.setUint32(yResAt, 72, true);
|
|
172
|
+
view.setUint32(yResAt + 4, 1, true);
|
|
173
|
+
bytes.set(pixels, pixelsAt);
|
|
174
|
+
|
|
175
|
+
return new Blob([buffer], { type: "image/tiff" });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 24-bit BMP, bottom up, rows padded to four bytes.
|
|
179
|
+
function encodeBmp(image) {
|
|
180
|
+
const { data, width, height } = image;
|
|
181
|
+
const rowSize = Math.floor((24 * width + 31) / 32) * 4;
|
|
182
|
+
const pixelSize = rowSize * height;
|
|
183
|
+
const buffer = new ArrayBuffer(54 + pixelSize);
|
|
184
|
+
const view = new DataView(buffer);
|
|
185
|
+
const bytes = new Uint8Array(buffer);
|
|
186
|
+
|
|
187
|
+
bytes[0] = 0x42;
|
|
188
|
+
bytes[1] = 0x4d;
|
|
189
|
+
view.setUint32(2, buffer.byteLength, true);
|
|
190
|
+
view.setUint32(10, 54, true);
|
|
191
|
+
view.setUint32(14, 40, true);
|
|
192
|
+
view.setInt32(18, width, true);
|
|
193
|
+
view.setInt32(22, height, true);
|
|
194
|
+
view.setUint16(26, 1, true);
|
|
195
|
+
view.setUint16(28, 24, true);
|
|
196
|
+
view.setUint32(34, pixelSize, true);
|
|
197
|
+
view.setInt32(38, 2835, true);
|
|
198
|
+
view.setInt32(42, 2835, true);
|
|
199
|
+
|
|
200
|
+
for (let y = 0; y < height; y++) {
|
|
201
|
+
const from = y * width * 4;
|
|
202
|
+
const to = 54 + (height - 1 - y) * rowSize;
|
|
203
|
+
for (let x = 0; x < width; x++) {
|
|
204
|
+
const i = from + x * 4;
|
|
205
|
+
const o = to + x * 3;
|
|
206
|
+
bytes[o] = data[i + 2];
|
|
207
|
+
bytes[o + 1] = data[i + 1];
|
|
208
|
+
bytes[o + 2] = data[i];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return new Blob([buffer], { type: "image/bmp" });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const CRC_TABLE = (() => {
|
|
215
|
+
const table = new Uint32Array(256);
|
|
216
|
+
for (let n = 0; n < 256; n++) {
|
|
217
|
+
let c = n;
|
|
218
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
219
|
+
table[n] = c >>> 0;
|
|
220
|
+
}
|
|
221
|
+
return table;
|
|
222
|
+
})();
|
|
223
|
+
|
|
224
|
+
function crc32(bytes) {
|
|
225
|
+
let c = 0xffffffff;
|
|
226
|
+
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
227
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Store only. The images are already compressed, so deflating them again would
|
|
231
|
+
// cost time and save nothing.
|
|
232
|
+
function zip(files) {
|
|
233
|
+
const now = new Date();
|
|
234
|
+
const time = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
|
|
235
|
+
const date = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
|
|
236
|
+
const chunks = [];
|
|
237
|
+
const central = [];
|
|
238
|
+
let offset = 0;
|
|
239
|
+
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
const name = new TextEncoder().encode(file.name);
|
|
242
|
+
const data = new Uint8Array(file.bytes);
|
|
243
|
+
const crc = crc32(data);
|
|
244
|
+
|
|
245
|
+
const local = new Uint8Array(30 + name.length);
|
|
246
|
+
const lv = new DataView(local.buffer);
|
|
247
|
+
lv.setUint32(0, 0x04034b50, true);
|
|
248
|
+
lv.setUint16(4, 20, true);
|
|
249
|
+
lv.setUint16(6, 0x0800, true);
|
|
250
|
+
lv.setUint16(8, 0, true);
|
|
251
|
+
lv.setUint16(10, time, true);
|
|
252
|
+
lv.setUint16(12, date, true);
|
|
253
|
+
lv.setUint32(14, crc, true);
|
|
254
|
+
lv.setUint32(18, data.length, true);
|
|
255
|
+
lv.setUint32(22, data.length, true);
|
|
256
|
+
lv.setUint16(26, name.length, true);
|
|
257
|
+
local.set(name, 30);
|
|
258
|
+
|
|
259
|
+
const entry = new Uint8Array(46 + name.length);
|
|
260
|
+
const cv = new DataView(entry.buffer);
|
|
261
|
+
cv.setUint32(0, 0x02014b50, true);
|
|
262
|
+
cv.setUint16(4, 20, true);
|
|
263
|
+
cv.setUint16(6, 20, true);
|
|
264
|
+
cv.setUint16(8, 0x0800, true);
|
|
265
|
+
cv.setUint16(10, 0, true);
|
|
266
|
+
cv.setUint16(12, time, true);
|
|
267
|
+
cv.setUint16(14, date, true);
|
|
268
|
+
cv.setUint32(16, crc, true);
|
|
269
|
+
cv.setUint32(20, data.length, true);
|
|
270
|
+
cv.setUint32(24, data.length, true);
|
|
271
|
+
cv.setUint16(28, name.length, true);
|
|
272
|
+
cv.setUint32(42, offset, true);
|
|
273
|
+
entry.set(name, 46);
|
|
274
|
+
|
|
275
|
+
chunks.push(local, data);
|
|
276
|
+
central.push(entry);
|
|
277
|
+
offset += local.length + data.length;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const centralSize = central.reduce((total, entry) => total + entry.length, 0);
|
|
281
|
+
const end = new Uint8Array(22);
|
|
282
|
+
const ev = new DataView(end.buffer);
|
|
283
|
+
ev.setUint32(0, 0x06054b50, true);
|
|
284
|
+
ev.setUint16(8, files.length, true);
|
|
285
|
+
ev.setUint16(10, files.length, true);
|
|
286
|
+
ev.setUint32(12, centralSize, true);
|
|
287
|
+
ev.setUint32(16, offset, true);
|
|
288
|
+
|
|
289
|
+
return new Blob([...chunks, ...central, end], { type: "application/zip" });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function mount(slot) {
|
|
293
|
+
const entries = [];
|
|
294
|
+
let nextId = 1;
|
|
295
|
+
let runToken = 0;
|
|
296
|
+
let timer = null;
|
|
297
|
+
let busy = false;
|
|
298
|
+
|
|
299
|
+
const picker = el("input", { type: "file", accept: "image/*", multiple: true, class: "hidden" });
|
|
300
|
+
const drop = el(
|
|
301
|
+
"div",
|
|
302
|
+
{
|
|
303
|
+
class:
|
|
304
|
+
"grid cursor-pointer justify-items-center gap-1.5 rounded-r2 border border-dashed border-orbit-hard bg-deck-2 px-4 py-7 text-center transition-[border-color,background-color] duration-150 hover:border-jade focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-jade data-[drag]:border-jade data-[drag]:bg-jade-soft",
|
|
305
|
+
role: "button",
|
|
306
|
+
tabindex: "0",
|
|
307
|
+
"aria-label": "Add images",
|
|
308
|
+
},
|
|
309
|
+
icon("image-up", "size-6 text-ink-3"),
|
|
310
|
+
el("p", { class: "text-sm font-semibold" }, "Drop images here"),
|
|
311
|
+
el("p", { class: "text-2xs text-ink-3" }, "or choose files")
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const target = selectInput(
|
|
315
|
+
AVAILABLE.map((format) => ({ value: format.id, label: format.label })),
|
|
316
|
+
{ value: AVAILABLE.some((format) => format.id === "webp") ? "webp" : "jpeg", "aria-label": "Convert to" }
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const qualityRange = rangeInput({ min: "10", max: "100", step: "1", value: "85", "aria-label": "Quality percent" });
|
|
320
|
+
const qualityNumber = numberInput({ min: "10", max: "100", step: "1", value: "85", "aria-label": "Quality percent" });
|
|
321
|
+
const qualityRow = el(
|
|
322
|
+
"div",
|
|
323
|
+
{ class: "flex min-w-0 items-center gap-3" },
|
|
324
|
+
el("div", { class: "min-w-0 flex-1" }, qualityRange),
|
|
325
|
+
qualityNumber
|
|
326
|
+
);
|
|
327
|
+
const qualityHint = el("span", { class: "text-2xs text-ink-3" });
|
|
328
|
+
const qualityPanel = el(
|
|
329
|
+
"div",
|
|
330
|
+
{ class: "grid min-w-0 content-start gap-1.5" },
|
|
331
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Quality"),
|
|
332
|
+
qualityRow,
|
|
333
|
+
qualityHint
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const error = notice("warn");
|
|
337
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
338
|
+
const grid = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(160px,1fr))]" });
|
|
339
|
+
const empty = blank("image-up", "Add an image to convert it.");
|
|
340
|
+
|
|
341
|
+
const clearAll = button("Clear all", { icon: "trash" });
|
|
342
|
+
const downloadAll = button("Download all", { icon: "download", kind: "fill" });
|
|
343
|
+
|
|
344
|
+
function currentFormat() {
|
|
345
|
+
return AVAILABLE.find((format) => format.id === target.value) ?? AVAILABLE[0];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function syncQuality() {
|
|
349
|
+
const format = currentFormat();
|
|
350
|
+
const lossy = Boolean(format.quality);
|
|
351
|
+
qualityRange.disabled = !lossy;
|
|
352
|
+
qualityNumber.disabled = !lossy;
|
|
353
|
+
qualityPanel.classList.toggle("opacity-50", !lossy);
|
|
354
|
+
qualityHint.textContent = lossy
|
|
355
|
+
? `Applies to ${format.label}. Lower means a smaller file.`
|
|
356
|
+
: `${format.label} is lossless, so there is no quality to set.`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function schedule() {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
timer = setTimeout(run, 220);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function outputName(entry, format) {
|
|
365
|
+
const base = baseName(entry.file.name);
|
|
366
|
+
const used = new Set(entries.filter((other) => other !== entry && other.outName).map((other) => other.outName));
|
|
367
|
+
let name = `${base}.${format.ext}`;
|
|
368
|
+
let index = 2;
|
|
369
|
+
while (used.has(name)) {
|
|
370
|
+
name = `${base}-${index}.${format.ext}`;
|
|
371
|
+
index++;
|
|
372
|
+
}
|
|
373
|
+
return name;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function run() {
|
|
377
|
+
if (!entries.length) return;
|
|
378
|
+
const token = ++runToken;
|
|
379
|
+
const format = currentFormat();
|
|
380
|
+
const quality = Math.max(10, Math.min(100, parseInt(qualityNumber.value || "85", 10) || 85));
|
|
381
|
+
busy = true;
|
|
382
|
+
error.write("");
|
|
383
|
+
downloadAll.disabled = true;
|
|
384
|
+
clearAll.disabled = true;
|
|
385
|
+
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
if (token !== runToken) return;
|
|
388
|
+
entry.status = "working";
|
|
389
|
+
entry.blob = null;
|
|
390
|
+
entry.error = "";
|
|
391
|
+
entry.outName = "";
|
|
392
|
+
}
|
|
393
|
+
render();
|
|
394
|
+
|
|
395
|
+
for (const [index, entry] of entries.entries()) {
|
|
396
|
+
if (token !== runToken) return;
|
|
397
|
+
summary.textContent = `Converting ${index + 1} of ${entries.length}`;
|
|
398
|
+
try {
|
|
399
|
+
const blob = await convert(entry.file, format, quality);
|
|
400
|
+
if (token !== runToken) return;
|
|
401
|
+
entry.blob = blob;
|
|
402
|
+
entry.outName = outputName(entry, format);
|
|
403
|
+
entry.status = "done";
|
|
404
|
+
} catch (caught) {
|
|
405
|
+
if (token !== runToken) return;
|
|
406
|
+
entry.error = caught?.message || "Could not convert this file.";
|
|
407
|
+
entry.status = "error";
|
|
408
|
+
}
|
|
409
|
+
render();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (token !== runToken) return;
|
|
413
|
+
busy = false;
|
|
414
|
+
downloadAll.disabled = false;
|
|
415
|
+
clearAll.disabled = false;
|
|
416
|
+
render();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Two lines, so a unit never breaks in half at the card edge.
|
|
420
|
+
function metaLines(entry) {
|
|
421
|
+
const format = currentFormat();
|
|
422
|
+
const from = `${inputFormat(entry.file)} · ${formatBytes(entry.file.size)}`;
|
|
423
|
+
if (entry.status === "error") return [from, entry.error];
|
|
424
|
+
if (entry.status === "working") return [from, `Converting to ${format.label}`];
|
|
425
|
+
if (entry.status === "done") {
|
|
426
|
+
return [from, `to ${format.label} · ${formatBytes(entry.blob.size)}`];
|
|
427
|
+
}
|
|
428
|
+
return [from, `to ${format.label}`];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function fileCard(entry) {
|
|
432
|
+
const remove = button("", {
|
|
433
|
+
icon: "x",
|
|
434
|
+
kind: "quiet",
|
|
435
|
+
class:
|
|
436
|
+
"absolute end-1 top-1 size-8 rounded-full border border-orbit bg-deck/90 text-ink-2 hover:border-danger hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-danger",
|
|
437
|
+
"aria-label": `Remove ${entry.file.name}`,
|
|
438
|
+
});
|
|
439
|
+
remove.addEventListener("click", () => removeEntry(entry));
|
|
440
|
+
|
|
441
|
+
const save = button("Save", { icon: "download", class: "w-full" });
|
|
442
|
+
save.disabled = entry.status !== "done";
|
|
443
|
+
save.addEventListener("click", () => {
|
|
444
|
+
if (entry.blob) download(entry.outName, entry.blob, entry.blob.type);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return el(
|
|
448
|
+
"div",
|
|
449
|
+
{ class: "grid content-start gap-2 rounded-r2 border border-orbit bg-deck-2 p-2" },
|
|
450
|
+
el(
|
|
451
|
+
"div",
|
|
452
|
+
{ class: "relative" },
|
|
453
|
+
el("img", {
|
|
454
|
+
src: entry.url,
|
|
455
|
+
alt: "",
|
|
456
|
+
class: "aspect-[4/3] w-full rounded-r1 bg-deck object-contain",
|
|
457
|
+
}),
|
|
458
|
+
remove
|
|
459
|
+
),
|
|
460
|
+
el(
|
|
461
|
+
"div",
|
|
462
|
+
{ class: "min-w-0" },
|
|
463
|
+
el("p", { class: "truncate text-sm font-semibold", title: entry.file.name }, entry.file.name),
|
|
464
|
+
el(
|
|
465
|
+
"p",
|
|
466
|
+
{ class: `mt-0.5 grid gap-0.5 text-2xs tabular-nums ${entry.status === "error" ? "text-danger" : "text-ink-3"}` },
|
|
467
|
+
...metaLines(entry).map((line) => el("span", { class: "truncate" }, line))
|
|
468
|
+
)
|
|
469
|
+
),
|
|
470
|
+
save
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function render() {
|
|
475
|
+
grid.replaceChildren(...entries.map(fileCard));
|
|
476
|
+
const done = entries.filter((entry) => entry.status === "done");
|
|
477
|
+
const before = done.reduce((total, entry) => total + entry.file.size, 0);
|
|
478
|
+
const after = done.reduce((total, entry) => total + entry.blob.size, 0);
|
|
479
|
+
if (!entries.length) {
|
|
480
|
+
summary.textContent = "";
|
|
481
|
+
grid.hidden = true;
|
|
482
|
+
empty.hidden = false;
|
|
483
|
+
} else {
|
|
484
|
+
grid.hidden = false;
|
|
485
|
+
empty.hidden = true;
|
|
486
|
+
if (busy) {
|
|
487
|
+
// The loop writes the counter itself.
|
|
488
|
+
} else {
|
|
489
|
+
const failed = entries.length - done.length;
|
|
490
|
+
const size = done.length ? ` · ${formatBytes(before)} → ${formatBytes(after)}` : "";
|
|
491
|
+
const trouble = failed ? ` · ${failed} failed` : "";
|
|
492
|
+
summary.textContent = `${entries.length} image${entries.length === 1 ? "" : "s"}${size}${trouble}`;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
downloadAll.disabled = busy || !done.length;
|
|
496
|
+
clearAll.disabled = busy || !entries.length;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function removeEntry(entry) {
|
|
500
|
+
const index = entries.indexOf(entry);
|
|
501
|
+
if (index < 0) return;
|
|
502
|
+
entries.splice(index, 1);
|
|
503
|
+
URL.revokeObjectURL(entry.url);
|
|
504
|
+
if (!entries.length) {
|
|
505
|
+
runToken++;
|
|
506
|
+
busy = false;
|
|
507
|
+
summary.textContent = "";
|
|
508
|
+
}
|
|
509
|
+
render();
|
|
510
|
+
if (entries.length) schedule();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function addFiles(files) {
|
|
514
|
+
const images = [];
|
|
515
|
+
let skipped = 0;
|
|
516
|
+
for (const file of files) {
|
|
517
|
+
if (looksLikeImage(file)) images.push(file);
|
|
518
|
+
else skipped++;
|
|
519
|
+
}
|
|
520
|
+
error.write(skipped ? `${skipped} file${skipped === 1 ? "" : "s"} skipped. Only images can be converted.` : "");
|
|
521
|
+
if (!images.length) return;
|
|
522
|
+
for (const file of images) {
|
|
523
|
+
entries.push({
|
|
524
|
+
id: nextId++,
|
|
525
|
+
file,
|
|
526
|
+
url: URL.createObjectURL(file),
|
|
527
|
+
status: "queued",
|
|
528
|
+
blob: null,
|
|
529
|
+
error: "",
|
|
530
|
+
outName: "",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
render();
|
|
534
|
+
schedule();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
picker.addEventListener("change", () => {
|
|
538
|
+
addFiles(Array.from(picker.files ?? []));
|
|
539
|
+
picker.value = "";
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
drop.addEventListener("click", () => picker.click());
|
|
543
|
+
drop.addEventListener("keydown", (event) => {
|
|
544
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
picker.click();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
let dragDepth = 0;
|
|
551
|
+
drop.addEventListener("dragenter", (event) => {
|
|
552
|
+
event.preventDefault();
|
|
553
|
+
dragDepth++;
|
|
554
|
+
drop.dataset.drag = "";
|
|
555
|
+
});
|
|
556
|
+
drop.addEventListener("dragover", (event) => event.preventDefault());
|
|
557
|
+
drop.addEventListener("dragleave", () => {
|
|
558
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
559
|
+
if (!dragDepth) delete drop.dataset.drag;
|
|
560
|
+
});
|
|
561
|
+
drop.addEventListener("drop", (event) => {
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
dragDepth = 0;
|
|
564
|
+
delete drop.dataset.drag;
|
|
565
|
+
addFiles(Array.from(event.dataTransfer?.files ?? []));
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
target.addEventListener("change", () => {
|
|
569
|
+
syncQuality();
|
|
570
|
+
schedule();
|
|
571
|
+
});
|
|
572
|
+
qualityRange.addEventListener("input", () => {
|
|
573
|
+
qualityNumber.value = qualityRange.value;
|
|
574
|
+
schedule();
|
|
575
|
+
});
|
|
576
|
+
qualityNumber.addEventListener("input", () => {
|
|
577
|
+
const value = parseInt(qualityNumber.value || "", 10);
|
|
578
|
+
if (Number.isFinite(value)) qualityRange.value = String(Math.max(10, Math.min(100, value)));
|
|
579
|
+
schedule();
|
|
580
|
+
});
|
|
581
|
+
qualityNumber.addEventListener("change", () => {
|
|
582
|
+
const value = Math.max(10, Math.min(100, parseInt(qualityNumber.value || "85", 10) || 85));
|
|
583
|
+
qualityNumber.value = String(value);
|
|
584
|
+
qualityRange.value = String(value);
|
|
585
|
+
schedule();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
clearAll.addEventListener("click", () => {
|
|
589
|
+
runToken++;
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
busy = false;
|
|
592
|
+
for (const entry of entries) URL.revokeObjectURL(entry.url);
|
|
593
|
+
entries.length = 0;
|
|
594
|
+
error.write("");
|
|
595
|
+
render();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
downloadAll.addEventListener("click", () => {
|
|
599
|
+
const done = entries.filter((entry) => entry.status === "done" && entry.blob);
|
|
600
|
+
if (!done.length) return toast("Nothing to save yet");
|
|
601
|
+
if (done.length === 1) {
|
|
602
|
+
download(done[0].outName, done[0].blob, done[0].blob.type);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
Promise.all(done.map(async (entry) => ({ name: entry.outName, bytes: await entry.blob.arrayBuffer() })))
|
|
606
|
+
.then((files) => download(`mevebu-images-${currentFormat().ext}.zip`, zip(files), "application/zip"))
|
|
607
|
+
.catch(() => toast("Could not build the ZIP"));
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
syncQuality();
|
|
611
|
+
render();
|
|
612
|
+
|
|
613
|
+
const shell = toolcard({
|
|
614
|
+
body: bench(
|
|
615
|
+
stack(
|
|
616
|
+
drop,
|
|
617
|
+
picker,
|
|
618
|
+
panel("Convert to", target),
|
|
619
|
+
qualityPanel,
|
|
620
|
+
error
|
|
621
|
+
),
|
|
622
|
+
stack(
|
|
623
|
+
summary,
|
|
624
|
+
empty,
|
|
625
|
+
grid
|
|
626
|
+
)
|
|
627
|
+
),
|
|
628
|
+
foot: actions(clearAll, downloadAll),
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
slot.append(shell.node);
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
unmount() {
|
|
635
|
+
runToken++;
|
|
636
|
+
clearTimeout(timer);
|
|
637
|
+
for (const entry of entries) URL.revokeObjectURL(entry.url);
|
|
638
|
+
entries.length = 0;
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export default { mount };
|