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,6 +1,6 @@
|
|
|
1
1
|
// ── Gradient — a wide-gamut OKLCH gradient editor. Lazy; depends on the color
|
|
2
2
|
// module (reuses its picker body, parseColor, and oklchStr).
|
|
3
|
-
import { el, btn, clamp, popover, registerControl, icon } from "../shared.js";
|
|
3
|
+
import { el, btn, clamp, popover, registerControl, icon, words } from "../shared.js";
|
|
4
4
|
import { dragGesture, triggerRow, arrowStep } from "../heavy.js";
|
|
5
5
|
import { createPickerBody, parseColor, isColor, oklchStr, CHECKER } from "./color.js";
|
|
6
6
|
import { modeInterpolation, interpolationMode } from "../../wide-gamut.js";
|
|
@@ -13,7 +13,8 @@ import type { Control, GradientStop, GradientEasing } from "../types.js";
|
|
|
13
13
|
|
|
14
14
|
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
15
15
|
/** The name an easing is stored under: trimmed and lowercased when parseEasing knows it, else "linear". */
|
|
16
|
-
const easingName = (e: unknown): GradientEasing =>
|
|
16
|
+
const easingName = (e: unknown): GradientEasing =>
|
|
17
|
+
parseEasing(e) ? ((e as string).trim().toLowerCase() as GradientEasing) : "linear";
|
|
17
18
|
|
|
18
19
|
/** The blend progress at a position, linear between samples exactly as the browser draws the
|
|
19
20
|
* stops, so a stop inserted at x takes the color the ramp really shows. Null is the identity. */
|
|
@@ -22,13 +23,19 @@ function easingAt(b: Bez | null, x: number): number {
|
|
|
22
23
|
if (!b) return x;
|
|
23
24
|
const s = easingSamples(b);
|
|
24
25
|
for (let k = 1; k < s.length; k++) {
|
|
25
|
-
if (x <= s[k][0]) {
|
|
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
|
+
}
|
|
26
31
|
}
|
|
27
32
|
return 1;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/** A stop handle on the rail, carrying the stop it moves. */
|
|
31
|
-
interface StopHandle extends HTMLButtonElement {
|
|
36
|
+
interface StopHandle extends HTMLButtonElement {
|
|
37
|
+
_stop?: GradientStop;
|
|
38
|
+
}
|
|
32
39
|
|
|
33
40
|
// ICON_PLUS — adapted from Hugeicons `add-01` (MIT), two paths merged. See ../../../THIRD-PARTY-NOTICES.md.
|
|
34
41
|
const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
|
|
@@ -39,99 +46,197 @@ const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
|
|
|
39
46
|
// host's `linear-gradient(in …)` through gradientCss() gets the same pixels. ──
|
|
40
47
|
// A value's blend space / easing: an explicit string on the object form, else null (the array
|
|
41
48
|
// shorthand carries none). Inspected field by field, so the normalizers take `any`.
|
|
42
|
-
const field = (value: any, key: string): string | null =>
|
|
49
|
+
const field = (value: any, key: string): string | null =>
|
|
50
|
+
value && !Array.isArray(value) && typeof value[key] === "string" ? value[key] : null;
|
|
43
51
|
const parseInterp = (value: any) => field(value, "interpolation");
|
|
44
52
|
const parseEase = (value: any) => field(value, "easing");
|
|
45
53
|
// The easing menu; a stored cubic-bezier() not among them shows as a "Custom" option while it's the value.
|
|
46
|
-
|
|
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
|
+
];
|
|
47
62
|
function normalizeStops(value: any): GradientStop[] {
|
|
48
|
-
const DEF: GradientStop[] = [
|
|
49
|
-
|
|
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;
|
|
50
68
|
// A stop's color must be a single color token: stop text goes straight into the preview's
|
|
51
69
|
// `linear-gradient(…)`, where an unparsed string could splice in further layers (a url() fetch
|
|
52
70
|
// from persisted state). A non-finite pos → 0, clamped into [0,1]; fewer than two usable stops → the default pair.
|
|
53
71
|
// 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 =>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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);
|
|
60
81
|
return out.length >= 2 ? out : DEF;
|
|
61
82
|
}
|
|
62
83
|
function createGradient(meta: Meta, onChange: OnChange): Control {
|
|
84
|
+
const tr = words();
|
|
63
85
|
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
|
|
65
|
-
|
|
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);
|
|
66
90
|
let stops = normalizeStops(meta.value);
|
|
67
91
|
let selStop = stops[0];
|
|
68
92
|
let easing = easingName(ease0); // unrecognized reads as linear
|
|
69
93
|
|
|
70
94
|
// ── Trigger row — the strip is the readout; the stop count rides along for the accessibility tree. ──
|
|
71
|
-
|
|
72
|
-
const
|
|
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");
|
|
73
99
|
right.append(preview);
|
|
74
100
|
|
|
75
101
|
// ── Editor popover — carries the color popover's class so it inherits its tokens and shell. ──
|
|
76
|
-
const pop = el("div", "
|
|
77
|
-
const barRow = el("div", "
|
|
78
|
-
const bar = el("div", "
|
|
79
|
-
const grad = el("div", "
|
|
80
|
-
const rail = el("div", "
|
|
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");
|
|
81
107
|
bar.append(grad, rail);
|
|
82
|
-
const addBtn = btn("
|
|
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"));
|
|
83
111
|
barRow.append(bar, addBtn);
|
|
84
112
|
|
|
85
113
|
const sorted = () => [...stops].sort((a, b) => a.pos - b.pos);
|
|
86
114
|
// body.mode() is the single source of truth for the blend space, so the bar, preview and emitted
|
|
87
115
|
// interpolation can't drift. (Stops keep their own notation; CSS lets `in srgb` carry oklch() stops.)
|
|
88
116
|
const interp = () => modeInterpolation(body.mode());
|
|
89
|
-
const value = () => ({
|
|
117
|
+
const value = () => ({
|
|
118
|
+
stops: sorted().map((s) => ({ color: s.color, pos: +s.pos.toFixed(4) })),
|
|
119
|
+
interpolation: interp(),
|
|
120
|
+
easing,
|
|
121
|
+
});
|
|
90
122
|
// Both draw gradientCss(value()), so the editor can't show a ramp a host templating through it won't get.
|
|
91
|
-
const paint = () => {
|
|
92
|
-
|
|
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
|
+
};
|
|
93
134
|
const emit = () => onChange(value());
|
|
94
135
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
);
|
|
102
158
|
// Easing sits beside the mode: both are "how the ramp blends".
|
|
103
|
-
const easeSel = el("select", "
|
|
104
|
-
|
|
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
|
+
}
|
|
105
168
|
const reflectEase = () => {
|
|
106
169
|
let custom = easeSel.querySelector<HTMLOptionElement>("option[data-custom]");
|
|
107
170
|
if (EASINGS.some(([v]) => v === easing)) custom?.remove();
|
|
108
|
-
else {
|
|
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
|
+
}
|
|
109
180
|
easeSel.value = easing;
|
|
110
181
|
};
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
190
|
reflectEase();
|
|
114
191
|
pop.append(barRow, body.el);
|
|
115
192
|
root.append(pop);
|
|
116
193
|
|
|
117
194
|
// A stop's accessible name carries its position and selection, the state the ring shows sighted users.
|
|
118
|
-
const labelStop = (h: StopHandle) =>
|
|
119
|
-
|
|
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
|
+
};
|
|
120
210
|
// 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) => {
|
|
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
|
+
};
|
|
122
218
|
const renderHandles = () => {
|
|
123
219
|
rail.replaceChildren();
|
|
124
220
|
for (const s of stops) {
|
|
125
|
-
const h: StopHandle = btn("
|
|
126
|
-
h.
|
|
127
|
-
h.
|
|
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);
|
|
128
227
|
// Focus is selection: tabbing onto a stop points the picker at it.
|
|
129
228
|
h.addEventListener("focus", () => select(s, false));
|
|
130
229
|
let offBar = false;
|
|
131
230
|
dragGesture(h, {
|
|
132
231
|
// preventDefault suppresses click-to-focus, so focus explicitly (ringless); that also blurs
|
|
133
232
|
// a dirty channel field while the old stop is still selected, so its commit lands there.
|
|
134
|
-
onDown: (e) => {
|
|
233
|
+
onDown: (e) => {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
e.stopPropagation();
|
|
236
|
+
h.focus({ focusVisible: false });
|
|
237
|
+
select(s, false);
|
|
238
|
+
offBar = false;
|
|
239
|
+
},
|
|
135
240
|
onMove: (e) => {
|
|
136
241
|
// One rail rect for X and the off-bar Y test; the X math is posFromX inlined.
|
|
137
242
|
const r = rail.getBoundingClientRect();
|
|
@@ -140,95 +245,169 @@ function createGradient(meta: Meta, onChange: OnChange): Control {
|
|
|
140
245
|
offBar = stops.length > 2 && (e.clientY < r.top - 24 || e.clientY > r.bottom + 24);
|
|
141
246
|
h.dataset.removing = String(offBar);
|
|
142
247
|
},
|
|
143
|
-
onEnd: () => {
|
|
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);
|
|
144
256
|
});
|
|
145
|
-
h.addEventListener("dblclick", (e) => { e.preventDefault(); removeStop(s); });
|
|
146
257
|
// Arrows nudge by 0.01 (⇧ = 0.1); Delete stays on the popover.
|
|
147
258
|
h.addEventListener("keydown", (e) => {
|
|
148
|
-
const [d] = arrowStep(e);
|
|
149
|
-
|
|
259
|
+
const [d] = arrowStep(e);
|
|
260
|
+
if (!d) return;
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
moveStop(h, s.pos + d * (e.shiftKey ? 0.1 : 0.01));
|
|
150
263
|
});
|
|
151
264
|
rail.append(h);
|
|
152
265
|
}
|
|
153
266
|
};
|
|
154
267
|
// A drag only flips the selected flag, so the dragged handle stays live. body.set runs before
|
|
155
268
|
// selStop switches: re-pointing blurs a dirty channel field, whose commit must land on the old stop.
|
|
156
|
-
const select = (s: GradientStop, rerender: boolean) => {
|
|
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
|
+
};
|
|
157
276
|
|
|
158
|
-
const posFromX = (x: number) => {
|
|
277
|
+
const posFromX = (x: number) => {
|
|
278
|
+
const r = rail.getBoundingClientRect();
|
|
279
|
+
return clamp((x - r.left) / (r.width || 1), 0, 1);
|
|
280
|
+
};
|
|
159
281
|
|
|
160
|
-
// A new stop's color: the bracketing stops interpolated in OKLCH at the EASED progress, so it lands invisibly on the
|
|
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.
|
|
161
284
|
const colorAt = (pos: number) => {
|
|
162
|
-
const ss = sorted();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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);
|
|
166
298
|
// CSS missing-hue: a ~zero-chroma stop carries no hue, so the other's holds (white→red stays red).
|
|
167
299
|
const ach = (c: Oklcha) => c[1] < 1e-4;
|
|
168
|
-
let dh = b[2] - a[2];
|
|
300
|
+
let dh = b[2] - a[2];
|
|
301
|
+
if (dh > 180) dh -= 360;
|
|
302
|
+
else if (dh < -180) dh += 360;
|
|
169
303
|
if (ach(a) || ach(b)) dh = 0;
|
|
170
|
-
|
|
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;
|
|
171
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);
|
|
172
307
|
};
|
|
173
308
|
const removeStop = (s: GradientStop) => {
|
|
174
309
|
if (stops.length <= 2) return;
|
|
175
|
-
const i = stops.indexOf(s);
|
|
310
|
+
const i = stops.indexOf(s);
|
|
311
|
+
if (i < 0) return; // a stale reference (rebuilt via set) must not splice(-1)
|
|
176
312
|
stops.splice(i, 1);
|
|
177
313
|
const next = selStop === s ? stops[Math.max(0, i - 1)] : selStop;
|
|
178
|
-
paint();
|
|
314
|
+
paint();
|
|
315
|
+
select(next, true);
|
|
316
|
+
reflectCount();
|
|
317
|
+
emit();
|
|
179
318
|
handleFor(selStop)?.focus(); // keep focus on a handle so Delete can chain
|
|
180
319
|
};
|
|
181
320
|
// Insert a stop at `pos`, colored by sampling the gradient there, and select it. Shared by the +
|
|
182
321
|
// button (midway toward the selected stop's neighbor, so where it lands is predictable) and double-click on the bar.
|
|
183
322
|
const insertStopAt = (pos: number) => {
|
|
184
323
|
const s = { color: colorAt(pos), pos: clamp(pos, 0, 1) };
|
|
185
|
-
stops.push(s);
|
|
324
|
+
stops.push(s);
|
|
325
|
+
paint();
|
|
326
|
+
select(s, true);
|
|
327
|
+
reflectCount();
|
|
328
|
+
emit();
|
|
186
329
|
};
|
|
187
330
|
// Midway toward the next stop, or toward the previous from the last. There are always two stops at least (the
|
|
188
331
|
// normalizer's default pair, removeStop's floor), and the selected one is always among them.
|
|
189
332
|
const addStop = () => {
|
|
190
|
-
const ss = sorted(),
|
|
333
|
+
const ss = sorted(),
|
|
334
|
+
i = ss.indexOf(selStop);
|
|
191
335
|
insertStopAt(i < ss.length - 1 ? (ss[i].pos + ss[i + 1].pos) / 2 : (ss[i - 1].pos + ss[i].pos) / 2);
|
|
192
336
|
};
|
|
193
|
-
addBtn.addEventListener("click", (e) => {
|
|
194
|
-
|
|
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.
|
|
195
343
|
bar.addEventListener("dblclick", (e) => {
|
|
196
|
-
if ((e.target as Element).closest(".
|
|
344
|
+
if ((e.target as Element).closest(".twk-gradient-stop")) return;
|
|
197
345
|
e.preventDefault();
|
|
198
346
|
insertStopAt(posFromX(e.clientX));
|
|
199
347
|
});
|
|
200
348
|
|
|
201
|
-
// Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset
|
|
349
|
+
// Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset
|
|
350
|
+
// chord).
|
|
202
351
|
pop.tabIndex = -1;
|
|
203
352
|
pop.addEventListener("keydown", (e) => {
|
|
204
|
-
if (
|
|
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
|
+
}
|
|
205
362
|
});
|
|
206
363
|
|
|
207
|
-
// Focus lands on the selected stop, not the first: focus is selection, so that would re-point the picker on every
|
|
208
|
-
|
|
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
|
+
});
|
|
209
372
|
|
|
210
|
-
renderHandles();
|
|
373
|
+
renderHandles();
|
|
374
|
+
paint();
|
|
375
|
+
reflectCount();
|
|
211
376
|
return {
|
|
212
377
|
el: root,
|
|
213
378
|
set: (v: unknown) => {
|
|
214
379
|
// Named → applied; absent → left as the user set it, never reset to OKLCH on a stops-only set.
|
|
215
|
-
const ip = parseInterp(v);
|
|
216
|
-
|
|
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
|
+
}
|
|
217
388
|
const next = normalizeStops(v).sort((a, b) => a.pos - b.pos);
|
|
218
389
|
if (next.length === stops.length) {
|
|
219
390
|
// Same count (a host mirroring values back): update the stop objects in place, paired in
|
|
220
391
|
// position order, so a live drag keeps its handle and the picker its stop.
|
|
221
392
|
sorted().forEach((s, i) => {
|
|
222
|
-
s.color = next[i].color;
|
|
223
|
-
|
|
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
|
+
}
|
|
224
400
|
});
|
|
225
401
|
} else {
|
|
226
402
|
const selPos = selStop.pos;
|
|
227
403
|
stops = next;
|
|
228
|
-
|
|
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]);
|
|
229
406
|
renderHandles();
|
|
230
407
|
}
|
|
231
|
-
paint();
|
|
408
|
+
paint();
|
|
409
|
+
reflectCount();
|
|
410
|
+
body.set(selStop.color);
|
|
232
411
|
},
|
|
233
412
|
get: () => value(),
|
|
234
413
|
};
|