tweakit 0.6.0 → 0.7.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 (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -1,5 +1,5 @@
1
1
  // ── Image — a drop zone / file pick that yields a data URL; with `options`, a gallery of supplied URLs over the upload. Lazy.
2
- import { el, txt, btn, icon, optValue, radioButton, navIndex, popover, registerControl } from "../shared.js";
2
+ import { el, txt, btn, icon, optValue, radioButton, navIndex, popover, registerControl, words } from "../shared.js";
3
3
  import { triggerRow } from "../heavy.js";
4
4
  import type { OnChange, RadioBtn } from "../shared.js";
5
5
  import type { Meta } from "../schema.js";
@@ -7,100 +7,181 @@ import type { Control } from "../types.js";
7
7
 
8
8
  // Escape for a CSS string literal: the quote, the backslash and control characters (a raw newline ends a CSS string),
9
9
  // so a stored `a.png"), url("https://x/p.png` is one broken image, not a second background layer and a fetch.
10
- const cssString = (s: string) => String(s).replace(/[\\"]/g, "\\$&").replace(/[\x00-\x1f\x7f]/g, (c) => "\\" + c.charCodeAt(0).toString(16) + " ");
10
+ const cssString = (s: string) =>
11
+ String(s)
12
+ .replace(/[\\"]/g, "\\$&")
13
+ .replace(/[\x00-\x1f\x7f]/g, (c) => "\\" + c.charCodeAt(0).toString(16) + " ");
11
14
  const urlCss = (s: string) => `url("${cssString(s)}")`;
12
15
  const MB = 1 << 20;
13
- const size = (n: number) => (n >= MB ? `${Math.round((n / MB) * 10) / 10} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`);
16
+ const size = (n: number) =>
17
+ n >= MB ? `${Math.round((n / MB) * 10) / 10} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
14
18
  // A tile's accessible name when its option has no label: the URL's file stem; a data URL has none.
15
- const stem = (url: string) => { if (/^data:/i.test(url)) return ""; const s = url.split(/[?#]/)[0].split("/").pop()!.replace(/\.\w+$/, ""); try { return decodeURIComponent(s); } catch { return s; } };
16
- // Pixels, not bytes, are what make an upload heavy: a phone photo is ~4 MB as a data URL, and storage holds ~5 MB an origin,
17
- // so one upload could keep every value from saving. A picture longer than `fit` on its long side is drawn down to it before
18
- // it becomes the param, and one within `fit` but heavier than 1.5 MB is redrawn at its size. A JPEG stays a JPEG, at `quality`. Anything else is tried as a PNG first, so a screenshot or text
19
- // isn't smudged, and kept as one up to 1.5 MB: a photo's PNG at 2560px is several times that, while a screenshot's is not.
20
- // (It's weighed by size, not against the JPEG: drawn down, even a screenshot's PNG runs about twice its JPEG.) Past that it
21
- // is a JPEG, or with transparency a WebP where the browser writes one, whichever is smaller than the PNG. An SVG has no
22
- // pixels to fit, and a GIF, an animated WebP or an APNG would keep only its first frame, so they are taken as they are.
23
- // Resolves "" to take the file as it is: it fits and is light, its format is kept, the browser can't decode or draw it, or
24
- // the redraw came out no smaller.
19
+ const stem = (url: string) => {
20
+ if (/^data:/i.test(url)) return "";
21
+ const s = url
22
+ .split(/[?#]/)[0]
23
+ .split("/")
24
+ .pop()!
25
+ .replace(/\.\w+$/, "");
26
+ try {
27
+ return decodeURIComponent(s);
28
+ } catch {
29
+ return s;
30
+ }
31
+ };
32
+ // Pixels, not bytes, are what make an upload heavy: a phone photo is ~4 MB as a data URL, and storage holds ~5 MB an
33
+ // origin, so one upload could keep every value from saving. A picture longer than `fit` on its long side is drawn down
34
+ // to it before it becomes the param, and one within `fit` but heavier than 1.5 MB is redrawn at its size. A JPEG stays
35
+ // a JPEG, at `quality`. Anything else is tried as a PNG first, so a screenshot or text isn't smudged, and kept as one
36
+ // up to 1.5 MB: a photo's PNG at 2560px is several times that, while a screenshot's is not.
37
+ // (It's weighed by size, not against the JPEG: drawn down, even a screenshot's PNG runs about twice its JPEG.) Past
38
+ // that it is a JPEG, or with transparency a WebP where the browser writes one, whichever is smaller than the PNG. An
39
+ // SVG has no pixels to fit, and a GIF, an animated WebP or an APNG would keep only its first frame, so they are taken
40
+ // as they are.
41
+ // Resolves "" to take the file as it is: it fits and is light, its format is kept, the browser can't decode or draw it,
42
+ // or the redraw came out no smaller.
25
43
  const KEEP = /^image\/(gif|svg\+xml)$/i;
26
44
  const PNG_BUDGET = 1.5 * MB;
27
45
  const PNG_SIG = "\x89PNG\r\n\x1a\n";
28
- const ascii = (b: Uint8Array, at: number, s: string) => { for (let k = 0; k < s.length; k++) if (b[at + k] !== s.charCodeAt(k)) return false; return true; };
46
+ const ascii = (b: Uint8Array, at: number, s: string) => {
47
+ for (let k = 0; k < s.length; k++) if (b[at + k] !== s.charCodeAt(k)) return false;
48
+ return true;
49
+ };
29
50
  // The file says it's animated before its first frame: WebP in VP8X's animation flag or an ANIM chunk, a PNG in an acTL
30
51
  // chunk ahead of its first IDAT. Only chunk headers are read, never the frames, however many chunks come first.
31
52
  export const animated = async (file: Blob) => {
32
53
  const at = async (i: number, n: number) => new Uint8Array(await file.slice(i, i + n).arrayBuffer());
33
- const h = await at(0, 21), webp = ascii(h, 0, "RIFF") && ascii(h, 8, "WEBP");
54
+ const h = await at(0, 21),
55
+ webp = ascii(h, 0, "RIFF") && ascii(h, 8, "WEBP");
34
56
  if (webp && ascii(h, 12, "VP8X") && h[20] & 2) return true;
35
57
  if (!webp && !ascii(h, 0, PNG_SIG)) return false;
36
- for (let i = webp ? 12 : 8; ; ) {
37
- const c = await at(i, 8); if (c.length < 8) return false;
58
+ for (let i = webp ? 12 : 8; ;) {
59
+ const c = await at(i, 8);
60
+ if (c.length < 8) return false;
38
61
  const type = String.fromCharCode(...c.subarray(webp ? 0 : 4, webp ? 4 : 8));
39
62
  if (type === (webp ? "ANIM" : "acTL")) return true;
40
63
  if (webp ? /^(VP8 |VP8L|ANMF)$/.test(type) : type === "IDAT") return false; // the picture has begun
41
- const n = new DataView(c.buffer).getUint32(webp ? 4 : 0, webp); // a WebP chunk's size is little-endian and padded to even
64
+ // a WebP chunk's size is little-endian and padded to even
65
+ const n = new DataView(c.buffer).getUint32(webp ? 4 : 0, webp);
42
66
  i += webp ? 8 + n + (n & 1) : 12 + n; // at least a header on, so the end of the file comes
43
67
  }
44
68
  };
45
- // A picture taken as it is keeps its bytes, and a phone's carry where it was taken: Exif's GPS, and XMP, which can repeat
46
- // it. The value is stored and copied as JSON into source, so those blocks are cut from the bytes, losslessly: the ICC
47
- // profile, which carries a wide gamut, and everything else stays. Exif's Orientation decides which way up the picture
48
- // shows, so a turned one gets a bare Exif back holding only that tag. A redraw needs none of this: a canvas writes no Exif.
69
+ // A picture taken as it is keeps its bytes, and a phone's carry where it was taken: Exif's GPS, and XMP, which can
70
+ // repeat it. The value is stored and copied as JSON into source, so those blocks are cut from the bytes, losslessly:
71
+ // the ICC profile, which carries a wide gamut, and everything else stays. Exif's Orientation decides which way up the
72
+ // picture shows, so a turned one gets a bare Exif back holding only that tag. A redraw needs none of this: a canvas
73
+ // writes no Exif.
49
74
  // Each parser hands back the bytes left, or null to keep the file whole: nothing to cut, or a block it can't read.
50
75
  const orientation = (t: Uint8Array) => {
51
- const le = ascii(t, 0, "II"), v = new DataView(t.buffer, t.byteOffset, t.byteLength); if (!le && !ascii(t, 0, "MM")) return null;
76
+ const le = ascii(t, 0, "II"),
77
+ v = new DataView(t.buffer, t.byteOffset, t.byteLength);
78
+ if (!le && !ascii(t, 0, "MM")) return null;
79
+ // an offset past the end
52
80
  try {
53
81
  const ifd = v.getUint32(4, le);
54
- for (let k = 0, n = v.getUint16(ifd, le); k < n; k++) if (v.getUint16(ifd + 2 + k * 12, le) === 0x112) { const o = v.getUint16(ifd + 10 + k * 12, le); return o > 1 && o <= 8 ? o : 1; }
82
+ for (let k = 0, n = v.getUint16(ifd, le); k < n; k++)
83
+ if (v.getUint16(ifd + 2 + k * 12, le) === 0x112) {
84
+ const o = v.getUint16(ifd + 10 + k * 12, le);
85
+ return o > 1 && o <= 8 ? o : 1;
86
+ }
55
87
  return 1;
56
- } catch { return null; } // an offset past the end
88
+ } catch {
89
+ return null;
90
+ }
57
91
  };
58
92
  // A big-endian TIFF header and one IFD with the one entry: Orientation, a SHORT.
59
- const bareExif = (o: number) => new Uint8Array([77, 77, 0, 42, 0, 0, 0, 8, 0, 1, 1, 0x12, 0, 3, 0, 0, 0, 1, 0, o, 0, 0, 0, 0, 0, 0]);
93
+ const bareExif = (o: number) =>
94
+ new Uint8Array([77, 77, 0, 42, 0, 0, 0, 8, 0, 1, 1, 0x12, 0, 3, 0, 0, 0, 1, 0, o, 0, 0, 0, 0, 0, 0]);
60
95
  type Bytes = Uint8Array<ArrayBuffer>; // what a Blob takes: never a SharedArrayBuffer
61
- const crc32 = (b: Uint8Array) => { let c = ~0; for (const x of b) { c ^= x; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return ~c >>> 0; };
62
- // An Ultra HDR JPEG's XMP carries its gain map (hdrgm:), the HDR itself, so it stays when every namespace it declares and
63
- // every prefix it uses is the gain map's or its container's: nothing there can say where or who. Anything more, and it's cut.
96
+ const crc32 = (b: Uint8Array) => {
97
+ let c = ~0;
98
+ for (const x of b) {
99
+ c ^= x;
100
+ for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
101
+ }
102
+ return ~c >>> 0;
103
+ };
104
+ // An Ultra HDR JPEG's XMP carries its gain map (hdrgm:), the HDR itself, so it stays when every namespace it declares
105
+ // and every prefix it uses is the gain map's or its container's: nothing there can say where or who. Anything more, and
106
+ // it's cut.
64
107
  const XMP = "http://ns.adobe.com/xap/1.0/\0";
65
- const GAIN_NS = ["adobe:ns:meta/", "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "http://ns.adobe.com/hdr-gain-map/1.0/", "http://ns.google.com/photos/1.0/container/", "http://ns.google.com/photos/1.0/container/item/"];
108
+ const GAIN_NS = [
109
+ "adobe:ns:meta/",
110
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
111
+ "http://ns.adobe.com/hdr-gain-map/1.0/",
112
+ "http://ns.google.com/photos/1.0/container/",
113
+ "http://ns.google.com/photos/1.0/container/item/",
114
+ ];
66
115
  const gainMapOnly = (b: Uint8Array) => {
67
- const x = new TextDecoder("latin1").decode(b), ok = new Set(["xmlns", "xml"]);
116
+ const x = new TextDecoder("latin1").decode(b),
117
+ ok = new Set(["xmlns", "xml"]);
68
118
  if (!x.includes(GAIN_NS[2])) return false;
69
- for (const [, p, ns] of x.matchAll(/xmlns(?::([\w.-]+))?\s*=\s*["']([^"']*)["']/g)) { if (!GAIN_NS.includes(ns)) return false; if (p) ok.add(p); }
119
+ for (const [, p, ns] of x.matchAll(/xmlns(?::([\w.-]+))?\s*=\s*["']([^"']*)["']/g)) {
120
+ if (!GAIN_NS.includes(ns)) return false;
121
+ if (p) ok.add(p);
122
+ }
70
123
  for (const [, p] of x.matchAll(/[<\s/]([\w.-]+):[\w.-]/g)) if (!ok.has(p)) return false;
71
124
  return true;
72
125
  };
73
126
  // An MPF JPEG (Ultra HDR's gain map, a camera's preview) indexes the images after the first by their offset from its MP
74
- // header, so a cut between that header and the first image's end moves them all up by the bytes cut. A list that doesn't
75
- // read is left as it was. The images after the first are left whole, their own Exif or XMP with them: the MP index and
76
- // the gain-map XMP's Item:Length both record their sizes, so cutting into them means rewriting both, and a gain map's XMP
77
- // is its HDR.
127
+ // header, so a cut between that header and the first image's end moves them all up by the bytes cut. A list that
128
+ // doesn't read is left as it was. The images after the first are left whole, their own Exif or XMP with them: the MP
129
+ // index and the gain-map XMP's Item:Length both record their sizes, so cutting into them means rewriting both, and a
130
+ // gain map's XMP is its HDR.
78
131
  const mpfShift = (seg: Uint8Array, by: number) => {
79
- const le = ascii(seg, 0, "II"), v = new DataView(seg.buffer, seg.byteOffset, seg.byteLength); if (!le && !ascii(seg, 0, "MM")) return;
132
+ const le = ascii(seg, 0, "II"),
133
+ v = new DataView(seg.buffer, seg.byteOffset, seg.byteLength);
134
+ if (!le && !ascii(seg, 0, "MM")) return;
80
135
  try {
81
136
  const ifd = v.getUint32(4, le);
82
137
  for (let k = 0, n = v.getUint16(ifd, le); k < n; k++) {
83
- const e = ifd + 2 + k * 12; if (v.getUint16(e, le) !== 0xb002) continue; // MPEntry: 16 bytes an image, its offset at 8
84
- const list = v.getUint32(e + 8, le), count = v.getUint32(e + 4, le) >>> 4;
138
+ // MPEntry: 16 bytes an image, its offset at 8
139
+ const e = ifd + 2 + k * 12;
140
+ if (v.getUint16(e, le) !== 0xb002) continue;
141
+ const list = v.getUint32(e + 8, le),
142
+ count = v.getUint32(e + 4, le) >>> 4;
85
143
  if (count) v.getUint32(list + count * 16 - 4, le); // the whole list is in the segment, or nothing is written
86
- for (let j = 0; j < count; j++) { const o = list + j * 16 + 8, off = v.getUint32(o, le); if (off > by) v.setUint32(o, off - by, le); }
144
+ for (let j = 0; j < count; j++) {
145
+ const o = list + j * 16 + 8,
146
+ off = v.getUint32(o, le);
147
+ if (off > by) v.setUint32(o, off - by, le);
148
+ }
87
149
  }
88
- } catch { /* an offset past the segment */ }
150
+ } catch {
151
+ /* an offset past the segment */
152
+ }
89
153
  };
90
154
  const jpegCut = (b: Bytes) => {
91
155
  if (b[0] !== 0xff || b[1] !== 0xd8) return null;
92
- const out: Bytes[] = []; let i = 2, from = 0, turned = 0, mpf: Uint8Array | null = null, moved = 0;
156
+ const out: Bytes[] = [];
157
+ let i = 2,
158
+ from = 0,
159
+ turned = 0,
160
+ mpf: Uint8Array | null = null,
161
+ moved = 0;
93
162
  for (;;) {
94
163
  while (b[i] === 0xff && b[i + 1] === 0xff) i++; // fill bytes before a marker (T.81 B.1.1.2) stay with what's kept
95
- const m = b[i + 1], end = i + 2 + ((b[i + 2] << 8) | b[i + 3]);
96
- if (b[i] !== 0xff || m === 0xff || m === 1 || (m >= 0xd0 && m <= 0xd9) || end < i + 4 || end > b.length) return null; // a marker out of place
164
+ const m = b[i + 1],
165
+ end = i + 2 + ((b[i + 2] << 8) | b[i + 3]);
166
+ // a marker out of place
167
+ if (b[i] !== 0xff || m === 0xff || m === 1 || (m >= 0xd0 && m <= 0xd9) || end < i + 4 || end > b.length)
168
+ return null;
97
169
  if (m === 0xda) break; // start of scan: the picture itself, kept to the end
98
170
  if (m === 0xe2 && ascii(b, i + 4, "MPF\0")) mpf = b.subarray(i + 8, end);
99
171
  const exif = m === 0xe1 && ascii(b, i + 4, "Exif\0");
100
- if (exif || (m === 0xe1 && ascii(b, i + 4, "http://ns.adobe.com/x") && !(ascii(b, i + 4, XMP) && gainMapOnly(b.subarray(i + 4 + XMP.length, end))))) { // XMP, and its extension
101
- const o = exif ? orientation(b.subarray(i + 10, end)) : 1; if (o === null) return null;
102
- out.push(b.subarray(from, i)); from = end;
103
- const bare = o > 1 && !turned++; if (bare) out.push(new Uint8Array([0xff, 0xe1, 0, 34, 69, 120, 105, 102, 0, 0, ...bareExif(o)]));
172
+ // XMP, and its extension
173
+ if (
174
+ exif ||
175
+ (m === 0xe1 &&
176
+ ascii(b, i + 4, "http://ns.adobe.com/x") &&
177
+ !(ascii(b, i + 4, XMP) && gainMapOnly(b.subarray(i + 4 + XMP.length, end))))
178
+ ) {
179
+ const o = exif ? orientation(b.subarray(i + 10, end)) : 1;
180
+ if (o === null) return null;
181
+ out.push(b.subarray(from, i));
182
+ from = end;
183
+ const bare = o > 1 && !turned++;
184
+ if (bare) out.push(new Uint8Array([0xff, 0xe1, 0, 34, 69, 120, 105, 102, 0, 0, ...bareExif(o)]));
104
185
  if (mpf) moved += end - i - (bare ? 36 : 0);
105
186
  }
106
187
  i = end;
@@ -110,19 +191,31 @@ const jpegCut = (b: Bytes) => {
110
191
  };
111
192
  const pngCut = (b: Bytes) => {
112
193
  if (!ascii(b, 0, PNG_SIG)) return null;
113
- const v = new DataView(b.buffer, b.byteOffset, b.byteLength), out: Bytes[] = [];
114
- let i = 8, from = 0, turned = 0;
115
- for (let type = ""; type !== "IEND"; ) {
116
- const end = i + 12 > b.length ? Infinity : i + 12 + v.getUint32(i); if (end > b.length) return null;
194
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength),
195
+ out: Bytes[] = [];
196
+ let i = 8,
197
+ from = 0,
198
+ turned = 0;
199
+ for (let type = ""; type !== "IEND";) {
200
+ const end = i + 12 > b.length ? Infinity : i + 12 + v.getUint32(i);
201
+ if (end > b.length) return null;
117
202
  type = String.fromCharCode(...b.subarray(i + 4, i + 8));
118
203
  // A text chunk is metadata by its keyword: XMP's, or ImageMagick's raw Exif and XMP. Other text stays.
119
- const key = /^[tiz]TXt$/.test(type) ? String.fromCharCode(...b.subarray(i + 8, Math.min(end - 4, i + 88))).split("\0")[0] : "";
204
+ const key = /^[tiz]TXt$/.test(type)
205
+ ? String.fromCharCode(...b.subarray(i + 8, Math.min(end - 4, i + 88))).split("\0")[0]
206
+ : "";
120
207
  if (type === "eXIf" || /^(XML:com\.adobe\.xmp|Raw profile type (exif|app1|xmp))$/i.test(key)) {
121
- const o = type === "eXIf" ? orientation(b.subarray(i + 8, end - 4)) : 1; if (o === null) return null;
122
- out.push(b.subarray(from, i)); from = end;
208
+ const o = type === "eXIf" ? orientation(b.subarray(i + 8, end - 4)) : 1;
209
+ if (o === null) return null;
210
+ out.push(b.subarray(from, i));
211
+ from = end;
123
212
  if (o > 1 && !turned++) {
124
- const c = new Uint8Array(38), cv = new DataView(c.buffer);
125
- cv.setUint32(0, 26); c.set([101, 88, 73, 102], 4); c.set(bareExif(o), 8); cv.setUint32(34, crc32(c.subarray(4, 34)));
213
+ const c = new Uint8Array(38),
214
+ cv = new DataView(c.buffer);
215
+ cv.setUint32(0, 26);
216
+ c.set([101, 88, 73, 102], 4);
217
+ c.set(bareExif(o), 8);
218
+ cv.setUint32(34, crc32(c.subarray(4, 34)));
126
219
  out.push(c);
127
220
  }
128
221
  }
@@ -131,28 +224,64 @@ const pngCut = (b: Bytes) => {
131
224
  return out.length ? [...out, b.subarray(from)] : null;
132
225
  };
133
226
  export const strip = async (file: File): Promise<Blob> => {
134
- try { const b = new Uint8Array(await file.arrayBuffer()), parts = jpegCut(b) || pngCut(b); return parts ? new Blob(parts, { type: file.type }) : file; } catch { return file; }
227
+ try {
228
+ const b = new Uint8Array(await file.arrayBuffer()),
229
+ parts = jpegCut(b) || pngCut(b);
230
+ return parts ? new Blob(parts, { type: file.type }) : file;
231
+ } catch {
232
+ return file;
233
+ }
234
+ };
235
+ const hasAlpha = (ctx: CanvasRenderingContext2D, w: number, h: number) => {
236
+ const d = ctx.getImageData(0, 0, w, h).data;
237
+ for (let i = 3; i < d.length; i += 4) if (d[i] < 255) return true;
238
+ return false;
135
239
  };
136
- const hasAlpha = (ctx: CanvasRenderingContext2D, w: number, h: number) => { const d = ctx.getImageData(0, 0, w, h).data; for (let i = 3; i < d.length; i += 4) if (d[i] < 255) return true; return false; };
137
- // toBlob encodes off the main thread where the browser can. null when it can't write that type (it hands back a PNG instead).
138
- const encode = (c: HTMLCanvasElement, type: string, q?: number) => new Promise<Blob | null>((r) => c.toBlob((b) => r(b && b.type === type ? b : null), type, q));
139
- const dataUrl = (b: Blob) => new Promise<string>((r, j) => { const fr = new FileReader(); fr.onload = () => r(String(fr.result)); fr.onerror = j; fr.readAsDataURL(b); });
240
+ // toBlob encodes off the main thread where the browser can. null when it can't write that type (it hands back a PNG
241
+ // instead).
242
+ const encode = (c: HTMLCanvasElement, type: string, q?: number) =>
243
+ new Promise<Blob | null>((r) => c.toBlob((b) => r(b && b.type === type ? b : null), type, q));
244
+ const dataUrl = (b: Blob) =>
245
+ new Promise<string>((r, j) => {
246
+ const fr = new FileReader();
247
+ fr.onload = () => r(String(fr.result));
248
+ fr.onerror = j;
249
+ fr.readAsDataURL(b);
250
+ });
140
251
  // A 2D context is sRGB unless asked, and a Display P3 photo (every iPhone's) drawn into it is flattened to sRGB. A P3
141
252
  // canvas keeps the gamut, and toBlob tags what it writes with the P3 profile. A browser that doesn't know the option
142
253
  // ignores it and hands back sRGB, as before; one that refuses the value throws, and gets the plain context.
143
- const context = (c: HTMLCanvasElement) => { try { const ctx = c.getContext("2d", { colorSpace: "display-p3" }); if (ctx) return ctx; } catch { /* the plain context */ } return c.getContext("2d"); };
254
+ const context = (c: HTMLCanvasElement) => {
255
+ try {
256
+ const ctx = c.getContext("2d", { colorSpace: "display-p3" });
257
+ if (ctx) return ctx;
258
+ } catch {
259
+ /* the plain context */
260
+ }
261
+ return c.getContext("2d");
262
+ };
144
263
  const shrink = async (file: File, fit: number, quality: number): Promise<string> => {
145
- if (!fit || KEEP.test(file.type) || typeof Image === "undefined" || typeof URL.createObjectURL !== "function") return "";
264
+ if (!fit || KEEP.test(file.type) || typeof Image === "undefined" || typeof URL.createObjectURL !== "function")
265
+ return "";
146
266
  let src = "";
147
267
  try {
148
268
  if (await animated(file)) return "";
149
269
  src = URL.createObjectURL(file);
150
- const img = new Image(); img.src = src; await img.decode();
151
- const iw = img.naturalWidth, ih = img.naturalHeight, k = Math.min(1, fit / Math.max(iw, ih));
152
- if (!(iw > 0 && ih > 0) || (k === 1 && file.size <= PNG_BUDGET)) return ""; // within fit and light: a picture already shrunk is never redrawn
153
- const c = document.createElement("canvas"), w = (c.width = Math.max(1, Math.round(iw * k))), h = (c.height = Math.max(1, Math.round(ih * k)));
154
- const ctx = context(c); if (!ctx) return "";
155
- ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, w, h);
270
+ const img = new Image();
271
+ img.src = src;
272
+ await img.decode();
273
+ const iw = img.naturalWidth,
274
+ ih = img.naturalHeight,
275
+ k = Math.min(1, fit / Math.max(iw, ih));
276
+ // within fit and light: a picture already shrunk is never redrawn
277
+ if (!(iw > 0 && ih > 0) || (k === 1 && file.size <= PNG_BUDGET)) return "";
278
+ const c = document.createElement("canvas"),
279
+ w = (c.width = Math.max(1, Math.round(iw * k))),
280
+ h = (c.height = Math.max(1, Math.round(ih * k)));
281
+ const ctx = context(c);
282
+ if (!ctx) return "";
283
+ ctx.imageSmoothingQuality = "high";
284
+ ctx.drawImage(img, 0, 0, w, h);
156
285
  const jpeg = /^image\/jpe?g$/i.test(file.type);
157
286
  let out = jpeg ? null : await encode(c, "image/png");
158
287
  if (!out || out.size > PNG_BUDGET) {
@@ -160,140 +289,267 @@ const shrink = async (file: File, fit: number, quality: number): Promise<string>
160
289
  if (alt && (!out || alt.size < out.size)) out = alt;
161
290
  }
162
291
  return out && out.size < file.size ? await dataUrl(out) : "";
163
- } catch { return ""; } finally { if (src) URL.revokeObjectURL(src); }
292
+ } catch {
293
+ return "";
294
+ } finally {
295
+ if (src) URL.revokeObjectURL(src);
296
+ }
164
297
  };
165
298
  const ICON_UPLOAD = icon('<path d="M12 15V4m-4.5 4.5L12 4l4.5 4.5M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>');
166
299
 
167
300
  function createImage(meta: Meta, onChange: OnChange): Control {
301
+ const tr = words();
168
302
  let value = typeof meta.value === "string" ? meta.value : "";
169
- // Options: URLs, or { value, label }; anything but a non-empty string value is skipped, and a repeated URL keeps its first.
303
+ // Options: URLs, or { value, label }; anything but a non-empty string value is skipped, and a repeated URL keeps its
304
+ // first.
170
305
  const seen = new Set<string>();
171
- const options = (Array.isArray(meta.options) ? meta.options : []).map((o) => ({ value: optValue(o), label: o && typeof o === "object" && o.label != null ? String(o.label) : "" })).filter((o): o is { value: string; label: string } => typeof o.value === "string" && !!o.value && !seen.has(o.value) && !!seen.add(o.value));
172
- const canUpload = meta.upload !== false || !options.length; // the plain row is the upload: there is nothing else to leave
306
+ const options = (Array.isArray(meta.options) ? meta.options : [])
307
+ .map((o) => ({ value: optValue(o), label: o && typeof o === "object" && o.label != null ? String(o.label) : "" }))
308
+ .filter(
309
+ (o): o is { value: string; label: string } =>
310
+ typeof o.value === "string" && !!o.value && !seen.has(o.value) && !!seen.add(o.value),
311
+ );
312
+ // the plain row is the upload: there is nothing else to leave
313
+ const canUpload = meta.upload !== false || !options.length;
173
314
  const canClear = meta.clear !== false; // false: a required choice, with no Clear and no Delete to empty it
174
315
  const maxSize = +meta.maxSize! > 0 ? +meta.maxSize! : 10 * MB;
175
316
  // A number only: `fit: true` would read as 1 and draw every upload as one pixel.
176
- const fit = meta.fit === false || meta.fit === 0 ? 0 : typeof meta.fit === "number" && meta.fit >= 1 ? Math.round(meta.fit) : 2560; // false or 0: uploads as they are
317
+ // false or 0: uploads as they are
318
+ const fit =
319
+ meta.fit === false || meta.fit === 0
320
+ ? 0
321
+ : typeof meta.fit === "number" && meta.fit >= 1
322
+ ? Math.round(meta.fit)
323
+ : 2560;
177
324
  const quality = typeof meta.quality === "number" && meta.quality > 0 && meta.quality <= 1 ? meta.quality : 0.85;
178
- const input = el("input", "tw-image-input"); input.type = "file"; input.accept = "image/*";
325
+ const input = el("input", "twk-image-input");
326
+ input.type = "file";
327
+ input.accept = "image/*";
179
328
  // A refused file's reason takes the button text's place for a moment. It is spoken from a status region beside the
180
329
  // button, not inside it: a button's children are presentational, so a live region there is never read.
181
- const note = el("span", "tw-image-note"), live = el("span", "tw-image-live"); live.setAttribute("role", "status");
330
+ const note = el("span", "twk-image-note"),
331
+ live = el("span", "twk-image-live");
332
+ live.setAttribute("role", "status");
182
333
  const notes = [note, live]; // a gallery adds its row's note, so a file refused on the closed row still shows why
183
334
  let noteTimer: ReturnType<typeof setTimeout>;
184
- const say = (msg: string) => { for (const n of notes) n.textContent = msg; clearTimeout(noteTimer); noteTimer = setTimeout(() => { for (const n of notes) n.textContent = ""; }, 2600); };
335
+ const say = (msg: string) => {
336
+ for (const n of notes) n.textContent = msg;
337
+ clearTimeout(noteTimer);
338
+ noteTimer = setTimeout(() => {
339
+ for (const n of notes) n.textContent = "";
340
+ }, 2600);
341
+ };
185
342
  // An off-list value's name: its file stem, else what it is. A blob: URL's stem is a UUID, so it counts as an upload.
186
- const customName = (v: string) => (/^blob:/i.test(v) ? "" : stem(v)) || (/^(data|blob):/i.test(v) ? "Uploaded image" : "Custom image");
343
+ const customName = (v: string) =>
344
+ (/^blob:/i.test(v) ? "" : stem(v)) ||
345
+ (/^(data|blob):/i.test(v) ? tr("uploadedImage", "Uploaded image") : tr("customImage", "Custom image"));
187
346
  let render: () => void;
188
347
  // Every commit bumps gen, so a file read that lands after a tile was picked, a reset or a host set() is dropped.
189
348
  let gen = 0;
190
- const commit = (v: string, fire = true) => { gen++; value = v; render(); if (fire) onChange(value); };
349
+ const commit = (v: string, fire = true) => {
350
+ gen++;
351
+ value = v;
352
+ render();
353
+ if (fire) onChange(value);
354
+ };
191
355
  const load = (file: File | undefined) => {
192
356
  if (!file) return;
193
- if (!file.type.startsWith("image/")) return say("Not an image");
194
- if (file.size > maxSize) return say(`${size(file.size)}, max ${size(maxSize)}`);
195
- // One commit, of the shrunk picture when there is one, else of the file as it is less its metadata: one change event, one save.
357
+ if (!file.type.startsWith("image/")) return say(tr("notAnImage", "Not an image"));
358
+ if (file.size > maxSize)
359
+ return say(tr("tooLarge", "{size}, max {max}", { size: size(file.size), max: size(maxSize) }));
360
+ // One commit, of the shrunk picture when there is one, else of the file as it is less its metadata: one change
361
+ // event, one save.
196
362
  const at = ++gen;
197
363
  shrink(file, fit, quality).then(async (url) => {
198
364
  if (gen !== at) return;
199
365
  if (url) return commit(url);
200
366
  const fr = new FileReader();
201
- fr.onload = () => { if (gen === at) commit(String(fr.result)); };
202
- fr.onerror = () => say("Couldn't read the file");
367
+ fr.onload = () => {
368
+ if (gen === at) commit(String(fr.result));
369
+ };
370
+ fr.onerror = () => say(tr("unreadable", "Couldn't read the file"));
203
371
  fr.readAsDataURL(await strip(file));
204
372
  });
205
373
  };
206
- // `zone` takes the drop and `over` wears [data-over] meanwhile. dragover re-asserts each tick, so crossing the children doesn't drop the outline.
374
+ // `zone` takes the drop and `over` wears [data-over] meanwhile. dragover re-asserts each tick, so crossing the
375
+ // children doesn't drop the outline.
207
376
  // A disabled control's root stays hit-testable (only its children go inert), so the zone checks for itself; on the
208
377
  // control's root, since the gallery's pane is portaled out of the panel while open.
209
378
  const dropZone = (zone: HTMLElement, over: HTMLElement) => {
210
379
  const off = () => !!root.closest(".is-disabled");
211
- zone.addEventListener("dragover", (e) => { if (off()) return; e.preventDefault(); over.dataset.over = "true"; });
212
- zone.addEventListener("dragleave", () => { over.dataset.over = "false"; });
213
- zone.addEventListener("drop", (e) => { over.dataset.over = "false"; if (off()) return; e.preventDefault(); load(e.dataTransfer?.files[0]); });
380
+ zone.addEventListener("dragover", (e) => {
381
+ if (off()) return;
382
+ e.preventDefault();
383
+ over.dataset.over = "true";
384
+ });
385
+ zone.addEventListener("dragleave", () => {
386
+ over.dataset.over = "false";
387
+ });
388
+ zone.addEventListener("drop", (e) => {
389
+ over.dataset.over = "false";
390
+ if (off()) return;
391
+ e.preventDefault();
392
+ load(e.dataTransfer?.files[0]);
393
+ });
214
394
  };
215
- input.addEventListener("change", () => { load(input.files?.[0]); input.value = ""; }); // cleared, so the same file picked again fires change
395
+ // cleared, so the same file picked again fires change
396
+ input.addEventListener("change", () => {
397
+ load(input.files?.[0]);
398
+ input.value = "";
399
+ });
216
400
  // Both forms are a trigger row with the chosen image's thumb first, as every chip row.
217
- const row = triggerRow("tw-image", meta.label), root = row.root;
218
- const thumb = el("span", "tw-trigger-chip tw-image-thumb");
219
- const paintThumb = () => { thumb.dataset.set = value ? "true" : "false"; thumb.style.backgroundImage = value ? urlCss(value) : ""; };
401
+ const row = triggerRow("twk-image", meta.label),
402
+ root = row.root;
403
+ const thumb = el("span", "twk-trigger-chip twk-image-thumb");
404
+ const paintThumb = () => {
405
+ thumb.dataset.set = value ? "true" : "false";
406
+ thumb.style.backgroundImage = value ? urlCss(value) : "";
407
+ };
220
408
 
221
409
  if (!options.length) {
222
410
  // The plain row: the whole trigger is the target, a click opens the file dialog and the row is also the drop zone.
223
411
  row.trigger.removeAttribute("aria-expanded"); // opens a file dialog, not an expandable popover
224
- row.trigger.setAttribute("aria-label", `${meta.label}: choose an image`);
225
- const text = el("span", "tw-image-text");
412
+ row.trigger.setAttribute("aria-label", tr("chooseImage", "{label}: choose an image", { label: meta.label }));
413
+ const text = el("span", "twk-image-text");
226
414
  row.right.append(thumb, note, text);
227
415
  root.append(input, live);
228
- render = () => { paintThumb(); text.textContent = value ? "Replace" : "Drop or choose"; };
416
+ render = () => {
417
+ paintThumb();
418
+ text.textContent = value ? tr("replace", "Replace") : tr("dropOrChoose", "Drop or choose");
419
+ };
229
420
  row.trigger.addEventListener("click", () => input.click());
230
421
  dropZone(row.trigger, row.trigger);
231
422
  } else {
232
- // The gallery: the row, with the chosen image's name, opens a pane of the tiles, as one radio group, over a foot with
233
- // the upload button and Clear. The row names the control, so the pane doesn't repeat it. The row and the pane both
234
- // take a dropped file.
235
- const name = el("span", "tw-trigger-value"), rowNote = el("span", "tw-image-note");
423
+ // The gallery: the row, with the chosen image's name, opens a pane of the tiles, as one radio group, over a foot
424
+ // with the upload button and Clear. The row names the control, so the pane doesn't repeat it. The row and the pane
425
+ // both take a dropped file.
426
+ const name = el("span", "twk-trigger-value"),
427
+ rowNote = el("span", "twk-image-note");
236
428
  row.right.append(thumb, rowNote, name);
237
429
  notes.push(rowNote);
238
430
  // The pane is the gallery, with no wrapper: its padding and the gaps between tiles and foot are its drag edge.
239
- const pop = el("div", "tw-color-pop tw-image-pop tw-image-gallery");
240
- const upload = canUpload ? btn("tw-button tw-image-upload", ICON_UPLOAD) : null;
241
- const clear = txt("button", "tw-image-clear", "Clear"); clear.type = "button"; clear.setAttribute("aria-label", `${meta.label}: clear the image`);
431
+ const pop = el("div", "twk-color-pop twk-image-pop twk-image-gallery");
432
+ const upload = canUpload ? btn("twk-button twk-image-upload", ICON_UPLOAD) : null;
433
+ const clear = txt("button", "twk-image-clear", tr("clear", "Clear"));
434
+ clear.type = "button";
435
+ clear.setAttribute("aria-label", tr("clearImage", "{label}: clear the image", { label: meta.label }));
242
436
  // The foot folds like a folder: a row track holds the upload button and a column track holds Clear, so either can
243
437
  // ease shut without measuring. Clear folds away when there's nothing to clear; the foot folds when it's empty.
244
- const foot = el("div", "tw-image-foot"), footRow = el("div", "tw-image-foot-row"), slot = el("div", "tw-image-clear-slot");
245
- foot.append(footRow); slot.append(clear);
246
- const grid = el("div", "tw-image-grid"); grid.setAttribute("role", "radiogroup"); grid.setAttribute("aria-label", meta.label);
438
+ const foot = el("div", "twk-image-foot"),
439
+ footRow = el("div", "twk-image-foot-row"),
440
+ slot = el("div", "twk-image-clear-slot");
441
+ foot.append(footRow);
442
+ slot.append(clear);
443
+ const grid = el("div", "twk-image-grid");
444
+ grid.setAttribute("role", "radiogroup");
445
+ grid.setAttribute("aria-label", meta.label);
247
446
  const n = options.length;
248
- const cols = Math.min(6, Math.max(1, (meta.cols! | 0) || (n <= 3 ? Math.max(2, n) : n === 4 ? 2 : 3)));
249
- grid.style.setProperty("--tw-img-cols", String(cols));
250
- const tile = (v: string, label: string) => { const b = radioButton("tw-image-tile", v, () => commit(v)); b.textContent = ""; b.setAttribute("aria-label", label); b.style.backgroundImage = urlCss(v); return b; };
251
- const names = options.map((o, i) => o.label || stem(o.value) || `Image ${i + 1}`);
252
- const tiles = options.map((o, i) => { const b = tile(o.value, names[i]); grid.append(b); return b; });
447
+ const cols = Math.min(6, Math.max(1, meta.cols! | 0 || (n <= 3 ? Math.max(2, n) : n === 4 ? 2 : 3)));
448
+ grid.style.setProperty("--twk-img-cols", String(cols));
449
+ const tile = (v: string, label: string) => {
450
+ const b = radioButton("twk-image-tile", v, () => commit(v));
451
+ b.textContent = "";
452
+ b.setAttribute("aria-label", label);
453
+ b.style.backgroundImage = urlCss(v);
454
+ return b;
455
+ };
456
+ const names = options.map((o, i) => o.label || stem(o.value) || tr("imageN", "Image {n}", { n: i + 1 }));
457
+ const tiles = options.map((o, i) => {
458
+ const b = tile(o.value, names[i]);
459
+ grid.append(b);
460
+ return b;
461
+ });
253
462
  // A value that is none of the options (an upload, or a host-set URL) shows as a last tile, selected. Its URL rides
254
- // on _twVal, not data-value: the inline style already holds one copy of a data URL, and data-value would be a second.
255
- const custom: RadioBtn = btn("tw-image-tile tw-image-custom"); custom.setAttribute("role", "radio");
256
- const all = () => (custom.parentNode ? [...tiles, custom] : tiles); // parentNode, not isConnected: a panel not yet in the document has no connected nodes
463
+ // on _twVal, not data-value: the inline style already holds one copy of a data URL, and data-value would be a
464
+ // second.
465
+ const custom: RadioBtn = btn("twk-image-tile twk-image-custom");
466
+ custom.setAttribute("role", "radio");
467
+ // parentNode, not isConnected: a panel not yet in the document has no connected nodes
468
+ const all = () => (custom.parentNode ? [...tiles, custom] : tiles);
257
469
  render = () => {
258
470
  const k = tiles.findIndex((b) => b._twVal === value);
259
471
  if (value && k < 0) {
260
- custom._twVal = value; custom.style.backgroundImage = urlCss(value); custom.setAttribute("aria-label", customName(value));
472
+ custom._twVal = value;
473
+ custom.style.backgroundImage = urlCss(value);
474
+ custom.setAttribute("aria-label", customName(value));
261
475
  if (!custom.parentNode) grid.append(custom); // re-appending an attached node moves it, and a move blurs it
262
476
  } else {
263
- // The focused tile is leaving (undo, a preset, set()): focus moves on first, since removing a focused node blurs
264
- // it to <body>, and that blur closes the pane.
477
+ // The focused tile is leaving (undo, a preset, set()): focus moves on first, since removing a focused node
478
+ // blurs it to <body>, and that blur closes the pane.
265
479
  if (custom.matches(":focus")) tiles[Math.max(0, k)].focus();
266
480
  custom.remove();
267
481
  }
268
- const btns = all(), stop = Math.max(0, btns.findIndex((b) => b._twVal === value)); // the roving tab stop: the chosen tile, else the first
269
- btns.forEach((b, j) => { const on = !!value && b._twVal === value; b.dataset.active = String(on); b.setAttribute("aria-checked", String(on)); b.tabIndex = j === stop ? 0 : -1; });
270
- slot.dataset.open = String(!!value); slot.inert = !value; // nothing to clear: Clear folds away, out of the tab order
271
- foot.dataset.open = String(!!upload || (canClear && !!value)); foot.inert = foot.dataset.open === "false"; // an empty foot folds to nothing
482
+ // the roving tab stop: the chosen tile, else the first
483
+ const btns = all(),
484
+ stop = Math.max(
485
+ 0,
486
+ btns.findIndex((b) => b._twVal === value),
487
+ );
488
+ btns.forEach((b, j) => {
489
+ const on = !!value && b._twVal === value;
490
+ b.dataset.active = String(on);
491
+ b.setAttribute("aria-checked", String(on));
492
+ b.tabIndex = j === stop ? 0 : -1;
493
+ });
494
+ // nothing to clear: Clear folds away, out of the tab order
495
+ slot.dataset.open = String(!!value);
496
+ slot.inert = !value;
497
+ // an empty foot folds to nothing
498
+ foot.dataset.open = String(!!upload || (canClear && !!value));
499
+ foot.inert = foot.dataset.open === "false";
272
500
  paintThumb();
273
- name.textContent = !value ? "None" : k >= 0 ? names[k] : customName(value);
501
+ name.textContent = !value ? tr("none", "None") : k >= 0 ? names[k] : customName(value);
274
502
  };
275
503
  grid.addEventListener("keydown", (e) => {
276
- const btns = all(), i = btns.indexOf(e.target as RadioBtn); if (i < 0) return;
504
+ const btns = all(),
505
+ i = btns.indexOf(e.target as RadioBtn);
506
+ if (i < 0) return;
277
507
  // Focus moves before the commit: the commit can remove the focused tile (the custom one) or hide Clear.
278
- if (canClear && (e.key === "Delete" || e.key === "Backspace") && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); tiles[0].focus(); commit(""); return; } // a modifier+Delete is the panel's reset, not a clear
279
- const j = navIndex(e.key, i, btns.length, cols); if (j < 0) return;
280
- e.preventDefault(); if (j !== i) { btns[j].focus(); commit(btns[j]._twVal as string); } // a tile holds its URL
508
+ // a modifier+Delete is the panel's reset, not a clear
509
+ if (canClear && (e.key === "Delete" || e.key === "Backspace") && !e.altKey && !e.metaKey && !e.ctrlKey) {
510
+ e.preventDefault();
511
+ tiles[0].focus();
512
+ commit("");
513
+ return;
514
+ }
515
+ const j = navIndex(e.key, i, btns.length, cols);
516
+ if (j < 0) return;
517
+ e.preventDefault();
518
+ // a tile holds its URL
519
+ if (j !== i) {
520
+ btns[j].focus();
521
+ commit(btns[j]._twVal as string);
522
+ }
523
+ });
524
+ // Clear hides itself: hand focus on first, not to the body
525
+ clear.addEventListener("click", () => {
526
+ (upload || tiles[0]).focus();
527
+ commit("");
281
528
  });
282
- clear.addEventListener("click", () => { (upload || tiles[0]).focus(); commit(""); }); // Clear hides itself: hand focus on first, not to the body
283
529
  pop.append(grid, foot);
284
530
  root.append(pop, live);
285
531
  if (upload) {
286
- upload.setAttribute("aria-label", `${meta.label}: upload an image`);
287
- upload.append(note, txt("span", "tw-image-text", "Upload image"));
532
+ upload.setAttribute("aria-label", tr("uploadImage", "{label}: upload an image", { label: meta.label }));
533
+ upload.append(note, txt("span", "twk-image-text", tr("upload", "Upload image")));
288
534
  upload.addEventListener("click", () => input.click());
289
- footRow.append(upload); root.append(input);
290
- dropZone(row.trigger, row.trigger); dropZone(pop, pop);
535
+ footRow.append(upload);
536
+ root.append(input);
537
+ dropZone(row.trigger, row.trigger);
538
+ dropZone(pop, pop);
291
539
  }
292
540
  if (canClear) footRow.append(slot);
293
541
  popover(root, row.trigger, pop, { fallbackH: 220, initialFocus: () => all().find((b) => b.tabIndex === 0) });
294
542
  }
295
543
  render();
296
- return { el: root, set: (v: string | null | undefined) => { if (v != null && typeof v !== "string") return; commit(v || "", false); }, get: () => value, note: say };
544
+ return {
545
+ el: root,
546
+ set: (v: string | null | undefined) => {
547
+ if (v != null && typeof v !== "string") return;
548
+ commit(v || "", false);
549
+ },
550
+ get: () => value,
551
+ note: say,
552
+ };
297
553
  }
298
554
 
299
555
  registerControl("image", createImage);