react-native-ai-animations 0.8.1 → 0.9.1
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/README.md +3 -1
- package/lib/module/chat/effort-bar.js +38 -27
- package/lib/module/chat/effort-bar.js.map +1 -1
- package/lib/module/chat/question-card.js +1 -1
- package/lib/module/chat/question-card.js.map +1 -1
- package/lib/module/chat/status-row.js +6 -4
- package/lib/module/chat/status-row.js.map +1 -1
- package/lib/module/chat/thinking-block.js +3 -2
- package/lib/module/chat/thinking-block.js.map +1 -1
- package/lib/module/internal/color.js +24 -9
- package/lib/module/internal/color.js.map +1 -1
- package/lib/module/internal/graphemes.js +38 -0
- package/lib/module/internal/graphemes.js.map +1 -0
- package/lib/module/internal/use-elapsed.js +2 -1
- package/lib/module/internal/use-elapsed.js.map +1 -1
- package/lib/module/internal/use-motion-clock.js +11 -0
- package/lib/module/internal/use-motion-clock.js.map +1 -0
- package/lib/module/skia/dot-orb.js +5 -4
- package/lib/module/skia/dot-orb.js.map +1 -1
- package/lib/module/skia/matrix-orb.js +6 -3
- package/lib/module/skia/matrix-orb.js.map +1 -1
- package/lib/module/skia/media-generating.js +4 -3
- package/lib/module/skia/media-generating.js.map +1 -1
- package/lib/module/skia/voice-wave.js +8 -3
- package/lib/module/skia/voice-wave.js.map +1 -1
- package/lib/module/text/shimmer-text.js +22 -3
- package/lib/module/text/shimmer-text.js.map +1 -1
- package/lib/module/text/spoken-text.js +6 -1
- package/lib/module/text/spoken-text.js.map +1 -1
- package/lib/module/text/streaming-text.js +151 -55
- package/lib/module/text/streaming-text.js.map +1 -1
- package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -1
- package/lib/typescript/src/chat/status-row.d.ts +2 -1
- package/lib/typescript/src/chat/status-row.d.ts.map +1 -1
- package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -1
- package/lib/typescript/src/internal/color.d.ts +4 -0
- package/lib/typescript/src/internal/color.d.ts.map +1 -1
- package/lib/typescript/src/internal/graphemes.d.ts +4 -0
- package/lib/typescript/src/internal/graphemes.d.ts.map +1 -0
- package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -1
- package/lib/typescript/src/internal/use-motion-clock.d.ts +3 -0
- package/lib/typescript/src/internal/use-motion-clock.d.ts.map +1 -0
- package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -1
- package/lib/typescript/src/skia/matrix-orb.d.ts +3 -1
- package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -1
- package/lib/typescript/src/skia/media-generating.d.ts.map +1 -1
- package/lib/typescript/src/skia/voice-wave.d.ts +2 -2
- package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -1
- package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -1
- package/lib/typescript/src/text/spoken-text.d.ts.map +1 -1
- package/lib/typescript/src/text/streaming-text.d.ts +2 -2
- package/lib/typescript/src/text/streaming-text.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/chat/effort-bar.tsx +40 -27
- package/src/chat/question-card.tsx +1 -1
- package/src/chat/status-row.tsx +7 -4
- package/src/chat/thinking-block.tsx +3 -2
- package/src/internal/color.ts +30 -15
- package/src/internal/graphemes.ts +40 -0
- package/src/internal/use-elapsed.ts +2 -1
- package/src/internal/use-motion-clock.ts +9 -0
- package/src/skia/dot-orb.tsx +5 -4
- package/src/skia/matrix-orb.tsx +7 -2
- package/src/skia/media-generating.tsx +3 -3
- package/src/skia/voice-wave.tsx +9 -4
- package/src/text/shimmer-text.tsx +25 -3
- package/src/text/spoken-text.tsx +5 -1
- package/src/text/streaming-text.tsx +133 -57
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { isValidElement, memo, useEffect, useMemo, type ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
1
|
+
import { isValidElement, memo, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, Text, View, type ColorValue, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
3
3
|
import Animated, {
|
|
4
4
|
cancelAnimation,
|
|
5
|
-
Easing,
|
|
6
5
|
useAnimatedStyle,
|
|
7
6
|
useReducedMotion,
|
|
8
7
|
useSharedValue,
|
|
@@ -15,7 +14,7 @@ import { useMotionPalette } from "../theme/provider";
|
|
|
15
14
|
|
|
16
15
|
const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 16, lineHeight: 24 };
|
|
17
16
|
const DEFAULT_FADE = 360;
|
|
18
|
-
const
|
|
17
|
+
const FRAME_MS = 32;
|
|
19
18
|
const CARET_BLINK = 530;
|
|
20
19
|
|
|
21
20
|
export type StreamingTextProps = {
|
|
@@ -24,17 +23,71 @@ export type StreamingTextProps = {
|
|
|
24
23
|
textStyle?: StyleProp<TextStyle>;
|
|
25
24
|
paragraphGap?: number;
|
|
26
25
|
fadeDuration?: number;
|
|
27
|
-
rise?: number;
|
|
28
26
|
caret?: boolean | ReactNode;
|
|
27
|
+
selectable?: boolean;
|
|
29
28
|
colors?: Partial<MotionPalette>;
|
|
30
29
|
style?: StyleProp<ViewStyle>;
|
|
31
30
|
};
|
|
32
31
|
|
|
33
|
-
type
|
|
32
|
+
type Chunk = { start: number; end: number; born: number };
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
type Reveal = { text: string; settled: number; chunks: Chunk[] };
|
|
35
|
+
|
|
36
|
+
type Range = { start: number; end: number };
|
|
37
|
+
|
|
38
|
+
type Piece = Range & { alpha: number };
|
|
39
|
+
|
|
40
|
+
function easeOut(progress: number) {
|
|
41
|
+
const clamped = Math.min(1, Math.max(0, progress));
|
|
42
|
+
return 1 - (1 - clamped) ** 3;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commonPrefix(a: string, b: string) {
|
|
46
|
+
const limit = Math.min(a.length, b.length);
|
|
47
|
+
let index = 0;
|
|
48
|
+
while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) index += 1;
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function paragraphRanges(text: string): Range[] {
|
|
53
|
+
const ranges: Range[] = [];
|
|
54
|
+
const breaks = /\n{2,}/g;
|
|
55
|
+
let start = 0;
|
|
56
|
+
let match = breaks.exec(text);
|
|
57
|
+
while (match) {
|
|
58
|
+
ranges.push({ start, end: match.index });
|
|
59
|
+
start = match.index + match[0].length;
|
|
60
|
+
match = breaks.exec(text);
|
|
61
|
+
}
|
|
62
|
+
ranges.push({ start, end: text.length });
|
|
63
|
+
return ranges;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function settledReveal(text: string): Reveal {
|
|
67
|
+
return { text, settled: text.length, chunks: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nextReveal(previous: Reveal, text: string, born: number): Reveal {
|
|
71
|
+
if (text === previous.text) return previous;
|
|
72
|
+
const common = commonPrefix(previous.text, text);
|
|
73
|
+
const chunks = previous.chunks
|
|
74
|
+
.filter((chunk) => chunk.start < common)
|
|
75
|
+
.map((chunk) => (chunk.end > common ? { ...chunk, end: common } : chunk));
|
|
76
|
+
if (text.length > common) chunks.push({ start: common, end: text.length, born });
|
|
77
|
+
return { text, settled: Math.min(previous.settled, common), chunks };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function revealPieces(reveal: Reveal, length: number, now: number, fadeDuration: number): Piece[] {
|
|
81
|
+
const pieces: Piece[] = [];
|
|
82
|
+
if (reveal.settled > 0) pieces.push({ start: 0, end: reveal.settled, alpha: 1 });
|
|
83
|
+
let cursor = reveal.settled;
|
|
84
|
+
for (const chunk of reveal.chunks) {
|
|
85
|
+
const end = Math.min(chunk.end, length);
|
|
86
|
+
if (end > cursor) pieces.push({ start: cursor, end, alpha: easeOut((now - chunk.born) / fadeDuration) });
|
|
87
|
+
cursor = Math.max(cursor, end);
|
|
88
|
+
}
|
|
89
|
+
if (cursor < length) pieces.push({ start: cursor, end: length, alpha: 0 });
|
|
90
|
+
return pieces;
|
|
38
91
|
}
|
|
39
92
|
|
|
40
93
|
export function StreamingText({
|
|
@@ -43,80 +96,103 @@ export function StreamingText({
|
|
|
43
96
|
textStyle = DEFAULT_TEXT_STYLE,
|
|
44
97
|
paragraphGap = 12,
|
|
45
98
|
fadeDuration = DEFAULT_FADE,
|
|
46
|
-
rise = DEFAULT_RISE,
|
|
47
99
|
caret = true,
|
|
100
|
+
selectable = false,
|
|
48
101
|
colors,
|
|
49
102
|
style,
|
|
50
103
|
}: StreamingTextProps) {
|
|
51
104
|
const palette = useMotionPalette(colors);
|
|
52
105
|
const reduceMotion = useReducedMotion();
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
106
|
+
const animate = !reduceMotion && fadeDuration > 0;
|
|
107
|
+
const [reveal, setReveal] = useState<Reveal>(() => (streaming && animate ? settledReveal("") : settledReveal(text)));
|
|
108
|
+
const [now, setNow] = useState(() => Date.now());
|
|
109
|
+
const fading = reveal.chunks.length > 0;
|
|
110
|
+
const flatStyle = StyleSheet.flatten(textStyle);
|
|
111
|
+
const color = flatStyle?.color ?? palette.text;
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!streaming || !animate) {
|
|
115
|
+
setReveal((previous) => (previous.text === text ? previous : settledReveal(text)));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const born = Date.now();
|
|
119
|
+
setNow(born);
|
|
120
|
+
setReveal((previous) => nextReveal(previous, text, born));
|
|
121
|
+
}, [text, streaming, animate]);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!fading) return;
|
|
125
|
+
const timer = setInterval(() => {
|
|
126
|
+
const time = Date.now();
|
|
127
|
+
setNow(time);
|
|
128
|
+
setReveal((previous) => {
|
|
129
|
+
const live = previous.chunks.filter((chunk) => time - chunk.born < fadeDuration);
|
|
130
|
+
if (live.length === previous.chunks.length) return previous;
|
|
131
|
+
const done = previous.chunks[previous.chunks.length - live.length - 1];
|
|
132
|
+
return { ...previous, settled: Math.max(previous.settled, done.end), chunks: live };
|
|
133
|
+
});
|
|
134
|
+
}, FRAME_MS);
|
|
135
|
+
return () => clearInterval(timer);
|
|
136
|
+
}, [fading, fadeDuration]);
|
|
137
|
+
|
|
138
|
+
const current = streaming ? nextReveal(reveal, text, Number.POSITIVE_INFINITY) : reveal.text === text ? reveal : settledReveal(text);
|
|
139
|
+
const pieces = animate ? revealPieces(current, text.length, now, fadeDuration) : [{ start: 0, end: text.length, alpha: 1 }];
|
|
140
|
+
const paragraphs = useMemo(() => paragraphRanges(text), [text]);
|
|
141
|
+
const caretNode =
|
|
142
|
+
!streaming || caret === false ? null : isValidElement(caret) ? caret : <Caret color={color} blink={!reduceMotion} textStyle={textStyle} />;
|
|
57
143
|
|
|
58
144
|
return (
|
|
59
145
|
<View style={[{ gap: paragraphGap }, style]}>
|
|
60
|
-
{paragraphs.map((
|
|
146
|
+
{paragraphs.map((range, index) => {
|
|
61
147
|
const last = index === paragraphs.length - 1;
|
|
62
|
-
|
|
148
|
+
const inside = pieces.filter((piece) => piece.end > range.start && piece.start < range.end);
|
|
149
|
+
const plain = inside.length <= 1 && (inside[0]?.alpha ?? 1) >= 1;
|
|
150
|
+
if (plain && !last) {
|
|
63
151
|
return (
|
|
64
|
-
<
|
|
65
|
-
{
|
|
66
|
-
{
|
|
67
|
-
|
|
152
|
+
<PlainParagraph
|
|
153
|
+
key={index}
|
|
154
|
+
value={text.slice(range.start, range.end)}
|
|
155
|
+
textStyle={textStyle}
|
|
156
|
+
color={color}
|
|
157
|
+
selectable={selectable}
|
|
158
|
+
/>
|
|
68
159
|
);
|
|
69
160
|
}
|
|
70
161
|
return (
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
color={color}
|
|
84
|
-
fadeDuration={fadeDuration}
|
|
85
|
-
rise={rise}
|
|
86
|
-
/>
|
|
87
|
-
))}
|
|
88
|
-
{caretNode}
|
|
89
|
-
</View>
|
|
162
|
+
<Text key={index} style={[textStyle, { color }]} selectable={selectable}>
|
|
163
|
+
{inside.map((piece) => {
|
|
164
|
+
const value = text.slice(Math.max(piece.start, range.start), Math.min(piece.end, range.end));
|
|
165
|
+
if (piece.alpha >= 1) return value;
|
|
166
|
+
return (
|
|
167
|
+
<Text key={piece.start} style={{ opacity: piece.alpha }}>
|
|
168
|
+
{value}
|
|
169
|
+
</Text>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
{last && caretNode}
|
|
173
|
+
</Text>
|
|
90
174
|
);
|
|
91
175
|
})}
|
|
92
176
|
</View>
|
|
93
177
|
);
|
|
94
178
|
}
|
|
95
179
|
|
|
96
|
-
type
|
|
180
|
+
type PlainParagraphProps = {
|
|
97
181
|
value: string;
|
|
98
182
|
textStyle: StyleProp<TextStyle>;
|
|
99
|
-
color:
|
|
100
|
-
|
|
101
|
-
rise: number;
|
|
183
|
+
color: ColorValue;
|
|
184
|
+
selectable: boolean;
|
|
102
185
|
};
|
|
103
186
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const animatedStyle = useAnimatedStyle(() => ({
|
|
112
|
-
opacity: progress.value,
|
|
113
|
-
transform: [{ translateY: rise * (1 - progress.value) }],
|
|
114
|
-
}));
|
|
115
|
-
|
|
116
|
-
return <Animated.Text style={[textStyle, { color }, animatedStyle]}>{value}</Animated.Text>;
|
|
187
|
+
const PlainParagraph = memo(function PlainParagraph({ value, textStyle, color, selectable }: PlainParagraphProps) {
|
|
188
|
+
return (
|
|
189
|
+
<Text style={[textStyle, { color }]} selectable={selectable}>
|
|
190
|
+
{value}
|
|
191
|
+
</Text>
|
|
192
|
+
);
|
|
117
193
|
});
|
|
118
194
|
|
|
119
|
-
function Caret({ color, blink, textStyle }: { color:
|
|
195
|
+
function Caret({ color, blink, textStyle }: { color: ColorValue; blink: boolean; textStyle: StyleProp<TextStyle> }) {
|
|
120
196
|
const opacity = useSharedValue(1);
|
|
121
197
|
const flat = StyleSheet.flatten(textStyle);
|
|
122
198
|
const height = Math.round((flat?.fontSize ?? 16) * 1.05);
|