@enigmax/primitives 0.25.0 → 0.26.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 (48) hide show
  1. package/dist/chunk-4SZ5N7BA.js +121 -0
  2. package/dist/chunk-EYZ366LP.js +189 -0
  3. package/dist/chunk-ISXB5RUH.js +380 -0
  4. package/dist/chunk-JJVZYW5C.js +946 -0
  5. package/dist/chunk-QIIV2QSV.js +108 -0
  6. package/dist/chunk-RUEQ3TGP.js +155 -0
  7. package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
  8. package/dist/context-BfjYGPnH.d.ts +54 -0
  9. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
  10. package/dist/index.d.ts +266 -2
  11. package/dist/index.js +4 -2
  12. package/dist/menu-MSQSE6U5.js +54 -0
  13. package/dist/menu-PNQRTRNV.js +33 -0
  14. package/dist/next/index.d.ts +6 -2
  15. package/dist/next/index.js +10 -6
  16. package/dist/react/context-menu.d.ts +8 -54
  17. package/dist/react/image.d.ts +109 -0
  18. package/dist/react/image.js +3 -0
  19. package/dist/react/index.d.ts +6 -2
  20. package/dist/react/index.js +10 -6
  21. package/dist/react/input.js +1 -1
  22. package/dist/react/video.d.ts +120 -0
  23. package/dist/react/video.js +3 -0
  24. package/dist/react-router/index.d.ts +6 -2
  25. package/dist/react-router/index.js +10 -6
  26. package/dist/viewer-73LSE6RR.js +3 -0
  27. package/package.json +15 -2
  28. package/recipes/image/styles.css +182 -0
  29. package/recipes/video/styles.css +154 -0
  30. package/registry.json +465 -0
  31. package/src/core/image-viewer.ts +222 -0
  32. package/src/core/player.ts +303 -0
  33. package/src/index.ts +52 -0
  34. package/src/react/image/icons.tsx +55 -0
  35. package/src/react/image/index.tsx +185 -0
  36. package/src/react/image/menu.tsx +76 -0
  37. package/src/react/image/styles.ts +213 -0
  38. package/src/react/image/types.ts +113 -0
  39. package/src/react/image/viewer.tsx +571 -0
  40. package/src/react/index.ts +3 -0
  41. package/src/react/video/icons.tsx +135 -0
  42. package/src/react/video/index.tsx +724 -0
  43. package/src/react/video/menu.tsx +66 -0
  44. package/src/react/video/rail.tsx +99 -0
  45. package/src/react/video/styles.ts +161 -0
  46. package/src/react/video/types.ts +135 -0
  47. /package/dist/{chunk-3MGBZOAU.js → chunk-RMNXVWQ4.js} +0 -0
  48. /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
package/registry.json CHANGED
@@ -1402,6 +1402,471 @@
1402
1402
  "dependencies": {
1403
1403
  "@github/relative-time-element": "^4.4.0"
1404
1404
  }
