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 +14 -5
- package/example/App.tsx +71 -8
- package/index.ts +2 -2
- package/package.json +5 -3
- package/src/RichTextInput.tsx +257 -247
- package/src/Toolbar.tsx +147 -32
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
|
|
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 (
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enriched-text-input",
|
|
3
|
-
"version": "1.0.
|
|
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
|
}
|
package/src/RichTextInput.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
717
|
-
const { result } = insertToken(
|
|
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(
|
|
720
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
|
801
|
-
end
|
|
802
|
-
|
|
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
|
-
|
|
821
|
+
annotations: concileAnnotations(toSplit.annotations, { [style]: true })
|
|
828
822
|
})
|
|
829
823
|
}
|
|
830
824
|
|
|
831
|
-
const { result } = splitTokens(tokens, start, end,
|
|
825
|
+
const { result } = splitTokens(tokens, start, end, { [style]: true });
|
|
832
826
|
setTokens([...concatTokens(result)]);
|
|
833
|
-
requestAnimationFrame(() =>
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
{
|
|
856
|
-
|
|
857
|
-
|
|
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,
|
|
2
|
-
import {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
});
|