enriched-text-input 1.0.1 → 1.0.3

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 CHANGED
@@ -7,16 +7,16 @@
7
7
 
8
8
  Proof of concept for a JavaScript only rich-text TextInput component for React Native.
9
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.
10
+ It will only support text styling since it's not possible to render images inside `Text` views in React Native. [Try it on Expo Snack](https://snack.expo.dev/@patosala/enriched-text-input).
11
11
 
12
12
  ## Motivation
13
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
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.
15
+ In theory, by only using JavaScript we are able to provide better cross-platform compatibility and the possibility to style elements however you want as long as they follow react-native's `Text` supported styles.
16
16
 
17
17
  ## Features
18
18
 
19
- - [x] Basic text formatting (*bold*, _italic_, __underline__, ~~strikethrough~~).
19
+ - [x] Basic text formatting (__bold__, _italic_, underline, ~~strikethrough~~ and `codeblocks`).
20
20
  - [x] Rich text format parsing.
21
21
  - [ ] Links and mentions.
22
22
  - [ ] Custom styling.
@@ -27,6 +27,7 @@ In theory, by only using JavaScript we are able to provide better cross-platform
27
27
 
28
28
  ## Known limitations
29
29
  - Inline images.
30
+ - Only `Text`component styles are supported.
30
31
 
31
32
  ## Installation
32
33
  ```
@@ -35,7 +36,7 @@ npm install enriched-text-input
35
36
 
36
37
  ## Usage
37
38
  ```js
38
- import { useRef, useState } from 'react';
39
+ import { useRef } from 'react';
39
40
  import { StyleSheet, View } from 'react-native';
40
41
 
41
42
  import { RichTextInput, Toolbar } from 'enriched-text-input';
@@ -46,7 +47,14 @@ export default function App() {
46
47
  return (
47
48
  <View style={styles.container}>
48
49
  <RichTextInput ref={richTextInputRef}/>
49
- <Toolbar richTextInputRef={richTextInputRef} />
50
+ <Toolbar richTextInputRef={richTextInputRef}>
51
+ <Toolbar.Bold />
52
+ <Toolbar.Italic />
53
+ <Toolbar.Underline />
54
+ <Toolbar.Strikethrough />
55
+ <Toolbar.Code />
56
+ <Toolbar.Keyboard />
57
+ </Toolbar>
50
58
  </View>
51
59
  );
52
60
  }
@@ -59,6 +67,7 @@ const styles = StyleSheet.create({
59
67
  },
60
68
  });
61
69
 