1405
+ },
1406
+ {
1407
+ "name": "image",
1408
+ "title": "Image, with the viewer a press on it opens",
1409
+ "description": "An image that opens into a lightbox: a press enlarges it and the picture flies out of the page into the viewer, the wheel zooms around the cursor, with a drag to pan, a pinch on touch, and the keyboard driving all of it. Everything else is a decision about the page it sits in, so it is off until asked for - the arrows and counter of a set, the strip of previews along the bottom, a three-dot menu that downloads the file, and a discard action that drops an image and moves to the next. The viewer is its own chunk, fetched on intent, and the menu another under that, so a page of images nobody enlarges downloads neither. It brings a theme: there is no useful unstyled lightbox.",
1410
+ "targets": [
1411
+ "react"
1412
+ ],
1413
+ "entry": {
1414
+ "react": "@enigmax/primitives/react/image"
1415
+ },
1416
+ "exports": {
1417
+ "react": [
1418
+ "Image"
1419
+ ]
1420
+ },
1421
+ "files": [
1422
+ {
1423
+ "path": "src/core/image-viewer.ts",
1424
+ "dest": "image/image-viewer.ts",
1425
+ "targets": [
1426
+ "react"
1427
+ ]
1428
+ },
1429
+ {
1430
+ "path": "src/core/context-menu.ts",
1431
+ "dest": "image/context-menu.ts",
1432
+ "targets": [
1433
+ "react"
1434
+ ],
1435
+ "rewrite": {
1436
+ "@/core/search": "./search-core",
1437
+ "@/core/keys": "./keys"
1438
+ }
1439
+ },
1440
+ {
1441
+ "path": "src/core/search.ts",
1442
+ "dest": "image/search-core.ts",
1443
+ "targets": [
1444
+ "react"
1445
+ ]
1446
+ },
1447
+ {
1448
+ "path": "src/core/keys.ts",
1449
+ "dest": "image/keys.ts",
1450
+ "targets": [
1451
+ "react"
1452
+ ]
1453
+ },
1454
+ {
1455
+ "path": "src/react/image/types.ts",
1456
+ "dest": "image/types.ts",
1457
+ "targets": [
1458
+ "react"
1459
+ ],
1460
+ "rewrite": {
1461
+ "@/react/context-menu/context": "./context"
1462
+ }
1463
+ },
1464
+ {
1465
+ "path": "src/react/image/icons.tsx",
1466
+ "dest": "image/icons.tsx",
1467
+ "targets": [
1468
+ "react"
1469
+ ]
1470
+ },
1471
+ {
1472
+ "path": "src/react/image/styles.ts",
1473
+ "dest": "image/styles.ts",
1474
+ "targets": [
1475
+ "react"
1476
+ ]
1477
+ },
1478
+ {
1479
+ "path": "src/core/clipboard-menu.ts",
1480
+ "dest": "image/clipboard-menu.ts",
1481
+ "targets": [
1482
+ "react"
1483
+ ],
1484
+ "rewrite": {
1485
+ "@/core/context-menu": "./context-menu",
1486
+ "@/react/context-menu/styles": "./context-menu-styles"
1487
+ }
1488
+ },
1489
+ {
1490
+ "path": "src/react/slot.tsx",
1491
+ "dest": "image/slot.tsx",
1492
+ "targets": [
1493
+ "react"
1494
+ ],
1495
+ "rewrite": {
1496
+ "@/react/context-menu/styles": "./context-menu-styles"
1497
+ }
1498
+ },
1499
+ {
1500
+ "path": "src/react/context-menu/styles.ts",
1501
+ "dest": "image/context-menu-styles.ts",
1502
+ "targets": [
1503
+ "react"
1504
+ ],
1505
+ "rewrite": {
1506
+ "@/react/context-menu/styles": "./context-menu-styles"
1507
+ }
1508
+ },
1509
+ {
1510
+ "path": "recipes/context-menu/styles.css",
1511
+ "dest": "image/context-menu-styles.css",
1512
+ "targets": [
1513
+ "react"
1514
+ ],
1515
+ "style": "css",
1516
+ "rewrite": {
1517
+ "@/react/context-menu/styles": "./context-menu-styles"
1518
+ }
1519
+ },
1520
+ {
1521
+ "path": "src/react/context-menu/context.ts",
1522
+ "dest": "image/context.ts",
1523
+ "targets": [
1524
+ "react"
1525
+ ],
1526
+ "rewrite": {
1527
+ "@/core/context-menu": "./context-menu",
1528
+ "@/react/context-menu/styles": "./context-menu-styles"
1529
+ }
1530
+ },
1531
+ {
1532
+ "path": "src/react/context-menu/root.tsx",
1533
+ "dest": "image/root.tsx",
1534
+ "targets": [
1535
+ "react"
1536
+ ],
1537
+ "rewrite": {
1538
+ "@/core/context-menu": "./context-menu",
1539
+ "@/core/search": "./search-core",
1540
+ "@/core/keys": "./keys",
1541
+ "@/react/slot": "./slot",
1542
+ "@/react/context-menu/styles": "./context-menu-styles",
1543
+ "@/react/context-menu/context": "./context",
1544
+ "@/core/clipboard-menu": "./clipboard-menu"
1545
+ }
1546
+ },
1547
+ {
1548
+ "path": "src/react/context-menu/index.tsx",
1549
+ "dest": "image/context-menu-ui.tsx",
1550
+ "targets": [
1551
+ "react"
1552
+ ],
1553
+ "rewrite": {
1554
+ "@/react/context-menu/root": "./root",
1555
+ "@/react/context-menu/context": "./context",
1556
+ "@/react/context-menu/styles": "./context-menu-styles",
1557
+ "@/core/context-menu": "./context-menu",
1558
+ "@/core/keys": "./keys",
1559
+ "@/core/clipboard-menu": "./clipboard-menu"
1560
+ }
1561
+ },
1562
+ {
1563
+ "path": "src/react/image/menu.tsx",
1564
+ "dest": "image/menu.tsx",
1565
+ "targets": [
1566
+ "react"
1567
+ ],
1568
+ "rewrite": {
1569
+ "@/react/image/icons": "./icons",
1570
+ "@/react/image/types": "./types",
1571
+ "@/react/image/viewer": "./viewer",
1572
+ "@/core/image-viewer": "./image-viewer",
1573
+ "@/react/context-menu": "./context-menu-ui"
1574
+ }
1575
+ },
1576
+ {
1577
+ "path": "src/react/image/viewer.tsx",
1578
+ "dest": "image/viewer.tsx",
1579
+ "targets": [
1580
+ "react"
1581
+ ],
1582
+ "rewrite": {
1583
+ "@/react/image/icons": "./icons",
1584
+ "@/react/image/types": "./types",
1585
+ "@/react/image/styles": "./styles",
1586
+ "@/react/image/menu": "./menu",
1587
+ "@/core/image-viewer": "./image-viewer"
1588
+ }
1589
+ },
1590
+ {
1591
+ "path": "src/react/image/index.tsx",
1592
+ "dest": "image/index.tsx",
1593
+ "main": true,
1594
+ "targets": [
1595
+ "react"
1596
+ ],
1597
+ "rewrite": {
1598
+ "@/react/image/types": "./types",
1599
+ "@/react/image/viewer": "./viewer",
1600
+ "@/react/image/styles": "./styles"
1601
+ }
1602
+ },
1603
+ {
1604
+ "path": "recipes/image/styles.css",
1605
+ "dest": "image/styles.css",
1606
+ "targets": [
1607
+ "react"
1608
+ ],
1609
+ "style": "css"
1610
+ }
1611
+ ],
1612
+ "styles": true,
1613
+ "themeHooks": [
1614
+ "[data-enigma-image]",
1615
+ "[data-enigma-image][data-clickable]",
1616
+ "[data-enigma-image-trigger]",
1617
+ "[data-enigma-image-viewer]",
1618
+ "[data-enigma-image-bar]",
1619
+ "[data-enigma-image-counter]",
1620
+ "[data-enigma-image-button]",
1621
+ "[data-enigma-image-frame]",
1622
+ "[data-enigma-image-frame][data-zoomed]",
1623
+ "[data-enigma-image-frame][data-panning]",
1624
+ "[data-enigma-image-frame][data-loading]",
1625
+ "[data-enigma-image-nav]",
1626
+ "[data-enigma-image-spinner]",
1627
+ "[data-enigma-image-foot]",
1628
+ "[data-enigma-image-caption]",
1629
+ "[data-enigma-image-strip]",
1630
+ "[data-enigma-image-thumb]",
1631
+ "[data-enigma-image-viewer][data-state]",
1632
+ "[data-enigma-image-frame][data-flying]"
1633
+ ],
1634
+ "docs": "docs/notes/primitives.md#image",
1635
+ "recipes": [
1636
+ "css"
1637
+ ]
1638
+ },
1639
+ {
1640
+ "name": "video",
1641
+ "title": "Video player",
1642
+ "description": "A video with the controls a video is expected to have, shaped after Plyr: a big play button over the poster, a scrubber showing what is buffered, current time and duration, volume that folds away until reached, subtitles with a language picker, a speed menu, picture in picture, casting to a TV and fullscreen. A right-click opens a menu the way YouTube's does - play, loop, speed, subtitles, copy the link or the link at this second - built from the context-menu primitive rather than hand-rolled. The keyboard is the platform's - space and K play, J and L jump ten seconds, the arrows nudge and set the volume, M mutes, F is fullscreen, C captions, a digit seeks to that tenth - and it stands down inside anything that takes text. The bar fades while playing and comes back on the first pointer move. The element stays the source of truth: playback, the subtitle list and the cast target are all read back from it rather than kept twice.",
1643
+ "targets": [
1644
+ "react"
1645
+ ],
1646
+ "entry": {
1647
+ "react": "@enigmax/primitives/react/video"
1648
+ },
1649
+ "exports": {
1650
+ "react": [
1651
+ "Video"
1652
+ ]
1653
+ },
1654
+ "files": [
1655
+ {
1656
+ "path": "src/core/context-menu.ts",
1657
+ "dest": "video/context-menu.ts",
1658
+ "targets": [
1659
+ "react"
1660
+ ],
1661
+ "rewrite": {
1662
+ "@/core/search": "./search-core",
1663
+ "@/core/keys": "./keys"
1664
+ }
1665
+ },
1666
+ {
1667
+ "path": "src/core/search.ts",
1668
+ "dest": "video/search-core.ts",
1669
+ "targets": [
1670
+ "react"
1671
+ ]
1672
+ },
1673
+ {
1674
+ "path": "src/core/keys.ts",
1675
+ "dest": "video/keys.ts",
1676
+ "targets": [
1677
+ "react"
1678
+ ]
1679
+ },
1680
+ {
1681
+ "path": "src/core/clipboard-menu.ts",
1682
+ "dest": "video/clipboard-menu.ts",
1683
+ "targets": [
1684
+ "react"
1685
+ ],
1686
+ "rewrite": {
1687
+ "@/core/context-menu": "./context-menu",
1688
+ "@/react/context-menu/styles": "./context-menu-styles"
1689
+ }
1690
+ },
1691
+ {
1692
+ "path": "src/react/slot.tsx",
1693
+ "dest": "video/slot.tsx",
1694
+ "targets": [
1695
+ "react"
1696
+ ],
1697
+ "rewrite": {
1698
+ "@/react/context-menu/styles": "./context-menu-styles"
1699
+ }
1700
+ },
1701
+ {
1702
+ "path": "src/react/context-menu/styles.ts",
1703
+ "dest": "video/context-menu-styles.ts",
1704
+ "targets": [
1705
+ "react"
1706
+ ],
1707
+ "rewrite": {
1708
+ "@/react/context-menu/styles": "./context-menu-styles"
1709
+ }
1710
+ },
1711
+ {
1712
+ "path": "recipes/context-menu/styles.css",
1713
+ "dest": "video/context-menu-styles.css",
1714
+ "targets": [
1715
+ "react"
1716
+ ],
1717
+ "style": "css",
1718
+ "rewrite": {
1719
+ "@/react/context-menu/styles": "./context-menu-styles"
1720
+ }
1721
+ },
1722
+ {
1723
+ "path": "src/react/context-menu/context.ts",
1724
+ "dest": "video/context.ts",
1725
+ "targets": [
1726
+ "react"
1727
+ ],
1728
+ "rewrite": {
1729
+ "@/core/context-menu": "./context-menu",
1730
+ "@/react/context-menu/styles": "./context-menu-styles"
1731
+ }
1732
+ },
1733
+ {
1734
+ "path": "src/react/context-menu/root.tsx",
1735
+ "dest": "video/root.tsx",
1736
+ "targets": [
1737
+ "react"
1738
+ ],
1739
+ "rewrite": {
1740
+ "@/core/context-menu": "./context-menu",
1741
+ "@/core/search": "./search-core",
1742
+ "@/core/keys": "./keys",
1743
+ "@/react/slot": "./slot",
1744
+ "@/react/context-menu/styles": "./context-menu-styles",
1745
+ "@/react/context-menu/context": "./context",
1746
+ "@/core/clipboard-menu": "./clipboard-menu"
1747
+ }
1748
+ },
1749
+ {
1750
+ "path": "src/react/context-menu/index.tsx",
1751
+ "dest": "video/context-menu-ui.tsx",
1752
+ "targets": [
1753
+ "react"
1754
+ ],
1755
+ "rewrite": {
1756
+ "@/react/context-menu/root": "./root",
1757
+ "@/react/context-menu/context": "./context",
1758
+ "@/react/context-menu/styles": "./context-menu-styles",
1759
+ "@/core/context-menu": "./context-menu",
1760
+ "@/core/keys": "./keys",
1761
+ "@/core/clipboard-menu": "./clipboard-menu"
1762
+ }
1763
+ },
1764
+ {
1765
+ "path": "src/core/player.ts",
1766
+ "dest": "video/player.ts",
1767
+ "targets": [
1768
+ "react"
1769
+ ]
1770
+ },
1771
+ {
1772
+ "path": "src/react/video/types.ts",
1773
+ "dest": "video/types.ts",
1774
+ "targets": [
1775
+ "react"
1776
+ ],
1777
+ "rewrite": {
1778
+ "@/react/context-menu/context": "./context"
1779
+ }
1780
+ },
1781
+ {
1782
+ "path": "src/react/video/icons.tsx",
1783
+ "dest": "video/icons.tsx",
1784
+ "targets": [
1785
+ "react"
1786
+ ]
1787
+ },
1788
+ {
1789
+ "path": "src/react/video/styles.ts",
1790
+ "dest": "video/styles.ts",
1791
+ "targets": [
1792
+ "react"
1793
+ ]
1794
+ },
1795
+ {
1796
+ "path": "src/react/video/rail.tsx",
1797
+ "dest": "video/rail.tsx",
1798
+ "targets": [
1799
+ "react"
1800
+ ],
1801
+ "rewrite": {
1802
+ "@/core/player": "./player"
1803
+ }
1804
+ },
1805
+ {
1806
+ "path": "src/react/video/menu.tsx",
1807
+ "dest": "video/menu.tsx",
1808
+ "targets": [
1809
+ "react"
1810
+ ],
1811
+ "rewrite": {
1812
+ "@/core/context-menu": "./context-menu",
1813
+ "@/react/context-menu": "./context-menu-ui"
1814
+ }
1815
+ },
1816
+ {
1817
+ "path": "src/react/video/index.tsx",
1818
+ "dest": "video/index.tsx",
1819
+ "main": true,
1820
+ "targets": [
1821
+ "react"
1822
+ ],
1823
+ "rewrite": {
1824
+ "@/core/player": "./player",
1825
+ "@/react/video/icons": "./icons",
1826
+ "@/react/video/rail": "./rail",
1827
+ "@/react/video/styles": "./styles",
1828
+ "@/react/video/types": "./types",
1829
+ "@/react/context-menu/context": "./context",
1830
+ "@/react/video/menu": "./menu"
1831
+ }
1832
+ },
1833
+ {
1834
+ "path": "recipes/video/styles.css",
1835
+ "dest": "video/styles.css",
1836
+ "targets": [
1837
+ "react"
1838
+ ],
1839
+ "style": "css"
1840
+ }
1841
+ ],
1842
+ "styles": true,
1843
+ "themeHooks": [
1844
+ "[data-enigma-video]",
1845
+ "[data-enigma-video][data-playing]",
1846
+ "[data-enigma-video][data-hidden]",
1847
+ "[data-enigma-video][data-fullscreen]",
1848
+ "[data-enigma-video-big]",
1849
+ "[data-enigma-video-overlay]",
1850
+ "[data-enigma-video-spinner]",
1851
+ "[data-enigma-video-controls]",
1852
+ "[data-enigma-video-button]",
1853
+ "[data-enigma-video-time]",
1854
+ "[data-enigma-video-rail]",
1855
+ "[data-enigma-video-track]",
1856
+ "[data-enigma-video-buffer]",
1857
+ "[data-enigma-video-played]",
1858
+ "[data-enigma-video-knob]",
1859
+ "[data-enigma-video-volume]",
1860
+ "[data-enigma-video-menu]",
1861
+ "[data-enigma-video-panel]",
1862
+ "[data-enigma-video-option]",
1863
+ "[data-enigma-video-heading]",
1864
+ "[data-enigma-video][data-casting]"
1865
+ ],
1866
+ "docs": "docs/notes/primitives.md#video",
1867
+ "recipes": [
1868
+ "css"
1869
+ ]
1405
1870
  }
