@enigmax/primitives 0.26.0 → 0.27.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 (42) hide show
  1. package/dist/{chunk-4SZ5N7BA.js → chunk-2RIO4VDV.js} +2 -2
  2. package/dist/{chunk-DZMHY3SU.js → chunk-CPJJ3FPW.js} +17 -7
  3. package/dist/{chunk-QIIV2QSV.js → chunk-HUUINHPR.js} +28 -1
  4. package/dist/{chunk-RUEQ3TGP.js → chunk-JLZDS6CV.js} +23 -1
  5. package/dist/{chunk-ISXB5RUH.js → chunk-O2UJ5ZIX.js} +10 -3
  6. package/dist/{chunk-JJVZYW5C.js → chunk-PVDU6AZU.js} +10 -3
  7. package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
  8. package/dist/{clipboard-menu-BvXJCSFN.d.ts → clipboard-menu-eemzEBqB.d.ts} +1 -1
  9. package/dist/{context-BfjYGPnH.d.ts → context-DR1dpO8b.d.ts} +1 -1
  10. package/dist/{context-menu-D3FtTn7v.d.ts → context-menu-qqpGBkny.d.ts} +6 -0
  11. package/dist/index.d.ts +29 -3
  12. package/dist/index.js +3 -3
  13. package/dist/{menu-PNQRTRNV.js → menu-52PXC5PL.js} +2 -2
  14. package/dist/{menu-MSQSE6U5.js → menu-YIGMPFUP.js} +8 -4
  15. package/dist/next/index.d.ts +3 -3
  16. package/dist/next/index.js +5 -5
  17. package/dist/react/context-menu.d.ts +13 -7
  18. package/dist/react/context-menu.js +2 -2
  19. package/dist/react/image.d.ts +5 -2
  20. package/dist/react/image.js +1 -1
  21. package/dist/react/index.d.ts +3 -3
  22. package/dist/react/index.js +5 -5
  23. package/dist/react/video.d.ts +9 -3
  24. package/dist/react/video.js +2 -2
  25. package/dist/react-router/index.d.ts +3 -3
  26. package/dist/react-router/index.js +5 -5
  27. package/dist/viewer-N5WLFB24.js +3 -0
  28. package/package.json +1 -1
  29. package/recipes/context-menu/styles.css +8 -1
  30. package/registry.json +21 -5
  31. package/src/core/context-menu.ts +22 -3
  32. package/src/core/image-viewer.ts +47 -0
  33. package/src/core/player.ts +42 -0
  34. package/src/index.ts +3 -0
  35. package/src/react/context-menu/root.tsx +27 -5
  36. package/src/react/context-menu/styles.ts +8 -1
  37. package/src/react/image/icons.tsx +4 -0
  38. package/src/react/image/menu.tsx +5 -1
  39. package/src/react/image/types.ts +3 -0
  40. package/src/react/video/index.tsx +8 -1
  41. package/src/react/video/types.ts +7 -1
  42. package/dist/viewer-73LSE6RR.js +0 -3
@@ -4,11 +4,11 @@ export { useSearch } from '../chunk-45UHLZYT.js';
4
4
  export { useNetworkState } from '../chunk-S653GLSF.js';
5
5
  export { RelativeTime } from '../chunk-OCMI7R6H.js';
6
6
  export { PasswordStrength } from '../chunk-XQHCZAPJ.js';
7
- export { ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext } from '../chunk-DZMHY3SU.js';
7
+ export { ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext } from '../chunk-CPJJ3FPW.js';
8
8
  export { SelectionList, useSelection } from '../chunk-N6PDHMAX.js';
9
- export { Image } from '../chunk-4SZ5N7BA.js';
9
+ export { Image } from '../chunk-2RIO4VDV.js';
10
10
  import '../chunk-EYZ366LP.js';
11
- export { Video } from '../chunk-JJVZYW5C.js';
11
+ export { Video } from '../chunk-PVDU6AZU.js';
12
12
  export { Button, getLinkComponent, setLinkComponent, useButton } from '../chunk-6BGBYUSZ.js';
13
13
  export { Flag } from '../chunk-NPHRS2Q7.js';
14
14
  export { Toaster, toast, useSonner } from '../chunk-WPTBURIC.js';
