@enigmax/primitives 0.26.0 → 0.28.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.
Files changed (58) hide show
  1. package/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
  2. package/dist/{chunk-ISXB5RUH.js → chunk-CB7K6VW4.js} +10 -3
  3. package/dist/{chunk-QIIV2QSV.js → chunk-HUUINHPR.js} +28 -1
  4. package/dist/{chunk-4SZ5N7BA.js → chunk-IQMHDRJF.js} +2 -2
  5. package/dist/{chunk-RUEQ3TGP.js → chunk-JLZDS6CV.js} +23 -1
  6. package/dist/{chunk-JJVZYW5C.js → chunk-JPBVKXOL.js} +10 -3
  7. package/dist/{chunk-DZMHY3SU.js → chunk-RRPWJAIJ.js} +41 -17
  8. package/dist/chunk-RXEO4WPI.js +111 -0
  9. package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
  10. package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
  11. package/dist/{clipboard-menu-BvXJCSFN.d.ts → clipboard-menu-eemzEBqB.d.ts} +1 -1
  12. package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
  13. package/dist/{context-BfjYGPnH.d.ts → context-DR1dpO8b.d.ts} +1 -1
  14. package/dist/{context-menu-D3FtTn7v.d.ts → context-menu-qqpGBkny.d.ts} +6 -0
  15. package/dist/index.d.ts +29 -3
  16. package/dist/index.js +3 -3
  17. package/dist/{menu-PNQRTRNV.js → menu-5JZMGNOT.js} +3 -2
  18. package/dist/{menu-MSQSE6U5.js → menu-O3G4LM5O.js} +9 -4
  19. package/dist/next/index.d.ts +3 -3
  20. package/dist/next/index.js +8 -7
  21. package/dist/react/context-menu.d.ts +13 -7
  22. package/dist/react/context-menu.js +3 -2
  23. package/dist/react/image.d.ts +5 -2
  24. package/dist/react/image.js +1 -1
  25. package/dist/react/index.d.ts +3 -3
  26. package/dist/react/index.js +8 -7
  27. package/dist/react/input.js +1 -1
  28. package/dist/react/select.d.ts +6 -1
  29. package/dist/react/select.js +2 -1
  30. package/dist/react/video.d.ts +9 -3
  31. package/dist/react/video.js +2 -2
  32. package/dist/react-router/index.d.ts +3 -3
  33. package/dist/react-router/index.js +8 -7
  34. package/dist/viewer-C323RYNZ.js +3 -0
  35. package/package.json +1 -1
  36. package/recipes/color/styles.css +12 -7
  37. package/recipes/context-menu/styles.css +15 -1
  38. package/recipes/select/styles.css +24 -7
  39. package/registry.json +119 -10
  40. package/src/core/context-menu.ts +22 -3
  41. package/src/core/floating.ts +71 -0
  42. package/src/core/image-viewer.ts +47 -0
  43. package/src/core/player.ts +42 -0
  44. package/src/index.ts +3 -0
  45. package/src/react/context-menu/root.tsx +57 -20
  46. package/src/react/context-menu/styles.ts +15 -1
  47. package/src/react/floating.ts +185 -0
  48. package/src/react/image/icons.tsx +4 -0
  49. package/src/react/image/menu.tsx +5 -1
  50. package/src/react/image/types.ts +3 -0
  51. package/src/react/input/color-styles.ts +12 -7
  52. package/src/react/input/color.tsx +30 -27
  53. package/src/react/select/context.ts +5 -0
  54. package/src/react/select/root.tsx +33 -21
  55. package/src/react/select/styles.ts +24 -7
  56. package/src/react/video/index.tsx +8 -1
  57. package/src/react/video/types.ts +7 -1
  58. package/dist/viewer-73LSE6RR.js +0 -3
package/registry.json CHANGED
@@ -86,6 +86,7 @@
86
86
  "[data-enigma-marquee-copy]"
87
87
  ],
