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