@@ -19,9 +19,9 @@ export { colorEquals, formatColor, hslToRgb, hsvToRgb, parseColor, rgbToHsl, rgb
19
19
  import '../chunk-2QFTRNAZ.js';
20
20
  import '../chunk-IXVMRVD4.js';
21
21
  export { estimatePasswordStrength, generatePassword } from '../chunk-HC2ME5PU.js';
22
- export { CLIPBOARD_PREFIX, clipboardAction, clipboardEntries, clipboardHasText, createContextMenu, inspectClipboardTarget, isAction, performClipboardAction } from '../chunk-Z3VDE7OA.js';
22
+ export { CLIPBOARD_PREFIX, clipboardAction, clipboardEntries, clipboardHasText, createContextMenu, inspectClipboardTarget, isAction, performClipboardAction } from '../chunk-YQV5XZIA.js';
23
23
  export { DEFAULT_SELECTION_SHORTCUTS, createSelection } from '../chunk-WSQC3PCC.js';
24
- import '../chunk-RUEQ3TGP.js';
24
+ import '../chunk-JLZDS6CV.js';
25
25
  import '../chunk-MMQPZGSU.js';
26
26
  export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-LMZJBOZG.js';
27
27
  import '../chunk-SNYUBXWQ.js';
@@ -1,6 +1,6 @@
1
1
  import { ComponentPropsWithoutRef, ReactNode } from 'react';
2
- import { b as ContextMenuNode, a as ContextMenuItem } from '../context-BfjYGPnH.js';
3
- import '../context-menu-D3FtTn7v.js';
2
+ import { b as ContextMenuNode, a as ContextMenuItem } from '../context-DR1dpO8b.js';
3
+ import '../context-menu-qqpGBkny.js';
4
4
  import '../search-DYgqRp37.js';
5
5
 
6
6
  /**
@@ -14,7 +14,13 @@ import '../search-DYgqRp37.js';
14
14
 
15
15
  interface VideoSource {
16
16
  src: string;
17
- /** `video/mp4`, `video/webm`. The browser picks the first one it can play. */
17
+ /**
18
+ * `video/mp4`, `video/webm`. The browser picks the first one it can play.
19
+ *
20
+ * Inferred from the file extension when it is left out, and left off entirely when the
21
+ * extension means nothing - the browser then fetches and sniffs, which always works. A
22
+ * WRONG type is the expensive mistake: the source is skipped without being tried.
23
+ */
18
24
  type?: string;
19
25
  }