70
+
62
71
  ```
63
72
 
64
73
  ## Contributing
package/example/App.tsx CHANGED
@@ -1,18 +1,74 @@
1
1
  import { useRef, useState } from 'react';
2
- import { StatusBar } from 'expo-status-bar';
3
- import { StyleSheet, Text, View, Button } from 'react-native';
2
+ import { StyleSheet, View, KeyboardAvoidingView, Text, TouchableOpacity, Button, TextInput } from 'react-native';
3
+ import { FontAwesome6 } from '@expo/vector-icons';
4
+ import { RichTextInput, Toolbar, PATTERNS } from 'enriched-text-input';
4
5
 
5
- import { RichTextInput, Toolbar } from 'enriched-text-input';
6
+ function Comment({ children }) {
7
+ return (
8
+ <Text style={{
9
+ backgroundColor: "rgba(255, 203, 0, .12)",
10
+ textDecorationLine: "underline",
11
+ textDecorationColor: "rgba(255, 203, 0, .35)",
12
+ }}>
13
+ {children}
14
+ </Text>
15
+ )
16
+ }
6
17
 
7
18
  export default function App() {
19
+ const [rawValue, setRawValue] = useState("");
8
20
  const richTextInputRef = useRef(null);
9
21
 
22
+ const customPatterns = [
23
+ ...PATTERNS,
24
+ { style: "comment", regex: null, render: Comment }
25
+ ];
26
+
27
+ const handleComment = () => {
28
+ richTextInputRef.current?.toggleStyle("comment");
29
+ }
30
+
31
+ const handleGetRichText = () => {
32
+ const richText = richTextInputRef.current?.getRichText();
33
+ console.log(richText);
34
+ }
35
+
10
36
  return (
11
- <View style={styles.container}>
12
- <RichTextInput ref={richTextInputRef}/>
13
- <Toolbar richTextInputRef={richTextInputRef} />
14
- <StatusBar style="auto" />
15
- </View>
37
+ <KeyboardAvoidingView style={styles.container} behavior="padding">
38
+ <View style={{ flex: 1 }}>
39
+ {/* <TextInput
40
+ style={{ fontSize: 20, padding: 16 }}
41
+ value={rawValue}
42
+ onChangeText={(text) => setRawValue(text)}
43
+ />
44
+ <Button
45
+ title='Set rich text string'
46
+ onPress={() => richTextInputRef.current?.setValue(rawValue)}
47
+ /> */}
48
+ <RichTextInput
49
+ ref={richTextInputRef}
50
+ patterns={customPatterns}/>
51
+
52
+ <Button
53
+ title='Get rich text string (check console)'
54
+ onPress={handleGetRichText}
55
+ />
56
+ </View>
57
+ <View style={{ alignSelf: "end"}}>
58
+ <Toolbar richTextInputRef={richTextInputRef}>
59
+ <Toolbar.Bold />
60
+ <Toolbar.Italic />
61
+ <Toolbar.Underline />
62
+ <Toolbar.Strikethrough />
63
+ <Toolbar.Code />
64
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleComment}>
65
+ <FontAwesome6 name="comment-alt" size={16} color="black" />
66
+ </TouchableOpacity>
67
+
68
+ <Toolbar.Keyboard />
69
+ </Toolbar>
70
+ </View>
71
+ </KeyboardAvoidingView>
16
72
  );
17
73
  }
18
74
 
@@ -22,4 +78,11 @@ const styles = StyleSheet.create({
22
78
  backgroundColor: '#fff',
23
79
  paddingTop: 120
24
80
  },
81
+ toolbarButton: {
82
+ height: 50,
83
+ width: 50,
84
+ display: "flex",
85
+ justifyContent: "center",
86
+ alignItems: "center"
87
+ }
25
88
  });
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import RichTextInput from "./src/RichTextInput";
1
+ import RichTextInput, { PATTERNS } from "./src/RichTextInput";
2
2
  import Toolbar from "./src/Toolbar";
3
3
 
4
- export { RichTextInput, Toolbar };
4
+ export { RichTextInput, PATTERNS, Toolbar };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enriched-text-input",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "JavaScript only rich text input component for React Native. Compatible with Expo Go.",
5
5
  "keywords": [
6
6
  "rich-text",
@@ -28,7 +28,8 @@
28
28
  "example"
29
29
  ],
30
30
  "scripts": {
31
- "test": "echo \"Error: no test specified\" && exit 1"
31
+ "test": "echo \"Error: no test specified\" && exit 1",
32
+ "release": "release-it"
32
33
  },
33
34
  "dependencies": {
34
35
  "@expo/vector-icons": "^15.0.3",
@@ -37,6 +38,7 @@
37
38
  "react-native": "0.81.5"
38
39
  },
39
40
  "devDependencies": {
40
- "metro-react-native-babel-preset": "^0.77.0"
41
+ "metro-react-native-babel-preset": "^0.77.0",
42
+ "release-it": "^19.0.6"
41
43
  }
42
44
  }
@@ -1,17 +1,10 @@
1
- import { useState, useImperativeHandle, useRef, useEffect } from "react";
1
+ import { useState, useImperativeHandle, useRef, useEffect, ReactElement, JSX } from "react";
2
2
  import { TextInput, Text, StyleSheet, View, Linking } from "react-native";
3
-
4
- const exampleText = "_None_ *bold* _italic_ ~strikethrough~ none";
3
+ import { get } from "react-native/Libraries/TurboModule/TurboModuleRegistry";
5
4
 
6
5
  interface Token {
7
6
  text: string;
8
- annotations: {
9
- bold: boolean;
10
- italic: boolean;
11
- lineThrough: boolean;
12
- underline: boolean;
13
- color: string;
14
- }
7
+ annotations: Annotations
15
8
  }
16
9
 
17
10
  interface Diff {
@@ -25,7 +18,8 @@ interface Annotations {
25
18
  italic: boolean;
26
19
  lineThrough: boolean;
27
20
  underline: boolean;
28
- color: string;
21
+ underlineLineThrough: boolean;
22
+ code: boolean;
29
23
  }
30
24
 
31
25
  interface RichTextMatch {
@@ -35,11 +29,27 @@ interface RichTextMatch {
35
29
  end: number;
36
30
  expression: string;
37
31
  }
32
+
33
+ interface Pattern {
34
+ regex: string;
35
+ style: string;
36
+ render: any
37
+ }
38
+
39
+ interface RichTextInputProps {
40
+ ref: any;
41
+ patterns?: Pattern[]
42
+ }
38
43
 
39
- const PATTERNS = [
40
- { style: "bold", regex: "\\*([^*]+)\\*" },
41
- { style: "italic", regex: "_([^_]+)_" },
42
- { style: "lineThrough", regex: "~([^~]+)~" },
44
+ export const PATTERNS : Pattern[] = [
45
+ { style: "bold", regex: "\\*([^*]+)\\*", render: Bold },
46
+ { style: "italic", regex: "_([^_]+)_", render: Italic },
47
+ { style: "lineThrough", regex: "~([^~]+)~", render: Strikethrough },
48
+ { style: "code", regex: "`([^`]+)`", render: Code },
49
+ { style: "underline", regex: "__([^_]+)__", render: Underline },
50
+ { style: "heading", regex: null, render: Heading },
51
+ { style: "subHeading", regex: null, render: SubHeading },
52
+ { style: "subSubHeading", regex: null, render: SubSubHeading }
43
53
  ];
44
54
 
45
55
  function insertAt(str, index, substring) {
@@ -72,7 +82,7 @@ function findMatch(str: string, regexExpression: string) : RichTextMatch | null
72
82
  : null;
73
83
  }
74
84
 
75
- function getRequiredLiterals(regexString) {
85
+ function getRequiredLiterals(regexString: string) {
76
86
  // Strip leading/trailing slashes and flags (if user passed /.../ form)
77
87
  regexString = regexString.replace(/^\/|\/[a-z]*$/g, "");
78
88
 
@@ -109,14 +119,19 @@ function getRequiredLiterals(regexString) {
109
119
  };
110
120
  }
111
121
 
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
- };
122
+ /**
123
+ * If prev token contains new annotation, negate prev. Else, use new annotation.
124
+ */
125
+ function concileAnnotations(prevAnnotations, newAnnotations) {
126
+ let updatedAnnotations = { ...prevAnnotations };
127
+
128
+ for (const key of Object.keys(newAnnotations)) {
129
+ newAnnotations[key]
130
+ ? updatedAnnotations[key] = !updatedAnnotations[key]
131
+ : updatedAnnotations[key] = newAnnotations[key];
132
+ }
133
+
134
+ return updatedAnnotations;
120
135
  }
121
136
 
122
137
  // Returns string modifications
@@ -147,26 +162,20 @@ const parseRichTextString = (richTextString: string, patterns: { regex: string,
147
162
  let tokens = initalTokens || [
148
163
  {
149
164
  text: richTextString,
150
- annotations: {
151
- bold: false,
152
- italic: false,
153
- lineThrough: false,
154
- underline: false,
155
- color: "black"
156
- }
165
+ annotations: {}
157
166
  }
158
167
  ];
159
168
  let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
160
169
 
161
170
  for (const pattern of patterns) {
162
- let match = findMatch(plain_text, pattern.regex);
171
+ let match = pattern.regex ? findMatch(plain_text, pattern.regex) : null;
163
172
 
164
173
  if (match) {
165
174
  const { result: splittedTokens } = splitTokens(
166
175
  tokens,
167
176
  match.start,
168
177
  match.end - 1,
169
- pattern.style,
178
+ { [pattern.style]: true },
170
179
  getRequiredLiterals(match.expression).opening
171
180
  );
172
181
  tokens = splittedTokens;
@@ -188,13 +197,27 @@ const parseRichTextString = (richTextString: string, patterns: { regex: string,
188
197
  }
189
198
 
190
199
  // Returns a rich text string
191
- const parseTokens = (tokens) => {
192
-
200
+ const parseTokens = (tokens: Token[], patterns: Pattern[]) => {
201
+ return tokens.map(token => {
202
+ const { text, annotations } = token;
203
+ const wrappers = [];
204
+
205
+ Object.keys(annotations).forEach(key => {
206
+ // If annotation has a truthy value, add the corresponding wrapper.
207
+ if (annotations[key]) wrappers.push(getRequiredLiterals(patterns.find(p => p.style === key).regex));
208
+ });
209
+
210
+ return wrappers.reduce(
211
+ (children, Wrapper) => `${Wrapper.opening}${children}${Wrapper.closing}`,
212
+ text
213
+ );
214
+ }).join("");
193
215
  }
194
216
 
195
217
  // Inserts a token at the given index
196
218
  // Only when start === end
197
- function insertToken(tokens: Token[], index: number, type: string, text = "" ) {
219
+ // To-do: Instead of recieving annotations and text it could recieve a token.
220
+ function insertToken(tokens: Token[], index: number, annotations: Annotations, text = "" ) {
198
221
  const updatedTokens = [...tokens];
199
222
 
200
223
  let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
@@ -203,10 +226,7 @@ function insertToken(tokens: Token[], index: number, type: string, text = "" ) {
203
226
  if (plain_text.length === index) {
204
227
  updatedTokens.push({
205
228
  text: text,
206
- annotations: {
207
- ...updatedTokens[updatedTokens.length - 1].annotations,
208
- [type]: !updatedTokens[updatedTokens.length - 1].annotations[type]
209
- }
229
+ annotations: concileAnnotations(updatedTokens[updatedTokens.length - 1].annotations, annotations)
210
230
  });
211
231
 
212
232
  return { result: updatedTokens.filter(token => token.text.length > 0) };
@@ -224,48 +244,35 @@ function insertToken(tokens: Token[], index: number, type: string, text = "" ) {
224
244
  }
225
245
 
226
246
  const startTokenIndex = updatedTokens.indexOf(startToken);
227
-
228
247
  let firstToken = {
229
248
  text: startToken.text.slice(0, startIndex),
230
- annotations: {
231
- ...startToken.annotations,
232
- [type]: startToken.annotations[type]
233
- }
249
+ annotations: startToken.annotations
234
250
  }
235
251
 
236
252
  // Middle token is the selected text
237
253
  let middleToken = {
238
254
  text: text,
239
- annotations: {
240
- ...startToken.annotations,
241
- [type]: !startToken.annotations[type]
242
- }
255
+ annotations: concileAnnotations(startToken.annotations, annotations) // prevAnnotations + newAnnotations
243
256
  }
244
257
 
245
258
  let lastToken = {
246
259
  text: startToken.text.slice(startIndex , startToken.text.length),
247
- annotations: {
248
- ...startToken.annotations,
249
- [type]: startToken.annotations[type]
250
- }
260
+ annotations: startToken.annotations
251
261
  }
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
262
 
259
263
  updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken);
260
-
261
264
  return {
262
265
  result: updatedTokens.filter(token => token.text.length > 0)
263
266
  };
264
267
  }
265
268
 
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
+ /**
270
+ * Updates token content (add, remove, replace)
271
+ * Note: need to support cross-token updates.
272
+ * It's actually updating just the text of tokens
273
+ * To-do: Separate the logic of finding the corresponding token into another function.
274
+ * Instead of recieving a diff it could recieve an array of tokens to update.
275
+ */
269
276
  const updateTokens = (tokens: Token[], diff: Diff) => {
270
277
  let updatedTokens = [...tokens];
271
278
  const plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
@@ -427,14 +434,16 @@ const updateTokens = (tokens: Token[], diff: Diff) => {
427
434
  }
428
435
  }
429
436
 
430
- // Updates annotations and splits tokens if necessary
431
- // Only when start !== end
432
- // To-do: Add support for multiple annotations
437
+ /**
438
+ * Updates annotations and splits tokens if necessary. Only when start !== end.
439
+ * To-do: Add support for multiple annotations. [done].
440
+ * To-do: Separate the logic of finding the corresponding token into another function.
441
+ */
433
442
  const splitTokens = (
434
443
  tokens: Token[],
435
444
  start: number,
436
445
  end: number,
437
- type: string,
446
+ annotations: Annotations,
438
447
  /** Used to strip opening and closing chars of rich text matches. */
439
448
  withReplacement?: string
440
449
  ) => {
@@ -483,56 +492,37 @@ const splitTokens = (
483
492
 
484
493
  let firstToken = {
485
494
  text: startToken.text.slice(0, startIndex),
486
- annotations: {
487
- ...startToken.annotations,
488
- [type]: startToken.annotations[type]
489
- }
495
+ annotations: startToken.annotations
490
496
  }
491
497
 
492
498
  // Middle token is the selected text
493
499
  let middleToken = {
494
500
  // The replace method is used to remove the opening and closing rich text literal chars when parsing.
495
501
  text: startToken.text.slice(startIndex, endIndex).replace(withReplacement, ""),
496
- annotations: {
497
- ...startToken.annotations,
498
- [type]: !startToken.annotations[type]
499
- }
502
+ /**
503
+ * We need to concile previous annotations with new ones.
504
+ * Eg. If we are applying bold to middle token but start token already has bold, we need to toggle bold off.
505
+ * But if we are applying bold to middle token but start token does not have bold, we need to toggle bold on.
506
+ */
507
+ annotations: concileAnnotations(startToken.annotations, annotations)
500
508
  }
501
509
 
502
510
  let lastToken = {
503
511
  // The replace method is used to remove the opening and closing rich text literal chars when parsing.
504
512
  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 };
513
+ annotations: startToken.annotations
526
514
  }
527
515
 
528
516
  updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken)
529
- return { result: updatedTokens };
517
+ return { result: updatedTokens.filter(token => token.text.length > 0) };
530
518
  }
531
519
 
532
520
  // Cross-token selection
533
521
  if (startTokenIndex !== endTokenIndex) {
534
522
  // Before splitting, check if all selected tokens already have the annotation
535
523
  const selectedTokens = updatedTokens.slice(startTokenIndex, endTokenIndex + 1);
524
+
525
+ const type = Object.keys(annotations)[0]; // When splitting we only pass one key to annotations param.
536
526
  const allSelectedTokensHaveAnnotation = selectedTokens.every((token) => token.annotations[type] === true);
537
527
 
538
528
  let firstToken = {
@@ -582,24 +572,8 @@ const splitTokens = (
582
572
  }
583
573
  }
584
574
 
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
575
  updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
602
- return { result: updatedTokens };
576
+ return { result: updatedTokens.filter(token => token.text.length > 0) };
603
577
  }
604
578
  }
605
579
 
@@ -615,11 +589,11 @@ const concatTokens = (tokens: Token[]) => {
615
589
 
616
590
  const prevToken = concatenedTokens[concatenedTokens.length - 1];
617
591
 
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) {
592
+ /**
593
+ * If prev token has all the same annotations as current token, we add curent token text to prev token
594
+ * and continue looping without adding current token to concatened tokens array.
595
+ */
596
+ if (Object.keys(prevToken.annotations).every(key => prevToken.annotations[key] === token.annotations[key])) {
623
597
  prevToken.text += token.text;
624
598
  continue;
625
599
  }
@@ -630,7 +604,87 @@ const concatTokens = (tokens: Token[]) => {
630
604
  return concatenedTokens;
631
605
  }
632
606
 
633
- export default function RichTextInput({ ref }) {
607
+ interface TokenProps {
608
+ token: Token;
609
+ patterns: Pattern[]
610
+ }
611
+
612
+ function Token(props: TokenProps) : JSX.Element {
613
+ const { token, patterns } = props;
614
+ const { text, annotations } = token;
615
+ const wrappers = [];
616
+
617
+ Object.keys(annotations).forEach(key => {
618
+ // If annotation has a truthy value, add the corresponding wrapper.
619
+ if (annotations[key]) wrappers.push(patterns.find(p => p.style === key).render);
620
+ });
621
+
622
+ return wrappers.reduce(
623
+ (children, Wrapper) => <Wrapper>{children}</Wrapper>,
624
+ text
625
+ );
626
+ }
627
+
628
+ function Code({ children }) {
629
+ return (
630
+ <Text style={styles.code}>{children}</Text>
631
+ )
632
+ }
633
+
634
+ function Bold({ children }) {
635
+ return (
636
+ <Text style={styles.bold}>{children}</Text>
637
+ )
638
+ }
639
+
640
+ function Italic({ children }) {
641
+ return (
642
+ <Text style={styles.italic}>{children}</Text>
643
+ )
644
+ }
645
+
646
+ function Underline({ children }) {
647
+ return (
648
+ <Text style={styles.underline}>{children}</Text>
649
+ )
650
+ }
651
+
652
+ function Strikethrough({ children }) {
653
+ return (
654
+ <Text style={styles.lineThrough}>{children}</Text>
655
+ )
656
+ }
657
+
658
+ function UnderlineStrikethrough({ children }) {
659
+ return (
660
+ <Text style={styles.underlineLineThrough}>{children}</Text>
661
+ )
662
+ }
663
+
664
+ function Heading({ children }) {
665
+ return (
666
+ <Text style={styles.heading}>{children}</Text>
667
+ )
668
+ }
669
+
670
+ function SubHeading({ children }) {
671
+ return (
672
+ <Text style={styles.subHeading}>{children}</Text>
673
+ )
674
+ }
675
+
676
+ function SubSubHeading({ children }) {
677
+ return (
678
+ <Text style={styles.subSubHeading}>{children}</Text>
679
+ )
680
+ }
681
+
682
+ export default function RichTextInput(props: RichTextInputProps) {
683
+ const {
684
+ ref,
685
+ patterns = PATTERNS
686
+ } = props;
687
+
634
688
  const inputRef = useRef<TextInput>(null);
635
689
  const selectionRef = useRef({ start: 0, end: 0 });
636
690
  const [tokens, setTokens] = useState([{
@@ -640,7 +694,7 @@ export default function RichTextInput({ ref }) {
640
694
  italic: false,
641
695
  lineThrough: false,
642
696
  underline: false,
643
- color: "black"
697
+ code: false
644
698
  }
645
699
  }]);
646
700
 
@@ -653,7 +707,7 @@ export default function RichTextInput({ ref }) {
653
707
  italic: false,
654
708
  lineThrough: false,
655
709
  underline: false,
656
- color: "black"
710
+ code: false
657
711
  }
658
712
  }])
659
713
  }
@@ -670,7 +724,7 @@ export default function RichTextInput({ ref }) {
670
724
  const [toSplit, setToSplit] = useState({
671
725
  start: 0,
672
726
  end: 0,
673
- type: null
727
+ annotations: {}
674
728
  });
675
729
 
676
730
  const handleSelectionChange = ({ nativeEvent }) => {
@@ -682,7 +736,7 @@ export default function RichTextInput({ ref }) {
682
736
 
683
737
  let match : RichTextMatch | null = null;
684
738
 
685
- for (const pattern of PATTERNS) {
739
+ for (const pattern of patterns) {
686
740
  match = findMatch(nextText, pattern.regex);
687
741
  if (match) break;
688
742
  }
@@ -690,38 +744,44 @@ export default function RichTextInput({ ref }) {
690
744
  if (match) {
691
745
  // Check token containing match
692
746
  // If token already haves this annotation, do not format and perform a simple updateToken.
693
- const annotation = PATTERNS.find(p => p.regex === match.expression);
747
+ const annotation = patterns.find(p => p.regex === match.expression);
694
748
  const { result } = splitTokens(
695
749
  tokens,
696
750
  match.start,
697
751
  match.end - 1,
698
- annotation.style,
752
+ { [annotation.style]: true },
699
753
  // Get the rich text opening char to replace it
700
754
  getRequiredLiterals(match.expression).opening
701
755
  );
702
756
  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
757
 
709
758
  setTokens([...concatTokens(result)]);
710
759
  prevTextRef.current = plain_text;
711
760
 
712
-
713
761
  return;
714
762
  }
715
763
 
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);
764
+ if (Object.values(toSplit.annotations).some(Boolean) && diff.start === toSplit.start && diff.start === toSplit.end) {
765
+ const { result } = insertToken(
766
+ tokens,
767
+ diff.start,
768
+ toSplit.annotations,
769
+ diff.added
770
+ );
718
771
  const plain_text = result.map(t => t.text).join("");
719
- setTokens([...concatTokens(result)]);
720
- setToSplit({ start: 0, end: 0, type: null });
772
+ setTokens(concatTokens(result));
773
+
774
+ // Reset
775
+ setToSplit({
776
+ start: 0,
777
+ end: 0,
778
+ annotations: {}
779
+ });
721
780
  prevTextRef.current = plain_text;
722
781
  return;
723
782
  }
724
783
 
784
+ // Default update
725
785
  const { updatedTokens, plain_text} = updateTokens(tokens, diff);
726
786
 
727
787
  setTokens([...concatTokens(updatedTokens)]);
@@ -729,140 +789,61 @@ export default function RichTextInput({ ref }) {
729
789
  }
730
790
 
731
791
  useImperativeHandle(ref, () => ({
732
- toggleBold() {
733
- const { start, end } = selectionRef.current;
734
792
 
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));
793
+ setValue(value: string) {
794
+ // To keep styles, parsing should be done before setting value
795
+ const { tokens, plain_text } = parseRichTextString(value, patterns);
796
+ setTokens([...concatTokens(tokens)]);
797
+ prevTextRef.current = plain_text;
759
798
  },
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));
799
+ getRichText() {
800
+ return parseTokens(tokens, patterns);
784
801
  },
785
- toggleLineThrough() {
802
+ toggleStyle(style: string) {
786
803
  const { start, end } = selectionRef.current;
787
804
 
788
- if (start === end && toSplit.type === "lineThrough") {
789
- setToSplit({ start: 0, end: 0, type: null });
790
- return;
791
- }
792
-
793
805
  if (start === end) {
794
- setToSplit({ start, end, type: "lineThrough" });
795
- return;
796
- }
797
-
798
- if (start < end) {
799
806
  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" });
807
+ start,
808
+ end,
809
+ annotations: concileAnnotations(toSplit.annotations, { [style]: true })
810
+ });
820
811
  return;
821
812
  }
822
813
 
814
+ /**
815
+ * This prevents that when a portion of text is set to bold, the next text inserted after it is not bold.
816
+ */
823
817
  if (start < end) {
824
818
  setToSplit({
825
819
  start: end,
826
820
  end: end,
827
- type: "underline"
821
+ annotations: concileAnnotations(toSplit.annotations, { [style]: true })
828
822
  })
829
823
  }
830
824
 
831
- const { result } = splitTokens(tokens, start, end, "underline");
825
+ const { result } = splitTokens(tokens, start, end, { [style]: true });
832
826
  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;
827
+ requestAnimationFrame(() => {
828
+ inputRef.current.setSelection(start, end);
829
+ })
840
830
  }
841
- }))
831
+ }));
842
832
 
843
833
  return (
844
834
  <View style={{ position: "relative" }}>
845
835
  <TextInput
846
836
  multiline={true}
847
837
  ref={inputRef}
848
- autoCorrect={false}
849
838
  autoComplete="off"
850
839
  style={styles.textInput}
851
840
  placeholder="Rich text input"
852
841
  onSelectionChange={handleSelectionChange}
853
842
  onChangeText={handleOnChangeText}
854
843
  >
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
- })}
844
+ <Text style={styles.text}>
845
+ {tokens.map((token, i) => <Token key={i} token={token} patterns={patterns}/>)}
846
+ </Text>
866
847
  </TextInput>
867
848
  </View>
868
849
  );
