react-native-ai-animations 0.8.0 → 0.9.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/README.md +124 -72
- package/lib/module/text/streaming-text.js +150 -54
- package/lib/module/text/streaming-text.js.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 +2 -2
- package/src/text/streaming-text.tsx +129 -53
package/README.md
CHANGED
|
@@ -1,72 +1,71 @@
|
|
|
1
1
|
# React Native AI Animations
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/react-native-ai-animations)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
Animated React Native and Expo components for AI chat apps: streaming text,
|
|
4
7
|
shimmer loading text, thinking and status indicators, voice orbs and
|
|
5
8
|
waveforms, image and video generation loaders, question cards and reasoning
|
|
6
9
|
effort bars. Built on Reanimated and Skia, themeable, and reduce motion aware.
|
|
7
10
|
|
|
11
|
+
**Docs and live demos:** [nami-motion-web.vercel.app](https://nami-motion-web.vercel.app/)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
8
15
|
```bash
|
|
9
16
|
npx expo install react-native-ai-animations react-native-reanimated react-native-worklets
|
|
10
17
|
```
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
The orbs, waveforms and media loaders in `react-native-ai-animations/skia` also need Skia:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx expo install @shopify/react-native-skia
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Peer | Version |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `react` | 19.2 or newer |
|
|
28
|
+
| `react-native` | 0.83 or newer |
|
|
29
|
+
| `react-native-reanimated` | 4.2 or newer |
|
|
30
|
+
| `react-native-worklets` | 0.7 or newer |
|
|
31
|
+
| `@shopify/react-native-skia` | 2.4 or newer, optional |
|
|
32
|
+
|
|
33
|
+
> Formerly published as `nami-motion`. Replace `nami-motion` with
|
|
34
|
+
> `react-native-ai-animations` in your imports.
|
|
14
35
|
|
|
15
36
|
## Components
|
|
16
37
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/spinning-mark.gif" width="200" alt="Spinning Mark recording" /><br /><b>Spinning Mark</b><br /><code>react-native-ai-animations/brand</code></td>
|
|
20
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/shimmer-text.gif" width="200" alt="Shimmer Text recording" /><br /><b>Shimmer Text</b><br /><code>react-native-ai-animations/text</code></td>
|
|
21
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/streaming-text.gif" width="200" alt="Streaming Text recording" /><br /><b>Streaming Text</b><br /><code>react-native-ai-animations/text</code></td>
|
|
22
|
-
</tr>
|
|
23
|
-
<tr>
|
|
24
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/status-row.gif" width="200" alt="Status Row recording" /><br /><b>Status Row</b><br /><code>react-native-ai-animations/chat</code></td>
|
|
25
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/thinking-block.gif" width="200" alt="Thinking Block recording" /><br /><b>Thinking Block</b><br /><code>react-native-ai-animations/chat</code></td>
|
|
26
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/question-card.gif" width="200" alt="Question Card recording" /><br /><b>Question Card</b><br /><code>react-native-ai-animations/chat</code></td>
|
|
27
|
-
</tr>
|
|
28
|
-
<tr>
|
|
29
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/effort-bar.gif" width="200" alt="Effort Bar recording" /><br /><b>Effort Bar</b><br /><code>react-native-ai-animations/chat</code></td>
|
|
30
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/media-generating.gif" width="200" alt="Media Generating recording" /><br /><b>Media Generating</b><br /><code>react-native-ai-animations/skia</code></td>
|
|
31
|
-
<td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/voice-wave.gif" width="200" alt="Voice Wave recording" /><br /><b>Voice Wave</b><br /><code>react-native-ai-animations/skia</code></td>
|
|
32
|
-
</tr>
|
|
33
|
-
</table>
|
|
34
|
-
|
|
35
|
-
<table>
|
|
36
|
-
<tr>
|
|
37
|
-
<td align="center" valign="top" width="50%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/shimmer-text.gif" width="160" alt="Shimmer Text recording" /><br /><b>Shimmer Text</b><br /><code>react-native-ai-animations/text</code><br /><a href="https://github.com/AhmeetKaar/nami-motion/raw/main/media/shimmer-text.mp4">10s video</a></td>
|
|
38
|
-
<td align="center" valign="top" width="50%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/voice-wave.gif" width="160" alt="Voice Wave recording" /><br /><b>Voice Wave</b><br /><code>react-native-ai-animations/skia</code><br /><a href="https://github.com/AhmeetKaar/nami-motion/raw/main/media/voice-wave.mp4">10s video</a></td>
|
|
39
|
-
</tr>
|
|
40
|
-
</table>
|
|
38
|
+
Every component and style is shown in motion, with copyable code and agent
|
|
39
|
+
prompts, on the [website](https://nami-motion-web.vercel.app/).
|
|
41
40
|
|
|
42
41
|
| Component | Entry | What it does |
|
|
43
42
|
| --- | --- | --- |
|
|
44
|
-
| Shimmer Text |
|
|
45
|
-
| Streaming Text |
|
|
46
|
-
| Spoken Text |
|
|
47
|
-
| Spinning Mark |
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
43
|
+
| Shimmer Text | `/text` | A light band sweeping across text while a response is being generated. |
|
|
44
|
+
| Streaming Text | `/text` | A streaming answer fades in chunk by chunk with native text layout and a blinking caret. |
|
|
45
|
+
| Spoken Text | `/text` | Highlights the word being spoken and dims the words already read aloud, for voice replies. |
|
|
46
|
+
| Spinning Mark | `/brand` | Spins any icon you pass (your logo) with optional breathing and entrance. |
|
|
47
|
+
| Status Row | `/chat` | Pulsing icon, shimmering label and elapsed timer for agent status. |
|
|
48
|
+
| Thinking Block | `/chat` | "Thought for Ns" header that springs open into a step timeline. |
|
|
49
|
+
| Question Card | `/chat` | Paged questions with numbered options and a custom answer, as a card or a sheet. |
|
|
50
|
+
| Effort Bar | `/chat` | Reasoning effort control wired to `reasoning.effort` (low, medium, high) in four styles: bars, track, segmented and slider. |
|
|
51
|
+
| Media Generating | `/skia` | One loading state for image and video generation in six variants: blobs, matrix, orbit, scan, constellation and helix. |
|
|
52
|
+
| Dot Orb | `/skia` | The dot sphere behind the media variants, also usable on its own. |
|
|
53
|
+
| Matrix Orb | `/skia` | Dot matrix orb with idle, listening and thinking states and per state colors. |
|
|
54
|
+
| Liquid Orb | `/skia` | Voice orb in metal, water, iridescent or ring material that reacts to idle, listening, thinking and speaking and a live level. |
|
|
55
|
+
| Voice Wave | `/skia` | Live microphone waveform in four styles: lines, bars, dots and pulse. |
|
|
56
|
+
|
|
57
|
+
Entries are subpaths of `react-native-ai-animations`. The root export holds
|
|
58
|
+
`/theme` and `/text`; nothing that needs Skia is imported from the root.
|
|
56
59
|
|
|
57
60
|
Every style is also its own component with the same props minus `variant`:
|
|
58
|
-
`VoiceWaveLines`, `VoiceWaveBars`, `VoiceWaveDots` and `VoiceWavePulse`;
|
|
59
|
-
`EffortBars`, `EffortTrack`, `EffortSegmented` and
|
|
60
|
-
`EffortSlider`; `MediaGeneratingBlobs`, `MediaGeneratingMatrix`,
|
|
61
|
-
`MediaGeneratingOrbit`, `MediaGeneratingScan`, `MediaGeneratingConstellation`
|
|
62
|
-
and `MediaGeneratingHelix`; and `QuestionSheet`.
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
`
|
|
62
|
+
- `VoiceWaveLines`, `VoiceWaveBars`, `VoiceWaveDots`, `VoiceWavePulse`
|
|
63
|
+
- `EffortBars`, `EffortTrack`, `EffortSegmented`, `EffortSlider`
|
|
64
|
+
- `MediaGeneratingBlobs`, `MediaGeneratingMatrix`, `MediaGeneratingOrbit`,
|
|
65
|
+
`MediaGeneratingScan`, `MediaGeneratingConstellation`, `MediaGeneratingHelix`
|
|
66
|
+
- `QuestionSheet`
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
## Usage
|
|
70
69
|
|
|
71
70
|
### Shimmer Text
|
|
72
71
|
|
|
@@ -76,16 +75,6 @@ import { ShimmerText } from "react-native-ai-animations/text";
|
|
|
76
75
|
<ShimmerText text="Generating a response" activeColor={colors.text} mutedColor={colors.textTertiary} />;
|
|
77
76
|
```
|
|
78
77
|
|
|
79
|
-
### Spinning Mark
|
|
80
|
-
|
|
81
|
-
```tsx
|
|
82
|
-
import { SpinningMark } from "react-native-ai-animations/brand";
|
|
83
|
-
|
|
84
|
-
<SpinningMark size={160} duration={3600} breathe entrance>
|
|
85
|
-
<Image source={require("./logo.png")} style={{ width: 160, height: 160 }} />
|
|
86
|
-
</SpinningMark>;
|
|
87
|
-
```
|
|
88
|
-
|
|
89
78
|
### Streaming Text, Status Row, Thinking Block
|
|
90
79
|
|
|
91
80
|
```tsx
|
|
@@ -97,20 +86,33 @@ import { StatusRow, ThinkingBlock } from "react-native-ai-animations/chat";
|
|
|
97
86
|
<StreamingText text={answer} streaming={streaming} textStyle={{ fontFamily: "Inter" }} />;
|
|
98
87
|
```
|
|
99
88
|
|
|
100
|
-
###
|
|
89
|
+
### Spinning Mark
|
|
101
90
|
|
|
102
91
|
```tsx
|
|
103
|
-
import {
|
|
92
|
+
import { SpinningMark } from "react-native-ai-animations/brand";
|
|
104
93
|
|
|
105
|
-
<
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
<VideoGenerating status="queued" variant="orbit" />;
|
|
94
|
+
<SpinningMark size={160} duration={3600} breathe entrance>
|
|
95
|
+
<Image source={require("./logo.png")} style={{ width: 160, height: 160 }} />
|
|
96
|
+
</SpinningMark>;
|
|
109
97
|
```
|
|
110
98
|
|
|
99
|
+
### Question Card
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { QuestionCard } from "react-native-ai-animations/chat";
|
|
103
|
+
|
|
104
|
+
<QuestionCard questions={questions} variant="sheet" onSubmit={(answers) => send(answers)} />;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`QuestionCard` and `QuestionSheet` take a `ref` with `select(index)`,
|
|
108
|
+
`goTo(page)` and `submit()`, so a demo, a voice flow or a test can drive the
|
|
109
|
+
questions without touches.
|
|
110
|
+
|
|
111
111
|
### Effort Bar
|
|
112
112
|
|
|
113
|
-
`REASONING_EFFORTS` matches the `reasoning.effort` values OpenRouter and most
|
|
113
|
+
`REASONING_EFFORTS` matches the `reasoning.effort` values OpenRouter and most
|
|
114
|
+
reasoning APIs take (`low`, `medium`, `high`), with each level's share of
|
|
115
|
+
`max_tokens`. `reasoningRequest(effort)` builds the request fields.
|
|
114
116
|
|
|
115
117
|
```tsx
|
|
116
118
|
import { EffortBar, REASONING_EFFORTS, reasoningRequest } from "react-native-ai-animations/chat";
|
|
@@ -123,15 +125,29 @@ import { EffortBar, REASONING_EFFORTS, reasoningRequest } from "react-native-ai-
|
|
|
123
125
|
const body = { model, messages, max_tokens: 16000, ...reasoningRequest(effort) };
|
|
124
126
|
```
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
| Prop | Does |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `variant` | `bars` (default), `track`, `segmented` or `slider` |
|
|
131
|
+
| `size="compact"` | Small inline form for model rows |
|
|
132
|
+
| `fillColors` | Tints the fill of any style |
|
|
133
|
+
| `tabLabel` | Your model name in the slider's tab |
|
|
134
|
+
| `levelIcon` | Replaces every level name with your own icon |
|
|
135
|
+
|
|
136
|
+
EffortBar is only the control: put your own title, level label or description
|
|
137
|
+
around it.
|
|
138
|
+
|
|
139
|
+
### Media Generating
|
|
127
140
|
|
|
128
141
|
```tsx
|
|
129
|
-
import {
|
|
142
|
+
import { ImageGenerating, MediaGenerating, VideoGenerating } from "react-native-ai-animations/skia";
|
|
130
143
|
|
|
131
|
-
<
|
|
144
|
+
<MediaGenerating variant="constellation" label="Creating image" startedAt={startedAt} />;
|
|
145
|
+
<MediaGenerating variant="scan" chrome="overlay" status="generating" progress={0.42} meta="720p · 6s" aspectRatio={16 / 9} />;
|
|
146
|
+
<ImageGenerating variant="helix" startedAt={startedAt} />;
|
|
147
|
+
<VideoGenerating status="queued" variant="orbit" />;
|
|
132
148
|
```
|
|
133
149
|
|
|
134
|
-
|
|
150
|
+
`ImageGenerating` and `VideoGenerating` are presets of `MediaGenerating`.
|
|
135
151
|
|
|
136
152
|
### Voice Wave
|
|
137
153
|
|
|
@@ -144,9 +160,13 @@ const level = useSharedValue(0);
|
|
|
144
160
|
<VoiceWave level={level} variant="bars" colors={["#1F8A70", "#88C9F7", "#5FC7B0"]} />;
|
|
145
161
|
```
|
|
146
162
|
|
|
147
|
-
### Liquid Orb and Spoken Text
|
|
163
|
+
### Liquid Orb and Spoken Text
|
|
148
164
|
|
|
149
|
-
`LiquidOrb`
|
|
165
|
+
`LiquidOrb` comes in `metal`, `water` or `iridescent`, or three glowing loops
|
|
166
|
+
with `ring` (color them with `colors`). `state` switches between `idle`,
|
|
167
|
+
`listening`, `thinking` and `speaking`, and `level` (0 to 1) makes it swell
|
|
168
|
+
while listening or speaking. `SpokenText` highlights the word being read aloud:
|
|
169
|
+
pass the index of the current word as `spoken`.
|
|
150
170
|
|
|
151
171
|
```tsx
|
|
152
172
|
import { useSharedValue } from "react-native-reanimated";
|
|
@@ -160,6 +180,38 @@ const level = useSharedValue(0);
|
|
|
160
180
|
<SpokenText text={reply} spoken={wordIndex} textStyle={{ fontFamily: "Inter", fontSize: 20, lineHeight: 28 }} />;
|
|
161
181
|
```
|
|
162
182
|
|
|
183
|
+
## Theming
|
|
184
|
+
|
|
185
|
+
Components use the built-in Moodstone palette until you wrap your app in
|
|
186
|
+
`MotionThemeProvider`. Pass one palette, or a `light` and `dark` pair with a
|
|
187
|
+
`colorScheme`. Components that read the palette also take `colors` to override
|
|
188
|
+
a single instance; Shimmer Text, Voice Wave, Dot Orb and Matrix Orb take their
|
|
189
|
+
colors directly (`activeColor`, `colors`, `color`).
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
import { useColorScheme } from "react-native";
|
|
193
|
+
import { MotionThemeProvider } from "react-native-ai-animations/theme";
|
|
194
|
+
|
|
195
|
+
<MotionThemeProvider
|
|
196
|
+
colorScheme={useColorScheme() ?? "light"}
|
|
197
|
+
palette={{ light: { accent: "#2F7BF5" }, dark: { accent: "#5AA2FF" } }}
|
|
198
|
+
>
|
|
199
|
+
<App />
|
|
200
|
+
</MotionThemeProvider>;
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Palette keys: `background`, `card`, `surface`, `raised`, `text`,
|
|
204
|
+
`textSecondary`, `textTertiary`, `accent`, `accentContrast`, `success`,
|
|
205
|
+
`danger`. Read the active palette in your own components with
|
|
206
|
+
`useMotionPalette()`.
|
|
207
|
+
|
|
208
|
+
## Customization and accessibility
|
|
209
|
+
|
|
210
|
+
- Icons, fonts, colors and timing are props on every component; nothing is
|
|
211
|
+
tied to a brand or an icon set.
|
|
212
|
+
- When the system's reduce motion setting is on, every component settles to a
|
|
213
|
+
static final state.
|
|
214
|
+
|
|
163
215
|
## Development
|
|
164
216
|
|
|
165
217
|
```bash
|
|
@@ -172,4 +224,4 @@ Changes that affect the package need a changeset: `npm run changeset`.
|
|
|
172
224
|
|
|
173
225
|
## License
|
|
174
226
|
|
|
175
|
-
MIT
|
|
227
|
+
[MIT](LICENSE)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import { isValidElement, memo, useEffect, useMemo } from "react";
|
|
3
|
+
import { isValidElement, memo, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import { StyleSheet, Text, View } from "react-native";
|
|
5
|
-
import Animated, { cancelAnimation,
|
|
5
|
+
import Animated, { cancelAnimation, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated";
|
|
6
|
+
import { withAlpha } from "../internal/color.js";
|
|
6
7
|
import { useMotionPalette } from "../theme/provider.js";
|
|
7
8
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
8
9
|
const DEFAULT_TEXT_STYLE = {
|
|
@@ -10,14 +11,85 @@ const DEFAULT_TEXT_STYLE = {
|
|
|
10
11
|
lineHeight: 24
|
|
11
12
|
};
|
|
12
13
|
const DEFAULT_FADE = 360;
|
|
13
|
-
const
|
|
14
|
+
const FRAME_MS = 32;
|
|
14
15
|
const CARET_BLINK = 530;
|
|
15
|
-
function
|
|
16
|
-
const
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
function easeOut(progress) {
|
|
17
|
+
const clamped = Math.min(1, Math.max(0, progress));
|
|
18
|
+
return 1 - (1 - clamped) ** 3;
|
|
19
|
+
}
|
|
20
|
+
function commonPrefix(a, b) {
|
|
21
|
+
const limit = Math.min(a.length, b.length);
|
|
22
|
+
let index = 0;
|
|
23
|
+
while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) index += 1;
|
|
24
|
+
return index;
|
|
25
|
+
}
|
|
26
|
+
function paragraphRanges(text) {
|
|
27
|
+
const ranges = [];
|
|
28
|
+
const breaks = /\n{2,}/g;
|
|
29
|
+
let start = 0;
|
|
30
|
+
let match = breaks.exec(text);
|
|
31
|
+
while (match) {
|
|
32
|
+
ranges.push({
|
|
33
|
+
start,
|
|
34
|
+
end: match.index
|
|
35
|
+
});
|
|
36
|
+
start = match.index + match[0].length;
|
|
37
|
+
match = breaks.exec(text);
|
|
38
|
+
}
|
|
39
|
+
ranges.push({
|
|
40
|
+
start,
|
|
41
|
+
end: text.length
|
|
42
|
+
});
|
|
43
|
+
return ranges;
|
|
44
|
+
}
|
|
45
|
+
function settledReveal(text) {
|
|
46
|
+
return {
|
|
47
|
+
text,
|
|
48
|
+
settled: text.length,
|
|
49
|
+
chunks: []
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function nextReveal(previous, text, born) {
|
|
53
|
+
if (text === previous.text) return previous;
|
|
54
|
+
const common = commonPrefix(previous.text, text);
|
|
55
|
+
const chunks = previous.chunks.filter(chunk => chunk.start < common).map(chunk => chunk.end > common ? {
|
|
56
|
+
...chunk,
|
|
57
|
+
end: common
|
|
58
|
+
} : chunk);
|
|
59
|
+
if (text.length > common) chunks.push({
|
|
60
|
+
start: common,
|
|
61
|
+
end: text.length,
|
|
62
|
+
born
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
text,
|
|
66
|
+
settled: Math.min(previous.settled, common),
|
|
67
|
+
chunks
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function revealPieces(reveal, length, now, fadeDuration) {
|
|
71
|
+
const pieces = [];
|
|
72
|
+
if (reveal.settled > 0) pieces.push({
|
|
73
|
+
start: 0,
|
|
74
|
+
end: reveal.settled,
|
|
75
|
+
alpha: 1
|
|
76
|
+
});
|
|
77
|
+
let cursor = reveal.settled;
|
|
78
|
+
for (const chunk of reveal.chunks) {
|
|
79
|
+
const end = Math.min(chunk.end, length);
|
|
80
|
+
if (end > cursor) pieces.push({
|
|
81
|
+
start: cursor,
|
|
82
|
+
end,
|
|
83
|
+
alpha: easeOut((now - chunk.born) / fadeDuration)
|
|
84
|
+
});
|
|
85
|
+
cursor = Math.max(cursor, end);
|
|
86
|
+
}
|
|
87
|
+
if (cursor < length) pieces.push({
|
|
88
|
+
start: cursor,
|
|
89
|
+
end: length,
|
|
90
|
+
alpha: 0
|
|
91
|
+
});
|
|
92
|
+
return pieces;
|
|
21
93
|
}
|
|
22
94
|
export function StreamingText({
|
|
23
95
|
text,
|
|
@@ -25,17 +97,53 @@ export function StreamingText({
|
|
|
25
97
|
textStyle = DEFAULT_TEXT_STYLE,
|
|
26
98
|
paragraphGap = 12,
|
|
27
99
|
fadeDuration = DEFAULT_FADE,
|
|
28
|
-
rise = DEFAULT_RISE,
|
|
29
100
|
caret = true,
|
|
101
|
+
selectable = false,
|
|
30
102
|
colors,
|
|
31
103
|
style
|
|
32
104
|
}) {
|
|
33
105
|
const palette = useMotionPalette(colors);
|
|
34
106
|
const reduceMotion = useReducedMotion();
|
|
35
|
-
const
|
|
36
|
-
const
|
|
107
|
+
const animate = !reduceMotion && fadeDuration > 0;
|
|
108
|
+
const [reveal, setReveal] = useState(() => streaming && animate ? settledReveal("") : settledReveal(text));
|
|
109
|
+
const [now, setNow] = useState(() => Date.now());
|
|
110
|
+
const fading = reveal.chunks.length > 0;
|
|
37
111
|
const color = palette.text;
|
|
38
|
-
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!streaming || !animate) {
|
|
114
|
+
setReveal(previous => previous.text === text ? previous : settledReveal(text));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const born = Date.now();
|
|
118
|
+
setNow(born);
|
|
119
|
+
setReveal(previous => nextReveal(previous, text, born));
|
|
120
|
+
}, [text, streaming, animate]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!fading) return;
|
|
123
|
+
const timer = setInterval(() => {
|
|
124
|
+
const time = Date.now();
|
|
125
|
+
setNow(time);
|
|
126
|
+
setReveal(previous => {
|
|
127
|
+
const live = previous.chunks.filter(chunk => time - chunk.born < fadeDuration);
|
|
128
|
+
if (live.length === previous.chunks.length) return previous;
|
|
129
|
+
const done = previous.chunks[previous.chunks.length - live.length - 1];
|
|
130
|
+
return {
|
|
131
|
+
...previous,
|
|
132
|
+
settled: Math.max(previous.settled, done.end),
|
|
133
|
+
chunks: live
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}, FRAME_MS);
|
|
137
|
+
return () => clearInterval(timer);
|
|
138
|
+
}, [fading, fadeDuration]);
|
|
139
|
+
const current = streaming ? nextReveal(reveal, text, Number.POSITIVE_INFINITY) : reveal.text === text ? reveal : settledReveal(text);
|
|
140
|
+
const pieces = animate ? revealPieces(current, text.length, now, fadeDuration) : [{
|
|
141
|
+
start: 0,
|
|
142
|
+
end: text.length,
|
|
143
|
+
alpha: 1
|
|
144
|
+
}];
|
|
145
|
+
const paragraphs = useMemo(() => paragraphRanges(text), [text]);
|
|
146
|
+
const caretNode = !streaming || caret === false ? null : /*#__PURE__*/isValidElement(caret) ? caret : /*#__PURE__*/_jsx(Caret, {
|
|
39
147
|
color: color,
|
|
40
148
|
blink: !reduceMotion,
|
|
41
149
|
textStyle: textStyle
|
|
@@ -44,60 +152,48 @@ export function StreamingText({
|
|
|
44
152
|
style: [{
|
|
45
153
|
gap: paragraphGap
|
|
46
154
|
}, style],
|
|
47
|
-
children: paragraphs.map((
|
|
155
|
+
children: paragraphs.map((range, index) => {
|
|
48
156
|
const last = index === paragraphs.length - 1;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
children: [paragraph, last && streaming && caretNode]
|
|
55
|
-
}, index);
|
|
56
|
-
}
|
|
57
|
-
return /*#__PURE__*/_jsxs(View, {
|
|
58
|
-
accessible: true,
|
|
59
|
-
accessibilityRole: "text",
|
|
60
|
-
accessibilityLabel: paragraph,
|
|
61
|
-
style: {
|
|
62
|
-
flexDirection: "row",
|
|
63
|
-
flexWrap: "wrap",
|
|
64
|
-
alignItems: "flex-end"
|
|
65
|
-
},
|
|
66
|
-
children: [tokenize(paragraph).map(token => /*#__PURE__*/_jsx(StreamingWord, {
|
|
67
|
-
value: token.value,
|
|
157
|
+
const inside = pieces.filter(piece => piece.end > range.start && piece.start < range.end);
|
|
158
|
+
const plain = inside.length <= 1 && (inside[0]?.alpha ?? 1) >= 1;
|
|
159
|
+
if (plain && !last) {
|
|
160
|
+
return /*#__PURE__*/_jsx(PlainParagraph, {
|
|
161
|
+
value: text.slice(range.start, range.end),
|
|
68
162
|
textStyle: textStyle,
|
|
69
163
|
color: color,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
164
|
+
selectable: selectable
|
|
165
|
+
}, index);
|
|
166
|
+
}
|
|
167
|
+
return /*#__PURE__*/_jsxs(Text, {
|
|
168
|
+
style: [textStyle, {
|
|
169
|
+
color
|
|
170
|
+
}],
|
|
171
|
+
selectable: selectable,
|
|
172
|
+
children: [inside.map(piece => {
|
|
173
|
+
const value = text.slice(Math.max(piece.start, range.start), Math.min(piece.end, range.end));
|
|
174
|
+
if (piece.alpha >= 1) return value;
|
|
175
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
176
|
+
style: {
|
|
177
|
+
color: withAlpha(color, piece.alpha)
|
|
178
|
+
},
|
|
179
|
+
children: value
|
|
180
|
+
}, piece.start);
|
|
181
|
+
}), last && caretNode]
|
|
73
182
|
}, index);
|
|
74
183
|
})
|
|
75
184
|
});
|
|
76
185
|
}
|
|
77
|
-
const
|
|
186
|
+
const PlainParagraph = /*#__PURE__*/memo(function PlainParagraph({
|
|
78
187
|
value,
|
|
79
188
|
textStyle,
|
|
80
189
|
color,
|
|
81
|
-
|
|
82
|
-
rise
|
|
190
|
+
selectable
|
|
83
191
|
}) {
|
|
84
|
-
|
|
85
|
-
useEffect(() => {
|
|
86
|
-
progress.value = withTiming(1, {
|
|
87
|
-
duration: fadeDuration,
|
|
88
|
-
easing: Easing.out(Easing.cubic)
|
|
89
|
-
});
|
|
90
|
-
}, [fadeDuration, progress]);
|
|
91
|
-
const animatedStyle = useAnimatedStyle(() => ({
|
|
92
|
-
opacity: progress.value,
|
|
93
|
-
transform: [{
|
|
94
|
-
translateY: rise * (1 - progress.value)
|
|
95
|
-
}]
|
|
96
|
-
}));
|
|
97
|
-
return /*#__PURE__*/_jsx(Animated.Text, {
|
|
192
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
98
193
|
style: [textStyle, {
|
|
99
194
|
color
|
|
100
|
-
}
|
|
195
|
+
}],
|
|
196
|
+
selectable: selectable,
|
|
101
197
|
children: value
|
|
102
198
|
});
|
|
103
199
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["isValidElement","memo","useEffect","useMemo","StyleSheet","Text","View","Animated","cancelAnimation","
|
|
1
|
+
{"version":3,"names":["isValidElement","memo","useEffect","useMemo","useState","StyleSheet","Text","View","Animated","cancelAnimation","useAnimatedStyle","useReducedMotion","useSharedValue","withRepeat","withSequence","withTiming","withAlpha","useMotionPalette","jsx","_jsx","jsxs","_jsxs","DEFAULT_TEXT_STYLE","fontSize","lineHeight","DEFAULT_FADE","FRAME_MS","CARET_BLINK","easeOut","progress","clamped","Math","min","max","commonPrefix","a","b","limit","length","index","charCodeAt","paragraphRanges","text","ranges","breaks","start","match","exec","push","end","settledReveal","settled","chunks","nextReveal","previous","born","common","filter","chunk","map","revealPieces","reveal","now","fadeDuration","pieces","alpha","cursor","StreamingText","streaming","textStyle","paragraphGap","caret","selectable","colors","style","palette","reduceMotion","animate","setReveal","setNow","Date","fading","color","timer","setInterval","time","live","done","clearInterval","current","Number","POSITIVE_INFINITY","paragraphs","caretNode","Caret","blink","gap","children","range","last","inside","piece","plain","PlainParagraph","value","slice","opacity","flat","flatten","height","round","duration","animatedStyle","width","borderRadius","backgroundColor","marginLeft","marginBottom"],"sourceRoot":"../../../src","sources":["text/streaming-text.tsx"],"mappings":"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","ignoreList":[]}
|
|
@@ -7,10 +7,10 @@ export type StreamingTextProps = {
|
|
|
7
7
|
textStyle?: StyleProp<TextStyle>;
|
|
8
8
|
paragraphGap?: number;
|
|
9
9
|
fadeDuration?: number;
|
|
10
|
-
rise?: number;
|
|
11
10
|
caret?: boolean | ReactNode;
|
|
11
|
+
selectable?: boolean;
|
|
12
12
|
colors?: Partial<MotionPalette>;
|
|
13
13
|
style?: StyleProp<ViewStyle>;
|
|
14
14
|
};
|
|
15
|
-
export declare function StreamingText({ text, streaming, textStyle, paragraphGap, fadeDuration,
|
|
15
|
+
export declare function StreamingText({ text, streaming, textStyle, paragraphGap, fadeDuration, caret, selectable, colors, style, }: StreamingTextProps): import("react").JSX.Element;
|
|
16
16
|
//# sourceMappingURL=streaming-text.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"streaming-text.d.ts","sourceRoot":"","sources":["../../../../src/text/streaming-text.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"streaming-text.d.ts","sourceRoot":"","sources":["../../../../src/text/streaming-text.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC3F,OAAO,EAA0B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAWtG,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAkB,CAAC;AAQtD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAChC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC;AA+DF,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,SAAS,EACT,SAA8B,EAC9B,YAAiB,EACjB,YAA2B,EAC3B,KAAY,EACZ,UAAkB,EAClB,MAAM,EACN,KAAK,GACN,EAAE,kBAAkB,+BA0EpB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-ai-animations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Animated React Native and Expo components for AI chat apps: streaming text, shimmer loading text, thinking and status indicators, voice orbs and waveforms, image and video generation loaders, question cards and reasoning effort bars. Built on Reanimated and Skia.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ahmet Kar",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/AhmeetKaar/nami-motion.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://nami-motion-web.vercel.app/",
|
|
12
12
|
"keywords": [
|
|
13
13
|
"react-native",
|
|
14
14
|
"expo",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { isValidElement, memo, useEffect, useMemo, type ReactNode } from "react";
|
|
1
|
+
import { isValidElement, memo, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import { StyleSheet, Text, View, 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,
|
|
@@ -10,12 +9,13 @@ import Animated, {
|
|
|
10
9
|
withSequence,
|
|
11
10
|
withTiming,
|
|
12
11
|
} from "react-native-reanimated";
|
|
12
|
+
import { withAlpha } from "../internal/color";
|
|
13
13
|
import { type MotionPalette } from "../theme/palette";
|
|
14
14
|
import { useMotionPalette } from "../theme/provider";
|
|
15
15
|
|
|
16
16
|
const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 16, lineHeight: 24 };
|
|
17
17
|
const DEFAULT_FADE = 360;
|
|
18
|
-
const
|
|
18
|
+
const FRAME_MS = 32;
|
|
19
19
|
const CARET_BLINK = 530;
|
|
20
20
|
|
|
21
21
|
export type StreamingTextProps = {
|
|
@@ -24,17 +24,71 @@ export type StreamingTextProps = {
|
|
|
24
24
|
textStyle?: StyleProp<TextStyle>;
|
|
25
25
|
paragraphGap?: number;
|
|
26
26
|
fadeDuration?: number;
|
|
27
|
-
rise?: number;
|
|
28
27
|
caret?: boolean | ReactNode;
|
|
28
|
+
selectable?: boolean;
|
|
29
29
|
colors?: Partial<MotionPalette>;
|
|
30
30
|
style?: StyleProp<ViewStyle>;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
type
|
|
33
|
+
type Chunk = { start: number; end: number; born: number };
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
type Reveal = { text: string; settled: number; chunks: Chunk[] };
|
|
36
|
+
|
|
37
|
+
type Range = { start: number; end: number };
|
|
38
|
+
|
|
39
|
+
type Piece = Range & { alpha: number };
|
|
40
|
+
|
|
41
|
+
function easeOut(progress: number) {
|
|
42
|
+
const clamped = Math.min(1, Math.max(0, progress));
|
|
43
|
+
return 1 - (1 - clamped) ** 3;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commonPrefix(a: string, b: string) {
|
|
47
|
+
const limit = Math.min(a.length, b.length);
|
|
48
|
+
let index = 0;
|
|
49
|
+
while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) index += 1;
|
|
50
|
+
return index;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function paragraphRanges(text: string): Range[] {
|
|
54
|
+
const ranges: Range[] = [];
|
|
55
|
+
const breaks = /\n{2,}/g;
|
|
56
|
+
let start = 0;
|
|
57
|
+
let match = breaks.exec(text);
|
|
58
|
+
while (match) {
|
|
59
|
+
ranges.push({ start, end: match.index });
|
|
60
|
+
start = match.index + match[0].length;
|
|
61
|
+
match = breaks.exec(text);
|
|
62
|
+
}
|
|
63
|
+
ranges.push({ start, end: text.length });
|
|
64
|
+
return ranges;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function settledReveal(text: string): Reveal {
|
|
68
|
+
return { text, settled: text.length, chunks: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nextReveal(previous: Reveal, text: string, born: number): Reveal {
|
|
72
|
+
if (text === previous.text) return previous;
|
|
73
|
+
const common = commonPrefix(previous.text, text);
|
|
74
|
+
const chunks = previous.chunks
|
|
75
|
+
.filter((chunk) => chunk.start < common)
|
|
76
|
+
.map((chunk) => (chunk.end > common ? { ...chunk, end: common } : chunk));
|
|
77
|
+
if (text.length > common) chunks.push({ start: common, end: text.length, born });
|
|
78
|
+
return { text, settled: Math.min(previous.settled, common), chunks };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function revealPieces(reveal: Reveal, length: number, now: number, fadeDuration: number): Piece[] {
|
|
82
|
+
const pieces: Piece[] = [];
|
|
83
|
+
if (reveal.settled > 0) pieces.push({ start: 0, end: reveal.settled, alpha: 1 });
|
|
84
|
+
let cursor = reveal.settled;
|
|
85
|
+
for (const chunk of reveal.chunks) {
|
|
86
|
+
const end = Math.min(chunk.end, length);
|
|
87
|
+
if (end > cursor) pieces.push({ start: cursor, end, alpha: easeOut((now - chunk.born) / fadeDuration) });
|
|
88
|
+
cursor = Math.max(cursor, end);
|
|
89
|
+
}
|
|
90
|
+
if (cursor < length) pieces.push({ start: cursor, end: length, alpha: 0 });
|
|
91
|
+
return pieces;
|
|
38
92
|
}
|
|
39
93
|
|
|
40
94
|
export function StreamingText({
|
|
@@ -43,77 +97,99 @@ export function StreamingText({
|
|
|
43
97
|
textStyle = DEFAULT_TEXT_STYLE,
|
|
44
98
|
paragraphGap = 12,
|
|
45
99
|
fadeDuration = DEFAULT_FADE,
|
|
46
|
-
rise = DEFAULT_RISE,
|
|
47
100
|
caret = true,
|
|
101
|
+
selectable = false,
|
|
48
102
|
colors,
|
|
49
103
|
style,
|
|
50
104
|
}: StreamingTextProps) {
|
|
51
105
|
const palette = useMotionPalette(colors);
|
|
52
106
|
const reduceMotion = useReducedMotion();
|
|
53
|
-
const
|
|
54
|
-
const
|
|
107
|
+
const animate = !reduceMotion && fadeDuration > 0;
|
|
108
|
+
const [reveal, setReveal] = useState<Reveal>(() => (streaming && animate ? settledReveal("") : settledReveal(text)));
|
|
109
|
+
const [now, setNow] = useState(() => Date.now());
|
|
110
|
+
const fading = reveal.chunks.length > 0;
|
|
55
111
|
const color = palette.text;
|
|
56
|
-
|
|
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={{ color: withAlpha(color, 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
183
|
color: string;
|
|
100
|
-
|
|
101
|
-
rise: number;
|
|
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
195
|
function Caret({ color, blink, textStyle }: { color: string; blink: boolean; textStyle: StyleProp<TextStyle> }) {
|