20
26
  interface VideoTrack {
@@ -1,3 +1,3 @@
1
1
  "use client";
2
- export { Video } from '../chunk-JJVZYW5C.js';
3
- import '../chunk-RUEQ3TGP.js';
2
+ export { Video } from '../chunk-PVDU6AZU.js';
3
+ import '../chunk-JLZDS6CV.js';
@@ -24,14 +24,14 @@ export { R as RecentEntry, b as RecentStore, e as createRecentStore, g as groupR
24
24
  export { Select, SelectContent, SelectContextValue, SelectItem, SelectList, SelectMultipleProps, SelectOptionRow, SelectProps, SelectRoot, SelectRootProps, SelectSearch, SelectSingleProps, SelectTrigger, SelectValue, useSelectContext } from '../react/select.js';
25
25
  export { a as SelectInstance, c as SelectOption, d as SelectOptions, e as SelectState, f as createSelect } from '../select-ClSy-J1f.js';
26
26
  export { ContextMenu, ContextMenuContent, ContextMenuContentProps, ContextMenuList, ContextMenuPanel, ContextMenuProps, ContextMenuRoot, ContextMenuRootProps, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, ContextMenuTriggerProps } from '../react/context-menu.js';
27
- export { C as CLIPBOARD_PREFIX, a as ClipboardAction, b as ClipboardMenuLabels, c as ClipboardMenuOptions, d as ClipboardTarget, e as clipboardAction, f as clipboardEntries, g as clipboardHasText, i as inspectClipboardTarget, p as performClipboardAction } from '../clipboard-menu-BvXJCSFN.js';
28
- export { a as ContextMenuAction, b as ContextMenuEntry, c as ContextMenuInstance, g as ContextMenuOptions, j as ContextMenuState, k as createContextMenu, l as isAction } from '../context-menu-D3FtTn7v.js';
27
+ export { C as CLIPBOARD_PREFIX, a as ClipboardAction, b as ClipboardMenuLabels, c as ClipboardMenuOptions, d as ClipboardTarget, e as clipboardAction, f as clipboardEntries, g as clipboardHasText, i as inspectClipboardTarget, p as performClipboardAction } from '../clipboard-menu-eemzEBqB.js';
28
+ export { a as ContextMenuAction, b as ContextMenuEntry, c as ContextMenuInstance, g as ContextMenuOptions, j as ContextMenuState, k as createContextMenu, l as isAction } from '../context-menu-qqpGBkny.js';
29
29
  export { MarqueeRect, SelectionList, SelectionListProps, SelectionRenderer, SelectionRowRender, UseSelectionOptions, UseSelectionResult, useSelection } from '../react/selection.js';
30
30
  export { D as DEFAULT_SELECTION_SHORTCUTS, a as SelectionCommand, b as SelectionCommandEvent, c as SelectionCommandName, d as SelectionInstance, e as SelectionOptions, f as SelectionShortcuts, g as SelectionState, h as createSelection } from '../selection-B_pmzHpy.js';
31
31
  export { S as Shortcut, b as ShortcutSpec, i as isApplePlatform, m as matchesShortcut, p as parseShortcut, e as shortcutText, f as shortcutTokens } from '../keys-D2zJs1uB.js';
32
32
  export { Image, ImageItem, ImageLabels, ImageMenuOptions, ImageProps, ImageSource, ZoomOptions } from '../react/image.js';
33
33
  export { Video, VideoControls, VideoLabels, VideoProps, VideoSource, VideoTrack } from '../react/video.js';
34
- export { C as ContextMenuContextValue, a as ContextMenuItem, b as ContextMenuNode, u as useContextMenuContext } from '../context-BfjYGPnH.js';
34
+ export { C as ContextMenuContextValue, a as ContextMenuItem, b as ContextMenuNode, u as useContextMenuContext } from '../context-DR1dpO8b.js';
35
35
 
36
36
  declare const Button: React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLElement>>;
37
37
 
@@ -4,11 +4,11 @@ export { useSearch } from '../chunk-45UHLZYT.js';
4
4
  export { useNetworkState } from '../chunk-S653GLSF.js';
5
5
  export { RelativeTime } from '../chunk-OCMI7R6H.js';
6
6
  export { PasswordStrength } from '../chunk-XQHCZAPJ.js';
7
- export { ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext } from '../chunk-DZMHY3SU.js';
7
+ export { ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext } from '../chunk-CPJJ3FPW.js';
8
8
  export { SelectionList, useSelection } from '../chunk-N6PDHMAX.js';
9
- export { Image } from '../chunk-4SZ5N7BA.js';
9
+ export { Image } from '../chunk-2RIO4VDV.js';
10
10
  import '../chunk-EYZ366LP.js';
11
- export { Video } from '../chunk-JJVZYW5C.js';
11
+ export { Video } from '../chunk-PVDU6AZU.js';
12
12
  import { Button } from '../chunk-6BGBYUSZ.js';
13
13
  export { getLinkComponent, setLinkComponent, useButton } from '../chunk-6BGBYUSZ.js';
14
14
  export { Flag } from '../chunk-NPHRS2Q7.js';
@@ -20,9 +20,9 @@ export { colorEquals, formatColor, hslToRgb, hsvToRgb, parseColor, rgbToHsl, rgb
20
20
  import '../chunk-2QFTRNAZ.js';
21
21
  import '../chunk-IXVMRVD4.js';
22
22
  export { estimatePasswordStrength, generatePassword } from '../chunk-HC2ME5PU.js';
23
- export { CLIPBOARD_PREFIX, clipboardAction, clipboardEntries, clipboardHasText, createContextMenu, inspectClipboardTarget, isAction, performClipboardAction } from '../chunk-Z3VDE7OA.js';
23
+ export { CLIPBOARD_PREFIX, clipboardAction, clipboardEntries, clipboardHasText, createContextMenu, inspectClipboardTarget, isAction, performClipboardAction } from '../chunk-YQV5XZIA.js';
24
24
  export { DEFAULT_SELECTION_SHORTCUTS, createSelection } from '../chunk-WSQC3PCC.js';
25
- import '../chunk-RUEQ3TGP.js';
25
+ import '../chunk-JLZDS6CV.js';
26
26
  import '../chunk-MMQPZGSU.js';
27
27
  export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-LMZJBOZG.js';
28
28
  import '../chunk-SNYUBXWQ.js';
@@ -0,0 +1,3 @@
1
+ export { ImageViewer, MenuButton } from './chunk-O2UJ5ZIX.js';
2
+ import './chunk-HUUINHPR.js';
3
+ import './chunk-EYZ366LP.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -21,6 +21,13 @@
21
21
  --enigma-menu-accent: #fbbf24;
22
22
  --enigma-menu-danger: #f87171;
23
23
  --enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
24
+
25
+ /* Above the lightbox, which sits at 9999, because a menu is opened FROM whatever is on
26
+ top: the image viewer's own three-dot menu was portalled to the same body and painted
27
+ underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
28
+ transient surface on a page, and this is the number that says so. It belongs here and
29
+ not in the light-scheme block, or half the readers get an invalid z-index. */
30
+ --enigma-menu-z: 10000;
24
31
  }
25
32
 
26
33
  @media (prefers-color-scheme: light) {
@@ -41,7 +48,7 @@
41
48
  and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
42
49
  ancestor cannot drag it away from the place it was opened. */
43
50
  [data-enigma-menu-panel] {
44
- position: fixed; z-index: 60;
51
+ position: fixed; z-index: var(--enigma-menu-z);
45
52
  box-sizing: border-box;
46
53
  min-width: var(--enigma-menu-min-width);
47
54
  max-width: min(22rem, calc(100vw - 1rem));
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",
@@ -392,6 +394,7 @@
392
394
  "[data-enigma-color-preset]"
393
395
  ],
394
396
  "docs": "docs/notes/primitives.md#input",
397
+ "source": "src/core/input.ts",
395
398
  "recipes": [
396
399
  "tailwind",
397
400
  "css"
@@ -460,6 +463,7 @@
460
463
  "fuse.js": "Fuzzy matching. Omit it and a built-in substring matcher is used."
461
464
  },
462
465
  "docs": "docs/notes/primitives.md#search",
466
+ "source": "src/core/search.ts",
463
467
  "recipes": [
464
468
  "tailwind"
465
469
  ],
@@ -551,7 +555,8 @@
551
555
  "[aria-busy=true]",
552
556
  "[data-enigma-button-key]"
553
557
  ],
554
- "docs": "docs/notes/primitives.md#button"
558
+ "docs": "docs/notes/primitives.md#button",
559
+ "source": "src/core/button.ts"
555
560
  },
556
561
  {
557
562
  "name": "palette",
@@ -656,6 +661,7 @@
656
661
  "[data-enigma-palette-hint]"
657
662
  ],
658
663
  "docs": "docs/notes/primitives.md#palette",
664
+ "source": "src/core/palette.ts",
659
665
  "recipes": [
660
666
  "css"
661
667
  ]
@@ -799,6 +805,7 @@
799
805
  "[data-enigma-select-empty]"
800
806
  ],
