enriched-text-input 1.0.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/.babelrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "presets": ["module:metro-react-native-babel-preset"]
3
+ }
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ [![plastic](https://dcbadge.limes.pink/api/server/https://discord.gg/DRmNp34bFE?bot=true&style=plastic)](https://discord.gg/DRmNp34bFE)
2
+
3
+ # enriched-text-input
4
+
5
+ > [!Note]
6
+ > This library is still a work in progress. Expect breaking changes.
7
+
8
+ Proof of concept for a JavaScript only rich-text TextInput component for React Native.
9
+ The main idea is to render `<Text>` views as children of `<TextInput>`.
10
+ It will only support text styling since it's not possible to render images inside `Text` views in React Native.
11
+
12
+ ## Motivation
13
+ The field for rich-text in react native is still a bit green. Current libraries that add support for rich-text in react native applications are either WebViews wrapping libraries for the web, limiting customization, or require native code which drops support for Expo Go and react-native-web.
14
+
15
+ In theory, by only using JavaScript we are able to provide better cross-platform compatibility and the possibility to style however you want elements like links, mentions, bold, italic, unerline text and more.
16
+
17
+ ## Features
18
+
19
+ - [x] Basic text formatting (*bold*, _italic_, __underline__, ~~strikethrough~~).
20
+ - [x] Rich text format parsing.
21
+ - [ ] Links and mentions.
22
+ - [ ] Custom styling.
23
+ - [ ] Custom rich text patterns.
24
+ - [ ] Exposed event handlers (onSubmit, onChange, onBlur, onFocus, etc).
25
+ - [ ] Custom methods and event handlers (setValue, onStartMention, onStyleChange, etc).
26
+ - [ ] Headings.
27
+
28
+ ## Known limitations
29
+ - Inline images.
30
+
31
+ ## Contributing
32
+
33
+ ### Clone this repo
34
+
35
+ 1. Fork and clone your Github froked repo:
36
+ ```
37
+ git clone https://github.com/<github_username>/react-native-rich-text.git
38
+ ```
39
+
40
+ 2. Go to cloned repo directory:
41
+ ```
42
+ cd react-native-rich-text
43
+ ```
44
+
45
+ ### Install dependencies
46
+
47
+ 1. Install the dependencies in the root of the repo:
48
+ ```
49
+ npm install
50
+ ```
51
+
52
+ 3. After that you can start the project with:
53
+ ```
54
+ npm start
55
+ ```
56
+
57
+ ## Create a pull request
58
+ After making any changes, open a pull request. Once you submit your pull request, it will get reviewed.
@@ -0,0 +1,25 @@
1
+ import { useRef, useState } from 'react';
2
+ import { StatusBar } from 'expo-status-bar';
3
+ import { StyleSheet, Text, View, Button } from 'react-native';
4
+
5
+ import { RichTextInput, Toolbar } from 'enriched-text-input';
6
+
7
+ export default function App() {
8
+ const richTextInputRef = useRef(null);
9
+
10
+ return (
11
+ <View style={styles.container}>
12
+ <RichTextInput ref={richTextInputRef}/>
13
+ <Toolbar richTextInputRef={richTextInputRef} />
14
+ <StatusBar style="auto" />
15
+ </View>
16
+ );
17
+ }
18
+
19
+ const styles = StyleSheet.create({
20
+ container: {
21
+ flex: 1,
22
+ backgroundColor: '#fff',
23
+ paddingTop: 120
24
+ },
25
+ });
@@ -0,0 +1,32 @@
1
+ {
2
+ "expo": {
3
+ "name": "enriched-text-input-example",
4
+ "slug": "enriched-text-input-example",
5
+ "version": "1.0.0",
6
+ "orientation": "portrait",
7
+ "icon": "./assets/icon.png",
8
+ "userInterfaceStyle": "light",
9
+ "newArchEnabled": true,
10
+ "autolinking": {
11
+ "searchPaths": ["../"]
12
+ },
13
+ "splash": {
14
+ "image": "./assets/splash-icon.png",
15
+ "resizeMode": "contain",
16
+ "backgroundColor": "#ffffff"
17
+ },
18
+ "ios": {
19
+ "supportsTablet": true
20
+ },
21
+ "android": {
22
+ "adaptiveIcon": {
23
+ "foregroundImage": "./assets/adaptive-icon.png",
24
+ "backgroundColor": "#ffffff"
25
+ },
26
+ "edgeToEdgeEnabled": true
27
+ },
28
+ "web": {
29
+ "favicon": "./assets/favicon.png"
30
+ }
31
+ }
32
+ }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,8 @@
1
+ import { registerRootComponent } from 'expo';
2
+
3
+ import App from './App';
4
+
5
+ // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
6
+ // It also ensures that whether you load the app in Expo Go or in a native build,
7
+ // the environment is set up appropriately
8
+ registerRootComponent(App);
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "enriched-text-input-example",
3
+ "version": "1.0.0",
4
+ "main": "index.ts",
5
+ "scripts": {
6
+ "start": "expo start",
7
+ "android": "expo start --android",
8
+ "ios": "expo start --ios",
9
+ "web": "expo start --web"
10
+ },
11
+ "dependencies": {
12
+ "@expo/vector-icons": "^15.0.3",
13
+ "expo": "~54.0.25",
14
+ "expo-status-bar": "~3.0.8",
15
+ "react": "19.1.0",
16
+ "react-native": "0.81.5",
17
+ "enriched-text-input": "file:../"
18
+ },
19
+ "private": true,
20
+ "devDependencies": {
21
+ "@types/react": "~19.1.10",
22
+ "typescript": "~5.9.2"
23
+ }
24
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "compilerOptions": {},
3
+ "extends": "expo/tsconfig.base"
4
+ }
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ import RichTextInput from "./src/RichTextInput";
2
+ import Toolbar from "./src/Toolbar";
3
+
4
+ export { RichTextInput, Toolbar };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "enriched-text-input",
3
+ "version": "1.0.0",
4
+ "description": "JavaScript only rich text input component for React Native. Compatible with Expo Go.",
5
+ "keywords": [
6
+ "rich-text",
7
+ "react-native",
8
+ "react-native-web",
9
+ "expo"
10
+ ],
11
+ "homepage": "https://github.com/PatoSala/react-native-rich-text#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/PatoSala/react-native-rich-text/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/PatoSala/react-native-rich-text.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Pato Sala",
21
+ "private": false,
22
+ "main": "index.ts",
23
+ "directories": {
24
+ "example": "example"
25
+ },
26
+ "workspaces": [
27
+ ".",
28
+ "example"
29
+ ],
30
+ "scripts": {
31
+ "test": "echo \"Error: no test specified\" && exit 1"
32
+ },
33
+ "dependencies": {
34
+ "@expo/vector-icons": "^15.0.3",
35
+ "expo": "~54.0.25",
36
+ "react": "19.1.0",
37
+ "react-native": "0.81.5"
38
+ },
39
+ "devDependencies": {
40
+ "metro-react-native-babel-preset": "^0.77.0"
41
+ }
42
+ }
@@ -0,0 +1,900 @@
1
+ import { useState, useImperativeHandle, useRef, useEffect } from "react";
2
+ import { TextInput, Text, StyleSheet, View, Linking } from "react-native";
3
+
4
+ const exampleText = "_None_ *bold* _italic_ ~strikethrough~ none";
5
+
6
+ interface Token {
7
+ text: string;
8
+ annotations: {
9
+ bold: boolean;
10
+ italic: boolean;
11
+ lineThrough: boolean;
12
+ underline: boolean;
13
+ color: string;
14
+ }
15
+ }
16
+
17
+ interface Diff {
18
+ start: number;
19
+ removed: string;
20
+ added: string;
21
+ }
22
+
23
+ interface Annotations {
24
+ bold: boolean;
25
+ italic: boolean;
26
+ lineThrough: boolean;
27
+ underline: boolean;
28
+ color: string;
29
+ }
30
+
31
+ interface RichTextMatch {
32
+ raw: string;
33
+ content: string;
34
+ start: number;
35
+ end: number;
36
+ expression: string;
37
+ }
38
+
39
+ const PATTERNS = [
40
+ { style: "bold", regex: "\\*([^*]+)\\*" },
41
+ { style: "italic", regex: "_([^_]+)_" },
42
+ { style: "lineThrough", regex: "~([^~]+)~" },
43
+ ];
44
+
45
+ function insertAt(str, index, substring) {
46
+ // Clamp index into valid boundaries
47
+ const i = Math.max(0, Math.min(index, str.length));
48
+ return str.slice(0, i) + substring + str.slice(i);
49
+ }
50
+
51
+ function removeAt(str, index, strToRemove) {
52
+ return str.slice(0, index) + str.slice(index + strToRemove.length);
53
+ }
54
+
55
+ function replaceAt(str, index, substring, length) {
56
+ // Clamp index into valid boundaries
57
+ const i = Math.max(0, Math.min(index, str.length));
58
+ return str.slice(0, i) + substring + str.slice(i + length);
59
+ }
60
+
61
+ function findMatch(str: string, regexExpression: string) : RichTextMatch | null {
62
+ const regex = new RegExp(regexExpression);
63
+ const match = regex.exec(str);
64
+ return match
65
+ ? {
66
+ raw: match[0],
67
+ content: match[1],
68
+ start: match.index,
69
+ end: match.index + match[0].length,
70
+ expression: regexExpression
71
+ }
72
+ : null;
73
+ }
74
+
75
+ function getRequiredLiterals(regexString) {
76
+ // Strip leading/trailing slashes and flags (if user passed /.../ form)
77
+ regexString = regexString.replace(/^\/|\/[a-z]*$/g, "");
78
+
79
+ // Remove ^ and $ anchors
80
+ regexString = regexString.replace(/^\^|\$$/g, "");
81
+
82
+ // 1. Find the first literal before any group or operator
83
+ const beforeGroup = regexString.match(/^((?:\\.|[^[(])+)/);
84
+ let openLiteral = null;
85
+
86
+ if (beforeGroup) {
87
+ const part = beforeGroup[1];
88
+ const litMatch = part.match(/\\(.)|([^\\])/); // first literal
89
+ if (litMatch) {
90
+ openLiteral = litMatch[1] ?? litMatch[2];
91
+ }
92
+ }
93
+
94
+ // 2. Detect a closing literal after a capturing group (optional)
95
+ let closeLiteral = null;
96
+ const afterGroup = regexString.match(/\)([^).]+)/);
97
+ if (afterGroup) {
98
+ const part = afterGroup[1];
99
+ const litMatch = part.match(/\\(.)|([^\\])/);
100
+ if (litMatch) {
101
+ closeLiteral = litMatch[1] ?? litMatch[2];
102
+ }
103
+ }
104
+
105
+ // Return both if available, otherwise just the opening literal
106
+ return {
107
+ opening: openLiteral,
108
+ closing: closeLiteral,
109
+ };
110
+ }
111
+
112
+ function concileAnnotations(prevAnnotations, nextAnnotations) {
113
+ return {
114
+ bold: prevAnnotations.bold || nextAnnotations.bold,
115
+ italic: prevAnnotations.italic || nextAnnotations.italic,
116
+ lineThrough: prevAnnotations.lineThrough || nextAnnotations.lineThrough,
117
+ underline: prevAnnotations.underline || nextAnnotations.underline,
118
+ color: prevAnnotations.color || nextAnnotations.color,
119
+ };
120
+ }
121
+
122
+ // Returns string modifications
123
+ function diffStrings(prev, next) : Diff {
124
+ let start = 0;
125
+
126
+ while (start < prev.length && start < next.length && prev[start] === next[start]) {
127
+ start++;
128
+ }
129
+
130
+ let endPrev = prev.length - 1;
131
+ let endNext = next.length - 1;
132
+
133
+ while (endPrev >= start && endNext >= start && prev[endPrev] === next[endNext]) {
134
+ endPrev--;
135
+ endNext--;
136
+ }
137
+
138
+ return {
139
+ start,
140
+ removed: prev.slice(start, endPrev + 1),
141
+ added: next.slice(start, endNext + 1),
142
+ };
143
+ }
144
+
145
+ // Returns an array of tokens
146
+ const parseRichTextString = (richTextString: string, patterns: { regex: string, style: string }[], initalTokens = null) => {
147
+ let tokens = initalTokens || [
148
+ {
149
+ text: richTextString,
150
+ annotations: {
151
+ bold: false,
152
+ italic: false,
153
+ lineThrough: false,
154
+ underline: false,
155
+ color: "black"
156
+ }
157
+ }
158
+ ];
159
+ let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
160
+
161
+ for (const pattern of patterns) {
162
+ let match = findMatch(plain_text, pattern.regex);
163
+
164
+ if (match) {
165
+ const { result: splittedTokens } = splitTokens(
166
+ tokens,
167
+ match.start,
168
+ match.end - 1,
169
+ pattern.style,
170
+ getRequiredLiterals(match.expression).opening
171
+ );
172
+ tokens = splittedTokens;
173
+ plain_text = splittedTokens.reduce((acc, curr) => acc + curr.text, "");
174
+
175
+ const parsed = parseRichTextString(tokens, patterns, tokens);
176
+
177
+ return {
178
+ tokens: parsed.tokens,
179
+ plain_text: parsed.plain_text
180
+ }
181
+ }
182
+ }
183
+
184
+ return {
185
+ tokens: tokens.filter(token => token.text.length > 0),
186
+ plain_text: plain_text
187
+ }
188
+ }
189
+
190
+ // Returns a rich text string
191
+ const parseTokens = (tokens) => {
192
+
193
+ }
194
+
195
+ // Inserts a token at the given index
196
+ // Only when start === end
197
+ function insertToken(tokens: Token[], index: number, type: string, text = "" ) {
198
+ const updatedTokens = [...tokens];
199
+
200
+ let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
201
+
202
+ // If cursor is at the end
203
+ if (plain_text.length === index) {
204
+ updatedTokens.push({
205
+ text: text,
206
+ annotations: {
207
+ ...updatedTokens[updatedTokens.length - 1].annotations,
208
+ [type]: !updatedTokens[updatedTokens.length - 1].annotations[type]
209
+ }
210
+ });
211
+
212
+ return { result: updatedTokens.filter(token => token.text.length > 0) };
213
+ }
214
+
215
+ let startIndex = index;
216
+ let startToken;
217
+
218
+ for (const token of updatedTokens) {
219
+ if (startIndex <= token.text.length) {
220
+ startToken = token;
221
+ break;
222
+ }
223
+ startIndex -= token.text.length;
224
+ }
225
+
226
+ const startTokenIndex = updatedTokens.indexOf(startToken);
227
+
228
+ let firstToken = {
229
+ text: startToken.text.slice(0, startIndex),
230
+ annotations: {
231
+ ...startToken.annotations,
232
+ [type]: startToken.annotations[type]
233
+ }
234
+ }
235
+
236
+ // Middle token is the selected text
237
+ let middleToken = {
238
+ text: text,
239
+ annotations: {
240
+ ...startToken.annotations,
241
+ [type]: !startToken.annotations[type]
242
+ }
243
+ }
244
+
245
+ let lastToken = {
246
+ text: startToken.text.slice(startIndex , startToken.text.length),
247
+ annotations: {
248
+ ...startToken.annotations,
249
+ [type]: startToken.annotations[type]
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Note: the following conditionals are to prevent empty tokens.
255
+ * It would be ideal if instead of catching empty tokens we could write the correct insert logic to prevent them.
256
+ * Maybe use a filter instead?
257
+ */
258
+
259
+ updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken);
260
+
261
+ return {
262
+ result: updatedTokens.filter(token => token.text.length > 0)
263
+ };
264
+ }
265
+
266
+ // Updates token content (add, remove, replace)
267
+ // Note: need to support cross-token updates.
268
+ // It's actually updating just the text of tokens
269
+ const updateTokens = (tokens: Token[], diff: Diff) => {
270
+ let updatedTokens = [...tokens];
271
+ const plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
272
+
273
+ // If we're at the end of the string
274
+ if (diff.start >= plain_text.length) {
275
+ if (diff.added.length > 0) {
276
+ updatedTokens[updatedTokens.length - 1].text += diff.added;
277
+
278
+ return {
279
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
280
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
281
+ };
282
+ }
283
+
284
+ if (diff.removed.length > 0) {
285
+ const lastTokenIndex = updatedTokens.length - 1;
286
+ updatedTokens[lastTokenIndex].text = updatedTokens[lastTokenIndex].text.slice(0, updatedTokens[lastTokenIndex].text.length - diff.removed.length);
287
+
288
+ return {
289
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
290
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
291
+ };
292
+ }
293
+ }
294
+
295
+ // Find token where start
296
+ let startIndex = diff.start;
297
+ let startToken;
298
+
299
+ for (const token of updatedTokens) {
300
+ if (startIndex < token.text.length) {
301
+ startToken = token;
302
+ break;
303
+ }
304
+ startIndex -= token.text.length;
305
+ }
306
+ // Find token where end
307
+ // We need to add the length of the removed/added text to the start index to get the end index
308
+ let endIndex = diff.removed.length > diff.added.length
309
+ ? diff.start + diff.removed.length
310
+ : diff.start + diff.added.length;
311
+ let endToken;
312
+
313
+ for (const token of updatedTokens) {
314
+ if (endIndex <= token.text.length) {
315
+ endToken = token;
316
+ break;
317
+ }
318
+ endIndex -= token.text.length;
319
+ }
320
+
321
+ const startTokenIndex = updatedTokens.indexOf(startToken);
322
+ const endTokenIndex = updatedTokens.indexOf(endToken);
323
+
324
+ // Same token
325
+ if (startTokenIndex === endTokenIndex) {
326
+ const tokenCopy = { ...startToken };
327
+
328
+ if (diff.removed.length > 0 && diff.added.length > 0) {
329
+ tokenCopy.text = replaceAt(tokenCopy.text, startIndex, diff.added, diff.removed.length);
330
+ updatedTokens[startTokenIndex] = tokenCopy;
331
+ return {
332
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
333
+ // Plain text must be updated to prevent bad diffs
334
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
335
+ };
336
+ }
337
+
338
+ if (diff.removed.length > 0) {
339
+ tokenCopy.text = removeAt(tokenCopy.text, startIndex, diff.removed);
340
+
341
+ updatedTokens[startTokenIndex] = tokenCopy;
342
+ return {
343
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
344
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
345
+ };
346
+ }
347
+
348
+ if (diff.added.length > 0) {
349
+ // Check if token index is > 0 and if startIndex === 0 (See RNRT-6)
350
+ if (startTokenIndex > 0 && startIndex === 0) {
351
+ updatedTokens[startTokenIndex - 1].text += diff.added;
352
+ return {
353
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
354
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
355
+ };
356
+ }
357
+
358
+ tokenCopy.text = insertAt(tokenCopy.text, startIndex, diff.added);
359
+ updatedTokens[startTokenIndex] = tokenCopy;
360
+ return {
361
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
362
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
363
+ };
364
+ }
365
+ }
366
+
367
+ // Cross-token
368
+ if (startTokenIndex !== endTokenIndex) {
369
+ const selectedTokens = updatedTokens.slice(startTokenIndex, endTokenIndex + 1);
370
+
371
+ if (diff.added.length > 0) {
372
+ const firstToken = selectedTokens[0];
373
+ const lastToken = selectedTokens[selectedTokens.length - 1];
374
+
375
+ firstToken.text = firstToken.text.slice(0, startIndex) + diff.added;
376
+ lastToken.text = lastToken.text.slice(endIndex);
377
+ updatedTokens[startTokenIndex] = firstToken;
378
+ updatedTokens[endTokenIndex] = lastToken;
379
+
380
+ if (selectedTokens.length > 2) {
381
+ updatedTokens.splice(startTokenIndex + 1, selectedTokens.length - 2);
382
+ return {
383
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
384
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
385
+ }
386
+ }
387
+
388
+ return {
389
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
390
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Remove:
396
+ * - For more than two tokens, works.
397
+ * - For two tokens, does not work properly.
398
+ */
399
+ if (diff.removed.length > 0) {
400
+ const firstToken = selectedTokens[0];
401
+ const lastToken = selectedTokens[selectedTokens.length - 1];
402
+
403
+ firstToken.text = firstToken.text.slice(0, startIndex);
404
+ lastToken.text = lastToken.text.slice(endIndex);
405
+ updatedTokens[startTokenIndex] = firstToken;
406
+ updatedTokens[endTokenIndex] = lastToken;
407
+
408
+ // If more than two tokens, whe need to remove the ones in between
409
+ if (selectedTokens.length > 2) {
410
+ updatedTokens.splice(startTokenIndex + 1, selectedTokens.length - 2);
411
+ return {
412
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
413
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
414
+ }
415
+ }
416
+
417
+ return {
418
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
419
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, ""),
420
+ }
421
+ }
422
+
423
+ return {
424
+ updatedTokens: updatedTokens.filter(token => token.text.length > 0),
425
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
426
+ };
427
+ }
428
+ }
429
+
430
+ // Updates annotations and splits tokens if necessary
431
+ // Only when start !== end
432
+ // To-do: Add support for multiple annotations
433
+ const splitTokens = (
434
+ tokens: Token[],
435
+ start: number,
436
+ end: number,
437
+ type: string,
438
+ /** Used to strip opening and closing chars of rich text matches. */
439
+ withReplacement?: string
440
+ ) => {
441
+ let updatedTokens = [...tokens];
442
+
443
+ // Find token where start
444
+ let startIndex = start;
445
+ let startToken;
446
+
447
+ for (const token of updatedTokens) {
448
+ if (startIndex < token.text.length) {
449
+ startToken = token;
450
+ break;
451
+ }
452
+ startIndex -= token.text.length;
453
+ }
454
+ // Find token where end
455
+ let endIndex = end;
456
+ let endToken;
457
+ for (const token of updatedTokens) {
458
+ // The - 1 is necessary
459
+ if (endIndex <= token.text.length) {
460
+ endToken = token;
461
+ break;
462
+ }
463
+ endIndex -= token.text.length;
464
+ }
465
+
466
+ const startTokenIndex = updatedTokens.indexOf(startToken);
467
+ const endTokenIndex = updatedTokens.indexOf(endToken);
468
+
469
+ // If same token, split
470
+ if (startTokenIndex === endTokenIndex) {
471
+ /*
472
+ Selection: |---------|
473
+ Tokens: ["Rich text input "] ["bold"] ["world!"] [" "]
474
+
475
+ Selection is within a token. We need to split that token to apply annotations:
476
+
477
+ First token: ["Ri"]
478
+ Middle token: ["ch text inp"] --> Annotations are applied here.
479
+ Last token: ["ut "]
480
+
481
+ Result: ["Ri"] ["ch text inp"] ["ut "] ["bold"] ["world!"] [" "]
482
+ */
483
+
484
+ let firstToken = {
485
+ text: startToken.text.slice(0, startIndex),
486
+ annotations: {
487
+ ...startToken.annotations,
488
+ [type]: startToken.annotations[type]
489
+ }
490
+ }
491
+
492
+ // Middle token is the selected text
493
+ let middleToken = {
494
+ // The replace method is used to remove the opening and closing rich text literal chars when parsing.
495
+ text: startToken.text.slice(startIndex, endIndex).replace(withReplacement, ""),
496
+ annotations: {
497
+ ...startToken.annotations,
498
+ [type]: !startToken.annotations[type]
499
+ }
500
+ }
501
+
502
+ let lastToken = {
503
+ // The replace method is used to remove the opening and closing rich text literal chars when parsing.
504
+ text: startToken.text.slice(endIndex , startToken.text.length).replace(withReplacement, ""),
505
+ annotations: {
506
+ ...startToken.annotations,
507
+ [type]: startToken.annotations[type]
508
+ }
509
+ }
510
+
511
+ // Note: the following conditionals are to prevent empty tokens.
512
+ // It would be ideal if instead of catching empty tokens we could write the correct insert logic to prevent them.
513
+ if (firstToken.text.length === 0 && lastToken.text.length === 0) {
514
+ updatedTokens.splice(startTokenIndex, 1, middleToken);
515
+ return { result: updatedTokens };
516
+ }
517
+
518
+ if (firstToken.text.length === 0) {
519
+ updatedTokens.splice(startTokenIndex, 1, middleToken, lastToken);
520
+ return { result: updatedTokens };
521
+ }
522
+
523
+ if (lastToken.text.length === 0) {
524
+ updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken);
525
+ return { result: updatedTokens };
526
+ }
527
+
528
+ updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken)
529
+ return { result: updatedTokens };
530
+ }
531
+
532
+ // Cross-token selection
533
+ if (startTokenIndex !== endTokenIndex) {
534
+ // Before splitting, check if all selected tokens already have the annotation
535
+ const selectedTokens = updatedTokens.slice(startTokenIndex, endTokenIndex + 1);
536
+ const allSelectedTokensHaveAnnotation = selectedTokens.every((token) => token.annotations[type] === true);
537
+
538
+ let firstToken = {
539
+ text: startToken.text.slice(0, startIndex),
540
+ annotations: {
541
+ ...startToken.annotations,
542
+ [type]: startToken.annotations[type]
543
+ }
544
+ }
545
+
546
+ let secondToken = {
547
+ // The replace method is used to remove the opening and closing rich text literal chars when parsing.
548
+ text: startToken.text.slice(startIndex, startToken.text.length).replace(withReplacement, ""),
549
+ annotations: {
550
+ ...startToken.annotations,
551
+ [type]: allSelectedTokensHaveAnnotation ? false : true
552
+ }
553
+ }
554
+
555
+ const middleTokens = updatedTokens.slice(startTokenIndex + 1, endTokenIndex);
556
+ let updatedMiddleTokens = [...middleTokens];
557
+
558
+ for (const [index, token] of middleTokens.entries()) {
559
+ updatedMiddleTokens[index] = {
560
+ text: token.text,
561
+ annotations: {
562
+ ...token.annotations,
563
+ [type]: allSelectedTokensHaveAnnotation ? false : true
564
+ }
565
+ }
566
+ }
567
+
568
+ let secondToLastToken = {
569
+ text: endToken.text.slice(0, endIndex),
570
+ annotations: {
571
+ ...endToken.annotations,
572
+ [type]: allSelectedTokensHaveAnnotation ? false : true
573
+ }
574
+ }
575
+
576
+ let lastToken = {
577
+ // The replace method is used to remove the opening and closing rich text literal chars when parsing.
578
+ text: endToken.text.slice(endIndex, endToken.text.length).replace(withReplacement, ""),
579
+ annotations: {
580
+ ...endToken.annotations,
581
+ [type]: endToken.annotations[type]
582
+ }
583
+ }
584
+
585
+ // Catch empty tokens. Empty tokens are always at the extremes.
586
+ if (firstToken.text.length === 0 && lastToken.text.length === 0) {
587
+ updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([secondToken, ...updatedMiddleTokens, secondToLastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
588
+ return { result: updatedTokens };
589
+ }
590
+
591
+ if (firstToken.text.length === 0) {
592
+ updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
593
+ return { result: updatedTokens };
594
+ }
595
+
596
+ if (lastToken.text.length === 0) {
597
+ updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
598
+ return { result: updatedTokens };
599
+ }
600
+
601
+ updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
602
+ return { result: updatedTokens };
603
+ }
604
+ }
605
+
606
+ // Concats tokens containing similar annotations
607
+ const concatTokens = (tokens: Token[]) => {
608
+ let concatenedTokens = [];
609
+
610
+ for (const [index, token] of tokens.entries()) {
611
+ if (index === 0) {
612
+ concatenedTokens.push(token);
613
+ continue;
614
+ }
615
+
616
+ const prevToken = concatenedTokens[concatenedTokens.length - 1];
617
+
618
+ if (prevToken.annotations.bold === token.annotations.bold &&
619
+ prevToken.annotations.italic === token.annotations.italic &&
620
+ prevToken.annotations.lineThrough === token.annotations.lineThrough &&
621
+ prevToken.annotations.underline === token.annotations.underline &&
622
+ prevToken.annotations.color === token.annotations.color) {
623
+ prevToken.text += token.text;
624
+ continue;
625
+ }
626
+
627
+ concatenedTokens.push(token);
628
+ }
629
+
630
+ return concatenedTokens;
631
+ }
632
+
633
+ export default function RichTextInput({ ref }) {
634
+ const inputRef = useRef<TextInput>(null);
635
+ const selectionRef = useRef({ start: 0, end: 0 });
636
+ const [tokens, setTokens] = useState([{
637
+ text: "",
638
+ annotations: {
639
+ bold: false,
640
+ italic: false,
641
+ lineThrough: false,
642
+ underline: false,
643
+ color: "black"
644
+ }
645
+ }]);
646
+
647
+ useEffect(() => {
648
+ if (tokens.length === 0) {
649
+ setTokens([{
650
+ text: "",
651
+ annotations: {
652
+ bold: false,
653
+ italic: false,
654
+ lineThrough: false,
655
+ underline: false,
656
+ color: "black"
657
+ }
658
+ }])
659
+ }
660
+ }, [tokens]);
661
+
662
+ /**
663
+ * Prev text should not contain matching rich text formats.
664
+ * Those should be spliced once the corresponding tokens are created.
665
+ */
666
+ const prevTextRef = useRef(tokens.map(t => t.text).join(""));
667
+
668
+ // Find a better name
669
+ // To-do: Allow for multiple styles at once.
670
+ const [toSplit, setToSplit] = useState({
671
+ start: 0,
672
+ end: 0,
673
+ type: null
674
+ });
675
+
676
+ const handleSelectionChange = ({ nativeEvent }) => {
677
+ selectionRef.current = nativeEvent.selection;
678
+ }
679
+
680
+ const handleOnChangeText = (nextText: string) => {
681
+ const diff = diffStrings(prevTextRef.current, nextText);
682
+
683
+ let match : RichTextMatch | null = null;
684
+
685
+ for (const pattern of PATTERNS) {
686
+ match = findMatch(nextText, pattern.regex);
687
+ if (match) break;
688
+ }
689
+
690
+ if (match) {
691
+ // Check token containing match
692
+ // If token already haves this annotation, do not format and perform a simple updateToken.
693
+ const annotation = PATTERNS.find(p => p.regex === match.expression);
694
+ const { result } = splitTokens(
695
+ tokens,
696
+ match.start,
697
+ match.end - 1,
698
+ annotation.style,
699
+ // Get the rich text opening char to replace it
700
+ getRequiredLiterals(match.expression).opening
701
+ );
702
+ const plain_text = result.reduce((acc, curr) => acc + curr.text, "");
703
+ /* const { updatedTokens, plain_text } = updateTokens(result, {
704
+ removed: getRequiredLiterals(match.expression).opening,
705
+ start: match.start,
706
+ added: ""
707
+ }) */
708
+
709
+ setTokens([...concatTokens(result)]);
710
+ prevTextRef.current = plain_text;
711
+
712
+
713
+ return;
714
+ }
715
+
716
+ if (diff.start === toSplit.start && diff.start === toSplit.end && diff.added.length > 0 && toSplit.type) {
717
+ const { result } = insertToken(tokens, diff.start, toSplit.type, diff.added);
718
+ const plain_text = result.map(t => t.text).join("");
719
+ setTokens([...concatTokens(result)]);
720
+ setToSplit({ start: 0, end: 0, type: null });
721
+ prevTextRef.current = plain_text;
722
+ return;
723
+ }
724
+
725
+ const { updatedTokens, plain_text} = updateTokens(tokens, diff);
726
+
727
+ setTokens([...concatTokens(updatedTokens)]);
728
+ prevTextRef.current = plain_text;
729
+ }
730
+
731
+ useImperativeHandle(ref, () => ({
732
+ toggleBold() {
733
+ const { start, end } = selectionRef.current;
734
+
735
+ if (start === end && toSplit.type === "bold") {
736
+ setToSplit({ start: 0, end: 0, type: null });
737
+ return;
738
+ }
739
+
740
+ if (start === end) {
741
+ setToSplit({ start, end, type: "bold" });
742
+ return;
743
+ }
744
+
745
+ /**
746
+ * This prevents that when a portion of text is set to bold, the next text inserted after it is not bold.
747
+ */
748
+ if (start < end) {
749
+ setToSplit({
750
+ start: end,
751
+ end: end,
752
+ type: "bold"
753
+ })
754
+ }
755
+
756
+ const { result } = splitTokens(tokens, start, end, "bold");
757
+ setTokens([...concatTokens(result)]);
758
+ requestAnimationFrame(() => inputRef.current.setSelection(start, end));
759
+ },
760
+ toggleItalic() {
761
+ const { start, end } = selectionRef.current;
762
+
763
+ if (start === end && toSplit.type === "italic") {
764
+ setToSplit({ start: 0, end: 0, type: null });
765
+ return;
766
+ }
767
+
768
+ if (start === end) {
769
+ setToSplit({ start, end, type: "italic" });
770
+ return;
771
+ }
772
+
773
+ if (start < end) {
774
+ setToSplit({
775
+ start: end,
776
+ end: end,
777
+ type: "italic"
778
+ })
779
+ }
780
+
781
+ const { result } = splitTokens(tokens, start, end, "italic");
782
+ setTokens([...concatTokens(result)]);
783
+ requestAnimationFrame(() => inputRef.current.setSelection(start, end));
784
+ },
785
+ toggleLineThrough() {
786
+ const { start, end } = selectionRef.current;
787
+
788
+ if (start === end && toSplit.type === "lineThrough") {
789
+ setToSplit({ start: 0, end: 0, type: null });
790
+ return;
791
+ }
792
+
793
+ if (start === end) {
794
+ setToSplit({ start, end, type: "lineThrough" });
795
+ return;
796
+ }
797
+
798
+ if (start < end) {
799
+ setToSplit({
800
+ start: end,
801
+ end: end,
802
+ type: "lineThrough"
803
+ })
804
+ }
805
+
806
+ const { result } = splitTokens(tokens, start, end, "lineThrough");
807
+ setTokens([...concatTokens(result)]);
808
+ requestAnimationFrame(() => inputRef.current.setSelection(start, end));
809
+ },
810
+ toggleUnderline() {
811
+ const { start, end } = selectionRef.current;
812
+
813
+ if (start === end && toSplit.type === "underline") {
814
+ setToSplit({ start: 0, end: 0, type: null });
815
+ return;
816
+ }
817
+
818
+ if (start === end) {
819
+ setToSplit({ start, end, type: "underline" });
820
+ return;
821
+ }
822
+
823
+ if (start < end) {
824
+ setToSplit({
825
+ start: end,
826
+ end: end,
827
+ type: "underline"
828
+ })
829
+ }
830
+
831
+ const { result } = splitTokens(tokens, start, end, "underline");
832
+ setTokens([...concatTokens(result)]);
833
+ requestAnimationFrame(() => inputRef.current.setSelection(start, end));
834
+ },
835
+ setValue(value: string) {
836
+ // To keep styles, parsing should be done before setting value
837
+ const { tokens, plain_text } = parseRichTextString(value, PATTERNS);
838
+ setTokens([...concatTokens(tokens)]);
839
+ prevTextRef.current = plain_text;
840
+ }
841
+ }))
842
+
843
+ return (
844
+ <View style={{ position: "relative" }}>
845
+ <TextInput
846
+ multiline={true}
847
+ ref={inputRef}
848
+ autoCorrect={false}
849
+ autoComplete="off"
850
+ style={styles.textInput}
851
+ placeholder="Rich text input"
852
+ onSelectionChange={handleSelectionChange}
853
+ onChangeText={handleOnChangeText}
854
+ >
855
+ {tokens.map((token, i) => {
856
+ return (
857
+ <Text key={i} style={[
858
+ styles.text,
859
+ ...Object.entries(token.annotations).map(([key, value]) => value ? styles[key] : null).filter(Boolean),
860
+ token.annotations.underline && token.annotations.lineThrough && styles.underlineLineThrough
861
+ ]}>
862
+ {token.text}
863
+ </Text>
864
+ )
865
+ })}
866
+ </TextInput>
867
+ </View>
868
+ );
869
+ }
870
+
871
+ const styles = StyleSheet.create({
872
+ textInput: {
873
+ fontSize: 20,
874
+ width: "100%",
875
+ paddingHorizontal: 16
876
+ },
877
+ text: {
878
+ color: "black"
879
+ },
880
+ bold: {
881
+ fontWeight: 'bold',
882
+ },
883
+ italic: {
884
+ fontStyle: "italic"
885
+ },
886
+ lineThrough: {
887
+ textDecorationLine: "line-through"
888
+ },
889
+ underline: {
890
+ textDecorationLine: "underline",
891
+ },
892
+ comment: {
893
+ textDecorationLine: "underline",
894
+ textDecorationColor: "rgba(255, 203, 0, .35)",
895
+ backgroundColor: "rgba(255, 203, 0, .12)"
896
+ },
897
+ underlineLineThrough: {
898
+ textDecorationLine: "underline line-through"
899
+ }
900
+ });
@@ -0,0 +1,94 @@
1
+ import { View, Button, StyleSheet, TouchableOpacity, Keyboard } from "react-native";
2
+ import { RefObject, Ref } from "react";
3
+ import FontAwesome6 from '@expo/vector-icons/FontAwesome6';
4
+
5
+ interface RichTextInput {
6
+ toggleBold: () => void;
7
+ toggleItalic: () => void;
8
+ setValue: (value: string) => void;
9
+ }
10
+
11
+ interface ToolbarProps {
12
+ richTextInputRef: Ref<RichTextInput>,
13
+ }
14
+
15
+ export default function Toolbar({
16
+ richTextInputRef
17
+ } : ToolbarProps) {
18
+
19
+ const handleBold = () => {
20
+ richTextInputRef.current.toggleBold();
21
+ }
22
+
23
+ const handleItalic = () => {
24
+ richTextInputRef.current.toggleItalic();
25
+ }
26
+
27
+ const handleLineThrough = () => {
28
+ richTextInputRef.current.toggleLineThrough();
29
+ }
30
+
31
+ const handleUnderline = () => {
32
+ richTextInputRef.current.toggleUnderline();
33
+ }
34
+
35
+ const handleKeyboardDismiss = () => {
36
+ Keyboard.dismiss();
37
+ }
38
+
39
+ return (
40
+ <View style={styles.toolbar}>
41
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleBold}>
42
+ <FontAwesome6 name="bold" size={16} color="black" />
43
+ </TouchableOpacity>
44
+
45
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleItalic}>
46
+ <FontAwesome6 name="italic" size={16} color="black" />
47
+ </TouchableOpacity>
48
+
49
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleLineThrough}>
50
+ <FontAwesome6 name="strikethrough" size={16} color="black" />
51
+ </TouchableOpacity>
52
+
53
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleUnderline}>
54
+ <FontAwesome6 name="underline" size={16} color="black" />
55
+ </TouchableOpacity>
56
+
57
+ <TouchableOpacity style={[styles.toolbarButton, styles.keyboardDown]} onPress={handleKeyboardDismiss}>
58
+ <FontAwesome6 name="keyboard" size={16} color="black" />
59
+ <View style={styles.keyboardArrowContainer}>
60
+ <FontAwesome6 name="chevron-down" size={8} color="black"/>
61
+ </View>
62
+ </TouchableOpacity>
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create({
68
+ toolbar: {
69
+ width: "100%",
70
+ height: 50,
71
+ boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.1)",
72
+ flexDirection: "row",
73
+ gap: 8,
74
+ paddingHorizontal: 16,
75
+ marginTop: 16
76
+ },
77
+ toolbarButton: {
78
+ height: 50,
79
+ width: 50,
80
+ display: "flex",
81
+ justifyContent: "center",
82
+ alignItems: "center"
83
+ },
84
+ keyboardDown: {
85
+ alignItems: "center",
86
+ justifyContent: "center",
87
+ position: "relative",
88
+ paddingBottom: 6
89
+ },
90
+ keyboardArrowContainer: {
91
+ position: "absolute",
92
+ bottom: 13
93
+ }
94
+ });