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.
Files changed (68) hide show
  1. package/README.md +3 -1
  2. package/lib/module/chat/effort-bar.js +38 -27
  3. package/lib/module/chat/effort-bar.js.map +1 -1
  4. package/lib/module/chat/question-card.js +1 -1
  5. package/lib/module/chat/question-card.js.map +1 -1
  6. package/lib/module/chat/status-row.js +6 -4
  7. package/lib/module/chat/status-row.js.map +1 -1
  8. package/lib/module/chat/thinking-block.js +3 -2
  9. package/lib/module/chat/thinking-block.js.map +1 -1
  10. package/lib/module/internal/color.js +24 -9
  11. package/lib/module/internal/color.js.map +1 -1
  12. package/lib/module/internal/graphemes.js +38 -0
  13. package/lib/module/internal/graphemes.js.map +1 -0
  14. package/lib/module/internal/use-elapsed.js +2 -1
  15. package/lib/module/internal/use-elapsed.js.map +1 -1
  16. package/lib/module/internal/use-motion-clock.js +11 -0
  17. package/lib/module/internal/use-motion-clock.js.map +1 -0
  18. package/lib/module/skia/dot-orb.js +5 -4
  19. package/lib/module/skia/dot-orb.js.map +1 -1
  20. package/lib/module/skia/matrix-orb.js +6 -3
  21. package/lib/module/skia/matrix-orb.js.map +1 -1
  22. package/lib/module/skia/media-generating.js +4 -3
  23. package/lib/module/skia/media-generating.js.map +1 -1
  24. package/lib/module/skia/voice-wave.js +8 -3
  25. package/lib/module/skia/voice-wave.js.map +1 -1
  26. package/lib/module/text/shimmer-text.js +22 -3
  27. package/lib/module/text/shimmer-text.js.map +1 -1
  28. package/lib/module/text/spoken-text.js +6 -1
  29. package/lib/module/text/spoken-text.js.map +1 -1
  30. package/lib/module/text/streaming-text.js +151 -55
  31. package/lib/module/text/streaming-text.js.map +1 -1
  32. package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -1
  33. package/lib/typescript/src/chat/status-row.d.ts +2 -1
  34. package/lib/typescript/src/chat/status-row.d.ts.map +1 -1
  35. package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -1
  36. package/lib/typescript/src/internal/color.d.ts +4 -0
  37. package/lib/typescript/src/internal/color.d.ts.map +1 -1
  38. package/lib/typescript/src/internal/graphemes.d.ts +4 -0
  39. package/lib/typescript/src/internal/graphemes.d.ts.map +1 -0
  40. package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -1
  41. package/lib/typescript/src/internal/use-motion-clock.d.ts +3 -0
  42. package/lib/typescript/src/internal/use-motion-clock.d.ts.map +1 -0
  43. package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -1
  44. package/lib/typescript/src/skia/matrix-orb.d.ts +3 -1
  45. package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -1
  46. package/lib/typescript/src/skia/media-generating.d.ts.map +1 -1
  47. package/lib/typescript/src/skia/voice-wave.d.ts +2 -2
  48. package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -1
  49. package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -1
  50. package/lib/typescript/src/text/spoken-text.d.ts.map +1 -1
  51. package/lib/typescript/src/text/streaming-text.d.ts +2 -2
  52. package/lib/typescript/src/text/streaming-text.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/chat/effort-bar.tsx +40 -27
  55. package/src/chat/question-card.tsx +1 -1
  56. package/src/chat/status-row.tsx +7 -4
  57. package/src/chat/thinking-block.tsx +3 -2
  58. package/src/internal/color.ts +30 -15
  59. package/src/internal/graphemes.ts +40 -0
  60. package/src/internal/use-elapsed.ts +2 -1
  61. package/src/internal/use-motion-clock.ts +9 -0
  62. package/src/skia/dot-orb.tsx +5 -4
  63. package/src/skia/matrix-orb.tsx +7 -2
  64. package/src/skia/media-generating.tsx +3 -3
  65. package/src/skia/voice-wave.tsx +9 -4
  66. package/src/text/shimmer-text.tsx +25 -3
  67. package/src/text/spoken-text.tsx +5 -1
  68. 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 DEFAULT_RISE = 6;
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 Token = { value: string; key: number };
32
+ type Chunk = { start: number; end: number; born: number };
34
33
 
35
- function tokenize(paragraph: string): Token[] {
36
- const parts = paragraph.match(/\S+\s*/g) ?? [];
37
- return parts.map((value, key) => ({ value, key }));
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 paragraphs = useMemo(() => text.split(/\n{2,}/), [text]);
54
- const animate = streaming && !reduceMotion;
55
- const color = palette.text;
56
- const caretNode = caret === false ? null : isValidElement(caret) ? caret : <Caret color={color} blink={!reduceMotion} textStyle={textStyle} />;
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((paragraph, index) => {
146
+ {paragraphs.map((range, index) => {
61
147
  const last = index === paragraphs.length - 1;
62
- if (!animate || !last) {
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
- <Text key={index} style={[textStyle, { color }]}>
65
- {paragraph}
66
- {last && streaming && caretNode}
67
- </Text>
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
- <View
72
- key={index}
73
- accessible
74
- accessibilityRole="text"
75
- accessibilityLabel={paragraph}
76
- style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "flex-end" }}
77
- >
78
- {tokenize(paragraph).map((token) => (
79
- <StreamingWord
80
- key={token.key}
81
- value={token.value}
82
- textStyle={textStyle}
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 StreamingWordProps = {
180
+ type PlainParagraphProps = {
97
181
  value: string;
98
182
  textStyle: StyleProp<TextStyle>;
99
- color: string;
100
- fadeDuration: number;
101
- rise: number;
183
+ color: ColorValue;
184
+ selectable: boolean;
102
185
  };
103
186
 
104
- const StreamingWord = memo(function StreamingWord({ value, textStyle, color, fadeDuration, rise }: StreamingWordProps) {
105
- const progress = useSharedValue(0);
106
-
107
- useEffect(() => {
108
- progress.value = withTiming(1, { duration: fadeDuration, easing: Easing.out(Easing.cubic) });
109
- }, [fadeDuration, progress]);
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: string; blink: boolean; textStyle: StyleProp<TextStyle> }) {
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);