enriched-text-input 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API_REFERENCE.md +1 -0
- package/README.md +19 -16
- package/example/App.tsx +98 -14
- package/example/package.json +3 -2
- package/index.ts +2 -3
- package/package.json +5 -3
- package/src/RichTextInput.tsx +316 -369
- package/src/Toolbar.tsx +68 -18
package/API_REFERENCE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# API Reference
|
package/README.md
CHANGED
|
@@ -14,21 +14,6 @@ The field for rich-text in react native is still a bit green. Current libraries
|
|
|
14
14
|
|
|
15
15
|
In theory, by only using JavaScript we are able to provide better cross-platform compatibility and the possibility to style elements however you want as long as they follow react-native's `Text` supported styles.
|
|
16
16
|
|
|
17
|
-
## Features
|
|
18
|
-
|
|
19
|
-
- [x] Basic text formatting (__bold__, _italic_, underline, ~~strikethrough~~ and `codeblocks`).
|
|
20
|
-
- [x] Rich text format parsing.
|
|
21
|
-
- [ ] Links and mentions.
|
|
22
|
-
- [ ] Custom styling.
|
|
23
|
-
- [ ] Custom rich text patterns.
|
|
24
|
-
- [ ] Exposed event handlers (onSubmit, onChange, onBlur, onFocus, etc).
|
|
25
|
-
- [ ] Custom methods and event handlers (setValue, onStartMention, onStyleChange, etc).
|
|
26
|
-
- [ ] Headings.
|
|
27
|
-
|
|
28
|
-
## Known limitations
|
|
29
|
-
- Inline images.
|
|
30
|
-
- Only `Text`component styles are supported.
|
|
31
|
-
|
|
32
17
|
## Installation
|
|
33
18
|
```
|
|
34
19
|
npm install enriched-text-input
|
|
@@ -66,9 +51,27 @@ const styles = StyleSheet.create({
|
|
|
66
51
|
paddingTop: 120
|
|
67
52
|
},
|
|
68
53
|
});
|
|
54
|
+
```
|
|
69
55
|
|
|
56
|
+
## Current state
|
|
57
|
+
At the moment [1/1/2026] `enriched-text-input` works great for things such as small rich-text inputs (Eg. an input for a messaging app with rich-text support) but not for creating whole rich-text editors. This is because inline styles that do not break line are working as expected (Eg. bold, italic or underline work great but styles such as headings break line so they are currently not supported).
|
|
70
58
|
|
|
71
|
-
|
|
59
|
+
Live parsing of rich text symbols (such as wrapping words in asterisks `*`) is still a work in progress an not working correctly but you can toggle styles through the ref api of the `EnrichedTextInput` (or use the provided `Toolbar` component as shown in the example usage).
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- [x] Basic text formatting (__bold__, _italic_, underline, ~~strikethrough~~ and `inline code`).
|
|
64
|
+
- [ ] Rich text format parsing.
|
|
65
|
+
- [ ] Links and mentions.
|
|
66
|
+
- [x] Custom styling.
|
|
67
|
+
- [x] Custom rich text patterns.
|
|
68
|
+
- [ ] Exposed event handlers (onSubmit, onChange, onBlur, onFocus, etc).
|
|
69
|
+
- [ ] Custom methods and event handlers (setValue, onStartMention, onStyleChange, etc).
|
|
70
|
+
- [ ] Headings.
|
|
71
|
+
|
|
72
|
+
## Known limitations
|
|
73
|
+
- Inline images.
|
|
74
|
+
- Only `Text`component styles are supported.
|
|
72
75
|
|
|
73
76
|
## Contributing
|
|
74
77
|
|
package/example/App.tsx
CHANGED
|
@@ -1,23 +1,100 @@
|
|
|
1
|
-
import { useRef } from 'react';
|
|
2
|
-
import { StyleSheet, View } from 'react-native';
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View, KeyboardAvoidingView, Text, TouchableOpacity, Button, TextInput } from 'react-native';
|
|
3
|
+
import { FontAwesome6 } from '@expo/vector-icons';
|
|
4
|
+
import { RichTextInput, Toolbar, PATTERNS } from 'enriched-text-input';
|
|
5
|
+
import * as Clipboard from 'expo-clipboard';
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
function Comment({ children }) {
|
|
8
|
+
return (
|
|
9
|
+
<Text style={{
|
|
10
|
+
backgroundColor: "rgba(255, 203, 0, .12)",
|
|
11
|
+
textDecorationLine: "underline",
|
|
12
|
+
textDecorationColor: "rgba(255, 203, 0, .35)",
|
|
13
|
+
}}>
|
|
14
|
+
{children}
|
|
15
|
+
</Text>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
5
18
|
|
|
6
19
|
export default function App() {
|
|
20
|
+
const [rawValue, setRawValue] = useState("");
|
|
21
|
+
const [richTextStringValue, setRichTextStringValue] = useState("");
|
|
22
|
+
const [activeStyles, setActiveStyles] = useState([]);
|
|
23
|
+
console.log("ACTIVE STYLES:", activeStyles);
|
|
7
24
|
const richTextInputRef = useRef(null);
|
|
8
25
|
|
|
26
|
+
const customPatterns = [
|
|
27
|
+
...PATTERNS,
|
|
28
|
+
{ style: "comment", regex: null, render: Comment }
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const handleComment = () => {
|
|
32
|
+
richTextInputRef.current?.toggleStyle("comment");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const handleGetRichText = () => {
|
|
36
|
+
const richText = richTextInputRef.current?.getRichTextString();
|
|
37
|
+
|
|
38
|
+
setRichTextStringValue(richText);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const handleCopyToClipboard = async (text: string) => {
|
|
42
|
+
await Clipboard.setStringAsync(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
9
45
|
return (
|
|
10
|
-
<
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
<KeyboardAvoidingView style={styles.container} behavior="padding">
|
|
47
|
+
<View style={{ flex: 1 }}>
|
|
48
|
+
<TextInput
|
|
49
|
+
multiline
|
|
50
|
+
style={{ fontSize: 20, padding: 16 }}
|
|
51
|
+
value={rawValue}
|
|
52
|
+
onChangeText={(text) => setRawValue(text)}
|
|
53
|
+
placeholder='Raw text'
|
|
54
|
+
/>
|
|
55
|
+
<Button
|
|
56
|
+
title='Set rich text string'
|
|
57
|
+
onPress={() => richTextInputRef.current?.setValue(rawValue)}
|
|
58
|
+
/>
|
|
59
|
+
|
|
60
|
+
<RichTextInput
|
|
61
|
+
ref={richTextInputRef}
|
|
62
|
+
patterns={customPatterns}
|
|
63
|
+
autoComplete="off"
|
|
64
|
+
placeholder="Rich text"
|
|
65
|
+
multiline={true}
|
|
66
|
+
/>
|
|
67
|
+
<Button
|
|
68
|
+
title='Get rich text string'
|
|
69
|
+
onPress={handleGetRichText}
|
|
70
|
+
/>
|
|
71
|
+
<Text style={{ padding: 16, fontSize: 20, color: richTextStringValue ? "black" : "#b3b3b3" }}>{richTextStringValue ? richTextStringValue : "Get rich text output will appear here!"}</Text>
|
|
72
|
+
|
|
73
|
+
<View style={{ flexDirection: "row", justifyContent: "center"}}>
|
|
74
|
+
<Button
|
|
75
|
+
title='Clear'
|
|
76
|
+
onPress={() => setRichTextStringValue("")}
|
|
77
|
+
/>
|
|
78
|
+
<Button
|
|
79
|
+
title='Copy'
|
|
80
|
+
onPress={() => handleCopyToClipboard(richTextStringValue)}
|
|
81
|
+
/>
|
|
82
|
+
</View>
|
|
83
|
+
</View>
|
|
84
|
+
<View style={{ alignSelf: "end"}}>
|
|
85
|
+
<Toolbar richTextInputRef={richTextInputRef}>
|
|
86
|
+
<Toolbar.Bold/>
|
|
87
|
+
<Toolbar.Italic />
|
|
88
|
+
<Toolbar.Underline />
|
|
89
|
+
<Toolbar.Strikethrough />
|
|
90
|
+
<Toolbar.Code />
|
|
91
|
+
{/* <TouchableOpacity style={styles.toolbarButton} onPress={handleComment}>
|
|
92
|
+
<FontAwesome6 name="comment-alt" size={16} color="black" />
|
|
93
|
+
</TouchableOpacity> */}
|
|
94
|
+
<Toolbar.Keyboard />
|
|
95
|
+
</Toolbar>
|
|
96
|
+
</View>
|
|
97
|
+
</KeyboardAvoidingView>
|
|
21
98
|
);
|
|
22
99
|
}
|
|
23
100
|
|
|
@@ -27,4 +104,11 @@ const styles = StyleSheet.create({
|
|
|
27
104
|
backgroundColor: '#fff',
|
|
28
105
|
paddingTop: 120
|
|
29
106
|
},
|
|
107
|
+
toolbarButton: {
|
|
108
|
+
height: 50,
|
|
109
|
+
width: 50,
|
|
110
|
+
display: "flex",
|
|
111
|
+
justifyContent: "center",
|
|
112
|
+
alignItems: "center"
|
|
113
|
+
}
|
|
30
114
|
});
|
package/example/package.json
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@expo/vector-icons": "^15.0.3",
|
|
13
|
+
"enriched-text-input": "file:../",
|
|
13
14
|
"expo": "~54.0.25",
|
|
15
|
+
"expo-clipboard": "~8.0.8",
|
|
14
16
|
"expo-status-bar": "~3.0.8",
|
|
15
17
|
"react": "19.1.0",
|
|
16
|
-
"react-native": "0.81.5"
|
|
17
|
-
"enriched-text-input": "file:../"
|
|
18
|
+
"react-native": "0.81.5"
|
|
18
19
|
},
|
|
19
20
|
"private": true,
|
|
20
21
|
"devDependencies": {
|
package/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import RichTextInput from "./src/RichTextInput";
|
|
2
|
-
import { Code } from "./src/RichTextInput";
|
|
1
|
+
import RichTextInput, { PATTERNS } from "./src/RichTextInput";
|
|
3
2
|
import Toolbar from "./src/Toolbar";
|
|
4
3
|
|
|
5
|
-
export { RichTextInput,
|
|
4
|
+
export { RichTextInput, PATTERNS, Toolbar };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enriched-text-input",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "JavaScript only rich text input component for React Native. Compatible with Expo Go.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rich-text",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"example"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
31
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
32
|
+
"release": "release-it"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@expo/vector-icons": "^15.0.3",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"react-native": "0.81.5"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
|
-
"metro-react-native-babel-preset": "^0.77.0"
|
|
41
|
+
"metro-react-native-babel-preset": "^0.77.0",
|
|
42
|
+
"release-it": "^19.0.6"
|
|
41
43
|
}
|
|
42
44
|
}
|
package/src/RichTextInput.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useImperativeHandle, useRef, useEffect } from "react";
|
|
2
|
-
import { TextInput, Text, StyleSheet, View,
|
|
1
|
+
import { useState, useImperativeHandle, useRef, useEffect, JSX } from "react";
|
|
2
|
+
import { TextInput, Text, StyleSheet, View, TextInputProps } from "react-native";
|
|
3
3
|
|
|
4
4
|
interface Token {
|
|
5
5
|
text: string;
|
|
@@ -13,11 +13,7 @@ interface Diff {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
interface Annotations {
|
|
16
|
-
|
|
17
|
-
italic: boolean;
|
|
18
|
-
lineThrough: boolean;
|
|
19
|
-
underline: boolean;
|
|
20
|
-
code: boolean;
|
|
16
|
+
[key: string]: boolean | string | null
|
|
21
17
|
}
|
|
22
18
|
|
|
23
19
|
interface RichTextMatch {
|
|
@@ -25,18 +21,39 @@ interface RichTextMatch {
|
|
|
25
21
|
content: string;
|
|
26
22
|
start: number;
|
|
27
23
|
end: number;
|
|
24
|
+
pattern: Pattern;
|
|
25
|
+
/** @deprecated */
|
|
28
26
|
expression: string;
|
|
29
27
|
}
|
|
30
28
|
|
|
29
|
+
interface Pattern {
|
|
30
|
+
regex: string;
|
|
31
|
+
style: string;
|
|
32
|
+
render: any;
|
|
33
|
+
opening?: string;
|
|
34
|
+
closing?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
interface RichTextInputProps {
|
|
32
|
-
ref: any
|
|
38
|
+
ref: any;
|
|
39
|
+
patterns?: Pattern[]
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Note: maybe instead of using regex we could just define an "opening" and "closing" char.
|
|
44
|
+
* If both are defined we look for a match that looks like {opening}{content}{closing}.
|
|
45
|
+
* If just opening is defined, we look for a match that looks like {opening}{content}.
|
|
46
|
+
* Closing can not be defined if opening is not defined.
|
|
47
|
+
*/
|
|
48
|
+
export const PATTERNS : Pattern[] = [
|
|
49
|
+
{ style: "bold", regex: "\\*([^*]+)\\*", render: Bold, opening: "*", closing: "*" },
|
|
50
|
+
{ style: "italic", regex: "_([^_]+)_", render: Italic, opening: "_", closing: "_" },
|
|
51
|
+
{ style: "lineThrough", regex: "~([^~]+)~", render: Strikethrough, opening: "~", closing: "~" },
|
|
52
|
+
{ style: "code", regex: "`([^`]+)`", render: Code, opening: "`", closing: "`" },
|
|
53
|
+
{ style: "underline", regex: "__([^_]+)__", render: Underline, opening: "__", closing: "__" },
|
|
54
|
+
{ style: "heading", regex: null, render: Heading, opening: "#", closing: null },
|
|
55
|
+
{ style: "subHeading", regex: null, render: SubHeading, opening: "##", closing: null },
|
|
56
|
+
{ style: "subSubHeading", regex: null, render: SubSubHeading, opening: "###", closing: null }
|
|
40
57
|
];
|
|
41
58
|
|
|
42
59
|
function insertAt(str, index, substring) {
|
|
@@ -55,66 +72,88 @@ function replaceAt(str, index, substring, length) {
|
|
|
55
72
|
return str.slice(0, i) + substring + str.slice(i + length);
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
function findTokens(
|
|
76
|
+
/** The tokens to search over. */
|
|
77
|
+
tokens: Token[],
|
|
78
|
+
/** start position of selection.*/
|
|
79
|
+
start: number,
|
|
80
|
+
/** end position of selection.*/
|
|
81
|
+
end?: number
|
|
82
|
+
) {
|
|
83
|
+
|
|
84
|
+
if (end) {
|
|
85
|
+
// Search for all tokens between start and end
|
|
86
|
+
return { result: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let startIndex = start;
|
|
90
|
+
let startToken;
|
|
91
|
+
for (const token of tokens) {
|
|
92
|
+
if (startIndex <= token.text.length) {
|
|
93
|
+
startToken = token;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
startIndex -= token.text.length;
|
|
68
97
|
}
|
|
69
|
-
: null;
|
|
70
|
-
}
|
|
71
98
|
|
|
72
|
-
|
|
73
|
-
// Strip leading/trailing slashes and flags (if user passed /.../ form)
|
|
74
|
-
regexString = regexString.replace(/^\/|\/[a-z]*$/g, "");
|
|
99
|
+
const startTokenIndex = tokens.indexOf(startToken);
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
return { result: [startToken] };
|
|
102
|
+
}
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
/**
|
|
105
|
+
* To-do: Add support for openings and closings that are conformed by two or more chars (e.g. **, __, <b>, etc.)
|
|
106
|
+
*/
|
|
107
|
+
function findMatchV2(str: string, patterns: Pattern[]) : RichTextMatch | null {
|
|
108
|
+
let match = null;
|
|
109
|
+
let copyOfString = str;
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
for (const pattern of patterns) {
|
|
112
|
+
let evenCount = 0;
|
|
113
|
+
|
|
114
|
+
for (const char of copyOfString) {
|
|
115
|
+
/** Cases where both opening and closing chars are defined (*...*, _..._, etc.)*/
|
|
116
|
+
if (pattern.opening && pattern.closing) {
|
|
117
|
+
if (evenCount < 2 && char === pattern.opening) {
|
|
118
|
+
evenCount++;
|
|
119
|
+
}
|
|
120
|
+
if (evenCount === 2 && char === pattern.closing) {
|
|
121
|
+
const openingIndex = copyOfString.indexOf(pattern.opening);
|
|
122
|
+
const closingIndex = copyOfString.indexOf(pattern.closing, openingIndex + 1);
|
|
123
|
+
|
|
124
|
+
match = {
|
|
125
|
+
raw: copyOfString.slice(openingIndex, closingIndex + 1),
|
|
126
|
+
content: copyOfString.slice(openingIndex + 1, closingIndex),
|
|
127
|
+
start: openingIndex,
|
|
128
|
+
end: closingIndex,
|
|
129
|
+
pattern,
|
|
130
|
+
/** @deprecated */
|
|
131
|
+
expression: pattern.regex
|
|
132
|
+
};
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
90
136
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const afterGroup = regexString.match(/\)([^).]+)/);
|
|
94
|
-
if (afterGroup) {
|
|
95
|
-
const part = afterGroup[1];
|
|
96
|
-
const litMatch = part.match(/\\(.)|([^\\])/);
|
|
97
|
-
if (litMatch) {
|
|
98
|
-
closeLiteral = litMatch[1] ?? litMatch[2];
|
|
137
|
+
/** Cases where only opening char is defined (@, #, etc.) */
|
|
138
|
+
}
|
|
99
139
|
}
|
|
100
|
-
}
|
|
101
140
|
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
opening: openLiteral,
|
|
105
|
-
closing: closeLiteral,
|
|
106
|
-
};
|
|
141
|
+
return match;
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
144
|
+
/**
|
|
145
|
+
* If prev token contains new annotation, negate prev. Else, use new annotation.
|
|
146
|
+
*/
|
|
147
|
+
function concileAnnotations(prevAnnotations, newAnnotations) {
|
|
148
|
+
let updatedAnnotations = { ...prevAnnotations };
|
|
149
|
+
|
|
150
|
+
for (const key of Object.keys(newAnnotations)) {
|
|
151
|
+
newAnnotations[key]
|
|
152
|
+
? updatedAnnotations[key] = !updatedAnnotations[key]
|
|
153
|
+
: updatedAnnotations[key] = newAnnotations[key];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return updatedAnnotations;
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
// Returns string modifications
|
|
@@ -140,58 +179,73 @@ function diffStrings(prev, next) : Diff {
|
|
|
140
179
|
};
|
|
141
180
|
}
|
|
142
181
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Parse rich text string into tokens.
|
|
184
|
+
*/
|
|
185
|
+
const parseRichTextString = (richTextString: string, patterns: Pattern[], initialTokens?: Token[])
|
|
186
|
+
: { tokens: Token[], plain_text: string } => {
|
|
187
|
+
let copyOfString = richTextString;
|
|
188
|
+
let tokens : Token[] = initialTokens || [
|
|
146
189
|
{
|
|
147
|
-
text:
|
|
148
|
-
annotations: {
|
|
149
|
-
bold: false,
|
|
150
|
-
italic: false,
|
|
151
|
-
lineThrough: false,
|
|
152
|
-
underline: false,
|
|
153
|
-
code: false
|
|
154
|
-
}
|
|
190
|
+
text: copyOfString,
|
|
191
|
+
annotations: {}
|
|
155
192
|
}
|
|
156
193
|
];
|
|
157
|
-
let plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
|
|
158
194
|
|
|
159
195
|
for (const pattern of patterns) {
|
|
160
|
-
let
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
plain_text: parsed.plain_text
|
|
196
|
+
let evenCount = 0;
|
|
197
|
+
|
|
198
|
+
for (const char of copyOfString) {
|
|
199
|
+
/** Cases where both opening and closing chars are defined (*...*, _..._, etc.)*/
|
|
200
|
+
if (pattern.opening && pattern.closing) {
|
|
201
|
+
if (evenCount < 2 && char === pattern.opening) {
|
|
202
|
+
evenCount++;
|
|
203
|
+
}
|
|
204
|
+
if (evenCount === 2 && char === pattern.closing) {
|
|
205
|
+
const openingIndex = copyOfString.indexOf(pattern.opening);
|
|
206
|
+
const closingIndex = copyOfString.indexOf(pattern.closing, openingIndex + 1);
|
|
207
|
+
|
|
208
|
+
copyOfString = copyOfString.slice(0, openingIndex) + copyOfString.slice(closingIndex);
|
|
209
|
+
const { result, plain_text } = splitTokens(tokens, openingIndex, closingIndex, { [pattern.style]: true }, pattern.opening);
|
|
210
|
+
tokens = result;
|
|
211
|
+
copyOfString = plain_text;
|
|
212
|
+
}
|
|
178
213
|
}
|
|
214
|
+
|
|
215
|
+
/** Cases where only opening char is defined (@, #, etc.) */
|
|
179
216
|
}
|
|
180
217
|
}
|
|
181
218
|
|
|
182
219
|
return {
|
|
183
|
-
tokens
|
|
184
|
-
plain_text:
|
|
185
|
-
}
|
|
220
|
+
tokens,
|
|
221
|
+
plain_text: copyOfString
|
|
222
|
+
};
|
|
186
223
|
}
|
|
187
224
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Parse tokens into rich text string.
|
|
227
|
+
*/
|
|
228
|
+
const parseTokens = (tokens: Token[], patterns: Pattern[]) => {
|
|
229
|
+
return tokens.map(token => {
|
|
230
|
+
const { text, annotations } = token;
|
|
231
|
+
// Rich text wrappers (opening and closing chars)
|
|
232
|
+
const wrappers = [];
|
|
233
|
+
|
|
234
|
+
patterns.forEach(pattern => {
|
|
235
|
+
// If annotation has a truthy value, add the corresponding wrapper.
|
|
236
|
+
if (annotations[pattern.style]) wrappers.push(pattern.opening);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return wrappers.reduce(
|
|
240
|
+
(children, wrapper) => `${wrapper}${children}${wrapper}`,
|
|
241
|
+
text
|
|
242
|
+
);
|
|
243
|
+
}).join("");
|
|
191
244
|
}
|
|
192
245
|
|
|
193
246
|
// Inserts a token at the given index
|
|
194
247
|
// Only when start === end
|
|
248
|
+
// To-do: Instead of recieving annotations and text it could recieve a token.
|
|
195
249
|
function insertToken(tokens: Token[], index: number, annotations: Annotations, text = "" ) {
|
|
196
250
|
const updatedTokens = [...tokens];
|
|
197
251
|
|
|
@@ -227,30 +281,26 @@ function insertToken(tokens: Token[], index: number, annotations: Annotations, t
|
|
|
227
281
|
// Middle token is the selected text
|
|
228
282
|
let middleToken = {
|
|
229
283
|
text: text,
|
|
230
|
-
annotations: concileAnnotations(startToken.annotations, annotations)
|
|
284
|
+
annotations: concileAnnotations(startToken.annotations, annotations) // prevAnnotations + newAnnotations
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
let lastToken = {
|
|
234
288
|
text: startToken.text.slice(startIndex , startToken.text.length),
|
|
235
289
|
annotations: startToken.annotations
|
|
236
290
|
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Note: the following conditionals are to prevent empty tokens.
|
|
240
|
-
* It would be ideal if instead of catching empty tokens we could write the correct insert logic to prevent them.
|
|
241
|
-
* Maybe use a filter instead?
|
|
242
|
-
*/
|
|
243
291
|
|
|
244
292
|
updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken);
|
|
245
|
-
|
|
246
293
|
return {
|
|
247
294
|
result: updatedTokens.filter(token => token.text.length > 0)
|
|
248
295
|
};
|
|
249
296
|
}
|
|
250
297
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Updates token content (add, remove, replace)
|
|
300
|
+
* It's actually updating just the text of tokens
|
|
301
|
+
* To-do: Separate the logic of finding the corresponding token into another function.
|
|
302
|
+
* Instead of recieving a diff it could recieve an array of tokens to update.
|
|
303
|
+
*/
|
|
254
304
|
const updateTokens = (tokens: Token[], diff: Diff) => {
|
|
255
305
|
let updatedTokens = [...tokens];
|
|
256
306
|
const plain_text = tokens.reduce((acc, curr) => acc + curr.text, "");
|
|
@@ -412,9 +462,10 @@ const updateTokens = (tokens: Token[], diff: Diff) => {
|
|
|
412
462
|
}
|
|
413
463
|
}
|
|
414
464
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
465
|
+
/**
|
|
466
|
+
* Updates annotations and splits tokens if necessary. Only when start !== end.
|
|
467
|
+
* To-do: Separate the logic of finding the corresponding token into another function.
|
|
468
|
+
*/
|
|
418
469
|
const splitTokens = (
|
|
419
470
|
tokens: Token[],
|
|
420
471
|
start: number,
|
|
@@ -475,6 +526,11 @@ const splitTokens = (
|
|
|
475
526
|
let middleToken = {
|
|
476
527
|
// The replace method is used to remove the opening and closing rich text literal chars when parsing.
|
|
477
528
|
text: startToken.text.slice(startIndex, endIndex).replace(withReplacement, ""),
|
|
529
|
+
/**
|
|
530
|
+
* We need to concile previous annotations with new ones.
|
|
531
|
+
* Eg. If we are applying bold to middle token but start token already has bold, we need to toggle bold off.
|
|
532
|
+
* But if we are applying bold to middle token but start token does not have bold, we need to toggle bold on.
|
|
533
|
+
*/
|
|
478
534
|
annotations: concileAnnotations(startToken.annotations, annotations)
|
|
479
535
|
}
|
|
480
536
|
|
|
@@ -485,7 +541,10 @@ const splitTokens = (
|
|
|
485
541
|
}
|
|
486
542
|
|
|
487
543
|
updatedTokens.splice(startTokenIndex, 1, firstToken, middleToken, lastToken)
|
|
488
|
-
return {
|
|
544
|
+
return {
|
|
545
|
+
result: updatedTokens.filter(token => token.text.length > 0),
|
|
546
|
+
plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
|
|
547
|
+
};
|
|
489
548
|
}
|
|
490
549
|
|
|
491
550
|
// Cross-token selection
|
|
@@ -544,11 +603,14 @@ const splitTokens = (
|
|
|
544
603
|
}
|
|
545
604
|
|
|
546
605
|
updatedTokens = updatedTokens.slice(0, startTokenIndex).concat([firstToken, secondToken, ...updatedMiddleTokens, secondToLastToken, lastToken]).concat(updatedTokens.slice(endTokenIndex + 1));
|
|
547
|
-
return {
|
|
606
|
+
return {
|
|
607
|
+
result: updatedTokens.filter(token => token.text.length > 0),
|
|
608
|
+
plain_text: updatedTokens.reduce((acc, curr) => acc + curr.text, "")
|
|
609
|
+
};
|
|
548
610
|
}
|
|
549
611
|
}
|
|
550
612
|
|
|
551
|
-
// Concats tokens containing
|
|
613
|
+
// Concats tokens containing same annotations
|
|
552
614
|
const concatTokens = (tokens: Token[]) => {
|
|
553
615
|
let concatenedTokens = [];
|
|
554
616
|
|
|
@@ -560,11 +622,12 @@ const concatTokens = (tokens: Token[]) => {
|
|
|
560
622
|
|
|
561
623
|
const prevToken = concatenedTokens[concatenedTokens.length - 1];
|
|
562
624
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
625
|
+
/**
|
|
626
|
+
* If prev token has all the same annotations as current token, we add curent token text to prev token
|
|
627
|
+
* and continue looping without adding current token to concatened tokens array.
|
|
628
|
+
*/
|
|
629
|
+
const prevTokenAnnotations = Object.keys(prevToken.annotations);
|
|
630
|
+
if (prevTokenAnnotations.length > 0 && prevTokenAnnotations.every(key => prevToken.annotations[key] === token.annotations[key])) {
|
|
568
631
|
prevToken.text += token.text;
|
|
569
632
|
continue;
|
|
570
633
|
}
|
|
@@ -575,17 +638,20 @@ const concatTokens = (tokens: Token[]) => {
|
|
|
575
638
|
return concatenedTokens;
|
|
576
639
|
}
|
|
577
640
|
|
|
578
|
-
|
|
641
|
+
interface TokenProps {
|
|
642
|
+
token: Token;
|
|
643
|
+
patterns: Pattern[]
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function Token(props: TokenProps) : JSX.Element {
|
|
647
|
+
const { token, patterns } = props;
|
|
579
648
|
const { text, annotations } = token;
|
|
580
649
|
const wrappers = [];
|
|
581
650
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (annotations.lineThrough) wrappers.push(Strikethrough);
|
|
587
|
-
if (annotations.code) wrappers.push(Code);
|
|
588
|
-
|
|
651
|
+
patterns.forEach(pattern => {
|
|
652
|
+
// If annotation has a truthy value, add the corresponding wrapper.
|
|
653
|
+
if (annotations[pattern.style]) wrappers.push(pattern.render);
|
|
654
|
+
});
|
|
589
655
|
return wrappers.reduce(
|
|
590
656
|
(children, Wrapper) => <Wrapper>{children}</Wrapper>,
|
|
591
657
|
text
|
|
@@ -628,9 +694,35 @@ function UnderlineStrikethrough({ children }) {
|
|
|
628
694
|
)
|
|
629
695
|
}
|
|
630
696
|
|
|
697
|
+
function Heading({ children }) {
|
|
698
|
+
return (
|
|
699
|
+
<Text style={styles.heading}>{children}</Text>
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function SubHeading({ children }) {
|
|
704
|
+
return (
|
|
705
|
+
<Text style={styles.subHeading}>{children}</Text>
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function SubSubHeading({ children }) {
|
|
710
|
+
return (
|
|
711
|
+
<Text style={styles.subSubHeading}>{children}</Text>
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
631
715
|
export default function RichTextInput(props: RichTextInputProps) {
|
|
632
716
|
const {
|
|
633
|
-
ref
|
|
717
|
+
ref,
|
|
718
|
+
patterns = PATTERNS,
|
|
719
|
+
|
|
720
|
+
/** TextInput props */
|
|
721
|
+
value,
|
|
722
|
+
defaultValue,
|
|
723
|
+
onChangeText,
|
|
724
|
+
onSelectionChange,
|
|
725
|
+
...rest
|
|
634
726
|
} = props;
|
|
635
727
|
|
|
636
728
|
const inputRef = useRef<TextInput>(null);
|
|
@@ -645,7 +737,6 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
645
737
|
code: false
|
|
646
738
|
}
|
|
647
739
|
}]);
|
|
648
|
-
console.log(tokens);
|
|
649
740
|
useEffect(() => {
|
|
650
741
|
if (tokens.length === 0) {
|
|
651
742
|
setTokens([{
|
|
@@ -667,47 +758,43 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
667
758
|
*/
|
|
668
759
|
const prevTextRef = useRef(tokens.map(t => t.text).join(""));
|
|
669
760
|
|
|
670
|
-
|
|
671
|
-
|
|
761
|
+
/**
|
|
762
|
+
* To-do: Find a better name.
|
|
763
|
+
* toSplit state is used to toggle styles when selection length === 0 (start === end).
|
|
764
|
+
* Eg, if user is typing with no styles applied and then presses the "bold" button with no text selected,
|
|
765
|
+
* the text to be inserted after that press should be styled as bold.
|
|
766
|
+
* The same happens to toggle of a style. If user is typing in "bold" and presses the "bold" button again,
|
|
767
|
+
* the text to be inserted after that press should not be styled as bold.
|
|
768
|
+
*/
|
|
672
769
|
const [toSplit, setToSplit] = useState({
|
|
673
770
|
start: 0,
|
|
674
771
|
end: 0,
|
|
675
|
-
annotations: {
|
|
676
|
-
bold: false,
|
|
677
|
-
italic: false,
|
|
678
|
-
lineThrough: false,
|
|
679
|
-
underline: false,
|
|
680
|
-
code: false
|
|
681
|
-
}
|
|
772
|
+
annotations: {}
|
|
682
773
|
});
|
|
683
|
-
|
|
774
|
+
/* console.log("toSplit", toSplit); */
|
|
684
775
|
const handleSelectionChange = ({ nativeEvent }) => {
|
|
685
776
|
selectionRef.current = nativeEvent.selection;
|
|
777
|
+
onSelectionChange && onSelectionChange(nativeEvent);
|
|
686
778
|
}
|
|
687
779
|
|
|
688
780
|
const handleOnChangeText = (nextText: string) => {
|
|
689
781
|
const diff = diffStrings(prevTextRef.current, nextText);
|
|
690
782
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
for (const pattern of PATTERNS) {
|
|
694
|
-
match = findMatch(nextText, pattern.regex);
|
|
695
|
-
if (match) break;
|
|
696
|
-
}
|
|
783
|
+
const match = findMatchV2(nextText, patterns);
|
|
784
|
+
/* console.log("MATCH:", match); */
|
|
697
785
|
|
|
786
|
+
// Note: refactor to use new parseRichText function instead of regex
|
|
698
787
|
if (match) {
|
|
699
788
|
// Check token containing match
|
|
700
789
|
// If token already haves this annotation, do not format and perform a simple updateToken.
|
|
701
|
-
const
|
|
702
|
-
const { result } = splitTokens(
|
|
790
|
+
const { result, plain_text } = splitTokens(
|
|
703
791
|
tokens,
|
|
704
792
|
match.start,
|
|
705
|
-
match.end
|
|
706
|
-
{ [
|
|
793
|
+
match.end/* - 1 */, // I don't remember why the -1
|
|
794
|
+
{ [match.pattern.style]: true },
|
|
707
795
|
// Get the rich text opening char to replace it
|
|
708
|
-
|
|
796
|
+
match.pattern.opening
|
|
709
797
|
);
|
|
710
|
-
const plain_text = result.reduce((acc, curr) => acc + curr.text, "");
|
|
711
798
|
|
|
712
799
|
setTokens([...concatTokens(result)]);
|
|
713
800
|
prevTextRef.current = plain_text;
|
|
@@ -715,10 +802,8 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
715
802
|
return;
|
|
716
803
|
}
|
|
717
804
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
&& diff.added.length > 0
|
|
721
|
-
&& Object.values(toSplit.annotations).includes(true)) {
|
|
805
|
+
|
|
806
|
+
if (Object.values(toSplit.annotations).some(Boolean) && diff.start === toSplit.start && diff.start === toSplit.end) {
|
|
722
807
|
const { result } = insertToken(
|
|
723
808
|
tokens,
|
|
724
809
|
diff.start,
|
|
@@ -726,24 +811,19 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
726
811
|
diff.added
|
|
727
812
|
);
|
|
728
813
|
const plain_text = result.map(t => t.text).join("");
|
|
729
|
-
setTokens(
|
|
814
|
+
setTokens(concatTokens(result));
|
|
730
815
|
|
|
731
816
|
// Reset
|
|
732
817
|
setToSplit({
|
|
733
818
|
start: 0,
|
|
734
819
|
end: 0,
|
|
735
|
-
annotations: {
|
|
736
|
-
bold: false,
|
|
737
|
-
italic: false,
|
|
738
|
-
lineThrough: false,
|
|
739
|
-
underline: false,
|
|
740
|
-
code: false
|
|
741
|
-
}
|
|
820
|
+
annotations: {}
|
|
742
821
|
});
|
|
743
822
|
prevTextRef.current = plain_text;
|
|
744
823
|
return;
|
|
745
824
|
}
|
|
746
825
|
|
|
826
|
+
// Default update
|
|
747
827
|
const { updatedTokens, plain_text} = updateTokens(tokens, diff);
|
|
748
828
|
|
|
749
829
|
setTokens([...concatTokens(updatedTokens)]);
|
|
@@ -751,241 +831,96 @@ export default function RichTextInput(props: RichTextInputProps) {
|
|
|
751
831
|
}
|
|
752
832
|
|
|
753
833
|
useImperativeHandle(ref, () => ({
|
|
754
|
-
|
|
755
|
-
|
|
834
|
+
/**
|
|
835
|
+
* Sets the TextInput's value as a rich text string or an array of tokens.
|
|
836
|
+
*/
|
|
837
|
+
setValue(value: string | Token[]) {
|
|
838
|
+
if (Array.isArray(value)) {
|
|
839
|
+
// Maybe check if tokens structure is valid before setting.
|
|
840
|
+
setTokens(value);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
756
843
|
// To keep styles, parsing should be done before setting value
|
|
757
|
-
const { tokens, plain_text } = parseRichTextString(value,
|
|
758
|
-
setTokens(
|
|
844
|
+
const { tokens, plain_text } = parseRichTextString(value, patterns);
|
|
845
|
+
setTokens(tokens);
|
|
759
846
|
prevTextRef.current = plain_text;
|
|
760
847
|
},
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
start,
|
|
767
|
-
end,
|
|
768
|
-
annotations: {
|
|
769
|
-
...toSplit.annotations,
|
|
770
|
-
bold: false
|
|
771
|
-
}
|
|
772
|
-
});
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
if (start === end) {
|
|
777
|
-
setToSplit({
|
|
778
|
-
start,
|
|
779
|
-
end,
|
|
780
|
-
annotations: {
|
|
781
|
-
...toSplit.annotations,
|
|
782
|
-
bold: true
|
|
783
|
-
}
|
|
784
|
-
});
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
/**
|
|
789
|
-
* This prevents that when a portion of text is set to bold, the next text inserted after it is not bold.
|
|
790
|
-
*/
|
|
791
|
-
if (start < end) {
|
|
792
|
-
setToSplit({
|
|
793
|
-
start: end,
|
|
794
|
-
end: end,
|
|
795
|
-
annotations: {
|
|
796
|
-
...toSplit.annotations,
|
|
797
|
-
bold: true
|
|
798
|
-
}
|
|
799
|
-
})
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const { result } = splitTokens(tokens, start, end, { bold: true });
|
|
803
|
-
setTokens([...concatTokens(result)]);
|
|
804
|
-
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
848
|
+
/**
|
|
849
|
+
* Sets the TextInput's selection.
|
|
850
|
+
*/
|
|
851
|
+
setSelection(start: number, end: number) {
|
|
852
|
+
inputRef.current.setSelection(start, end);
|
|
805
853
|
},
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
start,
|
|
812
|
-
end,
|
|
813
|
-
annotations: {
|
|
814
|
-
...toSplit.annotations,
|
|
815
|
-
italic: false
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
if (start === end) {
|
|
822
|
-
setToSplit({
|
|
823
|
-
start,
|
|
824
|
-
end,
|
|
825
|
-
annotations: {
|
|
826
|
-
...toSplit.annotations,
|
|
827
|
-
italic: true
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
if (start < end) {
|
|
834
|
-
setToSplit({
|
|
835
|
-
start: end,
|
|
836
|
-
end: end,
|
|
837
|
-
annotations: {
|
|
838
|
-
...toSplit.annotations,
|
|
839
|
-
italic: true
|
|
840
|
-
}
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const { result } = splitTokens(tokens, start, end, { italic: true });
|
|
845
|
-
setTokens([...concatTokens(result)]);
|
|
846
|
-
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
854
|
+
/**
|
|
855
|
+
* Focuses the TextInput.
|
|
856
|
+
*/
|
|
857
|
+
focus() {
|
|
858
|
+
inputRef.current.focus();
|
|
847
859
|
},
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
setToSplit({
|
|
865
|
-
start,
|
|
866
|
-
end,
|
|
867
|
-
annotations: {
|
|
868
|
-
...toSplit.annotations,
|
|
869
|
-
lineThrough: true
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
if (start < end) {
|
|
876
|
-
setToSplit({
|
|
877
|
-
start: end,
|
|
878
|
-
end: end,
|
|
879
|
-
annotations: {
|
|
880
|
-
...toSplit.annotations,
|
|
881
|
-
lineThrough: true
|
|
882
|
-
}
|
|
883
|
-
})
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
const { result } = splitTokens(tokens, start, end, { lineThrough: true });
|
|
887
|
-
setTokens([...concatTokens(result)]);
|
|
888
|
-
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
860
|
+
blur() {
|
|
861
|
+
inputRef.current.blur();
|
|
862
|
+
},
|
|
863
|
+
/**
|
|
864
|
+
* Returns the TextInput's value as a rich text string matching the patterns
|
|
865
|
+
* for each style defined in the patterns prop. If a style does not define an
|
|
866
|
+
* opening and closing char, it is ignored.
|
|
867
|
+
*/
|
|
868
|
+
getRichTextString() {
|
|
869
|
+
return parseTokens(tokens, patterns);
|
|
870
|
+
},
|
|
871
|
+
/**
|
|
872
|
+
* Returns the TextInput's value as an array of tokens with annotations.
|
|
873
|
+
*/
|
|
874
|
+
getTokenizedString() : Token[] {
|
|
875
|
+
return tokens;
|
|
889
876
|
},
|
|
890
|
-
|
|
877
|
+
/**
|
|
878
|
+
* Toggles a given style. The style prop must match the name of a pattern.
|
|
879
|
+
*/
|
|
880
|
+
toggleStyle(style: keyof Token["annotations"]) {
|
|
891
881
|
const { start, end } = selectionRef.current;
|
|
892
882
|
|
|
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
|
-
});
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
883
|
if (start === end) {
|
|
906
884
|
setToSplit({
|
|
907
885
|
start,
|
|
908
886
|
end,
|
|
909
|
-
annotations: {
|
|
910
|
-
...toSplit.annotations,
|
|
911
|
-
underline: true
|
|
912
|
-
}
|
|
887
|
+
annotations: concileAnnotations(toSplit.annotations, { [style]: true })
|
|
913
888
|
});
|
|
914
889
|
return;
|
|
915
890
|
}
|
|
916
891
|
|
|
917
|
-
|
|
918
|
-
setToSplit({
|
|
919
|
-
start: end,
|
|
920
|
-
end: end,
|
|
921
|
-
annotations: {
|
|
922
|
-
...toSplit.annotations,
|
|
923
|
-
underline: true
|
|
924
|
-
}
|
|
925
|
-
})
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
const { result } = splitTokens(tokens, start, end, { underline: true });
|
|
892
|
+
const { result } = splitTokens(tokens, start, end, { [style]: true });
|
|
929
893
|
setTokens([...concatTokens(result)]);
|
|
930
|
-
requestAnimationFrame(() =>
|
|
894
|
+
requestAnimationFrame(() => {
|
|
895
|
+
inputRef.current.setSelection(start, end);
|
|
896
|
+
});
|
|
931
897
|
},
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
});
|
|
898
|
+
/**
|
|
899
|
+
* Returns the active styles for the current selection.
|
|
900
|
+
*/
|
|
901
|
+
getActiveStyles() {
|
|
902
|
+
// Check for styles of the token at the current cursor position.
|
|
903
|
+
const { result } = findTokens(tokens, selectionRef.current.start);
|
|
904
|
+
|
|
905
|
+
if (result[0].annotations) {
|
|
906
|
+
return Object.keys(result[0].annotations).filter(key => result[0].annotations[key]);
|
|
968
907
|
}
|
|
969
908
|
|
|
970
|
-
|
|
971
|
-
setTokens([...concatTokens(result)]);
|
|
972
|
-
requestAnimationFrame(() => inputRef.current.setSelection(start, end));
|
|
909
|
+
return [];
|
|
973
910
|
}
|
|
974
911
|
}));
|
|
975
912
|
|
|
976
913
|
return (
|
|
977
914
|
<View style={{ position: "relative" }}>
|
|
978
915
|
<TextInput
|
|
979
|
-
multiline={true}
|
|
980
916
|
ref={inputRef}
|
|
981
|
-
autoComplete="off"
|
|
982
917
|
style={styles.textInput}
|
|
983
|
-
placeholder="Rich text input"
|
|
984
918
|
onSelectionChange={handleSelectionChange}
|
|
985
919
|
onChangeText={handleOnChangeText}
|
|
920
|
+
{...rest}
|
|
986
921
|
>
|
|
987
922
|
<Text style={styles.text}>
|
|
988
|
-
{tokens.map((token, i) => <Token key={i} token={token} />)}
|
|
923
|
+
{tokens.map((token, i) => <Token key={i} token={token} patterns={patterns}/>)}
|
|
989
924
|
</Text>
|
|
990
925
|
</TextInput>
|
|
991
926
|
</View>
|
|
@@ -1037,5 +972,17 @@ const styles = StyleSheet.create({
|
|
|
1037
972
|
padding: 20,
|
|
1038
973
|
height: 24,
|
|
1039
974
|
backgroundColor: "blue"
|
|
975
|
+
},
|
|
976
|
+
heading: {
|
|
977
|
+
fontSize: 32,
|
|
978
|
+
fontWeight: "bold"
|
|
979
|
+
},
|
|
980
|
+
subHeading: {
|
|
981
|
+
fontSize: 28,
|
|
982
|
+
fontWeight: "bold"
|
|
983
|
+
},
|
|
984
|
+
subSubHeading: {
|
|
985
|
+
fontSize: 24,
|
|
986
|
+
fontWeight: "bold"
|
|
1040
987
|
}
|
|
1041
988
|
});
|
package/src/Toolbar.tsx
CHANGED
|
@@ -45,86 +45,132 @@ export default function Toolbar({
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
Toolbar.Bold = () => {
|
|
48
|
+
Toolbar.Bold = ({ color = "black" }) => {
|
|
49
|
+
console.log("COLOR:", color);
|
|
49
50
|
const richTextInputRef = useToolbarContext();
|
|
50
51
|
|
|
51
52
|
const handleBold = () => {
|
|
52
|
-
richTextInputRef
|
|
53
|
+
richTextInputRef?.current?.toggleStyle("bold");
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
return (
|
|
56
57
|
<TouchableOpacity style={styles.toolbarButton} onPress={handleBold}>
|
|
57
|
-
<FontAwesome6 name="bold" size={16} color=
|
|
58
|
+
<FontAwesome6 name="bold" size={16} color={color} />
|
|
58
59
|
</TouchableOpacity>
|
|
59
60
|
)
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
Toolbar.Italic = () => {
|
|
63
|
+
Toolbar.Italic = ({ color = "black" }) => {
|
|
63
64
|
const richTextInputRef = useToolbarContext();
|
|
64
65
|
|
|
65
66
|
const handleItalic = () => {
|
|
66
|
-
richTextInputRef
|
|
67
|
+
richTextInputRef?.current?.toggleStyle("italic");
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
return (
|
|
70
71
|
<TouchableOpacity style={styles.toolbarButton} onPress={handleItalic}>
|
|
71
|
-
<FontAwesome6 name="italic" size={16} color=
|
|
72
|
+
<FontAwesome6 name="italic" size={16} color={color} />
|
|
72
73
|
</TouchableOpacity>
|
|
73
74
|
)
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
Toolbar.Strikethrough = () => {
|
|
77
|
+
Toolbar.Strikethrough = ({ color = "black" }) => {
|
|
77
78
|
const richTextInputRef = useToolbarContext();
|
|
78
79
|
|
|
79
80
|
const handleLineThrough = () => {
|
|
80
|
-
richTextInputRef
|
|
81
|
+
richTextInputRef?.current?.toggleStyle("lineThrough");
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return (
|
|
84
85
|
<TouchableOpacity style={styles.toolbarButton} onPress={handleLineThrough}>
|
|
85
|
-
<FontAwesome6 name="strikethrough" size={16} color=
|
|
86
|
+
<FontAwesome6 name="strikethrough" size={16} color={color} />
|
|
86
87
|
</TouchableOpacity>
|
|
87
88
|
)
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
Toolbar.Underline = () => {
|
|
91
|
+
Toolbar.Underline = ({ color = "black" }) => {
|
|
91
92
|
const richTextInputRef = useToolbarContext();
|
|
92
93
|
|
|
93
94
|
const handleUnderline = () => {
|
|
94
|
-
richTextInputRef.current.
|
|
95
|
+
richTextInputRef.current.toggleStyle("underline");
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
return (
|
|
98
99
|
<TouchableOpacity style={styles.toolbarButton} onPress={handleUnderline}>
|
|
99
|
-
<FontAwesome6 name="underline" size={16} color=
|
|
100
|
+
<FontAwesome6 name="underline" size={16} color={color} />
|
|
100
101
|
</TouchableOpacity>
|
|
101
102
|
)
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
Toolbar.
|
|
105
|
+
Toolbar.Heading = () => {
|
|
106
|
+
const richTextInputRef = useToolbarContext();
|
|
107
|
+
|
|
108
|
+
const handleHeading = () => {
|
|
109
|
+
richTextInputRef.current.toggleStyle("heading");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleHeading}>
|
|
114
|
+
<FontAwesome6 name="heading" size={16} color={color} />
|
|
115
|
+
<FontAwesome6 name="1" size={16} color={color} />
|
|
116
|
+
</TouchableOpacity>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
Toolbar.SubHeading = () => {
|
|
121
|
+
const richTextInputRef = useToolbarContext();
|
|
122
|
+
|
|
123
|
+
const handleSubHeading = () => {
|
|
124
|
+
richTextInputRef.current.toggleStyle("subHeading");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleSubHeading}>
|
|
129
|
+
<FontAwesome6 name="heading" size={16} color={color} />
|
|
130
|
+
<FontAwesome6 name="2" size={16} color={color} />
|
|
131
|
+
</TouchableOpacity>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Toolbar.SubSubHeading = () => {
|
|
136
|
+
const richTextInputRef = useToolbarContext();
|
|
137
|
+
|
|
138
|
+
const handleSubSubHeading = () => {
|
|
139
|
+
richTextInputRef.current.toggleStyle("subSubHeading");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<TouchableOpacity style={[styles.toolbarButton, styles.heading]} onPress={handleSubSubHeading}>
|
|
144
|
+
<FontAwesome6 name="heading" size={16} color={color} />
|
|
145
|
+
<FontAwesome6 name="3" size={16} color={color} />
|
|
146
|
+
</TouchableOpacity>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Toolbar.Code = ({ color = "black" }) => {
|
|
105
151
|
const richTextInputRef = useToolbarContext();
|
|
106
152
|
|
|
107
153
|
const handleCode = () => {
|
|
108
|
-
richTextInputRef
|
|
154
|
+
richTextInputRef?.current?.toggleStyle("code");
|
|
109
155
|
}
|
|
110
156
|
|
|
111
157
|
return (
|
|
112
158
|
<TouchableOpacity style={styles.toolbarButton} onPress={handleCode}>
|
|
113
|
-
<FontAwesome6 name="code" size={16} color=
|
|
159
|
+
<FontAwesome6 name="code" size={16} color={color} />
|
|
114
160
|
</TouchableOpacity>
|
|
115
161
|
)
|
|
116
162
|
}
|
|
117
163
|
|
|
118
|
-
Toolbar.Keyboard = () => {
|
|
164
|
+
Toolbar.Keyboard = ({ color = "black" }) => {
|
|
119
165
|
const handleKeyboardDismiss = () => {
|
|
120
166
|
Keyboard.dismiss();
|
|
121
167
|
}
|
|
122
168
|
|
|
123
169
|
return (
|
|
124
170
|
<TouchableOpacity style={[styles.toolbarButton, styles.keyboardDown]} onPress={handleKeyboardDismiss}>
|
|
125
|
-
<FontAwesome6 name="keyboard" size={16} color=
|
|
171
|
+
<FontAwesome6 name="keyboard" size={16} color={color} />
|
|
126
172
|
<View style={styles.keyboardArrowContainer}>
|
|
127
|
-
<FontAwesome6 name="chevron-down" size={8} color=
|
|
173
|
+
<FontAwesome6 name="chevron-down" size={8} color={color}/>
|
|
128
174
|
</View>
|
|
129
175
|
</TouchableOpacity>
|
|
130
176
|
)
|
|
@@ -156,5 +202,9 @@ const styles = StyleSheet.create({
|
|
|
156
202
|
keyboardArrowContainer: {
|
|
157
203
|
position: "absolute",
|
|
158
204
|
bottom: 13
|
|
205
|
+
},
|
|
206
|
+
heading: {
|
|
207
|
+
flexDirection: "row",
|
|
208
|
+
gap: 4
|
|
159
209
|
}
|
|
160
210
|
});
|