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 +8 -12
- package/mevebu/app/mevebu.js +0 -27
- package/mevebu/app/tailwind.css +7 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/registry.js +19 -0
- package/mevebu/libs/js/tools/favicon-converter.js +432 -0
- package/mevebu/libs/js/tools/image-converter.js +1 -78
- package/mevebu/libs/js/zip.js +80 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# mevebu
|
|
4
4
|
|
|
5
|
-
**
|
|
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
|
[](https://www.npmjs.com/package/mevebu)
|
|
11
11
|
[](./LICENSE)
|
|
12
12
|
[](https://github.com/mevebu/mevebu/stargazers)
|
|
13
|
-

|
|
14
14
|

|
|
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 **
|
|
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 **
|
|
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 |
|
|
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** | **
|
|
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
|
|
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
|
-
##
|
|
209
|
-
|
|
210
|
-
If a tool here saved you time, you can help keep the next one coming:
|
|
211
|
-
|
|
212
|
-
[](https://buymeacoffee.com/fastro)
|
|
208
|
+
## Follow
|
|
213
209
|
|
|
214
210
|
Star the repo if you like it, and follow mevebu for updates:
|
|
215
211
|
|
package/mevebu/app/mevebu.js
CHANGED
|
@@ -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
|
),
|
package/mevebu/app/tailwind.css
CHANGED
|
@@ -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
|
}
|
package/mevebu/libs/js/core.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|