1406
1871
  ]
1407
1872
  }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The arithmetic behind the image viewer: zoom around a point, panning that cannot lose the
3
+ * image, moving through a set, and getting a file onto the disk.
4
+ *
5
+ * Not a React module, and deliberately: none of this is rendering. A page drawing its own
6
+ * viewer, or a test, gets the same maths - and the parts that are easy to get wrong (zoom
7
+ * that drifts away from the cursor, a pan that strands the image off screen, a download that
8
+ * silently opens a tab instead) are then testable without a browser.
9
+ */
10
+
11
+ /** How far the image is scaled, and where its centre sits relative to the frame's, in px. */
12
+ export interface Transform {
13
+ scale: number;
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ export interface ZoomLimits {
19
+ min: number;
20
+ max: number;
21
+ }
22
+
23
+ /** A box in viewport coordinates - the frame the image is being viewed in. */
24
+ export interface Box {
25
+ left: number;
26
+ top: number;
27
+ width: number;
28
+ height: number;
29
+ }
30
+
31
+ export const IDENTITY: Transform = { scale: 1, x: 0, y: 0 };
32
+
33
+ export const ZOOM_LIMITS: ZoomLimits = { min: 1, max: 8 };
34
+
35
+ /** What a keyboard `+`/`-` step multiplies by, and the ratio a double press toggles to. */
36
+ export const ZOOM_STEP = 1.35;
37
+ export const ZOOM_DOUBLE = 2.5;
38
+
39
+ export function clampScale(scale: number, limits: ZoomLimits = ZOOM_LIMITS): number {
40
+ return Math.min(Math.max(scale, limits.min), limits.max);
41
+ }
42
+
43
+ /**
44
+ * A wheel notch to a zoom factor.
45
+ *
46
+ * `deltaMode` matters: a mouse reports pixels, but Firefox reports LINES (mode 1) and a page
47
+ * scroll reports pages (mode 2), so treating every delta as pixels makes the same gesture
48
+ * zoom a hundred times harder in one browser than another. The exponential keeps a notch
49
+ * proportional - zooming out from 8x and in from 1x take the same number of turns.
50
+ */
51
+ export function wheelFactor(deltaY: number, deltaMode = 0): number {
52
+ const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * 400 : deltaY;
53
+ return Math.exp(-Math.min(Math.max(pixels, -240), 240) / 320);
54
+ }
55
+
56
+ /**
57
+ * Zoom, keeping the point under the pointer under the pointer.
58
+ *
59
+ * The defect this exists for: scaling around the frame's centre slides whatever the visitor
60
+ * was pointing at out from under them, so zooming into a face means zooming and then hunting
61
+ * for it again. The offset is corrected by how much that point moved when the scale changed.
62
+ */
63
+ export function zoomAt(current: Transform, factor: number, point: { x: number; y: number; }, box: Box, limits: ZoomLimits = ZOOM_LIMITS): Transform {
64
+ const scale = clampScale(current.scale * factor, limits);
65
+ if (scale === current.scale) return current;
66
+
67
+ // Where the pointer is relative to the frame's centre, which is what the offsets are
68
+ // measured from.
69
+ const dx = point.x - (box.left + box.width / 2);
70
+ const dy = point.y - (box.top + box.height / 2);
71
+ const ratio = scale / current.scale;
72
+
73
+ return { scale, x: dx - (dx - current.x) * ratio, y: dy - (dy - current.y) * ratio };
74
+ }
75
+
76
+ /**
77
+ * Keep the image over the frame.
78
+ *
79
+ * At 1x it is centred and there is nothing to pan. Zoomed, the offset is bounded by the half
80
+ * of the image that hangs outside the frame - without it a drag can throw the picture off the
81
+ * screen entirely, and the only way back is to close the viewer.
82
+ */
83
+ export function clampPan(transform: Transform, box: Box, natural: { width: number; height: number; }): Transform {
84
+ const width = natural.width * transform.scale;
85
+ const height = natural.height * transform.scale;
86
+ const x = Math.max(0, (width - box.width) / 2);
87
+ const y = Math.max(0, (height - box.height) / 2);
88
+
89
+ return {
90
+ scale: transform.scale,
91
+ x: Math.min(Math.max(transform.x, -x), x),
92
+ y: Math.min(Math.max(transform.y, -y), y)
93
+ };
94
+ }
95
+
96
+ /** The size an image is DRAWN at inside a frame it is fitted to, which is what pan is bounded by. */
97
+ export function fittedSize(natural: { width: number; height: number; }, box: Box): { width: number; height: number; } {
98
+ if (!natural.width || !natural.height) return { width: box.width, height: box.height };
99
+ const ratio = Math.min(box.width / natural.width, box.height / natural.height, 1);
100
+ return { width: natural.width * ratio, height: natural.height * ratio };
101
+ }
102
+
103
+ /** The distance between two pointers, for a pinch. */
104
+ export function pinchDistance(a: { x: number; y: number; }, b: { x: number; y: number; }): number {
105
+ return Math.hypot(a.x - b.x, a.y - b.y);
106
+ }
107
+
108
+ /**
109
+ * The next index in a set, skipping what has been discarded.
110
+ *
111
+ * Returns -1 when there is nothing left to show, which is the viewer's cue to close rather
112
+ * than to sit on an empty frame.
113
+ */
114
+ export function nextIndex(index: number, length: number, step: number, options: { loop?: boolean; skip?: ReadonlySet<number>; } = {}): number {
115
+ const { loop = true, skip } = options;
116
+ if (length <= 0) return -1;
117
+
118
+ let next = index;
119
+ // At most one full pass: with everything else discarded the walk would never stop.
120
+ for (let moved = 0; moved < length; moved += 1) {
121
+ next += step;
122
+ if (next < 0 || next >= length) {
123
+ if (!loop) return -1;
124
+ next = (next % length + length) % length;
125
+ }
126
+ if (!skip?.has(next)) return next;
127
+ }
128
+ return -1;
129
+ }
130
+
131
+ /** The file name to save as: whatever the caller asked for, else the one in the URL. */
132
+ export function filenameFrom(url: string, fallback = "image"): string {
133
+ try {
134
+ const { pathname } = new URL(url, typeof location === "undefined" ? "https://localhost" : location.href);
135
+ const name = pathname.split("/").filter(Boolean).pop();
136
+ return name && name.includes(".") ? decodeURIComponent(name) : fallback;
137
+ } catch {
138
+ return fallback;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Save the file, rather than navigate to it.
144
+ *
145
+ * `<a download>` is honoured only for a same-origin URL; on a CDN the browser ignores the
146
+ * attribute and opens the image in a tab instead, which is not what the row said it would do.
147
+ * So the bytes are fetched and handed over as a blob, and the plain anchor is the fallback for
148
+ * when that is refused (no CORS header) - a tab is still better than nothing happening.
149
+ */
150
+ export async function downloadFile(url: string, filename?: string): Promise<void> {
151
+ const name = filename ?? filenameFrom(url);
152
+ try {
153
+ const response = await fetch(url, { mode: "cors", credentials: "omit" });
154
+ if (!response.ok) throw new Error(`the file answered ${response.status}`);
155
+ const blob = await response.blob();
156
+ const href = URL.createObjectURL(blob);
157
+ saveAs(href, name);
158
+ // Revoked on the next task, not immediately: Safari has not started reading yet.
159
+ setTimeout(() => URL.revokeObjectURL(href), 10_000);
160
+ } catch {
161
+ saveAs(url, name, true);
162
+ }
163
+ }
164
+
165
+ function saveAs(href: string, name: string, external = false): void {
166
+ if (typeof document === "undefined") return;
167
+ const anchor = document.createElement("a");
168
+ anchor.href = href;
169
+ anchor.download = name;
170
+ anchor.rel = "noopener";
171
+ if (external) anchor.target = "_blank";
172
+ document.body.append(anchor);
173
+ anchor.click();
174
+ anchor.remove();
175
+ }
176
+
177
+ /* -------- the flight from the thumbnail into the viewer -------- */
178
+
179
+ /** How long the picture takes to fly, when the stylesheet does not say otherwise. */
180
+ export const FLIGHT_MS = 300;
181
+
182
+ /**
183
+ * How long the flight ACTUALLY lasts, read off the element.
184
+ *
185
+ * The duration lives in the stylesheet (`--enigma-image-flight`), so a project that slows it
186
+ * down would otherwise have the viewer give up waiting halfway through and close mid-flight.
187
+ * The constant above is only the answer for an element with no transition on it at all.
188
+ */
189
+ export function flightMs(element: Element | null): number {
190
+ if (!element || typeof getComputedStyle === "undefined") return FLIGHT_MS;
191
+ const seconds = Number.parseFloat(getComputedStyle(element).transitionDuration);
192
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : FLIGHT_MS;
193
+ }
194
+
195
+ /**
196
+ * The transform that puts a picture exactly over another box.
197
+ *
198
+ * This is the FLIP the lightbox opens with: the full-size image is laid out where it belongs,
199
+ * measured, and then drawn back ON the thumbnail with this transform - so releasing it to the
200
+ * identity transform is the picture growing out of the one in the page rather than a dialog
201
+ * appearing over it. Closing is the same transform applied in the other direction.
202
+ *
203
+ * Both boxes are the same picture, so one scale is enough; the width is used because a
204
+ * thumbnail is nearly always constrained by it. Null when either box has not been laid out
205
+ * yet, which is the caller's cue to skip the animation rather than to divide by zero.
206
+ */
207
+ export function flightFrom(from: Box, to: Box): Transform | null {
208
+ if (!from.width || !from.height || !to.width || !to.height) return null;
209
+ return {
210
+ scale: from.width / to.width,
211
+ // Centre to centre: `translate(x, y) scale(s)` scales about the element's own centre
212
+ // first, so the offset that lands it on the thumbnail is the distance between them.
213
+ x: (from.left + from.width / 2) - (to.left + to.width / 2),
214
+ y: (from.top + from.height / 2) - (to.top + to.height / 2)
215
+ };
216
+ }
217
+
218
+ /** Whether the reader has asked for less movement. Every animation here is skipped when they have. */
219
+ export function prefersReducedMotion(): boolean {
220
+ if (typeof window === "undefined" || !window.matchMedia) return false;
221
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
222
+ }