801
807
  "docs": "docs/notes/primitives.md#select",
808
+ "source": "src/core/select.ts",
802
809
  "recipes": [
803
810
  "css"
804
811
  ]
@@ -946,6 +953,7 @@
946
953
  "[data-enigma-menu-status][data-error]"
947
954
  ],
948
955
  "docs": "docs/notes/primitives.md#context-menu",
956
+ "source": "src/core/context-menu.ts",
949
957
  "recipes": [
950
958
  "css"
951
959
  ]
@@ -1019,7 +1027,8 @@
1019
1027
  "[data-enigma-selection-marquee]",
1020
1028
  "[data-enigma-selection-empty]"
1021
1029
  ],
1022
- "docs": "docs/notes/primitives.md#selection"
1030
+ "docs": "docs/notes/primitives.md#selection",
1031
+ "source": "src/core/selection.ts"
1023
1032
  },
1024
1033
  {
1025
1034
  "name": "flags",
@@ -1081,7 +1090,8 @@
1081
1090
  "[data-enigma-flag][data-flag-shape=circle]"
1082
1091
  ],
1083
1092
  "assets": "flags",
1084
- "docs": "docs/notes/primitives.md#flags"
1093
+ "docs": "docs/notes/primitives.md#flags",
1094
+ "source": "src/core/flags.ts"
1085
1095
  },
