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.
- package/AGENTS.md +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /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) =>
|
|
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) =>
|
|
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) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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) => {
|
|
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),
|
|
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);
|
|
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
|
-
|
|
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
|
|
46
|
-
// it. The value is stored and copied as JSON into source, so those blocks are cut from the bytes, losslessly:
|
|
47
|
-
// profile, which carries a wide gamut, and everything else stays. Exif's Orientation decides which way up the
|
|
48
|
-
// shows, so a turned one gets a bare Exif back holding only that tag. A redraw needs none of this: a canvas
|
|
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"),
|
|
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++)
|
|
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 {
|
|
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) =>
|
|
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) => {
|
|
62
|
-
|
|
63
|
-
|
|
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 = [
|
|
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),
|
|
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)) {
|
|
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
|
|
75
|
-
// read is left as it was. The images after the first are left whole, their own Exif or XMP with them: the MP
|
|
76
|
-
// the gain-map XMP's Item:Length both record their sizes, so cutting into them means rewriting both, and a
|
|
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"),
|
|
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
|
-
|
|
84
|
-
const
|
|
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++) {
|
|
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 {
|
|
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[] = [];
|
|
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],
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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),
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
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;
|
|
122
|
-
|
|
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),
|
|
125
|
-
|
|
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 {
|
|
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
|
-
|
|
137
|
-
//
|
|
138
|
-
const encode = (c: HTMLCanvasElement, type: string, q?: number) =>
|
|
139
|
-
|
|
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) => {
|
|
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")
|
|
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();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
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 {
|
|
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
|
|
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 : [])
|
|
172
|
-
|
|
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
|
-
|
|
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", "
|
|
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", "
|
|
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) => {
|
|
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) =>
|
|
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) => {
|
|
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)
|
|
195
|
-
|
|
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 = () => {
|
|
202
|
-
|
|
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
|
|
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) => {
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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("
|
|
218
|
-
|
|
219
|
-
const
|
|
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",
|
|
225
|
-
const text = el("span", "
|
|
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 = () => {
|
|
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
|
|
233
|
-
// the upload button and Clear. The row names the control, so the pane doesn't repeat it. The row and the pane
|
|
234
|
-
// take a dropped file.
|
|
235
|
-
const name = el("span", "
|
|
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", "
|
|
240
|
-
const upload = canUpload ? btn("
|
|
241
|
-
const clear = txt("button", "
|
|
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", "
|
|
245
|
-
|
|
246
|
-
|
|
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,
|
|
249
|
-
grid.style.setProperty("--
|
|
250
|
-
const tile = (v: string, label: string) => {
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
const
|
|
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;
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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(),
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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",
|
|
287
|
-
upload.append(note, txt("span", "
|
|
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);
|
|
290
|
-
|
|
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 {
|
|
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);
|