88
88
  "docs": "docs/notes/primitives.md#marquee",
89
+ "source": "src/core/marquee.ts",
89
90
  "recipes": [
90
91
  "tailwind",
91
92
  "css"
@@ -151,7 +152,8 @@
151
152
  ".logo-marquee[data-grabbing]",
152
153
  ".logo-marquee__item"
153
154
  ],
154
- "docs": "docs/notes/primitives.md#logo-marquee"
155
+ "docs": "docs/notes/primitives.md#logo-marquee",
156
+ "source": "recipes/logo-marquee/index.tsx"
155
157
  },
156
158
  {
157
159
  "name": "input",
@@ -286,6 +288,23 @@
286
288
  "react"
287
289
  ]
288
290
  },
291
+ {
292
+ "path": "src/core/floating.ts",
293
+ "dest": "input/floating-core.ts",
294
+ "targets": [
295
+ "react"
296
+ ]
297
+ },
298
+ {
299
+ "path": "src/react/floating.ts",
300
+ "dest": "input/floating.ts",
301
+ "targets": [
302
+ "react"
303
+ ],
304
+ "rewrite": {
305
+ "@/core/floating": "./floating-core"
306
+ }
307
+ },
289
308
  {
290
309
  "path": "src/react/input/color.tsx",
291
310
  "dest": "input/color.tsx",
@@ -297,7 +316,8 @@
297
316
  "@/react/input/write-value": "./write-value",
298
317
  "@/react/input/color-styles": "./color-styles",
299
318
  "@/react/input/types": "./types",
300
- "@/react/input/color-swatch": "./color-swatch"
319
+ "@/react/input/color-swatch": "./color-swatch",
320
+ "@/react/floating": "./floating"
301
321
  }
302
322
  },
303
323
  {
@@ -392,6 +412,7 @@
392
412
  "[data-enigma-color-preset]"
393
413
  ],
394
414
  "docs": "docs/notes/primitives.md#input",
415
+ "source": "src/core/input.ts",
395
416
  "recipes": [
396
417
  "tailwind",
397
418
  "css"
@@ -460,6 +481,7 @@
460
481
  "fuse.js": "Fuzzy matching. Omit it and a built-in substring matcher is used."
461
482
  },
462
483
  "docs": "docs/notes/primitives.md#search",
484
+ "source": "src/core/search.ts",
463
485
  "recipes": [
464
486
  "tailwind"
465
487
  ],
@@ -551,7 +573,8 @@
551
573
  "[aria-busy=true]",
552
574
  "[data-enigma-button-key]"
553
575
  ],
554
- "docs": "docs/notes/primitives.md#button"
576
+ "docs": "docs/notes/primitives.md#button",
577
+ "source": "src/core/button.ts"
555
578
  },
556
579
  {
557
580
  "name": "palette",
@@ -656,6 +679,7 @@
656
679
  "[data-enigma-palette-hint]"
657
680
  ],
658
681
  "docs": "docs/notes/primitives.md#palette",
682
+ "source": "src/core/palette.ts",
659
683
  "recipes": [
660
684
  "css"
661
685
  ]
@@ -742,6 +766,23 @@
742
766
  "@/core/select": "./select"
743
767
  }
744
768
  },
769
+ {
770
+ "path": "src/core/floating.ts",
771
+ "dest": "select/floating-core.ts",
772
+ "targets": [
773
+ "react"
774
+ ]
775
+ },
776
+ {
777
+ "path": "src/react/floating.ts",
778
+ "dest": "select/floating.ts",
779
+ "targets": [
780
+ "react"
781
+ ],
782
+ "rewrite": {
783
+ "@/core/floating": "./floating-core"
784
+ }
785
+ },
745
786
  {
746
787
  "path": "src/react/select/root.tsx",
747
788
  "dest": "select/root.tsx",
@@ -754,7 +795,8 @@
754
795
  "@/core/search": "./search-core",
755
796
  "@/react/slot": "./slot",
756
797
  "@/react/select/styles": "./styles",
757
- "@/react/select/context": "./context"
798
+ "@/react/select/context": "./context",
799
+ "@/react/floating": "./floating"
758
800
  }