@@ -870,12 +851,13 @@ export default function RichTextInput({ ref }) {
870
851
 
871
852
  const styles = StyleSheet.create({
872
853
  textInput: {
873
- fontSize: 20,
874
854
  width: "100%",
875
- paddingHorizontal: 16
855
+ paddingHorizontal: 16,
856
+ fontSize: 20,
857
+ zIndex: 1
876
858
  },
877
859
  text: {
878
- color: "black"
860
+ color: "black",
879
861
  },
880
862
  bold: {
881
863
  fontWeight: 'bold',
@@ -889,12 +871,40 @@ const styles = StyleSheet.create({
889
871
  underline: {
890
872
  textDecorationLine: "underline",
891
873
  },
892
- comment: {
893
- textDecorationLine: "underline",
894
- textDecorationColor: "rgba(255, 203, 0, .35)",
895
- backgroundColor: "rgba(255, 203, 0, .12)"
896
- },
897
874
  underlineLineThrough: {
898
875
  textDecorationLine: "underline line-through"
876
+ },
877
+ codeContainer: {
878
+ backgroundColor: "lightgray",
879
+ paddingHorizontal: 4,
880
+ borderRadius: 4,
881
+ height: 24,
882
+ position: "absolute",
883
+ top: 10
884
+ },
885
+ code: {
886
+ fontFamily: "ui-monospace",
887
+ color: "#EB5757",
888
+ fontSize: 20,
889
+ backgroundColor: "rgba(135, 131, 120, .15)"
890
+ },
891
+ highlight: {
892
+ width: "100%",
893
+ position: "absolute",
894
+ padding: 20,
895
+ height: 24,
896
+ backgroundColor: "blue"
897
+ },
898
+ heading: {
899
+ fontSize: 32,
900
+ fontWeight: "bold"
901
+ },
902
+ subHeading: {
903
+ fontSize: 28,
904
+ fontWeight: "bold"
905
+ },
906
+ subSubHeading: {
907
+ fontSize: 24,
908
+ fontWeight: "bold"
899
909
  }
900
910
  });
package/src/Toolbar.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { View, Button, StyleSheet, TouchableOpacity, Keyboard } from "react-native";
2
- import { RefObject, Ref } from "react";
1
+ import { View, ScrollView, StyleSheet, TouchableOpacity, Keyboard } from "react-native";
2
+ import { useContext, createContext, Ref, useState, useEffect } from "react";
3
3
  import FontAwesome6 from '@expo/vector-icons/FontAwesome6';
4
4
 
5
5
  interface RichTextInput {
@@ -10,58 +10,169 @@ interface RichTextInput {
10
10
 
11
11
  interface ToolbarProps {
12
12
  richTextInputRef: Ref<RichTextInput>,
13
+ children: React.ReactNode
14
+ }
15
+
16
+ const ToolbarContext = createContext(null);
17
+
18
+ const useToolbarContext = () => {
19
+ const context = useContext(ToolbarContext);
20
+
21
+ return context;
13
22
  }
14
23
 
15
24
  export default function Toolbar({
16
- richTextInputRef
25
+ richTextInputRef,
26
+ children
17
27
  } : ToolbarProps) {
18
28
 
29
+ const [ref, setRef] = useState(null);
30
+
31
+ useEffect(() => {
32
+ setRef(richTextInputRef);
33
+ }, [richTextInputRef]);
34
+
35
+ return (
36
+ <ToolbarContext.Provider value={ref}>
37
+ <ScrollView
38
+ style={styles.toolbar}
39
+ horizontal
40
+ keyboardShouldPersistTaps="always"
41
+ >
42
+ {children}
43
+ </ScrollView>
44
+ </ToolbarContext.Provider>
45
+ );
46
+ }
47
+
48
+ Toolbar.Bold = () => {
49
+ const richTextInputRef = useToolbarContext();
50
+
19
51
  const handleBold = () => {
20
- richTextInputRef.current.toggleBold();
52
+ richTextInputRef.current.toggleStyle("bold");
21
53
  }
22
54
 
55
+ return (
56
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleBold}>
57
+ <FontAwesome6 name="bold" size={16} color="black" />
58
+ </TouchableOpacity>
59
+ )
60
+ }
61
+
62
+ Toolbar.Italic = () => {
63
+ const richTextInputRef = useToolbarContext();
64
+
23
65
  const handleItalic = () => {
24
- richTextInputRef.current.toggleItalic();
66
+ richTextInputRef.current.toggleStyle("italic");
25
67
  }
26
68
 
69
+ return (
70
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleItalic}>
71
+ <FontAwesome6 name="italic" size={16} color="black" />
72
+ </TouchableOpacity>
73
+ )
74
+ }
75
+
76
+ Toolbar.Strikethrough = () => {
77
+ const richTextInputRef = useToolbarContext();
78
+
27
79
  const handleLineThrough = () => {
28
- richTextInputRef.current.toggleLineThrough();
80
+ richTextInputRef.current.toggleStyle("lineThrough");
29
81
  }
30
82
 
83
+ return (
84
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleLineThrough}>
85
+ <FontAwesome6 name="strikethrough" size={16} color="black" />
86
+ </TouchableOpacity>
87
+ )
88
+ }
89
+
90
+ Toolbar.Underline = () => {
91
+ const richTextInputRef = useToolbarContext();
92
+
31
93
  const handleUnderline = () => {
32
- richTextInputRef.current.toggleUnderline();
94
+ richTextInputRef.current.toggleStyle("underline");
95
+ }
96
+
97
+ return (
98
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleUnderline}>
99
+ <FontAwesome6 name="underline" size={16} color="black" />
100
+ </TouchableOpacity>
101
+ )
102
+ }
103
+
104
+ Toolbar.Heading = () => {
105
+ const richTextInputRef = useToolbarContext();
106
+
107
+ const handleHeading = () => {
108
+ richTextInputRef.current.toggleStyle("heading");
109
+ }
110
+
111
+ return (
112
+ <TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleHeading}>
113
+ <FontAwesome6 name="heading" size={16} color="black" />
114
+ <FontAwesome6 name="1" size={16} color="black" />
115
+ </TouchableOpacity>
116
+ )
117
+ }
118
+
119
+ Toolbar.SubHeading = () => {
120
+ const richTextInputRef = useToolbarContext();
121
+
122
+ const handleSubHeading = () => {
123
+ richTextInputRef.current.toggleStyle("subHeading");
124
+ }
125
+
126
+ return (
127
+ <TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleSubHeading}>
128
+ <FontAwesome6 name="heading" size={16} color="black" />
129
+ <FontAwesome6 name="2" size={16} color="black" />
130
+ </TouchableOpacity>
131
+ )
132
+ }
133
+
134
+ Toolbar.SubSubHeading = () => {
135
+ const richTextInputRef = useToolbarContext();
136
+
137
+ const handleSubSubHeading = () => {
138
+ richTextInputRef.current.toggleStyle("subSubHeading");
139
+ }
140
+
141
+ return (
142
+ <TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleSubSubHeading}>
143
+ <FontAwesome6 name="heading" size={16} color="black" />
144
+ <FontAwesome6 name="3" size={16} color="black" />
145
+ </TouchableOpacity>
146
+ )
147
+ }
148
+
149
+ Toolbar.Code = () => {
150
+ const richTextInputRef = useToolbarContext();
151
+
152
+ const handleCode = () => {
153
+ richTextInputRef.current.toggleStyle("code");
33
154
  }
34
155
 
156
+ return (
157
+ <TouchableOpacity style={styles.toolbarButton} onPress={handleCode}>
158
+ <FontAwesome6 name="code" size={16} color="black" />
159
+ </TouchableOpacity>
160
+ )
161
+ }
162
+
163
+ Toolbar.Keyboard = () => {
35
164
  const handleKeyboardDismiss = () => {
36
165
  Keyboard.dismiss();
37
166
  }
38
167
 
39
168
  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
- );
169
+ <TouchableOpacity style={[styles.toolbarButton, styles.keyboardDown]} onPress={handleKeyboardDismiss}>
170
+ <FontAwesome6 name="keyboard" size={16} color="black" />
171
+ <View style={styles.keyboardArrowContainer}>
172
+ <FontAwesome6 name="chevron-down" size={8} color="black"/>
173
+ </View>
174
+ </TouchableOpacity>
175
+ )
65
176
  }
66
177
 
67
178
  const styles = StyleSheet.create({
@@ -90,5 +201,9 @@ const styles = StyleSheet.create({
90
201
  keyboardArrowContainer: {
91
202
  position: "absolute",
92
203
  bottom: 13
204
+ },
205
+ heading: {
206
+ flexDirection: "row",
207
+ gap: 4
93
208
  }
94
209
  });