mevebu 1.8.1 → 1.10.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.
@@ -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",
@@ -1336,17 +1355,18 @@ export const TOOLS = [
1336
1355
  category: "math",
1337
1356
  name: "Scientific Calculator",
1338
1357
  icon: "calculator",
1339
- note: "Do scientific math with a keypad.",
1340
- about: "Trigonometry, logs, powers and roots, in degrees or radians.",
1358
+ note: "Do scientific math with a live keypad.",
1359
+ about: "Variables, fractions, constants and a full function set, in degrees or radians.",
1341
1360
  tutorial: {
1342
- intro: "Do scientific math with a keypad.",
1361
+ intro: "A scientific calculator that evaluates as you type.",
1343
1362
  steps: [
1344
- "Type an expression or use the keys.",
1345
- "Pick degrees or radians for the trig functions.",
1346
- "Press = to evaluate. The history keeps recent results.",
1363
+ "Type an expression or press the keys. The result updates while you type.",
1364
+ "Press = or Enter to commit it to the history.",
1365
+ "Keep values with a = 5, then use a in the next line. ans holds the last result.",
1366
+ "Switch Degrees or Radians, and Decimal or Fraction for the result.",
1347
1367
  ],
1348
1368
  },
1349
- keywords: "scientific calculator trigonometry log sin cos tan",
1369
+ keywords: "scientific calculator trigonometry log sin cos tan variables fractions ans desmos",
1350
1370
  load: () => import("./tools/scientific-calculator.js"),
1351
1371
  },
1352
1372
  {
@@ -3072,7 +3092,7 @@ export const TOOLS = [
3072
3092
  id: "script-planner",
3073
3093
  category: "content",
3074
3094
  name: "Script Planner",
3075
- icon: "film",
3095
+ icon: "video",
3076
3096
  note: "Lay out a video script.",
3077
3097
  about: "Hook, intro, body and outro, with word and time estimates.",
3078
3098
  tutorial: {
@@ -3108,7 +3128,7 @@ export const TOOLS = [
3108
3128
  id: "subtitle-formatter",
3109
3129
  category: "content",
3110
3130
  name: "Subtitle Formatter",
3111
- icon: "film",
3131
+ icon: "video",
3112
3132
  note: "Check subtitle line lengths.",
3113
3133
  about: "Flags lines that run too long and tidies blank lines.",
3114
3134
  tutorial: {
@@ -0,0 +1,32 @@
1
+ // GENERATED FILE, DO NOT EDIT.
2
+ // Source: simple-icons v16.35.0 (CC0 1.0), 6 brand marks from scripts/vendor-social.mjs
3
+ // Regenerate with: npm run social
4
+
5
+ const D = {
6
+ "github": { title: "GitHub", path: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" },
7
+ "instagram": { title: "Instagram", path: "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" },
8
+ "threads": { title: "Threads", path: "M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z" },
9
+ "tiktok": { title: "TikTok", path: "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" },
10
+ "facebook": { title: "Facebook", path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" },
11
+ "youtube": { title: "YouTube", path: "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" },
12
+ };
13
+
14
+ const SVG_NS = "http://www.w3.org/2000/svg";
15
+
16
+ export const SOCIAL_NAMES = Object.keys(D);
17
+
18
+ /** Builds an inline <svg> for a brand name, or null when it is unknown. */
19
+ export function socialIcon(name, cls = "size-5") {
20
+ const icon = D[name];
21
+ if (!icon) return null;
22
+ const el = document.createElementNS(SVG_NS, "svg");
23
+ el.setAttribute("class", cls);
24
+ el.setAttribute("viewBox", "0 0 24 24");
25
+ el.setAttribute("fill", "currentColor");
26
+ el.setAttribute("role", "img");
27
+ el.setAttribute("aria-label", icon.title);
28
+ const path = document.createElementNS(SVG_NS, "path");
29
+ path.setAttribute("d", icon.path);
30
+ el.append(path);
31
+ return el;
32
+ }
@@ -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;