759
801
  },
760
802
  {
@@ -799,6 +841,7 @@
799
841
  "[data-enigma-select-empty]"
800
842
  ],
801
843
  "docs": "docs/notes/primitives.md#select",
844
+ "source": "src/core/select.ts",
802
845
  "recipes": [
803
846
  "css"
804
847
  ]
@@ -888,6 +931,23 @@
888
931
  "@/core/context-menu": "./context-menu"
889
932
  }
890
933
  },
934
+ {
935
+ "path": "src/core/floating.ts",
936
+ "dest": "context-menu/floating-core.ts",
937
+ "targets": [
938
+ "react"
939
+ ]
940
+ },
941
+ {
942
+ "path": "src/react/floating.ts",
943
+ "dest": "context-menu/floating.ts",
944
+ "targets": [
945
+ "react"
946
+ ],
947
+ "rewrite": {
948
+ "@/core/floating": "./floating-core"
949
+ }
950
+ },
891
951
  {
892
952
  "path": "src/react/context-menu/root.tsx",
893
953
  "dest": "context-menu/root.tsx",
@@ -901,7 +961,9 @@
901
961
  "@/react/slot": "./slot",
902
962
  "@/react/context-menu/styles": "./styles",
903
963
  "@/react/context-menu/context": "./context",
904
- "@/core/clipboard-menu": "./clipboard-menu"
964
+ "@/core/clipboard-menu": "./clipboard-menu",
965
+ "@/react/floating": "./floating",
966
+ "@/core/floating": "./floating-core"
905
967
  }
906
968
  },
907
969
  {
@@ -946,6 +1008,7 @@
946
1008
  "[data-enigma-menu-status][data-error]"
947
1009
  ],
948
1010
  "docs": "docs/notes/primitives.md#context-menu",
1011
+ "source": "src/core/context-menu.ts",
949
1012
  "recipes": [
950
1013
  "css"
951
1014
  ]
@@ -1019,7 +1082,8 @@
1019
1082
  "[data-enigma-selection-marquee]",
1020
1083
  "[data-enigma-selection-empty]"
1021
1084
  ],
1022
- "docs": "docs/notes/primitives.md#selection"
1085
+ "docs": "docs/notes/primitives.md#selection",
1086
+ "source": "src/core/selection.ts"
1023
1087
  },
1024
1088
  {
1025
1089
  "name": "flags",
@@ -1081,7 +1145,8 @@
1081
1145
  "[data-enigma-flag][data-flag-shape=circle]"
1082
1146
  ],
1083
1147
  "assets": "flags",
1084
- "docs": "docs/notes/primitives.md#flags"
1148
+ "docs": "docs/notes/primitives.md#flags",
1149
+ "source": "src/core/flags.ts"
1085
1150
  },
1086
1151
  {
1087
1152
  "name": "notifications",
@@ -1140,6 +1205,7 @@
1140
1205
  ],
1141
1206
  "styles": false,
1142
1207
  "docs": "docs/notes/primitives.md#notifications",
1208
+ "source": "src/core/notifications.ts",
1143
1209
  "recipes": [
1144
1210
  "tailwind"
1145
1211
  ]
@@ -1278,6 +1344,7 @@
1278
1344
  "[data-icon]"
1279
1345
  ],
1280
1346
  "docs": "docs/notes/primitives.md#toast",
1347
+ "source": "src/react/toaster.tsx",
1281
1348
  "recipes": [
1282
1349
  "tailwind",
1283
1350
  "css"
@@ -1331,7 +1398,8 @@
1331
1398
  }
1332
1399
  ],
1333
1400
  "styles": false,
1334
- "docs": "docs/notes/primitives.md#network"
1401
+ "docs": "docs/notes/primitives.md#network",
1402
+ "source": "src/core/network.ts"
1335
1403
  },
