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.
- package/API_REFERENCE.md +1 -0
- package/README.md +19 -16
- package/example/App.tsx +38 -12
- package/example/package.json +3 -2
- package/package.json +1 -1
- package/src/RichTextInput.tsx +214 -136
- package/src/Toolbar.tsx +24 -23
package/API_REFERENCE.md
ADDED
|
@@ -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?.
|
|
33
|
-
|
|
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
|
-
|
|
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='
|
|
54
|
-
onPress={
|
|
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>
|
package/example/package.json
CHANGED
|
@@ -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
package/src/RichTextInput.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { useState, useImperativeHandle, useRef, useEffect,
|
|
2
|
-
import { TextInput, Text, StyleSheet, View,
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
let openLiteral = null;
|
|
111
|
+
for (const pattern of patterns) {
|
|
112
|
+
let evenCount = 0;
|
|
95
113
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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:
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
195
|
-
plain_text:
|
|
196
|
-
}
|
|
220
|
+
tokens,
|
|
221
|
+
plain_text: copyOfString
|
|
222
|
+
};
|
|
197
223
|
}
|
|
198
224
|
|
|
199
|
-
|
|
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
|
-
|
|
234
|
+
patterns.forEach(pattern => {
|
|
206
235
|
// If annotation has a truthy value, add the corresponding wrapper.
|
|
207
|
-
if (annotations[
|
|
236
|
+
if (annotations[pattern.style]) wrappers.push(pattern.opening);
|
|
208
237
|
});
|
|
209
238
|
|
|
210
239
|
return wrappers.reduce(
|
|
211
|
-
(children,
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
651
|
+
patterns.forEach(pattern => {
|
|
618
652
|
// If annotation has a truthy value, add the corresponding wrapper.
|
|
619
|
-
if (annotations[
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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
|
|
748
|
-
const { result } = splitTokens(
|
|
790
|
+
const { result, plain_text } = splitTokens(
|
|
749
791
|
tokens,
|
|
750
792
|
match.start,
|
|
751
|
-
match.end
|
|
752
|
-
{ [
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
845
|
+
setTokens(tokens);
|
|
797
846
|
prevTextRef.current = plain_text;
|
|
798
847
|
},
|
|
799
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
114
|
-
<FontAwesome6 name="1" size={16} color=
|
|
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=
|
|
129
|
-
<FontAwesome6 name="2" size={16} color=
|
|
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=
|
|
144
|
-
<FontAwesome6 name="3" size={16} color=
|
|
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
|
|
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=
|
|
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=
|
|
171
|
+
<FontAwesome6 name="keyboard" size={16} color={color} />
|
|
171
172
|
<View style={styles.keyboardArrowContainer}>
|
|
172
|
-
<FontAwesome6 name="chevron-down" size={8} color=
|
|
173
|
+
<FontAwesome6 name="chevron-down" size={8} color={color}/>
|
|
173
174
|
</View>
|
|
174
175
|
</TouchableOpacity>
|
|
175
176
|
)
|