mevebu 1.8.0 → 1.9.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mevebu
4
4
 
5
- **187 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
5
+ **188 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
6
6
 
7
7
  A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
8
8
 
@@ -10,7 +10,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
10
10
  [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
11
  [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
12
  [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
- ![Tools](https://img.shields.io/badge/tools-187-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-188-brightgreen)
14
14
  ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
15
15
 
16
16
  [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Host it yourself](#host-it-yourself) · [Development](#development) · [FAQ](#faq)
@@ -23,7 +23,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
23
23
 
24
24
  ## What is mevebu?
25
25
 
26
- mevebu is a collection of **187 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
26
+ mevebu is a collection of **188 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
27
27
 
28
28
  Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
29
29
 
@@ -65,14 +65,14 @@ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free p
65
65
 
66
66
  ## What's inside
67
67
 
68
- mevebu has **187 tools in 20 categories**. Here is the full breakdown:
68
+ mevebu has **188 tools in 20 categories**. Here is the full breakdown:
69
69
 
70
70
  | Category | Tools |
71
71
  | ------------------ | ----: |
72
72
  | Random | 3 |
73
73
  | Passwords and keys | 10 |
74
74
  | Developer | 21 |
75
- | Convert | 4 |
75
+ | Convert | 5 |
76
76
  | Text and data | 14 |
77
77
  | Daily use | 8 |
78
78
  | Math | 17 |
@@ -89,7 +89,7 @@ mevebu has **187 tools in 20 categories**. Here is the full breakdown:
89
89
  | GitHub | 8 |
90
90
  | Units | 8 |
91
91
  | QR codes | 2 |
92
- | **Total** | **187** |
92
+ | **Total** | **188** |
93
93
 
94
94
  The counts come straight from the tool registry, so this table and the app never drift apart.
95
95
 
@@ -179,7 +179,7 @@ That is all. The catalog, search, breadcrumbs, and category pages pick it up aut
179
179
  ## FAQ
180
180
 
181
181
  **What is mevebu?**
182
- mevebu is a free, open source collection of 187 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
182
+ mevebu is a free, open source collection of 188 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
183
183
 
184
184
  **Is mevebu really free?**
185
185
  Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
@@ -205,11 +205,7 @@ Every tool page has a small **Report a problem** link at the bottom. It opens a
205
205
 
206
206
  You can also [open an issue](https://github.com/mevebu/mevebu/issues) directly, or send a pull request.
207
207
 
208
- ## Support and follow
209
-
210
- If a tool here saved you time, you can help keep the next one coming:
211
-
212
- [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-support-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/fastro)
208
+ ## Follow
213
209
 
214
210
  Star the repo if you like it, and follow mevebu for updates:
215
211
 
@@ -10,7 +10,6 @@ import {
10
10
  textInput, textArea, panel,
11
11
  } from "../libs/js/core.js";
12
12
 
13
- const DONATE_URL = "https://buymeacoffee.com/fastro";
14
13
  const REPO_URL = "https://github.com/mevebu/mevebu";
15
14
 
16
15
  const NAV = [
@@ -161,22 +160,6 @@ function openHelp(tool) {
161
160
  });
162
161
  }
163
162
 
164
- function openDonate() {
165
- const close = modal({
166
- label: "Support mevebu",
167
- body: [
168
- modalHead("coffee", "Keep mevebu flying"),
169
- el("p", {}, "mevebu is free and runs entirely in your browser. No accounts, no ads, nothing to sell."),
170
- el("p", {}, "If a tool here saved you time, a coffee keeps the next one coming."),
171
- ],
172
- actions: [
173
- button("Not now", { onclick: () => close() }),
174
- el("a", { class: LINK_FILL, href: DONATE_URL, target: "_blank", rel: "noopener" },
175
- icon("coffee"), el("span", {}, "Buy me a coffee")),
176
- ],
177
- });
178
- }
179
-
180
163
  // The footer chip opens this. `latest` is the published version, or null when the
181
164
  // check has not answered; opening the modal retries it, so a registry hiccup on
182
165
  // the first load is not a dead end. The body and the actions repaint in place
@@ -294,15 +277,6 @@ async function startVersionChip() {
294
277
  }
295
278
 
296
279
  function footer() {
297
- const donate = button("Donate", {
298
- icon: "coffee",
299
- kind: "quiet",
300
- onclick: (event) => {
301
- event.preventDefault();
302
- openDonate();
303
- },
304
- });
305
-
306
280
  // A finger needs 44px on a phone, so the whole list is a comfortable target
307
281
  // there. The desktop row stays as it was, at text height.
308
282
  const FOOT_LINK = "inline-flex items-center text-sm text-ink-2 no-underline hover:text-jade max-[45rem]:min-h-11";
@@ -341,7 +315,6 @@ function footer() {
341
315
  group("Project", [
342
316
  el("a", { class: FOOT_LINK, href: "#/about" }, "About"),
343
317
  el("a", { class: FOOT_LINK, href: REPO_URL, target: "_blank", rel: "noopener" }, "GitHub"),
344
- donate,
345
318
  ])
346
319
  )
347
320
  ),
@@ -402,6 +402,10 @@
402
402
  width: calc(var(--spacing) * 11);
403
403
  height: calc(var(--spacing) * 11);
404
404
  }
405
+ .size-16 {
406
+ width: calc(var(--spacing) * 16);
407
+ height: calc(var(--spacing) * 16);
408
+ }
405
409
  .size-32 {
406
410
  width: calc(var(--spacing) * 32);
407
411
  height: calc(var(--spacing) * 32);
@@ -644,6 +648,9 @@
644
648
  .\[grid-template-columns\:minmax\(90px\,34\%\)_minmax\(0\,1fr\)\] {
645
649
  grid-template-columns: minmax(90px,34%) minmax(0,1fr);
646
650
  }
651
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(92px\,1fr\)\)\] {
652
+ grid-template-columns: repeat(auto-fill,minmax(92px,1fr));
653
+ }
647
654
  .\[grid-template-columns\:repeat\(auto-fill\,minmax\(160px\,1fr\)\)\] {
648
655
  grid-template-columns: repeat(auto-fill,minmax(160px,1fr));
649
656
  }
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
750
750
  // ---------- version ----------
751
751
 
752
752
  // Kept in one place. The CLI reads the same value from package.json.
753
- export const VERSION = "1.8.0";
753
+ export const VERSION = "1.9.0";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -376,6 +376,25 @@ export const TOOLS = [
376
376
  keywords: "convert jpeg jpg png webp tiff bmp image batch compress quality",
377
377
  load: () => import("./tools/image-converter.js"),
378
378
  },
379
+ {
380
+ id: "favicon-converter",
381
+ category: "convert",
382
+ name: "Favicon Converter",
383
+ icon: "layout-grid",
384
+ note: "Turn one image into a full favicon set.",
385
+ about: "One image becomes an ICO, the PNG sizes, a manifest, and the tags.",
386
+ tutorial: {
387
+ intro: "Drop one image and get every icon file, a web manifest, and the link tags.",
388
+ steps: [
389
+ "Drop an image or choose a file. A square PNG or SVG gives the cleanest result.",
390
+ "Set a background if the artwork needs one. Transparent keeps the alpha channel.",
391
+ "Press Download .zip for the ICO, the PNG sizes, and the manifest.",
392
+ "Copy the tags into the head of your page.",
393
+ ],
394
+ },
395
+ keywords: "favicon ico png apple touch icon android manifest icon generator",
396
+ load: () => import("./tools/favicon-converter.js"),
397
+ },
379
398
 
380
399
  {
381
400
  id: "lorem-ipsum",
@@ -0,0 +1,432 @@
1
+ // One image becomes the full favicon set: a multi size ICO, the PNG sizes every
2
+ // platform asks for, a web manifest, and the tags that point at them. Everything
3
+ // is drawn on a canvas and zipped on this device.
4
+ import {
5
+ el, icon, toast, download, bench, stack, panel, textInput, colorInput, checkbox,
6
+ output, notice, blank, button, copyButton, actions, toolcard,
7
+ } from "../core.js";
8
+ import { zip } from "../zip.js";
9
+
10
+ const PNG_ICONS = [
11
+ { name: "favicon-16x16.png", size: 16 },
12
+ { name: "favicon-32x32.png", size: 32 },
13
+ { name: "favicon-48x48.png", size: 48 },
14
+ { name: "apple-touch-icon.png", size: 180 },
15
+ { name: "android-chrome-192x192.png", size: 192 },
16
+ { name: "android-chrome-512x512.png", size: 512 },
17
+ ];
18
+
19
+ const ICO_SIZES = [16, 32, 48];
20
+ const MASTER = 512;
21
+ const MAX_SIDE = 4096;
22
+ const MAX_PIXELS = 20_000_000;
23
+
24
+ function formatBytes(bytes) {
25
+ if (!bytes) return "0 B";
26
+ if (bytes < 1024) return `${bytes} B`;
27
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
28
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
29
+ }
30
+
31
+ function looksLikeImage(file) {
32
+ if (file.type.startsWith("image/")) return true;
33
+ return /\.(jpe?g|png|webp|avif|gif|bmp|tiff?|svg|ico)$/i.test(file.name);
34
+ }
35
+
36
+ async function decode(file) {
37
+ if (typeof createImageBitmap === "function") {
38
+ try {
39
+ return await createImageBitmap(file, { imageOrientation: "from-image" });
40
+ } catch {
41
+ // SVG and a few odd files only decode through an <img>.
42
+ }
43
+ }
44
+ const url = URL.createObjectURL(file);
45
+ try {
46
+ const image = new Image();
47
+ image.decoding = "sync";
48
+ await new Promise((resolve, reject) => {
49
+ image.onload = resolve;
50
+ image.onerror = () => reject(new Error("This file is not an image the browser can read."));
51
+ image.src = url;
52
+ });
53
+ return image;
54
+ } finally {
55
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
56
+ }
57
+ }
58
+
59
+ function toBlob(canvas) {
60
+ return new Promise((resolve, reject) => {
61
+ canvas.toBlob(
62
+ (blob) => (blob ? resolve(blob) : reject(new Error("The browser could not write this PNG."))),
63
+ "image/png"
64
+ );
65
+ });
66
+ }
67
+
68
+ function masterCanvas(source, background) {
69
+ const width = source.width || source.naturalWidth;
70
+ const height = source.height || source.naturalHeight;
71
+ if (!width || !height) throw new Error("This image has no pixel size to read.");
72
+ if (width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_PIXELS) {
73
+ throw new Error("This image is too large. Use one under 4096 px.");
74
+ }
75
+
76
+ const canvas = document.createElement("canvas");
77
+ canvas.width = MASTER;
78
+ canvas.height = MASTER;
79
+ const context = canvas.getContext("2d", { alpha: true });
80
+ context.imageSmoothingEnabled = true;
81
+ context.imageSmoothingQuality = "high";
82
+ if (background) {
83
+ context.fillStyle = background;
84
+ context.fillRect(0, 0, MASTER, MASTER);
85
+ }
86
+ const scale = Math.min(MASTER / width, MASTER / height);
87
+ const drawWidth = width * scale;
88
+ const drawHeight = height * scale;
89
+ context.drawImage(source, (MASTER - drawWidth) / 2, (MASTER - drawHeight) / 2, drawWidth, drawHeight);
90
+ if (source.close) source.close();
91
+ return canvas;
92
+ }
93
+
94
+ function scaledPng(master, size) {
95
+ const canvas = document.createElement("canvas");
96
+ canvas.width = size;
97
+ canvas.height = size;
98
+ const context = canvas.getContext("2d", { alpha: true });
99
+ context.imageSmoothingEnabled = true;
100
+ context.imageSmoothingQuality = "high";
101
+ context.drawImage(master, 0, 0, size, size);
102
+ return toBlob(canvas);
103
+ }
104
+
105
+ // ICO with PNG payloads. Vista and every current browser read this form, and it
106
+ // keeps the alpha channel that a BMP entry would need a second encoder for.
107
+ function buildIco(entries) {
108
+ const header = 6 + entries.length * 16;
109
+ const total = entries.reduce((sum, entry) => sum + entry.bytes.length, header);
110
+ const buffer = new ArrayBuffer(total);
111
+ const view = new DataView(buffer);
112
+ const bytes = new Uint8Array(buffer);
113
+
114
+ view.setUint16(0, 0, true);
115
+ view.setUint16(2, 1, true);
116
+ view.setUint16(4, entries.length, true);
117
+
118
+ let offset = header;
119
+ entries.forEach((entry, index) => {
120
+ const at = 6 + index * 16;
121
+ bytes[at] = entry.size >= 256 ? 0 : entry.size;
122
+ bytes[at + 1] = entry.size >= 256 ? 0 : entry.size;
123
+ bytes[at + 2] = 0;
124
+ bytes[at + 3] = 0;
125
+ view.setUint16(at + 4, 1, true);
126
+ view.setUint16(at + 6, 32, true);
127
+ view.setUint32(at + 8, entry.bytes.length, true);
128
+ view.setUint32(at + 12, offset, true);
129
+ bytes.set(entry.bytes, offset);
130
+ offset += entry.bytes.length;
131
+ });
132
+
133
+ return bytes;
134
+ }
135
+
136
+ function manifest(background, name) {
137
+ const color = background || "#ffffff";
138
+ const app = name ? { name, short_name: name } : {};
139
+ return JSON.stringify({
140
+ ...app,
141
+ icons: [
142
+ { src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
143
+ { src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
144
+ ],
145
+ theme_color: color,
146
+ background_color: color,
147
+ display: "standalone",
148
+ }, null, 2) + "\n";
149
+ }
150
+
151
+ const TAGS = [
152
+ '<link rel="icon" href="/favicon.ico" sizes="any" />',
153
+ '<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
154
+ '<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
155
+ '<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />',
156
+ '<link rel="manifest" href="/site.webmanifest" />',
157
+ ].join("\n");
158
+
159
+ export function mount(slot) {
160
+ let source = null;
161
+ let sourceUrl = "";
162
+ let files = [];
163
+ let previews = [];
164
+ let token = 0;
165
+ let timer = null;
166
+
167
+ const picker = el("input", { type: "file", accept: "image/*", class: "hidden" });
168
+ const drop = el(
169
+ "div",
170
+ {
171
+ class:
172
+ "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",
173
+ role: "button",
174
+ tabindex: "0",
175
+ "aria-label": "Add an image",
176
+ },
177
+ icon("image-up", "size-6 text-ink-3"),
178
+ el("p", { class: "text-sm font-semibold" }, "Drop an image here"),
179
+ el("p", { class: "text-2xs text-ink-3" }, "or choose a file")
180
+ );
181
+
182
+ const preview = el("img", { alt: "", class: "hidden size-16 rounded-r1 border border-orbit bg-deck object-contain p-1" });
183
+ const nameLine = el("p", { class: "truncate text-sm font-semibold" });
184
+
185
+ const color = colorInput({ value: "#ffffff", "aria-label": "Background color" });
186
+ const transparent = checkbox("Transparent", { checked: true });
187
+ const backgroundRow = el(
188
+ "div",
189
+ { class: "flex min-w-0 flex-wrap items-center gap-2" },
190
+ color,
191
+ transparent
192
+ );
193
+
194
+ const appName = textInput({ placeholder: "My site", "aria-label": "App name" });
195
+
196
+ const error = notice("warn");
197
+ const summary = el("p", { class: "text-sm text-ink-2" });
198
+ const grid = el("div", { class: "grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(92px,1fr))]" });
199
+ const tags = output({ rows: 6 });
200
+ const tagsPanel = el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "HTML tags"), tags);
201
+ const empty = blank("layout-grid", "Add an image to build the set.");
202
+
203
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
204
+ const downloadButton = button("Download .zip", { icon: "download", kind: "fill" });
205
+ const copyTags = copyButton(() => tags.textContent, "Copy tags");
206
+
207
+ function syncBackground() {
208
+ color.disabled = transparent.input.checked;
209
+ }
210
+
211
+ function releasePreviews() {
212
+ for (const url of previews) URL.revokeObjectURL(url);
213
+ previews = [];
214
+ }
215
+
216
+ function render() {
217
+ if (!source) {
218
+ grid.hidden = true;
219
+ tagsPanel.hidden = true;
220
+ empty.hidden = false;
221
+ summary.textContent = "";
222
+ downloadButton.disabled = true;
223
+ copyTags.disabled = true;
224
+ preview.classList.add("hidden");
225
+ nameLine.textContent = "";
226
+ return;
227
+ }
228
+ empty.hidden = true;
229
+ preview.classList.remove("hidden");
230
+ nameLine.textContent = source.name;
231
+ grid.hidden = false;
232
+ tagsPanel.hidden = false;
233
+ downloadButton.disabled = !files.length;
234
+ copyTags.disabled = !files.length;
235
+ }
236
+
237
+ function paintPreview(blobs) {
238
+ releasePreviews();
239
+ grid.replaceChildren(...PNG_ICONS.map(({ name, size }) => {
240
+ const blob = blobs.get(name);
241
+ const url = URL.createObjectURL(blob);
242
+ previews.push(url);
243
+ return el(
244
+ "figure",
245
+ { class: "m-0 grid justify-items-center gap-1 rounded-r1 border border-orbit bg-deck-2 p-2" },
246
+ el("img", {
247
+ src: url,
248
+ alt: "",
249
+ width: Math.min(size, 48),
250
+ height: Math.min(size, 48),
251
+ class: "object-contain",
252
+ }),
253
+ el("figcaption", { class: "text-2xs tabular-nums text-ink-3" }, `${size} × ${size}`)
254
+ );
255
+ }));
256
+ }
257
+
258
+ async function run() {
259
+ if (!source) return;
260
+ const current = ++token;
261
+ error.write("");
262
+ downloadButton.disabled = true;
263
+ copyTags.disabled = true;
264
+ summary.textContent = "Building the set";
265
+
266
+ try {
267
+ const image = await decode(source.file);
268
+ if (current !== token) return;
269
+ const background = transparent.input.checked ? "" : color.value;
270
+ const master = masterCanvas(image, background);
271
+ const blobs = new Map();
272
+ for (const { name, size } of PNG_ICONS) {
273
+ blobs.set(name, await scaledPng(master, size));
274
+ if (current !== token) return;
275
+ }
276
+
277
+ const pngBytes = new Map();
278
+ for (const [name, blob] of blobs) pngBytes.set(name, new Uint8Array(await blob.arrayBuffer()));
279
+ const ico = buildIco(ICO_SIZES.map((size) => ({
280
+ size,
281
+ bytes: pngBytes.get(`favicon-${size}x${size}.png`),
282
+ })));
283
+ const manifestBytes = new TextEncoder().encode(manifest(background, appName.value.trim()));
284
+
285
+ files = [
286
+ { name: "favicon.ico", bytes: ico },
287
+ ...PNG_ICONS.map(({ name }) => ({ name, bytes: pngBytes.get(name) })),
288
+ { name: "site.webmanifest", bytes: manifestBytes },
289
+ ];
290
+ const total = files.reduce((sum, file) => sum + file.bytes.length, 0);
291
+ summary.textContent = `${files.length} files · ${formatBytes(total)}`;
292
+ tags.write(TAGS);
293
+ paintPreview(blobs);
294
+ render();
295
+ } catch (caught) {
296
+ if (current !== token) return;
297
+ files = [];
298
+ summary.textContent = "";
299
+ tags.write("");
300
+ grid.replaceChildren();
301
+ releasePreviews();
302
+ error.write(caught?.message || "Could not build the icon set.");
303
+ render();
304
+ }
305
+ }
306
+
307
+ function schedule() {
308
+ clearTimeout(timer);
309
+ timer = setTimeout(run, 180);
310
+ }
311
+
312
+ function useFile(file) {
313
+ if (!file) return;
314
+ if (!looksLikeImage(file)) {
315
+ error.write("That file is not an image.");
316
+ return;
317
+ }
318
+ token++;
319
+ if (sourceUrl) URL.revokeObjectURL(sourceUrl);
320
+ source = { file, name: file.name };
321
+ sourceUrl = URL.createObjectURL(file);
322
+ preview.src = sourceUrl;
323
+ files = [];
324
+ error.write("");
325
+ tags.write("");
326
+ summary.textContent = "";
327
+ grid.replaceChildren();
328
+ releasePreviews();
329
+ render();
330
+ schedule();
331
+ }
332
+
333
+ function clearAll() {
334
+ token++;
335
+ clearTimeout(timer);
336
+ if (sourceUrl) URL.revokeObjectURL(sourceUrl);
337
+ source = null;
338
+ sourceUrl = "";
339
+ files = [];
340
+ preview.removeAttribute("src");
341
+ grid.replaceChildren();
342
+ tags.write("");
343
+ releasePreviews();
344
+ error.write("");
345
+ render();
346
+ }
347
+
348
+ picker.addEventListener("change", () => {
349
+ useFile(picker.files?.[0] ?? null);
350
+ picker.value = "";
351
+ });
352
+
353
+ drop.addEventListener("click", () => picker.click());
354
+ drop.addEventListener("keydown", (event) => {
355
+ if (event.key === "Enter" || event.key === " ") {
356
+ event.preventDefault();
357
+ picker.click();
358
+ }
359
+ });
360
+
361
+ let dragDepth = 0;
362
+ drop.addEventListener("dragenter", (event) => {
363
+ event.preventDefault();
364
+ dragDepth++;
365
+ drop.dataset.drag = "";
366
+ });
367
+ drop.addEventListener("dragover", (event) => event.preventDefault());
368
+ drop.addEventListener("dragleave", () => {
369
+ dragDepth = Math.max(0, dragDepth - 1);
370
+ if (!dragDepth) delete drop.dataset.drag;
371
+ });
372
+ drop.addEventListener("drop", (event) => {
373
+ event.preventDefault();
374
+ dragDepth = 0;
375
+ delete drop.dataset.drag;
376
+ useFile(event.dataTransfer?.files?.[0] ?? null);
377
+ });
378
+
379
+ transparent.input.addEventListener("change", () => {
380
+ syncBackground();
381
+ schedule();
382
+ });
383
+ color.addEventListener("input", schedule);
384
+ appName.addEventListener("input", schedule);
385
+
386
+ clearButton.addEventListener("click", clearAll);
387
+ downloadButton.addEventListener("click", () => {
388
+ if (!files.length) return toast("Nothing to save yet");
389
+ download("favicon.zip", zip(files), "application/zip");
390
+ });
391
+
392
+ syncBackground();
393
+ render();
394
+
395
+ const shell = toolcard({
396
+ body: bench(
397
+ stack(
398
+ drop,
399
+ picker,
400
+ el(
401
+ "div",
402
+ { class: "flex min-w-0 items-center gap-3" },
403
+ preview,
404
+ el("div", { class: "min-w-0" }, nameLine)
405
+ ),
406
+ el(
407
+ "div",
408
+ { class: "grid min-w-0 content-start gap-1.5" },
409
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Background"),
410
+ backgroundRow
411
+ ),
412
+ panel("App name", appName),
413
+ error
414
+ ),
415
+ stack(summary, empty, grid, tagsPanel)
416
+ ),
417
+ foot: actions(clearButton, copyTags, downloadButton),
418
+ });
419
+
420
+ slot.append(shell.node);
421
+
422
+ return {
423
+ unmount() {
424
+ token++;
425
+ clearTimeout(timer);
426
+ if (sourceUrl) URL.revokeObjectURL(sourceUrl);
427
+ releasePreviews();
428
+ },
429
+ };
430
+ }
431
+
432
+ export default { mount };
@@ -5,6 +5,7 @@ import {
5
5
  el, icon, toast, download, bench, stack, panel, selectInput, rangeInput, numberInput,
6
6
  button, actions, toolcard, blank, notice,
7
7
  } from "../core.js";
8
+ import { zip } from "../zip.js";
8
9
 
9
10
  const FORMATS = [
10
11
  { id: "jpeg", label: "JPEG", mime: "image/jpeg", ext: "jpg", quality: true },
@@ -211,84 +212,6 @@ function encodeBmp(image) {
211
212
  return new Blob([buffer], { type: "image/bmp" });
212
213
  }
213
214
 
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
215
  export function mount(slot) {
293
216
  const entries = [];
294
217
  let nextId = 1;
@@ -0,0 +1,80 @@
1
+ // A ZIP writer for files the browser already holds in memory. Store only:
2
+ // the payloads are compressed images, so deflating them again costs time and
3
+ // saves nothing.
4
+
5
+ const CRC_TABLE = (() => {
6
+ const table = new Uint32Array(256);
7
+ for (let n = 0; n < 256; n++) {
8
+ let c = n;
9
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
10
+ table[n] = c >>> 0;
11
+ }
12
+ return table;
13
+ })();
14
+
15
+ function crc32(bytes) {
16
+ let c = 0xffffffff;
17
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
18
+ return (c ^ 0xffffffff) >>> 0;
19
+ }
20
+
21
+ /** @param {{name: string, bytes: ArrayBuffer|Uint8Array}[]} files */
22
+ export function zip(files) {
23
+ const now = new Date();
24
+ const time = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
25
+ const date = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
26
+ const chunks = [];
27
+ const central = [];
28
+ let offset = 0;
29
+
30
+ for (const file of files) {
31
+ const name = new TextEncoder().encode(file.name);
32
+ const data = new Uint8Array(file.bytes);
33
+ const crc = crc32(data);
34
+
35
+ const local = new Uint8Array(30 + name.length);
36
+ const lv = new DataView(local.buffer);
37
+ lv.setUint32(0, 0x04034b50, true);
38
+ lv.setUint16(4, 20, true);
39
+ lv.setUint16(6, 0x0800, true);
40
+ lv.setUint16(8, 0, true);
41
+ lv.setUint16(10, time, true);
42
+ lv.setUint16(12, date, true);
43
+ lv.setUint32(14, crc, true);
44
+ lv.setUint32(18, data.length, true);
45
+ lv.setUint32(22, data.length, true);
46
+ lv.setUint16(26, name.length, true);
47
+ local.set(name, 30);
48
+
49
+ const entry = new Uint8Array(46 + name.length);
50
+ const cv = new DataView(entry.buffer);
51
+ cv.setUint32(0, 0x02014b50, true);
52
+ cv.setUint16(4, 20, true);
53
+ cv.setUint16(6, 20, true);
54
+ cv.setUint16(8, 0x0800, true);
55
+ cv.setUint16(10, 0, true);
56
+ cv.setUint16(12, time, true);
57
+ cv.setUint16(14, date, true);
58
+ cv.setUint32(16, crc, true);
59
+ cv.setUint32(20, data.length, true);
60
+ cv.setUint32(24, data.length, true);
61
+ cv.setUint16(28, name.length, true);
62
+ cv.setUint32(42, offset, true);
63
+ entry.set(name, 46);
64
+
65
+ chunks.push(local, data);
66
+ central.push(entry);
67
+ offset += local.length + data.length;
68
+ }
69
+
70
+ const centralSize = central.reduce((total, entry) => total + entry.length, 0);
71
+ const end = new Uint8Array(22);
72
+ const ev = new DataView(end.buffer);
73
+ ev.setUint32(0, 0x06054b50, true);
74
+ ev.setUint16(8, files.length, true);
75
+ ev.setUint16(10, files.length, true);
76
+ ev.setUint32(12, centralSize, true);
77
+ ev.setUint32(16, offset, true);
78
+
79
+ return new Blob([...chunks, ...central, end], { type: "application/zip" });
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",