enriched-text-input 1.0.1 → 1.0.2
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 +10 -5
- package/index.ts +2 -1
- package/package.json +1 -1
- package/src/RichTextInput.tsx +290 -149
- package/src/Toolbar.tsx +94 -28
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,6 +1,5 @@
|
|
|
1
|
-
import { useRef
|
|
2
|
-
import {
|
|
3
|
-
import { StyleSheet, Text, View, Button } from 'react-native';
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
4
3
|
|
|
5
4
|
import { RichTextInput, Toolbar } from 'enriched-text-input';
|
|
6
5
|
|
|
@@ -10,8 +9,14 @@ export default function App() {
|
|
|
10
9
|
return (
|
|
11
10
|
<View style={styles.container}>
|
|
12
11
|
<RichTextInput ref={richTextInputRef}/>
|
|
13
|
-
<Toolbar richTextInputRef={richTextInputRef}
|
|
14
|
-
|
|
12
|
+
<Toolbar richTextInputRef={richTextInputRef}>
|
|
13
|
+
<Toolbar.Bold />
|
|
14
|
+
<Toolbar.Italic />
|
|
15
|
+
<Toolbar.Underline />
|
|
16
|
+
<Toolbar.Strikethrough />
|
|
17
|
+
<Toolbar.Code />
|
|
18
|
+
<Toolbar.Keyboard />
|
|
19
|
+
</Toolbar>
|
|
15
20
|
</View>
|
|
16
21
|
);
|
|
17
22
|
}
|
package/index.ts
CHANGED
package/package.json
CHANGED
package/src/RichTextInput.tsx
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
import { useState, useImperativeHandle, useRef, useEffect } from "react";
|
|
2
2
|
import { TextInput, Text, StyleSheet, View, Linking } from "react-native";
|
|
3
3
|
|
|
4
|
-
const exampleText = "_None_ *bold* _italic_ ~strikethrough~ none";
|
|
5
|
-
|
|
6
4
|
interface Token {
|
|
7
5
|
text: string;
|
|
8
|
-
annotations:
|
|
9
|
-
bold: boolean;
|
|
10
|
-
italic: boolean;
|
|
11
|
-
lineThrough: boolean;
|
|
12
|
-
underline: boolean;
|
|
13
|
-
color: string;
|
|
14
|
-
}
|
|
6
|
+
annotations: Annotations
|
|
15
7
|
}
|
|
16
8
|
|
|
17
9
|
interface Diff {
|
|
@@ -25,7 +17,7 @@ interface Annotations {
|
|
|
25
17
|
italic: boolean;
|
|
26
18
|
lineThrough: boolean;
|
|
27
19
|
underline: boolean;
|
|
28
|
-
|
|
20
|
+
code: boolean;
|
|
29
21
|
}
|
|
30
22
|
|
|
31
23
|
interface RichTextMatch {
|
|
@@ -35,11 +27,16 @@ interface RichTextMatch {
|
|
|
35
27
|
end: number;
|
|
36
28
|
expression: string;
|
|
37
29
|
}
|
|
30
|
+
|
|
31
|
+
interface RichTextInputProps {
|
|
32
|
+
ref: any
|
|
33
|
+
}
|
|
38
34
|
|
|
39
35
|
const PATTERNS = [
|
|
40
|
-
{ style: "bold", regex: "\\*([^*]+)\\*" },
|
|
41
|
-
{ style: "italic", regex: "_([^_]+)_" },
|
|
42
|
-
{ style: "lineThrough", regex: "~([^~]+)~" },
|
|
36
|
+
{ style: "bold", regex: "\\*([^*]+)\\*", render: <Text style={{ fontWeight: "bold" }} /> },
|
|
37
|
+
{ style: "italic", regex: "_([^_]+)_", render: <Text style={{ fontStyle: "italic" }} /> },
|
|
38
|
+
{ style: "lineThrough", regex: "~([^~]+)~", render: <Text style={{ textDecorationLine: "line-through" }} /> },
|
|
39
|
+
{ style: "code", regex: "`([^`]+)`", render: <Text style={{ fontFamily: "ui-monospace", backgroundColor: "lightgray", color: "red", paddingHorizontal: 6 }} /> },
|
|
43
40
|
];
|
|
44
41
|
|
|
45
42
|
function insertAt(str, index, substring) {
|
|
@@ -111,11 +108,12 @@ function getRequiredLiterals(regexString) {
|
|
|
111
108
|
|
|
112
109
|
function concileAnnotations(prevAnnotations, nextAnnotations) {
|
|
113
110
|
return {
|
|
114
|
-
bold: prevAnnotations.bold
|
|
115
|
-
italic: prevAnnotations.italic
|
|
116
|
-
lineThrough: prevAnnotations.lineThrough
|
|
117
|
-
underline: prevAnnotations.underline
|
|
118
|
-
|
|
111
|
+
bold: nextAnnotations.bold ? !prevAnnotations.bold : prevAnnotations.bold,
|
|
112
|
+
italic: nextAnnotations.italic ? !prevAnnotations.italic : prevAnnotations.italic,
|
|
113
|
+
lineThrough: nextAnnotations.lineThrough ? !prevAnnotations.lineThrough : prevAnnotations.lineThrough,
|
|
114
|
+
underline: nextAnnotations.underline ? !prevAnnotations.underline : prevAnnotations.underline,
|
|
115
|
+
code: nextAnnotations.code ? !prevAnnotations.code : prevAnnotations.code,
|
|
116
|
+
/* color: nextAnnotations.color */
|
|
119
117
|
};
|
|
120
118
|
}
|
|
121
119
|
|
|
@@ -152,7 +150,7 @@ const parseRichTextString = (richTextString: string, patterns: { regex: string,
|
|
|
152
150
|
italic: false,
|
|
153
151
|
lineThrough: false,
|
|
154
152
|
underline: false,
|
|
155
|
-
|
|
153
|
+
code: false
|
|
156
154
|
}
|
|
157
155
|
}
|
|
158
156
|
];
|
|
@@ -166,7 +164,7 @@ const parseRichTextString = (richTextString: string, patterns: { regex: string,
|
|
|
166
164
|
tokens,
|
|
167
165
|
match.start,
|
|
168
166
|
match.end - 1,
|
|
169
|
-
pattern.style,
|
|
167
|
+
{ [pattern.style]: true },
|
|
170
168
|
getRequiredLiterals(match.expression).opening
|
|
171
169
|
);
|
|
172
170
|
tokens = splittedTokens;
|
|
@@ -194,7 +192,7 @@ const parseTokens = (tokens) => {
|
|
|
194
192
|
|
|
195
193
|
// Inserts a token at the given index
|
|
196
194
|
// Only when start === end
|
|
197
|
-
function insertToken(tokens: Token[], index: number,
|
|
195
|
+
function insertToken(tokens: Token[], index: number, annotations: Annotations, text = "" ) {
|
|
198
196
|
const updatedTokens = [...tokens];
|
|
199
197
|
|
|
200
198
|
let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
|
|
@@ -203,10 +201,7 @@ function insertToken(tokens: Token[], index: number, type: string, text = "" ) {
|
|
|
203
201
|
if (plain_text.length === index) {
|
|
204
202
|
updatedTokens.push({
|
|
205
203
|
text: text,
|
|
206
|
-
annotations:
|
|
207
|
-
...updatedTokens[updatedTokens.length - 1].annotations,
|
|
208
|
-
[type]: !updatedTokens[updatedTokens.length - 1].annotations[type]
|
|
209
|
-
}
|
|
204
|
+
annotations: concileAnnotations(updatedTokens[updatedTokens.length - 1].annotations, annotations)
|
|
210
205
|
});
|
|
211
206
|
|
|
212
207
|
return { result: updatedTokens.filter(token => token.text.length > 0) };
|
|
@@ -224,30 +219,20 @@ function insertToken(tokens: Token[], index: number, type: string, text = "" ) {
|
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
const startTokenIndex = updatedTokens.indexOf(startToken);
|
|
227
|
-
|
|
228
222
|
let firstToken = {
|
|
229
223
|
text: startToken.text.slice(0, startIndex),
|
|
230
|
-
annotations:
|
|
231
|
-
...startToken.annotations,
|
|
232
|
-
[type]: startToken.annotations[type]
|
|
233
|
-
}
|
|
224
|
+
annotations: startToken.annotations
|
|
234
225
|
}
|
|
235
226
|
|
|
236
227
|
// Middle token is the selected text
|
|
237
228
|
let middleToken = {
|
|
238
229
|
text: text,
|
|
239
|
-
annotations:
|
|
240
|
-
...startToken.annotations,
|
|
241
|
-
[type]: !startToken.annotations[type]
|
|
242
|
-
}
|
|
230
|
+
annotations: concileAnnotations(startToken.annotations, annotations)
|
|
243
231
|
}
|
|
244
232
|
|
|
245
233
|
let lastToken = {
|
|
246
234
|
text: startToken.text.slice(startIndex , startToken.text.length),
|
|
247
|
-
annotations:
|
|
248
|
-
...startToken.annotations,
|
|
249
|
-
[type]: startToken.annotations[type]
|
|
250
|
-
}
|
|
235
|
+
annotations: startToken.annotations
|
|
251
236
|
}
|
|
252
237
|
|
|
253
238
|
/**
|
|
@@ -434,7 +419,7 @@ const splitTokens = (
|
|
|
434
419
|
tokens: Token[],
|
|
435
420
|
start: number,
|
|
436
421
|
end: number,
|
|
437
|
-
|
|
422
|
+
annotations: Annotations,
|
|
438
423
|
/** Used to strip opening and closing chars of rich text matches. */
|
|
439
424
|
withReplacement?: string
|
|
440
425
|
) => {
|
|
@@ -483,56 +468,32 @@ const splitTokens = (
|
|
|
483
468
|
|
|
484
469
|
let firstToken = {
|
|
485
470
|
text: startToken.text.slice(0, startIndex),
|
|
486
|
-
annotations:
|
|
487
|
-
...startToken.annotations,
|
|
488
|
-
[type]: startToken.annotations[type]
|
|
489
|
-
}
|
|
471
|
+
annotations: startToken.annotations
|
|
490
472
|
}
|
|
491
473
|
|
|
492
474
|
// Middle token is the selected text
|
|
493
475
|
let middleToken = {
|
|
494
476
|
// The replace method is used to remove the opening and closing rich text literal chars when parsing.
|
|
495
477
|
text: startToken.text.slice(startIndex, endIndex).replace(withReplacement, ""),
|
|
496
|
-
annotations:
|
|
497
|
-
...startToken.annotations,
|
|
498
|
-
[type]: !startToken.annotations[type]
|
|
499
|
-
}
|
|
478
|
+
annotations: concileAnnotations(startToken.annotations, annotations)
|
|
500
479
|
}
|
|
501
480
|
|
|
502
481
|
let lastToken = {
|
|
503
482
|
// The replace method is used to remove the opening and closing rich text literal chars when parsing.
|
|
504
483
|
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 };
|
|
484
|
+
annotations: startToken.annotations
|
|
526
485
|
}
|
|
527
486
|
|
|
528
487
|
updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken)
|
|
529
|
-
return { result: updatedTokens };
|
|
488
|
+
return { result: updatedTokens.filter(token => token.text.length > 0) };
|
|
530
489
|
}
|
|
531
490
|
|
|
532
491
|
// Cross-token selection
|
|
533
492
|
if (startTokenIndex !== endTokenIndex) {
|
|
534
493
|
// Before splitting, check if all selected tokens already have the annotation
|
|
535
494
|
const selectedTokens = updatedTokens.slice(startTokenIndex, endTokenIndex + 1);
|
|
495
|
+
|
|
496
|
+
const type = Object.keys(annotations)[0]; // When splitting we only pass one key to annotations param.
|
|
536
497
|
const allSelectedTokensHaveAnnotation = selectedTokens.every((token) => token.annotations[type] === true);
|
|
537
498
|
|
|
538
499
|
let firstToken = {
|
|
@@ -582,24 +543,8 @@ const splitTokens = (
|
|
|
582
543
|
}
|
|
583
544
|
}
|
|
584
545
|
|
|
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
546
|
updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
|
|
602
|
-
return { result: updatedTokens };
|
|
547
|
+
return { result: updatedTokens.filter(token => token.text.length > 0) };
|
|
603
548
|
}
|
|
604
549
|
}
|
|
605
550
|
|
|
@@ -619,7 +564,7 @@ const concatTokens = (tokens: Token[]) => {
|
|
|
619
564
|
prevToken.annotations.italic === token.annotations.italic &&
|
|
620
565
|
prevToken.annotations.lineThrough === token.annotations.lineThrough &&
|
|
621
566
|
prevToken.annotations.underline === token.annotations.underline &&
|
|
622
|
-
prevToken.annotations.
|
|
567
|
+
prevToken.annotations.code === token.annotations.code) {
|
|
623
568
|
prevToken.text += token.text;
|
|
624
569
|
continue;
|
|
625
570
|
}
|
|
@@ -630,7 +575,64 @@ const concatTokens = (tokens: Token[]) => {
|
|
|
630
575
|
return concatenedTokens;
|
|
631
576
|
}
|
|
632
577
|
|
|
633
|
-
|
|
578
|
+
function Token({ token }) {
|
|
579
|
+
const { text, annotations } = token;
|
|
580
|
+
const wrappers = [];
|
|
581
|
+
|
|
582
|
+
if (annotations.bold) wrappers.push(Bold);
|
|
583
|
+
if (annotations.italic) wrappers.push(Italic);
|
|
584
|
+
if (annotations.underline && annotations.lineThrough) wrappers.push(UnderlineStrikethrough);
|
|
585
|
+
if (annotations.underline) wrappers.push(Underline);
|
|
586
|
+
if (annotations.lineThrough) wrappers.push(Strikethrough);
|
|
587
|
+
if (annotations.code) wrappers.push(Code);
|
|
588
|
+
|
|
589
|
+
return wrappers.reduce(
|
|
590
|
+
(children, Wrapper) => <Wrapper>{children}</Wrapper>,
|
|
591
|
+
text
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function Code({ children }) {
|
|
596
|
+
return (
|
|
597
|
+
<Text style={styles.code}>{children}</Text>
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function Bold({ children }) {
|
|
602
|
+
return (
|
|
603
|
+
<Text style={styles.bold}>{children}</Text>
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function Italic({ children }) {
|
|
608
|
+
return (
|
|
609
|
+
<Text style={styles.italic}>{children}</Text>
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function Underline({ children }) {
|
|
614
|
+
return (
|
|
615
|
+
<Text style={styles.underline}>{children}</Text>
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function Strikethrough({ children }) {
|
|
620
|
+
return (
|
|
621
|
+
<Text style={styles.lineThrough}>{children}</Text>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function UnderlineStrikethrough({ children }) {
|
|
626
|
+
return (
|
|
627
|
+
<Text style={styles.underlineLineThrough}>{children}</Text>
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export default function RichTextInput(props: RichTextInputProps) {
|
|
632
|
+
const {
|
|
633
|
+
ref
|
|
634
|
+
} = props;
|
|
635
|
+
|
|
634
636
|
const inputRef = useRef<TextInput>(null);
|
|
635
637
|
const selectionRef = useRef({ start: 0, end: 0 });
|
|
636
638
|
const [tokens, setTokens] = useState([{
|
|
@@ -640,10 +642,10 @@ export default function RichTextInput({ ref }) {
|
|
|
640
642
|
italic: false,
|
|
641
643
|
lineThrough: false,
|
|
642
644
|
underline: false,
|
|
643
|
-
|
|
645
|
+
code: false
|
|
644
646
|
}
|
|
645
647
|
}]);
|
|
646
|
-
|
|
648
|
+
console.log(tokens);
|
|
647
649
|
useEffect(() => {
|
|
648
650
|
if (tokens.length === 0) {
|
|
649
651
|
setTokens([{
|
|
@@ -653,7 +655,7 @@ export default function RichTextInput({ ref }) {
|
|
|
653
655
|
italic: false,
|
|
654
656
|
lineThrough: false,
|
|
655
657
|
underline: false,
|
|
656
|
-
|
|
658
|
+
code: false
|
|
657
659
|
}
|
|
658
660
|
}])
|
|
659
661
|
}
|
|
@@ -670,7 +672,13 @@ export default function RichTextInput({ ref }) {
|
|
|
670
672
|
const [toSplit, setToSplit] = useState({
|
|
671
673
|
start: 0,
|
|
672
674
|
end: 0,
|
|
673
|
-
|
|
675
|
+
annotations: {
|
|
676
|
+
bold: false,
|
|
677
|
+
italic: false,
|
|
678
|
+
lineThrough: false,
|
|
679
|
+
underline: false,
|
|
680
|
+
code: false
|
|
681
|
+
}
|
|
674
682
|
});
|
|
675
683
|
|
|
676
684
|
const handleSelectionChange = ({ nativeEvent }) => {
|
|
@@ -695,29 +703,43 @@ export default function RichTextInput({ ref }) {
|
|
|
695
703
|
tokens,
|
|
696
704
|
match.start,
|
|
697
705
|
match.end - 1,
|
|
698
|
-
annotation.style,
|
|
706
|
+
{ [annotation.style]: true },
|
|
699
707
|
// Get the rich text opening char to replace it
|
|
700
708
|
getRequiredLiterals(match.expression).opening
|
|
701
709
|
);
|
|
702
710
|
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
711
|
|
|
709
712
|
setTokens([...concatTokens(result)]);
|
|
710
713
|
prevTextRef.current = plain_text;
|
|
711
714
|
|
|
712
|
-
|
|
713
715
|
return;
|
|
714
716
|
}
|
|
715
717
|
|
|
716
|
-
if (diff.start === toSplit.start
|
|
717
|
-
|
|
718
|
+
if (diff.start === toSplit.start
|
|
719
|
+
&& diff.start === toSplit.end
|
|
720
|
+
&& diff.added.length > 0
|
|
721
|
+
&& Object.values(toSplit.annotations).includes(true)) {
|
|
722
|
+
const { result } = insertToken(
|
|
723
|
+
tokens,
|
|
724
|
+
diff.start,
|
|
725
|
+
toSplit.annotations,
|
|
726
|
+
diff.added
|
|
727
|
+
);
|
|
718
728
|
const plain_text = result.map(t => t.text).join("");
|
|
719
729
|
setTokens([...concatTokens(result)]);
|
|
720
|
-
|
|
730
|
+
|
|
731
|
+
// Reset
|
|
732
|
+
setToSplit({
|
|
733
|
+
start: 0,
|
|
734
|
+
end: 0,
|
|
735
|
+
annotations: {
|
|
736
|
+
bold: false,
|
|
737
|
+
italic: false,
|
|
738
|
+
lineThrough: false,
|
|
739
|
+
underline: false,
|
|
740
|
+
code: false
|
|
741
|
+
}
|
|
742
|
+
});
|
|
721
743
|
prevTextRef.current = plain_text;
|
|
722
744
|
return;
|
|
723
745
|
}
|
|
@@ -729,16 +751,37 @@ export default function RichTextInput({ ref }) {
|
|
|
729
751
|
}
|
|
730
752
|
|
|
731
753
|
useImperativeHandle(ref, () => ({
|
|
754
|
+
|
|
755
|
+
setValue(value: string) {
|
|
756
|
+
// To keep styles, parsing should be done before setting value
|
|
757
|
+
const { tokens, plain_text } = parseRichTextString(value, PATTERNS);
|
|
758
|
+
setTokens([...concatTokens(tokens)]);
|
|
759
|
+
prevTextRef.current = plain_text;
|
|
760
|
+
},
|
|
732
761
|
toggleBold() {
|
|
733
762
|
const { start, end } = selectionRef.current;
|
|
734
763
|
|
|
735
|
-
if (start === end && toSplit.
|
|
736
|
-
setToSplit({
|
|
764
|
+
if (start === end && toSplit.annotations.bold) {
|
|
765
|
+
setToSplit({
|
|
766
|
+
start,
|
|
767
|
+
end,
|
|
768
|
+
annotations: {
|
|
769
|
+
...toSplit.annotations,
|
|
770
|
+
bold: false
|
|
771
|
+
}
|
|
772
|
+
});
|
|
737
773
|
return;
|
|
738
774
|
}
|
|
739
775
|
|
|
740
776
|
if (start === end) {
|
|
741
|
-
setToSplit({
|
|
777
|
+
setToSplit({
|
|
778
|
+
start,
|
|
779
|
+
end,
|
|
780
|
+
annotations: {
|
|
781
|
+
...toSplit.annotations,
|
|
782
|
+
bold: true
|
|
783
|
+
}
|
|
784
|
+
});
|
|
742
785
|
return;
|
|
743
786
|
}
|
|
744
787
|
|
|
@@ -749,24 +792,41 @@ export default function RichTextInput({ ref }) {
|
|
|
749
792
|
setToSplit({
|
|
750
793
|
start: end,
|
|
751
794
|
end: end,
|
|
752
|
-
|
|
795
|
+
annotations: {
|
|
796
|
+
...toSplit.annotations,
|
|
797
|
+
bold: true
|
|
798
|
+
}
|
|
753
799
|
})
|
|
754
800
|
}
|
|
755
801
|
|
|
756
|
-
const { result } = splitTokens(tokens, start, end,
|
|
802
|
+
const { result } = splitTokens(tokens, start, end, { bold: true });
|
|
757
803
|
setTokens([...concatTokens(result)]);
|
|
758
804
|
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
759
805
|
},
|
|
760
806
|
toggleItalic() {
|
|
761
807
|
const { start, end } = selectionRef.current;
|
|
762
808
|
|
|
763
|
-
if (start === end && toSplit.
|
|
764
|
-
setToSplit({
|
|
809
|
+
if (start === end && toSplit.annotations.italic ) {
|
|
810
|
+
setToSplit({
|
|
811
|
+
start,
|
|
812
|
+
end,
|
|
813
|
+
annotations: {
|
|
814
|
+
...toSplit.annotations,
|
|
815
|
+
italic: false
|
|
816
|
+
}
|
|
817
|
+
});
|
|
765
818
|
return;
|
|
766
819
|
}
|
|
767
820
|
|
|
768
821
|
if (start === end) {
|
|
769
|
-
setToSplit({
|
|
822
|
+
setToSplit({
|
|
823
|
+
start,
|
|
824
|
+
end,
|
|
825
|
+
annotations: {
|
|
826
|
+
...toSplit.annotations,
|
|
827
|
+
italic: true
|
|
828
|
+
}
|
|
829
|
+
});
|
|
770
830
|
return;
|
|
771
831
|
}
|
|
772
832
|
|
|
@@ -774,24 +834,41 @@ export default function RichTextInput({ ref }) {
|
|
|
774
834
|
setToSplit({
|
|
775
835
|
start: end,
|
|
776
836
|
end: end,
|
|
777
|
-
|
|
778
|
-
|
|
837
|
+
annotations: {
|
|
838
|
+
...toSplit.annotations,
|
|
839
|
+
italic: true
|
|
840
|
+
}
|
|
841
|
+
});
|
|
779
842
|
}
|
|
780
843
|
|
|
781
|
-
const { result } = splitTokens(tokens, start, end,
|
|
844
|
+
const { result } = splitTokens(tokens, start, end, { italic: true });
|
|
782
845
|
setTokens([...concatTokens(result)]);
|
|
783
846
|
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
784
847
|
},
|
|
785
848
|
toggleLineThrough() {
|
|
786
849
|
const { start, end } = selectionRef.current;
|
|
787
850
|
|
|
788
|
-
if (start === end && toSplit.
|
|
789
|
-
setToSplit({
|
|
851
|
+
if (start === end && toSplit.annotations.lineThrough) {
|
|
852
|
+
setToSplit({
|
|
853
|
+
start,
|
|
854
|
+
end,
|
|
855
|
+
annotations: {
|
|
856
|
+
...toSplit.annotations,
|
|
857
|
+
lineThrough: false
|
|
858
|
+
}
|
|
859
|
+
});
|
|
790
860
|
return;
|
|
791
861
|
}
|
|
792
862
|
|
|
793
863
|
if (start === end) {
|
|
794
|
-
setToSplit({
|
|
864
|
+
setToSplit({
|
|
865
|
+
start,
|
|
866
|
+
end,
|
|
867
|
+
annotations: {
|
|
868
|
+
...toSplit.annotations,
|
|
869
|
+
lineThrough: true
|
|
870
|
+
}
|
|
871
|
+
});
|
|
795
872
|
return;
|
|
796
873
|
}
|
|
797
874
|
|
|
@@ -799,24 +876,41 @@ export default function RichTextInput({ ref }) {
|
|
|
799
876
|
setToSplit({
|
|
800
877
|
start: end,
|
|
801
878
|
end: end,
|
|
802
|
-
|
|
879
|
+
annotations: {
|
|
880
|
+
...toSplit.annotations,
|
|
881
|
+
lineThrough: true
|
|
882
|
+
}
|
|
803
883
|
})
|
|
804
884
|
}
|
|
805
885
|
|
|
806
|
-
const { result } = splitTokens(tokens, start, end,
|
|
886
|
+
const { result } = splitTokens(tokens, start, end, { lineThrough: true });
|
|
807
887
|
setTokens([...concatTokens(result)]);
|
|
808
888
|
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
809
889
|
},
|
|
810
890
|
toggleUnderline() {
|
|
811
891
|
const { start, end } = selectionRef.current;
|
|
812
892
|
|
|
813
|
-
if (start === end && toSplit.
|
|
814
|
-
setToSplit({
|
|
893
|
+
if (start === end && toSplit.annotations.underline) {
|
|
894
|
+
setToSplit({
|
|
895
|
+
start: 0,
|
|
896
|
+
end: 0,
|
|
897
|
+
annotations: {
|
|
898
|
+
...toSplit.annotations,
|
|
899
|
+
underline: false
|
|
900
|
+
}
|
|
901
|
+
});
|
|
815
902
|
return;
|
|
816
903
|
}
|
|
817
904
|
|
|
818
905
|
if (start === end) {
|
|
819
|
-
setToSplit({
|
|
906
|
+
setToSplit({
|
|
907
|
+
start,
|
|
908
|
+
end,
|
|
909
|
+
annotations: {
|
|
910
|
+
...toSplit.annotations,
|
|
911
|
+
underline: true
|
|
912
|
+
}
|
|
913
|
+
});
|
|
820
914
|
return;
|
|
821
915
|
}
|
|
822
916
|
|
|
@@ -824,45 +918,75 @@ export default function RichTextInput({ ref }) {
|
|
|
824
918
|
setToSplit({
|
|
825
919
|
start: end,
|
|
826
920
|
end: end,
|
|
827
|
-
|
|
921
|
+
annotations: {
|
|
922
|
+
...toSplit.annotations,
|
|
923
|
+
underline: true
|
|
924
|
+
}
|
|
828
925
|
})
|
|
829
926
|
}
|
|
830
927
|
|
|
831
|
-
const { result } = splitTokens(tokens, start, end,
|
|
928
|
+
const { result } = splitTokens(tokens, start, end, { underline: true });
|
|
832
929
|
setTokens([...concatTokens(result)]);
|
|
833
930
|
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
834
931
|
},
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
932
|
+
toggleCode() {
|
|
933
|
+
const { start, end } = selectionRef.current;
|
|
934
|
+
|
|
935
|
+
if (start === end && toSplit.annotations.code ) {
|
|
936
|
+
setToSplit({
|
|
937
|
+
start: 0,
|
|
938
|
+
end: 0,
|
|
939
|
+
annotations: {
|
|
940
|
+
...toSplit.annotations,
|
|
941
|
+
code: false
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (start === end) {
|
|
948
|
+
setToSplit({
|
|
949
|
+
start,
|
|
950
|
+
end,
|
|
951
|
+
annotations: {
|
|
952
|
+
...toSplit.annotations,
|
|
953
|
+
code: true
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (start < end) {
|
|
960
|
+
setToSplit({
|
|
961
|
+
start: end,
|
|
962
|
+
end: end,
|
|
963
|
+
annotations: {
|
|
964
|
+
...toSplit.annotations,
|
|
965
|
+
code: true
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const { result } = splitTokens(tokens, start, end, { code: true });
|
|
971
|
+
setTokens([...concatTokens(result)]);
|
|
972
|
+
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
840
973
|
}
|
|
841
|
-
}))
|
|
974
|
+
}));
|
|
842
975
|
|
|
843
976
|
return (
|
|
844
977
|
<View style={{ position: "relative" }}>
|
|
845
978
|
<TextInput
|
|
846
979
|
multiline={true}
|
|
847
980
|
ref={inputRef}
|
|
848
|
-
autoCorrect={false}
|
|
849
981
|
autoComplete="off"
|
|
850
982
|
style={styles.textInput}
|
|
851
983
|
placeholder="Rich text input"
|
|
852
984
|
onSelectionChange={handleSelectionChange}
|
|
853
985
|
onChangeText={handleOnChangeText}
|
|
854
986
|
>
|
|
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
|
-
})}
|
|
987
|
+
<Text style={styles.text}>
|
|
988
|
+
{tokens.map((token, i) => <Token key={i} token={token} />)}
|
|
989
|
+
</Text>
|
|
866
990
|
</TextInput>
|
|
867
991
|
</View>
|
|
868
992
|
);
|
|
@@ -870,12 +994,13 @@ export default function RichTextInput({ ref }) {
|
|
|
870
994
|
|
|
871
995
|
const styles = StyleSheet.create({
|
|
872
996
|
textInput: {
|
|
873
|
-
fontSize: 20,
|
|
874
997
|
width: "100%",
|
|
875
|
-
paddingHorizontal: 16
|
|
998
|
+
paddingHorizontal: 16,
|
|
999
|
+
fontSize: 20,
|
|
1000
|
+
zIndex: 1
|
|
876
1001
|
},
|
|
877
1002
|
text: {
|
|
878
|
-
color: "black"
|
|
1003
|
+
color: "black",
|
|
879
1004
|
},
|
|
880
1005
|
bold: {
|
|
881
1006
|
fontWeight: 'bold',
|
|
@@ -889,12 +1014,28 @@ const styles = StyleSheet.create({
|
|
|
889
1014
|
underline: {
|
|
890
1015
|
textDecorationLine: "underline",
|
|
891
1016
|
},
|
|
892
|
-
comment: {
|
|
893
|
-
textDecorationLine: "underline",
|
|
894
|
-
textDecorationColor: "rgba(255, 203, 0, .35)",
|
|
895
|
-
backgroundColor: "rgba(255, 203, 0, .12)"
|
|
896
|
-
},
|
|
897
1017
|
underlineLineThrough: {
|
|
898
1018
|
textDecorationLine: "underline line-through"
|
|
1019
|
+
},
|
|
1020
|
+
codeContainer: {
|
|
1021
|
+
backgroundColor: "lightgray",
|
|
1022
|
+
paddingHorizontal: 4,
|
|
1023
|
+
borderRadius: 4,
|
|
1024
|
+
height: 24,
|
|
1025
|
+
position: "absolute",
|
|
1026
|
+
top: 10
|
|
1027
|
+
},
|
|
1028
|
+
code: {
|
|
1029
|
+
fontFamily: "ui-monospace",
|
|
1030
|
+
color: "#EB5757",
|
|
1031
|
+
fontSize: 20,
|
|
1032
|
+
backgroundColor: "rgba(135, 131, 120, .15)"
|
|
1033
|
+
},
|
|
1034
|
+
highlight: {
|
|
1035
|
+
width: "100%",
|
|
1036
|
+
position: "absolute",
|
|
1037
|
+
padding: 20,
|
|
1038
|
+
height: 24,
|
|
1039
|
+
backgroundColor: "blue"
|
|
899
1040
|
}
|
|
900
1041
|
});
|
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,124 @@ 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
52
|
richTextInputRef.current.toggleBold();
|
|
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
66
|
richTextInputRef.current.toggleItalic();
|
|
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
80
|
richTextInputRef.current.toggleLineThrough();
|
|
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
94
|
richTextInputRef.current.toggleUnderline();
|
|
33
95
|
}
|
|
34
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.Code = () => {
|
|
105
|
+
const richTextInputRef = useToolbarContext();
|
|
106
|
+
|
|
107
|
+
const handleCode = () => {
|
|
108
|
+
richTextInputRef.current.toggleCode();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<TouchableOpacity style={styles.toolbarButton} onPress={handleCode}>
|
|
113
|
+
<FontAwesome6 name="code" size={16} color="black" />
|
|
114
|
+
</TouchableOpacity>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Toolbar.Keyboard = () => {
|
|
35
119
|
const handleKeyboardDismiss = () => {
|
|
36
120
|
Keyboard.dismiss();
|
|
37
121
|
}
|
|
38
122
|
|
|
39
123
|
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
|
-
);
|
|
124
|
+
<TouchableOpacity style={[styles.toolbarButton, styles.keyboardDown]} onPress={handleKeyboardDismiss}>
|
|
125
|
+
<FontAwesome6 name="keyboard" size={16} color="black" />
|
|
126
|
+
<View style={styles.keyboardArrowContainer}>
|
|
127
|
+
<FontAwesome6 name="chevron-down" size={8} color="black"/>
|
|
128
|
+
</View>
|
|
129
|
+
</TouchableOpacity>
|
|
130
|
+
)
|
|
65
131
|
}
|
|
66
132
|
|
|
67
133
|
const styles = StyleSheet.create({
|