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.
- package/README.md +17 -21
- package/mevebu/app/mevebu.js +1 -1
- package/mevebu/app/tailwind.css +110 -17
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/csv.js +87 -0
- package/mevebu/libs/js/files.js +125 -0
- package/mevebu/libs/js/icons.js +2 -1
- package/mevebu/libs/js/pdf-engine.js +67 -0
- package/mevebu/libs/js/registry.js +253 -37
- package/mevebu/libs/js/tools/audio-converter.js +228 -0
- package/mevebu/libs/js/tools/csv-viewer.js +1 -51
- package/mevebu/libs/js/tools/data-dictionary.js +204 -0
- package/mevebu/libs/js/tools/image-to-pdf.js +251 -0
- package/mevebu/libs/js/tools/pdf-merge.js +158 -0
- package/mevebu/libs/js/tools/pdf-organizer.js +215 -0
- package/mevebu/libs/js/tools/pdf-split.js +198 -0
- package/mevebu/libs/js/tools/pdf-to-image.js +190 -0
- package/mevebu/libs/js/tools/pdf-to-text.js +172 -0
- package/mevebu/libs/js/tools/roster-generator.js +195 -0
- package/mevebu/libs/js/tools/sample-csv.js +252 -0
- package/mevebu/libs/js/tools/sql-insert.js +184 -0
- package/mevebu/libs/js/tools/svg-to-png.js +207 -0
- package/mevebu/libs/js/tools/team-generator.js +203 -0
- package/mevebu/libs/js/tools/text-to-pdf.js +182 -0
- package/mevebu/libs/js/tools/video-converter.js +316 -0
- package/package.json +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +0 -78
- package/mevebu/libs/js/tools/name-picker.js +0 -68
- package/mevebu/libs/js/tools/spin-wheel.js +0 -168
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Plain text into a PDF with real page breaks. Lines are wrapped against the
|
|
2
|
+
// chosen font's own metrics, so nothing runs off the margin.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, textArea, textInput, selectInput,
|
|
5
|
+
numberInput, button, actions, toolcard, notice, checkbox,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { loadPdfLib } from "../pdf-engine.js";
|
|
8
|
+
import { formatBytes } from "../files.js";
|
|
9
|
+
|
|
10
|
+
const PAGES = {
|
|
11
|
+
a4: { label: "A4", width: 595.28, height: 841.89 },
|
|
12
|
+
letter: { label: "Letter", width: 612, height: 792 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SAMPLE = `mevebu
|
|
16
|
+
|
|
17
|
+
Small tools that run in your browser.
|
|
18
|
+
No account, no upload, no tracking.
|
|
19
|
+
|
|
20
|
+
Paste your own text here, then press Build PDF.`;
|
|
21
|
+
|
|
22
|
+
export function mount(slot) {
|
|
23
|
+
let busy = false;
|
|
24
|
+
let token = 0;
|
|
25
|
+
|
|
26
|
+
const body = textArea({ rows: 14, value: SAMPLE, "aria-label": "Text" });
|
|
27
|
+
const title = textInput({ placeholder: "Optional title", "aria-label": "Title" });
|
|
28
|
+
const size = selectInput(
|
|
29
|
+
Object.entries(PAGES).map(([value, page]) => ({ value, label: page.label })),
|
|
30
|
+
{ value: "a4", "aria-label": "Page size" }
|
|
31
|
+
);
|
|
32
|
+
const margin = numberInput({ min: "18", max: "108", step: "6", value: "54", "aria-label": "Margin in points" });
|
|
33
|
+
const fontSize = numberInput({ min: "8", max: "24", step: "1", value: "11", "aria-label": "Font size" });
|
|
34
|
+
const leading = numberInput({ min: "1", max: "2.5", step: "0.1", value: "1.5", "aria-label": "Line height" });
|
|
35
|
+
const numbers = checkbox("Page numbers", { checked: true });
|
|
36
|
+
|
|
37
|
+
const error = notice("warn");
|
|
38
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
39
|
+
const buildButton = button("Build PDF", { icon: "file-text", kind: "fill" });
|
|
40
|
+
|
|
41
|
+
function wrap(text, font, maxWidth, sizeValue) {
|
|
42
|
+
const lines = [];
|
|
43
|
+
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
44
|
+
if (!raw.trim()) {
|
|
45
|
+
lines.push("");
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const words = raw.split(/\s+/);
|
|
49
|
+
let line = "";
|
|
50
|
+
for (const word of words) {
|
|
51
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
52
|
+
if (font.widthOfTextAtSize(candidate, sizeValue) <= maxWidth) {
|
|
53
|
+
line = candidate;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line) lines.push(line);
|
|
57
|
+
// A single word longer than the line is cut by character.
|
|
58
|
+
if (font.widthOfTextAtSize(word, sizeValue) <= maxWidth) {
|
|
59
|
+
line = word;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let piece = "";
|
|
63
|
+
for (const char of word) {
|
|
64
|
+
if (font.widthOfTextAtSize(piece + char, sizeValue) > maxWidth) {
|
|
65
|
+
lines.push(piece);
|
|
66
|
+
piece = char;
|
|
67
|
+
} else piece += char;
|
|
68
|
+
}
|
|
69
|
+
line = piece;
|
|
70
|
+
}
|
|
71
|
+
lines.push(line);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function build() {
|
|
77
|
+
const text = body.value;
|
|
78
|
+
if (!text.trim() || busy) {
|
|
79
|
+
error.write("Write something first.");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
busy = true;
|
|
83
|
+
const current = ++token;
|
|
84
|
+
buildButton.disabled = true;
|
|
85
|
+
summary.textContent = "Building the PDF";
|
|
86
|
+
error.write("");
|
|
87
|
+
try {
|
|
88
|
+
const { PDFDocument, StandardFonts, rgb } = await loadPdfLib();
|
|
89
|
+
const doc = await PDFDocument.create();
|
|
90
|
+
const font = await doc.embedFont(StandardFonts.Helvetica);
|
|
91
|
+
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
|
|
92
|
+
const page = PAGES[size.value] ?? PAGES.a4;
|
|
93
|
+
const inset = Math.max(0, Number(margin.value) || 54);
|
|
94
|
+
const sizeValue = Math.max(6, Number(fontSize.value) || 11);
|
|
95
|
+
const step = sizeValue * Math.max(1, Number(leading.value) || 1.5);
|
|
96
|
+
const maxWidth = page.width - inset * 2;
|
|
97
|
+
|
|
98
|
+
let sheet = doc.addPage([page.width, page.height]);
|
|
99
|
+
let y = page.height - inset;
|
|
100
|
+
|
|
101
|
+
const heading = title.value.trim();
|
|
102
|
+
if (heading) {
|
|
103
|
+
sheet.drawText(heading, { x: inset, y: y - sizeValue * 1.6, size: sizeValue * 1.6, font: bold, color: rgb(0.05, 0.05, 0.05) });
|
|
104
|
+
y -= sizeValue * 1.6 + step * 0.8;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const line of wrap(text, font, maxWidth, sizeValue)) {
|
|
108
|
+
if (y - step < inset) {
|
|
109
|
+
sheet = doc.addPage([page.width, page.height]);
|
|
110
|
+
y = page.height - inset;
|
|
111
|
+
}
|
|
112
|
+
if (line) {
|
|
113
|
+
sheet.drawText(line, { x: inset, y: y - sizeValue, size: sizeValue, font, color: rgb(0.1, 0.1, 0.1) });
|
|
114
|
+
}
|
|
115
|
+
y -= step;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (numbers.input.checked) {
|
|
119
|
+
const total = doc.getPageCount();
|
|
120
|
+
doc.getPages().forEach((pageNode, index) => {
|
|
121
|
+
const label = `${index + 1} / ${total}`;
|
|
122
|
+
const width = font.widthOfTextAtSize(label, 8);
|
|
123
|
+
pageNode.drawText(label, {
|
|
124
|
+
x: (page.width - width) / 2,
|
|
125
|
+
y: inset / 2,
|
|
126
|
+
size: 8,
|
|
127
|
+
font,
|
|
128
|
+
color: rgb(0.45, 0.45, 0.45),
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const bytes = await doc.save();
|
|
134
|
+
const name = `${(heading || "document").replace(/[^\w -]+/g, "").trim().replace(/\s+/g, "-").toLowerCase() || "document"}.pdf`;
|
|
135
|
+
download(name, new Blob([bytes], { type: "application/pdf" }));
|
|
136
|
+
summary.textContent = `${doc.getPageCount()} page${doc.getPageCount() === 1 ? "" : "s"} · ${formatBytes(bytes.length)}`;
|
|
137
|
+
toast("PDF ready");
|
|
138
|
+
} catch (caught) {
|
|
139
|
+
summary.textContent = "";
|
|
140
|
+
error.write(caught?.message || "Could not build the PDF.");
|
|
141
|
+
} finally {
|
|
142
|
+
if (current === token) {
|
|
143
|
+
busy = false;
|
|
144
|
+
buildButton.disabled = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
buildButton.addEventListener("click", build);
|
|
150
|
+
|
|
151
|
+
const shell = toolcard({
|
|
152
|
+
body: bench(
|
|
153
|
+
stack(
|
|
154
|
+
panel("Text", body),
|
|
155
|
+
panel("Title", title, "Optional. Sits at the top of the first page."),
|
|
156
|
+
panel("Page size", size),
|
|
157
|
+
el("div", { class: "grid grid-cols-2 gap-3" },
|
|
158
|
+
panel("Margin", margin),
|
|
159
|
+
panel("Font size", fontSize)),
|
|
160
|
+
panel("Line height", leading),
|
|
161
|
+
numbers,
|
|
162
|
+
error
|
|
163
|
+
),
|
|
164
|
+
stack(summary,
|
|
165
|
+
el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-4 text-sm leading-[1.6] text-ink-2" },
|
|
166
|
+
el("p", { class: "m-0 font-semibold text-ink" }, "What you get"),
|
|
167
|
+
el("p", { class: "m-0" }, "A PDF with selectable text, wrapped to the margin, with real page breaks."),
|
|
168
|
+
el("p", { class: "m-0 text-ink-3" }, "The font is Helvetica, which every PDF reader already has, so the file stays small.")))
|
|
169
|
+
),
|
|
170
|
+
foot: actions(buildButton),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
slot.append(shell.node);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
unmount() {
|
|
177
|
+
token++;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default { mount };
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Video to WebM, or just its audio. The file plays into a canvas and the canvas
|
|
2
|
+
// is recorded, so the conversion runs at real time and needs no WebAssembly
|
|
3
|
+
// encoder. A thirty second clip takes about thirty seconds.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, download, bench, stack, panel, selectInput, rangeInput, button,
|
|
6
|
+
actions, toolcard, blank, notice, checkbox,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
9
|
+
|
|
10
|
+
const HEIGHTS = [
|
|
11
|
+
{ value: "keep", label: "Keep" },
|
|
12
|
+
{ value: "1080", label: "1080p" },
|
|
13
|
+
{ value: "720", label: "720p" },
|
|
14
|
+
{ value: "480", label: "480p" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function pickMime(preferred) {
|
|
18
|
+
const candidates = preferred === "audio"
|
|
19
|
+
? ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"]
|
|
20
|
+
: ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"];
|
|
21
|
+
for (const type of candidates) {
|
|
22
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(type)) return type;
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function mount(slot) {
|
|
28
|
+
let file = null;
|
|
29
|
+
let url = "";
|
|
30
|
+
let video = null;
|
|
31
|
+
let recorder = null;
|
|
32
|
+
let stream = null;
|
|
33
|
+
let audioContext = null;
|
|
34
|
+
let frame = 0;
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
let busy = false;
|
|
37
|
+
let token = 0;
|
|
38
|
+
|
|
39
|
+
const output = selectInput(
|
|
40
|
+
[
|
|
41
|
+
{ value: "video", label: "WebM video" },
|
|
42
|
+
{ value: "audio", label: "Audio only (Opus)" },
|
|
43
|
+
],
|
|
44
|
+
{ value: "video", "aria-label": "Output" }
|
|
45
|
+
);
|
|
46
|
+
const height = selectInput(HEIGHTS, { value: "720", "aria-label": "Resolution" });
|
|
47
|
+
const bitrate = rangeInput({ min: "1", max: "10", step: "0.5", value: "4", "aria-label": "Bitrate" });
|
|
48
|
+
const bitrateLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "4 Mbps");
|
|
49
|
+
const bitratePanel = el("div", { class: "grid gap-1.5" },
|
|
50
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Bitrate"),
|
|
51
|
+
bitrate,
|
|
52
|
+
bitrateLabel);
|
|
53
|
+
const keepAudio = checkbox("Include audio", { checked: true });
|
|
54
|
+
|
|
55
|
+
const error = notice("warn");
|
|
56
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
57
|
+
const progress = el("div", { class: "h-2 overflow-hidden rounded-full bg-deck-2" },
|
|
58
|
+
el("div", { class: "h-full w-0 rounded-full bg-jade transition-[width] duration-150" }));
|
|
59
|
+
const stage = el("div", { class: "grid place-items-center overflow-hidden rounded-r2 border border-orbit bg-deck-2 p-3" });
|
|
60
|
+
const empty = blank("video", "Open a video to convert it.");
|
|
61
|
+
const progressBox = el("div", { class: "grid gap-1.5" }, progress);
|
|
62
|
+
|
|
63
|
+
const drop = dropzone({ label: "Drop a video here", accept: "video/*", iconName: "video" });
|
|
64
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
65
|
+
const cancelButton = button("Cancel", { icon: "x", kind: "quiet" });
|
|
66
|
+
const convertButton = button("Convert", { icon: "download", kind: "fill" });
|
|
67
|
+
|
|
68
|
+
function sync() {
|
|
69
|
+
bitrateLabel.textContent = `${Number(bitrate.value).toFixed(1).replace(/\.0$/, "")} Mbps`;
|
|
70
|
+
const audioOnly = output.value === "audio";
|
|
71
|
+
height.disabled = audioOnly;
|
|
72
|
+
keepAudio.hidden = audioOnly;
|
|
73
|
+
bitratePanel.hidden = audioOnly;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function reset() {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
token++;
|
|
79
|
+
cancelAnimationFrame(frame);
|
|
80
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
81
|
+
recorder = null;
|
|
82
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
83
|
+
stream = null;
|
|
84
|
+
audioContext?.close().catch(() => {});
|
|
85
|
+
audioContext = null;
|
|
86
|
+
if (url) URL.revokeObjectURL(url);
|
|
87
|
+
url = "";
|
|
88
|
+
file = null;
|
|
89
|
+
video = null;
|
|
90
|
+
stage.replaceChildren();
|
|
91
|
+
stage.hidden = true;
|
|
92
|
+
progress.firstElementChild.style.width = "0%";
|
|
93
|
+
progressBox.hidden = true;
|
|
94
|
+
summary.textContent = "";
|
|
95
|
+
error.write("");
|
|
96
|
+
empty.hidden = false;
|
|
97
|
+
convertButton.disabled = true;
|
|
98
|
+
cancelButton.hidden = true;
|
|
99
|
+
clearButton.disabled = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function open(files) {
|
|
103
|
+
const picked = Array.from(files).find((entry) => entry.type.startsWith("video/") || /\.(mp4|webm|mov|m4v|ogv|mkv)$/i.test(entry.name));
|
|
104
|
+
if (!picked) {
|
|
105
|
+
error.write("That file is not a video.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
reset();
|
|
109
|
+
const current = ++token;
|
|
110
|
+
summary.textContent = "Reading the video";
|
|
111
|
+
url = URL.createObjectURL(picked);
|
|
112
|
+
const element = el("video", {
|
|
113
|
+
src: url,
|
|
114
|
+
class: "max-h-[320px] w-auto max-w-full rounded-r1 border border-orbit",
|
|
115
|
+
playsinline: "",
|
|
116
|
+
preload: "metadata",
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
element.onloadedmetadata = resolve;
|
|
121
|
+
element.onerror = () => reject(new Error("This video could not be read by the browser."));
|
|
122
|
+
});
|
|
123
|
+
if (current !== token) return;
|
|
124
|
+
file = picked;
|
|
125
|
+
video = element;
|
|
126
|
+
stage.replaceChildren(element);
|
|
127
|
+
stage.hidden = false;
|
|
128
|
+
empty.hidden = true;
|
|
129
|
+
convertButton.disabled = false;
|
|
130
|
+
clearButton.disabled = false;
|
|
131
|
+
error.write("");
|
|
132
|
+
const ratio = element.videoWidth && element.videoHeight ? `${element.videoWidth} x ${element.videoHeight}` : "unknown size";
|
|
133
|
+
summary.textContent = `${picked.name} · ${ratio} · ${element.duration.toFixed(1)} s · ${formatBytes(picked.size)}`;
|
|
134
|
+
if (!element.captureStream && !element.mozCaptureStream) {
|
|
135
|
+
error.write("This browser cannot capture a video element, so conversion is unavailable here.");
|
|
136
|
+
}
|
|
137
|
+
} catch (caught) {
|
|
138
|
+
if (current !== token) return;
|
|
139
|
+
summary.textContent = "";
|
|
140
|
+
error.write(caught?.message || "This video could not be read.");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function targetSize(sourceWidth, sourceHeight) {
|
|
145
|
+
const wanted = height.value === "keep" ? sourceHeight : Number(height.value);
|
|
146
|
+
const scale = Math.min(1, wanted / sourceHeight);
|
|
147
|
+
const even = (value) => Math.max(2, Math.round(value / 2) * 2);
|
|
148
|
+
return { width: even(sourceWidth * scale), height: even(sourceHeight * scale) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function convert() {
|
|
152
|
+
if (!video || busy) return;
|
|
153
|
+
const mime = pickMime(output.value);
|
|
154
|
+
if (!mime) {
|
|
155
|
+
error.write("This browser has no MediaRecorder support for WebM.");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
busy = true;
|
|
159
|
+
cancelled = false;
|
|
160
|
+
const current = ++token;
|
|
161
|
+
convertButton.disabled = true;
|
|
162
|
+
clearButton.disabled = true;
|
|
163
|
+
cancelButton.hidden = false;
|
|
164
|
+
progressBox.hidden = false;
|
|
165
|
+
error.write("");
|
|
166
|
+
summary.textContent = output.value === "audio" ? "Recording audio" : "Recording video";
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const tracks = [];
|
|
170
|
+
let canvas = null;
|
|
171
|
+
|
|
172
|
+
if (output.value === "video") {
|
|
173
|
+
const { width, height: targetHeight } = targetSize(video.videoWidth || 1280, video.videoHeight || 720);
|
|
174
|
+
canvas = document.createElement("canvas");
|
|
175
|
+
canvas.width = width;
|
|
176
|
+
canvas.height = targetHeight;
|
|
177
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
178
|
+
const draw = () => {
|
|
179
|
+
if (cancelled) return;
|
|
180
|
+
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
181
|
+
frame = requestAnimationFrame(draw);
|
|
182
|
+
};
|
|
183
|
+
draw();
|
|
184
|
+
stream = canvas.captureStream(30);
|
|
185
|
+
tracks.push(...stream.getVideoTracks());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (keepAudio.input.checked || output.value === "audio") {
|
|
189
|
+
audioContext = new AudioContext();
|
|
190
|
+
const source = audioContext.createMediaElementSource(video);
|
|
191
|
+
const destination = audioContext.createMediaStreamDestination();
|
|
192
|
+
source.connect(destination);
|
|
193
|
+
source.connect(audioContext.destination);
|
|
194
|
+
tracks.push(...destination.stream.getAudioTracks());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const combined = new MediaStream(tracks);
|
|
198
|
+
const kbps = Math.round(Number(bitrate.value) * 1000 * 1000);
|
|
199
|
+
recorder = new MediaRecorder(combined, {
|
|
200
|
+
mimeType: mime,
|
|
201
|
+
videoBitsPerSecond: output.value === "video" ? kbps : undefined,
|
|
202
|
+
audioBitsPerSecond: 128000,
|
|
203
|
+
});
|
|
204
|
+
const chunks = [];
|
|
205
|
+
recorder.ondataavailable = (event) => {
|
|
206
|
+
if (event.data.size) chunks.push(event.data);
|
|
207
|
+
};
|
|
208
|
+
const done = new Promise((resolve) => {
|
|
209
|
+
recorder.onstop = resolve;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
video.currentTime = 0;
|
|
213
|
+
video.muted = false;
|
|
214
|
+
await video.play();
|
|
215
|
+
recorder.start(250);
|
|
216
|
+
|
|
217
|
+
await new Promise((resolve) => {
|
|
218
|
+
const tick = () => {
|
|
219
|
+
if (cancelled) return resolve();
|
|
220
|
+
const ratio = video.duration ? Math.min(1, video.currentTime / video.duration) : 0;
|
|
221
|
+
progress.firstElementChild.style.width = `${Math.round(ratio * 100)}%`;
|
|
222
|
+
summary.textContent = `${output.value === "audio" ? "Recording audio" : "Recording video"} · ${video.currentTime.toFixed(1)} / ${video.duration.toFixed(1)} s`;
|
|
223
|
+
if (video.ended || video.paused) return resolve();
|
|
224
|
+
frame = requestAnimationFrame(tick);
|
|
225
|
+
};
|
|
226
|
+
video.onended = resolve;
|
|
227
|
+
tick();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
cancelAnimationFrame(frame);
|
|
231
|
+
video.pause();
|
|
232
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
233
|
+
await done;
|
|
234
|
+
if (current !== token) return;
|
|
235
|
+
|
|
236
|
+
const blob = new Blob(chunks, { type: mime.split(";")[0] });
|
|
237
|
+
if (!blob.size) throw new Error("The recorder produced no data.");
|
|
238
|
+
const ext = output.value === "audio" ? "opus.webm" : "webm";
|
|
239
|
+
download(`${baseName(file.name)}.${ext}`, blob);
|
|
240
|
+
progress.firstElementChild.style.width = "100%";
|
|
241
|
+
summary.textContent = `${formatBytes(blob.size)} · ${output.value === "audio" ? "audio" : `${canvas.width} x ${canvas.height}`}`;
|
|
242
|
+
toast("WebM ready");
|
|
243
|
+
} catch (caught) {
|
|
244
|
+
summary.textContent = "";
|
|
245
|
+
error.write(caught?.message || "Could not convert this video.");
|
|
246
|
+
} finally {
|
|
247
|
+
cancelAnimationFrame(frame);
|
|
248
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
249
|
+
stream = null;
|
|
250
|
+
audioContext?.close().catch(() => {});
|
|
251
|
+
audioContext = null;
|
|
252
|
+
if (current === token) {
|
|
253
|
+
busy = false;
|
|
254
|
+
convertButton.disabled = false;
|
|
255
|
+
clearButton.disabled = false;
|
|
256
|
+
cancelButton.hidden = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function cancel() {
|
|
262
|
+
cancelled = true;
|
|
263
|
+
token++;
|
|
264
|
+
cancelAnimationFrame(frame);
|
|
265
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
266
|
+
if (video) video.pause();
|
|
267
|
+
summary.textContent = "Cancelled";
|
|
268
|
+
progressBox.hidden = true;
|
|
269
|
+
cancelButton.hidden = true;
|
|
270
|
+
convertButton.disabled = false;
|
|
271
|
+
clearButton.disabled = false;
|
|
272
|
+
busy = false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
drop.onFiles(open);
|
|
276
|
+
output.addEventListener("change", sync);
|
|
277
|
+
bitrate.addEventListener("input", sync);
|
|
278
|
+
convertButton.addEventListener("click", convert);
|
|
279
|
+
cancelButton.addEventListener("click", cancel);
|
|
280
|
+
clearButton.addEventListener("click", reset);
|
|
281
|
+
sync();
|
|
282
|
+
reset();
|
|
283
|
+
|
|
284
|
+
const shell = toolcard({
|
|
285
|
+
body: bench(
|
|
286
|
+
stack(
|
|
287
|
+
drop,
|
|
288
|
+
panel("Output", output),
|
|
289
|
+
panel("Resolution", height),
|
|
290
|
+
bitratePanel,
|
|
291
|
+
keepAudio,
|
|
292
|
+
error,
|
|
293
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
294
|
+
"Recording happens at real time: a two minute clip takes about two minutes. Keep the tab in front while it runs.")
|
|
295
|
+
),
|
|
296
|
+
stack(summary, empty, stage, progressBox)
|
|
297
|
+
),
|
|
298
|
+
foot: actions(clearButton, cancelButton, convertButton),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
slot.append(shell.node);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
unmount() {
|
|
305
|
+
cancelled = true;
|
|
306
|
+
token++;
|
|
307
|
+
cancelAnimationFrame(frame);
|
|
308
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
309
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
310
|
+
audioContext?.close().catch(() => {});
|
|
311
|
+
if (url) URL.revokeObjectURL(url);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default { mount };
|
package/package.json
CHANGED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// Heads or tails with a running count that survives a page change.
|
|
2
|
-
import { el, button, bench, stack, panel, actions, readPin, writePin, randomInt, toolcard } from "../core.js";
|
|
3
|
-
|
|
4
|
-
// A toss is one flip: a click while the coin is still turning replaces the turn
|
|
5
|
-
// in progress instead of queueing another one behind it.
|
|
6
|
-
const TOSS_MS = 400;
|
|
7
|
-
|
|
8
|
-
/** A tally tile. The value is the node the tool writes to. */
|
|
9
|
-
function tile(key) {
|
|
10
|
-
const value = el("span", { class: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, "0");
|
|
11
|
-
const row = el("div", { class: "grid gap-0.5 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" }, value, el("span", { class: "text-2xs font-semibold text-ink-3" }, key));
|
|
12
|
-
return { row, value };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function mount(slot) {
|
|
16
|
-
const PIN = "coin-flip";
|
|
17
|
-
const saved = readPin(PIN, { heads: 0, tails: 0 });
|
|
18
|
-
let heads = Number(saved.heads) || 0;
|
|
19
|
-
let tails = Number(saved.tails) || 0;
|
|
20
|
-
let timer = 0;
|
|
21
|
-
|
|
22
|
-
const coin = el("div", { class: "grid size-32 select-none place-items-center rounded-full bg-jade text-display font-bold text-on-jade data-[toss]:animate-toss", role: "img", "aria-label": "Coin, not flipped yet" }, "?");
|
|
23
|
-
const verdict = el("p", { class: "text-lg font-bold tracking-[-0.01em] min-h-7", "aria-live": "polite" });
|
|
24
|
-
const headsTile = tile("Heads");
|
|
25
|
-
const tailsTile = tile("Tails");
|
|
26
|
-
const totalTile = tile("Total");
|
|
27
|
-
const stats = el("div", { class: "grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))]" }, headsTile.row, tailsTile.row, totalTile.row);
|
|
28
|
-
|
|
29
|
-
function render(decision) {
|
|
30
|
-
verdict.textContent = decision;
|
|
31
|
-
coin.setAttribute("aria-label", decision ? `Coin: ${decision.toLowerCase()}` : "Coin, not flipped yet");
|
|
32
|
-
headsTile.value.textContent = String(heads);
|
|
33
|
-
tailsTile.value.textContent = String(tails);
|
|
34
|
-
totalTile.value.textContent = String(heads + tails);
|
|
35
|
-
writePin(PIN, { heads, tails });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const flipButton = button("Flip", { kind: "fill" });
|
|
39
|
-
const resetButton = button("Reset the count", { kind: "quiet" });
|
|
40
|
-
|
|
41
|
-
flipButton.addEventListener("click", () => {
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
delete coin.dataset.toss;
|
|
44
|
-
void coin.offsetWidth;
|
|
45
|
-
coin.dataset.toss = "";
|
|
46
|
-
timer = setTimeout(() => {
|
|
47
|
-
const isHeads = randomInt(2) === 0;
|
|
48
|
-
if (isHeads) heads++;
|
|
49
|
-
else tails++;
|
|
50
|
-
coin.textContent = isHeads ? "H" : "T";
|
|
51
|
-
render(isHeads ? "Heads" : "Tails");
|
|
52
|
-
}, TOSS_MS);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
resetButton.addEventListener("click", () => {
|
|
56
|
-
clearTimeout(timer);
|
|
57
|
-
heads = 0;
|
|
58
|
-
tails = 0;
|
|
59
|
-
coin.textContent = "?";
|
|
60
|
-
render("");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const card = toolcard({
|
|
64
|
-
body: bench(
|
|
65
|
-
stack(panel("Coin", coin)),
|
|
66
|
-
stack(panel("Result", verdict), stats)
|
|
67
|
-
),
|
|
68
|
-
foot: actions(resetButton, flipButton)});
|
|
69
|
-
slot.append(card.node);
|
|
70
|
-
render("");
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
unmount() {
|
|
74
|
-
clearTimeout(timer);
|
|
75
|
-
}};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export default { mount };
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Draws one or more names from a list, every entry with equal odds.
|
|
2
|
-
import { el, toast, copyText, shuffle, randomInt, debounce, bench, stack, panel, textArea, numberInput, checkbox, button, output, readPin, writePin, actions, toolcard } from "../core.js";
|
|
3
|
-
|
|
4
|
-
export function mount(slot) {
|
|
5
|
-
const PIN = "name-picker";
|
|
6
|
-
const saved = readPin(PIN, { entries: "Ana\nBudi\nCitra\nDimas\nElena\nFajar\nGita\nHendra", winners: "1", unique: true });
|
|
7
|
-
|
|
8
|
-
const listField = textArea({ value: saved.entries ?? "", rows: 8, "aria-label": "Entries, one per line", placeholder: "One entry per line" });
|
|
9
|
-
const countField = numberInput({ value: saved.winners ?? "1", min: "1", max: "500", "aria-label": "How many to draw" });
|
|
10
|
-
const uniqueBox = checkbox("No repeats", { checked: saved.unique !== false });
|
|
11
|
-
const notify = el("p", { class: "border-s-[3px] border-transparent ps-2 text-sm text-ink-3" });
|
|
12
|
-
|
|
13
|
-
const drawButton = button("Draw", { icon: "play", kind: "fill" });
|
|
14
|
-
const copyButton = button("Copy", { icon: "copy" });
|
|
15
|
-
const result = output();
|
|
16
|
-
let current = [];
|
|
17
|
-
|
|
18
|
-
function entries() {
|
|
19
|
-
const raw = listField.value.includes(",") && !listField.value.includes("\n")
|
|
20
|
-
? listField.value.split(",")
|
|
21
|
-
: listField.value.split(/\n+/);
|
|
22
|
-
return raw.map((line) => line.trim()).filter(Boolean);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function draw(syncInput = false) {
|
|
26
|
-
const list = entries();
|
|
27
|
-
if (!list.length) {
|
|
28
|
-
current = [];
|
|
29
|
-
result.write("");
|
|
30
|
-
notify.textContent = "Add entries to draw.";
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const wants = Math.max(1, parseInt(countField.value || "1", 10) || 1);
|
|
34
|
-
const total = uniqueBox.input.checked ? Math.min(wants, list.length) : Math.min(wants, 500);
|
|
35
|
-
if (syncInput) countField.value = String(total);
|
|
36
|
-
current = uniqueBox.input.checked
|
|
37
|
-
? shuffle(list).slice(0, total)
|
|
38
|
-
: Array.from({ length: total }, () => list[randomInt(list.length)]);
|
|
39
|
-
result.write(current.join("\n"));
|
|
40
|
-
notify.textContent = `${current.length} drawn from ${list.length}.`;
|
|
41
|
-
writePin(PIN, { entries: listField.value, winners: String(current.length), unique: uniqueBox.input.checked });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Editing the list, the count or the repeat rule redraws right away. The
|
|
45
|
-
// button stays for a fresh draw with the same settings.
|
|
46
|
-
drawButton.addEventListener("click", () => draw(true));
|
|
47
|
-
copyButton.addEventListener("click", async () => {
|
|
48
|
-
if (!current.length) return toast("Draw first, then copy");
|
|
49
|
-
toast((await copyText(result.textContent)) ? "Copied" : "Copy failed");
|
|
50
|
-
});
|
|
51
|
-
listField.addEventListener("input", debounce(() => draw(false), 200));
|
|
52
|
-
countField.addEventListener("input", () => draw(false));
|
|
53
|
-
countField.addEventListener("change", () => draw(true));
|
|
54
|
-
uniqueBox.input.addEventListener("change", () => draw(true));
|
|
55
|
-
|
|
56
|
-
const card = toolcard({
|
|
57
|
-
body: bench(
|
|
58
|
-
stack(panel("Entries", listField), panel("Winners", countField), panel(null, uniqueBox)),
|
|
59
|
-
stack(panel("Drawn", result), notify)
|
|
60
|
-
),
|
|
61
|
-
foot: actions(copyButton, drawButton)});
|
|
62
|
-
slot.append(card.node);
|
|
63
|
-
draw();
|
|
64
|
-
|
|
65
|
-
return {};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export default { mount };
|