1086
1096
  {
1087
1097
  "name": "notifications",
@@ -1140,6 +1150,7 @@
1140
1150
  ],
1141
1151
  "styles": false,
1142
1152
  "docs": "docs/notes/primitives.md#notifications",
1153
+ "source": "src/core/notifications.ts",
1143
1154
  "recipes": [
1144
1155
  "tailwind"
1145
1156
  ]
@@ -1278,6 +1289,7 @@
1278
1289
  "[data-icon]"
1279
1290
  ],
1280
1291
  "docs": "docs/notes/primitives.md#toast",
1292
+ "source": "src/react/toaster.tsx",
1281
1293
  "recipes": [
1282
1294
  "tailwind",
1283
1295
  "css"
@@ -1331,7 +1343,8 @@
1331
1343
  }
1332
1344
  ],
1333
1345
  "styles": false,
1334
- "docs": "docs/notes/primitives.md#network"
1346
+ "docs": "docs/notes/primitives.md#network",
1347
+ "source": "src/core/network.ts"
1335
1348
  },
1336
1349
  {
1337
1350
  "name": "relative-time",
@@ -1399,6 +1412,7 @@
1399
1412
  "[data-relative-time-capitalize-first-letter=true]"
1400
1413
  ],
1401
1414
  "docs": "docs/notes/primitives.md#relative-time",
1415
+ "source": "src/core/relative-time.ts",
1402
1416
  "dependencies": {
1403
1417
  "@github/relative-time-element": "^4.4.0"
1404
1418
  }
@@ -1632,6 +1646,7 @@
1632
1646
  "[data-enigma-image-frame][data-flying]"
1633
1647
  ],
1634
1648
  "docs": "docs/notes/primitives.md#image",
1649
+ "source": "src/core/image-viewer.ts",
1635
1650
  "recipes": [
1636
1651
  "css"
1637
1652
  ]
@@ -1864,6 +1879,7 @@
1864
1879
  "[data-enigma-video][data-casting]"
1865
1880
  ],
1866
1881
  "docs": "docs/notes/primitives.md#video",
1882
+ "source": "src/core/player.ts",
1867
1883
  "recipes": [
1868
1884
  "css"
1869
1885
  ]
@@ -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
  }
@@ -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,
@@ -146,6 +146,12 @@ export interface ContextMenuRootProps {
146
146
  * selection, Cut over a selection in something writable, Paste in anything writable.
147
147
  */
148
148
  clipboard?: boolean | ClipboardMenuOptions;
149
+ /**
150
+ * A filter field over the rows. `"auto"` (the default) grows one only once there are
151
+ * enough rows to be worth filtering; `true` asks for one whatever the count - which is
152
+ * what a menu whose rows are fetched wants - and `false` never draws one.
153
+ */
154
+ searchable?: boolean | "auto";
149
155
  /** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
150
156
  fuse?: ContextMenuOptions["fuse"];
151
157
  fuseOptions?: Record<string, unknown>;
@@ -169,6 +175,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
169
175
  const {
170
176
  items,
171
177
  title,
178
+ searchable,
172
179
  disabled = false,
173
180
  fuse,
174
181
  fuseOptions,
@@ -208,6 +215,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
208
215
  const instance = useMemo<ContextMenuInstance>(() => createContextMenu({
209
216
  items: items as readonly ContextMenuEntry[],
210
217
  title,
218
+ searchable,
211
219
  fuse,
212
220
  fuseOptions,
213
221
  matcher,
@@ -307,8 +315,8 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
307
315
  }, []);
308
316
 
309
317
  useEffect(() => {
310
- instance.update({ items: withClipboard(currentItems.current), title });
311
- }, [instance, signature, title, withClipboard]);
318
+ instance.update({ items: withClipboard(currentItems.current), title, searchable });
319
+ }, [instance, signature, title, searchable, withClipboard]);
312
320
 
313
321
  const cancelClose = useCallback(() => { window.clearTimeout(closing.current); }, []);
314
322
 
@@ -883,6 +891,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
883
891
  return <p {...props} data-enigma-menu-label="" aria-hidden="true">{entry.label}</p>;
884
892
  }
885
893
 
894
+ /**
895
+ * Whether the level RESERVES the tick column, which is not the same as this row having a
896
+ * tick.
897
+ *
898
+ * A checkable row carries a check and, usually, an icon; its neighbours carry only the
899
+ * icon. Drawn per row, the ticked one's label therefore sat a column further right than
900
+ * everyone else's and the menu read as broken. Every desktop menu reserves the column for
901
+ * the whole level instead, so this asks the LEVEL. Read from `entries` rather than from
902
+ * what is visible: filtering a menu must not shift its rows sideways.
903
+ */
904
+ const reserveCheck = state?.entries.some((entry) => isAction(entry) && (entry as ContextMenuItem).checked !== undefined) ?? false;
905
+
886
906
  const checkable = item.checked !== undefined;
887
907
  // A checkable row inside a group is a radio: choosing one is choosing INSTEAD of its
888
908
  // siblings, and a screen reader announces the two differently.
@@ -942,16 +962,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
942
962
  menu.instance.select(level, index);
943
963
  }}
