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
|
@@ -2,84 +2,194 @@
|
|
|
2
2
|
// linked; unlinked, the row opens in place and four fields sit in a line under it, in CSS order, each under its glyph.
|
|
3
3
|
// The value is the four numbers in CSS order plus the shorthand `css`, built only from the numbers and the unit —
|
|
4
4
|
// never a host string. Lazy. ──
|
|
5
|
-
import { el, txt, btn, icon, registerControl, stepPrecision } from "../shared.js";
|
|
5
|
+
import { el, txt, btn, icon, registerControl, stepPrecision, words } from "../shared.js";
|
|
6
6
|
import { numField, decimalStep } from "../heavy.js";
|
|
7
7
|
import type { OnChange, NumField } from "../shared.js";
|
|
8
8
|
import type { Meta } from "../schema.js";
|
|
9
9
|
import type { Control, SidesValue } from "../types.js";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
11
|
+
// original: the four corner arcs of a rounded box, apart
|
|
12
|
+
const ICON_SPLIT = icon(
|
|
13
|
+
'<path d="M3 9V7a4 4 0 0 1 4-4h2M15 3h2a4 4 0 0 1 4 4v2M21 15v2a4 4 0 0 1-4 4h-2M9 21H7a4 4 0 0 1-4-4v-2"/>',
|
|
14
|
+
);
|
|
15
|
+
// original: the top-left quarter of a rounded box, turned per corner in CSS
|
|
16
|
+
const ICON_CORNER = icon('<path d="M3 13V8a5 5 0 0 1 5-5h5"/>', "", 1.5, 16);
|
|
17
|
+
// original: the top of that box, two of its corners joined, turned per side
|
|
18
|
+
const ICON_EDGE = icon('<path d="M3 10V8a5 5 0 0 1 5-5a5 5 0 0 1 5 5v2"/>', "", 1.5, 16);
|
|
14
19
|
const LEN_RE = /^(-?\d*\.?\d+)([a-z%]*)$/i;
|
|
15
20
|
const num = (v: unknown) => (v != null && v !== "" && Number.isFinite(+v) ? +v : null);
|
|
16
|
-
const expand = (n: number[]) =>
|
|
21
|
+
const expand = (n: number[]) =>
|
|
22
|
+
n.length === 1
|
|
23
|
+
? [n[0], n[0], n[0], n[0]]
|
|
24
|
+
: n.length === 2
|
|
25
|
+
? [n[0], n[1], n[0], n[1]]
|
|
26
|
+
: n.length === 3
|
|
27
|
+
? [n[0], n[1], n[2], n[1]]
|
|
28
|
+
: n.slice(0, 4);
|
|
17
29
|
// A shorthand string as one to four [number, unit] pairs, or null for anything else: calc(), var(), five lengths, junk.
|
|
18
|
-
const lengths = (s: string) => {
|
|
30
|
+
const lengths = (s: string) => {
|
|
31
|
+
const m = s
|
|
32
|
+
.trim()
|
|
33
|
+
.split(/\s+/)
|
|
34
|
+
.map((t) => LEN_RE.exec(t));
|
|
35
|
+
return m.length <= 4 && m.every(Boolean)
|
|
36
|
+
? (m as RegExpExecArray[]).map((x) => [+x[1], x[2].toLowerCase()] as const)
|
|
37
|
+
: null;
|
|
38
|
+
};
|
|
19
39
|
// Any form to four numbers, or null; an object fills in from `cur`, so `{ top: 4 }` moves one side.
|
|
20
40
|
const read = (v: unknown, cur: number[]): number[] | null => {
|
|
21
41
|
if (typeof v === "number") return Number.isFinite(v) ? [v, v, v, v] : null;
|
|
22
|
-
if (typeof v === "string") {
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
if (typeof v === "string") {
|
|
43
|
+
const n = lengths(v);
|
|
44
|
+
return n ? expand(n.map((x) => x[0])) : null;
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(v)) {
|
|
47
|
+
const n = v.map(num);
|
|
48
|
+
return n.length >= 1 && n.length <= 4 && n.every((x) => x !== null) ? expand(n as number[]) : null;
|
|
49
|
+
}
|
|
50
|
+
if (v && typeof v === "object") {
|
|
51
|
+
const o = v as Record<string, unknown>,
|
|
52
|
+
out = cur.slice();
|
|
53
|
+
let any = false;
|
|
54
|
+
(["top", "right", "bottom", "left"] as const).forEach((k, i) => {
|
|
55
|
+
const x = num(o[k]);
|
|
56
|
+
if (x !== null) {
|
|
57
|
+
out[i] = x;
|
|
58
|
+
any = true;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return any ? out : null;
|
|
62
|
+
}
|
|
25
63
|
return null;
|
|
26
64
|
};
|
|
27
65
|
|
|
28
|
-
// The unit a shorthand string says, when its sides agree: "1rem 2rem" is rem, and so is "0 1rem 2". A zero doesn't
|
|
29
|
-
// as 0 is 0 in any unit. Mixed units can't be kept, since the control edits four numbers in one unit, so they
|
|
30
|
-
// read as px, as every string did before; "" when the string names no unit.
|
|
66
|
+
// The unit a shorthand string says, when its sides agree: "1rem 2rem" is rem, and so is "0 1rem 2". A zero doesn't
|
|
67
|
+
// vote, as 0 is 0 in any unit. Mixed units can't be kept, since the control edits four numbers in one unit, so they
|
|
68
|
+
// warn and read as px, as every string did before; "" when the string names no unit.
|
|
31
69
|
const saidUnit = (v: unknown, name: string, sides: string[]) => {
|
|
32
|
-
const n = typeof v === "string" && lengths(v);
|
|
33
|
-
|
|
70
|
+
const n = typeof v === "string" && lengths(v);
|
|
71
|
+
if (!n) return "";
|
|
72
|
+
const said = expand(n.map((_, i) => i))
|
|
73
|
+
.map((i, s) => [sides[s].toLowerCase(), n[i][0] ? n[i][1] : ""])
|
|
74
|
+
.filter(([, u]) => u);
|
|
34
75
|
if (new Set(said.map(([, u]) => u)).size < 2) return said.length ? said[0][1] : "";
|
|
35
|
-
console.warn(
|
|
76
|
+
console.warn(
|
|
77
|
+
`[tweaks] sides "${name}": "${v}" mixes units (${said.map((s) => s.join(" ")).join(", ")}) and the control edits one, so all four read as px; write one unit, or set unit`,
|
|
78
|
+
);
|
|
36
79
|
return "";
|
|
37
80
|
};
|
|
38
81
|
|
|
39
82
|
function createSides(meta: Meta, onChange: OnChange): Control {
|
|
40
|
-
const corners = !!meta.corners,
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
const corners = !!meta.corners,
|
|
84
|
+
names = corners ? ["Top-left", "Top-right", "Bottom-right", "Bottom-left"] : ["Top", "Right", "Bottom", "Left"];
|
|
85
|
+
// topLeft …; `names` stays the glyph's data-at
|
|
86
|
+
const tr = words(),
|
|
87
|
+
shown = names.map((n) =>
|
|
88
|
+
tr(
|
|
89
|
+
n.toLowerCase().replace(/-(\w)/, (_, c) => c.toUpperCase()),
|
|
90
|
+
n,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
const name = meta.label || meta.key,
|
|
94
|
+
init = meta.value ?? meta;
|
|
95
|
+
// an explicit unit wins over the string's
|
|
96
|
+
const unit = (typeof meta.unit === "string" && meta.unit.trim()) || saidUnit(init, name, names) || "px";
|
|
43
97
|
let v = read(init, [0, 0, 0, 0]);
|
|
44
|
-
// No step: 1, or finer when a side has decimals, so "0.5rem 1.25rem" keeps its quarter rem (decimalStep, as the
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
98
|
+
// No step: 1, or finer when a side has decimals, so "0.5rem 1.25rem" keeps its quarter rem (decimalStep, as the
|
|
99
|
+
// number's).
|
|
100
|
+
// a radius can't go negative; a margin can
|
|
101
|
+
const spec = {
|
|
102
|
+
step: meta.step ?? Math.min(1, ...(v || []).map(decimalStep)),
|
|
103
|
+
min: meta.min ?? (corners ? 0 : undefined),
|
|
104
|
+
max: meta.max,
|
|
105
|
+
unit,
|
|
106
|
+
};
|
|
107
|
+
// the readout and css at the step's decimals, so 0.125rem isn't 0.13rem
|
|
108
|
+
const fmt = (n: number) => String(+n.toFixed(Math.max(2, stepPrecision(+spec.step))));
|
|
109
|
+
// A default it can't read still starts at 0, but says so: a padding that quietly lost its value looks like the page's
|
|
110
|
+
// bug.
|
|
48
111
|
// An object naming no side stays quiet, as markup hands its whole dataset over when there's no data-value.
|
|
49
|
-
if (!v && meta.value != null && (typeof meta.value !== "object" || Array.isArray(meta.value)))
|
|
112
|
+
if (!v && meta.value != null && (typeof meta.value !== "object" || Array.isArray(meta.value)))
|
|
113
|
+
console.warn(
|
|
114
|
+
`[tweaks] sides "${name}": can't read ${JSON.stringify(meta.value)?.slice(0, 60)}, so it starts at 0; it takes one to four numbers or plain lengths, like "8px 16px"`,
|
|
115
|
+
);
|
|
50
116
|
v = v || [0, 0, 0, 0];
|
|
51
117
|
let linked = v.every((x) => x === v[0]);
|
|
52
|
-
const root = el("div", "
|
|
53
|
-
const all = numField({ label: meta.label, value: v[0], row: true, ...spec }, (n) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
118
|
+
const root = el("div", "twk-sides");
|
|
119
|
+
const all = numField({ label: meta.label, value: v[0], row: true, ...spec }, (n) => {
|
|
120
|
+
v = [n, n, n, n];
|
|
121
|
+
update();
|
|
122
|
+
emit();
|
|
123
|
+
});
|
|
124
|
+
// One toggle, moved to whichever block is showing; last in both, so it sits on the same pixels either way. It says
|
|
125
|
+
// what a click opens — each corner or side on its own — and is pressed while they are.
|
|
126
|
+
const split = corners ? tr("splitCorners", "Independent corners") : tr("splitSides", "Independent sides");
|
|
127
|
+
const link = btn("twk-sides-link", ICON_SPLIT);
|
|
128
|
+
link.setAttribute("aria-label", split);
|
|
129
|
+
link.title = split;
|
|
130
|
+
link.addEventListener("click", () => toggle());
|
|
58
131
|
// The head has the row's geometry, so opening moves nothing: the readout takes the field's place, with the unit once.
|
|
59
|
-
const multi = el("div", "
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
132
|
+
const multi = el("div", "twk-sides-multi"),
|
|
133
|
+
head = el("div", "twk-sides-head"),
|
|
134
|
+
sum = el("span", "twk-sides-sum"),
|
|
135
|
+
nums = document.createTextNode("");
|
|
136
|
+
sum.append(nums, txt("span", "twk-sides-unit", unit));
|
|
137
|
+
head.append(txt("span", "twk-row-label", meta.label), sum);
|
|
138
|
+
const fields = el("div", "twk-fields");
|
|
139
|
+
const flds: NumField[] = shown.map((n, i) =>
|
|
140
|
+
numField({ label: n, value: v[i], ...spec }, (x) => {
|
|
141
|
+
v[i] = x;
|
|
142
|
+
update();
|
|
143
|
+
emit();
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
63
146
|
// The input and the tooltip carry the full name; on screen each field wears its glyph, one drawing turned per place.
|
|
64
|
-
flds.forEach((f, i) => {
|
|
65
|
-
|
|
147
|
+
flds.forEach((f, i) => {
|
|
148
|
+
const lab = f.el.querySelector<HTMLElement>(".twk-field-label")!;
|
|
149
|
+
lab.title = shown[i];
|
|
150
|
+
lab.classList.add("twk-sides-glyph");
|
|
151
|
+
lab.dataset.at = names[i].toLowerCase();
|
|
152
|
+
lab.innerHTML = corners ? ICON_CORNER : ICON_EDGE;
|
|
153
|
+
fields.append(f.el);
|
|
154
|
+
});
|
|
155
|
+
multi.append(head, fields);
|
|
156
|
+
root.append(all.el, multi);
|
|
66
157
|
// As the fields fitted them.
|
|
67
158
|
v = flds.map((f) => f.get());
|
|
68
159
|
|
|
69
|
-
const css = () => {
|
|
160
|
+
const css = () => {
|
|
161
|
+
const [t, r, b, l] = v.map(fmt),
|
|
162
|
+
u = (s: string) => (s === "0" ? "0" : s + unit);
|
|
163
|
+
return (t === b && r === l ? (t === r ? [t] : [t, r]) : r === l ? [t, r, b] : [t, r, b, l]).map(u).join(" ");
|
|
164
|
+
};
|
|
70
165
|
const value = (): SidesValue => ({ top: v[0], right: v[1], bottom: v[2], left: v[3], css: css() });
|
|
71
166
|
const emit = () => onChange(value());
|
|
72
167
|
const update = () => {
|
|
73
|
-
root.classList.toggle("is-linked", linked);
|
|
168
|
+
root.classList.toggle("is-linked", linked);
|
|
169
|
+
link.setAttribute("aria-pressed", String(!linked));
|
|
74
170
|
(linked ? all.el : head).append(link);
|
|
75
|
-
all.set(v[0]);
|
|
171
|
+
all.set(v[0]);
|
|
172
|
+
flds.forEach((f, i) => f.set(v[i]));
|
|
76
173
|
nums.data = v.map(fmt).join(" ");
|
|
77
174
|
};
|
|
78
|
-
const toggle = () => {
|
|
175
|
+
const toggle = () => {
|
|
176
|
+
linked = !linked;
|
|
177
|
+
if (linked) v = [v[0], v[0], v[0], v[0]];
|
|
178
|
+
update();
|
|
179
|
+
emit();
|
|
180
|
+
link.focus();
|
|
181
|
+
};
|
|
79
182
|
update();
|
|
80
183
|
|
|
81
184
|
// Set/restore: equal sides re-link, unequal ones unlink, so a restored "8 16 8 16" opens the fields it needs.
|
|
82
|
-
const set = (x: unknown) => {
|
|
185
|
+
const set = (x: unknown) => {
|
|
186
|
+
const n = read(x, v);
|
|
187
|
+
if (!n) return;
|
|
188
|
+
flds.forEach((f, i) => f.set(n[i]));
|
|
189
|
+
v = flds.map((f) => f.get());
|
|
190
|
+
linked = v.every((s) => s === v[0]);
|
|
191
|
+
update();
|
|
192
|
+
};
|
|
83
193
|
return { el: root, set, get: value, range: all.range };
|
|
84
194
|
}
|
|
85
195
|
|
|
@@ -1,145 +1,305 @@
|
|
|
1
1
|
// ── Spring config — physics (stiffness/damping/mass) or a perceptual time (duration/bounce) mode, over one settle-curve preview. Lazy. ──
|
|
2
|
-
import { el, txt, onReady, onLive, clamp, createSegmented, registerControl } from "../shared.js";
|
|
3
|
-
import {
|
|
2
|
+
import { el, txt, onReady, onLive, clamp, createSegmented, registerControl, words } from "../shared.js";
|
|
3
|
+
import {
|
|
4
|
+
dragGesture,
|
|
5
|
+
numField,
|
|
6
|
+
cssVar,
|
|
7
|
+
accentColor,
|
|
8
|
+
springCurve,
|
|
9
|
+
springFromTime,
|
|
10
|
+
boxFrac,
|
|
11
|
+
notice,
|
|
12
|
+
SPRING_DUR,
|
|
13
|
+
} from "../heavy.js";
|
|
4
14
|
import type { OnChange, NumField } from "../shared.js";
|
|
5
15
|
import type { Meta } from "../schema.js";
|
|
6
16
|
import type { Control } from "../types.js";
|
|
7
17
|
|
|
8
18
|
type SpringMode = "time" | "physics";
|
|
9
19
|
/** The physics triple every emitted value carries. */
|
|
10
|
-
interface Phys {
|
|
20
|
+
interface Phys {
|
|
21
|
+
stiffness: number;
|
|
22
|
+
damping: number;
|
|
23
|
+
mass: number;
|
|
24
|
+
}
|
|
11
25
|
type PhysKey = keyof Phys;
|
|
12
26
|
/** The perceptual pair the time mode edits. */
|
|
13
|
-
interface SpringTime {
|
|
27
|
+
interface SpringTime {
|
|
28
|
+
visualDuration: number;
|
|
29
|
+
bounce: number;
|
|
30
|
+
}
|
|
14
31
|
/** What set() accepts: any subset of the physics, the time pair and an explicit mode. */
|
|
15
32
|
type SpringValue = Partial<Phys & SpringTime & { mode: SpringMode }>;
|
|
16
33
|
|
|
17
34
|
function createSpring(meta: Meta, onChange: OnChange): Control {
|
|
35
|
+
const tr = words();
|
|
18
36
|
// The fields' floors, non-finite to the defaults: a stiffness or mass of 0 would divide the settle curve to NaN.
|
|
19
37
|
const cl = (v: unknown, lo: number, def: number) => (Number.isFinite(+v) ? Math.max(lo, +v) : def);
|
|
20
|
-
const clampS = (o: { stiffness?: unknown; damping?: unknown; mass?: unknown }): Phys => ({
|
|
38
|
+
const clampS = (o: { stiffness?: unknown; damping?: unknown; mass?: unknown }): Phys => ({
|
|
39
|
+
stiffness: cl(o.stiffness, 1, 300),
|
|
40
|
+
damping: cl(o.damping, 0.5, 26),
|
|
41
|
+
mass: cl(o.mass, 0.1, 1),
|
|
42
|
+
});
|
|
21
43
|
const has = (x: unknown) => Number.isFinite(+x);
|
|
22
44
|
|
|
23
|
-
// Time mode edits visualDuration + bounce (Motion's spring shorthand), mapped onto the physics the preview and emit
|
|
24
|
-
// exactly as Motion resolves them (springFromTime): stiffness = (2π / (visualDuration × 1.2))², ζ from bounce
|
|
25
|
-
//
|
|
45
|
+
// Time mode edits visualDuration + bounce (Motion's spring shorthand), mapped onto the physics the preview and emit
|
|
46
|
+
// share exactly as Motion resolves them (springFromTime): stiffness = (2π / (visualDuration × 1.2))², ζ from bounce
|
|
47
|
+
// floored at
|
|
48
|
+
// 0.05, a negative bounce overdamped. Not through the physics pad's clamps, which would cut a short spring's
|
|
49
|
+
// stiffness.
|
|
26
50
|
// An authored duration past the editor's range is clamped, and the console says so once.
|
|
27
51
|
const [DUR_MIN, DUR_MAX] = SPRING_DUR;
|
|
28
52
|
let said = false;
|
|
29
53
|
const clampDur = (v: unknown) => {
|
|
30
|
-
const n = has(v) ? +v : 0.5,
|
|
31
|
-
|
|
54
|
+
const n = has(v) ? +v : 0.5,
|
|
55
|
+
c = clamp(n, DUR_MIN, DUR_MAX);
|
|
56
|
+
if (c !== n && !said) {
|
|
57
|
+
said = true;
|
|
58
|
+
notice("springDur", [meta.key, n, c]);
|
|
59
|
+
}
|
|
32
60
|
return c;
|
|
33
61
|
};
|
|
34
62
|
const clampBounce = (v: unknown) => clamp(has(v) ? +v : 0.2, -1, 1); // Motion takes a negative bounce: overdamped
|
|
35
63
|
// Ten significant figures: the trig round trip otherwise hands back 300.00000000000006 for a stiffness of 300.
|
|
36
64
|
const sig = (x: number) => +x.toPrecision(10);
|
|
37
|
-
const timeToPhysics = (t: SpringTime): Phys => {
|
|
65
|
+
const timeToPhysics = (t: SpringTime): Phys => {
|
|
66
|
+
const p = springFromTime(t.visualDuration, t.bounce);
|
|
67
|
+
return { stiffness: sig(p.stiffness), damping: sig(p.damping), mass: p.mass };
|
|
68
|
+
};
|
|
38
69
|
|
|
39
|
-
// Top-level props (the inline shorthand) or the `value:{…}` form, value winning; the mode is explicit or inferred,
|
|
70
|
+
// Top-level props (the inline shorthand) or the `value:{…}` form, value winning; the mode is explicit or inferred,
|
|
71
|
+
// time winning.
|
|
40
72
|
const explicit = (m: unknown): SpringMode | null => (m === "time" || m === "physics" ? m : null);
|
|
41
|
-
const init = {
|
|
42
|
-
|
|
73
|
+
const init = {
|
|
74
|
+
stiffness: meta.stiffness,
|
|
75
|
+
damping: meta.damping,
|
|
76
|
+
mass: meta.mass,
|
|
77
|
+
visualDuration: meta.visualDuration,
|
|
78
|
+
bounce: meta.bounce,
|
|
79
|
+
...(meta.value || {}),
|
|
80
|
+
};
|
|
81
|
+
let mode: SpringMode =
|
|
82
|
+
explicit(init.mode) ?? explicit(meta.mode) ?? (has(init.visualDuration) || has(init.bounce) ? "time" : "physics");
|
|
43
83
|
const time: SpringTime = { visualDuration: clampDur(init.visualDuration), bounce: clampBounce(init.bounce) };
|
|
44
84
|
let phys = clampS({ stiffness: init.stiffness, damping: init.damping, mass: init.mass });
|
|
45
85
|
|
|
46
|
-
const root = el("div", "
|
|
47
|
-
const viz = el("div", "
|
|
48
|
-
const canvas = document.createElement("canvas");
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
const root = el("div", "twk-spring");
|
|
87
|
+
const viz = el("div", "twk-spring-viz");
|
|
88
|
+
const canvas = document.createElement("canvas");
|
|
89
|
+
canvas.className = "twk-spring-canvas";
|
|
90
|
+
viz.append(canvas);
|
|
91
|
+
const physFields = el("div", "twk-fields");
|
|
92
|
+
const timeFields = el("div", "twk-fields");
|
|
51
93
|
const ctx = canvas.getContext("2d");
|
|
52
94
|
|
|
53
95
|
// The preview and the emitted value both read this, so they can never disagree.
|
|
54
96
|
const resolved = () => (mode === "time" ? timeToPhysics(time) : clampS(phys));
|
|
55
97
|
const draw = () => {
|
|
56
98
|
// Layout size: getBoundingClientRect includes the pop's entrance scale.
|
|
57
|
-
const w = Math.max(1, viz.clientWidth),
|
|
58
|
-
|
|
99
|
+
const w = Math.max(1, viz.clientWidth),
|
|
100
|
+
h = Math.max(1, viz.clientHeight);
|
|
101
|
+
if (w < 2) return;
|
|
102
|
+
const dpr = window.devicePixelRatio || 1;
|
|
103
|
+
canvas.width = w * dpr;
|
|
104
|
+
canvas.height = h * dpr;
|
|
105
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
59
106
|
ctx.clearRect(0, 0, w, h);
|
|
60
107
|
const s = resolved();
|
|
61
108
|
// Sample ~one point per device pixel so fast oscillations stay smooth, not angular.
|
|
62
109
|
const pts = springCurve(s.stiffness, s.damping, s.mass, Math.max(160, Math.round(w * dpr)));
|
|
63
|
-
const peak = Math.max(1.05, ...pts),
|
|
110
|
+
const peak = Math.max(1.05, ...pts),
|
|
111
|
+
pad = 8; // the bezier editor's PAD, so the two previews inset alike
|
|
64
112
|
const X = (i: number) => pad + (i / (pts.length - 1)) * (w - 2 * pad);
|
|
65
113
|
const Y = (v: number) => h - pad - (v / peak) * (h - 2 * pad);
|
|
66
|
-
ctx.strokeStyle = cssVar(root, "--
|
|
67
|
-
ctx.
|
|
68
|
-
ctx.
|
|
69
|
-
ctx.
|
|
114
|
+
ctx.strokeStyle = cssVar(root, "--twk-surface-active") || "rgba(255,255,255,0.1)";
|
|
115
|
+
ctx.lineWidth = 1;
|
|
116
|
+
ctx.beginPath();
|
|
117
|
+
ctx.moveTo(pad, Y(1));
|
|
118
|
+
ctx.lineTo(w - pad, Y(1));
|
|
119
|
+
ctx.stroke(); // target (=1)
|
|
120
|
+
ctx.strokeStyle = accentColor(root);
|
|
121
|
+
ctx.lineWidth = 1.5;
|
|
122
|
+
ctx.lineJoin = ctx.lineCap = "round";
|
|
123
|
+
ctx.beginPath();
|
|
124
|
+
pts.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v))));
|
|
125
|
+
ctx.stroke();
|
|
70
126
|
};
|
|
71
|
-
// The resolved {k,d,m} plus, in time mode, visualDuration/bounce, from which set() infers the mode back from
|
|
72
|
-
|
|
127
|
+
// The resolved {k,d,m} plus, in time mode, visualDuration/bounce, from which set() infers the mode back from
|
|
128
|
+
// persistence.
|
|
129
|
+
const resolvedValue = (): Phys & Partial<SpringTime> =>
|
|
130
|
+
mode === "time" ? { ...resolved(), visualDuration: time.visualDuration, bounce: time.bounce } : resolved();
|
|
73
131
|
const emit = () => onChange(resolvedValue());
|
|
74
132
|
|
|
75
133
|
const flds: Partial<Record<PhysKey, NumField>> = {};
|
|
76
|
-
(
|
|
77
|
-
|
|
134
|
+
(
|
|
135
|
+
[
|
|
136
|
+
["stiffness", "Stiffness", 1],
|
|
137
|
+
["damping", "Damping", 0.5],
|
|
138
|
+
["mass", "Mass", 0.1],
|
|
139
|
+
] as const
|
|
140
|
+
).forEach(([key, lab, step]) => {
|
|
141
|
+
flds[key] = numField({ label: tr(key, lab), value: phys[key], step, min: step }, (v) => {
|
|
142
|
+
phys[key] = v;
|
|
143
|
+
draw();
|
|
144
|
+
emit();
|
|
145
|
+
});
|
|
78
146
|
physFields.append(flds[key].el);
|
|
79
147
|
});
|
|
80
148
|
// The field shows ms; the value stays Motion's visualDuration in seconds.
|
|
81
|
-
const durFld = numField(
|
|
82
|
-
|
|
149
|
+
const durFld = numField(
|
|
150
|
+
{
|
|
151
|
+
label: tr("duration", "Duration"),
|
|
152
|
+
value: time.visualDuration * 1000,
|
|
153
|
+
step: 10,
|
|
154
|
+
min: DUR_MIN * 1000,
|
|
155
|
+
max: DUR_MAX * 1000,
|
|
156
|
+
unit: "ms",
|
|
157
|
+
},
|
|
158
|
+
(v) => {
|
|
159
|
+
time.visualDuration = clampDur(v / 1000);
|
|
160
|
+
draw();
|
|
161
|
+
emit();
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
// shown as a percentage; the value stays Motion's −1–1
|
|
165
|
+
const bounceFld = numField(
|
|
166
|
+
{ label: tr("bounce", "Bounce"), value: time.bounce * 100, step: 5, min: -100, max: 100, unit: "%" },
|
|
167
|
+
(v) => {
|
|
168
|
+
time.bounce = clampBounce(v / 100);
|
|
169
|
+
draw();
|
|
170
|
+
emit();
|
|
171
|
+
},
|
|
172
|
+
);
|
|
83
173
|
timeFields.append(durFld.el, bounceFld.el);
|
|
84
174
|
|
|
85
|
-
const showMode = () => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
175
|
+
const showMode = () => {
|
|
176
|
+
timeFields.style.display = mode === "time" ? "" : "none";
|
|
177
|
+
physFields.style.display = mode === "physics" ? "" : "none";
|
|
178
|
+
};
|
|
179
|
+
const switchMode = (m: unknown) => {
|
|
180
|
+
if ((m !== "time" && m !== "physics") || m === mode) return;
|
|
181
|
+
mode = m;
|
|
182
|
+
showMode();
|
|
183
|
+
draw();
|
|
184
|
+
emit();
|
|
185
|
+
};
|
|
186
|
+
const modeToggle = createSegmented(
|
|
187
|
+
[
|
|
188
|
+
{ value: "time", label: tr("time", "Time") },
|
|
189
|
+
{ value: "physics", label: tr("physics", "Physics") },
|
|
190
|
+
],
|
|
191
|
+
mode,
|
|
192
|
+
switchMode,
|
|
193
|
+
tr("springMode", "Spring mode"),
|
|
194
|
+
);
|
|
195
|
+
const modeRow = el("div", "twk-row");
|
|
196
|
+
modeRow.append(txt("span", "twk-row-label", tr("mode", "Mode")), modeToggle.el);
|
|
90
197
|
// A caption, so two springs in one panel can be told apart and the filter matches shown text.
|
|
91
|
-
if (meta.label) root.append(txt("div", "
|
|
198
|
+
if (meta.label) root.append(txt("div", "twk-spring-label", meta.label));
|
|
92
199
|
root.append(viz, modeRow, physFields, timeFields);
|
|
93
200
|
|
|
94
201
|
// Drag the preview to tune by feel: horizontal sets stiffness or duration, vertical sets damping or bounce.
|
|
95
|
-
|
|
202
|
+
// the feel-tuning span; the fields reach further
|
|
203
|
+
const ST_MIN = 1,
|
|
204
|
+
ST_MAX = 500,
|
|
205
|
+
DA_MIN = 1,
|
|
206
|
+
DA_MAX = 40,
|
|
207
|
+
DRAG_DUR_MIN = 0.1,
|
|
208
|
+
DRAG_DUR_MAX = 1;
|
|
96
209
|
const fromPointer = (e: PointerEvent) => {
|
|
97
210
|
const [px, py] = boxFrac(e, viz);
|
|
98
211
|
// Set the field (it snaps and clamps), then read back, so readout, value and preview agree.
|
|
99
212
|
if (mode === "time") {
|
|
100
|
-
durFld.set((DRAG_DUR_MIN + px * (DRAG_DUR_MAX - DRAG_DUR_MIN)) * 1000);
|
|
101
|
-
|
|
213
|
+
durFld.set((DRAG_DUR_MIN + px * (DRAG_DUR_MAX - DRAG_DUR_MIN)) * 1000);
|
|
214
|
+
time.visualDuration = durFld.get() / 1000;
|
|
215
|
+
// top = bounce 100%, the middle 0, the bottom −100% (overdamped)
|
|
216
|
+
bounceFld.set((1 - 2 * py) * 100);
|
|
217
|
+
time.bounce = bounceFld.get() / 100;
|
|
102
218
|
} else {
|
|
103
|
-
flds.stiffness.set(ST_MIN + px * (ST_MAX - ST_MIN));
|
|
104
|
-
|
|
219
|
+
flds.stiffness.set(ST_MIN + px * (ST_MAX - ST_MIN));
|
|
220
|
+
phys.stiffness = flds.stiffness.get();
|
|
221
|
+
flds.damping.set(DA_MIN + py * (DA_MAX - DA_MIN));
|
|
222
|
+
phys.damping = flds.damping.get(); // top = low damping
|
|
105
223
|
}
|
|
106
|
-
draw();
|
|
224
|
+
draw();
|
|
225
|
+
emit();
|
|
107
226
|
};
|
|
108
227
|
dragGesture(viz, {
|
|
109
|
-
onDown: (e) => {
|
|
228
|
+
onDown: (e) => {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
viz.classList.add("is-dragging");
|
|
231
|
+
fromPointer(e);
|
|
232
|
+
},
|
|
110
233
|
onMove: fromPointer,
|
|
111
234
|
onEnd: () => viz.classList.remove("is-dragging"),
|
|
112
235
|
});
|
|
113
236
|
showMode();
|
|
114
237
|
onReady(draw);
|
|
115
|
-
// The canvas reads theme tokens at draw time: redraw on resize, on a tab page revealing it (
|
|
116
|
-
// 0×0 while hidden), on an OS scheme flip and on a host re-theme — CSS re-themes the SVG controls, a canvas keeps a
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
238
|
+
// The canvas reads theme tokens at draw time: redraw on resize, on a tab page revealing it (twk-reflow; it built at
|
|
239
|
+
// 0×0 while hidden), on an OS scheme flip and on a host re-theme — CSS re-themes the SVG controls, a canvas keeps a
|
|
240
|
+
// stale color.
|
|
241
|
+
onLive(
|
|
242
|
+
canvas,
|
|
243
|
+
[
|
|
244
|
+
[window, "resize"],
|
|
245
|
+
[window, "twk-reflow"],
|
|
246
|
+
[matchMedia("(prefers-color-scheme: dark)"), "change"],
|
|
247
|
+
[window, "twk-retheme"],
|
|
248
|
+
],
|
|
249
|
+
draw,
|
|
250
|
+
);
|
|
251
|
+
// A host flipping [data-twk-scheme] re-themes through CSS alone; the canvas needs a redraw. Let go once mounted and
|
|
252
|
+
// removed.
|
|
253
|
+
// The redraw waits a frame, for a shadow panel's own observer to mirror the pin first.
|
|
254
|
+
let schemeObs: MutationObserver | null = null,
|
|
255
|
+
wasMounted = false;
|
|
120
256
|
if (typeof MutationObserver === "function") {
|
|
121
257
|
schemeObs = new MutationObserver(() => {
|
|
122
|
-
if (canvas.isConnected) {
|
|
123
|
-
|
|
258
|
+
if (canvas.isConnected) {
|
|
259
|
+
wasMounted = true;
|
|
260
|
+
requestAnimationFrame(draw);
|
|
261
|
+
} else if (wasMounted) {
|
|
262
|
+
schemeObs.disconnect();
|
|
263
|
+
schemeObs = null;
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
schemeObs.observe(document.documentElement, {
|
|
267
|
+
subtree: true,
|
|
268
|
+
attributes: true,
|
|
269
|
+
attributeFilter: ["data-twk-scheme"],
|
|
124
270
|
});
|
|
125
|
-
schemeObs.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ["data-tw-scheme"] });
|
|
126
271
|
}
|
|
127
272
|
return {
|
|
128
273
|
el: root,
|
|
129
|
-
destroy: () => {
|
|
274
|
+
destroy: () => {
|
|
275
|
+
if (schemeObs) {
|
|
276
|
+
schemeObs.disconnect();
|
|
277
|
+
schemeObs = null;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
130
280
|
// Silent set/restore: infer the mode from the keys, update caches and fields, redraw without emitting.
|
|
131
281
|
set: (v: SpringValue) => {
|
|
132
282
|
if (!v || typeof v !== "object") return;
|
|
133
|
-
const ex = explicit(v.mode),
|
|
283
|
+
const ex = explicit(v.mode),
|
|
284
|
+
hasTime = has(v.visualDuration) || has(v.bounce),
|
|
285
|
+
hasPhys = has(v.stiffness) || has(v.damping) || has(v.mass);
|
|
134
286
|
if (!hasTime && !hasPhys && !ex) return;
|
|
135
287
|
mode = ex ?? (hasTime ? "time" : "physics"); // an explicit mode wins; else time, the authoring mode
|
|
136
|
-
if (hasTime) {
|
|
137
|
-
|
|
288
|
+
if (hasTime) {
|
|
289
|
+
if (has(v.visualDuration)) time.visualDuration = clampDur(v.visualDuration);
|
|
290
|
+
if (has(v.bounce)) time.bounce = clampBounce(v.bounce);
|
|
291
|
+
}
|
|
292
|
+
// Restore the physics cache even alongside time keys (reset's default carries both), so a later toggle to Physics
|
|
293
|
+
// shows the intended spring.
|
|
138
294
|
if (hasPhys) phys = clampS({ ...phys, ...v });
|
|
139
295
|
modeToggle.set(mode);
|
|
140
|
-
durFld.set(time.visualDuration * 1000);
|
|
141
|
-
|
|
142
|
-
|
|
296
|
+
durFld.set(time.visualDuration * 1000);
|
|
297
|
+
bounceFld.set(time.bounce * 100);
|
|
298
|
+
flds.stiffness.set(phys.stiffness);
|
|
299
|
+
flds.damping.set(phys.damping);
|
|
300
|
+
flds.mass.set(phys.mass);
|
|
301
|
+
showMode();
|
|
302
|
+
draw();
|
|
143
303
|
},
|
|
144
304
|
get: () => resolvedValue(),
|
|
145
305
|
};
|