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 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 elements like links, mentions, bold, italic, unerline text and more.
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 (*bold*, _italic_, __underline__, ~~strikethrough~~).
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, useState } from 'react';
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, useState } from 'react';
2
- import { StatusBar } from 'expo-status-bar';
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
- <StatusBar style="auto" />
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
@@ -1,4 +1,5 @@
1
1
  import RichTextInput from "./src/RichTextInput";
2
+ import { Code } from "./src/RichTextInput";
2
3
  import Toolbar from "./src/Toolbar";
3
4
 
4
- export { RichTextInput, Toolbar };
5
+ export { RichTextInput, Toolbar, Code };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enriched-text-input",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "JavaScript only rich text input component for React Native. Compatible with Expo Go.",
5
5
  "keywords": [
6
6
  "rich-text",
@@ -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
- color: string;
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 || nextAnnotations.bold,
115
- italic: prevAnnotations.italic || nextAnnotations.italic,
116
- lineThrough: prevAnnotations.lineThrough || nextAnnotations.lineThrough,
117
- underline: prevAnnotations.underline || nextAnnotations.underline,
118
- color: prevAnnotations.color || nextAnnotations.color,
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
- color: "black"
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, type: string, text = "" ) {
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
- type: string,
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.color === token.annotations.color) {
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
- export default function RichTextInput({ ref }) {
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
- color: "black"
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
- color: "black"
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
- type: null
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 && diff.start === toSplit.end && diff.added.length > 0 && toSplit.type) {
717
- const { result } = insertToken(tokens, diff.start, toSplit.type, diff.added);
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
- setToSplit({ start: 0, end: 0, type: null });
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.type === "bold") {
736
- setToSplit({ start: 0, end: 0, type: null });
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({ start, end, type: "bold" });
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
- type: "bold"
795
+ annotations: {
796
+ ...toSplit.annotations,
797
+ bold: true
798
+ }
753
799
  })
754
800
  }
755
801
 
756
- const { result } = splitTokens(tokens, start, end, "bold");
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.type === "italic") {
764
- setToSplit({ start: 0, end: 0, type: null });
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({ start, end, type: "italic" });
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
- type: "italic"
778
- })
837
+ annotations: {
838
+ ...toSplit.annotations,
839
+ italic: true
840
+ }
841
+ });
779
842
  }
780
843
 
781
- const { result } = splitTokens(tokens, start, end, "italic");
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.type === "lineThrough") {
789
- setToSplit({ start: 0, end: 0, type: null });
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({ start, end, type: "lineThrough" });
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
- type: "lineThrough"
879
+ annotations: {
880
+ ...toSplit.annotations,
881
+ lineThrough: true
882
+ }
803
883
  })
804
884
  }
805
885
 
806
- const { result } = splitTokens(tokens, start, end, "lineThrough");
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.type === "underline") {
814
- setToSplit({ start: 0, end: 0, type: null });
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({ start, end, type: "underline" });
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
- type: "underline"
921
+ annotations: {
922
+ ...toSplit.annotations,
923
+ underline: true
924
+ }
828
925
  })
829
926
  }
830
927
 
831
- const { result } = splitTokens(tokens, start, end, "underline");
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
- setValue(value: string) {
836
- // To keep styles, parsing should be done before setting value
837
- const { tokens, plain_text } = parseRichTextString(value, PATTERNS);
838
- setTokens([...concatTokens(tokens)]);
839
- prevTextRef.current = plain_text;
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
- {tokens.map((token, i) => {
856
- return (
857
- <Text key={i} style={[
858
- styles.text,
859
- ...Object.entries(token.annotations).map(([key, value]) => value ? styles[key] : null).filter(Boolean),
860
- token.annotations.underline && token.annotations.lineThrough && styles.underlineLineThrough
861
- ]}>
862
- {token.text}
863
- </Text>
864
- )
865
- })}
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, Button, StyleSheet, TouchableOpacity, Keyboard } from "react-native";
2
- import { RefObject, Ref } from "react";
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
- <View style={styles.toolbar}>
41
- <TouchableOpacity style={styles.toolbarButton} onPress={handleBold}>
42
- <FontAwesome6 name="bold" size={16} color="black" />
43
- </TouchableOpacity>
44
-
45
- <TouchableOpacity style={styles.toolbarButton} onPress={handleItalic}>
46
- <FontAwesome6 name="italic" size={16} color="black" />
47
- </TouchableOpacity>
48
-
49
- <TouchableOpacity style={styles.toolbarButton} onPress={handleLineThrough}>
50
- <FontAwesome6 name="strikethrough" size={16} color="black" />
51
- </TouchableOpacity>
52
-
53
- <TouchableOpacity style={styles.toolbarButton} onPress={handleUnderline}>
54
- <FontAwesome6 name="underline" size={16} color="black" />
55
- </TouchableOpacity>
56
-
57
- <TouchableOpacity style={[styles.toolbarButton, styles.keyboardDown]} onPress={handleKeyboardDismiss}>
58
- <FontAwesome6 name="keyboard" size={16} color="black" />
59
- <View style={styles.keyboardArrowContainer}>
60
- <FontAwesome6 name="chevron-down" size={8} color="black"/>
61
- </View>
62
- </TouchableOpacity>
63
- </View>
64
- );
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({