tweakit 0.5.2 → 0.6.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 +31 -29
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// ── Gradient — a wide-gamut OKLCH gradient editor. Lazy; depends on the color
|
|
2
|
+
// module (reuses its picker body, parseColor, and oklchStr).
|
|
3
|
+
import { el, btn, clamp, popover, registerControl, icon } from "../shared.js";
|
|
4
|
+
import { dragGesture, triggerRow, arrowStep } from "../heavy.js";
|
|
5
|
+
import { createPickerBody, parseColor, isColor, oklchStr, CHECKER } from "./color.js";
|
|
6
|
+
import { modeInterpolation, interpolationMode } from "../../wide-gamut.js";
|
|
7
|
+
import { gradientCss, parseEasing, easingSamples } from "../easing.js";
|
|
8
|
+
import type { Bez } from "../easing.js";
|
|
9
|
+
import type { Oklcha } from "./color.js";
|
|
10
|
+
import type { OnChange } from "../shared.js";
|
|
11
|
+
import type { Meta } from "../schema.js";
|
|
12
|
+
import type { Control, GradientStop, GradientEasing } from "../types.js";
|
|
13
|
+
|
|
14
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
15
|
+
/** The name an easing is stored under: trimmed and lowercased when parseEasing knows it, else "linear". */
|
|
16
|
+
const easingName = (e: unknown): GradientEasing => (parseEasing(e) ? (e as string).trim().toLowerCase() as GradientEasing : "linear");
|
|
17
|
+
|
|
18
|
+
/** The blend progress at a position, linear between samples exactly as the browser draws the
|
|
19
|
+
* stops, so a stop inserted at x takes the color the ramp really shows. Null is the identity. */
|
|
20
|
+
function easingAt(b: Bez | null, x: number): number {
|
|
21
|
+
x = clamp01(x); // outside the segment is its end; never extrapolate
|
|
22
|
+
if (!b) return x;
|
|
23
|
+
const s = easingSamples(b);
|
|
24
|
+
for (let k = 1; k < s.length; k++) {
|
|
25
|
+
if (x <= s[k][0]) { const [x0, y0] = s[k - 1], [x1, y1] = s[k]; return x1 > x0 ? y0 + (y1 - y0) * (x - x0) / (x1 - x0) : y1; }
|
|
26
|
+
}
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A stop handle on the rail, carrying the stop it moves. */
|
|
31
|
+
interface StopHandle extends HTMLButtonElement { _stop?: GradientStop }
|
|
32
|
+
|
|
33
|
+
// ICON_PLUS — adapted from Hugeicons `add-01` (MIT), two paths merged. See ../../../THIRD-PARTY-NOTICES.md.
|
|
34
|
+
const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
|
|
35
|
+
|
|
36
|
+
// ── Gradient — a Figma-style editor: a swatch-trigger row opens a popover holding the stop bar
|
|
37
|
+
// over a reused color picker body; selecting a stop re-points the body at it. The bar blends in
|
|
38
|
+
// the space of the editor's mode, which rides along as `interpolation`, and `easing` likewise, so a
|
|
39
|
+
// host's `linear-gradient(in …)` through gradientCss() gets the same pixels. ──
|
|
40
|
+
// A value's blend space / easing: an explicit string on the object form, else null (the array
|
|
41
|
+
// shorthand carries none). Inspected field by field, so the normalizers take `any`.
|
|
42
|
+
const field = (value: any, key: string): string | null => (value && !Array.isArray(value) && typeof value[key] === "string" ? value[key] : null);
|
|
43
|
+
const parseInterp = (value: any) => field(value, "interpolation");
|
|
44
|
+
const parseEase = (value: any) => field(value, "easing");
|
|
45
|
+
// The easing menu; a stored cubic-bezier() not among them shows as a "Custom" option while it's the value.
|
|
46
|
+
const EASINGS: Array<[GradientEasing, string]> = [["linear", "Linear"], ["ease", "Ease"], ["ease-in", "Ease in"], ["ease-out", "Ease out"], ["ease-in-out", "Ease in-out"]];
|
|
47
|
+
function normalizeStops(value: any): GradientStop[] {
|
|
48
|
+
const DEF: GradientStop[] = [{ color: "oklch(0.72 0.19 25)", pos: 0 }, { color: "oklch(0.72 0.16 280)", pos: 1 }];
|
|
49
|
+
const arr = Array.isArray(value) ? value : (value && Array.isArray(value.stops) ? value.stops : null);
|
|
50
|
+
// A stop's color must be a single color token: stop text goes straight into the preview's
|
|
51
|
+
// `linear-gradient(…)`, where an unparsed string could splice in further layers (a url() fetch
|
|
52
|
+
// from persisted state). A non-finite pos → 0, clamped into [0,1]; fewer than two usable stops → the default pair.
|
|
53
|
+
// Trimmed as the gate trims it: the CSS tokenizer reads a non-ASCII space as part of a name.
|
|
54
|
+
const stop = (color: string, pos: unknown): GradientStop | null => (isColor(color) ? { color: String(color).trim(), pos: clamp(+pos || 0, 0, 1) } : null);
|
|
55
|
+
const out = (arr || []).map((s: any) => {
|
|
56
|
+
if (Array.isArray(s)) return stop(s[0], s[1]);
|
|
57
|
+
if (s && typeof s === "object") return stop(s.color, s.pos);
|
|
58
|
+
return null;
|
|
59
|
+
}).filter(Boolean);
|
|
60
|
+
return out.length >= 2 ? out : DEF;
|
|
61
|
+
}
|
|
62
|
+
function createGradient(meta: Meta, onChange: OnChange): Control {
|
|
63
|
+
meta.value ??= meta.stops; // the verbose form's `stops` aliases `value`
|
|
64
|
+
// The blend and easing read off `value: {…}` first, else the verbose form's top level, where a pasted value's fields land.
|
|
65
|
+
const interp0 = parseInterp(meta.value) ?? parseInterp(meta), ease0 = parseEase(meta.value) ?? parseEase(meta);
|
|
66
|
+
let stops = normalizeStops(meta.value);
|
|
67
|
+
let selStop = stops[0];
|
|
68
|
+
let easing = easingName(ease0); // unrecognized reads as linear
|
|
69
|
+
|
|
70
|
+
// ── Trigger row — the strip is the readout; the stop count rides along for the accessibility tree. ──
|
|
71
|
+
const { root, trigger, right } = triggerRow("tw-gradient", meta.label ?? "Gradient"); // ??: an explicit "" label renders none
|
|
72
|
+
const preview = el("span", "tw-trigger-chip tw-gradient-preview"); preview.setAttribute("role", "img");
|
|
73
|
+
right.append(preview);
|
|
74
|
+
|
|
75
|
+
// ── Editor popover — carries the color popover's class so it inherits its tokens and shell. ──
|
|
76
|
+
const pop = el("div", "tw-color-pop tw-gradient-pop");
|
|
77
|
+
const barRow = el("div", "tw-gradient-bar-row");
|
|
78
|
+
const bar = el("div", "tw-gradient-bar");
|
|
79
|
+
const grad = el("div", "tw-gradient-grad");
|
|
80
|
+
const rail = el("div", "tw-gradient-rail");
|
|
81
|
+
bar.append(grad, rail);
|
|
82
|
+
const addBtn = btn("tw-gradient-add", ICON_PLUS); addBtn.title = "Add a stop after the selected one"; addBtn.setAttribute("aria-label", "Add a color stop after the selected one");
|
|
83
|
+
barRow.append(bar, addBtn);
|
|
84
|
+
|
|
85
|
+
const sorted = () => [...stops].sort((a, b) => a.pos - b.pos);
|
|
86
|
+
// body.mode() is the single source of truth for the blend space, so the bar, preview and emitted
|
|
87
|
+
// interpolation can't drift. (Stops keep their own notation; CSS lets `in srgb` carry oklch() stops.)
|
|
88
|
+
const interp = () => modeInterpolation(body.mode());
|
|
89
|
+
const value = () => ({ stops: sorted().map((s) => ({ color: s.color, pos: +s.pos.toFixed(4) })), interpolation: interp(), easing });
|
|
90
|
+
// Both draw gradientCss(value()), so the editor can't show a ramp a host templating through it won't get.
|
|
91
|
+
const paint = () => { const css = gradientCss(value()); grad.style.background = css; preview.style.background = `${css}, ${CHECKER}`; };
|
|
92
|
+
const reflectCount = () => { preview.setAttribute("aria-label", `${stops.length} stop${stops.length === 1 ? "" : "s"}`); };
|
|
93
|
+
const emit = () => onChange(value());
|
|
94
|
+
|
|
95
|
+
const handleFor = (s: GradientStop) => [...(rail.children as HTMLCollectionOf<StopHandle>)].find((h) => h._stop === s); // the rail holds only stop handles
|
|
96
|
+
// Unlike the standalone color control, a mode switch here IS a change to the gradient's output, so it repaints and re-emits.
|
|
97
|
+
const body = createPickerBody({ value: selStop.color, mode: interpolationMode(interp0 || "oklch"), onMode: () => { paint(); emit(); } }, (c) => { // oklch: the documented default and colorAt's blend space
|
|
98
|
+
selStop.color = c;
|
|
99
|
+
handleFor(selStop)?.style.setProperty("--stop", c);
|
|
100
|
+
paint(); emit();
|
|
101
|
+
});
|
|
102
|
+
// Easing sits beside the mode: both are "how the ramp blends".
|
|
103
|
+
const easeSel = el("select", "tw-color-mode tw-gradient-ease"); easeSel.setAttribute("aria-label", "Easing between stops"); easeSel.title = "How the ramp blends between stops";
|
|
104
|
+
for (const [v, label] of EASINGS) { const o = document.createElement("option"); o.value = v; o.textContent = label; easeSel.append(o); }
|
|
105
|
+
const reflectEase = () => {
|
|
106
|
+
let custom = easeSel.querySelector<HTMLOptionElement>("option[data-custom]");
|
|
107
|
+
if (EASINGS.some(([v]) => v === easing)) custom?.remove();
|
|
108
|
+
else { if (!custom) { custom = document.createElement("option"); custom.dataset.custom = ""; custom.textContent = "Custom"; easeSel.append(custom); } custom.value = easing; }
|
|
109
|
+
easeSel.value = easing;
|
|
110
|
+
};
|
|
111
|
+
easeSel.addEventListener("change", () => { easing = easeSel.value as GradientEasing; reflectEase(); paint(); emit(); }); // reflect: picking a keyword retires the Custom option
|
|
112
|
+
body.el.querySelector(".tw-color-mode")!.after(easeSel); // after the mode select, before the gamut tag
|
|
113
|
+
reflectEase();
|
|
114
|
+
pop.append(barRow, body.el);
|
|
115
|
+
root.append(pop);
|
|
116
|
+
|
|
117
|
+
// A stop's accessible name carries its position and selection, the state the ring shows sighted users.
|
|
118
|
+
const labelStop = (h: StopHandle) => h.setAttribute("aria-label", `Color stop at ${Math.round(h._stop.pos * 100)}%${h._stop === selStop ? ", selected" : ""}`);
|
|
119
|
+
const reflectSel = () => { for (const h of rail.children as HTMLCollectionOf<StopHandle>) { h.dataset.sel = String(h._stop === selStop); labelStop(h); } };
|
|
120
|
+
// A drag or an arrow moving a stop: its spot on the rail, its name, the ramp and the value follow.
|
|
121
|
+
const moveStop = (h: StopHandle, pos: number) => { h._stop.pos = clamp(pos, 0, 1); h.style.left = h._stop.pos * 100 + "%"; labelStop(h); paint(); emit(); };
|
|
122
|
+
const renderHandles = () => {
|
|
123
|
+
rail.replaceChildren();
|
|
124
|
+
for (const s of stops) {
|
|
125
|
+
const h: StopHandle = btn("tw-gradient-stop"); h._stop = s;
|
|
126
|
+
h.style.left = s.pos * 100 + "%"; h.style.setProperty("--stop", s.color);
|
|
127
|
+
h.dataset.sel = String(s === selStop); labelStop(h);
|
|
128
|
+
// Focus is selection: tabbing onto a stop points the picker at it.
|
|
129
|
+
h.addEventListener("focus", () => select(s, false));
|
|
130
|
+
let offBar = false;
|
|
131
|
+
dragGesture(h, {
|
|
132
|
+
// preventDefault suppresses click-to-focus, so focus explicitly (ringless); that also blurs
|
|
133
|
+
// a dirty channel field while the old stop is still selected, so its commit lands there.
|
|
134
|
+
onDown: (e) => { e.preventDefault(); e.stopPropagation(); h.focus({ focusVisible: false }); select(s, false); offBar = false; },
|
|
135
|
+
onMove: (e) => {
|
|
136
|
+
// One rail rect for X and the off-bar Y test; the X math is posFromX inlined.
|
|
137
|
+
const r = rail.getBoundingClientRect();
|
|
138
|
+
moveStop(h, (e.clientX - r.left) / (r.width || 1));
|
|
139
|
+
// Dragging ~24px clear of the bar removes the stop (floored at two).
|
|
140
|
+
offBar = stops.length > 2 && (e.clientY < r.top - 24 || e.clientY > r.bottom + 24);
|
|
141
|
+
h.dataset.removing = String(offBar);
|
|
142
|
+
},
|
|
143
|
+
onEnd: () => { if (offBar) removeStop(s); else h.dataset.removing = "false"; },
|
|
144
|
+
});
|
|
145
|
+
h.addEventListener("dblclick", (e) => { e.preventDefault(); removeStop(s); });
|
|
146
|
+
// Arrows nudge by 0.01 (⇧ = 0.1); Delete stays on the popover.
|
|
147
|
+
h.addEventListener("keydown", (e) => {
|
|
148
|
+
const [d] = arrowStep(e); if (!d) return;
|
|
149
|
+
e.preventDefault(); moveStop(h, s.pos + d * (e.shiftKey ? 0.1 : 0.01));
|
|
150
|
+
});
|
|
151
|
+
rail.append(h);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
// A drag only flips the selected flag, so the dragged handle stays live. body.set runs before
|
|
155
|
+
// selStop switches: re-pointing blurs a dirty channel field, whose commit must land on the old stop.
|
|
156
|
+
const select = (s: GradientStop, rerender: boolean) => { if (s !== selStop) { body.set(s.color); selStop = s; } rerender ? renderHandles() : reflectSel(); };
|
|
157
|
+
|
|
158
|
+
const posFromX = (x: number) => { const r = rail.getBoundingClientRect(); return clamp((x - r.left) / (r.width || 1), 0, 1); };
|
|
159
|
+
|
|
160
|
+
// A new stop's color: the bracketing stops interpolated in OKLCH at the EASED progress, so it lands invisibly on the ramp the bar draws.
|
|
161
|
+
const colorAt = (pos: number) => {
|
|
162
|
+
const ss = sorted(); let lo = ss[0], hi = ss[ss.length - 1];
|
|
163
|
+
for (let k = 0; k < ss.length - 1; k++) if (pos >= ss[k].pos && pos <= ss[k + 1].pos) { lo = ss[k]; hi = ss[k + 1]; break; }
|
|
164
|
+
const t = easingAt(parseEasing(easing), hi.pos > lo.pos ? clamp((pos - lo.pos) / (hi.pos - lo.pos), 0, 1) : 0); // a pos outside the outermost stops never extrapolates
|
|
165
|
+
const a = parseColor(lo.color), b = parseColor(hi.color);
|
|
166
|
+
// CSS missing-hue: a ~zero-chroma stop carries no hue, so the other's holds (white→red stays red).
|
|
167
|
+
const ach = (c: Oklcha) => c[1] < 1e-4;
|
|
168
|
+
let dh = b[2] - a[2]; if (dh > 180) dh -= 360; else if (dh < -180) dh += 360;
|
|
169
|
+
if (ach(a) || ach(b)) dh = 0;
|
|
170
|
+
const H = ((((ach(a) && !ach(b) ? b[2] : a[2]) + dh * t) % 360) + 360) % 360; // normalize to [0,360) so the picker reads it cleanly
|
|
171
|
+
return oklchStr(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, H, a[3] + (b[3] - a[3]) * t);
|
|
172
|
+
};
|
|
173
|
+
const removeStop = (s: GradientStop) => {
|
|
174
|
+
if (stops.length <= 2) return;
|
|
175
|
+
const i = stops.indexOf(s); if (i < 0) return; // a stale reference (rebuilt via set) must not splice(-1)
|
|
176
|
+
stops.splice(i, 1);
|
|
177
|
+
const next = selStop === s ? stops[Math.max(0, i - 1)] : selStop;
|
|
178
|
+
paint(); select(next, true); reflectCount(); emit();
|
|
179
|
+
handleFor(selStop)?.focus(); // keep focus on a handle so Delete can chain
|
|
180
|
+
};
|
|
181
|
+
// Insert a stop at `pos`, colored by sampling the gradient there, and select it. Shared by the +
|
|
182
|
+
// button (midway toward the selected stop's neighbor, so where it lands is predictable) and double-click on the bar.
|
|
183
|
+
const insertStopAt = (pos: number) => {
|
|
184
|
+
const s = { color: colorAt(pos), pos: clamp(pos, 0, 1) };
|
|
185
|
+
stops.push(s); paint(); select(s, true); reflectCount(); emit();
|
|
186
|
+
};
|
|
187
|
+
// Midway toward the next stop, or toward the previous from the last. There are always two stops at least (the
|
|
188
|
+
// normalizer's default pair, removeStop's floor), and the selected one is always among them.
|
|
189
|
+
const addStop = () => {
|
|
190
|
+
const ss = sorted(), i = ss.indexOf(selStop);
|
|
191
|
+
insertStopAt(i < ss.length - 1 ? (ss[i].pos + ss[i + 1].pos) / 2 : (ss[i - 1].pos + ss[i].pos) / 2);
|
|
192
|
+
};
|
|
193
|
+
addBtn.addEventListener("click", (e) => { e.stopPropagation(); addStop(); });
|
|
194
|
+
// Double-click, not single, so it never fights grabbing a stop; a double-click on a stop falls through to its own handler.
|
|
195
|
+
bar.addEventListener("dblclick", (e) => {
|
|
196
|
+
if ((e.target as Element).closest(".tw-gradient-stop")) return;
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
insertStopAt(posFromX(e.clientX));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset chord).
|
|
202
|
+
pop.tabIndex = -1;
|
|
203
|
+
pop.addEventListener("keydown", (e) => {
|
|
204
|
+
if ((e.key === "Delete" || e.key === "Backspace") && !e.altKey && !/^(input|textarea|select)$/i.test((e.target as Element).tagName) && stops.length > 2) { e.preventDefault(); removeStop(selStop); }
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Focus lands on the selected stop, not the first: focus is selection, so that would re-point the picker on every open.
|
|
208
|
+
popover(root, trigger, pop, { fallbackH: 392, onOpen: body.reflow, onReflow: body.reflow, initialFocus: () => handleFor(selStop) });
|
|
209
|
+
|
|
210
|
+
renderHandles(); paint(); reflectCount();
|
|
211
|
+
return {
|
|
212
|
+
el: root,
|
|
213
|
+
set: (v: unknown) => {
|
|
214
|
+
// Named → applied; absent → left as the user set it, never reset to OKLCH on a stops-only set.
|
|
215
|
+
const ip = parseInterp(v); if (ip) body.setMode(interpolationMode(ip));
|
|
216
|
+
const es = parseEase(v); if (es !== null) { easing = easingName(es); reflectEase(); } // same rule as the blend
|
|
217
|
+
const next = normalizeStops(v).sort((a, b) => a.pos - b.pos);
|
|
218
|
+
if (next.length === stops.length) {
|
|
219
|
+
// Same count (a host mirroring values back): update the stop objects in place, paired in
|
|
220
|
+
// position order, so a live drag keeps its handle and the picker its stop.
|
|
221
|
+
sorted().forEach((s, i) => {
|
|
222
|
+
s.color = next[i].color; s.pos = next[i].pos;
|
|
223
|
+
const h = handleFor(s); if (h) { h.style.left = s.pos * 100 + "%"; h.style.setProperty("--stop", s.color); }
|
|
224
|
+
});
|
|
225
|
+
} else {
|
|
226
|
+
const selPos = selStop.pos;
|
|
227
|
+
stops = next;
|
|
228
|
+
selStop = stops.reduce((b, s) => (Math.abs(s.pos - selPos) < Math.abs(b.pos - selPos) ? s : b), stops[0]); // re-select the nearest position, not index 0
|
|
229
|
+
renderHandles();
|
|
230
|
+
}
|
|
231
|
+
paint(); reflectCount(); body.set(selStop.color);
|
|
232
|
+
},
|
|
233
|
+
get: () => value(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
registerControl("gradient", createGradient);
|
|
238
|
+
|
|
239
|
+
// For test/gradient-css.test.mjs, which checks them beside the easing parser.
|
|
240
|
+
export { easingName, easingAt };
|
|
@@ -0,0 +1,299 @@
|
|
|
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";
|
|
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) => String(s).replace(/[\\"]/g, "\\$&").replace(/[\x00-\x1f\x7f]/g, (c) => "\\" + c.charCodeAt(0).toString(16) + " ");
|
|
11
|
+
const urlCss = (s: string) => `url("${cssString(s)}")`;
|
|
12
|
+
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`);
|
|
14
|
+
// 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.
|
|
25
|
+
const KEEP = /^image\/(gif|svg\+xml)$/i;
|
|
26
|
+
const PNG_BUDGET = 1.5 * MB;
|
|
27
|
+
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; };
|
|
29
|
+
// 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
|
+
// chunk ahead of its first IDAT. Only chunk headers are read, never the frames, however many chunks come first.
|
|
31
|
+
export const animated = async (file: Blob) => {
|
|
32
|
+
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");
|
|
34
|
+
if (webp && ascii(h, 12, "VP8X") && h[20] & 2) return true;
|
|
35
|
+
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;
|
|
38
|
+
const type = String.fromCharCode(...c.subarray(webp ? 0 : 4, webp ? 4 : 8));
|
|
39
|
+
if (type === (webp ? "ANIM" : "acTL")) return true;
|
|
40
|
+
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
|
|
42
|
+
i += webp ? 8 + n + (n & 1) : 12 + n; // at least a header on, so the end of the file comes
|
|
43
|
+
}
|
|
44
|
+
};
|
|
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.
|
|
49
|
+
// 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
|
+
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;
|
|
52
|
+
try {
|
|
53
|
+
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; }
|
|
55
|
+
return 1;
|
|
56
|
+
} catch { return null; } // an offset past the end
|
|
57
|
+
};
|
|
58
|
+
// 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]);
|
|
60
|
+
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.
|
|
64
|
+
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/"];
|
|
66
|
+
const gainMapOnly = (b: Uint8Array) => {
|
|
67
|
+
const x = new TextDecoder("latin1").decode(b), ok = new Set(["xmlns", "xml"]);
|
|
68
|
+
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); }
|
|
70
|
+
for (const [, p] of x.matchAll(/[<\s/]([\w.-]+):[\w.-]/g)) if (!ok.has(p)) return false;
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
// 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.
|
|
78
|
+
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;
|
|
80
|
+
try {
|
|
81
|
+
const ifd = v.getUint32(4, le);
|
|
82
|
+
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;
|
|
85
|
+
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); }
|
|
87
|
+
}
|
|
88
|
+
} catch { /* an offset past the segment */ }
|
|
89
|
+
};
|
|
90
|
+
const jpegCut = (b: Bytes) => {
|
|
91
|
+
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;
|
|
93
|
+
for (;;) {
|
|
94
|
+
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
|
|
97
|
+
if (m === 0xda) break; // start of scan: the picture itself, kept to the end
|
|
98
|
+
if (m === 0xe2 && ascii(b, i + 4, "MPF\0")) mpf = b.subarray(i + 8, end);
|
|
99
|
+
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)]));
|
|
104
|
+
if (mpf) moved += end - i - (bare ? 36 : 0);
|
|
105
|
+
}
|
|
106
|
+
i = end;
|
|
107
|
+
}
|
|
108
|
+
if (mpf && moved > 0) mpfShift(mpf, moved); // in place: these are strip()'s own bytes, and they go out as parts of it
|
|
109
|
+
return out.length ? [...out, b.subarray(from)] : null;
|
|
110
|
+
};
|
|
111
|
+
const pngCut = (b: Bytes) => {
|
|
112
|
+
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;
|
|
117
|
+
type = String.fromCharCode(...b.subarray(i + 4, i + 8));
|
|
118
|
+
// 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] : "";
|
|
120
|
+
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;
|
|
123
|
+
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)));
|
|
126
|
+
out.push(c);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
i = end;
|
|
130
|
+
}
|
|
131
|
+
return out.length ? [...out, b.subarray(from)] : null;
|
|
132
|
+
};
|
|
133
|
+
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; }
|
|
135
|
+
};
|
|
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); });
|
|
140
|
+
// 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
|
+
// canvas keeps the gamut, and toBlob tags what it writes with the P3 profile. A browser that doesn't know the option
|
|
142
|
+
// 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"); };
|
|
144
|
+
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 "";
|
|
146
|
+
let src = "";
|
|
147
|
+
try {
|
|
148
|
+
if (await animated(file)) return "";
|
|
149
|
+
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);
|
|
156
|
+
const jpeg = /^image\/jpe?g$/i.test(file.type);
|
|
157
|
+
let out = jpeg ? null : await encode(c, "image/png");
|
|
158
|
+
if (!out || out.size > PNG_BUDGET) {
|
|
159
|
+
const alt = await encode(c, !jpeg && hasAlpha(ctx, w, h) ? "image/webp" : "image/jpeg", quality);
|
|
160
|
+
if (alt && (!out || alt.size < out.size)) out = alt;
|
|
161
|
+
}
|
|
162
|
+
return out && out.size < file.size ? await dataUrl(out) : "";
|
|
163
|
+
} catch { return ""; } finally { if (src) URL.revokeObjectURL(src); }
|
|
164
|
+
};
|
|
165
|
+
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
|
+
|
|
167
|
+
function createImage(meta: Meta, onChange: OnChange): Control {
|
|
168
|
+
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.
|
|
170
|
+
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
|
|
173
|
+
const canClear = meta.clear !== false; // false: a required choice, with no Clear and no Delete to empty it
|
|
174
|
+
const maxSize = +meta.maxSize! > 0 ? +meta.maxSize! : 10 * MB;
|
|
175
|
+
// 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
|
|
177
|
+
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/*";
|
|
179
|
+
// A refused file's reason takes the button text's place for a moment. It is spoken from a status region beside the
|
|
180
|
+
// 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");
|
|
182
|
+
const notes = [note, live]; // a gallery adds its row's note, so a file refused on the closed row still shows why
|
|
183
|
+
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); };
|
|
185
|
+
// 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");
|
|
187
|
+
let render: () => void;
|
|
188
|
+
// Every commit bumps gen, so a file read that lands after a tile was picked, a reset or a host set() is dropped.
|
|
189
|
+
let gen = 0;
|
|
190
|
+
const commit = (v: string, fire = true) => { gen++; value = v; render(); if (fire) onChange(value); };
|
|
191
|
+
const load = (file: File | undefined) => {
|
|
192
|
+
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.
|
|
196
|
+
const at = ++gen;
|
|
197
|
+
shrink(file, fit, quality).then(async (url) => {
|
|
198
|
+
if (gen !== at) return;
|
|
199
|
+
if (url) return commit(url);
|
|
200
|
+
const fr = new FileReader();
|
|
201
|
+
fr.onload = () => { if (gen === at) commit(String(fr.result)); };
|
|
202
|
+
fr.onerror = () => say("Couldn't read the file");
|
|
203
|
+
fr.readAsDataURL(await strip(file));
|
|
204
|
+
});
|
|
205
|
+
};
|
|
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.
|
|
207
|
+
// A disabled control's root stays hit-testable (only its children go inert), so the zone checks for itself; on the
|
|
208
|
+
// control's root, since the gallery's pane is portaled out of the panel while open.
|
|
209
|
+
const dropZone = (zone: HTMLElement, over: HTMLElement) => {
|
|
210
|
+
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]); });
|
|
214
|
+
};
|
|
215
|
+
input.addEventListener("change", () => { load(input.files?.[0]); input.value = ""; }); // cleared, so the same file picked again fires change
|
|
216
|
+
// 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) : ""; };
|
|
220
|
+
|
|
221
|
+
if (!options.length) {
|
|
222
|
+
// The plain row: the whole trigger is the target, a click opens the file dialog and the row is also the drop zone.
|
|
223
|
+
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");
|
|
226
|
+
row.right.append(thumb, note, text);
|
|
227
|
+
root.append(input, live);
|
|
228
|
+
render = () => { paintThumb(); text.textContent = value ? "Replace" : "Drop or choose"; };
|
|
229
|
+
row.trigger.addEventListener("click", () => input.click());
|
|
230
|
+
dropZone(row.trigger, row.trigger);
|
|
231
|
+
} 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");
|
|
236
|
+
row.right.append(thumb, rowNote, name);
|
|
237
|
+
notes.push(rowNote);
|
|
238
|
+
// 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`);
|
|
242
|
+
// The foot folds like a folder: a row track holds the upload button and a column track holds Clear, so either can
|
|
243
|
+
// 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);
|
|
247
|
+
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; });
|
|
253
|
+
// 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
|
|
257
|
+
render = () => {
|
|
258
|
+
const k = tiles.findIndex((b) => b._twVal === value);
|
|
259
|
+
if (value && k < 0) {
|
|
260
|
+
custom._twVal = value; custom.style.backgroundImage = urlCss(value); custom.setAttribute("aria-label", customName(value));
|
|
261
|
+
if (!custom.parentNode) grid.append(custom); // re-appending an attached node moves it, and a move blurs it
|
|
262
|
+
} 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.
|
|
265
|
+
if (custom.matches(":focus")) tiles[Math.max(0, k)].focus();
|
|
266
|
+
custom.remove();
|
|
267
|
+
}
|
|
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
|
|
272
|
+
paintThumb();
|
|
273
|
+
name.textContent = !value ? "None" : k >= 0 ? names[k] : customName(value);
|
|
274
|
+
};
|
|
275
|
+
grid.addEventListener("keydown", (e) => {
|
|
276
|
+
const btns = all(), i = btns.indexOf(e.target as RadioBtn); if (i < 0) return;
|
|
277
|
+
// 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
|
|
281
|
+
});
|
|
282
|
+
clear.addEventListener("click", () => { (upload || tiles[0]).focus(); commit(""); }); // Clear hides itself: hand focus on first, not to the body
|
|
283
|
+
pop.append(grid, foot);
|
|
284
|
+
root.append(pop, live);
|
|
285
|
+
if (upload) {
|
|
286
|
+
upload.setAttribute("aria-label", `${meta.label}: upload an image`);
|
|
287
|
+
upload.append(note, txt("span", "tw-image-text", "Upload image"));
|
|
288
|
+
upload.addEventListener("click", () => input.click());
|
|
289
|
+
footRow.append(upload); root.append(input);
|
|
290
|
+
dropZone(row.trigger, row.trigger); dropZone(pop, pop);
|
|
291
|
+
}
|
|
292
|
+
if (canClear) footRow.append(slot);
|
|
293
|
+
popover(root, row.trigger, pop, { fallbackH: 220, initialFocus: () => all().find((b) => b.tabIndex === 0) });
|
|
294
|
+
}
|
|
295
|
+
render();
|
|
296
|
+
return { el: root, set: (v: string | null | undefined) => { if (v != null && typeof v !== "string") return; commit(v || "", false); }, get: () => value, note: say };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
registerControl("image", createImage);
|