tweakit 0.5.2 → 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 (120) hide show
  1. package/AGENTS.md +32 -30
  2. package/README.md +55 -29
  3. package/THIRD-PARTY-NOTICES.md +0 -1
  4. package/dist/tweaks/auto.js +2 -0
  5. package/dist/tweaks/auto.js.map +6 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  7. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  9. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  11. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  13. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  14. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  15. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  16. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  17. package/dist/tweaks/color-UGYMAF3W.js.map +6 -0
  18. package/dist/tweaks/core.js +2 -1
  19. package/dist/tweaks/core.js.map +6 -0
  20. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  21. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  22. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  23. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  24. package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
  25. package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
  26. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  27. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  28. package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
  29. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  30. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  31. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  32. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  33. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  34. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  35. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  36. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  37. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  38. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  39. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  40. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  41. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  42. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  43. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  44. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  45. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  46. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  47. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  48. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  49. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  50. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  51. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  52. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  53. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  54. package/dist/tweaks.css +1 -1
  55. package/dist/tweaks.js +7 -3
  56. package/dist/tweaks.js.map +6 -0
  57. package/dist/types/tweaks/easing.d.ts +8 -11
  58. package/dist/types/tweaks/enhance.d.ts +3 -2
  59. package/dist/types/tweaks/panel.d.ts +6 -4
  60. package/dist/types/tweaks/schema.d.ts +4 -8
  61. package/dist/types/tweaks/standalone.d.ts +3 -3
  62. package/dist/types/tweaks/types.d.ts +499 -116
  63. package/package.json +30 -5
  64. package/src/tweaks/auto.ts +16 -0
  65. package/src/tweaks/clip.ts +156 -0
  66. package/src/tweaks/controls/basic.ts +727 -0
  67. package/src/tweaks/controls/bezier.ts +204 -0
  68. package/src/tweaks/controls/color.ts +861 -0
  69. package/src/tweaks/controls/gradient.ts +419 -0
  70. package/src/tweaks/controls/image.ts +555 -0
  71. package/src/tweaks/controls/interval.ts +189 -0
  72. package/src/tweaks/controls/monitor.ts +211 -0
  73. package/src/tweaks/controls/motion.ts +316 -0
  74. package/src/tweaks/controls/plot.ts +440 -0
  75. package/src/tweaks/controls/point.ts +208 -0
  76. package/src/tweaks/controls/shadow.ts +156 -0
  77. package/src/tweaks/controls/sides.ts +196 -0
  78. package/src/tweaks/controls/spring.ts +308 -0
  79. package/src/tweaks/controls/tabs.ts +68 -0
  80. package/src/tweaks/core.ts +9 -0
  81. package/src/tweaks/easing.ts +118 -0
  82. package/src/tweaks/enhance.ts +35 -0
  83. package/src/tweaks/feedback.ts +218 -0
  84. package/src/tweaks/fold.ts +33 -0
  85. package/src/tweaks/heavy.ts +626 -0
  86. package/src/tweaks/icons.ts +26 -0
  87. package/src/tweaks/image-store.ts +189 -0
  88. package/src/tweaks/lazy.ts +63 -0
  89. package/src/tweaks/markup.ts +317 -0
  90. package/src/tweaks/panel.ts +1693 -0
  91. package/src/tweaks/presets.ts +85 -0
  92. package/src/tweaks/schema.ts +351 -0
  93. package/src/tweaks/shadow-root.ts +178 -0
  94. package/src/tweaks/shared.ts +1137 -0
  95. package/src/tweaks/single.ts +22 -0
  96. package/src/tweaks/standalone.ts +179 -0
  97. package/src/tweaks/types.ts +1027 -0
  98. package/src/tweaks/undo.ts +104 -0
  99. package/src/tweaks.css +965 -0
  100. package/src/wide-gamut.ts +658 -0
  101. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  102. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  103. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  104. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  105. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  106. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  107. package/dist/tweaks/fold-UGN7TXGX.js +0 -1
  108. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  109. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  110. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  111. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  112. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  113. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  114. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  115. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  116. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  117. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  118. package/dist/tweaks/sides-FH227Z54.js +0 -1
  119. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  120. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,555 @@
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, words } from "../shared.js";
3
+ import { triggerRow } from "../heavy.js";
4
+ import type { OnChange, RadioBtn } from "../shared.js";
5
+ import type { Meta } from "../schema.js";
6
+ import type { Control } from "../types.js";
7
+
8
+ // Escape for a CSS string literal: the quote, the backslash and control characters (a raw newline ends a CSS string),
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) =>
11
+ String(s)
12
+ .replace(/[\\"]/g, "\\$&")
13
+ .replace(/[\x00-\x1f\x7f]/g, (c) => "\\" + c.charCodeAt(0).toString(16) + " ");
14
+ const urlCss = (s: string) => `url("${cssString(s)}")`;
15
+ const MB = 1 << 20;
16
+ const size = (n: number) =>
17
+ n >= MB ? `${Math.round((n / MB) * 10) / 10} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
18
+ // A tile's accessible name when its option has no label: the URL's file stem; a data URL has none.
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.
43
+ const KEEP = /^image\/(gif|svg\+xml)$/i;
44
+ const PNG_BUDGET = 1.5 * MB;
45
+ const PNG_SIG = "\x89PNG\r\n\x1a\n";
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
+ };
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
51
+ // chunk ahead of its first IDAT. Only chunk headers are read, never the frames, however many chunks come first.
52
+ export const animated = async (file: Blob) => {
53
+ const at = async (i: number, n: number) => new Uint8Array(await file.slice(i, i + n).arrayBuffer());
54
+ const h = await at(0, 21),
55
+ webp = ascii(h, 0, "RIFF") && ascii(h, 8, "WEBP");
56
+ if (webp && ascii(h, 12, "VP8X") && h[20] & 2) return true;
57
+ if (!webp && !ascii(h, 0, PNG_SIG)) return false;
58
+ for (let i = webp ? 12 : 8; ;) {
59
+ const c = await at(i, 8);
60
+ if (c.length < 8) return false;
61
+ const type = String.fromCharCode(...c.subarray(webp ? 0 : 4, webp ? 4 : 8));
62
+ if (type === (webp ? "ANIM" : "acTL")) return true;
63
+ if (webp ? /^(VP8 |VP8L|ANMF)$/.test(type) : type === "IDAT") return false; // the picture has begun
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);
66
+ i += webp ? 8 + n + (n & 1) : 12 + n; // at least a header on, so the end of the file comes
67
+ }
68
+ };
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.
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.
75
+ const orientation = (t: Uint8Array) => {
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
80
+ try {
81
+ const ifd = v.getUint32(4, le);
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
+ }
87
+ return 1;
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+ // A big-endian TIFF header and one IFD with the one entry: Orientation, a SHORT.
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]);
95
+ type Bytes = Uint8Array<ArrayBuffer>; // what a Blob takes: never a SharedArrayBuffer
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.
107
+ const XMP = "http://ns.adobe.com/xap/1.0/\0";
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
+ ];
115
+ const gainMapOnly = (b: Uint8Array) => {
116
+ const x = new TextDecoder("latin1").decode(b),
117
+ ok = new Set(["xmlns", "xml"]);
118
+ if (!x.includes(GAIN_NS[2])) return false;
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
+ }
123
+ for (const [, p] of x.matchAll(/[<\s/]([\w.-]+):[\w.-]/g)) if (!ok.has(p)) return false;
124
+ return true;
125
+ };
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
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.
131
+ const mpfShift = (seg: Uint8Array, by: number) => {
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;
135
+ try {
136
+ const ifd = v.getUint32(4, le);
137
+ for (let k = 0, n = v.getUint16(ifd, le); k < n; k++) {
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;
143
+ if (count) v.getUint32(list + count * 16 - 4, le); // the whole list is in the segment, or nothing is written
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
+ }
149
+ }
150
+ } catch {
151
+ /* an offset past the segment */
152
+ }
153
+ };
154
+ const jpegCut = (b: Bytes) => {
155
+ if (b[0] !== 0xff || b[1] !== 0xd8) return null;
156
+ const out: Bytes[] = [];
157
+ let i = 2,
158
+ from = 0,
159
+ turned = 0,
160
+ mpf: Uint8Array | null = null,
161
+ moved = 0;
162
+ for (;;) {
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
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;
169
+ if (m === 0xda) break; // start of scan: the picture itself, kept to the end
170
+ if (m === 0xe2 && ascii(b, i + 4, "MPF\0")) mpf = b.subarray(i + 8, end);
171
+ const exif = m === 0xe1 && ascii(b, i + 4, "Exif\0");
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)]));
185
+ if (mpf) moved += end - i - (bare ? 36 : 0);
186
+ }
187
+ i = end;
188
+ }
189
+ if (mpf && moved > 0) mpfShift(mpf, moved); // in place: these are strip()'s own bytes, and they go out as parts of it
190
+ return out.length ? [...out, b.subarray(from)] : null;
191
+ };
192
+ const pngCut = (b: Bytes) => {
193
+ if (!ascii(b, 0, PNG_SIG)) 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;
202
+ type = String.fromCharCode(...b.subarray(i + 4, i + 8));
203
+ // A text chunk is metadata by its keyword: XMP's, or ImageMagick's raw Exif and XMP. Other text stays.
204
+ const key = /^[tiz]TXt$/.test(type)
205
+ ? String.fromCharCode(...b.subarray(i + 8, Math.min(end - 4, i + 88))).split("\0")[0]
206
+ : "";
207
+ if (type === "eXIf" || /^(XML:com\.adobe\.xmp|Raw profile type (exif|app1|xmp))$/i.test(key)) {
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;
212
+ if (o > 1 && !turned++) {
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)));
219
+ out.push(c);
220
+ }
221
+ }
222
+ i = end;
223
+ }
224
+ return out.length ? [...out, b.subarray(from)] : null;
225
+ };
226
+ export const strip = async (file: File): Promise<Blob> => {
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;
239
+ };
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
+ });
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
252
+ // canvas keeps the gamut, and toBlob tags what it writes with the P3 profile. A browser that doesn't know the option
253
+ // ignores it and hands back sRGB, as before; one that refuses the value throws, and gets the plain context.
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
+ };
263
+ const shrink = async (file: File, fit: number, quality: number): Promise<string> => {
264
+ if (!fit || KEEP.test(file.type) || typeof Image === "undefined" || typeof URL.createObjectURL !== "function")
265
+ return "";
266
+ let src = "";
267
+ try {
268
+ if (await animated(file)) return "";
269
+ src = URL.createObjectURL(file);
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);
285
+ const jpeg = /^image\/jpe?g$/i.test(file.type);
286
+ let out = jpeg ? null : await encode(c, "image/png");
287
+ if (!out || out.size > PNG_BUDGET) {
288
+ const alt = await encode(c, !jpeg && hasAlpha(ctx, w, h) ? "image/webp" : "image/jpeg", quality);
289
+ if (alt && (!out || alt.size < out.size)) out = alt;
290
+ }
291
+ return out && out.size < file.size ? await dataUrl(out) : "";
292
+ } catch {
293
+ return "";
294
+ } finally {
295
+ if (src) URL.revokeObjectURL(src);
296
+ }
297
+ };
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"/>');
299
+
300
+ function createImage(meta: Meta, onChange: OnChange): Control {
301
+ const tr = words();
302
+ let value = typeof meta.value === "string" ? meta.value : "";
303
+ // Options: URLs, or { value, label }; anything but a non-empty string value is skipped, and a repeated URL keeps its
304
+ // first.
305
+ const seen = new Set<string>();
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;
314
+ const canClear = meta.clear !== false; // false: a required choice, with no Clear and no Delete to empty it
315
+ const maxSize = +meta.maxSize! > 0 ? +meta.maxSize! : 10 * MB;
316
+ // A number only: `fit: true` would read as 1 and draw every upload as one pixel.
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;
324
+ const quality = typeof meta.quality === "number" && meta.quality > 0 && meta.quality <= 1 ? meta.quality : 0.85;
325
+ const input = el("input", "twk-image-input");
326
+ input.type = "file";
327
+ input.accept = "image/*";
328
+ // A refused file's reason takes the button text's place for a moment. It is spoken from a status region beside the
329
+ // button, not inside it: a button's children are presentational, so a live region there is never read.
330
+ const note = el("span", "twk-image-note"),
331
+ live = el("span", "twk-image-live");
332
+ live.setAttribute("role", "status");
333
+ const notes = [note, live]; // a gallery adds its row's note, so a file refused on the closed row still shows why
334
+ let noteTimer: ReturnType<typeof setTimeout>;
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
+ };
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.
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"));
346
+ let render: () => void;
347
+ // Every commit bumps gen, so a file read that lands after a tile was picked, a reset or a host set() is dropped.
348
+ let gen = 0;
349
+ const commit = (v: string, fire = true) => {
350
+ gen++;
351
+ value = v;
352
+ render();
353
+ if (fire) onChange(value);
354
+ };
355
+ const load = (file: File | undefined) => {
356
+ if (!file) return;
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.
362
+ const at = ++gen;
363
+ shrink(file, fit, quality).then(async (url) => {
364
+ if (gen !== at) return;
365
+ if (url) return commit(url);
366
+ const fr = new FileReader();
367
+ fr.onload = () => {
368
+ if (gen === at) commit(String(fr.result));
369
+ };
370
+ fr.onerror = () => say(tr("unreadable", "Couldn't read the file"));
371
+ fr.readAsDataURL(await strip(file));
372
+ });
373
+ };
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.
376
+ // A disabled control's root stays hit-testable (only its children go inert), so the zone checks for itself; on the
377
+ // control's root, since the gallery's pane is portaled out of the panel while open.
378
+ const dropZone = (zone: HTMLElement, over: HTMLElement) => {
379
+ const off = () => !!root.closest(".is-disabled");
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
+ });
394
+ };
395
+ // cleared, so the same file picked again fires change
396
+ input.addEventListener("change", () => {
397
+ load(input.files?.[0]);
398
+ input.value = "";
399
+ });
400
+ // Both forms are a trigger row with the chosen image's thumb first, as every chip row.
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
+ };
408
+
409
+ if (!options.length) {
410
+ // The plain row: the whole trigger is the target, a click opens the file dialog and the row is also the drop zone.
411
+ row.trigger.removeAttribute("aria-expanded"); // opens a file dialog, not an expandable popover
412
+ row.trigger.setAttribute("aria-label", tr("chooseImage", "{label}: choose an image", { label: meta.label }));
413
+ const text = el("span", "twk-image-text");
414
+ row.right.append(thumb, note, text);
415
+ root.append(input, live);
416
+ render = () => {
417
+ paintThumb();
418
+ text.textContent = value ? tr("replace", "Replace") : tr("dropOrChoose", "Drop or choose");
419
+ };
420
+ row.trigger.addEventListener("click", () => input.click());
421
+ dropZone(row.trigger, row.trigger);
422
+ } else {
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");
428
+ row.right.append(thumb, rowNote, name);
429
+ notes.push(rowNote);
430
+ // The pane is the gallery, with no wrapper: its padding and the gaps between tiles and foot are its drag edge.
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 }));
436
+ // The foot folds like a folder: a row track holds the upload button and a column track holds Clear, so either can
437
+ // ease shut without measuring. Clear folds away when there's nothing to clear; the foot folds when it's empty.
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);
446
+ const n = options.length;
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
+ });
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
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);
469
+ render = () => {
470
+ const k = tiles.findIndex((b) => b._twVal === value);
471
+ if (value && k < 0) {
472
+ custom._twVal = value;
473
+ custom.style.backgroundImage = urlCss(value);
474
+ custom.setAttribute("aria-label", customName(value));
475
+ if (!custom.parentNode) grid.append(custom); // re-appending an attached node moves it, and a move blurs it
476
+ } else {
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.
479
+ if (custom.matches(":focus")) tiles[Math.max(0, k)].focus();
480
+ custom.remove();
481
+ }
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";
500
+ paintThumb();
501
+ name.textContent = !value ? tr("none", "None") : k >= 0 ? names[k] : customName(value);
502
+ };
503
+ grid.addEventListener("keydown", (e) => {
504
+ const btns = all(),
505
+ i = btns.indexOf(e.target as RadioBtn);
506
+ if (i < 0) return;
507
+ // Focus moves before the commit: the commit can remove the focused tile (the custom one) or hide Clear.
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("");
528
+ });
529
+ pop.append(grid, foot);
530
+ root.append(pop, live);
531
+ if (upload) {
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")));
534
+ upload.addEventListener("click", () => input.click());
535
+ footRow.append(upload);
536
+ root.append(input);
537
+ dropZone(row.trigger, row.trigger);
538
+ dropZone(pop, pop);
539
+ }
540
+ if (canClear) footRow.append(slot);
541
+ popover(root, row.trigger, pop, { fallbackH: 220, initialFocus: () => all().find((b) => b.tabIndex === 0) });
542
+ }
543
+ render();
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
+ };
553
+ }
554
+
555
+ registerControl("image", createImage);