1336
1404
  {
1337
1405
  "name": "relative-time",
@@ -1399,6 +1467,7 @@
1399
1467
  "[data-relative-time-capitalize-first-letter=true]"
1400
1468
  ],
1401
1469
  "docs": "docs/notes/primitives.md#relative-time",
1470
+ "source": "src/core/relative-time.ts",
1402
1471
  "dependencies": {
1403
1472
  "@github/relative-time-element": "^4.4.0"
1404
1473
  }
@@ -1528,6 +1597,23 @@
1528
1597
  "@/react/context-menu/styles": "./context-menu-styles"
1529
1598
  }
1530
1599
  },
1600
+ {
1601
+ "path": "src/core/floating.ts",
1602
+ "dest": "image/floating-core.ts",
1603
+ "targets": [
1604
+ "react"
1605
+ ]
1606
+ },
1607
+ {
1608
+ "path": "src/react/floating.ts",
1609
+ "dest": "image/floating.ts",
1610
+ "targets": [
1611
+ "react"
1612
+ ],
1613
+ "rewrite": {
1614
+ "@/core/floating": "./floating-core"
1615
+ }
1616
+ },
1531
1617
  {
1532
1618
  "path": "src/react/context-menu/root.tsx",
1533
1619
  "dest": "image/root.tsx",
@@ -1541,7 +1627,9 @@
1541
1627
  "@/react/slot": "./slot",
1542
1628
  "@/react/context-menu/styles": "./context-menu-styles",
1543
1629
  "@/react/context-menu/context": "./context",
1544
- "@/core/clipboard-menu": "./clipboard-menu"
1630
+ "@/core/clipboard-menu": "./clipboard-menu",
1631
+ "@/react/floating": "./floating",
1632
+ "@/core/floating": "./floating-core"
1545
1633
  }
1546
1634
  },
1547
1635
  {
@@ -1632,6 +1720,7 @@
1632
1720
  "[data-enigma-image-frame][data-flying]"
1633
1721
  ],
1634
1722
  "docs": "docs/notes/primitives.md#image",
1723
+ "source": "src/core/image-viewer.ts",
1635
1724
  "recipes": [
1636
1725
  "css"
1637
1726
  ]
@@ -1730,6 +1819,23 @@
1730
1819
  "@/react/context-menu/styles": "./context-menu-styles"
1731
1820
  }
1732
1821
  },
1822
+ {
1823
+ "path": "src/core/floating.ts",
1824
+ "dest": "video/floating-core.ts",
1825
+ "targets": [
1826
+ "react"
1827
+ ]
1828
+ },
1829
+ {
1830
+ "path": "src/react/floating.ts",
1831
+ "dest": "video/floating.ts",
1832
+ "targets": [
1833
+ "react"
1834
+ ],
1835
+ "rewrite": {
1836
+ "@/core/floating": "./floating-core"
1837
+ }
1838
+ },
1733
1839
  {
1734
1840
  "path": "src/react/context-menu/root.tsx",
1735
1841
  "dest": "video/root.tsx",
@@ -1743,7 +1849,9 @@
1743
1849
  "@/react/slot": "./slot",
1744
1850
  "@/react/context-menu/styles": "./context-menu-styles",
1745
1851
  "@/react/context-menu/context": "./context",
1746
- "@/core/clipboard-menu": "./clipboard-menu"
1852
+ "@/core/clipboard-menu": "./clipboard-menu",
1853
+ "@/react/floating": "./floating",
1854
+ "@/core/floating": "./floating-core"
1747
1855
  }
1748
1856
  },
1749
1857
  {
@@ -1864,6 +1972,7 @@
1864
1972
  "[data-enigma-video][data-casting]"
1865
1973
  ],
1866
1974
  "docs": "docs/notes/primitives.md#video",
1975
+ "source": "src/core/player.ts",
1867
1976
  "recipes": [
1868
1977
  "css"
1869
1978
  ]
