mevebu 1.4.2 → 1.4.4
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/mevebu/app/mevebu.js +60 -24
- package/mevebu/app/tailwind.css +3 -0
- package/mevebu/libs/js/core.js +40 -21
- package/mevebu/libs/js/registry.js +12 -12
- package/mevebu/libs/js/tools/case-inspector.js +3 -3
- package/mevebu/libs/js/tools/character-counter.js +9 -5
- package/mevebu/libs/js/tools/find-replace.js +14 -8
- package/mevebu/libs/js/tools/json-formatter.js +2 -3
- package/mevebu/libs/js/tools/json-minifier.js +2 -3
- package/mevebu/libs/js/tools/json-to-typescript.js +0 -1
- package/mevebu/libs/js/tools/json-validator.js +1 -1
- package/mevebu/libs/js/tools/jwt-decoder.js +1 -1
- package/mevebu/libs/js/tools/line-counter.js +3 -1
- package/mevebu/libs/js/tools/prefix-suffix.js +4 -4
- package/mevebu/libs/js/tools/qr-generator.js +10 -8
- package/mevebu/libs/js/tools/qr-scanner.js +14 -13
- package/mevebu/libs/js/tools/regex-tester.js +1 -1
- package/mevebu/libs/js/tools/remove-duplicate-lines.js +3 -3
- package/mevebu/libs/js/tools/remove-extra-spaces.js +3 -3
- package/mevebu/libs/js/tools/slug-converter.js +1 -1
- package/mevebu/libs/js/tools/sort-lines.js +3 -3
- package/mevebu/libs/js/tools/text-diff.js +5 -4
- package/mevebu/libs/js/tools/text-reverser.js +3 -3
- package/mevebu/libs/js/tools/word-counter.js +10 -6
- package/package.json +1 -1
package/mevebu/app/mevebu.js
CHANGED
|
@@ -6,7 +6,7 @@ import { route, start, match, currentPath, go } from "../libs/js/router.js";
|
|
|
6
6
|
import {
|
|
7
7
|
$, $$, el, icon, getFavorites, isFavorite, toggleFavorite, setTheme,
|
|
8
8
|
modal, modalHead, button, copyButton, openPalette, shuffle, toolcard, blank,
|
|
9
|
-
VERSION, PACKAGE_NAME, checkForUpdate, updateCommand, compareVersions,
|
|
9
|
+
VERSION, PACKAGE_NAME, checkForUpdate, updateCommand, compareVersions, releaseUrl,
|
|
10
10
|
} from "../libs/js/core.js";
|
|
11
11
|
|
|
12
12
|
const DONATE_URL = "https://buymeacoffee.com/fastro";
|
|
@@ -174,17 +174,18 @@ function openDonate() {
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// `latest` is the published version, or null when the
|
|
177
|
+
// The footer chip opens this. `latest` is the published version, or null when the
|
|
178
|
+
// check has not answered; opening the modal retries it, so a registry hiccup on
|
|
179
|
+
// the first load is not a dead end. The body and the actions repaint in place
|
|
180
|
+
// while that retry runs.
|
|
178
181
|
function openUpdate(latest) {
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
let resolved = latest;
|
|
183
|
+
let busy = false;
|
|
181
184
|
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
copyButton(() => command, "Copy command", "fill")
|
|
187
|
-
);
|
|
185
|
+
const bodyBox = el("div", { class: "grid gap-2.5" });
|
|
186
|
+
const actionBox = el("div", { class: "flex flex-wrap items-center justify-end gap-2" });
|
|
187
|
+
const command = updateCommand();
|
|
188
|
+
const updatable = () => Boolean(resolved) && compareVersions(resolved, VERSION) > 0;
|
|
188
189
|
|
|
189
190
|
const versionRow = (label, value, isNew) =>
|
|
190
191
|
el(
|
|
@@ -194,22 +195,57 @@ function openUpdate(latest) {
|
|
|
194
195
|
el("span", { class: `font-mono text-sm font-semibold ${isNew ? "text-jade" : ""}` }, value)
|
|
195
196
|
);
|
|
196
197
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
modalHead(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
function paint() {
|
|
199
|
+
const newer = updatable();
|
|
200
|
+
bodyBox.replaceChildren(...[
|
|
201
|
+
modalHead(
|
|
202
|
+
busy || !resolved ? "refresh-cw" : "circle-arrow-up",
|
|
203
|
+
busy
|
|
204
|
+
? "Checking for updates"
|
|
205
|
+
: newer
|
|
206
|
+
? `mevebu ${resolved} is out`
|
|
207
|
+
: resolved
|
|
208
|
+
? "mevebu is up to date"
|
|
209
|
+
: "Could not check for updates"
|
|
210
|
+
),
|
|
211
|
+
newer
|
|
212
|
+
? el("p", {}, "A newer version is published. Update your global install, then run ", el("code", { class: CODE }, PACKAGE_NAME), " again.")
|
|
213
|
+
: null,
|
|
204
214
|
el("div", { class: "mt-[18px] overflow-hidden rounded-r2 border border-orbit" },
|
|
205
215
|
versionRow("Installed", VERSION, false),
|
|
206
|
-
versionRow("Latest",
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
versionRow("Latest", busy ? "Checking" : resolved ?? "Unknown", newer)),
|
|
217
|
+
newer ? el("p", { class: "mt-3.5 text-sm" }, "Copy the command below and run it in your terminal.") : null,
|
|
218
|
+
newer
|
|
219
|
+
? el("div", { class: "mt-2.5 flex flex-wrap items-center gap-2 rounded-r2 border border-orbit bg-deck-2 py-2.5 pe-2.5 ps-4" },
|
|
220
|
+
el("code", { class: "min-w-0 flex-1 font-mono text-sm [overflow-wrap:anywhere]" }, command),
|
|
221
|
+
copyButton(() => command, "Copy command", "fill"))
|
|
222
|
+
: null,
|
|
223
|
+
].filter(Boolean));
|
|
224
|
+
|
|
225
|
+
actionBox.replaceChildren(...[
|
|
226
|
+
!resolved && !busy ? button("Check again", { icon: "refresh-cw", onclick: () => check() }) : null,
|
|
227
|
+
el("a", { class: LINK_PLAIN, href: releaseUrl(resolved), target: "_blank", rel: "noopener" }, "What changed"),
|
|
228
|
+
].filter(Boolean));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function check() {
|
|
232
|
+
if (busy) return;
|
|
233
|
+
busy = true;
|
|
234
|
+
paint();
|
|
235
|
+
const result = await checkForUpdate();
|
|
236
|
+
busy = false;
|
|
237
|
+
if (result?.latest) resolved = result.latest;
|
|
238
|
+
paint();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
paint();
|
|
242
|
+
modal({
|
|
243
|
+
label: "Update mevebu",
|
|
244
|
+
body: [bodyBox],
|
|
245
|
+
actions: [actionBox],
|
|
246
|
+
onOpen: () => {
|
|
247
|
+
if (!resolved) check();
|
|
248
|
+
},
|
|
213
249
|
});
|
|
214
250
|
}
|
|
215
251
|
|
package/mevebu/app/tailwind.css
CHANGED
package/mevebu/libs/js/core.js
CHANGED
|
@@ -608,7 +608,8 @@ export function switchControl(labelText, attrs = {}) {
|
|
|
608
608
|
|
|
609
609
|
/* One button: one height, one padding, three weights. `fill` is the high
|
|
610
610
|
emphasis action and the only button that carries the accent. There is no size
|
|
611
|
-
modifier, because a tool that needs a smaller button needs fewer buttons.
|
|
611
|
+
modifier, because a tool that needs a smaller button needs fewer buttons.
|
|
612
|
+
`tag: "a"` lets a link wear the same skin, so no tool hand writes the class. */
|
|
612
613
|
const BTN =
|
|
613
614
|
"inline-flex min-h-[var(--ctl)] cursor-pointer items-center justify-center gap-2 rounded-r1 border text-sm font-semibold whitespace-nowrap no-underline transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45";
|
|
614
615
|
const BTN_KIND = {
|
|
@@ -617,10 +618,10 @@ const BTN_KIND = {
|
|
|
617
618
|
quiet: "border-transparent bg-transparent px-2.5 text-ink-2 hover:bg-deck-2 hover:text-ink",
|
|
618
619
|
};
|
|
619
620
|
|
|
620
|
-
export function button(labelText, { icon: iconName, kind = "plain", class: extra, ...rest } = {}) {
|
|
621
|
+
export function button(labelText, { icon: iconName, kind = "plain", class: extra, tag = "button", ...rest } = {}) {
|
|
621
622
|
return el(
|
|
622
|
-
|
|
623
|
-
{ class: withExtra(`${BTN} ${BTN_KIND[kind] ?? BTN_KIND.plain}`, extra), type: "button", ...rest },
|
|
623
|
+
tag,
|
|
624
|
+
{ class: withExtra(`${BTN} ${BTN_KIND[kind] ?? BTN_KIND.plain}`, extra), ...(tag === "button" ? { type: "button" } : null), ...rest },
|
|
624
625
|
iconName ? icon(iconName) : null,
|
|
625
626
|
el("span", {}, labelText)
|
|
626
627
|
);
|
|
@@ -742,7 +743,7 @@ export function blank(iconName, text) {
|
|
|
742
743
|
// ---------- version ----------
|
|
743
744
|
|
|
744
745
|
// Kept in one place. The CLI reads the same value from package.json.
|
|
745
|
-
export const VERSION = "1.4.
|
|
746
|
+
export const VERSION = "1.4.4";
|
|
746
747
|
export const PACKAGE_NAME = "mevebu";
|
|
747
748
|
export const REPO_SLUG = "mevebu/mevebu";
|
|
748
749
|
|
|
@@ -757,34 +758,52 @@ export function compareVersions(a, b) {
|
|
|
757
758
|
return 0;
|
|
758
759
|
}
|
|
759
760
|
|
|
760
|
-
/**
|
|
761
|
-
|
|
762
|
-
* offline, so the app never blocks on the network. Resolves to
|
|
763
|
-
* `{ latest, current, outdated }` or null when the check did not complete.
|
|
764
|
-
*/
|
|
765
|
-
export async function checkForUpdate({ timeout = 5000 } = {}) {
|
|
761
|
+
/** One JSON GET with its own timeout. Answers null on any failure. */
|
|
762
|
+
async function getJson(url, timeout) {
|
|
766
763
|
const controller = new AbortController();
|
|
767
764
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
768
765
|
try {
|
|
769
|
-
const response = await fetch(
|
|
770
|
-
signal: controller.signal,
|
|
771
|
-
headers: { accept: "application/json" },
|
|
772
|
-
});
|
|
773
|
-
clearTimeout(timer);
|
|
766
|
+
const response = await fetch(url, { signal: controller.signal, headers: { accept: "application/json" } });
|
|
774
767
|
if (!response.ok) return null;
|
|
775
|
-
|
|
776
|
-
const latest = data?.version;
|
|
777
|
-
if (!latest) return null;
|
|
778
|
-
return { latest, current: VERSION, outdated: compareVersions(latest, VERSION) > 0 };
|
|
768
|
+
return await response.json();
|
|
779
769
|
} catch {
|
|
780
|
-
clearTimeout(timer);
|
|
781
770
|
return null;
|
|
771
|
+
} finally {
|
|
772
|
+
clearTimeout(timer);
|
|
782
773
|
}
|
|
783
774
|
}
|
|
784
775
|
|
|
776
|
+
async function npmVersion(timeout) {
|
|
777
|
+
const data = await getJson(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, timeout);
|
|
778
|
+
return data?.version ?? null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function githubVersion(timeout) {
|
|
782
|
+
const data = await getJson(`https://api.github.com/repos/${REPO_SLUG}/releases/latest`, timeout);
|
|
783
|
+
return (data?.tag_name ?? "").replace(/^v/, "") || null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Reads the newest published version. npm answers first and the GitHub release
|
|
788
|
+
* is the fallback, since either one can be unreachable on its own. Resolves to
|
|
789
|
+
* `{ latest, current, outdated }`, or null when neither answered, so the app
|
|
790
|
+
* never blocks on the network and the modal can offer a retry.
|
|
791
|
+
*/
|
|
792
|
+
export async function checkForUpdate({ timeout = 5000 } = {}) {
|
|
793
|
+
const latest = (await npmVersion(timeout)) ?? (await githubVersion(timeout));
|
|
794
|
+
if (!latest) return null;
|
|
795
|
+
return { latest, current: VERSION, outdated: compareVersions(latest, VERSION) > 0 };
|
|
796
|
+
}
|
|
797
|
+
|
|
785
798
|
/** The command that updates an existing global install. */
|
|
786
799
|
export function updateCommand() {
|
|
787
800
|
return `npm install -g ${PACKAGE_NAME}`;
|
|
788
801
|
}
|
|
789
802
|
|
|
803
|
+
/** The GitHub page listing what changed, for one version or for every release. */
|
|
804
|
+
export function releaseUrl(version) {
|
|
805
|
+
const base = `https://github.com/${REPO_SLUG}/releases`;
|
|
806
|
+
return version ? `${base}/tag/v${version}` : base;
|
|
807
|
+
}
|
|
808
|
+
|
|
790
809
|
export { hasIcon };
|
|
@@ -403,9 +403,9 @@ export const TOOLS = [
|
|
|
403
403
|
note: "Make a QR code from text or a link.",
|
|
404
404
|
about: "Download PNG for chat or SVG for print.",
|
|
405
405
|
tutorial: {
|
|
406
|
-
intro: "Generate a QR code from any text or link
|
|
406
|
+
intro: "Generate a QR code from any text or link.",
|
|
407
407
|
steps: [
|
|
408
|
-
"Type or paste your text or link
|
|
408
|
+
"Type or paste your text or link.",
|
|
409
409
|
"Set the size in pixels, between 120 and 1024.",
|
|
410
410
|
"Save PNG for chat or SVG for print. Copy image puts the PNG on the clipboard.",
|
|
411
411
|
],
|
|
@@ -419,13 +419,13 @@ export const TOOLS = [
|
|
|
419
419
|
name: "QR Scanner",
|
|
420
420
|
icon: "scan-line",
|
|
421
421
|
note: "Read a QR code with your camera or an image.",
|
|
422
|
-
about: "
|
|
422
|
+
about: "Read the text inside a QR code, with the camera or an image file.",
|
|
423
423
|
tutorial: {
|
|
424
|
-
intro: "
|
|
424
|
+
intro: "Point the camera at a code, or drop in an image. The decoded text is ready to copy.",
|
|
425
425
|
steps: [
|
|
426
426
|
"Press Start camera and point it at a code. Allow the camera permission.",
|
|
427
427
|
"Or drop an image on the box, or pick one with Choose an image.",
|
|
428
|
-
"
|
|
428
|
+
"Copy the decoded text, or open it if it is a link.",
|
|
429
429
|
],
|
|
430
430
|
},
|
|
431
431
|
keywords: "read decode camera image photo scan",
|
|
@@ -443,7 +443,7 @@ export const TOOLS = [
|
|
|
443
443
|
tutorial: {
|
|
444
444
|
intro: "Type or paste text and read the counts as they change.",
|
|
445
445
|
steps: [
|
|
446
|
-
"Type or paste your text
|
|
446
|
+
"Type or paste your text.",
|
|
447
447
|
"The counts and the reading time update as you type.",
|
|
448
448
|
"Set a word limit to track how close you are, then Copy summary.",
|
|
449
449
|
],
|
|
@@ -480,7 +480,7 @@ export const TOOLS = [
|
|
|
480
480
|
intro: "Count the lines in a block of text.",
|
|
481
481
|
steps: [
|
|
482
482
|
"Paste your text.",
|
|
483
|
-
"Total, filled and blank
|
|
483
|
+
"Total, filled and blank counts follow as you type.",
|
|
484
484
|
],
|
|
485
485
|
},
|
|
486
486
|
keywords: "lines blank empty rows count",
|
|
@@ -534,7 +534,7 @@ export const TOOLS = [
|
|
|
534
534
|
steps: [
|
|
535
535
|
"Paste your text, then set the find and replace values.",
|
|
536
536
|
"Toggle Regex to match a pattern, or Ignore case to match either case.",
|
|
537
|
-
"Press Replace all
|
|
537
|
+
"Press Replace all to change every match, or Replace one for the first one only.",
|
|
538
538
|
],
|
|
539
539
|
},
|
|
540
540
|
keywords: "search replace regex substitute text",
|
|
@@ -622,7 +622,7 @@ export const TOOLS = [
|
|
|
622
622
|
tutorial: {
|
|
623
623
|
intro: "Compare two versions of a text.",
|
|
624
624
|
steps: [
|
|
625
|
-
"Paste the first text
|
|
625
|
+
"Paste the first text and the second text.",
|
|
626
626
|
"Added and removed lines are marked in the result.",
|
|
627
627
|
"Toggle Ignore case or Ignore spaces to soften the comparison.",
|
|
628
628
|
],
|
|
@@ -656,7 +656,7 @@ export const TOOLS = [
|
|
|
656
656
|
name: "JWT Decoder",
|
|
657
657
|
icon: "signature",
|
|
658
658
|
note: "Read a JWT header and payload.",
|
|
659
|
-
about: "
|
|
659
|
+
about: "Reads the header and payload. Decoding is not signature validation.",
|
|
660
660
|
tutorial: {
|
|
661
661
|
intro: "Read the header and payload of a JSON Web Token.",
|
|
662
662
|
steps: [
|
|
@@ -678,7 +678,7 @@ export const TOOLS = [
|
|
|
678
678
|
tutorial: {
|
|
679
679
|
intro: "Format or compact a JSON document.",
|
|
680
680
|
steps: [
|
|
681
|
-
"Paste JSON
|
|
681
|
+
"Paste JSON.",
|
|
682
682
|
"Pick 2 or 4 spaces, or Compact.",
|
|
683
683
|
"Copy the result. An invalid document shows where parsing stopped.",
|
|
684
684
|
],
|
|
@@ -714,7 +714,7 @@ export const TOOLS = [
|
|
|
714
714
|
intro: "Compact JSON by removing needless whitespace.",
|
|
715
715
|
steps: [
|
|
716
716
|
"Paste JSON.",
|
|
717
|
-
"The compacted document and the size saved
|
|
717
|
+
"The compacted document and the size saved are shown.",
|
|
718
718
|
"Copy the result.",
|
|
719
719
|
],
|
|
720
720
|
},
|
|
@@ -51,7 +51,7 @@ function detectCase(text) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function mount(slot) {
|
|
54
|
-
const source = textArea({ rows: 5, value: "helloWorld", "aria-label": "Text"
|
|
54
|
+
const source = textArea({ rows: 5, value: "helloWorld", "aria-label": "Text" });
|
|
55
55
|
const detected = el("span", { class: "wrap-anywhere font-mono text-2xs" }, "No letters");
|
|
56
56
|
const kv = el(
|
|
57
57
|
"div",
|
|
@@ -76,7 +76,7 @@ export function mount(slot) {
|
|
|
76
76
|
const on = row.dataset.case === active;
|
|
77
77
|
if (on) row.dataset.on = "";
|
|
78
78
|
else delete row.dataset.on;
|
|
79
|
-
row.setAttribute("aria-
|
|
79
|
+
row.setAttribute("aria-current", on ? "true" : "false");
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -84,7 +84,7 @@ export function mount(slot) {
|
|
|
84
84
|
const button = el(
|
|
85
85
|
"button",
|
|
86
86
|
{
|
|
87
|
-
class: "group flex w-full cursor-pointer items-baseline justify-between gap-3 rounded-r1 border border-transparent bg-transparent px-3 py-[9px] text-start transition-colors duration-150 hover:bg-deck-2 data-[on]:border-jade data-[on]:bg-jade-soft", type: "button", dataset: { case: name }, "aria-
|
|
87
|
+
class: "group flex w-full cursor-pointer items-baseline justify-between gap-3 rounded-r1 border border-transparent bg-transparent px-3 py-[9px] text-start transition-colors duration-150 hover:bg-deck-2 data-[on]:border-jade data-[on]:bg-jade-soft", type: "button", dataset: { case: name }, "aria-current": "false",
|
|
88
88
|
onclick: async () => {
|
|
89
89
|
const value = button.querySelector("[data-value]").textContent.trim();
|
|
90
90
|
if (value) toast((await copyText(value)) ? "Copied" : "Copy failed");
|
|
@@ -12,8 +12,10 @@ function tile(label) {
|
|
|
12
12
|
return node;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
16
|
+
|
|
15
17
|
export function mount(slot) {
|
|
16
|
-
const source = textArea({ rows: 10, "aria-label": "Text"
|
|
18
|
+
const source = textArea({ rows: 10, "aria-label": "Text" });
|
|
17
19
|
const limitField = numberInput({ min: "0", placeholder: "No limit", "aria-label": "Character limit" });
|
|
18
20
|
|
|
19
21
|
const totalTile = tile("Characters");
|
|
@@ -29,9 +31,9 @@ export function mount(slot) {
|
|
|
29
31
|
|
|
30
32
|
function summary() {
|
|
31
33
|
return [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
plural(counts.total, "character"),
|
|
35
|
+
plural(counts.noSpace, "character") + " without spaces",
|
|
36
|
+
plural(counts.words, "word")].join("\n");
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function run() {
|
|
@@ -45,11 +47,13 @@ export function mount(slot) {
|
|
|
45
47
|
wordsTile.set(String(counts.words));
|
|
46
48
|
|
|
47
49
|
const limit = parseInt(limitField.value, 10) || 0;
|
|
50
|
+
// The track is only worth showing once a limit exists to fill it.
|
|
51
|
+
meter.hidden = limit <= 0;
|
|
48
52
|
if (limit > 0) {
|
|
49
53
|
const ratio = counts.total / limit;
|
|
50
54
|
fill.style.width = `${Math.min(100, ratio * 100)}%`;
|
|
51
55
|
fill.dataset.level = ratio > 1 ? "weak" : ratio > 0.8 ? "ok" : "strong";
|
|
52
|
-
limitNote.write(ratio > 1 ? `${counts.total - limit} over the limit.` : `${limit - counts.total}
|
|
56
|
+
limitNote.write(ratio > 1 ? `${counts.total - limit} over the limit.` : `${plural(limit - counts.total, "character")} left.`);
|
|
53
57
|
} else {
|
|
54
58
|
fill.style.width = "0%";
|
|
55
59
|
delete fill.dataset.level;
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Find and replace, plain text or a regular expression, with a live match count.
|
|
2
|
-
import { bench, stack, panel, textArea, textInput, toggleRow, notice, button, copyButton, actions, toolcard } from "../core.js";
|
|
2
|
+
import { bench, stack, panel, textArea, textInput, output, toggleRow, notice, button, copyButton, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
5
|
|
|
6
6
|
export function mount(slot) {
|
|
7
|
-
const source = textArea({ rows: 9, "aria-label": "Text"
|
|
8
|
-
const findField = textInput({
|
|
9
|
-
const replaceField = textInput({
|
|
7
|
+
const source = textArea({ rows: 9, "aria-label": "Text" });
|
|
8
|
+
const findField = textInput({ "aria-label": "Find" });
|
|
9
|
+
const replaceField = textInput({ "aria-label": "Replace with" });
|
|
10
10
|
const options = toggleRow([
|
|
11
11
|
{ value: "regex", label: "Regex", checked: false },
|
|
12
12
|
{ value: "ignore", label: "Ignore case", checked: false }]);
|
|
13
|
-
const result =
|
|
13
|
+
const result = output({ tag: "textarea", rows: 9 });
|
|
14
14
|
const error = notice("error");
|
|
15
15
|
const countNote = notice("note");
|
|
16
16
|
|
|
@@ -41,15 +41,21 @@ export function mount(slot) {
|
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
const matches = source.value.match(pattern) || [];
|
|
44
|
-
result.value = source.value.replace(pattern,
|
|
44
|
+
result.value = source.value.replace(pattern, replacement());
|
|
45
45
|
countNote.write(matches.length === 1 ? "1 match." : `${matches.length} matches.`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// With Regex off the field is literal text, so a dollar sign in it stays one
|
|
49
|
+
// instead of reading as $& or $$.
|
|
50
|
+
function replacement() {
|
|
51
|
+
return options.values().includes("regex") ? replaceField.value : replaceField.value.replace(/\$/g, "$$$$");
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
function replaceOne() {
|
|
49
55
|
const pattern = build(false);
|
|
50
56
|
if (pattern === "error") return;
|
|
51
57
|
if (!pattern) return;
|
|
52
|
-
source.value = source.value.replace(pattern,
|
|
58
|
+
source.value = source.value.replace(pattern, replacement());
|
|
53
59
|
run();
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -57,7 +63,7 @@ export function mount(slot) {
|
|
|
57
63
|
const pattern = build(true);
|
|
58
64
|
if (pattern === "error") return;
|
|
59
65
|
if (!pattern) return;
|
|
60
|
-
source.value = source.value.replace(pattern,
|
|
66
|
+
source.value = source.value.replace(pattern, replacement());
|
|
61
67
|
run();
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// Pretty-prints or compacts JSON and points at the error when it is invalid.
|
|
2
|
-
import { bench, stack, panel, textArea, notice, chipRow, copyButton, actions, toolcard } from "../core.js";
|
|
2
|
+
import { bench, stack, panel, textArea, output, notice, chipRow, copyButton, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
export function mount(slot) {
|
|
5
5
|
const source = textArea({
|
|
6
6
|
rows: 12,
|
|
7
7
|
"aria-label": "JSON",
|
|
8
|
-
placeholder: "Paste JSON",
|
|
9
8
|
value: '{"hello":"world","count":3,"items":[1,2,3]}'});
|
|
10
|
-
const result =
|
|
9
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
11
10
|
const error = notice("error");
|
|
12
11
|
let indent = "2";
|
|
13
12
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Compacts valid JSON and reports the size saved.
|
|
2
|
-
import { el, bench, stack, panel, textArea, notice, copyButton, actions, toolcard } from "../core.js";
|
|
2
|
+
import { el, bench, stack, panel, textArea, output, notice, copyButton, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
const bytes = (text) => new TextEncoder().encode(text).length;
|
|
5
5
|
|
|
@@ -10,9 +10,8 @@ export function mount(slot) {
|
|
|
10
10
|
const source = textArea({
|
|
11
11
|
rows: 12,
|
|
12
12
|
"aria-label": "JSON",
|
|
13
|
-
placeholder: "Paste JSON",
|
|
14
13
|
value: '{\n "hello": "world",\n "count": 3\n}'});
|
|
15
|
-
const result =
|
|
14
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
16
15
|
const stats = el("div", { class: "grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))]" });
|
|
17
16
|
const error = notice("error");
|
|
18
17
|
|
|
@@ -56,7 +56,6 @@ export function mount(slot) {
|
|
|
56
56
|
const source = textArea({
|
|
57
57
|
rows: 12,
|
|
58
58
|
"aria-label": "JSON",
|
|
59
|
-
placeholder: "Paste a JSON object or array",
|
|
60
59
|
value: '{\n "id": 1,\n "name": "Ada",\n "tags": ["dev"],\n "address": { "city": "London" }\n}'});
|
|
61
60
|
const nameInput = textInput({ value: "Root", "aria-label": "Root type name" });
|
|
62
61
|
const result = output({ rows: 16 });
|
|
@@ -12,7 +12,7 @@ function describe(data) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function mount(slot) {
|
|
15
|
-
const source = textArea({ rows: 12, "aria-label": "JSON"
|
|
15
|
+
const source = textArea({ rows: 12, "aria-label": "JSON" });
|
|
16
16
|
const report = output({ rows: 4 });
|
|
17
17
|
const error = notice("error");
|
|
18
18
|
|
|
@@ -12,7 +12,7 @@ function decodeSegment(segment) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function mount(slot) {
|
|
15
|
-
const source = textArea({ rows: 4, "aria-label": "Token"
|
|
15
|
+
const source = textArea({ rows: 4, "aria-label": "Token" });
|
|
16
16
|
const headerOut = output({ rows: 4 });
|
|
17
17
|
const payloadOut = output({ rows: 8 });
|
|
18
18
|
const claims = el("div", { class: "grid gap-px overflow-hidden rounded-r1 border border-orbit bg-orbit" });
|
|
@@ -8,6 +8,8 @@ function tile(label) {
|
|
|
8
8
|
return node;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
12
|
+
|
|
11
13
|
export function mount(slot) {
|
|
12
14
|
const source = textArea({ rows: 14, "aria-label": "Text", placeholder: "Paste text, one entry per line" });
|
|
13
15
|
|
|
@@ -20,7 +22,7 @@ export function mount(slot) {
|
|
|
20
22
|
|
|
21
23
|
function summary() {
|
|
22
24
|
return [
|
|
23
|
-
`${counts.total} total
|
|
25
|
+
`${plural(counts.total, "line")} total`,
|
|
24
26
|
`${counts.filled} filled`,
|
|
25
27
|
`${counts.blank} blank`].join("\n");
|
|
26
28
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Adds a prefix, a suffix, or automatic numbering to every line.
|
|
2
|
-
import { bench, stack, panel, textArea, textInput, checkbox, copyButton, actions, toolcard } from "../core.js";
|
|
2
|
+
import { bench, stack, panel, textArea, textInput, output, checkbox, copyButton, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
export function mount(slot) {
|
|
5
5
|
const source = textArea({ rows: 11, "aria-label": "Text", placeholder: "Paste your list, one entry per line" });
|
|
6
|
-
const prefixField = textInput({
|
|
7
|
-
const suffixField = textInput({
|
|
6
|
+
const prefixField = textInput({ "aria-label": "Prefix" });
|
|
7
|
+
const suffixField = textInput({ "aria-label": "Suffix" });
|
|
8
8
|
const numbering = checkbox("Numbering", { checked: false });
|
|
9
|
-
const result =
|
|
9
|
+
const result = output({ tag: "textarea", rows: 11 });
|
|
10
10
|
|
|
11
11
|
function run() {
|
|
12
12
|
const text = source.value;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// A QR code from text or a link. PNG for chat, SVG for print. The quiet zone
|
|
2
2
|
// and the size cap keep the code scannable at any setting.
|
|
3
|
-
import { el, toast, download, bench, stack, panel, textArea, numberInput, notice, button, actions, toolcard } from "../core.js";
|
|
3
|
+
import { el, toast, download, downloadButton, bench, stack, panel, textArea, numberInput, notice, button, actions, toolcard } from "../core.js";
|
|
4
4
|
import qrcode from "../vendor/qrcode-generator.mjs";
|
|
5
5
|
|
|
6
6
|
export function mount(slot) {
|
|
7
|
-
const content = textArea({ rows: 4,
|
|
7
|
+
const content = textArea({ rows: 4, "aria-label": "Text or link" });
|
|
8
8
|
const sizeField = numberInput({ value: "256", min: "120", max: "1024", step: "8", "aria-label": "Size in pixels" });
|
|
9
9
|
const canvas = el("canvas", { class: "h-auto w-[min(300px,100%)] [image-rendering:pixelated]", role: "img", "aria-label": "QR code" });
|
|
10
10
|
const reading = el("p", { class: "border-s-[3px] border-transparent ps-2 text-sm text-ink-3" });
|
|
@@ -46,7 +46,7 @@ export function mount(slot) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
svg = typeof code.createSVGTag === "function" ? code.createSVGTag(cell, false) : "";
|
|
49
|
-
reading.textContent =
|
|
49
|
+
reading.textContent = "";
|
|
50
50
|
} catch (caught) {
|
|
51
51
|
error.write(caught?.message || "That text is too long for a QR code.");
|
|
52
52
|
}
|
|
@@ -59,18 +59,20 @@ export function mount(slot) {
|
|
|
59
59
|
render();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
// An empty field leaves a zero sized canvas, so a save would write an empty
|
|
63
|
+
// file and a copy would put nothing on the clipboard.
|
|
64
|
+
const noCode = () => !content.value.trim();
|
|
65
|
+
|
|
62
66
|
const pngButton = button("Save PNG", { icon: "download", kind: "fill" });
|
|
63
|
-
const svgButton =
|
|
67
|
+
const svgButton = downloadButton(() => svg, "qr.svg", "Save SVG", "image/svg+xml");
|
|
64
68
|
const copyButton = button("Copy image", { icon: "copy" });
|
|
65
69
|
|
|
66
70
|
pngButton.addEventListener("click", () => {
|
|
71
|
+
if (noCode()) return toast("Nothing to save yet");
|
|
67
72
|
canvas.toBlob((blob) => blob && download("qr.png", blob, "image/png"));
|
|
68
73
|
});
|
|
69
|
-
svgButton.addEventListener("click", () => {
|
|
70
|
-
if (!svg) return toast("Type something first");
|
|
71
|
-
download("qr.svg", svg, "image/svg+xml");
|
|
72
|
-
});
|
|
73
74
|
copyButton.addEventListener("click", () => {
|
|
75
|
+
if (noCode()) return toast("Nothing to copy yet");
|
|
74
76
|
canvas.toBlob(async (blob) => {
|
|
75
77
|
try {
|
|
76
78
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Reads a QR code from the camera or from an image file. The decoder loads
|
|
2
2
|
// once and stays in memory. The camera stops when the page changes or a code
|
|
3
3
|
// is found. Nothing leaves the tab at any point.
|
|
4
|
-
import { el, icon, toast,
|
|
4
|
+
import { el, icon, toast, bench, stack, panel, notice, button, copyButton, output, actions, toolcard } from "../core.js";
|
|
5
5
|
|
|
6
6
|
// jsQR ships as a UMD bundle, so it has to load as a classic script and be read
|
|
7
7
|
// off the window. Importing it as a module yields an empty namespace.
|
|
@@ -32,9 +32,9 @@ export function mount(slot) {
|
|
|
32
32
|
const paint = scratch.getContext("2d", { willReadFrequently: true });
|
|
33
33
|
|
|
34
34
|
const startButton = button("Start camera", { icon: "camera", kind: "fill" });
|
|
35
|
-
const stopButton =
|
|
35
|
+
const stopButton = button("Stop", { icon: "video-off" });
|
|
36
|
+
stopButton.hidden = true;
|
|
36
37
|
const uploadButton = button("Choose an image", { icon: "upload" });
|
|
37
|
-
const copyButton = button("Copy", { icon: "copy" });
|
|
38
38
|
const openWrap = el("span", { class: "flex min-w-0 flex-wrap items-center gap-2" });
|
|
39
39
|
|
|
40
40
|
const drop = el(
|
|
@@ -50,8 +50,7 @@ export function mount(slot) {
|
|
|
50
50
|
error.write("");
|
|
51
51
|
openWrap.replaceChildren();
|
|
52
52
|
if (/^https?:\/\//i.test(text.trim())) {
|
|
53
|
-
openWrap.append(
|
|
54
|
-
icon("external-link"), el("span", {}, "Open link")));
|
|
53
|
+
openWrap.append(button("Open link", { tag: "a", href: text.trim(), target: "_blank", rel: "noopener", icon: "external-link" }));
|
|
55
54
|
}
|
|
56
55
|
toast("Found");
|
|
57
56
|
}
|
|
@@ -74,8 +73,15 @@ export function mount(slot) {
|
|
|
74
73
|
async function tick() {
|
|
75
74
|
if (!live) return;
|
|
76
75
|
if (video.readyState === video.HAVE_ENOUGH_DATA && video.videoWidth) {
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
let hit = false;
|
|
77
|
+
try {
|
|
78
|
+
hit = await scan(video.videoWidth, video.videoHeight, () =>
|
|
79
|
+
paint.drawImage(video, 0, 0, video.videoWidth, video.videoHeight));
|
|
80
|
+
} catch {
|
|
81
|
+
stop();
|
|
82
|
+
error.write("The decoder did not load. Try again, or read an image instead.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
79
85
|
if (hit) {
|
|
80
86
|
stop();
|
|
81
87
|
return;
|
|
@@ -167,11 +173,6 @@ export function mount(slot) {
|
|
|
167
173
|
delete drop.dataset.drag;
|
|
168
174
|
if (event.dataTransfer.files[0]) scanFile(event.dataTransfer.files[0]);
|
|
169
175
|
});
|
|
170
|
-
copyButton.addEventListener("click", async () => {
|
|
171
|
-
if (!found.textContent) return toast("Nothing to copy yet");
|
|
172
|
-
toast((await copyText(found.textContent)) ? "Copied" : "Copy failed");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
176
|
status.textContent = "Camera off.";
|
|
176
177
|
|
|
177
178
|
const card = toolcard({
|
|
@@ -180,7 +181,7 @@ export function mount(slot) {
|
|
|
180
181
|
stack(video, drop, fileField, status, error),
|
|
181
182
|
panel("Decoded", found)
|
|
182
183
|
),
|
|
183
|
-
foot: actions(openWrap, copyButton)});
|
|
184
|
+
foot: actions(openWrap, copyButton(() => found.textContent, "Copy"))});
|
|
184
185
|
slot.append(card.node);
|
|
185
186
|
|
|
186
187
|
return { unmount: stop };
|
|
@@ -10,7 +10,7 @@ export function mount(slot) {
|
|
|
10
10
|
rows: 8,
|
|
11
11
|
"aria-label": "Text to test",
|
|
12
12
|
value: "Contact ada@example.com or grace@navy.com for details."});
|
|
13
|
-
const pane = el("div", { class: "min-h-[120px] max-h-[380px] overflow-auto wrap-anywhere rounded-r1 border border-orbit bg-deck-2 px-4 py-3.5 leading-normal
|
|
13
|
+
const pane = el("div", { class: "min-h-[120px] max-h-[380px] overflow-auto wrap-anywhere rounded-r1 border border-orbit bg-deck-2 px-4 py-3.5 leading-normal" });
|
|
14
14
|
const listWrap = el("div", { class: "max-h-80 overflow-auto" });
|
|
15
15
|
const error = notice("error");
|
|
16
16
|
let lines = [];
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Drops repeated lines and keeps the first time each one appears.
|
|
2
|
-
import { bench, stack, panel, textArea, checkbox, notice, copyButton, actions, toolcard } from "../core.js";
|
|
2
|
+
import { bench, stack, panel, textArea, output, checkbox, notice, copyButton, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
export function mount(slot) {
|
|
5
5
|
const source = textArea({ rows: 12, "aria-label": "Text", placeholder: "Paste your list, one entry per line" });
|
|
6
6
|
const ignoreCase = checkbox("Ignore case", { checked: false });
|
|
7
|
-
const result =
|
|
7
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
8
8
|
const note = notice("note");
|
|
9
9
|
|
|
10
10
|
function run() {
|
|
@@ -24,7 +24,7 @@ export function mount(slot) {
|
|
|
24
24
|
else { seen.add(key); kept.push(line); }
|
|
25
25
|
}
|
|
26
26
|
result.value = kept.join("\n");
|
|
27
|
-
note.write(removed ? `${removed}
|
|
27
|
+
note.write(removed ? `${removed} line${removed === 1 ? "" : "s"} removed.` : "No duplicate lines found.");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
source.addEventListener("input", run);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Tidies spacing in pasted text: collapses runs of spaces, trims each line and
|
|
2
2
|
// optionally drops blank lines.
|
|
3
|
-
import { bench, stack, panel, textArea, toggleRow, copyButton, actions, toolcard } from "../core.js";
|
|
3
|
+
import { bench, stack, panel, textArea, output, toggleRow, copyButton, actions, toolcard } from "../core.js";
|
|
4
4
|
|
|
5
5
|
export function mount(slot) {
|
|
6
|
-
const source = textArea({ rows: 12, "aria-label": "Text"
|
|
7
|
-
const result =
|
|
6
|
+
const source = textArea({ rows: 12, "aria-label": "Text" });
|
|
7
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
8
8
|
const options = toggleRow([
|
|
9
9
|
{ value: "collapse", label: "Collapse spaces", checked: true },
|
|
10
10
|
{ value: "trim", label: "Trim lines", checked: true },
|
|
@@ -13,7 +13,7 @@ function slugify(text, sep) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function mount(slot) {
|
|
16
|
-
const source = textInput({ value: "Hello mevebu world",
|
|
16
|
+
const source = textInput({ value: "Hello mevebu world", "aria-label": "Title" });
|
|
17
17
|
const result = textInput({ readonly: true, mono: true, "aria-label": "Slug" });
|
|
18
18
|
|
|
19
19
|
let sep = "-";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Sorts a list of lines alphabetically or numerically, either direction.
|
|
2
|
-
import { bench, stack, panel, textArea, chipRow, toggleRow, copyButton, actions, toolcard } from "../core.js";
|
|
2
|
+
import { bench, stack, panel, textArea, output, chipRow, toggleRow, copyButton, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
export function mount(slot) {
|
|
5
5
|
const source = textArea({ rows: 12, "aria-label": "Text", placeholder: "Paste your list, one entry per line" });
|
|
6
|
-
const result =
|
|
6
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
7
7
|
|
|
8
8
|
let direction = "asc";
|
|
9
9
|
const dirChips = chipRow(
|
|
@@ -39,7 +39,7 @@ export function mount(slot) {
|
|
|
39
39
|
|
|
40
40
|
const card = toolcard({
|
|
41
41
|
body: bench(
|
|
42
|
-
stack(panel("Direction", dirChips), options),
|
|
42
|
+
stack(panel("Text", source), panel("Direction", dirChips), options),
|
|
43
43
|
stack(panel("Result", result))
|
|
44
44
|
),
|
|
45
45
|
foot: actions(copyButton(() => result.value, "Copy result", "fill"))});
|
|
@@ -4,8 +4,8 @@ import { el, bench, stack, panel, textArea, toggleRow, notice, button, copyButto
|
|
|
4
4
|
const MAX_LINES = 800;
|
|
5
5
|
|
|
6
6
|
export function mount(slot) {
|
|
7
|
-
const left = textArea({ rows: 9, "aria-label": "First text"
|
|
8
|
-
const right = textArea({ rows: 9, "aria-label": "Second text"
|
|
7
|
+
const left = textArea({ rows: 9, "aria-label": "First text" });
|
|
8
|
+
const right = textArea({ rows: 9, "aria-label": "Second text" });
|
|
9
9
|
const options = toggleRow([
|
|
10
10
|
{ value: "ignore", label: "Ignore case", checked: false },
|
|
11
11
|
{ value: "spaces", label: "Ignore spaces", checked: false }]);
|
|
@@ -52,9 +52,10 @@ export function mount(slot) {
|
|
|
52
52
|
const aRaw = left.value === "" ? [] : left.value.split("\n");
|
|
53
53
|
const bRaw = right.value === "" ? [] : right.value.split("\n");
|
|
54
54
|
if (!aRaw.length && !bRaw.length) {
|
|
55
|
-
|
|
55
|
+
// The result area explains itself while there is nothing to compare.
|
|
56
|
+
diffBox.replaceChildren(el("div", { class: "bg-deck-2 px-4 py-8 text-center text-sm text-ink-3" }, "Paste two texts to compare."));
|
|
56
57
|
rows = [];
|
|
57
|
-
note.write("
|
|
58
|
+
note.write("");
|
|
58
59
|
return;
|
|
59
60
|
}
|
|
60
61
|
const truncated = aRaw.length > MAX_LINES || bRaw.length > MAX_LINES;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Reverses text by character, word or line. Character mode keeps emoji and
|
|
2
2
|
// accented letters whole when the browser exposes grapheme segmentation.
|
|
3
|
-
import { bench, stack, panel, textArea, chipRow, copyButton, actions, toolcard } from "../core.js";
|
|
3
|
+
import { bench, stack, panel, textArea, output, chipRow, copyButton, actions, toolcard } from "../core.js";
|
|
4
4
|
|
|
5
5
|
function reverseChars(text) {
|
|
6
6
|
if (typeof Intl !== "undefined" && Intl.Segmenter) {
|
|
@@ -14,8 +14,8 @@ const reverseWords = (text) => text.trim() ? text.trim().split(/\s+/).reverse().
|
|
|
14
14
|
const reverseLines = (text) => text ? text.split("\n").reverse().join("\n") : "";
|
|
15
15
|
|
|
16
16
|
export function mount(slot) {
|
|
17
|
-
const source = textArea({ rows: 10, "aria-label": "Text"
|
|
18
|
-
const result =
|
|
17
|
+
const source = textArea({ rows: 10, "aria-label": "Text" });
|
|
18
|
+
const result = output({ tag: "textarea", rows: 10 });
|
|
19
19
|
|
|
20
20
|
let mode = "chars";
|
|
21
21
|
const chips = chipRow(
|
|
@@ -15,8 +15,10 @@ const countSentences = (text) => text.trim()
|
|
|
15
15
|
? text.trim().split(/[.!?\u2026]+/).filter((part) => part.trim()).length
|
|
16
16
|
: 0;
|
|
17
17
|
|
|
18
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
19
|
+
|
|
18
20
|
export function mount(slot) {
|
|
19
|
-
const source = textArea({ rows: 10, "aria-label": "Text"
|
|
21
|
+
const source = textArea({ rows: 10, "aria-label": "Text" });
|
|
20
22
|
const limitField = numberInput({ min: "0", placeholder: "No limit", "aria-label": "Word limit" });
|
|
21
23
|
|
|
22
24
|
const wordsTile = tile("Words");
|
|
@@ -34,10 +36,10 @@ export function mount(slot) {
|
|
|
34
36
|
|
|
35
37
|
function summary() {
|
|
36
38
|
return [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
plural(counts.words, "word"),
|
|
40
|
+
plural(counts.chars, "character"),
|
|
41
|
+
plural(counts.sentences, "sentence"),
|
|
42
|
+
plural(counts.lines, "line")].join("\n");
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
function run() {
|
|
@@ -55,11 +57,13 @@ export function mount(slot) {
|
|
|
55
57
|
readingTile.set(counts.words ? `${Math.max(1, Math.round(counts.words / WPM))} min` : "0 min");
|
|
56
58
|
|
|
57
59
|
const limit = parseInt(limitField.value, 10) || 0;
|
|
60
|
+
// The track is only worth showing once a limit exists to fill it.
|
|
61
|
+
meter.hidden = limit <= 0;
|
|
58
62
|
if (limit > 0) {
|
|
59
63
|
const ratio = words / limit;
|
|
60
64
|
fill.style.width = `${Math.min(100, ratio * 100)}%`;
|
|
61
65
|
fill.dataset.level = ratio > 1 ? "weak" : ratio > 0.8 ? "ok" : "strong";
|
|
62
|
-
limitNote.write(ratio > 1 ? `${words - limit} over the limit.` : `${limit - words}
|
|
66
|
+
limitNote.write(ratio > 1 ? `${words - limit} over the limit.` : `${plural(limit - words, "word")} left.`);
|
|
63
67
|
} else {
|
|
64
68
|
fill.style.width = "0%";
|
|
65
69
|
delete fill.dataset.level;
|