enriched-text-input 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API_REFERENCE.md +119 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +58 -48
- package/example/App.tsx +38 -35
- package/example/package.json +3 -2
- package/index.ts +3 -2
- package/package.json +1 -1
- package/src/{RichTextInput.tsx → EnrichedTextInput.tsx} +251 -246
- package/src/Toolbar.tsx +24 -23
- package/src/components/StyledText.tsx +106 -0
- package/src/markdownStyles.ts +12 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useState, useImperativeHandle, useRef, useEffect,
|
|
2
|
-
import { TextInput, Text, StyleSheet, View,
|
|
3
|
-
import {
|
|
1
|
+
import { useState, useImperativeHandle, useRef, useEffect, JSX } from "react";
|
|
2
|
+
import { TextInput, Text, StyleSheet, View, TextInputProps } from "react-native";
|
|
3
|
+
import { markdownStyles } from "./markdownStyles";
|
|
4
4
|
|
|
5
5
|
interface Token {
|
|
6
6
|
text: string;
|
|
@@ -14,12 +14,7 @@ interface Diff {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
interface Annotations {
|
|
17
|
-
|
|
18
|
-
italic: boolean;
|
|
19
|
-
lineThrough: boolean;
|
|
20
|
-
underline: boolean;
|
|
21
|
-
underlineLineThrough: boolean;
|
|
22
|
-
code: boolean;
|
|
17
|
+
[key: string]: boolean | string | null
|
|
23
18
|
}
|
|
24
19
|
|
|
25
20
|
interface RichTextMatch {
|
|
@@ -27,30 +22,36 @@ interface RichTextMatch {
|
|
|
27
22
|
content: string;
|
|
28
23
|
start: number;
|
|
29
24
|
end: number;
|
|
25
|
+
pattern: Pattern;
|
|
26
|
+
/** @deprecated */
|
|
30
27
|
expression: string;
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
interface Pattern {
|
|
34
31
|
regex: string;
|
|
35
32
|
style: string;
|
|
36
|
-
render: any
|
|
33
|
+
render: any;
|
|
34
|
+
opening?: string;
|
|
35
|
+
closing?: string;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
interface
|
|
38
|
+
interface EnrichedTextInputProps {
|
|
40
39
|
ref: any;
|
|
41
|
-
|
|
40
|
+
stylePatterns?: Pattern[];
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
multiline?: boolean;
|
|
43
|
+
defaultValue?: string | Token[];
|
|
44
|
+
onValueChange?: () => void;
|
|
45
|
+
onSelectionChange?: () => void;
|
|
42
46
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{ style: "subHeading", regex: null, render: SubHeading },
|
|
52
|
-
{ style: "subSubHeading", regex: null, render: SubSubHeading }
|
|
53
|
-
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Note: maybe instead of using regex we could just define an "opening" and "closing" char.
|
|
50
|
+
* If both are defined we look for a match that looks like {opening}{content}{closing}.
|
|
51
|
+
* If just opening is defined, we look for a match that looks like {opening}{content}.
|
|
52
|
+
* Closing can not be defined if opening is not defined.
|
|
53
|
+
*/
|
|
54
|
+
export const defaultStylePatterns : Pattern[] = markdownStyles;
|
|
54
55
|
|
|
55
56
|
function insertAt(str, index, substring) {
|
|
56
57
|
// Clamp index into valid boundaries
|
|
@@ -68,59 +69,82 @@ function replaceAt(str, index, substring, length) {
|
|
|
68
69
|
return str.slice(0, i) + substring + str.slice(i + length);
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
72
|
+
function findTokens(
|
|
73
|
+
/** The tokens to search over. */
|
|
74
|
+
tokens: Token[],
|
|
75
|
+
/** start position of selection.*/
|
|
76
|
+
start: number,
|
|
77
|
+
/** end position of selection.*/
|
|
78
|
+
end?: number
|
|
79
|
+
) {
|
|
80
|
+
|
|
81
|
+
if (end) {
|
|
82
|
+
// To-do: search for all tokens between start and end
|
|
83
|
+
return { result: null };
|
|
81
84
|
}
|
|
82
|
-
: null;
|
|
83
|
-
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
let startIndex = start;
|
|
87
|
+
let startToken;
|
|
88
|
+
for (const token of tokens) {
|
|
89
|
+
if (startIndex <= token.text.length) {
|
|
90
|
+
startToken = token;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
startIndex -= token.text.length;
|
|
94
|
+
}
|
|
88
95
|
|
|
89
|
-
|
|
90
|
-
regexString = regexString.replace(/^\^|\$$/g, "");
|
|
96
|
+
const startTokenIndex = tokens.indexOf(startToken);
|
|
91
97
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let openLiteral = null;
|
|
98
|
+
return { result: [startToken] };
|
|
99
|
+
}
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
101
|
+
/**
|
|
102
|
+
* To-do: Add support for openings and closings that are conformed by two or more chars (e.g. **, __, <b>, etc.)
|
|
103
|
+
*/
|
|
104
|
+
function findMatchV2(str: string, patterns: Pattern[]) : RichTextMatch | null {
|
|
105
|
+
let match = null;
|
|
106
|
+
let copyOfString = str;
|
|
103
107
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
for (const pattern of patterns) {
|
|
109
|
+
let evenCount = 0;
|
|
110
|
+
|
|
111
|
+
for (const char of copyOfString) {
|
|
112
|
+
/** Cases where both opening and closing chars are defined (*...*, _..._, etc.)*/
|
|
113
|
+
if (pattern.opening && pattern.closing) {
|
|
114
|
+
if (evenCount < 2 && char === pattern.opening) {
|
|
115
|
+
evenCount++;
|
|
116
|
+
}
|
|
117
|
+
if (evenCount === 2 && char === pattern.closing) {
|
|
118
|
+
const openingIndex = copyOfString.indexOf(pattern.opening);
|
|
119
|
+
const closingIndex = copyOfString.indexOf(pattern.closing, openingIndex + 1);
|
|
120
|
+
|
|
121
|
+
match = {
|
|
122
|
+
raw: copyOfString.slice(openingIndex, closingIndex + 1),
|
|
123
|
+
content: copyOfString.slice(openingIndex + 1, closingIndex),
|
|
124
|
+
start: openingIndex,
|
|
125
|
+
end: closingIndex,
|
|
126
|
+
pattern,
|
|
127
|
+
/** @deprecated */
|
|
128
|
+
expression: pattern.regex
|
|
129
|
+
};
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Cases where only opening char is defined (@, #, etc.) */
|
|
135
|
+
}
|
|
112
136
|
}
|
|
113
|
-
}
|
|
114
137
|
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
opening: openLiteral,
|
|
118
|
-
closing: closeLiteral,
|
|
119
|
-
};
|
|
138
|
+
return match;
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
/**
|
|
123
142
|
* If prev token contains new annotation, negate prev. Else, use new annotation.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* prev: { bold: true }
|
|
146
|
+
* new: { bold: false }
|
|
147
|
+
* result: { bold: false }
|
|
124
148
|
*/
|
|
125
149
|
function concileAnnotations(prevAnnotations, newAnnotations) {
|
|
126
150
|
let updatedAnnotations = { ...prevAnnotations };
|
|
@@ -157,58 +181,73 @@ function diffStrings(prev, next) : Diff {
|
|
|
157
181
|
};
|
|
158
182
|
}
|
|
159
183
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
184
|
+
/**
|
|
185
|
+
* [Needs refactoring]
|
|
186
|
+
* Parse rich text string into tokens.
|
|
187
|
+
*/
|
|
188
|
+
const parseRichTextString = (richTextString: string, patterns: Pattern[], initialTokens?: Token[])
|
|
189
|
+
: { tokens: Token[], plain_text: string } => {
|
|
190
|
+
let copyOfString = richTextString;
|
|
191
|
+
let tokens : Token[] = initialTokens || [
|
|
163
192
|
{
|
|
164
|
-
text:
|
|
193
|
+
text: copyOfString,
|
|
165
194
|
annotations: {}
|
|
166
195
|
}
|
|
167
196
|
];
|
|
168
|
-
let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
|
|
169
197
|
|
|
170
198
|
for (const pattern of patterns) {
|
|
171
|
-
let
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
plain_text: parsed.plain_text
|
|
199
|
+
let evenCount = 0;
|
|
200
|
+
|
|
201
|
+
for (const char of copyOfString) {
|
|
202
|
+
/** Cases where both opening and closing chars are defined (*...*, _..._, etc.)*/
|
|
203
|
+
if (pattern.opening && pattern.closing) {
|
|
204
|
+
if (evenCount < 2 && char === pattern.opening) {
|
|
205
|
+
evenCount++;
|
|
206
|
+
}
|
|
207
|
+
if (evenCount === 2 && char === pattern.closing) {
|
|
208
|
+
const openingIndex = copyOfString.indexOf(pattern.opening);
|
|
209
|
+
const closingIndex = copyOfString.indexOf(pattern.closing, openingIndex + 1);
|
|
210
|
+
|
|
211
|
+
copyOfString = copyOfString.slice(0, openingIndex) + copyOfString.slice(closingIndex);
|
|
212
|
+
const { result, plain_text } = splitTokens(tokens, openingIndex, closingIndex, { [pattern.style]: true }, pattern.opening);
|
|
213
|
+
tokens = result;
|
|
214
|
+
copyOfString = plain_text;
|
|
215
|
+
}
|
|
189
216
|
}
|
|
217
|
+
|
|
218
|
+
/** Cases where only opening char is defined (@, #, etc.) */
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
return {
|
|
194
|
-
tokens
|
|
195
|
-
plain_text:
|
|
196
|
-
}
|
|
223
|
+
tokens,
|
|
224
|
+
plain_text: copyOfString
|
|
225
|
+
};
|
|
197
226
|
}
|
|
198
227
|
|
|
199
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Parse tokens into rich text string.
|
|
230
|
+
* To-do: Find a way to group consequitive tokens with a same annotation inside one single
|
|
231
|
+
* wrapper.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* Tokens: [{ text: "Hello", annotations: { bold: true } }, { text: "World", annotations: { bold: true, italic: true } }]
|
|
235
|
+
* Current output: *Hello* *_World_*
|
|
236
|
+
* Desired output: *Hello _World_*
|
|
237
|
+
*/
|
|
200
238
|
const parseTokens = (tokens: Token[], patterns: Pattern[]) => {
|
|
201
239
|
return tokens.map(token => {
|
|
202
240
|
const { text, annotations } = token;
|
|
241
|
+
// Rich text wrappers (opening and closing chars)
|
|
203
242
|
const wrappers = [];
|
|
204
243
|
|
|
205
|
-
|
|
244
|
+
patterns.forEach(pattern => {
|
|
206
245
|
// If annotation has a truthy value, add the corresponding wrapper.
|
|
207
|
-
if (annotations[
|
|
246
|
+
if (annotations[pattern.style]) wrappers.push(pattern.opening);
|
|
208
247
|
});
|
|
209
248
|
|
|
210
249
|
return wrappers.reduce(
|
|
211
|
-
(children,
|
|
250
|
+
(children, wrapper) => `${wrapper}${children}${wrapper}`,
|
|
212
251
|
text
|
|
213
252
|
);
|
|
214
253
|
}).join("");
|
|
@@ -268,7 +307,6 @@ function insertToken(tokens: Token[], index: number, annotations: Annotations, t
|
|
|
268
307
|
|
|
269
308
|
/**
|
|
270
309
|
* Updates token content (add, remove, replace)
|
|
271
|
-
* Note: need to support cross-token updates.
|
|
272
310
|
* It's actually updating just the text of tokens
|
|
273
311
|
* To-do: Separate the logic of finding the corresponding token into another function.
|
|
274
312
|
* Instead of recieving a diff it could recieve an array of tokens to update.
|
|
@@ -402,6 +440,7 @@ const updateTokens = (tokens: Token[], diff: Diff) => {
|
|
|
402
440
|
* Remove:
|
|
403
441
|
* - For more than two tokens, works.
|
|
404
442
|
* - For two tokens, does not work properly.
|
|
443
|
+
* (right now remove for more than two tokens works properly. Anyway, it might need better testing).
|
|
405
444
|
*/
|
|
406
445
|
if (diff.removed.length > 0) {
|
|
407
446
|
const firstToken = selectedTokens[0];
|
|
@@ -436,7 +475,6 @@ const updateTokens = (tokens: Token[], diff: Diff) => {
|
|
|
436
475
|
|
|
437
476
|
/**
|
|
438
477
|
* Updates annotations and splits tokens if necessary. Only when start !== end.
|
|
439
|
-
* To-do: Add support for multiple annotations. [done].
|
|
440
478
|
* To-do: Separate the logic of finding the corresponding token into another function.
|
|
441
479
|
*/
|
|
442
480
|
const splitTokens = (
|
|
@@ -514,7 +552,10 @@ const splitTokens = (
|
|
|
514
552
|
}
|
|
515
553
|
|
|
516
554
|
updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken)
|
|
517
|
-
return {
|
|
555
|
+
return {
|
|
556
|
+
result: updatedTokens.filter(token => token.text.length > 0),
|
|
557
|
+
plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
|
|
558
|
+
};
|
|
518
559
|
}
|
|
519
560
|
|
|
520
561
|
// Cross-token selection
|
|
@@ -573,11 +614,14 @@ const splitTokens = (
|
|
|
573
614
|
}
|
|
574
615
|
|
|
575
616
|
updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
|
|
576
|
-
return {
|
|
617
|
+
return {
|
|
618
|
+
result: updatedTokens.filter(token => token.text.length > 0),
|
|
619
|
+
plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
|
|
620
|
+
};
|
|
577
621
|
}
|
|
578
622
|
}
|
|
579
623
|
|
|
580
|
-
// Concats tokens containing
|
|
624
|
+
// Concats tokens containing same annotations
|
|
581
625
|
const concatTokens = (tokens: Token[]) => {
|
|
582
626
|
let concatenedTokens = [];
|
|
583
627
|
|
|
@@ -593,7 +637,8 @@ const concatTokens = (tokens: Token[]) => {
|
|
|
593
637
|
* If prev token has all the same annotations as current token, we add curent token text to prev token
|
|
594
638
|
* and continue looping without adding current token to concatened tokens array.
|
|
595
639
|
*/
|
|
596
|
-
|
|
640
|
+
const prevTokenAnnotations = Object.keys(prevToken.annotations);
|
|
641
|
+
if (prevTokenAnnotations.length > 0 && prevTokenAnnotations.every(key => prevToken.annotations[key] === token.annotations[key])) {
|
|
597
642
|
prevToken.text += token.text;
|
|
598
643
|
continue;
|
|
599
644
|
}
|
|
@@ -614,75 +659,25 @@ function Token(props: TokenProps) : JSX.Element {
|
|
|
614
659
|
const { text, annotations } = token;
|
|
615
660
|
const wrappers = [];
|
|
616
661
|
|
|
617
|
-
|
|
662
|
+
patterns.forEach(pattern => {
|
|
618
663
|
// If annotation has a truthy value, add the corresponding wrapper.
|
|
619
|
-
if (annotations[
|
|
664
|
+
if (annotations[pattern.style]) wrappers.push(pattern.render);
|
|
620
665
|
});
|
|
621
|
-
|
|
622
666
|
return wrappers.reduce(
|
|
623
667
|
(children, Wrapper) => <Wrapper>{children}</Wrapper>,
|
|
624
668
|
text
|
|
625
669
|
);
|
|
626
670
|
}
|
|
627
671
|
|
|
628
|
-
function
|
|
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) {
|
|
672
|
+
export default function EnrichedTextInput(props: EnrichedTextInputProps) {
|
|
683
673
|
const {
|
|
684
674
|
ref,
|
|
685
|
-
|
|
675
|
+
stylePatterns = defaultStylePatterns,
|
|
676
|
+
placeholder,
|
|
677
|
+
multiline = false,
|
|
678
|
+
defaultValue,
|
|
679
|
+
onSelectionChange,
|
|
680
|
+
onValueChange,
|
|
686
681
|
} = props;
|
|
687
682
|
|
|
688
683
|
const inputRef = useRef<TextInput>(null);
|
|
@@ -697,7 +692,6 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
697
692
|
code: false
|
|
698
693
|
}
|
|
699
694
|
}]);
|
|
700
|
-
|
|
701
695
|
useEffect(() => {
|
|
702
696
|
if (tokens.length === 0) {
|
|
703
697
|
setTokens([{
|
|
@@ -711,49 +705,67 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
711
705
|
}
|
|
712
706
|
}])
|
|
713
707
|
}
|
|
708
|
+
|
|
709
|
+
onValueChange && onValueChange();
|
|
714
710
|
}, [tokens]);
|
|
715
711
|
|
|
712
|
+
useEffect(( ) => {
|
|
713
|
+
if (defaultValue) {
|
|
714
|
+
if (Array.isArray(defaultValue)) {
|
|
715
|
+
// Maybe check if tokens structure is valid before setting.
|
|
716
|
+
setTokens(defaultValue);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
// To keep styles, parsing should be done before setting defaultValue
|
|
720
|
+
const { tokens, plain_text } = parseRichTextString(defaultValue, stylePatterns);
|
|
721
|
+
setTokens(tokens);
|
|
722
|
+
prevTextRef.current = plain_text;
|
|
723
|
+
}
|
|
724
|
+
}, []);
|
|
725
|
+
|
|
716
726
|
/**
|
|
717
727
|
* Prev text should not contain matching rich text formats.
|
|
718
728
|
* Those should be spliced once the corresponding tokens are created.
|
|
719
729
|
*/
|
|
720
730
|
const prevTextRef = useRef(tokens.map(t => t.text).join(""));
|
|
721
731
|
|
|
722
|
-
|
|
723
|
-
|
|
732
|
+
/**
|
|
733
|
+
* To-do: Find a better name.
|
|
734
|
+
* toSplit state is used to toggle styles when selection length === 0 (start === end).
|
|
735
|
+
* Eg, if user is typing with no styles applied and then presses the "bold" button with no text selected,
|
|
736
|
+
* the text to be inserted after that press should be styled as bold.
|
|
737
|
+
* The same happens to toggle of a style. If user is typing in "bold" and presses the "bold" button again,
|
|
738
|
+
* the text to be inserted after that press should not be styled as bold.
|
|
739
|
+
*/
|
|
724
740
|
const [toSplit, setToSplit] = useState({
|
|
725
741
|
start: 0,
|
|
726
742
|
end: 0,
|
|
727
743
|
annotations: {}
|
|
728
744
|
});
|
|
729
|
-
|
|
745
|
+
/* console.log("toSplit", toSplit); */
|
|
730
746
|
const handleSelectionChange = ({ nativeEvent }) => {
|
|
731
747
|
selectionRef.current = nativeEvent.selection;
|
|
748
|
+
onSelectionChange && onSelectionChange(nativeEvent);
|
|
732
749
|
}
|
|
733
750
|
|
|
734
751
|
const handleOnChangeText = (nextText: string) => {
|
|
735
752
|
const diff = diffStrings(prevTextRef.current, nextText);
|
|
736
753
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
for (const pattern of patterns) {
|
|
740
|
-
match = findMatch(nextText, pattern.regex);
|
|
741
|
-
if (match) break;
|
|
742
|
-
}
|
|
754
|
+
const match = findMatchV2(nextText, stylePatterns);
|
|
755
|
+
/* console.log("MATCH:", match); */
|
|
743
756
|
|
|
757
|
+
// Note: refactor to use new parseRichText function instead of regex
|
|
744
758
|
if (match) {
|
|
745
759
|
// Check token containing match
|
|
746
760
|
// If token already haves this annotation, do not format and perform a simple updateToken.
|
|
747
|
-
const
|
|
748
|
-
const { result } = splitTokens(
|
|
761
|
+
const { result, plain_text } = splitTokens(
|
|
749
762
|
tokens,
|
|
750
763
|
match.start,
|
|
751
|
-
match.end
|
|
752
|
-
{ [
|
|
764
|
+
match.end/* - 1 */, // I don't remember why the -1
|
|
765
|
+
{ [match.pattern.style]: true },
|
|
753
766
|
// Get the rich text opening char to replace it
|
|
754
|
-
|
|
767
|
+
match.pattern.opening
|
|
755
768
|
);
|
|
756
|
-
const plain_text = result.reduce((acc, curr) => acc + curr.text, "");
|
|
757
769
|
|
|
758
770
|
setTokens([...concatTokens(result)]);
|
|
759
771
|
prevTextRef.current = plain_text;
|
|
@@ -761,6 +773,7 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
761
773
|
return;
|
|
762
774
|
}
|
|
763
775
|
|
|
776
|
+
|
|
764
777
|
if (Object.values(toSplit.annotations).some(Boolean) && diff.start === toSplit.start && diff.start === toSplit.end) {
|
|
765
778
|
const { result } = insertToken(
|
|
766
779
|
tokens,
|
|
@@ -789,17 +802,56 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
789
802
|
}
|
|
790
803
|
|
|
791
804
|
useImperativeHandle(ref, () => ({
|
|
792
|
-
|
|
793
|
-
|
|
805
|
+
/**
|
|
806
|
+
* Sets the TextInput's value as a rich text string or an array of tokens.
|
|
807
|
+
*/
|
|
808
|
+
setValue(value: string | Token[]) {
|
|
809
|
+
if (Array.isArray(value)) {
|
|
810
|
+
// Maybe check if tokens structure is valid before setting.
|
|
811
|
+
setTokens(value);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
794
814
|
// To keep styles, parsing should be done before setting value
|
|
795
|
-
const { tokens, plain_text } = parseRichTextString(value,
|
|
796
|
-
setTokens(
|
|
815
|
+
const { tokens, plain_text } = parseRichTextString(value, stylePatterns);
|
|
816
|
+
setTokens(tokens);
|
|
797
817
|
prevTextRef.current = plain_text;
|
|
798
818
|
},
|
|
799
|
-
|
|
800
|
-
|
|
819
|
+
/**
|
|
820
|
+
* Sets the TextInput's selection.
|
|
821
|
+
*/
|
|
822
|
+
setSelection(start: number, end: number) {
|
|
823
|
+
inputRef.current.setSelection(start, end);
|
|
824
|
+
},
|
|
825
|
+
/**
|
|
826
|
+
* Focuses the TextInput.
|
|
827
|
+
*/
|
|
828
|
+
focus() {
|
|
829
|
+
inputRef.current.focus();
|
|
830
|
+
},
|
|
831
|
+
blur() {
|
|
832
|
+
inputRef.current.blur();
|
|
833
|
+
},
|
|
834
|
+
/**
|
|
835
|
+
* Returns the TextInput's value as a rich text string matching the patterns
|
|
836
|
+
* for each style defined in the patterns prop. If a style does not define an
|
|
837
|
+
* opening and closing char, it is ignored.
|
|
838
|
+
*/
|
|
839
|
+
getRawValue() {
|
|
840
|
+
return tokens.map(t => t.text).join("");
|
|
841
|
+
},
|
|
842
|
+
getRichTextValue() {
|
|
843
|
+
return parseTokens(tokens, stylePatterns);
|
|
844
|
+
},
|
|
845
|
+
/**
|
|
846
|
+
* Returns the text input's value as an array of tokens.
|
|
847
|
+
*/
|
|
848
|
+
getTokenizedValue() : Token[] {
|
|
849
|
+
return tokens;
|
|
801
850
|
},
|
|
802
|
-
|
|
851
|
+
/**
|
|
852
|
+
* Toggles a given style. The style prop must match the name of a pattern.
|
|
853
|
+
*/
|
|
854
|
+
toggleStyle(style: keyof Token["annotations"]) {
|
|
803
855
|
const { start, end } = selectionRef.current;
|
|
804
856
|
|
|
805
857
|
if (start === end) {
|
|
@@ -811,38 +863,39 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
811
863
|
return;
|
|
812
864
|
}
|
|
813
865
|
|
|
814
|
-
/**
|
|
815
|
-
* This prevents that when a portion of text is set to bold, the next text inserted after it is not bold.
|
|
816
|
-
*/
|
|
817
|
-
if (start < end) {
|
|
818
|
-
setToSplit({
|
|
819
|
-
start: end,
|
|
820
|
-
end: end,
|
|
821
|
-
annotations: concileAnnotations(toSplit.annotations, { [style]: true })
|
|
822
|
-
})
|
|
823
|
-
}
|
|
824
|
-
|
|
825
866
|
const { result } = splitTokens(tokens, start, end, { [style]: true });
|
|
826
867
|
setTokens([...concatTokens(result)]);
|
|
827
868
|
requestAnimationFrame(() => {
|
|
828
869
|
inputRef.current.setSelection(start, end);
|
|
829
|
-
})
|
|
870
|
+
});
|
|
871
|
+
},
|
|
872
|
+
/**
|
|
873
|
+
* Returns the active styles for the current selection.
|
|
874
|
+
*/
|
|
875
|
+
getActiveStyles() {
|
|
876
|
+
// Check for styles of the token at the current cursor position.
|
|
877
|
+
const { result } = findTokens(tokens, selectionRef.current.start);
|
|
878
|
+
|
|
879
|
+
if (result[0].annotations) {
|
|
880
|
+
return Object.keys(result[0].annotations).filter(key => result[0].annotations[key]);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return [];
|
|
830
884
|
}
|
|
831
885
|
}));
|
|
832
886
|
|
|
833
887
|
return (
|
|
834
888
|
<View style={{ position: "relative" }}>
|
|
835
889
|
<TextInput
|
|
836
|
-
multiline={true}
|
|
837
890
|
ref={inputRef}
|
|
838
|
-
autoComplete="off"
|
|
839
891
|
style={styles.textInput}
|
|
840
|
-
placeholder=
|
|
892
|
+
placeholder={placeholder}
|
|
893
|
+
multiline={multiline}
|
|
841
894
|
onSelectionChange={handleSelectionChange}
|
|
842
895
|
onChangeText={handleOnChangeText}
|
|
843
896
|
>
|
|
844
897
|
<Text style={styles.text}>
|
|
845
|
-
{tokens.map((token, i) => <Token key={i} token={token} patterns={
|
|
898
|
+
{tokens.map((token, i) => <Token key={i} token={token} patterns={stylePatterns}/>)}
|
|
846
899
|
</Text>
|
|
847
900
|
</TextInput>
|
|
848
901
|
</View>
|
|
@@ -858,53 +911,5 @@ const styles = StyleSheet.create({
|
|
|
858
911
|
},
|
|
859
912
|
text: {
|
|
860
913
|
color: "black",
|
|
861
|
-
},
|
|
862
|
-
bold: {
|
|
863
|
-
fontWeight: 'bold',
|
|
864
|
-
},
|
|
865
|
-
italic: {
|
|
866
|
-
fontStyle: "italic"
|
|
867
|
-
},
|
|
868
|
-
lineThrough: {
|
|
869
|
-
textDecorationLine: "line-through"
|
|
870
|
-
},
|
|
871
|
-
underline: {
|
|
872
|
-
textDecorationLine: "underline",
|
|
873
|
-
},
|
|
874
|
-
underlineLineThrough: {
|
|
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"
|
|
909
914
|
}
|
|
910
915
|
});
|