@@ -82,8 +82,18 @@ export function isAction(entry: ContextMenuEntry): entry is ContextMenuAction {
82
82
  /** The fields the filter reads when the caller does not say. */
83
83
  export const CONTEXT_MENU_SEARCH_KEYS = ["label", "description", "group", "keywords"];
84
84
 
85
- /** Filtering six rows is worth a field; filtering three is a bigger panel for nothing. */
86
- export const SEARCHABLE_FROM = 8;
85
+ /**
86
+ * How many rows a level needs before it grows a filter on its own.
87
+ *
88
+ * Raised from 8, which turned out to be a number you can still READ. A menu of eight is
89
+ * scanned in one look, so the field was a row of chrome, a focus stop and a taller panel in
90
+ * exchange for nothing - the video player's own menu, at nine rows, is what showed it. Twelve
91
+ * is about where a list stops being a shape and starts being a list.
92
+ *
93
+ * It is only the DEFAULT. A menu whose rows arrive from somewhere - a list of files, a
94
+ * directory, anything fetched - says `searchable` and does not wait to grow into it.
95
+ */
96
+ export const SEARCHABLE_FROM = 12;
87
97
 
88
98
  /** Where a menu was opened. Viewport coordinates, which is what a pointer event reports. */
89
99
  export interface ContextMenuPoint {
@@ -141,6 +151,12 @@ export interface ContextMenuOptions {
141
151
  /** A row was invoked. The menu has already closed unless the row keeps it open. */
142
152
  onSelect?: (item: ContextMenuAction, path: string[]) => void;
143
153
  onOpenChange?: (open: boolean) => void;
154
+ /**
155
+ * Whether the ROOT level draws a filter. `"auto"` (the default) grows one from
156
+ * `SEARCHABLE_FROM` rows; `true` puts one there whatever the count, which is what a menu
157
+ * filling itself from somewhere wants; `false` never does.
158
+ */
159
+ searchable?: boolean | "auto";
144
160
  }
145
161
 
146
162
  export interface ContextMenuInstance {
@@ -235,7 +251,10 @@ export function createContextMenu(options: ContextMenuOptions = {}): ContextMenu
235
251
  }
236
252
 
237
253
  function searchableFor(item: ContextMenuAction | null, entries: readonly ContextMenuEntry[]): boolean {
238
- const asked = item?.searchable ?? "auto";
254
+ // The root has no item to carry the answer, so it reads the menu's own option - which
255
+ // is the only way a caller can turn the field on for rows they are about to fetch, or
256
+ // off for a menu they know is short.
257
+ const asked = (item ? item.searchable : opts.searchable) ?? "auto";
239
258
  if (asked !== "auto") return asked;
240
259
  return entries.filter(isAction).length >= SEARCHABLE_FROM;
241
260
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where a floating panel goes: below or above the thing that opened it, inside the window.
3
+ *
4
+ * One function for every popup here - the select's list, the colour picker, the context menu
5
+ * - because they are the same question with different anchors. The select and the picker
6
+ * hang from a trigger's box; the context menu hangs from the pointer, which is the same box
7
+ * with no size. Pure arithmetic on rectangles, so it has no DOM and no framework: the React
8
+ * half that measures and applies it is `react/floating.ts`.
9
+ */
10
+
11
+ /** A box in viewport coordinates, the shape `getBoundingClientRect()` returns. */
12
+ export interface FloatingRect {
13
+ left: number;
14
+ top: number;
15
+ right: number;
16
+ bottom: number;
17
+ }
18
+
19
+ export type FloatingSide = "top" | "bottom";
20
+
21
+ export interface FloatingOptions {
22
+ /** The area the panel has to stay inside - the window, or less when something clips it. */
23
+ boundary: FloatingRect;
24
+ /** Kept clear of the boundary's edge, so a panel never sits flush against it. */
25
+ margin?: number;
26
+ /** Between the anchor and the panel. */
27
+ gap?: number;
28
+ /** A side the caller insists on. `"auto"` (the default) measures. */
29
+ side?: FloatingSide | "auto";
30
+ }
31
+
32
+ export interface FloatingPlacement {
33
+ left: number;
34
+ top: number;
35
+ side: FloatingSide;
36
+ /**
37
+ * The tallest the panel can be on that side without leaving the boundary. Applied as a
38
+ * `max-height`, so a list longer than the room scrolls inside the panel instead of
39
+ * running off the screen where its last rows cannot be reached.
40
+ */
41
+ maxHeight: number;
42
+ }
43
+
44
+ /**
45
+ * Below when it fits, above when it does not and there is more room there.
46
+ *
47
+ * Flipped only when the other side is genuinely roomier: near the bottom of the window an
48
+ * unflipped panel hangs off the screen, but flipping a panel that fits neither way into the
49
+ * SMALLER side just moves the problem. Whichever side wins, `maxHeight` is the room it has.
50
+ *
51
+ * Horizontally it starts at the anchor's left edge and flips to end at its right edge when
52
+ * it would run past the window - flipped rather than dragged back, because a context menu
53
+ * dragged back to fit sits under the pointer and the release chooses its first row. The
54
+ * clamp after it only matters for a panel wider than the room on both sides.
55
+ */
56
+ export function placeFloating(anchor: FloatingRect, size: { width: number; height: number; }, options: FloatingOptions): FloatingPlacement {
57
+ const { boundary, margin = 8, gap = 0, side = "auto" } = options;
58
+ const below = boundary.bottom - margin - anchor.bottom - gap;
59
+ const above = anchor.top - gap - boundary.top - margin;
60
+ const chosen: FloatingSide = side !== "auto" ? side : size.height <= below || below >= above ? "bottom" : "top";
61
+ const maxHeight = Math.max(0, Math.floor(chosen === "bottom" ? below : above));
62
+ const height = Math.min(size.height, maxHeight);
63
+ const top = chosen === "bottom" ? anchor.bottom + gap : anchor.top - gap - height;
64
+
65
+ const maxLeft = boundary.right - margin - size.width;
66
+ let left = anchor.left;
67
+ if (left > maxLeft) left = anchor.right - size.width;
68
+ left = Math.max(boundary.left + margin, Math.min(left, maxLeft));
69
+
70
+ return { left: Math.round(left), top: Math.round(top), side: chosen, maxHeight };
71
+ }
@@ -174,6 +174,53 @@ function saveAs(href: string, name: string, external = false): void {
174
174
  anchor.remove();
175
175
  }
176
176
 
177
+ /**
178
+ * A `data:` URL as the bytes it stands for.
179
+ *
180
+ * Synchronous on purpose: it is used to open a tab, and a window opened after an `await` is a
181
+ * popup with no gesture behind it, which the browser blocks.
182
+ */
183
+ export function dataUrlToBlob(url: string): Blob {
184
+ const comma = url.indexOf(",");
185
+ if (!url.startsWith("data:") || comma === -1) throw new Error("not a data URL");
186
+ const head = url.slice("data:".length, comma);
187
+ const body = url.slice(comma + 1);
188
+ const base64 = head.endsWith(";base64");
189
+ const type = (base64 ? head.slice(0, -";base64".length) : head) || "text/plain";
190
+ if (!base64) return new Blob([decodeURIComponent(body)], { type });
191
+
192
+ const binary = atob(body);
193
+ const bytes = new Uint8Array(binary.length);
194
+ for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
195
+ return new Blob([bytes], { type });
196
+ }
197
+
198
+ /**
199
+ * The picture on its own, in another tab.
200
+ *
201
+ * Chromium refuses a top-level navigation to a `data:` URL - it was a phishing vector - so a
202
+ * row that just called `window.open` on one did nothing at all, silently, which is how it
203
+ * shipped. Those are handed over as a blob instead; everything else opens as it is.
204
+ *
205
+ * `noopener` on both: the new tab must not get a handle on the page that opened it.
206
+ */
207
+ export function openInNewTab(url: string): void {
208
+ if (typeof window === "undefined") return;
209
+ if (!url.startsWith("data:")) {
210
+ window.open(url, "_blank", "noopener,noreferrer");
211
+ return;
212
+ }
213
+ try {
214
+ const href = URL.createObjectURL(dataUrlToBlob(url));
215
+ window.open(href, "_blank", "noopener,noreferrer");
216
+ // Revoked later, not now: the tab has not finished reading it yet.
217
+ setTimeout(() => URL.revokeObjectURL(href), 60_000);
218
+ } catch {
219
+ // Undecodable, or a blocked popup. Better the browser's own refusal than a throw.
220
+ window.open(url, "_blank", "noopener,noreferrer");
221
+ }
222
+ }
223
+
177
224
  /* -------- the flight from the thumbnail into the viewer -------- */
178
225
 
179
226
  /** How long the picture takes to fly, when the stylesheet does not say otherwise. */
@@ -150,6 +150,48 @@ export async function togglePip(video: HTMLVideoElement): Promise<void> {
150
150
  await video.requestPictureInPicture();
151
151
  }
152
152
 
153
+ /* -------- what a source IS -------- */
154
+
155
+ /**
156
+ * The MIME types worth naming from a file extension, and nothing else.
157
+ *
158
+ * Deliberately short. A `<source>` with no `type` makes the browser fetch and sniff, which
159
+ * costs a request and always works; a `<source>` with the WRONG type is skipped outright, and
160
+ * the video then does not play at all. So a guess is more expensive than silence, and only
161
+ * extensions that mean one thing are in here.
162
+ */
163
+ const SOURCE_TYPES: Record<string, string> = {
164
+ mp4: "video/mp4",
165
+ m4v: "video/mp4",
166
+ webm: "video/webm",
167
+ ogv: "video/ogg",
168
+ mov: "video/quicktime",
169
+ mkv: "video/x-matroska",
170
+ m3u8: "application/vnd.apple.mpegurl",
171
+ mpd: "application/dash+xml"
172
+ };
173
+
174
+ /**
175
+ * The type a URL's extension implies, or undefined.
176
+ *
177
+ * Read from the PATH, so a query string or a fragment cannot be mistaken for one -
178
+ * `/clip.mp4?token=...` is still an mp4, and `/download?file=x.webm` is not a webm, because
179
+ * the extension has to be on the path's last segment. A `blob:` or `data:` URL has no
180
+ * extension to read and gets nothing, which is the right answer: the browser sniffs.
181
+ */
182
+ export function sourceType(url: string): string | undefined {
183
+ let path = url;
184
+ try {
185
+ path = new URL(url, typeof location === "undefined" ? "https://localhost" : location.href).pathname;
186
+ } catch {
187
+ path = url.split("?")[0]!.split("#")[0]!;
188
+ }
189
+ const name = path.split("/").pop() ?? "";
190
+ const dot = name.lastIndexOf(".");
191
+ if (dot <= 0) return undefined;
192
+ return SOURCE_TYPES[name.slice(dot + 1).toLowerCase()];
193
+ }
194
+
153
195
  /* -------- captions -------- */
154
196
 
155
197
  /** One subtitle track, as the menu lists it. */
package/src/index.ts CHANGED
@@ -167,6 +167,8 @@ export {
167
167
  downloadFile,
168
168
  flightFrom,
169
169
  flightMs,
170
+ openInNewTab,
171
+ dataUrlToBlob,
170
172
  prefersReducedMotion,
171
173
  FLIGHT_MS,
172
174
  IDENTITY,
@@ -190,6 +192,7 @@ export {
190
192
  toggleFullscreen,
191
193
  supportsPip,
192
194
  togglePip,
195
+ sourceType,
193
196
  captionTracks,
194
197
  activeCaption,
195
198
  showCaption,