mevebu 1.10.0 → 2.0.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.
@@ -0,0 +1,215 @@
1
+ // Reorder, rotate and delete pages. Thumbnails come from pdf.js, the saved file
2
+ // from pdf-lib, and the two never touch the network.
3
+ import {
4
+ el, icon, toast, download, bench, stack, panel, button, actions, toolcard,
5
+ blank, notice,
6
+ } from "../core.js";
7
+ import { dropzone, baseName, formatBytes } from "../files.js";
8
+ import { loadPdfLib, loadPdfjs } from "../pdf-engine.js";
9
+
10
+ export function mount(slot) {
11
+ let file = null;
12
+ let source = null;
13
+ let pages = [];
14
+ let busy = false;
15
+ let token = 0;
16
+
17
+ const error = notice("warn");
18
+ const summary = el("p", { class: "text-sm text-ink-2" });
19
+ const grid = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(170px,1fr))]" });
20
+ const empty = blank("layout-grid", "Open a PDF to rearrange its pages.");
21
+
22
+ const drop = dropzone({ label: "Drop a PDF here", accept: "application/pdf,.pdf", iconName: "file-text" });
23
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
24
+ const saveButton = button("Save PDF", { icon: "download", kind: "fill" });
25
+
26
+ function reset() {
27
+ token++;
28
+ file = null;
29
+ source = null;
30
+ pages = [];
31
+ grid.replaceChildren();
32
+ summary.textContent = "";
33
+ error.write("");
34
+ empty.hidden = false;
35
+ saveButton.disabled = true;
36
+ clearButton.disabled = true;
37
+ }
38
+
39
+ function render() {
40
+ empty.hidden = pages.length > 0;
41
+ saveButton.disabled = pages.length === 0 || busy;
42
+ clearButton.disabled = pages.length === 0 || busy;
43
+ summary.textContent = file
44
+ ? `${file.name} · ${pages.length} page${pages.length === 1 ? "" : "s"} left · ${formatBytes(file.size)}`
45
+ : "";
46
+
47
+ const cards = pages.map((page, index) => {
48
+ const thumb = el("img", {
49
+ src: page.thumb,
50
+ alt: `Page ${page.original}`,
51
+ class: "w-full rounded-[5px] border border-orbit bg-deck transition-transform duration-150",
52
+ style: `transform: rotate(${page.rotation}deg)`,
53
+ });
54
+ const badge = el("span", {
55
+ class: "absolute top-1 start-1 rounded-full bg-ink px-1.5 py-0.5 text-2xs font-bold text-void tabular-nums",
56
+ }, String(index + 1));
57
+
58
+ const small = (label, iconName, extra, disabled, onclick) =>
59
+ el("button", {
60
+ class: `inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck disabled:opacity-40 ${extra}`,
61
+ type: "button",
62
+ "aria-label": label,
63
+ disabled,
64
+ onclick,
65
+ }, icon(iconName, "size-3.5"));
66
+
67
+ return el(
68
+ "figure",
69
+ {
70
+ class: "m-0 grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2",
71
+ draggable: "true",
72
+ ondragstart: (event) => {
73
+ event.dataTransfer.setData("text/plain", String(index));
74
+ event.dataTransfer.effectAllowed = "move";
75
+ },
76
+ ondragover: (event) => {
77
+ event.preventDefault();
78
+ event.dataTransfer.dropEffect = "move";
79
+ },
80
+ ondrop: (event) => {
81
+ event.preventDefault();
82
+ move(Number(event.dataTransfer.getData("text/plain")), index);
83
+ },
84
+ },
85
+ el("div", { class: "relative" }, thumb, badge),
86
+ el("div", { class: "grid gap-1.5" },
87
+ el("span", { class: "text-2xs text-ink-3 tabular-nums" }, `from ${page.original}`),
88
+ el("div", { class: "flex items-center justify-between gap-0.5" },
89
+ small(`Move page ${index + 1} left`, "chevron-right", "rotate-180 hover:text-ink", index === 0, () => move(index, index - 1)),
90
+ small(`Move page ${index + 1} right`, "chevron-right", "hover:text-ink", index === pages.length - 1, () => move(index, index + 1)),
91
+ small(`Rotate page ${index + 1}`, "rotate-cw", "hover:text-ink", false, () => rotate(index)),
92
+ small(`Delete page ${index + 1}`, "trash", "hover:text-danger", false, () => remove(index))))
93
+ );
94
+ });
95
+
96
+ grid.replaceChildren(...cards);
97
+ }
98
+
99
+ function move(from, to) {
100
+ if (!Number.isInteger(from) || from === to || from < 0 || from >= pages.length) return;
101
+ const [page] = pages.splice(from, 1);
102
+ pages.splice(Math.max(0, Math.min(pages.length, to)), 0, page);
103
+ render();
104
+ }
105
+
106
+ function rotate(index) {
107
+ const page = pages[index];
108
+ page.rotation = (page.rotation + 90) % 360;
109
+ render();
110
+ }
111
+
112
+ function remove(index) {
113
+ pages.splice(index, 1);
114
+ render();
115
+ }
116
+
117
+ async function open(files) {
118
+ const picked = Array.from(files).find((entry) => entry.type === "application/pdf" || /\.pdf$/i.test(entry.name));
119
+ if (!picked) {
120
+ error.write("That file is not a PDF.");
121
+ return;
122
+ }
123
+ reset();
124
+ const current = ++token;
125
+ summary.textContent = "Reading the PDF";
126
+ try {
127
+ const [pdfjs, lib] = await Promise.all([loadPdfjs(), loadPdfLib()]);
128
+ const bytes = new Uint8Array(await picked.arrayBuffer());
129
+ const doc = await pdfjs.getDocument({ data: bytes.slice(), isEvalSupported: false }).promise;
130
+ const editable = await lib.PDFDocument.load(bytes);
131
+ if (current !== token) return;
132
+ file = picked;
133
+ source = editable;
134
+
135
+ const thumbs = [];
136
+ for (let index = 0; index < doc.numPages; index++) {
137
+ const page = await doc.getPage(index + 1);
138
+ const viewport = page.getViewport({ scale: 0.35 });
139
+ const canvas = document.createElement("canvas");
140
+ canvas.width = Math.ceil(viewport.width);
141
+ canvas.height = Math.ceil(viewport.height);
142
+ await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
143
+ const rotation = editable.getPage(index).getRotation().angle % 360;
144
+ thumbs.push({ original: index + 1, rotation, thumb: canvas.toDataURL("image/png") });
145
+ summary.textContent = `Reading page ${index + 1} of ${doc.numPages}`;
146
+ }
147
+ if (current !== token) return;
148
+ pages = thumbs;
149
+ error.write("");
150
+ render();
151
+ } catch (caught) {
152
+ if (current !== token) return;
153
+ summary.textContent = "";
154
+ error.write(caught?.message || "This PDF could not be read.");
155
+ }
156
+ }
157
+
158
+ async function save() {
159
+ if (!source || !pages.length || busy) return;
160
+ busy = true;
161
+ const current = ++token;
162
+ saveButton.disabled = true;
163
+ summary.textContent = "Writing the PDF";
164
+ error.write("");
165
+ try {
166
+ const { PDFDocument, degrees } = await loadPdfLib();
167
+ const out = await PDFDocument.create();
168
+ const copied = await out.copyPages(source, pages.map((page) => page.original - 1));
169
+ copied.forEach((page, index) => {
170
+ page.setRotation(degrees(pages[index].rotation));
171
+ out.addPage(page);
172
+ });
173
+ const bytes = await out.save();
174
+ download(`${baseName(file.name)}-edited.pdf`, new Blob([bytes], { type: "application/pdf" }));
175
+ summary.textContent = `${out.getPageCount()} pages · ${formatBytes(bytes.length)}`;
176
+ toast("PDF saved");
177
+ } catch (caught) {
178
+ summary.textContent = "";
179
+ error.write(caught?.message || "Could not save the PDF.");
180
+ } finally {
181
+ if (current === token) {
182
+ busy = false;
183
+ render();
184
+ }
185
+ }
186
+ }
187
+
188
+ drop.onFiles(open);
189
+ saveButton.addEventListener("click", save);
190
+ clearButton.addEventListener("click", reset);
191
+ reset();
192
+
193
+ const shell = toolcard({
194
+ body: bench(
195
+ stack(
196
+ drop,
197
+ panel("How to use", el("p", { class: "m-0 text-sm leading-[1.6] text-ink-3" },
198
+ "Drag a page to move it, or use the arrows. The rotate button turns it 90 degrees clockwise. Delete removes it from the copy, never from your file.")),
199
+ error
200
+ ),
201
+ stack(summary, empty, grid)
202
+ ),
203
+ foot: actions(clearButton, saveButton),
204
+ });
205
+
206
+ slot.append(shell.node);
207
+
208
+ return {
209
+ unmount() {
210
+ token++;
211
+ },
212
+ };
213
+ }
214
+
215
+ export default { mount };
@@ -0,0 +1,198 @@
1
+ // One PDF into several. Split by ranges, by a fixed page count, or one file per
2
+ // page, then take the parts as a ZIP.
3
+ import {
4
+ el, toast, download, bench, stack, panel, numberInput, button, actions,
5
+ toolcard, blank, notice, chipRow,
6
+ } from "../core.js";
7
+ import { dropzone, baseName, formatBytes } from "../files.js";
8
+ import { loadPdfLib, readPdfLib, parsePageRange } from "../pdf-engine.js";
9
+ import { zip } from "../zip.js";
10
+
11
+ export function mount(slot) {
12
+ let file = null;
13
+ let doc = null;
14
+ let busy = false;
15
+ let token = 0;
16
+
17
+ let splitMode = "ranges";
18
+ const mode = chipRow(
19
+ [
20
+ { value: "ranges", label: "By range" },
21
+ { value: "every", label: "Every N pages" },
22
+ { value: "single", label: "One per page" },
23
+ ],
24
+ {
25
+ value: "ranges",
26
+ label: "Split mode",
27
+ onChange: (value) => {
28
+ splitMode = value;
29
+ sync();
30
+ },
31
+ }
32
+ );
33
+
34
+ const rangeInput = el("input", {
35
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
36
+ type: "text",
37
+ placeholder: "1-4, 5-8",
38
+ spellcheck: "false",
39
+ autocomplete: "off",
40
+ "aria-label": "Ranges",
41
+ });
42
+ const rangePanel = panel("Ranges", rangeInput, "One part per range, separated by commas.");
43
+
44
+ const every = numberInput({ min: "1", max: "500", step: "1", value: "2", "aria-label": "Pages per part" });
45
+ const everyPanel = panel("Pages per part", every);
46
+ everyPanel.hidden = true;
47
+
48
+ const error = notice("warn");
49
+ const summary = el("p", { class: "text-sm text-ink-2" });
50
+ const parts = el("div", { class: "grid gap-2" });
51
+ const empty = blank("file-text", "Open a PDF to split it.");
52
+
53
+ const drop = dropzone({ label: "Drop a PDF here", accept: "application/pdf,.pdf", iconName: "file-text" });
54
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
55
+ const splitButton = button("Split and download", { icon: "download", kind: "fill" });
56
+
57
+ function sync() {
58
+ rangePanel.hidden = splitMode !== "ranges";
59
+ everyPanel.hidden = splitMode !== "every";
60
+ }
61
+
62
+ function reset() {
63
+ token++;
64
+ file = null;
65
+ doc = null;
66
+ parts.replaceChildren();
67
+ summary.textContent = "";
68
+ error.write("");
69
+ empty.hidden = false;
70
+ splitButton.disabled = true;
71
+ clearButton.disabled = true;
72
+ }
73
+
74
+ async function open(files) {
75
+ const picked = Array.from(files).find((entry) => entry.type === "application/pdf" || /\.pdf$/i.test(entry.name));
76
+ if (!picked) {
77
+ error.write("That file is not a PDF.");
78
+ return;
79
+ }
80
+ reset();
81
+ const current = ++token;
82
+ summary.textContent = "Reading the PDF";
83
+ try {
84
+ const parsed = await readPdfLib(picked);
85
+ if (current !== token) return;
86
+ file = picked;
87
+ doc = parsed;
88
+ summary.textContent = `${picked.name} · ${parsed.getPageCount()} page${parsed.getPageCount() === 1 ? "" : "s"} · ${formatBytes(picked.size)}`;
89
+ empty.hidden = true;
90
+ splitButton.disabled = false;
91
+ clearButton.disabled = false;
92
+ error.write("");
93
+ } catch (caught) {
94
+ if (current !== token) return;
95
+ summary.textContent = "";
96
+ error.write(caught?.message || "This PDF could not be read.");
97
+ }
98
+ }
99
+
100
+ async function split() {
101
+ if (!doc || busy) return;
102
+ busy = true;
103
+ const current = ++token;
104
+ splitButton.disabled = true;
105
+ summary.textContent = "Splitting";
106
+ error.write("");
107
+ try {
108
+ const { PDFDocument } = await loadPdfLib();
109
+ const count = doc.getPageCount();
110
+ const base = baseName(file.name);
111
+ const groups = [];
112
+
113
+ if (splitMode === "every") {
114
+ const step = Math.max(1, Number(every.value) || 1);
115
+ for (let start = 0; start < count; start += step) {
116
+ groups.push({
117
+ label: `${start + 1}-${Math.min(count, start + step)}`,
118
+ pages: Array.from({ length: Math.min(step, count - start) }, (_, i) => start + i),
119
+ });
120
+ }
121
+ } else if (splitMode === "single") {
122
+ for (let index = 0; index < count; index++) groups.push({ label: `${index + 1}`, pages: [index] });
123
+ } else {
124
+ const text = rangeInput.value.trim();
125
+ if (!text) throw new Error("Write at least one range, for example 1-4, 5-8.");
126
+ for (const part of text.split(",")) {
127
+ const piece = part.trim();
128
+ if (!piece) continue;
129
+ const pages = parsePageRange(piece, count);
130
+ groups.push({ label: piece.replace(/\s+/g, ""), pages });
131
+ }
132
+ }
133
+
134
+ if (!groups.length) throw new Error("That split selects no pages.");
135
+
136
+ const files = [];
137
+ for (const [index, group] of groups.entries()) {
138
+ if (current !== token) return;
139
+ const out = await PDFDocument.create();
140
+ const copied = await out.copyPages(doc, group.pages);
141
+ for (const page of copied) out.addPage(page);
142
+ const bytes = await out.save();
143
+ files.push({ name: `${base}-${group.label}.pdf`, bytes });
144
+ summary.textContent = `Writing part ${index + 1} of ${groups.length}`;
145
+ }
146
+
147
+ const total = files.reduce((sum, entry) => sum + entry.bytes.length, 0);
148
+ download(`${base}-parts.zip`, zip(files), "application/zip");
149
+ summary.textContent = `${files.length} part${files.length === 1 ? "" : "s"} · ${formatBytes(total)}`;
150
+ parts.replaceChildren(
151
+ ...files.map((entry) =>
152
+ el("div", { class: "flex items-center justify-between gap-3 rounded-r1 border border-orbit bg-deck-2 px-3 py-2" },
153
+ el("span", { class: "truncate font-mono text-sm" }, entry.name),
154
+ el("span", { class: "flex-none text-2xs text-ink-3 tabular-nums" }, formatBytes(entry.bytes.length)))
155
+ )
156
+ );
157
+ toast("Parts ready");
158
+ } catch (caught) {
159
+ summary.textContent = "";
160
+ error.write(caught?.message || "Could not split this file.");
161
+ } finally {
162
+ if (current === token) {
163
+ busy = false;
164
+ splitButton.disabled = false;
165
+ }
166
+ }
167
+ }
168
+
169
+ drop.onFiles(open);
170
+ splitButton.addEventListener("click", split);
171
+ clearButton.addEventListener("click", reset);
172
+ sync();
173
+ reset();
174
+
175
+ const shell = toolcard({
176
+ body: bench(
177
+ stack(
178
+ drop,
179
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Split mode"), mode),
180
+ rangePanel,
181
+ everyPanel,
182
+ error
183
+ ),
184
+ stack(summary, empty, parts)
185
+ ),
186
+ foot: actions(clearButton, splitButton),
187
+ });
188
+
189
+ slot.append(shell.node);
190
+
191
+ return {
192
+ unmount() {
193
+ token++;
194
+ },
195
+ };
196
+ }
197
+
198
+ export default { mount };
@@ -0,0 +1,190 @@
1
+ // Every page of a PDF as its own image. pdf.js draws the page on a canvas, the
2
+ // canvas writes PNG or JPEG, and the set leaves as one ZIP.
3
+ import {
4
+ el, toast, download, bench, stack, panel, selectInput, numberInput, rangeInput,
5
+ button, actions, toolcard, blank, notice, chipRow,
6
+ } from "../core.js";
7
+ import { dropzone, canvasToBlob, baseName, formatBytes } from "../files.js";
8
+ import { readPdfjs, parsePageRange } from "../pdf-engine.js";
9
+ import { zip } from "../zip.js";
10
+
11
+ export function mount(slot) {
12
+ let file = null;
13
+ let pdf = null;
14
+ let token = 0;
15
+ let busy = false;
16
+
17
+ const range = el("input", {
18
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
19
+ type: "text",
20
+ placeholder: "all",
21
+ spellcheck: "false",
22
+ autocomplete: "off",
23
+ "aria-label": "Page range",
24
+ });
25
+ const format = selectInput(
26
+ [
27
+ { value: "png", label: "PNG" },
28
+ { value: "jpeg", label: "JPEG" },
29
+ ],
30
+ { value: "png", "aria-label": "Image format" }
31
+ );
32
+ const scale = rangeInput({ min: "1", max: "3", step: "0.5", value: "2", "aria-label": "Scale" });
33
+ const scaleLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "2x");
34
+ const quality = rangeInput({ min: "50", max: "100", step: "5", value: "88", "aria-label": "JPEG quality" });
35
+ const qualityLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "88");
36
+ const qualityRow = el("div", { class: "grid gap-1.5" }, quality, qualityLabel);
37
+
38
+ const error = notice("warn");
39
+ const summary = el("p", { class: "text-sm text-ink-2" });
40
+ const preview = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(150px,1fr))]" });
41
+ const empty = blank("file-text", "Open a PDF to render its pages.");
42
+
43
+ const drop = dropzone({ label: "Drop a PDF here", accept: "application/pdf,.pdf", iconName: "file-text" });
44
+
45
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
46
+ const downloadButton = button("Download ZIP", { icon: "download", kind: "fill" });
47
+
48
+ function sync() {
49
+ const jpeg = format.value === "jpeg";
50
+ qualityRow.hidden = !jpeg;
51
+ qualityLabel.textContent = `${quality.value}%`;
52
+ scaleLabel.textContent = `${Number(scale.value).toFixed(1).replace(/\.0$/, "")}x`;
53
+ }
54
+
55
+ function reset() {
56
+ token++;
57
+ file = null;
58
+ pdf = null;
59
+ preview.replaceChildren();
60
+ summary.textContent = "";
61
+ error.write("");
62
+ empty.hidden = false;
63
+ downloadButton.disabled = true;
64
+ clearButton.disabled = true;
65
+ }
66
+
67
+ async function open(files) {
68
+ const picked = Array.from(files).find((entry) => entry.type === "application/pdf" || /\.pdf$/i.test(entry.name));
69
+ if (!picked) {
70
+ error.write("That file is not a PDF.");
71
+ return;
72
+ }
73
+ reset();
74
+ const current = ++token;
75
+ summary.textContent = "Reading the PDF";
76
+ try {
77
+ const doc = await readPdfjs(picked);
78
+ if (current !== token) return;
79
+ file = picked;
80
+ pdf = doc;
81
+ summary.textContent = `${picked.name} · ${doc.numPages} page${doc.numPages === 1 ? "" : "s"} · ${formatBytes(picked.size)}`;
82
+ empty.hidden = true;
83
+ downloadButton.disabled = false;
84
+ clearButton.disabled = false;
85
+ error.write("");
86
+ await renderPreview();
87
+ } catch (caught) {
88
+ if (current !== token) return;
89
+ summary.textContent = "";
90
+ error.write(caught?.message || "This PDF could not be read.");
91
+ }
92
+ }
93
+
94
+ async function renderPreview() {
95
+ if (!pdf) return;
96
+ const current = token;
97
+ const pages = Math.min(pdf.numPages, 6);
98
+ const nodes = [];
99
+ for (let index = 0; index < pages; index++) {
100
+ const page = await pdf.getPage(index + 1);
101
+ if (current !== token) return;
102
+ const viewport = page.getViewport({ scale: 0.4 });
103
+ const canvas = document.createElement("canvas");
104
+ canvas.width = Math.ceil(viewport.width);
105
+ canvas.height = Math.ceil(viewport.height);
106
+ await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
107
+ nodes.push(
108
+ el("figure", { class: "m-0 grid gap-1.5" },
109
+ el("img", { src: canvas.toDataURL("image/png"), alt: `Page ${index + 1}`, class: "w-full rounded-r1 border border-orbit bg-deck" }),
110
+ el("figcaption", { class: "text-2xs text-ink-3" }, `Page ${index + 1}`))
111
+ );
112
+ }
113
+ preview.replaceChildren(...nodes);
114
+ }
115
+
116
+ async function run() {
117
+ if (!pdf || busy) return;
118
+ busy = true;
119
+ const current = ++token;
120
+ downloadButton.disabled = true;
121
+ summary.textContent = "Rendering pages";
122
+ error.write("");
123
+ try {
124
+ const indexes = parsePageRange(range.value, pdf.numPages);
125
+ const type = format.value === "jpeg" ? "image/jpeg" : "image/png";
126
+ const ext = format.value === "jpeg" ? "jpg" : "png";
127
+ const base = baseName(file.name);
128
+ const files = [];
129
+ for (const [position, index] of indexes.entries()) {
130
+ if (current !== token) return;
131
+ const page = await pdf.getPage(index + 1);
132
+ const viewport = page.getViewport({ scale: Number(scale.value) });
133
+ const canvas = document.createElement("canvas");
134
+ canvas.width = Math.ceil(viewport.width);
135
+ canvas.height = Math.ceil(viewport.height);
136
+ await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
137
+ const blob = await canvasToBlob(canvas, type, type === "image/jpeg" ? Number(quality.value) / 100 : undefined);
138
+ const pageNumber = String(index + 1).padStart(String(pdf.numPages).length, "0");
139
+ files.push({ name: `${base}-page-${pageNumber}.${ext}`, bytes: new Uint8Array(await blob.arrayBuffer()) });
140
+ summary.textContent = `Rendering page ${position + 1} of ${indexes.length}`;
141
+ }
142
+ const total = files.reduce((sum, entry) => sum + entry.bytes.length, 0);
143
+ download(`${base}-images.zip`, zip(files), "application/zip");
144
+ summary.textContent = `${files.length} image${files.length === 1 ? "" : "s"} · ${formatBytes(total)}`;
145
+ toast("Images ready");
146
+ } catch (caught) {
147
+ summary.textContent = "";
148
+ error.write(caught?.message || "Could not render the pages.");
149
+ } finally {
150
+ if (current === token) {
151
+ busy = false;
152
+ downloadButton.disabled = false;
153
+ }
154
+ }
155
+ }
156
+
157
+ drop.onFiles(open);
158
+ format.addEventListener("change", sync);
159
+ scale.addEventListener("input", sync);
160
+ quality.addEventListener("input", sync);
161
+ downloadButton.addEventListener("click", run);
162
+ clearButton.addEventListener("click", reset);
163
+ sync();
164
+ reset();
165
+
166
+ const shell = toolcard({
167
+ body: bench(
168
+ stack(
169
+ drop,
170
+ panel("Pages", range, "1-3, 7, 10- . Empty means every page."),
171
+ panel("Format", format),
172
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Scale"), scale, scaleLabel),
173
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "JPEG quality"), qualityRow),
174
+ error
175
+ ),
176
+ stack(summary, empty, preview)
177
+ ),
178
+ foot: actions(clearButton, downloadButton),
179
+ });
180
+
181
+ slot.append(shell.node);
182
+
183
+ return {
184
+ unmount() {
185
+ token++;
186
+ },
187
+ };
188
+ }
189
+
190
+ export default { mount };