@vsreact/core 0.0.25 → 0.0.26
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/dist/hostConfig.js +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/primitives.d.ts +34 -4
- package/dist/primitives.js +3 -1
- package/dist/transitions.d.ts +23 -0
- package/dist/transitions.js +205 -0
- package/dist/tw.js +69 -0
- package/package.json +1 -1
- package/src/gaps.test.tsx +205 -0
- package/src/hostConfig.ts +4 -1
- package/src/index.ts +1 -1
- package/src/primitives.tsx +12 -4
- package/src/transitions.ts +262 -0
- package/src/tw.ts +67 -0
package/dist/hostConfig.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// mutation ops; event props become handler-registry entries + listener lists.
|
|
3
3
|
import { DefaultEventPriority } from "react-reconciler/constants";
|
|
4
4
|
import { queueOp, flushOps, setHandlers, removeHandlers } from "./bridge";
|
|
5
|
+
import { commitProps, releaseNode } from "./transitions";
|
|
5
6
|
import { tw } from "./tw";
|
|
6
7
|
let nextId = 1;
|
|
7
8
|
const hostTypes = {
|
|
@@ -64,7 +65,8 @@ function normalizeProps(props) {
|
|
|
64
65
|
function applyProps(instance, props) {
|
|
65
66
|
const { payload, handlers } = normalizeProps(props);
|
|
66
67
|
setHandlers(instance.id, handlers);
|
|
67
|
-
|
|
68
|
+
// The transition engine queues the op — tweened when the style asks for it.
|
|
69
|
+
commitProps(instance.id, payload);
|
|
68
70
|
}
|
|
69
71
|
export const hostConfig = {
|
|
70
72
|
supportsMutation: true,
|
|
@@ -115,6 +117,7 @@ export const hostConfig = {
|
|
|
115
117
|
clearContainer: () => queueOp(["clearContainer"]),
|
|
116
118
|
detachDeletedInstance(instance) {
|
|
117
119
|
removeHandlers(instance.id);
|
|
120
|
+
releaseNode(instance.id);
|
|
118
121
|
},
|
|
119
122
|
getCurrentEventPriority: () => DefaultEventPriority,
|
|
120
123
|
getInstanceFromNode: () => null,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "./runtime";
|
|
2
2
|
import "./bridge";
|
|
3
3
|
/** The SDK version — stamp it on support dumps and analytics. */
|
|
4
|
-
export declare const VERSION = "0.0.
|
|
4
|
+
export declare const VERSION = "0.0.26";
|
|
5
5
|
export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
|
|
6
6
|
export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, SvgProps, SvgPathProps, } from "./primitives";
|
|
7
7
|
export { render, unmount } from "./render";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import "./runtime";
|
|
4
4
|
import "./bridge";
|
|
5
5
|
/** The SDK version — stamp it on support dumps and analytics. */
|
|
6
|
-
export const VERSION = "0.0.
|
|
6
|
+
export const VERSION = "0.0.26";
|
|
7
7
|
export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
|
|
8
8
|
export { render, unmount } from "./render";
|
|
9
9
|
export { native } from "./native";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -15,10 +15,11 @@ export interface LayoutRect {
|
|
|
15
15
|
width: number;
|
|
16
16
|
height: number;
|
|
17
17
|
}
|
|
18
|
-
/** Mouse-wheel payload.
|
|
19
|
-
positive = wheel up). */
|
|
18
|
+
/** Mouse-wheel payload. Deltas are JUCE's notch fraction (~0.1 per notch,
|
|
19
|
+
dy positive = wheel up; dx nonzero on trackpads/tilt wheels). */
|
|
20
20
|
export interface WheelEventPayload {
|
|
21
21
|
dy: number;
|
|
22
|
+
dx: number;
|
|
22
23
|
}
|
|
23
24
|
/** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
|
|
24
25
|
"Enter", "Escape", " ", "a"). */
|
|
@@ -38,8 +39,10 @@ export interface CommonProps {
|
|
|
38
39
|
className?: string;
|
|
39
40
|
style?: Style;
|
|
40
41
|
children?: ReactNode;
|
|
41
|
-
/** Resets the scroll offset of an overflow-
|
|
42
|
+
/** Resets the vertical scroll offset of an overflow-scroll container. */
|
|
42
43
|
scrollTop?: number;
|
|
44
|
+
/** Resets the horizontal scroll offset of an overflow-scroll container. */
|
|
45
|
+
scrollLeft?: number;
|
|
43
46
|
onClick?: () => void;
|
|
44
47
|
onDoubleClick?: () => void;
|
|
45
48
|
/** Wheel over this node (controls win the wheel over scroll containers). */
|
|
@@ -67,8 +70,34 @@ export interface CommonProps {
|
|
|
67
70
|
export declare function View(props: CommonProps): import("react").ReactElement<CommonProps, string | import("react").JSXElementConstructor<any>>;
|
|
68
71
|
export interface TextProps extends Omit<CommonProps, "children"> {
|
|
69
72
|
children?: ReactNode;
|
|
73
|
+
/** Opt this text into selection (drag to select, double-click for a word,
|
|
74
|
+
Ctrl/Cmd+C copies). Text is NOT selectable by default. Equivalent to
|
|
75
|
+
the `select-text` class / `userSelect: "text"` style key. */
|
|
76
|
+
selectable?: boolean;
|
|
70
77
|
}
|
|
71
|
-
export declare function Text(props: TextProps): import("react").ReactElement<
|
|
78
|
+
export declare function Text({ selectable, ...props }: TextProps): import("react").ReactElement<{
|
|
79
|
+
children?: ReactNode;
|
|
80
|
+
style?: Style | undefined;
|
|
81
|
+
className?: string | undefined;
|
|
82
|
+
scrollTop?: number | undefined;
|
|
83
|
+
scrollLeft?: number | undefined;
|
|
84
|
+
onClick?: (() => void) | undefined;
|
|
85
|
+
onDoubleClick?: (() => void) | undefined;
|
|
86
|
+
onWheel?: ((e: WheelEventPayload) => void) | undefined;
|
|
87
|
+
onMouseEnter?: (() => void) | undefined;
|
|
88
|
+
onMouseLeave?: (() => void) | undefined;
|
|
89
|
+
onMouseDown?: (() => void) | undefined;
|
|
90
|
+
onMouseUp?: (() => void) | undefined;
|
|
91
|
+
onDragStart?: ((e: DragEventPayload) => void) | undefined;
|
|
92
|
+
onDrag?: ((e: DragEventPayload) => void) | undefined;
|
|
93
|
+
onDragEnd?: ((e: DragEventPayload) => void) | undefined;
|
|
94
|
+
onMouseMove?: ((e: MouseMovePayload) => void) | undefined;
|
|
95
|
+
onKeyDown?: ((e: KeyEventPayload) => void) | undefined;
|
|
96
|
+
onKeyUp?: ((e: KeyEventPayload) => void) | undefined;
|
|
97
|
+
onFocus?: (() => void) | undefined;
|
|
98
|
+
onBlur?: (() => void) | undefined;
|
|
99
|
+
onLayout?: ((rect: LayoutRect) => void) | undefined;
|
|
100
|
+
}, string | import("react").JSXElementConstructor<any>>;
|
|
72
101
|
export interface ImageProps extends CommonProps {
|
|
73
102
|
src: string;
|
|
74
103
|
}
|
|
@@ -119,6 +148,7 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
|
|
|
119
148
|
style?: Style | undefined;
|
|
120
149
|
className?: string | undefined;
|
|
121
150
|
scrollTop?: number | undefined;
|
|
151
|
+
scrollLeft?: number | undefined;
|
|
122
152
|
onClick?: (() => void) | undefined;
|
|
123
153
|
onDoubleClick?: (() => void) | undefined;
|
|
124
154
|
onWheel?: ((e: WheelEventPayload) => void) | undefined;
|
package/dist/primitives.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createElement } from "react";
|
|
|
2
2
|
export function View(props) {
|
|
3
3
|
return createElement("vs-view", props);
|
|
4
4
|
}
|
|
5
|
-
export function Text(props) {
|
|
5
|
+
export function Text({ selectable, ...props }) {
|
|
6
|
+
if (selectable)
|
|
7
|
+
props = { ...props, style: { userSelect: "text", ...props.style } };
|
|
6
8
|
return createElement("vs-text", props);
|
|
7
9
|
}
|
|
8
10
|
export function Image(props) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Style } from "./tw";
|
|
2
|
+
type Payload = Record<string, unknown> & {
|
|
3
|
+
style?: Style;
|
|
4
|
+
};
|
|
5
|
+
export type TransitionEasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
6
|
+
/** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
|
|
7
|
+
export declare function parseHexColor(value: unknown): [number, number, number, number] | undefined;
|
|
8
|
+
export declare function lerpHexColor(from: string, to: string, t: number): string;
|
|
9
|
+
/** The animatable keys that differ between two styles, honoring the
|
|
10
|
+
transitionProperty group. Exported for tests. */
|
|
11
|
+
export declare function transitionKeys(prev: Style, next: Style, property: string): string[];
|
|
12
|
+
/** Interpolated patch for the animated keys at eased progress t. Exported
|
|
13
|
+
for tests. */
|
|
14
|
+
export declare function interpolateStyle(from: Style, to: Style, keys: string[], t: number): Style;
|
|
15
|
+
/** Style patch at loop phase t (0→1). Exported for tests. */
|
|
16
|
+
export declare const animationPresets: Record<string, (t: number) => Style>;
|
|
17
|
+
export declare const animationDurations: Record<string, number>;
|
|
18
|
+
/** hostConfig's single entry point: queue this commit's setProps, animated
|
|
19
|
+
when the style asks for it. */
|
|
20
|
+
export declare function commitProps(nodeId: number, payload: Payload): void;
|
|
21
|
+
/** Unmount cleanup — hostConfig.detachDeletedInstance. */
|
|
22
|
+
export declare function releaseNode(nodeId: number): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// CSS transitions and keyframe presets, driven from JS. When a commit changes
|
|
2
|
+
// a node whose style carries transitionDuration, the changed animatable keys
|
|
3
|
+
// tween from their *currently displayed* values instead of jumping; animate-*
|
|
4
|
+
// classes run keyframe loops. Every frame re-sends the node's full payload
|
|
5
|
+
// (setProps replaces wholesale) with the animated keys patched — the same
|
|
6
|
+
// traffic a useTween re-render produces, without the component code.
|
|
7
|
+
//
|
|
8
|
+
// Honest limit: native-side hover/active/focus style merges don't transition
|
|
9
|
+
// (they never round-trip through JS). Animate those with onMouseEnter state.
|
|
10
|
+
import { queueOp, flushOps } from "./bridge";
|
|
11
|
+
import { Easing } from "./animation";
|
|
12
|
+
const easings = {
|
|
13
|
+
linear: Easing.linear,
|
|
14
|
+
"ease-in": (t) => t * t * t,
|
|
15
|
+
"ease-out": Easing.outCubic,
|
|
16
|
+
"ease-in-out": Easing.inOutCubic,
|
|
17
|
+
};
|
|
18
|
+
/** Keys each transitionProperty group may animate. "colors" is any *Color/
|
|
19
|
+
color key; "all" is every animatable difference. */
|
|
20
|
+
const transformKeys = ["rotate", "scale", "translateX", "translateY"];
|
|
21
|
+
const defaultKeys = [
|
|
22
|
+
"color", "backgroundColor", "borderColor", "opacity",
|
|
23
|
+
...transformKeys, "blurRadius", "backdropBlurRadius",
|
|
24
|
+
];
|
|
25
|
+
function keysForProperty(property, candidates) {
|
|
26
|
+
switch (property) {
|
|
27
|
+
case "all":
|
|
28
|
+
return candidates;
|
|
29
|
+
case "colors":
|
|
30
|
+
return candidates.filter((k) => /color/i.test(k));
|
|
31
|
+
case "opacity":
|
|
32
|
+
return candidates.filter((k) => k === "opacity");
|
|
33
|
+
case "transform":
|
|
34
|
+
return candidates.filter((k) => transformKeys.includes(k));
|
|
35
|
+
case "none":
|
|
36
|
+
return [];
|
|
37
|
+
default:
|
|
38
|
+
return candidates.filter((k) => defaultKeys.includes(k));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ── value interpolation ────────────────────────────────────────────────
|
|
42
|
+
/** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
|
|
43
|
+
export function parseHexColor(value) {
|
|
44
|
+
if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value))
|
|
45
|
+
return undefined;
|
|
46
|
+
const n = (i) => parseInt(value.slice(i, i + 2), 16);
|
|
47
|
+
return [n(1), n(3), n(5), value.length === 9 ? n(7) : 255];
|
|
48
|
+
}
|
|
49
|
+
export function lerpHexColor(from, to, t) {
|
|
50
|
+
const a = parseHexColor(from);
|
|
51
|
+
const b = parseHexColor(to);
|
|
52
|
+
const hex = (i) => Math.round(a[i] + (b[i] - a[i]) * t)
|
|
53
|
+
.toString(16)
|
|
54
|
+
.padStart(2, "0");
|
|
55
|
+
const alpha = a[3] !== 255 || b[3] !== 255 ? hex(3) : "";
|
|
56
|
+
return `#${hex(0)}${hex(1)}${hex(2)}${alpha}`;
|
|
57
|
+
}
|
|
58
|
+
function isAnimatablePair(from, to) {
|
|
59
|
+
if (typeof from === "number" && typeof to === "number")
|
|
60
|
+
return Number.isFinite(from) && Number.isFinite(to);
|
|
61
|
+
return parseHexColor(from) !== undefined && parseHexColor(to) !== undefined;
|
|
62
|
+
}
|
|
63
|
+
/** The animatable keys that differ between two styles, honoring the
|
|
64
|
+
transitionProperty group. Exported for tests. */
|
|
65
|
+
export function transitionKeys(prev, next, property) {
|
|
66
|
+
const candidates = Object.keys(next).filter((key) => prev[key] !== next[key] && isAnimatablePair(prev[key], next[key]));
|
|
67
|
+
return keysForProperty(property, candidates);
|
|
68
|
+
}
|
|
69
|
+
/** Interpolated patch for the animated keys at eased progress t. Exported
|
|
70
|
+
for tests. */
|
|
71
|
+
export function interpolateStyle(from, to, keys, t) {
|
|
72
|
+
const patch = {};
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
const a = from[key];
|
|
75
|
+
const b = to[key];
|
|
76
|
+
patch[key] =
|
|
77
|
+
typeof a === "number" && typeof b === "number"
|
|
78
|
+
? a + (b - a) * t
|
|
79
|
+
: lerpHexColor(String(a), String(b), t);
|
|
80
|
+
}
|
|
81
|
+
return patch;
|
|
82
|
+
}
|
|
83
|
+
// ── keyframe presets (animate-*) ───────────────────────────────────────
|
|
84
|
+
/** Style patch at loop phase t (0→1). Exported for tests. */
|
|
85
|
+
export const animationPresets = {
|
|
86
|
+
spin: (t) => ({ rotate: 360 * t }),
|
|
87
|
+
pulse: (t) => ({ opacity: t < 0.5 ? 1 - t : t }), // 1 → 0.5 → 1
|
|
88
|
+
bounce: (t) => {
|
|
89
|
+
// dip up and settle, roughly the web's bounce
|
|
90
|
+
const k = t < 0.5 ? Easing.outCubic(t * 2) : 1 - Easing.inOutCubic((t - 0.5) * 2);
|
|
91
|
+
return { translateY: `${(-25 * k).toFixed(2)}%` };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
export const animationDurations = {
|
|
95
|
+
spin: 1000,
|
|
96
|
+
pulse: 2000,
|
|
97
|
+
bounce: 1000,
|
|
98
|
+
};
|
|
99
|
+
const FRAME_MS = 16;
|
|
100
|
+
const lastStyles = new Map();
|
|
101
|
+
const activeTransitions = new Map();
|
|
102
|
+
const activeAnimations = new Map();
|
|
103
|
+
let driver;
|
|
104
|
+
function ensureDriver() {
|
|
105
|
+
if (driver !== undefined)
|
|
106
|
+
return;
|
|
107
|
+
driver = setInterval(tick, FRAME_MS);
|
|
108
|
+
}
|
|
109
|
+
function stopDriverIfIdle() {
|
|
110
|
+
if (driver !== undefined && activeTransitions.size === 0 && activeAnimations.size === 0) {
|
|
111
|
+
clearInterval(driver);
|
|
112
|
+
driver = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function send(nodeId, payload, patch) {
|
|
116
|
+
queueOp(["setProps", nodeId, { ...payload, style: { ...payload.style, ...patch } }]);
|
|
117
|
+
}
|
|
118
|
+
function tick() {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
for (const [nodeId, tr] of [...activeTransitions]) {
|
|
121
|
+
const t = Math.min(1, Math.max(0, (now - tr.start) / tr.duration));
|
|
122
|
+
const target = tr.payload.style ?? {};
|
|
123
|
+
send(nodeId, tr.payload, interpolateStyle(tr.from, target, tr.keys, tr.easing(t)));
|
|
124
|
+
if (t >= 1) {
|
|
125
|
+
activeTransitions.delete(nodeId);
|
|
126
|
+
lastStyles.set(nodeId, target);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const [nodeId, anim] of activeAnimations) {
|
|
130
|
+
const phase = ((now - anim.start) % anim.duration) / anim.duration;
|
|
131
|
+
send(nodeId, anim.payload, animationPresets[anim.name](phase));
|
|
132
|
+
}
|
|
133
|
+
flushOps();
|
|
134
|
+
stopDriverIfIdle();
|
|
135
|
+
}
|
|
136
|
+
/** The currently displayed style for a node (mid-flight interpolation when a
|
|
137
|
+
transition is running, else the last committed style). */
|
|
138
|
+
function displayedStyle(nodeId) {
|
|
139
|
+
const tr = activeTransitions.get(nodeId);
|
|
140
|
+
if (!tr)
|
|
141
|
+
return lastStyles.get(nodeId);
|
|
142
|
+
const t = Math.min(1, Math.max(0, (Date.now() - tr.start) / tr.duration));
|
|
143
|
+
return { ...tr.payload.style, ...interpolateStyle(tr.from, tr.payload.style ?? {}, tr.keys, tr.easing(t)) };
|
|
144
|
+
}
|
|
145
|
+
/** hostConfig's single entry point: queue this commit's setProps, animated
|
|
146
|
+
when the style asks for it. */
|
|
147
|
+
export function commitProps(nodeId, payload) {
|
|
148
|
+
const next = payload.style ?? {};
|
|
149
|
+
const prev = displayedStyle(nodeId);
|
|
150
|
+
lastStyles.set(nodeId, next);
|
|
151
|
+
// Keyframe preset loops (animate-*).
|
|
152
|
+
const name = typeof next.animationName === "string" ? next.animationName : undefined;
|
|
153
|
+
if (name !== undefined && animationPresets[name] !== undefined) {
|
|
154
|
+
const running = activeAnimations.get(nodeId);
|
|
155
|
+
const duration = typeof next.animationDuration === "number" && next.animationDuration > 0
|
|
156
|
+
? next.animationDuration
|
|
157
|
+
: animationDurations[name];
|
|
158
|
+
activeAnimations.set(nodeId, {
|
|
159
|
+
payload,
|
|
160
|
+
name,
|
|
161
|
+
duration,
|
|
162
|
+
// A re-render mid-loop keeps the phase; a fresh class starts at 0.
|
|
163
|
+
start: running !== undefined && running.name === name ? running.start : Date.now(),
|
|
164
|
+
});
|
|
165
|
+
ensureDriver();
|
|
166
|
+
queueOp(["setProps", nodeId, payload]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (activeAnimations.delete(nodeId))
|
|
170
|
+
stopDriverIfIdle();
|
|
171
|
+
// Property transitions.
|
|
172
|
+
const duration = typeof next.transitionDuration === "number" ? next.transitionDuration : 0;
|
|
173
|
+
const property = typeof next.transitionProperty === "string" ? next.transitionProperty : "default";
|
|
174
|
+
if (prev !== undefined && duration > 0 && property !== "none") {
|
|
175
|
+
const keys = transitionKeys(prev, next, property);
|
|
176
|
+
if (keys.length > 0) {
|
|
177
|
+
const from = {};
|
|
178
|
+
for (const key of keys)
|
|
179
|
+
from[key] = prev[key];
|
|
180
|
+
const delay = typeof next.transitionDelay === "number" ? next.transitionDelay : 0;
|
|
181
|
+
const easingName = String(next.transitionEasing ?? "ease-in-out");
|
|
182
|
+
activeTransitions.set(nodeId, {
|
|
183
|
+
payload,
|
|
184
|
+
from,
|
|
185
|
+
keys,
|
|
186
|
+
start: Date.now() + delay,
|
|
187
|
+
duration,
|
|
188
|
+
easing: easings[easingName] ?? easings["ease-in-out"],
|
|
189
|
+
});
|
|
190
|
+
ensureDriver();
|
|
191
|
+
// Land this commit at the old values so nothing jumps before frame 1.
|
|
192
|
+
send(nodeId, payload, from);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
activeTransitions.delete(nodeId);
|
|
197
|
+
queueOp(["setProps", nodeId, payload]);
|
|
198
|
+
}
|
|
199
|
+
/** Unmount cleanup — hostConfig.detachDeletedInstance. */
|
|
200
|
+
export function releaseNode(nodeId) {
|
|
201
|
+
lastStyles.delete(nodeId);
|
|
202
|
+
activeTransitions.delete(nodeId);
|
|
203
|
+
activeAnimations.delete(nodeId);
|
|
204
|
+
stopDriverIfIdle();
|
|
205
|
+
}
|
package/dist/tw.js
CHANGED
|
@@ -40,7 +40,13 @@ const staticClasses = {
|
|
|
40
40
|
"overflow-hidden": { overflow: "hidden" },
|
|
41
41
|
"overflow-visible": { overflow: "visible" },
|
|
42
42
|
"overflow-y-scroll": { overflow: "scroll" },
|
|
43
|
+
"overflow-x-scroll": { overflow: "scroll" },
|
|
43
44
|
"overflow-scroll": { overflow: "scroll" },
|
|
45
|
+
// overflow:"scroll" only engages the axes whose content actually
|
|
46
|
+
// overflows, so auto is the same key.
|
|
47
|
+
"overflow-auto": { overflow: "scroll" },
|
|
48
|
+
"overflow-y-auto": { overflow: "scroll" },
|
|
49
|
+
"overflow-x-auto": { overflow: "scroll" },
|
|
44
50
|
"w-full": { width: "100%" },
|
|
45
51
|
"h-full": { height: "100%" },
|
|
46
52
|
"text-left": { textAlign: "left" },
|
|
@@ -80,6 +86,8 @@ const staticClasses = {
|
|
|
80
86
|
"cursor-not-allowed": { cursor: "not-allowed" },
|
|
81
87
|
"pointer-events-none": { pointerEvents: "none" },
|
|
82
88
|
"pointer-events-auto": { pointerEvents: "auto" },
|
|
89
|
+
"select-text": { userSelect: "text" },
|
|
90
|
+
"select-none": { userSelect: "none" },
|
|
83
91
|
hidden: { display: "none" },
|
|
84
92
|
invisible: { visibility: "hidden" },
|
|
85
93
|
visible: { visibility: "visible" },
|
|
@@ -98,6 +106,22 @@ const staticClasses = {
|
|
|
98
106
|
"object-contain": { objectFit: "contain" },
|
|
99
107
|
"object-cover": { objectFit: "cover" },
|
|
100
108
|
"object-fill": { objectFit: "fill" },
|
|
109
|
+
blur: { blurRadius: 8 },
|
|
110
|
+
"blur-none": { blurRadius: 0 },
|
|
111
|
+
"blur-sm": { blurRadius: 4 },
|
|
112
|
+
"blur-md": { blurRadius: 12 },
|
|
113
|
+
"blur-lg": { blurRadius: 16 },
|
|
114
|
+
"blur-xl": { blurRadius: 24 },
|
|
115
|
+
"blur-2xl": { blurRadius: 40 },
|
|
116
|
+
"blur-3xl": { blurRadius: 64 },
|
|
117
|
+
"backdrop-blur": { backdropBlurRadius: 8 },
|
|
118
|
+
"backdrop-blur-none": { backdropBlurRadius: 0 },
|
|
119
|
+
"backdrop-blur-sm": { backdropBlurRadius: 4 },
|
|
120
|
+
"backdrop-blur-md": { backdropBlurRadius: 12 },
|
|
121
|
+
"backdrop-blur-lg": { backdropBlurRadius: 16 },
|
|
122
|
+
"backdrop-blur-xl": { backdropBlurRadius: 24 },
|
|
123
|
+
"backdrop-blur-2xl": { backdropBlurRadius: 40 },
|
|
124
|
+
"backdrop-blur-3xl": { backdropBlurRadius: 64 },
|
|
101
125
|
shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
102
126
|
"shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
103
127
|
"shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
|
|
@@ -118,6 +142,20 @@ const staticClasses = {
|
|
|
118
142
|
"bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
|
|
119
143
|
"bg-gradient-radial": { gradientType: "radial" },
|
|
120
144
|
"bg-gradient-conic": { gradientType: "conic" },
|
|
145
|
+
transition: { transitionProperty: "default", transitionDuration: 150 },
|
|
146
|
+
"transition-all": { transitionProperty: "all", transitionDuration: 150 },
|
|
147
|
+
"transition-colors": { transitionProperty: "colors", transitionDuration: 150 },
|
|
148
|
+
"transition-opacity": { transitionProperty: "opacity", transitionDuration: 150 },
|
|
149
|
+
"transition-transform": { transitionProperty: "transform", transitionDuration: 150 },
|
|
150
|
+
"transition-none": { transitionProperty: "none" },
|
|
151
|
+
"ease-linear": { transitionEasing: "linear" },
|
|
152
|
+
"ease-in": { transitionEasing: "ease-in" },
|
|
153
|
+
"ease-out": { transitionEasing: "ease-out" },
|
|
154
|
+
"ease-in-out": { transitionEasing: "ease-in-out" },
|
|
155
|
+
"animate-spin": { animationName: "spin", animationDuration: 1000 },
|
|
156
|
+
"animate-pulse": { animationName: "pulse", animationDuration: 2000 },
|
|
157
|
+
"animate-bounce": { animationName: "bounce", animationDuration: 1000 },
|
|
158
|
+
"animate-none": { animationName: "none" },
|
|
121
159
|
};
|
|
122
160
|
const textSizes = {
|
|
123
161
|
xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
|
|
@@ -321,6 +359,24 @@ function resolveClass(cls) {
|
|
|
321
359
|
const n = Number(rest);
|
|
322
360
|
return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
|
|
323
361
|
}
|
|
362
|
+
case "blur": {
|
|
363
|
+
// Named sizes live in staticClasses; arbitrary is px: blur-[6].
|
|
364
|
+
if (rest.startsWith("[")) {
|
|
365
|
+
const radius = parseLength(rest);
|
|
366
|
+
if (typeof radius === "number")
|
|
367
|
+
return { blurRadius: radius };
|
|
368
|
+
}
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
case "backdrop": {
|
|
372
|
+
// backdrop-blur-[6] (named backdrop-blur-* sizes are static classes)
|
|
373
|
+
if (rest.startsWith("blur-[")) {
|
|
374
|
+
const radius = parseLength(rest.slice("blur-".length));
|
|
375
|
+
if (typeof radius === "number")
|
|
376
|
+
return { backdropBlurRadius: radius };
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
324
380
|
case "leading": {
|
|
325
381
|
if (rest.startsWith("[")) {
|
|
326
382
|
const px = parseLength(rest);
|
|
@@ -350,6 +406,19 @@ function resolveClass(cls) {
|
|
|
350
406
|
const n = Number(rest);
|
|
351
407
|
return Number.isFinite(n) ? { flex: n } : undefined;
|
|
352
408
|
}
|
|
409
|
+
case "duration": {
|
|
410
|
+
// Milliseconds, literal (duration-150, duration-[320]).
|
|
411
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
412
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
413
|
+
? { transitionDuration: ms }
|
|
414
|
+
: undefined;
|
|
415
|
+
}
|
|
416
|
+
case "delay": {
|
|
417
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
418
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
419
|
+
? { transitionDelay: ms }
|
|
420
|
+
: undefined;
|
|
421
|
+
}
|
|
353
422
|
case "z": {
|
|
354
423
|
const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
355
424
|
return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
|
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// 0.0.26 "honest gaps" round: selection opt-in, blur classes, transitions,
|
|
2
|
+
// animate-* presets, horizontal scroll surface.
|
|
3
|
+
|
|
4
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
const batches: unknown[][][] = [];
|
|
8
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
9
|
+
batches.push(JSON.parse(json));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
import { render, unmount, View, Text } from "./index";
|
|
13
|
+
import { tw } from "./tw";
|
|
14
|
+
import {
|
|
15
|
+
animationDurations,
|
|
16
|
+
animationPresets,
|
|
17
|
+
interpolateStyle,
|
|
18
|
+
lerpHexColor,
|
|
19
|
+
parseHexColor,
|
|
20
|
+
transitionKeys,
|
|
21
|
+
} from "./transitions";
|
|
22
|
+
|
|
23
|
+
const allOps = () => batches.flat();
|
|
24
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
25
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
unmount();
|
|
29
|
+
batches.length = 0;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("tw: 0.0.26 classes", () => {
|
|
33
|
+
test("overflow-x and auto variants map to overflow scroll", () => {
|
|
34
|
+
for (const cls of ["overflow-x-scroll", "overflow-x-auto", "overflow-auto", "overflow-y-auto"])
|
|
35
|
+
expect(tw(cls).style.overflow).toBe("scroll");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("select-text / select-none map to userSelect", () => {
|
|
39
|
+
expect(tw("select-text").style.userSelect).toBe("text");
|
|
40
|
+
expect(tw("select-none").style.userSelect).toBe("none");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("blur scale, arbitrary blur, and backdrop-blur", () => {
|
|
44
|
+
expect(tw("blur").style.blurRadius).toBe(8);
|
|
45
|
+
expect(tw("blur-sm").style.blurRadius).toBe(4);
|
|
46
|
+
expect(tw("blur-3xl").style.blurRadius).toBe(64);
|
|
47
|
+
expect(tw("blur-[6]").style.blurRadius).toBe(6);
|
|
48
|
+
expect(tw("backdrop-blur").style.backdropBlurRadius).toBe(8);
|
|
49
|
+
expect(tw("backdrop-blur-xl").style.backdropBlurRadius).toBe(24);
|
|
50
|
+
expect(tw("backdrop-blur-[10]").style.backdropBlurRadius).toBe(10);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("transition groups, duration, delay, easing", () => {
|
|
54
|
+
expect(tw("transition").style).toEqual({ transitionProperty: "default", transitionDuration: 150 });
|
|
55
|
+
expect(tw("transition-colors").style.transitionProperty).toBe("colors");
|
|
56
|
+
expect(tw("transition-none").style.transitionProperty).toBe("none");
|
|
57
|
+
expect(tw("duration-300").style.transitionDuration).toBe(300);
|
|
58
|
+
expect(tw("duration-[320]").style.transitionDuration).toBe(320);
|
|
59
|
+
expect(tw("delay-75").style.transitionDelay).toBe(75);
|
|
60
|
+
expect(tw("ease-out").style.transitionEasing).toBe("ease-out");
|
|
61
|
+
expect(tw("ease-in-out").style.transitionEasing).toBe("ease-in-out");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("animate presets carry name and default duration", () => {
|
|
65
|
+
expect(tw("animate-spin").style).toEqual({ animationName: "spin", animationDuration: 1000 });
|
|
66
|
+
expect(tw("animate-pulse").style.animationName).toBe("pulse");
|
|
67
|
+
expect(tw("animate-bounce").style.animationName).toBe("bounce");
|
|
68
|
+
expect(tw("animate-none").style.animationName).toBe("none");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("transitions: pure helpers", () => {
|
|
73
|
+
test("parseHexColor handles 6 and 8 digit hex only", () => {
|
|
74
|
+
expect(parseHexColor("#ff0080")).toEqual([255, 0, 128, 255]);
|
|
75
|
+
expect(parseHexColor("#ff008040")).toEqual([255, 0, 128, 64]);
|
|
76
|
+
expect(parseHexColor("red")).toBeUndefined();
|
|
77
|
+
expect(parseHexColor("#fff")).toBeUndefined();
|
|
78
|
+
expect(parseHexColor(12)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("lerpHexColor midpoints and alpha promotion", () => {
|
|
82
|
+
expect(lerpHexColor("#000000", "#ffffff", 0.5)).toBe("#808080");
|
|
83
|
+
expect(lerpHexColor("#000000", "#ffffff", 0)).toBe("#000000");
|
|
84
|
+
expect(lerpHexColor("#00000000", "#000000", 1)).toBe("#000000ff");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("transitionKeys diffs only animatable changes in the group", () => {
|
|
88
|
+
const prev = { opacity: 0, backgroundColor: "#000000", width: 10, flexDirection: "row" };
|
|
89
|
+
const next = { opacity: 1, backgroundColor: "#ffffff", width: 20, flexDirection: "column" };
|
|
90
|
+
|
|
91
|
+
expect(transitionKeys(prev, next, "all").sort()).toEqual(["backgroundColor", "opacity", "width"]);
|
|
92
|
+
expect(transitionKeys(prev, next, "colors")).toEqual(["backgroundColor"]);
|
|
93
|
+
expect(transitionKeys(prev, next, "opacity")).toEqual(["opacity"]);
|
|
94
|
+
expect(transitionKeys(prev, next, "none")).toEqual([]);
|
|
95
|
+
// default group: opacity+colors+transform, not width
|
|
96
|
+
expect(transitionKeys(prev, next, "default").sort()).toEqual(["backgroundColor", "opacity"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("transitionKeys skips keys missing an endpoint or non-animatable", () => {
|
|
100
|
+
expect(transitionKeys({}, { opacity: 1 }, "all")).toEqual([]);
|
|
101
|
+
expect(transitionKeys({ translateX: "50%" }, { translateX: "0%" }, "all")).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("interpolateStyle blends numbers and colors", () => {
|
|
105
|
+
const patch = interpolateStyle(
|
|
106
|
+
{ opacity: 0, color: "#000000" },
|
|
107
|
+
{ opacity: 1, color: "#ffffff" },
|
|
108
|
+
["opacity", "color"],
|
|
109
|
+
0.5,
|
|
110
|
+
);
|
|
111
|
+
expect(patch.opacity).toBeCloseTo(0.5, 5);
|
|
112
|
+
expect(patch.color).toBe("#808080");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("presets: spin is linear rotation, pulse dips to 0.5, bounce returns home", () => {
|
|
116
|
+
expect(animationPresets.spin(0).rotate).toBe(0);
|
|
117
|
+
expect(animationPresets.spin(0.25).rotate).toBe(90);
|
|
118
|
+
expect(animationPresets.pulse(0).opacity).toBe(1);
|
|
119
|
+
expect(animationPresets.pulse(0.5).opacity).toBe(0.5);
|
|
120
|
+
expect(animationPresets.pulse(1).opacity).toBe(1);
|
|
121
|
+
expect(animationPresets.bounce(0).translateY).toBe("0.00%");
|
|
122
|
+
expect(String(animationPresets.bounce(0.5).translateY)).toBe("-25.00%");
|
|
123
|
+
expect(animationDurations.spin).toBe(1000);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("selection + scroll props through the bridge", () => {
|
|
128
|
+
test("<Text selectable> lands userSelect:'text' in the style payload", () => {
|
|
129
|
+
render(<Text selectable>copy me</Text>);
|
|
130
|
+
|
|
131
|
+
const create: any = opsNamed("create").find((op: any) => op[2] === "text");
|
|
132
|
+
const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
|
|
133
|
+
expect(props[2].style.userSelect).toBe("text");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("plain <Text> stays unselectable (no userSelect key)", () => {
|
|
137
|
+
render(<Text>plain</Text>);
|
|
138
|
+
|
|
139
|
+
const create: any = opsNamed("create").find((op: any) => op[2] === "text");
|
|
140
|
+
const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
|
|
141
|
+
expect(props[2].style.userSelect).toBeUndefined();
|
|
142
|
+
expect(props[2].selectable).toBeUndefined(); // consumed, not forwarded
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("scrollLeft passes through like scrollTop", () => {
|
|
146
|
+
render(<View className="overflow-x-scroll" scrollLeft={12} scrollTop={4} />);
|
|
147
|
+
|
|
148
|
+
const props: any = opsNamed("setProps")[0];
|
|
149
|
+
expect(props[2].scrollLeft).toBe(12);
|
|
150
|
+
expect(props[2].scrollTop).toBe(4);
|
|
151
|
+
expect(props[2].style.overflow).toBe("scroll");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("transition engine end-to-end", () => {
|
|
156
|
+
test("a style change with transitionDuration tweens instead of jumping", async () => {
|
|
157
|
+
function Fader() {
|
|
158
|
+
const [on, setOn] = useState(false);
|
|
159
|
+
return (
|
|
160
|
+
<View
|
|
161
|
+
onClick={() => setOn(true)}
|
|
162
|
+
style={{ opacity: on ? 1 : 0, transitionDuration: 60, transitionProperty: "all" }}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
render(<Fader />);
|
|
168
|
+
|
|
169
|
+
const create: any = opsNamed("create")[0];
|
|
170
|
+
const nodeId = create[1];
|
|
171
|
+
|
|
172
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(
|
|
173
|
+
JSON.stringify({ kind: "event", nodeId, type: "click" }),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await sleep(160);
|
|
177
|
+
|
|
178
|
+
const styleOps = opsNamed("setProps")
|
|
179
|
+
.filter((op: any) => op[1] === nodeId)
|
|
180
|
+
.map((op: any) => op[2].style.opacity);
|
|
181
|
+
|
|
182
|
+
// The commit lands at the old value, frames climb, the last hits 1 exactly.
|
|
183
|
+
expect(styleOps[styleOps.length - 1]).toBe(1);
|
|
184
|
+
expect(styleOps.some((o: number) => o > 0 && o < 1)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("animate-spin keeps emitting rotate frames until unmounted", async () => {
|
|
188
|
+
render(<View className="animate-spin" />);
|
|
189
|
+
|
|
190
|
+
const create: any = opsNamed("create")[0];
|
|
191
|
+
const nodeId = create[1];
|
|
192
|
+
|
|
193
|
+
await sleep(80);
|
|
194
|
+
unmount();
|
|
195
|
+
const count = opsNamed("setProps").filter(
|
|
196
|
+
(op: any) => op[1] === nodeId && typeof op[2].style.rotate === "number",
|
|
197
|
+
).length;
|
|
198
|
+
expect(count).toBeGreaterThan(2);
|
|
199
|
+
|
|
200
|
+
batches.length = 0;
|
|
201
|
+
await sleep(60);
|
|
202
|
+
// no frames after unmount
|
|
203
|
+
expect(opsNamed("setProps").filter((op: any) => op[1] === nodeId).length).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/hostConfig.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { DefaultEventPriority } from "react-reconciler/constants";
|
|
5
5
|
import { queueOp, flushOps, setHandlers, removeHandlers, type EventHandler } from "./bridge";
|
|
6
|
+
import { commitProps, releaseNode } from "./transitions";
|
|
6
7
|
import { tw, type Style } from "./tw";
|
|
7
8
|
|
|
8
9
|
export interface Instance {
|
|
@@ -82,7 +83,8 @@ function normalizeProps(props: Record<string, unknown>): {
|
|
|
82
83
|
function applyProps(instance: Instance, props: Record<string, unknown>): void {
|
|
83
84
|
const { payload, handlers } = normalizeProps(props);
|
|
84
85
|
setHandlers(instance.id, handlers);
|
|
85
|
-
|
|
86
|
+
// The transition engine queues the op — tweened when the style asks for it.
|
|
87
|
+
commitProps(instance.id, payload);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export const hostConfig = {
|
|
@@ -157,6 +159,7 @@ export const hostConfig = {
|
|
|
157
159
|
|
|
158
160
|
detachDeletedInstance(instance: Instance): void {
|
|
159
161
|
removeHandlers(instance.id);
|
|
162
|
+
releaseNode(instance.id);
|
|
160
163
|
},
|
|
161
164
|
|
|
162
165
|
getCurrentEventPriority: () => DefaultEventPriority,
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import "./runtime";
|
|
|
4
4
|
import "./bridge";
|
|
5
5
|
|
|
6
6
|
/** The SDK version — stamp it on support dumps and analytics. */
|
|
7
|
-
export const VERSION = "0.0.
|
|
7
|
+
export const VERSION = "0.0.26";
|
|
8
8
|
|
|
9
9
|
export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
|
|
10
10
|
export type {
|
package/src/primitives.tsx
CHANGED
|
@@ -18,10 +18,11 @@ export interface LayoutRect {
|
|
|
18
18
|
height: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Mouse-wheel payload.
|
|
22
|
-
positive = wheel up). */
|
|
21
|
+
/** Mouse-wheel payload. Deltas are JUCE's notch fraction (~0.1 per notch,
|
|
22
|
+
dy positive = wheel up; dx nonzero on trackpads/tilt wheels). */
|
|
23
23
|
export interface WheelEventPayload {
|
|
24
24
|
dy: number;
|
|
25
|
+
dx: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
|
|
@@ -44,8 +45,10 @@ export interface CommonProps {
|
|
|
44
45
|
className?: string;
|
|
45
46
|
style?: Style;
|
|
46
47
|
children?: ReactNode;
|
|
47
|
-
/** Resets the scroll offset of an overflow-
|
|
48
|
+
/** Resets the vertical scroll offset of an overflow-scroll container. */
|
|
48
49
|
scrollTop?: number;
|
|
50
|
+
/** Resets the horizontal scroll offset of an overflow-scroll container. */
|
|
51
|
+
scrollLeft?: number;
|
|
49
52
|
onClick?: () => void;
|
|
50
53
|
onDoubleClick?: () => void;
|
|
51
54
|
/** Wheel over this node (controls win the wheel over scroll containers). */
|
|
@@ -77,9 +80,14 @@ export function View(props: CommonProps) {
|
|
|
77
80
|
|
|
78
81
|
export interface TextProps extends Omit<CommonProps, "children"> {
|
|
79
82
|
children?: ReactNode; // strings/numbers (and fragments of them)
|
|
83
|
+
/** Opt this text into selection (drag to select, double-click for a word,
|
|
84
|
+
Ctrl/Cmd+C copies). Text is NOT selectable by default. Equivalent to
|
|
85
|
+
the `select-text` class / `userSelect: "text"` style key. */
|
|
86
|
+
selectable?: boolean;
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
export function Text(props: TextProps) {
|
|
89
|
+
export function Text({ selectable, ...props }: TextProps) {
|
|
90
|
+
if (selectable) props = { ...props, style: { userSelect: "text", ...props.style } };
|
|
83
91
|
return createElement("vs-text", props);
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// CSS transitions and keyframe presets, driven from JS. When a commit changes
|
|
2
|
+
// a node whose style carries transitionDuration, the changed animatable keys
|
|
3
|
+
// tween from their *currently displayed* values instead of jumping; animate-*
|
|
4
|
+
// classes run keyframe loops. Every frame re-sends the node's full payload
|
|
5
|
+
// (setProps replaces wholesale) with the animated keys patched — the same
|
|
6
|
+
// traffic a useTween re-render produces, without the component code.
|
|
7
|
+
//
|
|
8
|
+
// Honest limit: native-side hover/active/focus style merges don't transition
|
|
9
|
+
// (they never round-trip through JS). Animate those with onMouseEnter state.
|
|
10
|
+
|
|
11
|
+
import { queueOp, flushOps } from "./bridge";
|
|
12
|
+
import { Easing, type EasingFn } from "./animation";
|
|
13
|
+
import type { Style, StyleValue } from "./tw";
|
|
14
|
+
|
|
15
|
+
type Payload = Record<string, unknown> & { style?: Style };
|
|
16
|
+
|
|
17
|
+
export type TransitionEasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
18
|
+
|
|
19
|
+
const easings: Record<TransitionEasingName, EasingFn> = {
|
|
20
|
+
linear: Easing.linear,
|
|
21
|
+
"ease-in": (t) => t * t * t,
|
|
22
|
+
"ease-out": Easing.outCubic,
|
|
23
|
+
"ease-in-out": Easing.inOutCubic,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Keys each transitionProperty group may animate. "colors" is any *Color/
|
|
27
|
+
color key; "all" is every animatable difference. */
|
|
28
|
+
const transformKeys = ["rotate", "scale", "translateX", "translateY"];
|
|
29
|
+
const defaultKeys = [
|
|
30
|
+
"color", "backgroundColor", "borderColor", "opacity",
|
|
31
|
+
...transformKeys, "blurRadius", "backdropBlurRadius",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function keysForProperty(property: string, candidates: string[]): string[] {
|
|
35
|
+
switch (property) {
|
|
36
|
+
case "all":
|
|
37
|
+
return candidates;
|
|
38
|
+
case "colors":
|
|
39
|
+
return candidates.filter((k) => /color/i.test(k));
|
|
40
|
+
case "opacity":
|
|
41
|
+
return candidates.filter((k) => k === "opacity");
|
|
42
|
+
case "transform":
|
|
43
|
+
return candidates.filter((k) => transformKeys.includes(k));
|
|
44
|
+
case "none":
|
|
45
|
+
return [];
|
|
46
|
+
default:
|
|
47
|
+
return candidates.filter((k) => defaultKeys.includes(k));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── value interpolation ────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
|
|
54
|
+
export function parseHexColor(value: unknown): [number, number, number, number] | undefined {
|
|
55
|
+
if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value)) return undefined;
|
|
56
|
+
const n = (i: number) => parseInt(value.slice(i, i + 2), 16);
|
|
57
|
+
return [n(1), n(3), n(5), value.length === 9 ? n(7) : 255];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function lerpHexColor(from: string, to: string, t: number): string {
|
|
61
|
+
const a = parseHexColor(from)!;
|
|
62
|
+
const b = parseHexColor(to)!;
|
|
63
|
+
const hex = (i: number) =>
|
|
64
|
+
Math.round(a[i] + (b[i] - a[i]) * t)
|
|
65
|
+
.toString(16)
|
|
66
|
+
.padStart(2, "0");
|
|
67
|
+
const alpha = a[3] !== 255 || b[3] !== 255 ? hex(3) : "";
|
|
68
|
+
return `#${hex(0)}${hex(1)}${hex(2)}${alpha}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isAnimatablePair(from: StyleValue | undefined, to: StyleValue | undefined): boolean {
|
|
72
|
+
if (typeof from === "number" && typeof to === "number") return Number.isFinite(from) && Number.isFinite(to);
|
|
73
|
+
return parseHexColor(from) !== undefined && parseHexColor(to) !== undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The animatable keys that differ between two styles, honoring the
|
|
77
|
+
transitionProperty group. Exported for tests. */
|
|
78
|
+
export function transitionKeys(prev: Style, next: Style, property: string): string[] {
|
|
79
|
+
const candidates = Object.keys(next).filter(
|
|
80
|
+
(key) => prev[key] !== next[key] && isAnimatablePair(prev[key], next[key]),
|
|
81
|
+
);
|
|
82
|
+
return keysForProperty(property, candidates);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Interpolated patch for the animated keys at eased progress t. Exported
|
|
86
|
+
for tests. */
|
|
87
|
+
export function interpolateStyle(from: Style, to: Style, keys: string[], t: number): Style {
|
|
88
|
+
const patch: Style = {};
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
const a = from[key];
|
|
91
|
+
const b = to[key];
|
|
92
|
+
patch[key] =
|
|
93
|
+
typeof a === "number" && typeof b === "number"
|
|
94
|
+
? a + (b - a) * t
|
|
95
|
+
: lerpHexColor(String(a), String(b), t);
|
|
96
|
+
}
|
|
97
|
+
return patch;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── keyframe presets (animate-*) ───────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Style patch at loop phase t (0→1). Exported for tests. */
|
|
103
|
+
export const animationPresets: Record<string, (t: number) => Style> = {
|
|
104
|
+
spin: (t) => ({ rotate: 360 * t }),
|
|
105
|
+
pulse: (t) => ({ opacity: t < 0.5 ? 1 - t : t }), // 1 → 0.5 → 1
|
|
106
|
+
bounce: (t) => {
|
|
107
|
+
// dip up and settle, roughly the web's bounce
|
|
108
|
+
const k = t < 0.5 ? Easing.outCubic(t * 2) : 1 - Easing.inOutCubic((t - 0.5) * 2);
|
|
109
|
+
return { translateY: `${(-25 * k).toFixed(2)}%` };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const animationDurations: Record<string, number> = {
|
|
114
|
+
spin: 1000,
|
|
115
|
+
pulse: 2000,
|
|
116
|
+
bounce: 1000,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── the shared frame driver ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
interface ActiveTransition {
|
|
122
|
+
payload: Payload;
|
|
123
|
+
from: Style;
|
|
124
|
+
keys: string[];
|
|
125
|
+
start: number;
|
|
126
|
+
duration: number;
|
|
127
|
+
easing: EasingFn;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ActiveAnimation {
|
|
131
|
+
payload: Payload;
|
|
132
|
+
name: string;
|
|
133
|
+
start: number;
|
|
134
|
+
duration: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const FRAME_MS = 16;
|
|
138
|
+
const lastStyles = new Map<number, Style>();
|
|
139
|
+
const activeTransitions = new Map<number, ActiveTransition>();
|
|
140
|
+
const activeAnimations = new Map<number, ActiveAnimation>();
|
|
141
|
+
let driver: ReturnType<typeof setInterval> | undefined;
|
|
142
|
+
|
|
143
|
+
function ensureDriver(): void {
|
|
144
|
+
if (driver !== undefined) return;
|
|
145
|
+
driver = setInterval(tick, FRAME_MS);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stopDriverIfIdle(): void {
|
|
149
|
+
if (driver !== undefined && activeTransitions.size === 0 && activeAnimations.size === 0) {
|
|
150
|
+
clearInterval(driver);
|
|
151
|
+
driver = undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function send(nodeId: number, payload: Payload, patch: Style): void {
|
|
156
|
+
queueOp(["setProps", nodeId, { ...payload, style: { ...payload.style, ...patch } }]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tick(): void {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
|
|
162
|
+
for (const [nodeId, tr] of [...activeTransitions]) {
|
|
163
|
+
const t = Math.min(1, Math.max(0, (now - tr.start) / tr.duration));
|
|
164
|
+
const target = tr.payload.style ?? {};
|
|
165
|
+
send(nodeId, tr.payload, interpolateStyle(tr.from, target, tr.keys, tr.easing(t)));
|
|
166
|
+
|
|
167
|
+
if (t >= 1) {
|
|
168
|
+
activeTransitions.delete(nodeId);
|
|
169
|
+
lastStyles.set(nodeId, target);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [nodeId, anim] of activeAnimations) {
|
|
174
|
+
const phase = ((now - anim.start) % anim.duration) / anim.duration;
|
|
175
|
+
send(nodeId, anim.payload, animationPresets[anim.name](phase));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
flushOps();
|
|
179
|
+
stopDriverIfIdle();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The currently displayed style for a node (mid-flight interpolation when a
|
|
183
|
+
transition is running, else the last committed style). */
|
|
184
|
+
function displayedStyle(nodeId: number): Style | undefined {
|
|
185
|
+
const tr = activeTransitions.get(nodeId);
|
|
186
|
+
if (!tr) return lastStyles.get(nodeId);
|
|
187
|
+
const t = Math.min(1, Math.max(0, (Date.now() - tr.start) / tr.duration));
|
|
188
|
+
return { ...tr.payload.style, ...interpolateStyle(tr.from, tr.payload.style ?? {}, tr.keys, tr.easing(t)) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** hostConfig's single entry point: queue this commit's setProps, animated
|
|
192
|
+
when the style asks for it. */
|
|
193
|
+
export function commitProps(nodeId: number, payload: Payload): void {
|
|
194
|
+
const next = payload.style ?? {};
|
|
195
|
+
const prev = displayedStyle(nodeId);
|
|
196
|
+
lastStyles.set(nodeId, next);
|
|
197
|
+
|
|
198
|
+
// Keyframe preset loops (animate-*).
|
|
199
|
+
const name = typeof next.animationName === "string" ? next.animationName : undefined;
|
|
200
|
+
|
|
201
|
+
if (name !== undefined && animationPresets[name] !== undefined) {
|
|
202
|
+
const running = activeAnimations.get(nodeId);
|
|
203
|
+
const duration =
|
|
204
|
+
typeof next.animationDuration === "number" && next.animationDuration > 0
|
|
205
|
+
? next.animationDuration
|
|
206
|
+
: animationDurations[name];
|
|
207
|
+
|
|
208
|
+
activeAnimations.set(nodeId, {
|
|
209
|
+
payload,
|
|
210
|
+
name,
|
|
211
|
+
duration,
|
|
212
|
+
// A re-render mid-loop keeps the phase; a fresh class starts at 0.
|
|
213
|
+
start: running !== undefined && running.name === name ? running.start : Date.now(),
|
|
214
|
+
});
|
|
215
|
+
ensureDriver();
|
|
216
|
+
queueOp(["setProps", nodeId, payload]);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (activeAnimations.delete(nodeId)) stopDriverIfIdle();
|
|
221
|
+
|
|
222
|
+
// Property transitions.
|
|
223
|
+
const duration = typeof next.transitionDuration === "number" ? next.transitionDuration : 0;
|
|
224
|
+
const property = typeof next.transitionProperty === "string" ? next.transitionProperty : "default";
|
|
225
|
+
|
|
226
|
+
if (prev !== undefined && duration > 0 && property !== "none") {
|
|
227
|
+
const keys = transitionKeys(prev, next, property);
|
|
228
|
+
|
|
229
|
+
if (keys.length > 0) {
|
|
230
|
+
const from: Style = {};
|
|
231
|
+
for (const key of keys) from[key] = prev[key];
|
|
232
|
+
|
|
233
|
+
const delay = typeof next.transitionDelay === "number" ? next.transitionDelay : 0;
|
|
234
|
+
const easingName = String(next.transitionEasing ?? "ease-in-out") as TransitionEasingName;
|
|
235
|
+
|
|
236
|
+
activeTransitions.set(nodeId, {
|
|
237
|
+
payload,
|
|
238
|
+
from,
|
|
239
|
+
keys,
|
|
240
|
+
start: Date.now() + delay,
|
|
241
|
+
duration,
|
|
242
|
+
easing: easings[easingName] ?? easings["ease-in-out"],
|
|
243
|
+
});
|
|
244
|
+
ensureDriver();
|
|
245
|
+
|
|
246
|
+
// Land this commit at the old values so nothing jumps before frame 1.
|
|
247
|
+
send(nodeId, payload, from);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
activeTransitions.delete(nodeId);
|
|
253
|
+
queueOp(["setProps", nodeId, payload]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Unmount cleanup — hostConfig.detachDeletedInstance. */
|
|
257
|
+
export function releaseNode(nodeId: number): void {
|
|
258
|
+
lastStyles.delete(nodeId);
|
|
259
|
+
activeTransitions.delete(nodeId);
|
|
260
|
+
activeAnimations.delete(nodeId);
|
|
261
|
+
stopDriverIfIdle();
|
|
262
|
+
}
|
package/src/tw.ts
CHANGED
|
@@ -51,7 +51,13 @@ const staticClasses: Record<string, Style> = {
|
|
|
51
51
|
"overflow-hidden": { overflow: "hidden" },
|
|
52
52
|
"overflow-visible": { overflow: "visible" },
|
|
53
53
|
"overflow-y-scroll": { overflow: "scroll" },
|
|
54
|
+
"overflow-x-scroll": { overflow: "scroll" },
|
|
54
55
|
"overflow-scroll": { overflow: "scroll" },
|
|
56
|
+
// overflow:"scroll" only engages the axes whose content actually
|
|
57
|
+
// overflows, so auto is the same key.
|
|
58
|
+
"overflow-auto": { overflow: "scroll" },
|
|
59
|
+
"overflow-y-auto": { overflow: "scroll" },
|
|
60
|
+
"overflow-x-auto": { overflow: "scroll" },
|
|
55
61
|
"w-full": { width: "100%" },
|
|
56
62
|
"h-full": { height: "100%" },
|
|
57
63
|
"text-left": { textAlign: "left" },
|
|
@@ -91,6 +97,8 @@ const staticClasses: Record<string, Style> = {
|
|
|
91
97
|
"cursor-not-allowed": { cursor: "not-allowed" },
|
|
92
98
|
"pointer-events-none": { pointerEvents: "none" },
|
|
93
99
|
"pointer-events-auto": { pointerEvents: "auto" },
|
|
100
|
+
"select-text": { userSelect: "text" },
|
|
101
|
+
"select-none": { userSelect: "none" },
|
|
94
102
|
hidden: { display: "none" },
|
|
95
103
|
invisible: { visibility: "hidden" },
|
|
96
104
|
visible: { visibility: "visible" },
|
|
@@ -109,6 +117,22 @@ const staticClasses: Record<string, Style> = {
|
|
|
109
117
|
"object-contain": { objectFit: "contain" },
|
|
110
118
|
"object-cover": { objectFit: "cover" },
|
|
111
119
|
"object-fill": { objectFit: "fill" },
|
|
120
|
+
blur: { blurRadius: 8 },
|
|
121
|
+
"blur-none": { blurRadius: 0 },
|
|
122
|
+
"blur-sm": { blurRadius: 4 },
|
|
123
|
+
"blur-md": { blurRadius: 12 },
|
|
124
|
+
"blur-lg": { blurRadius: 16 },
|
|
125
|
+
"blur-xl": { blurRadius: 24 },
|
|
126
|
+
"blur-2xl": { blurRadius: 40 },
|
|
127
|
+
"blur-3xl": { blurRadius: 64 },
|
|
128
|
+
"backdrop-blur": { backdropBlurRadius: 8 },
|
|
129
|
+
"backdrop-blur-none": { backdropBlurRadius: 0 },
|
|
130
|
+
"backdrop-blur-sm": { backdropBlurRadius: 4 },
|
|
131
|
+
"backdrop-blur-md": { backdropBlurRadius: 12 },
|
|
132
|
+
"backdrop-blur-lg": { backdropBlurRadius: 16 },
|
|
133
|
+
"backdrop-blur-xl": { backdropBlurRadius: 24 },
|
|
134
|
+
"backdrop-blur-2xl": { backdropBlurRadius: 40 },
|
|
135
|
+
"backdrop-blur-3xl": { backdropBlurRadius: 64 },
|
|
112
136
|
shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
113
137
|
"shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
114
138
|
"shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
|
|
@@ -129,6 +153,20 @@ const staticClasses: Record<string, Style> = {
|
|
|
129
153
|
"bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
|
|
130
154
|
"bg-gradient-radial": { gradientType: "radial" },
|
|
131
155
|
"bg-gradient-conic": { gradientType: "conic" },
|
|
156
|
+
transition: { transitionProperty: "default", transitionDuration: 150 },
|
|
157
|
+
"transition-all": { transitionProperty: "all", transitionDuration: 150 },
|
|
158
|
+
"transition-colors": { transitionProperty: "colors", transitionDuration: 150 },
|
|
159
|
+
"transition-opacity": { transitionProperty: "opacity", transitionDuration: 150 },
|
|
160
|
+
"transition-transform": { transitionProperty: "transform", transitionDuration: 150 },
|
|
161
|
+
"transition-none": { transitionProperty: "none" },
|
|
162
|
+
"ease-linear": { transitionEasing: "linear" },
|
|
163
|
+
"ease-in": { transitionEasing: "ease-in" },
|
|
164
|
+
"ease-out": { transitionEasing: "ease-out" },
|
|
165
|
+
"ease-in-out": { transitionEasing: "ease-in-out" },
|
|
166
|
+
"animate-spin": { animationName: "spin", animationDuration: 1000 },
|
|
167
|
+
"animate-pulse": { animationName: "pulse", animationDuration: 2000 },
|
|
168
|
+
"animate-bounce": { animationName: "bounce", animationDuration: 1000 },
|
|
169
|
+
"animate-none": { animationName: "none" },
|
|
132
170
|
};
|
|
133
171
|
|
|
134
172
|
const textSizes: Record<string, number> = {
|
|
@@ -329,6 +367,22 @@ function resolveClass(cls: string): Style | undefined {
|
|
|
329
367
|
const n = Number(rest);
|
|
330
368
|
return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
|
|
331
369
|
}
|
|
370
|
+
case "blur": {
|
|
371
|
+
// Named sizes live in staticClasses; arbitrary is px: blur-[6].
|
|
372
|
+
if (rest.startsWith("[")) {
|
|
373
|
+
const radius = parseLength(rest);
|
|
374
|
+
if (typeof radius === "number") return { blurRadius: radius };
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
case "backdrop": {
|
|
379
|
+
// backdrop-blur-[6] (named backdrop-blur-* sizes are static classes)
|
|
380
|
+
if (rest.startsWith("blur-[")) {
|
|
381
|
+
const radius = parseLength(rest.slice("blur-".length));
|
|
382
|
+
if (typeof radius === "number") return { backdropBlurRadius: radius };
|
|
383
|
+
}
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
332
386
|
case "leading": {
|
|
333
387
|
if (rest.startsWith("[")) {
|
|
334
388
|
const px = parseLength(rest);
|
|
@@ -357,6 +411,19 @@ function resolveClass(cls: string): Style | undefined {
|
|
|
357
411
|
const n = Number(rest);
|
|
358
412
|
return Number.isFinite(n) ? { flex: n } : undefined;
|
|
359
413
|
}
|
|
414
|
+
case "duration": {
|
|
415
|
+
// Milliseconds, literal (duration-150, duration-[320]).
|
|
416
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
417
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
418
|
+
? { transitionDuration: ms }
|
|
419
|
+
: undefined;
|
|
420
|
+
}
|
|
421
|
+
case "delay": {
|
|
422
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
423
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
424
|
+
? { transitionDelay: ms }
|
|
425
|
+
: undefined;
|
|
426
|
+
}
|
|
360
427
|
case "z": {
|
|
361
428
|
const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
362
429
|
return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
|