944
964
  >
945
- {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} />}
965
+ {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} reserveCheck={reserveCheck} />}
946
966
  </div>
947
967
  );
948
968
  }
949
969
 
950
970
  /** The default row: what a desktop menu draws, in the order it draws it. */
951
- function ContextMenuRowContent({ item, submenu }: { item: ContextMenuItem; submenu: boolean; }): ReactNode {
971
+ function ContextMenuRowContent({ item, submenu, reserveCheck }: { item: ContextMenuItem; submenu: boolean; reserveCheck: boolean; }): ReactNode {
952
972
  return (
953
973
  <>
954
- {item.checked !== undefined && <span data-enigma-menu-check="" aria-hidden="true" />}
974
+ {/* Empty when this row is not checkable and something else in the level is: the
975
+ column is the level's, not the row's. */}
976
+ {(item.checked !== undefined || reserveCheck) && <span data-enigma-menu-check="" aria-hidden="true" />}
955
977
  {item.icon ? <span data-enigma-menu-icon="">{item.icon}</span> : null}
956
978
  <span data-enigma-menu-item-text="">
957
979
  <span data-enigma-menu-item-label="">{item.label}</span>
@@ -29,6 +29,13 @@ export const CONTEXT_MENU_STYLES = `
29
29
  --enigma-menu-accent: #fbbf24;
30
30
  --enigma-menu-danger: #f87171;
31
31
  --enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
32
+
33
+ /* Above the lightbox, which sits at 9999, because a menu is opened FROM whatever is on
34
+ top: the image viewer's own three-dot menu was portalled to the same body and painted
35
+ underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
36
+ transient surface on a page, and this is the number that says so. It belongs here and
37
+ not in the light-scheme block, or half the readers get an invalid z-index. */
38
+ --enigma-menu-z: 10000;
32
39
  }
33
40
 
34
41
  @media (prefers-color-scheme: light) {
@@ -49,7 +56,7 @@ export const CONTEXT_MENU_STYLES = `
49
56
  and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
50
57
  ancestor cannot drag it away from the place it was opened. */
51
58
  [data-enigma-menu-panel] {
52
- position: fixed; z-index: 60;
59
+ position: fixed; z-index: var(--enigma-menu-z);
53
60
  box-sizing: border-box;
54
61
  min-width: var(--enigma-menu-min-width);
55
62
  max-width: min(22rem, calc(100vw - 1rem));
@@ -53,3 +53,7 @@ export function Dots(): ReactNode {
53
53
  export function Download(): ReactNode {
54
54
  return <Glyph><path d="M12 3v12M7 11l5 5 5-5M4 20h16" /></Glyph>;
55
55
  }
56
+
57
+ export function NewTab(): ReactNode {
58
+ return <Glyph><path d="M14 4h6v6" /><path d="M20 4 11 13" /><path d="M18 14v5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 19V7.5A1.5 1.5 0 0 1 5.5 6H10" /></Glyph>;
59
+ }
@@ -3,7 +3,7 @@
3
3
  import * as icons from "@/react/image/icons";
4
4
  import { useRef, type ReactNode } from "react";
5
5
  import { MenuButton } from "@/react/image/viewer";
6
- import { downloadFile } from "@/core/image-viewer";
6
+ import { downloadFile, openInNewTab } from "@/core/image-viewer";
7
7
  import type { ImageItem, ImageLabels, ImageMenuOptions } from "@/react/image/types";
8
8
  import { ContextMenu, useContextMenuContext, type ContextMenuNode } from "@/react/context-menu";
9
9
 
@@ -31,6 +31,9 @@ export function ImageMenu({ item, index, options, labels }: ImageMenuProps): Rea
31
31
  if (options.download !== false) {
32
32
  rows.push({ id: "download", label: labels.download ?? "Download", icon: <icons.Download /> });
33
33
  }
34
+ if (options.newTab !== false) {
35
+ rows.push({ id: "new-tab", label: labels.newTab ?? "Open in a new tab", icon: <icons.NewTab /> });
36
+ }
34
37
  if (options.items?.length) {
35
38
  if (rows.length > 0) rows.push({ type: "separator" });
36
39
  rows.push(...options.items);
@@ -44,6 +47,7 @@ export function ImageMenu({ item, index, options, labels }: ImageMenuProps): Rea
44
47
  clipboard={false}
45
48
  onSelect={(row) => {
46
49
  if (row.id === "download") void downloadFile(item.download ?? item.src, item.filename);
50
+ if (row.id === "new-tab") openInNewTab(item.src);
47
51
  options.onSelect?.(row.id, item, index);
48
52
  }}
49
53
  >
@@ -39,6 +39,8 @@ export interface ZoomOptions {
39
39
  export interface ImageMenuOptions {
40
40
  /** The download row. On whenever the menu is. */
41
41
  download?: boolean;
42
+ /** The row that opens the picture on its own, in another tab. On whenever the menu is. */
43
+ newTab?: boolean;
42
44
  /** Rows of your own, after the built-in ones. */
43
45
  items?: readonly ContextMenuNode[];
44
46
  onSelect?: (id: string, item: ImageItem, index: number) => void;
@@ -55,6 +57,7 @@ export interface ImageLabels {
55
57
  next?: string;
56
58
  menu?: string;
57
59
  download?: string;
60
+ newTab?: string;
58
61
  discard?: string;
59
62
  thumbnails?: string;
60
63
  /** The frame itself, announced as the dialog's name. Default "Image viewer". */
@@ -531,7 +531,14 @@ export function Video(props: VideoProps): ReactNode {
531
531
  playsInline={rest.playsInline ?? true}
532
532
  onClick={() => { if (clickToPlay) toggle(); }}
533
533
  >
534
- {typeof src !== "string" && sources.map((source) => <source key={source.src} src={source.src} type={source.type} />)}
534
+ {typeof src !== "string" && sources.map((source) => (
535
+ // The type is what lets the browser SKIP a file it cannot play without
536
+ // fetching it, which is the only reason to list several. Read from the
537
+ // extension when it was not given, and left off when the extension means
538
+ // nothing - a wrong type is worse than none, because the source is then
539
+ // skipped and the video does not play at all.
540
+ <source key={source.src} src={source.src} type={source.type ?? player.sourceType(source.src)} />
541
+ ))}
535
542
  {tracks?.map((track) => (
536
543
  <track
537
544
  key={track.src}
@@ -12,7 +12,13 @@ import type { ContextMenuItem, ContextMenuNode } from "@/react/context-menu/cont
12
12
 
13
13
  export interface VideoSource {
14
14
  src: string;
15
- /** `video/mp4`, `video/webm`. The browser picks the first one it can play. */
15
+ /**
16
+ * `video/mp4`, `video/webm`. The browser picks the first one it can play.
17
+ *
18
+ * Inferred from the file extension when it is left out, and left off entirely when the
19
+ * extension means nothing - the browser then fetches and sniffs, which always works. A
20
+ * WRONG type is the expensive mistake: the source is skipped without being tried.
21
+ */
16
22
  type?: string;
17
23
  }
18
24
 
@@ -1,3 +0,0 @@
1
- export { ImageViewer, MenuButton } from './chunk-ISXB5RUH.js';
2
- import './chunk-QIIV2QSV.js';
3
- import './chunk-EYZ366LP.js';