tweakit 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +32 -30
- 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-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/color-UGYMAF3W.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- 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-NPFIGHRM.js +2 -0
- package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
- 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 +7 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/enhance.d.ts +3 -2
- 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 +499 -116
- package/package.json +30 -5
- package/src/tweaks/auto.ts +16 -0
- package/src/tweaks/clip.ts +156 -0
- package/src/tweaks/controls/basic.ts +727 -0
- package/src/tweaks/controls/bezier.ts +204 -0
- package/src/tweaks/controls/color.ts +861 -0
- package/src/tweaks/controls/gradient.ts +419 -0
- package/src/tweaks/controls/image.ts +555 -0
- package/src/tweaks/controls/interval.ts +189 -0
- package/src/tweaks/controls/monitor.ts +211 -0
- package/src/tweaks/controls/motion.ts +316 -0
- package/src/tweaks/controls/plot.ts +440 -0
- package/src/tweaks/controls/point.ts +208 -0
- package/src/tweaks/controls/shadow.ts +156 -0
- package/src/tweaks/controls/sides.ts +196 -0
- package/src/tweaks/controls/spring.ts +308 -0
- package/src/tweaks/controls/tabs.ts +68 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +118 -0
- package/src/tweaks/enhance.ts +35 -0
- package/src/tweaks/feedback.ts +218 -0
- package/src/tweaks/fold.ts +33 -0
- package/src/tweaks/heavy.ts +626 -0
- package/src/tweaks/icons.ts +26 -0
- package/src/tweaks/image-store.ts +189 -0
- package/src/tweaks/lazy.ts +63 -0
- package/src/tweaks/markup.ts +317 -0
- package/src/tweaks/panel.ts +1693 -0
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +351 -0
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +1137 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +179 -0
- package/src/tweaks/types.ts +1027 -0
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +965 -0
- package/src/wide-gamut.ts +658 -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/fold-UGN7TXGX.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,419 @@
|
|
|
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, words } 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 =>
|
|
17
|
+
parseEasing(e) ? ((e as string).trim().toLowerCase() as GradientEasing) : "linear";
|
|
18
|
+
|
|
19
|
+
/** The blend progress at a position, linear between samples exactly as the browser draws the
|
|
20
|
+
* stops, so a stop inserted at x takes the color the ramp really shows. Null is the identity. */
|
|
21
|
+
function easingAt(b: Bez | null, x: number): number {
|
|
22
|
+
x = clamp01(x); // outside the segment is its end; never extrapolate
|
|
23
|
+
if (!b) return x;
|
|
24
|
+
const s = easingSamples(b);
|
|
25
|
+
for (let k = 1; k < s.length; k++) {
|
|
26
|
+
if (x <= s[k][0]) {
|
|
27
|
+
const [x0, y0] = s[k - 1],
|
|
28
|
+
[x1, y1] = s[k];
|
|
29
|
+
return x1 > x0 ? y0 + ((y1 - y0) * (x - x0)) / (x1 - x0) : y1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A stop handle on the rail, carrying the stop it moves. */
|
|
36
|
+
interface StopHandle extends HTMLButtonElement {
|
|
37
|
+
_stop?: GradientStop;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ICON_PLUS — adapted from Hugeicons `add-01` (MIT), two paths merged. See ../../../THIRD-PARTY-NOTICES.md.
|
|
41
|
+
const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
|
|
42
|
+
|
|
43
|
+
// ── Gradient — a Figma-style editor: a swatch-trigger row opens a popover holding the stop bar
|
|
44
|
+
// over a reused color picker body; selecting a stop re-points the body at it. The bar blends in
|
|
45
|
+
// the space of the editor's mode, which rides along as `interpolation`, and `easing` likewise, so a
|
|
46
|
+
// host's `linear-gradient(in …)` through gradientCss() gets the same pixels. ──
|
|
47
|
+
// A value's blend space / easing: an explicit string on the object form, else null (the array
|
|
48
|
+
// shorthand carries none). Inspected field by field, so the normalizers take `any`.
|
|
49
|
+
const field = (value: any, key: string): string | null =>
|
|
50
|
+
value && !Array.isArray(value) && typeof value[key] === "string" ? value[key] : null;
|
|
51
|
+
const parseInterp = (value: any) => field(value, "interpolation");
|
|
52
|
+
const parseEase = (value: any) => field(value, "easing");
|
|
53
|
+
// The easing menu; a stored cubic-bezier() not among them shows as a "Custom" option while it's the value.
|
|
54
|
+
// value, strings key, word
|
|
55
|
+
const EASINGS: Array<[GradientEasing, string, string]> = [
|
|
56
|
+
["linear", "easeLinear", "Linear"],
|
|
57
|
+
["ease", "ease", "Ease"],
|
|
58
|
+
["ease-in", "easeIn", "Ease in"],
|
|
59
|
+
["ease-out", "easeOut", "Ease out"],
|
|
60
|
+
["ease-in-out", "easeInOut", "Ease in-out"],
|
|
61
|
+
];
|
|
62
|
+
function normalizeStops(value: any): GradientStop[] {
|
|
63
|
+
const DEF: GradientStop[] = [
|
|
64
|
+
{ color: "oklch(0.72 0.19 25)", pos: 0 },
|
|
65
|
+
{ color: "oklch(0.72 0.16 280)", pos: 1 },
|
|
66
|
+
];
|
|
67
|
+
const arr = Array.isArray(value) ? value : value && Array.isArray(value.stops) ? value.stops : null;
|
|
68
|
+
// A stop's color must be a single color token: stop text goes straight into the preview's
|
|
69
|
+
// `linear-gradient(…)`, where an unparsed string could splice in further layers (a url() fetch
|
|
70
|
+
// from persisted state). A non-finite pos → 0, clamped into [0,1]; fewer than two usable stops → the default pair.
|
|
71
|
+
// Trimmed as the gate trims it: the CSS tokenizer reads a non-ASCII space as part of a name.
|
|
72
|
+
const stop = (color: string, pos: unknown): GradientStop | null =>
|
|
73
|
+
isColor(color) ? { color: String(color).trim(), pos: clamp(+pos || 0, 0, 1) } : null;
|
|
74
|
+
const out = (arr || [])
|
|
75
|
+
.map((s: any) => {
|
|
76
|
+
if (Array.isArray(s)) return stop(s[0], s[1]);
|
|
77
|
+
if (s && typeof s === "object") return stop(s.color, s.pos);
|
|
78
|
+
return null;
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
return out.length >= 2 ? out : DEF;
|
|
82
|
+
}
|
|
83
|
+
function createGradient(meta: Meta, onChange: OnChange): Control {
|
|
84
|
+
const tr = words();
|
|
85
|
+
meta.value ??= meta.stops; // the verbose form's `stops` aliases `value`
|
|
86
|
+
// The blend and easing read off `value: {…}` first, else the verbose form's top level, where a pasted value's fields
|
|
87
|
+
// land.
|
|
88
|
+
const interp0 = parseInterp(meta.value) ?? parseInterp(meta),
|
|
89
|
+
ease0 = parseEase(meta.value) ?? parseEase(meta);
|
|
90
|
+
let stops = normalizeStops(meta.value);
|
|
91
|
+
let selStop = stops[0];
|
|
92
|
+
let easing = easingName(ease0); // unrecognized reads as linear
|
|
93
|
+
|
|
94
|
+
// ── Trigger row — the strip is the readout; the stop count rides along for the accessibility tree. ──
|
|
95
|
+
// ??: an explicit "" label renders none
|
|
96
|
+
const { root, trigger, right } = triggerRow("twk-gradient", meta.label ?? tr("gradient", "Gradient"));
|
|
97
|
+
const preview = el("span", "twk-trigger-chip twk-gradient-preview");
|
|
98
|
+
preview.setAttribute("role", "img");
|
|
99
|
+
right.append(preview);
|
|
100
|
+
|
|
101
|
+
// ── Editor popover — carries the color popover's class so it inherits its tokens and shell. ──
|
|
102
|
+
const pop = el("div", "twk-color-pop twk-gradient-pop");
|
|
103
|
+
const barRow = el("div", "twk-gradient-bar-row");
|
|
104
|
+
const bar = el("div", "twk-gradient-bar");
|
|
105
|
+
const grad = el("div", "twk-gradient-grad");
|
|
106
|
+
const rail = el("div", "twk-gradient-rail");
|
|
107
|
+
bar.append(grad, rail);
|
|
108
|
+
const addBtn = btn("twk-gradient-add", ICON_PLUS);
|
|
109
|
+
addBtn.title = tr("addStop", "Add a stop after the selected one");
|
|
110
|
+
addBtn.setAttribute("aria-label", tr("addStopAria", "Add a color stop after the selected one"));
|
|
111
|
+
barRow.append(bar, addBtn);
|
|
112
|
+
|
|
113
|
+
const sorted = () => [...stops].sort((a, b) => a.pos - b.pos);
|
|
114
|
+
// body.mode() is the single source of truth for the blend space, so the bar, preview and emitted
|
|
115
|
+
// interpolation can't drift. (Stops keep their own notation; CSS lets `in srgb` carry oklch() stops.)
|
|
116
|
+
const interp = () => modeInterpolation(body.mode());
|
|
117
|
+
const value = () => ({
|
|
118
|
+
stops: sorted().map((s) => ({ color: s.color, pos: +s.pos.toFixed(4) })),
|
|
119
|
+
interpolation: interp(),
|
|
120
|
+
easing,
|
|
121
|
+
});
|
|
122
|
+
// Both draw gradientCss(value()), so the editor can't show a ramp a host templating through it won't get.
|
|
123
|
+
const paint = () => {
|
|
124
|
+
const css = gradientCss(value());
|
|
125
|
+
grad.style.background = css;
|
|
126
|
+
preview.style.background = `${css}, ${CHECKER}`;
|
|
127
|
+
};
|
|
128
|
+
const reflectCount = () => {
|
|
129
|
+
preview.setAttribute(
|
|
130
|
+
"aria-label",
|
|
131
|
+
tr("stops", (v) => `${v.n} stop${v.n === 1 ? "" : "s"}`, { n: stops.length }),
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
const emit = () => onChange(value());
|
|
135
|
+
|
|
136
|
+
// the rail holds only stop handles
|
|
137
|
+
const handleFor = (s: GradientStop) =>
|
|
138
|
+
[...(rail.children as HTMLCollectionOf<StopHandle>)].find((h) => h._stop === s);
|
|
139
|
+
// Unlike the standalone color control, a mode switch here IS a change to the gradient's output, so it repaints and
|
|
140
|
+
// re-emits.
|
|
141
|
+
// oklch: the documented default and colorAt's blend space
|
|
142
|
+
const body = createPickerBody(
|
|
143
|
+
{
|
|
144
|
+
value: selStop.color,
|
|
145
|
+
mode: interpolationMode(interp0 || "oklch"),
|
|
146
|
+
onMode: () => {
|
|
147
|
+
paint();
|
|
148
|
+
emit();
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
(c) => {
|
|
152
|
+
selStop.color = c;
|
|
153
|
+
handleFor(selStop)?.style.setProperty("--stop", c);
|
|
154
|
+
paint();
|
|
155
|
+
emit();
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
// Easing sits beside the mode: both are "how the ramp blends".
|
|
159
|
+
const easeSel = el("select", "twk-color-mode twk-gradient-ease");
|
|
160
|
+
easeSel.setAttribute("aria-label", tr("stopEasing", "Easing between stops"));
|
|
161
|
+
easeSel.title = tr("stopEasingHint", "How the ramp blends between stops");
|
|
162
|
+
for (const [v, k, label] of EASINGS) {
|
|
163
|
+
const o = document.createElement("option");
|
|
164
|
+
o.value = v;
|
|
165
|
+
o.textContent = tr(k, label);
|
|
166
|
+
easeSel.append(o);
|
|
167
|
+
}
|
|
168
|
+
const reflectEase = () => {
|
|
169
|
+
let custom = easeSel.querySelector<HTMLOptionElement>("option[data-custom]");
|
|
170
|
+
if (EASINGS.some(([v]) => v === easing)) custom?.remove();
|
|
171
|
+
else {
|
|
172
|
+
if (!custom) {
|
|
173
|
+
custom = document.createElement("option");
|
|
174
|
+
custom.dataset.custom = "";
|
|
175
|
+
custom.textContent = tr("custom", "Custom");
|
|
176
|
+
easeSel.append(custom);
|
|
177
|
+
}
|
|
178
|
+
custom.value = easing;
|
|
179
|
+
}
|
|
180
|
+
easeSel.value = easing;
|
|
181
|
+
};
|
|
182
|
+
// reflect: picking a keyword retires the Custom option
|
|
183
|
+
easeSel.addEventListener("change", () => {
|
|
184
|
+
easing = easeSel.value as GradientEasing;
|
|
185
|
+
reflectEase();
|
|
186
|
+
paint();
|
|
187
|
+
emit();
|
|
188
|
+
});
|
|
189
|
+
body.el.querySelector(".twk-color-mode")!.after(easeSel); // after the mode select, before the gamut tag
|
|
190
|
+
reflectEase();
|
|
191
|
+
pop.append(barRow, body.el);
|
|
192
|
+
root.append(pop);
|
|
193
|
+
|
|
194
|
+
// A stop's accessible name carries its position and selection, the state the ring shows sighted users.
|
|
195
|
+
const labelStop = (h: StopHandle) =>
|
|
196
|
+
h.setAttribute(
|
|
197
|
+
"aria-label",
|
|
198
|
+
tr(
|
|
199
|
+
h._stop === selStop ? "stopAtSelected" : "stopAt",
|
|
200
|
+
h._stop === selStop ? "Color stop at {pos}%, selected" : "Color stop at {pos}%",
|
|
201
|
+
{ pos: Math.round(h._stop.pos * 100) },
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
const reflectSel = () => {
|
|
205
|
+
for (const h of rail.children as HTMLCollectionOf<StopHandle>) {
|
|
206
|
+
h.dataset.sel = String(h._stop === selStop);
|
|
207
|
+
labelStop(h);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
// A drag or an arrow moving a stop: its spot on the rail, its name, the ramp and the value follow.
|
|
211
|
+
const moveStop = (h: StopHandle, pos: number) => {
|
|
212
|
+
h._stop.pos = clamp(pos, 0, 1);
|
|
213
|
+
h.style.left = h._stop.pos * 100 + "%";
|
|
214
|
+
labelStop(h);
|
|
215
|
+
paint();
|
|
216
|
+
emit();
|
|
217
|
+
};
|
|
218
|
+
const renderHandles = () => {
|
|
219
|
+
rail.replaceChildren();
|
|
220
|
+
for (const s of stops) {
|
|
221
|
+
const h: StopHandle = btn("twk-gradient-stop");
|
|
222
|
+
h._stop = s;
|
|
223
|
+
h.style.left = s.pos * 100 + "%";
|
|
224
|
+
h.style.setProperty("--stop", s.color);
|
|
225
|
+
h.dataset.sel = String(s === selStop);
|
|
226
|
+
labelStop(h);
|
|
227
|
+
// Focus is selection: tabbing onto a stop points the picker at it.
|
|
228
|
+
h.addEventListener("focus", () => select(s, false));
|
|
229
|
+
let offBar = false;
|
|
230
|
+
dragGesture(h, {
|
|
231
|
+
// preventDefault suppresses click-to-focus, so focus explicitly (ringless); that also blurs
|
|
232
|
+
// a dirty channel field while the old stop is still selected, so its commit lands there.
|
|
233
|
+
onDown: (e) => {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
e.stopPropagation();
|
|
236
|
+
h.focus({ focusVisible: false });
|
|
237
|
+
select(s, false);
|
|
238
|
+
offBar = false;
|
|
239
|
+
},
|
|
240
|
+
onMove: (e) => {
|
|
241
|
+
// One rail rect for X and the off-bar Y test; the X math is posFromX inlined.
|
|
242
|
+
const r = rail.getBoundingClientRect();
|
|
243
|
+
moveStop(h, (e.clientX - r.left) / (r.width || 1));
|
|
244
|
+
// Dragging ~24px clear of the bar removes the stop (floored at two).
|
|
245
|
+
offBar = stops.length > 2 && (e.clientY < r.top - 24 || e.clientY > r.bottom + 24);
|
|
246
|
+
h.dataset.removing = String(offBar);
|
|
247
|
+
},
|
|
248
|
+
onEnd: () => {
|
|
249
|
+
if (offBar) removeStop(s);
|
|
250
|
+
else h.dataset.removing = "false";
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
h.addEventListener("dblclick", (e) => {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
removeStop(s);
|
|
256
|
+
});
|
|
257
|
+
// Arrows nudge by 0.01 (⇧ = 0.1); Delete stays on the popover.
|
|
258
|
+
h.addEventListener("keydown", (e) => {
|
|
259
|
+
const [d] = arrowStep(e);
|
|
260
|
+
if (!d) return;
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
moveStop(h, s.pos + d * (e.shiftKey ? 0.1 : 0.01));
|
|
263
|
+
});
|
|
264
|
+
rail.append(h);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
// A drag only flips the selected flag, so the dragged handle stays live. body.set runs before
|
|
268
|
+
// selStop switches: re-pointing blurs a dirty channel field, whose commit must land on the old stop.
|
|
269
|
+
const select = (s: GradientStop, rerender: boolean) => {
|
|
270
|
+
if (s !== selStop) {
|
|
271
|
+
body.set(s.color);
|
|
272
|
+
selStop = s;
|
|
273
|
+
}
|
|
274
|
+
rerender ? renderHandles() : reflectSel();
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const posFromX = (x: number) => {
|
|
278
|
+
const r = rail.getBoundingClientRect();
|
|
279
|
+
return clamp((x - r.left) / (r.width || 1), 0, 1);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// A new stop's color: the bracketing stops interpolated in OKLCH at the EASED progress, so it lands invisibly on the
|
|
283
|
+
// ramp the bar draws.
|
|
284
|
+
const colorAt = (pos: number) => {
|
|
285
|
+
const ss = sorted();
|
|
286
|
+
let lo = ss[0],
|
|
287
|
+
hi = ss[ss.length - 1];
|
|
288
|
+
for (let k = 0; k < ss.length - 1; k++)
|
|
289
|
+
if (pos >= ss[k].pos && pos <= ss[k + 1].pos) {
|
|
290
|
+
lo = ss[k];
|
|
291
|
+
hi = ss[k + 1];
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
// a pos outside the outermost stops never extrapolates
|
|
295
|
+
const t = easingAt(parseEasing(easing), hi.pos > lo.pos ? clamp((pos - lo.pos) / (hi.pos - lo.pos), 0, 1) : 0);
|
|
296
|
+
const a = parseColor(lo.color),
|
|
297
|
+
b = parseColor(hi.color);
|
|
298
|
+
// CSS missing-hue: a ~zero-chroma stop carries no hue, so the other's holds (white→red stays red).
|
|
299
|
+
const ach = (c: Oklcha) => c[1] < 1e-4;
|
|
300
|
+
let dh = b[2] - a[2];
|
|
301
|
+
if (dh > 180) dh -= 360;
|
|
302
|
+
else if (dh < -180) dh += 360;
|
|
303
|
+
if (ach(a) || ach(b)) dh = 0;
|
|
304
|
+
// normalize to [0,360) so the picker reads it cleanly
|
|
305
|
+
const H = ((((ach(a) && !ach(b) ? b[2] : a[2]) + dh * t) % 360) + 360) % 360;
|
|
306
|
+
return oklchStr(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, H, a[3] + (b[3] - a[3]) * t);
|
|
307
|
+
};
|
|
308
|
+
const removeStop = (s: GradientStop) => {
|
|
309
|
+
if (stops.length <= 2) return;
|
|
310
|
+
const i = stops.indexOf(s);
|
|
311
|
+
if (i < 0) return; // a stale reference (rebuilt via set) must not splice(-1)
|
|
312
|
+
stops.splice(i, 1);
|
|
313
|
+
const next = selStop === s ? stops[Math.max(0, i - 1)] : selStop;
|
|
314
|
+
paint();
|
|
315
|
+
select(next, true);
|
|
316
|
+
reflectCount();
|
|
317
|
+
emit();
|
|
318
|
+
handleFor(selStop)?.focus(); // keep focus on a handle so Delete can chain
|
|
319
|
+
};
|
|
320
|
+
// Insert a stop at `pos`, colored by sampling the gradient there, and select it. Shared by the +
|
|
321
|
+
// button (midway toward the selected stop's neighbor, so where it lands is predictable) and double-click on the bar.
|
|
322
|
+
const insertStopAt = (pos: number) => {
|
|
323
|
+
const s = { color: colorAt(pos), pos: clamp(pos, 0, 1) };
|
|
324
|
+
stops.push(s);
|
|
325
|
+
paint();
|
|
326
|
+
select(s, true);
|
|
327
|
+
reflectCount();
|
|
328
|
+
emit();
|
|
329
|
+
};
|
|
330
|
+
// Midway toward the next stop, or toward the previous from the last. There are always two stops at least (the
|
|
331
|
+
// normalizer's default pair, removeStop's floor), and the selected one is always among them.
|
|
332
|
+
const addStop = () => {
|
|
333
|
+
const ss = sorted(),
|
|
334
|
+
i = ss.indexOf(selStop);
|
|
335
|
+
insertStopAt(i < ss.length - 1 ? (ss[i].pos + ss[i + 1].pos) / 2 : (ss[i - 1].pos + ss[i].pos) / 2);
|
|
336
|
+
};
|
|
337
|
+
addBtn.addEventListener("click", (e) => {
|
|
338
|
+
e.stopPropagation();
|
|
339
|
+
addStop();
|
|
340
|
+
});
|
|
341
|
+
// Double-click, not single, so it never fights grabbing a stop; a double-click on a stop falls through to its own
|
|
342
|
+
// handler.
|
|
343
|
+
bar.addEventListener("dblclick", (e) => {
|
|
344
|
+
if ((e.target as Element).closest(".twk-gradient-stop")) return;
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
insertStopAt(posFromX(e.clientX));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset
|
|
350
|
+
// chord).
|
|
351
|
+
pop.tabIndex = -1;
|
|
352
|
+
pop.addEventListener("keydown", (e) => {
|
|
353
|
+
if (
|
|
354
|
+
(e.key === "Delete" || e.key === "Backspace") &&
|
|
355
|
+
!e.altKey &&
|
|
356
|
+
!/^(input|textarea|select)$/i.test((e.target as Element).tagName) &&
|
|
357
|
+
stops.length > 2
|
|
358
|
+
) {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
removeStop(selStop);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Focus lands on the selected stop, not the first: focus is selection, so that would re-point the picker on every
|
|
365
|
+
// open.
|
|
366
|
+
popover(root, trigger, pop, {
|
|
367
|
+
fallbackH: 392,
|
|
368
|
+
onOpen: body.reflow,
|
|
369
|
+
onReflow: body.reflow,
|
|
370
|
+
initialFocus: () => handleFor(selStop),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
renderHandles();
|
|
374
|
+
paint();
|
|
375
|
+
reflectCount();
|
|
376
|
+
return {
|
|
377
|
+
el: root,
|
|
378
|
+
set: (v: unknown) => {
|
|
379
|
+
// Named → applied; absent → left as the user set it, never reset to OKLCH on a stops-only set.
|
|
380
|
+
const ip = parseInterp(v);
|
|
381
|
+
if (ip) body.setMode(interpolationMode(ip));
|
|
382
|
+
const es = parseEase(v);
|
|
383
|
+
// same rule as the blend
|
|
384
|
+
if (es !== null) {
|
|
385
|
+
easing = easingName(es);
|
|
386
|
+
reflectEase();
|
|
387
|
+
}
|
|
388
|
+
const next = normalizeStops(v).sort((a, b) => a.pos - b.pos);
|
|
389
|
+
if (next.length === stops.length) {
|
|
390
|
+
// Same count (a host mirroring values back): update the stop objects in place, paired in
|
|
391
|
+
// position order, so a live drag keeps its handle and the picker its stop.
|
|
392
|
+
sorted().forEach((s, i) => {
|
|
393
|
+
s.color = next[i].color;
|
|
394
|
+
s.pos = next[i].pos;
|
|
395
|
+
const h = handleFor(s);
|
|
396
|
+
if (h) {
|
|
397
|
+
h.style.left = s.pos * 100 + "%";
|
|
398
|
+
h.style.setProperty("--stop", s.color);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
402
|
+
const selPos = selStop.pos;
|
|
403
|
+
stops = next;
|
|
404
|
+
// re-select the nearest position, not index 0
|
|
405
|
+
selStop = stops.reduce((b, s) => (Math.abs(s.pos - selPos) < Math.abs(b.pos - selPos) ? s : b), stops[0]);
|
|
406
|
+
renderHandles();
|
|
407
|
+
}
|
|
408
|
+
paint();
|
|
409
|
+
reflectCount();
|
|
410
|
+
body.set(selStop.color);
|
|
411
|
+
},
|
|
412
|
+
get: () => value(),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
registerControl("gradient", createGradient);
|
|
417
|
+
|
|
418
|
+
// For test/gradient-css.test.mjs, which checks them beside the easing parser.
|
|
419
|
+
export { easingName, easingAt };
|