enriched-text-input 1.0.3 → 1.0.4

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.
@@ -0,0 +1 @@
1
+ # API Reference
package/README.md CHANGED
@@ -14,21 +14,6 @@ The field for rich-text in react native is still a bit green. Current libraries
14
14
 
15
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
- ## Features
18
-
19
- - [x] Basic text formatting (__bold__, _italic_, underline, ~~strikethrough~~ and `codeblocks`).
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
- - Only `Text`component styles are supported.
31
-
32
17
  ## Installation
33
18
  ```
34
19
  npm install enriched-text-input
@@ -66,9 +51,27 @@ const styles = StyleSheet.create({
66
51
  paddingTop: 120
67
52
  },
68
53
  });
54
+ ```
69
55
 
56
+ ## Current state
57
+ At the moment [1/1/2026] `enriched-text-input` works great for things such as small rich-text inputs (Eg. an input for a messaging app with rich-text support) but not for creating whole rich-text editors. This is because inline styles that do not break line are working as expected (Eg. bold, italic or underline work great but styles such as headings break line so they are currently not supported).
70
58
 
71
- ```
59
+ Live parsing of rich text symbols (such as wrapping words in asterisks `*`) is still a work in progress an not working correctly but you can toggle styles through the ref api of the `EnrichedTextInput` (or use the provided `Toolbar` component as shown in the example usage).
60
+
61
+ ## Features
62
+
63
+ - [x] Basic text formatting (__bold__, _italic_, underline, ~~strikethrough~~ and `inline code`).
64
+ - [ ] Rich text format parsing.
65
+ - [ ] Links and mentions.
66
+ - [x] Custom styling.
67
+ - [x] Custom rich text patterns.
68
+ - [ ] Exposed event handlers (onSubmit, onChange, onBlur, onFocus, etc).
69
+ - [ ] Custom methods and event handlers (setValue, onStartMention, onStyleChange, etc).
70
+ - [ ] Headings.
71
+
72
+ ## Known limitations
73
+ - Inline images.
74
+ - Only `Text`component styles are supported.
72
75
 
73
76
  ## Contributing
74
77
 
package/example/App.tsx CHANGED
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
2
2
  import { StyleSheet, View, KeyboardAvoidingView, Text, TouchableOpacity, Button, TextInput } from 'react-native';
3
3
  import { FontAwesome6 } from '@expo/vector-icons';
4
4
  import { RichTextInput, Toolbar, PATTERNS } from 'enriched-text-input';
5
+ import * as Clipboard from 'expo-clipboard';
5
6
 
6
7
  function Comment({ children }) {
7
8
  return (
@@ -17,6 +18,9 @@ function Comment({ children }) {
17
18
 
18
19
  export default function App() {
19
20
  const [rawValue, setRawValue] = useState("");
21
+ const [richTextStringValue, setRichTextStringValue] = useState("");
22
+ const [activeStyles, setActiveStyles] = useState([]);
23
+ console.log("ACTIVE STYLES:", activeStyles);
20
24
  const richTextInputRef = useRef(null);
21
25
 
22
26
  const customPatterns = [
@@ -29,42 +33,64 @@ export default function App() {
29
33
  }
30
34
 
31
35
  const handleGetRichText = () => {
32
- const richText = richTextInputRef.current?.getRichText();
33
- console.log(richText);
36
+ const richText = richTextInputRef.current?.getRichTextString();
37
+
38
+ setRichTextStringValue(richText);
39
+ }
40
+
41
+ const handleCopyToClipboard = async (text: string) => {
42
+ await Clipboard.setStringAsync(text);
34
43
  }
35
44
 
36
45
  return (
37
46
  <KeyboardAvoidingView style={styles.container} behavior="padding">
38
47
  <View style={{ flex: 1 }}>
39
- {/* <TextInput
48
+ <TextInput
49
+ multiline
40
50
  style={{ fontSize: 20, padding: 16 }}
41
51
  value={rawValue}
42
52
  onChangeText={(text) => setRawValue(text)}
53
+ placeholder='Raw text'
43
54
  />
44
55
  <Button
45
56
  title='Set rich text string'
46
57
  onPress={() => richTextInputRef.current?.setValue(rawValue)}
47
- /> */}
58
+ />
59
+
48
60
  <RichTextInput
49
61
  ref={richTextInputRef}
50
- patterns={customPatterns}/>
62
+ patterns={customPatterns}
63
+ autoComplete="off"
64
+ placeholder="Rich text"
65
+ multiline={true}
66
+ />
67
+ <Button
68
+ title='Get rich text string'
69
+ onPress={handleGetRichText}
70
+ />
71
+ <Text style={{ padding: 16, fontSize: 20, color: richTextStringValue ? "black" : "#b3b3b3" }}>{richTextStringValue ? richTextStringValue : "Get rich text output will appear here!"}</Text>
51
72
 
73
+ <View style={{ flexDirection: "row", justifyContent: "center"}}>
52
74
  <Button
53
- title='Get rich text string (check console)'
54
- onPress={handleGetRichText}
55
- />
75
+ title='Clear'
76
+ onPress={() => setRichTextStringValue("")}
77
+ />
78
+ <Button
79
+ title='Copy'
80
+ onPress={() => handleCopyToClipboard(richTextStringValue)}
81
+ />
82
+ </View>
56
83
  </View>
57
84
  <View style={{ alignSelf: "end"}}>
58
85
  <Toolbar richTextInputRef={richTextInputRef}>
59
- <Toolbar.Bold />
86
+ <Toolbar.Bold/>
60
87
  <Toolbar.Italic />
61
88
  <Toolbar.Underline />
62
89
  <Toolbar.Strikethrough />
63
90
  <Toolbar.Code />
64
- <TouchableOpacity style={styles.toolbarButton} onPress={handleComment}>
91
+ {/* <TouchableOpacity style={styles.toolbarButton} onPress={handleComment}>
65
92
  <FontAwesome6 name="comment-alt" size={16} color="black" />
66
- </TouchableOpacity>
67
-
93
+ </TouchableOpacity> */}
68
94
  <Toolbar.Keyboard />
69
95
  </Toolbar>
70
96
  </View>
@@ -10,11 +10,12 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@expo/vector-icons": "^15.0.3",
13
+ "enriched-text-input": "file:../",
13
14
  "expo": "~54.0.25",
15
+ "expo-clipboard": "~8.0.8",
14
16
  "expo-status-bar": "~3.0.8",
15
17
  "react": "19.1.0",
16
- "react-native": "0.81.5",
17
- "enriched-text-input": "file:../"
18
+ "react-native": "0.81.5"
18
19
  },
19
20
  "private": true,
20
21
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enriched-text-input",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "JavaScript only rich text input component for React Native. Compatible with Expo Go.",
5
5
  "keywords": [
6
6
  "rich-text",
@@ -1,6 +1,5 @@
1
- import { useState, useImperativeHandle, useRef, useEffect, ReactElement, JSX } from "react";
2
- import { TextInput, Text, StyleSheet, View, Linking } from "react-native";
3
- import { get } from "react-native/Libraries/TurboModule/TurboModuleRegistry";
1
+ import { useState, useImperativeHandle, useRef, useEffect, JSX } from "react";
2
+ import { TextInput, Text, StyleSheet, View, TextInputProps } from "react-native";
4
3
 
5
4
  interface Token {
6
5
  text: string;
@@ -14,12 +13,7 @@ interface Diff {
14
13
  }
15
14
 
16
15
  interface Annotations {
17
- bold: boolean;
18
- italic: boolean;
19
- lineThrough: boolean;
20
- underline: boolean;
21
- underlineLineThrough: boolean;
22
- code: boolean;
16
+ [key: string]: boolean | string | null
23
17
  }
24
18
 
25
19
  interface RichTextMatch {
@@ -27,29 +21,39 @@ interface RichTextMatch {
27
21
  content: string;
28
22
  start: number;
29
23
  end: number;
24
+ pattern: Pattern;
25
+ /** @deprecated */
30
26
  expression: string;
31
27
  }
32
28
 
33
29
  interface Pattern {
34
30
  regex: string;
35
31
  style: string;
36
- render: any
32
+ render: any;
33
+ opening?: string;
34
+ closing?: string;
37
35
  }
38
36
 
39
37
  interface RichTextInputProps {
40
38
  ref: any;
41
39
  patterns?: Pattern[]
42
40
  }
43
-
41
+
42
+ /**
43
+ * Note: maybe instead of using regex we could just define an "opening" and "closing" char.
44
+ * If both are defined we look for a match that looks like {opening}{content}{closing}.
45
+ * If just opening is defined, we look for a match that looks like {opening}{content}.
46
+ * Closing can not be defined if opening is not defined.
47
+ */
44
48
  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 }
49
+ { style: "bold", regex: "\\*([^*]+)\\*", render: Bold, opening: "*", closing: "*" },
50
+ { style: "italic", regex: "_([^_]+)_", render: Italic, opening: "_", closing: "_" },
51
+ { style: "lineThrough", regex: "~([^~]+)~", render: Strikethrough, opening: "~", closing: "~" },
52
+ { style: "code", regex: "`([^`]+)`", render: Code, opening: "`", closing: "`" },
53
+ { style: "underline", regex: "__([^_]+)__", render: Underline, opening: "__", closing: "__" },
54
+ { style: "heading", regex: null, render: Heading, opening: "#", closing: null },
55
+ { style: "subHeading", regex: null, render: SubHeading, opening: "##", closing: null },
56
+ { style: "subSubHeading", regex: null, render: SubSubHeading, opening: "###", closing: null }
53
57
  ];
54
58
 
55
59
  function insertAt(str, index, substring) {
@@ -68,55 +72,73 @@ function replaceAt(str, index, substring, length) {
68
72
  return str.slice(0, i) + substring + str.slice(i + length);
69
73
  }
70
74
 
71
- function findMatch(str: string, regexExpression: string) : RichTextMatch | null {
72
- const regex = new RegExp(regexExpression);
73
- const match = regex.exec(str);
74
- return match
75
- ? {
76
- raw: match[0],
77
- content: match[1],
78
- start: match.index,
79
- end: match.index + match[0].length,
80
- expression: regexExpression
75
+ function findTokens(
76
+ /** The tokens to search over. */
77
+ tokens: Token[],
78
+ /** start position of selection.*/
79
+ start: number,
80
+ /** end position of selection.*/
81
+ end?: number
82
+ ) {
83
+
84
+ if (end) {
85
+ // Search for all tokens between start and end
86
+ return { result: null };
81
87
  }
82
- : null;
83
- }
84
88
 
85
- function getRequiredLiterals(regexString: string) {
86
- // Strip leading/trailing slashes and flags (if user passed /.../ form)
87
- regexString = regexString.replace(/^\/|\/[a-z]*$/g, "");
89
+ let startIndex = start;
90
+ let startToken;
91
+ for (const token of tokens) {
92
+ if (startIndex <= token.text.length) {
93
+ startToken = token;
94
+ break;
95
+ }
96
+ startIndex -= token.text.length;
97
+ }
98
+
99
+ const startTokenIndex = tokens.indexOf(startToken);
100
+
101
+ return { result: [startToken] };
102
+ }
88
103
 
89
- // Remove ^ and $ anchors
90
- regexString = regexString.replace(/^\^|\$$/g, "");
104
+ /**
105
+ * To-do: Add support for openings and closings that are conformed by two or more chars (e.g. **, __, <b>, etc.)
106
+ */
107
+ function findMatchV2(str: string, patterns: Pattern[]) : RichTextMatch | null {
108
+ let match = null;
109
+ let copyOfString = str;
91
110
 
92
- // 1. Find the first literal before any group or operator
93
- const beforeGroup = regexString.match(/^((?:\\.|[^[(])+)/);
94
- let openLiteral = null;
111
+ for (const pattern of patterns) {
112
+ let evenCount = 0;
95
113
 
96
- if (beforeGroup) {
97
- const part = beforeGroup[1];
98
- const litMatch = part.match(/\\(.)|([^\\])/); // first literal
99
- if (litMatch) {
100
- openLiteral = litMatch[1] ?? litMatch[2];
101
- }
102
- }
114
+ for (const char of copyOfString) {
115
+ /** Cases where both opening and closing chars are defined (*...*, _..._, etc.)*/
116
+ if (pattern.opening && pattern.closing) {
117
+ if (evenCount < 2 && char === pattern.opening) {
118
+ evenCount++;
119
+ }
120
+ if (evenCount === 2 && char === pattern.closing) {
121
+ const openingIndex = copyOfString.indexOf(pattern.opening);
122
+ const closingIndex = copyOfString.indexOf(pattern.closing, openingIndex + 1);
123
+
124
+ match = {
125
+ raw: copyOfString.slice(openingIndex, closingIndex + 1),
126
+ content: copyOfString.slice(openingIndex + 1, closingIndex),
127
+ start: openingIndex,
128
+ end: closingIndex,
129
+ pattern,
130
+ /** @deprecated */
131
+ expression: pattern.regex
132
+ };
133
+ break;
134
+ }
135
+ }
103
136
 
104
- // 2. Detect a closing literal after a capturing group (optional)
105
- let closeLiteral = null;
106
- const afterGroup = regexString.match(/\)([^).]+)/);
107
- if (afterGroup) {
108
- const part = afterGroup[1];
109
- const litMatch = part.match(/\\(.)|([^\\])/);
110
- if (litMatch) {
111
- closeLiteral = litMatch[1] ?? litMatch[2];
137
+ /** Cases where only opening char is defined (@, #, etc.) */
138
+ }
112
139
  }
113
- }
114
140
 
115
- // Return both if available, otherwise just the opening literal
116
- return {
117
- opening: openLiteral,
118
- closing: closeLiteral,
119
- };
141
+ return match;
120
142
  }
121
143
 
122
144
  /**
@@ -157,58 +179,65 @@ function diffStrings(prev, next) : Diff {
157
179
  };
158
180
  }
159
181
 
160
- // Returns an array of tokens
161
- const parseRichTextString = (richTextString: string, patterns: { regex: string, style: string }[], initalTokens = null) => {
162
- let tokens = initalTokens || [
182
+ /**
183
+ * Parse rich text string into tokens.
184
+ */
185
+ const parseRichTextString = (richTextString: string, patterns: Pattern[], initialTokens?: Token[])
186
+ : { tokens: Token[], plain_text: string } => {
187
+ let copyOfString = richTextString;
188
+ let tokens : Token[] = initialTokens || [
163
189
  {
164
- text: richTextString,
190
+ text: copyOfString,
165
191
  annotations: {}
166
192
  }
167
193
  ];
168
- let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
169
194
 
170
195
  for (const pattern of patterns) {
171
- let match = pattern.regex ? findMatch(plain_text, pattern.regex) : null;
172
-
173
- if (match) {
174
- const { result: splittedTokens } = splitTokens(
175
- tokens,
176
- match.start,
177
- match.end - 1,
178
- { [pattern.style]: true },
179
- getRequiredLiterals(match.expression).opening
180
- );
181
- tokens = splittedTokens;
182
- plain_text = splittedTokens.reduce((acc, curr) => acc + curr.text, "");
183
-
184
- const parsed = parseRichTextString(tokens, patterns, tokens);
185
-
186
- return {
187
- tokens: parsed.tokens,
188
- plain_text: parsed.plain_text
196
+ let evenCount = 0;
197
+
198
+ for (const char of copyOfString) {
199
+ /** Cases where both opening and closing chars are defined (*...*, _..._, etc.)*/
200
+ if (pattern.opening && pattern.closing) {
201
+ if (evenCount < 2 && char === pattern.opening) {
202
+ evenCount++;
203
+ }
204
+ if (evenCount === 2 && char === pattern.closing) {
205
+ const openingIndex = copyOfString.indexOf(pattern.opening);
206
+ const closingIndex = copyOfString.indexOf(pattern.closing, openingIndex + 1);
207
+
208
+ copyOfString = copyOfString.slice(0, openingIndex) + copyOfString.slice(closingIndex);
209
+ const { result, plain_text } = splitTokens(tokens, openingIndex, closingIndex, { [pattern.style]: true }, pattern.opening);
210
+ tokens = result;
211
+ copyOfString = plain_text;
212
+ }
189
213
  }
214
+
215
+ /** Cases where only opening char is defined (@, #, etc.) */
190
216
  }
191
217
  }
192
218
 
193
219
  return {
194
- tokens: tokens.filter(token => token.text.length > 0),
195
- plain_text: plain_text
196
- }
220
+ tokens,
221
+ plain_text: copyOfString
222
+ };
197
223
  }
198
224
 
199
- // Returns a rich text string
225
+ /**
226
+ * Parse tokens into rich text string.
227
+ */
200
228
  const parseTokens = (tokens: Token[], patterns: Pattern[]) => {
201
229
  return tokens.map(token => {
202
230
  const { text, annotations } = token;
231
+ // Rich text wrappers (opening and closing chars)
203
232
  const wrappers = [];
204
233
 
205
- Object.keys(annotations).forEach(key => {
234
+ patterns.forEach(pattern => {
206
235
  // If annotation has a truthy value, add the corresponding wrapper.
207
- if (annotations[key]) wrappers.push(getRequiredLiterals(patterns.find(p => p.style === key).regex));
236
+ if (annotations[pattern.style]) wrappers.push(pattern.opening);
208
237
  });
209
238
 
210
239
  return wrappers.reduce(
211
- (children, Wrapper) => `${Wrapper.opening}${children}${Wrapper.closing}`,
240
+ (children, wrapper) => `${wrapper}${children}${wrapper}`,
212
241
  text
213
242
  );
214
243
  }).join("");
@@ -268,7 +297,6 @@ function insertToken(tokens: Token[], index: number, annotations: Annotations, t
268
297
 
269
298
  /**
270
299
  * Updates token content (add, remove, replace)
271
- * Note: need to support cross-token updates.
272
300
  * It's actually updating just the text of tokens
273
301
  * To-do: Separate the logic of finding the corresponding token into another function.
274
302
  * Instead of recieving a diff it could recieve an array of tokens to update.
@@ -436,7 +464,6 @@ const updateTokens = (tokens: Token[], diff: Diff) => {
436
464
 
437
465
  /**
438
466
  * Updates annotations and splits tokens if necessary. Only when start !== end.
439
- * To-do: Add support for multiple annotations. [done].
440
467
  * To-do: Separate the logic of finding the corresponding token into another function.
441
468
  */
442
469
  const splitTokens = (
@@ -514,7 +541,10 @@ const splitTokens = (
514
541
  }
515
542
 
516
543
  updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken)
517
- return { result: updatedTokens.filter(token => token.text.length > 0) };
544
+ return {
545
+ result: updatedTokens.filter(token => token.text.length > 0),
546
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
547
+ };
518
548
  }
519
549
 
520
550
  // Cross-token selection
@@ -573,11 +603,14 @@ const splitTokens = (
573
603
  }
574
604
 
575
605
  updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
576
- return { result: updatedTokens.filter(token => token.text.length > 0) };
606
+ return {
607
+ result: updatedTokens.filter(token => token.text.length > 0),
608
+ plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
609
+ };
577
610
  }
578
611
  }
579
612
 
580
- // Concats tokens containing similar annotations
613
+ // Concats tokens containing same annotations
581
614
  const concatTokens = (tokens: Token[]) => {
582
615
  let concatenedTokens = [];
583
616
 
@@ -593,7 +626,8 @@ const concatTokens = (tokens: Token[]) => {
593
626
  * If prev token has all the same annotations as current token, we add curent token text to prev token
594
627
  * and continue looping without adding current token to concatened tokens array.
595
628
  */
596
- if (Object.keys(prevToken.annotations).every(key => prevToken.annotations[key] === token.annotations[key])) {
629
+ const prevTokenAnnotations = Object.keys(prevToken.annotations);
630
+ if (prevTokenAnnotations.length > 0 && prevTokenAnnotations.every(key => prevToken.annotations[key] === token.annotations[key])) {
597
631
  prevToken.text += token.text;
598
632
  continue;
599
633
  }
@@ -614,11 +648,10 @@ function Token(props: TokenProps) : JSX.Element {
614
648
  const { text, annotations } = token;
615
649
  const wrappers = [];
616
650
 
617
- Object.keys(annotations).forEach(key => {
651
+ patterns.forEach(pattern => {
618
652
  // If annotation has a truthy value, add the corresponding wrapper.
619
- if (annotations[key]) wrappers.push(patterns.find(p => p.style === key).render);
653
+ if (annotations[pattern.style]) wrappers.push(pattern.render);
620
654
  });
621
-
622
655
  return wrappers.reduce(
623
656
  (children, Wrapper) => <Wrapper>{children}</Wrapper>,
624
657
  text
@@ -682,7 +715,14 @@ function SubSubHeading({ children }) {
682
715
  export default function RichTextInput(props: RichTextInputProps) {
683
716
  const {
684
717
  ref,
685
- patterns = PATTERNS
718
+ patterns = PATTERNS,
719
+
720
+ /** TextInput props */
721
+ value,
722
+ defaultValue,
723
+ onChangeText,
724
+ onSelectionChange,
725
+ ...rest
686
726
  } = props;
687
727
 
688
728
  const inputRef = useRef<TextInput>(null);
@@ -697,7 +737,6 @@ export default function RichTextInput(props: RichTextInputProps) {
697
737
  code: false
698
738
  }
699
739
  }]);
700
-
701
740
  useEffect(() => {
702
741
  if (tokens.length === 0) {
703
742
  setTokens([{
@@ -719,41 +758,43 @@ export default function RichTextInput(props: RichTextInputProps) {
719
758
  */
720
759
  const prevTextRef = useRef(tokens.map(t => t.text).join(""));
721
760
 
722
- // Find a better name
723
- // To-do: Allow for multiple styles at once.
761
+ /**
762
+ * To-do: Find a better name.
763
+ * toSplit state is used to toggle styles when selection length === 0 (start === end).
764
+ * Eg, if user is typing with no styles applied and then presses the "bold" button with no text selected,
765
+ * the text to be inserted after that press should be styled as bold.
766
+ * The same happens to toggle of a style. If user is typing in "bold" and presses the "bold" button again,
767
+ * the text to be inserted after that press should not be styled as bold.
768
+ */
724
769
  const [toSplit, setToSplit] = useState({
725
770
  start: 0,
726
771
  end: 0,
727
772
  annotations: {}
728
773
  });
729
-
774
+ /* console.log("toSplit", toSplit); */
730
775
  const handleSelectionChange = ({ nativeEvent }) => {
731
776
  selectionRef.current = nativeEvent.selection;
777
+ onSelectionChange && onSelectionChange(nativeEvent);
732
778
  }
733
779
 
734
780
  const handleOnChangeText = (nextText: string) => {
735
781
  const diff = diffStrings(prevTextRef.current, nextText);
736
782
 
737
- let match : RichTextMatch | null = null;
738
-
739
- for (const pattern of patterns) {
740
- match = findMatch(nextText, pattern.regex);
741
- if (match) break;
742
- }
783
+ const match = findMatchV2(nextText, patterns);
784
+ /* console.log("MATCH:", match); */
743
785
 
786
+ // Note: refactor to use new parseRichText function instead of regex
744
787
  if (match) {
745
788
  // Check token containing match
746
789
  // If token already haves this annotation, do not format and perform a simple updateToken.
747
- const annotation = patterns.find(p => p.regex === match.expression);
748
- const { result } = splitTokens(
790
+ const { result, plain_text } = splitTokens(
749
791
  tokens,
750
792
  match.start,
751
- match.end - 1,
752
- { [annotation.style]: true },
793
+ match.end/* - 1 */, // I don't remember why the -1
794
+ { [match.pattern.style]: true },
753
795
  // Get the rich text opening char to replace it
754
- getRequiredLiterals(match.expression).opening
796
+ match.pattern.opening
755
797
  );
756
- const plain_text = result.reduce((acc, curr) => acc + curr.text, "");
757
798
 
758
799
  setTokens([...concatTokens(result)]);
759
800
  prevTextRef.current = plain_text;
@@ -761,6 +802,7 @@ export default function RichTextInput(props: RichTextInputProps) {
761
802
  return;
762
803
  }
763
804
 
805
+
764
806
  if (Object.values(toSplit.annotations).some(Boolean) && diff.start === toSplit.start && diff.start === toSplit.end) {
765
807
  const { result } = insertToken(
766
808
  tokens,
@@ -789,17 +831,53 @@ export default function RichTextInput(props: RichTextInputProps) {
789
831
  }
790
832
 
791
833
  useImperativeHandle(ref, () => ({
792
-
793
- setValue(value: string) {
834
+ /**
835
+ * Sets the TextInput's value as a rich text string or an array of tokens.
836
+ */
837
+ setValue(value: string | Token[]) {
838
+ if (Array.isArray(value)) {
839
+ // Maybe check if tokens structure is valid before setting.
840
+ setTokens(value);
841
+ return;
842
+ }
794
843
  // To keep styles, parsing should be done before setting value
795
844
  const { tokens, plain_text } = parseRichTextString(value, patterns);
796
- setTokens([...concatTokens(tokens)]);
845
+ setTokens(tokens);
797
846
  prevTextRef.current = plain_text;
798
847
  },
799
- getRichText() {
848
+ /**
849
+ * Sets the TextInput's selection.
850
+ */
851
+ setSelection(start: number, end: number) {
852
+ inputRef.current.setSelection(start, end);
853
+ },
854
+ /**
855
+ * Focuses the TextInput.
856
+ */
857
+ focus() {
858
+ inputRef.current.focus();
859
+ },
860
+ blur() {
861
+ inputRef.current.blur();
862
+ },
863
+ /**
864
+ * Returns the TextInput's value as a rich text string matching the patterns
865
+ * for each style defined in the patterns prop. If a style does not define an
866
+ * opening and closing char, it is ignored.
867
+ */
868
+ getRichTextString() {
800
869
  return parseTokens(tokens, patterns);
801
870
  },
802
- toggleStyle(style: string) {
871
+ /**
872
+ * Returns the TextInput's value as an array of tokens with annotations.
873
+ */
874
+ getTokenizedString() : Token[] {
875
+ return tokens;
876
+ },
877
+ /**
878
+ * Toggles a given style. The style prop must match the name of a pattern.
879
+ */
880
+ toggleStyle(style: keyof Token["annotations"]) {
803
881
  const { start, end } = selectionRef.current;
804
882
 
805
883
  if (start === end) {
@@ -811,35 +889,35 @@ export default function RichTextInput(props: RichTextInputProps) {
811
889
  return;
812
890
  }
813
891
 
814
- /**
815
- * This prevents that when a portion of text is set to bold, the next text inserted after it is not bold.
816
- */
817
- if (start < end) {
818
- setToSplit({
819
- start: end,
820
- end: end,
821
- annotations: concileAnnotations(toSplit.annotations, { [style]: true })
822
- })
823
- }
824
-
825
892
  const { result } = splitTokens(tokens, start, end, { [style]: true });
826
893
  setTokens([...concatTokens(result)]);
827
894
  requestAnimationFrame(() => {
828
895
  inputRef.current.setSelection(start, end);
829
- })
896
+ });
897
+ },
898
+ /**
899
+ * Returns the active styles for the current selection.
900
+ */
901
+ getActiveStyles() {
902
+ // Check for styles of the token at the current cursor position.
903
+ const { result } = findTokens(tokens, selectionRef.current.start);
904
+
905
+ if (result[0].annotations) {
906
+ return Object.keys(result[0].annotations).filter(key => result[0].annotations[key]);
907
+ }
908
+
909
+ return [];
830
910
  }
831
911
  }));
832
912
 
833
913
  return (
834
914
  <View style={{ position: "relative" }}>
835
915
  <TextInput
836
- multiline={true}
837
916
  ref={inputRef}
838
- autoComplete="off"
839
917
  style={styles.textInput}
840
- placeholder="Rich text input"
841
918
  onSelectionChange={handleSelectionChange}
842
919
  onChangeText={handleOnChangeText}
920
+ {...rest}
843
921
  >
844
922
  <Text style={styles.text}>
845
923
  {tokens.map((token, i) => <Token key={i} token={token} patterns={patterns}/>)}
package/src/Toolbar.tsx CHANGED
@@ -45,49 +45,50 @@ export default function Toolbar({
45
45
  );
46
46
  }
47
47
 
48
- Toolbar.Bold = () => {
48
+ Toolbar.Bold = ({ color = "black" }) => {
49
+ console.log("COLOR:", color);
49
50
  const richTextInputRef = useToolbarContext();
50
51
 
51
52
  const handleBold = () => {
52
- richTextInputRef.current.toggleStyle("bold");
53
+ richTextInputRef?.current?.toggleStyle("bold");
53
54
  }
54
55
 
55
56
  return (
56
57
  <TouchableOpacity style={styles.toolbarButton} onPress={handleBold}>
57
- <FontAwesome6 name="bold" size={16} color="black" />
58
+ <FontAwesome6 name="bold" size={16} color={color} />
58
59
  </TouchableOpacity>
59
60
  )
60
61
  }
61
62
 
62
- Toolbar.Italic = () => {
63
+ Toolbar.Italic = ({ color = "black" }) => {
63
64
  const richTextInputRef = useToolbarContext();
64
65
 
65
66
  const handleItalic = () => {
66
- richTextInputRef.current.toggleStyle("italic");
67
+ richTextInputRef?.current?.toggleStyle("italic");
67
68
  }
68
69
 
69
70
  return (
70
71
  <TouchableOpacity style={styles.toolbarButton} onPress={handleItalic}>
71
- <FontAwesome6 name="italic" size={16} color="black" />
72
+ <FontAwesome6 name="italic" size={16} color={color} />
72
73
  </TouchableOpacity>
73
74
  )
74
75
  }
75
76
 
76
- Toolbar.Strikethrough = () => {
77
+ Toolbar.Strikethrough = ({ color = "black" }) => {
77
78
  const richTextInputRef = useToolbarContext();
78
79
 
79
80
  const handleLineThrough = () => {
80
- richTextInputRef.current.toggleStyle("lineThrough");
81
+ richTextInputRef?.current?.toggleStyle("lineThrough");
81
82
  }
82
83
 
83
84
  return (
84
85
  <TouchableOpacity style={styles.toolbarButton} onPress={handleLineThrough}>
85
- <FontAwesome6 name="strikethrough" size={16} color="black" />
86
+ <FontAwesome6 name="strikethrough" size={16} color={color} />
86
87
  </TouchableOpacity>
87
88
  )
88
89
  }
89
90
 
90
- Toolbar.Underline = () => {
91
+ Toolbar.Underline = ({ color = "black" }) => {
91
92
  const richTextInputRef = useToolbarContext();
92
93
 
93
94
  const handleUnderline = () => {
@@ -96,7 +97,7 @@ Toolbar.Underline = () => {
96
97
 
97
98
  return (
98
99
  <TouchableOpacity style={styles.toolbarButton} onPress={handleUnderline}>
99
- <FontAwesome6 name="underline" size={16} color="black" />
100
+ <FontAwesome6 name="underline" size={16} color={color} />
100
101
  </TouchableOpacity>
101
102
  )
102
103
  }
@@ -110,8 +111,8 @@ Toolbar.Heading = () => {
110
111
 
111
112
  return (
112
113
  <TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleHeading}>
113
- <FontAwesome6 name="heading" size={16} color="black" />
114
- <FontAwesome6 name="1" size={16} color="black" />
114
+ <FontAwesome6 name="heading" size={16} color={color} />
115
+ <FontAwesome6 name="1" size={16} color={color} />
115
116
  </TouchableOpacity>
116
117
  )
117
118
  }
@@ -125,8 +126,8 @@ Toolbar.SubHeading = () => {
125
126
 
126
127
  return (
127
128
  <TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleSubHeading}>
128
- <FontAwesome6 name="heading" size={16} color="black" />
129
- <FontAwesome6 name="2" size={16} color="black" />
129
+ <FontAwesome6 name="heading" size={16} color={color} />
130
+ <FontAwesome6 name="2" size={16} color={color} />
130
131
  </TouchableOpacity>
131
132
  )
132
133
  }
@@ -140,36 +141,36 @@ Toolbar.SubSubHeading = () => {
140
141
 
141
142
  return (
142
143
  <TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleSubSubHeading}>
143
- <FontAwesome6 name="heading" size={16} color="black" />
144
- <FontAwesome6 name="3" size={16} color="black" />
144
+ <FontAwesome6 name="heading" size={16} color={color} />
145
+ <FontAwesome6 name="3" size={16} color={color} />
145
146
  </TouchableOpacity>
146
147
  )
147
148
  }
148
149
 
149
- Toolbar.Code = () => {
150
+ Toolbar.Code = ({ color = "black" }) => {
150
151
  const richTextInputRef = useToolbarContext();
151
152
 
152
153
  const handleCode = () => {
153
- richTextInputRef.current.toggleStyle("code");
154
+ richTextInputRef?.current?.toggleStyle("code");
154
155
  }
155
156
 
156
157
  return (
157
158
  <TouchableOpacity style={styles.toolbarButton} onPress={handleCode}>
158
- <FontAwesome6 name="code" size={16} color="black" />
159
+ <FontAwesome6 name="code" size={16} color={color} />
159
160
  </TouchableOpacity>
160
161
  )
161
162
  }
162
163
 
163
- Toolbar.Keyboard = () => {
164
+ Toolbar.Keyboard = ({ color = "black" }) => {
164
165
  const handleKeyboardDismiss = () => {
165
166
  Keyboard.dismiss();
166
167
  }
167
168
 
168
169
  return (
169
170
  <TouchableOpacity style={[styles.toolbarButton, styles.keyboardDown]} onPress={handleKeyboardDismiss}>
170
- <FontAwesome6 name="keyboard" size={16} color="black" />
171
+ <FontAwesome6 name="keyboard" size={16} color={color} />
171
172
  <View style={styles.keyboardArrowContainer}>
172
- <FontAwesome6 name="chevron-down" size={8} color="black"/>
173
+ <FontAwesome6 name="chevron-down" size={8} color={color}/>
173
174
  </View>
174
175
  </TouchableOpacity>
175
176
  )