@bigbinary/neetoui-rn 3.3.4 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/typescript/src/components/ChatInput/AttachmentsView.d.ts +2 -1
- package/lib/typescript/src/components/ChatInput/AttachmentsView.d.ts.map +1 -1
- package/lib/typescript/src/components/ChatInput/ChatInput.d.ts +85 -0
- package/lib/typescript/src/components/ChatInput/ChatInput.d.ts.map +1 -1
- package/lib/typescript/src/components/ChatInput/MentionSuggestionList.d.ts +9 -0
- package/lib/typescript/src/components/ChatInput/MentionSuggestionList.d.ts.map +1 -0
- package/lib/typescript/src/components/ChatInput/MentionsInput.d.ts +3 -1
- package/lib/typescript/src/components/ChatInput/MentionsInput.d.ts.map +1 -1
- package/lib/typescript/src/components/ChatInput/SegmentedOptions.d.ts +10 -0
- package/lib/typescript/src/components/ChatInput/SegmentedOptions.d.ts.map +1 -0
- package/lib/typescript/src/components/ChatInput/constants.d.ts +3 -0
- package/lib/typescript/src/components/ChatInput/constants.d.ts.map +1 -1
- package/lib/typescript/src/components/ChatInput/index.d.ts +1 -1
- package/lib/typescript/src/components/ChatInput/index.d.ts.map +1 -1
- package/lib/typescript/src/components/ChatInput/mentions.d.ts +2 -0
- package/lib/typescript/src/components/ChatInput/mentions.d.ts.map +1 -1
- package/lib/typescript/src/components/RichTextEditor/LazyRichTextEditor.d.ts +3 -0
- package/lib/typescript/src/components/RichTextEditor/LazyRichTextEditor.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/MentionSuggestionBar.d.ts +8 -0
- package/lib/typescript/src/components/RichTextEditor/MentionSuggestionBar.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/RichTextEditor.d.ts +45 -0
- package/lib/typescript/src/components/RichTextEditor/RichTextEditor.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/RichTextToolbar.d.ts +11 -0
- package/lib/typescript/src/components/RichTextEditor/RichTextToolbar.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/CodeBlockBridge.d.ts +13 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/CodeBlockBridge.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/ComposerBridge.d.ts +27 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/ComposerBridge.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/FigureBridge.d.ts +16 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/FigureBridge.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/MentionBridge.d.ts +35 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/MentionBridge.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/VariableBridge.d.ts +3 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/VariableBridge.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/index.d.ts +8 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/index.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Figure.d.ts +3 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Figure.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Mention.d.ts +3 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Mention.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Variable.d.ts +3 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Variable.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/types.d.ts +8 -0
- package/lib/typescript/src/components/RichTextEditor/bridges/types.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/editorCss.d.ts +21 -0
- package/lib/typescript/src/components/RichTextEditor/editorCss.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/icons/index.d.ts +9 -0
- package/lib/typescript/src/components/RichTextEditor/icons/index.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/index.d.ts +4 -0
- package/lib/typescript/src/components/RichTextEditor/index.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/injectableText.d.ts +2 -0
- package/lib/typescript/src/components/RichTextEditor/injectableText.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/types.d.ts +6 -0
- package/lib/typescript/src/components/RichTextEditor/types.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextEditor/web/build/editorHtml.d.ts +2 -0
- package/lib/typescript/src/components/RichTextEditor/web/build/editorHtml.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +30 -7
- package/src/components/ChatInput/AttachmentsView.tsx +6 -0
- package/src/components/ChatInput/ChatInput.tsx +569 -91
- package/src/components/ChatInput/MentionSuggestionList.tsx +71 -0
- package/src/components/ChatInput/MentionsInput.tsx +34 -46
- package/src/components/ChatInput/SegmentedOptions.tsx +84 -0
- package/src/components/ChatInput/constants.ts +10 -0
- package/src/components/ChatInput/index.ts +3 -0
- package/src/components/ChatInput/mentions.ts +22 -0
- package/src/components/RichTextEditor/LazyRichTextEditor.tsx +19 -0
- package/src/components/RichTextEditor/MentionSuggestionBar.tsx +79 -0
- package/src/components/RichTextEditor/RichTextEditor.tsx +227 -0
- package/src/components/RichTextEditor/RichTextToolbar.tsx +476 -0
- package/src/components/RichTextEditor/bridges/CodeBlockBridge.ts +29 -0
- package/src/components/RichTextEditor/bridges/ComposerBridge.ts +107 -0
- package/src/components/RichTextEditor/bridges/FigureBridge.ts +38 -0
- package/src/components/RichTextEditor/bridges/MentionBridge.ts +79 -0
- package/src/components/RichTextEditor/bridges/VariableBridge.ts +7 -0
- package/src/components/RichTextEditor/bridges/index.ts +66 -0
- package/src/components/RichTextEditor/bridges/nodes/Figure.ts +127 -0
- package/src/components/RichTextEditor/bridges/nodes/Mention.ts +48 -0
- package/src/components/RichTextEditor/bridges/nodes/Variable.ts +68 -0
- package/src/components/RichTextEditor/bridges/types.ts +26 -0
- package/src/components/RichTextEditor/editorCss.ts +69 -0
- package/src/components/RichTextEditor/icons/index.tsx +41 -0
- package/src/components/RichTextEditor/index.ts +3 -0
- package/src/components/RichTextEditor/injectableText.ts +11 -0
- package/src/components/RichTextEditor/types.ts +1 -0
- package/src/components/RichTextEditor/web/build/editorHtml.ts +3 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ScrollView, View } from "react-native";
|
|
2
|
+
|
|
3
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
4
|
+
|
|
5
|
+
import { scaleFor } from "../../utils/scale";
|
|
6
|
+
import { Avatar } from "../Avatar";
|
|
7
|
+
import { Touchable } from "../Touchable";
|
|
8
|
+
import { Typography } from "../Typography";
|
|
9
|
+
import type { MentionSuggestion } from "./mentions";
|
|
10
|
+
|
|
11
|
+
const MAX_VISIBLE_SUGGESTIONS = 4;
|
|
12
|
+
|
|
13
|
+
export const filterMentionSuggestions = (
|
|
14
|
+
suggestions: MentionSuggestion[],
|
|
15
|
+
keyword: string,
|
|
16
|
+
limit = MAX_VISIBLE_SUGGESTIONS
|
|
17
|
+
) =>
|
|
18
|
+
suggestions
|
|
19
|
+
.filter(suggestion =>
|
|
20
|
+
suggestion.name.toLocaleLowerCase().includes(keyword.toLocaleLowerCase())
|
|
21
|
+
)
|
|
22
|
+
.slice(0, limit);
|
|
23
|
+
|
|
24
|
+
type MentionSuggestionListProps = {
|
|
25
|
+
suggestions: MentionSuggestion[];
|
|
26
|
+
onSelect: (suggestion: MentionSuggestion) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const MentionSuggestionList = ({
|
|
30
|
+
suggestions,
|
|
31
|
+
onSelect,
|
|
32
|
+
}: MentionSuggestionListProps) => (
|
|
33
|
+
<View style={styles.card} testID="mention-suggestions">
|
|
34
|
+
<ScrollView keyboardShouldPersistTaps="always">
|
|
35
|
+
{suggestions.map(suggestion => (
|
|
36
|
+
<Touchable
|
|
37
|
+
key={suggestion.id}
|
|
38
|
+
style={styles.row}
|
|
39
|
+
onPress={() => onSelect(suggestion)}
|
|
40
|
+
>
|
|
41
|
+
<Avatar
|
|
42
|
+
imageUrl={suggestion.imageUrl}
|
|
43
|
+
name={suggestion.name}
|
|
44
|
+
variant="small"
|
|
45
|
+
/>
|
|
46
|
+
<Typography size="xs">{suggestion.name}</Typography>
|
|
47
|
+
</Touchable>
|
|
48
|
+
))}
|
|
49
|
+
</ScrollView>
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const styles = StyleSheet.create((theme, rt) => {
|
|
54
|
+
const moderateScale = scaleFor(rt.screen);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
card: {
|
|
58
|
+
backgroundColor: theme.colors.background.secondary,
|
|
59
|
+
borderRadius: moderateScale(10),
|
|
60
|
+
maxHeight: moderateScale(200),
|
|
61
|
+
...theme.elevation.medium,
|
|
62
|
+
},
|
|
63
|
+
row: {
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
flexDirection: "row",
|
|
66
|
+
gap: moderateScale(8),
|
|
67
|
+
height: moderateScale(50),
|
|
68
|
+
paddingHorizontal: moderateScale(10),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { useMemo, type Ref } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
TextInput,
|
|
4
|
+
View,
|
|
5
|
+
useWindowDimensions,
|
|
6
|
+
type TextInputProps,
|
|
7
|
+
} from "react-native";
|
|
3
8
|
import { useMentions } from "react-native-controlled-mentions";
|
|
4
9
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
5
10
|
|
|
6
11
|
import { scaleFor } from "../../utils/scale";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
12
|
+
import { LINE_HEIGHT_RATIO } from "../Typography";
|
|
13
|
+
import {
|
|
14
|
+
MentionSuggestionList,
|
|
15
|
+
filterMentionSuggestions,
|
|
16
|
+
} from "./MentionSuggestionList";
|
|
10
17
|
import { buildMentionTriggerConfig, type MentionSuggestion } from "./mentions";
|
|
11
18
|
|
|
12
|
-
const MAX_VISIBLE_SUGGESTIONS = 4;
|
|
13
|
-
|
|
14
19
|
type MentionsInputProps = Omit<
|
|
15
20
|
TextInputProps,
|
|
16
21
|
"value" | "onChange" | "onChangeText" | "onSelectionChange"
|
|
@@ -20,6 +25,8 @@ type MentionsInputProps = Omit<
|
|
|
20
25
|
suggestions?: MentionSuggestion[];
|
|
21
26
|
shouldShowSuggestions?: boolean;
|
|
22
27
|
inputRef?: Ref<TextInput>;
|
|
28
|
+
/** Minimum visible lines; replaces the default minHeight when set. */
|
|
29
|
+
minLines?: number;
|
|
23
30
|
};
|
|
24
31
|
|
|
25
32
|
export const MentionsInput = ({
|
|
@@ -28,10 +35,16 @@ export const MentionsInput = ({
|
|
|
28
35
|
suggestions = [],
|
|
29
36
|
shouldShowSuggestions = true,
|
|
30
37
|
inputRef,
|
|
38
|
+
minLines,
|
|
31
39
|
style,
|
|
32
40
|
...rest
|
|
33
41
|
}: MentionsInputProps) => {
|
|
34
42
|
const { theme } = useUnistyles();
|
|
43
|
+
const { fontScale } = useWindowDimensions();
|
|
44
|
+
|
|
45
|
+
// An explicit lineHeight keeps minHeight an exact multiple of a line.
|
|
46
|
+
// RN scales lineHeight by the OS font scale but not minHeight.
|
|
47
|
+
const lineHeight = Math.floor(theme.fontSizes.m * LINE_HEIGHT_RATIO);
|
|
35
48
|
|
|
36
49
|
const triggersConfig = useMemo(
|
|
37
50
|
() =>
|
|
@@ -52,42 +65,28 @@ export const MentionsInput = ({
|
|
|
52
65
|
|
|
53
66
|
const filteredSuggestions =
|
|
54
67
|
shouldShowSuggestions && keyword !== undefined
|
|
55
|
-
? suggestions
|
|
56
|
-
.filter(suggestion =>
|
|
57
|
-
suggestion.name
|
|
58
|
-
.toLocaleLowerCase()
|
|
59
|
-
.includes(keyword.toLocaleLowerCase())
|
|
60
|
-
)
|
|
61
|
-
.slice(0, MAX_VISIBLE_SUGGESTIONS)
|
|
68
|
+
? filterMentionSuggestions(suggestions, keyword)
|
|
62
69
|
: [];
|
|
63
70
|
|
|
64
71
|
return (
|
|
65
72
|
<View style={styles.container}>
|
|
66
73
|
{filteredSuggestions.length > 0 && (
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
key={suggestion.id}
|
|
72
|
-
style={styles.suggestionRow}
|
|
73
|
-
onPress={() => onSelect(suggestion)}
|
|
74
|
-
>
|
|
75
|
-
<Avatar
|
|
76
|
-
imageUrl={suggestion.imageUrl}
|
|
77
|
-
name={suggestion.name}
|
|
78
|
-
variant="small"
|
|
79
|
-
/>
|
|
80
|
-
<Typography size="xs">{suggestion.name}</Typography>
|
|
81
|
-
</Touchable>
|
|
82
|
-
))}
|
|
83
|
-
</ScrollView>
|
|
84
|
-
</View>
|
|
74
|
+
<MentionSuggestionList
|
|
75
|
+
suggestions={filteredSuggestions}
|
|
76
|
+
onSelect={onSelect}
|
|
77
|
+
/>
|
|
85
78
|
)}
|
|
86
79
|
<TextInput
|
|
87
80
|
multiline
|
|
88
81
|
placeholderTextColor={theme.colors.text.muted}
|
|
89
82
|
ref={inputRef}
|
|
90
|
-
style={[
|
|
83
|
+
style={[
|
|
84
|
+
styles.input,
|
|
85
|
+
minLines
|
|
86
|
+
? { lineHeight, minHeight: lineHeight * minLines * fontScale }
|
|
87
|
+
: styles.inputDefaultHeight,
|
|
88
|
+
style,
|
|
89
|
+
]}
|
|
91
90
|
{...rest}
|
|
92
91
|
// Last on purpose: useMentions owns value, onChangeText and
|
|
93
92
|
// onSelectionChange, and losing any of them to a caller prop kills
|
|
@@ -106,19 +105,6 @@ const styles = StyleSheet.create((theme, rt) => {
|
|
|
106
105
|
flex: 1,
|
|
107
106
|
marginVertical: moderateScale(10),
|
|
108
107
|
},
|
|
109
|
-
suggestionsCard: {
|
|
110
|
-
backgroundColor: theme.colors.background.secondary,
|
|
111
|
-
borderRadius: moderateScale(10),
|
|
112
|
-
maxHeight: moderateScale(200),
|
|
113
|
-
...theme.elevation.medium,
|
|
114
|
-
},
|
|
115
|
-
suggestionRow: {
|
|
116
|
-
alignItems: "center",
|
|
117
|
-
flexDirection: "row",
|
|
118
|
-
gap: moderateScale(8),
|
|
119
|
-
height: moderateScale(50),
|
|
120
|
-
paddingHorizontal: moderateScale(10),
|
|
121
|
-
},
|
|
122
108
|
input: {
|
|
123
109
|
color: theme.colors.text.primary,
|
|
124
110
|
fontFamily: theme.fonts.regular,
|
|
@@ -131,8 +117,10 @@ const styles = StyleSheet.create((theme, rt) => {
|
|
|
131
117
|
// Sizing from ascent + descent on both sides removes it; iOS ignores
|
|
132
118
|
// the flag.
|
|
133
119
|
includeFontPadding: false,
|
|
134
|
-
minHeight: moderateScale(50),
|
|
135
120
|
padding: 0,
|
|
136
121
|
},
|
|
122
|
+
inputDefaultHeight: {
|
|
123
|
+
minHeight: moderateScale(50),
|
|
124
|
+
},
|
|
137
125
|
};
|
|
138
126
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
3
|
+
|
|
4
|
+
import { scaleFor } from "../../utils/scale";
|
|
5
|
+
import { Touchable } from "../Touchable";
|
|
6
|
+
import { Typography } from "../Typography";
|
|
7
|
+
import { type ChatInputOption, OPTION_LABELS } from "./constants";
|
|
8
|
+
|
|
9
|
+
export type SegmentedOptionsProps = {
|
|
10
|
+
options: ChatInputOption[];
|
|
11
|
+
selected: ChatInputOption;
|
|
12
|
+
onSelect: (option: ChatInputOption) => void;
|
|
13
|
+
/** Tighter segments for a phone-width footer. */
|
|
14
|
+
isCompact?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SegmentedOptions = ({
|
|
18
|
+
options,
|
|
19
|
+
selected,
|
|
20
|
+
onSelect,
|
|
21
|
+
isCompact = false,
|
|
22
|
+
}: SegmentedOptionsProps) => (
|
|
23
|
+
<View accessibilityRole="tablist" style={styles.group}>
|
|
24
|
+
{options.map(option => {
|
|
25
|
+
const isSelected = option === selected;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Touchable
|
|
29
|
+
accessibilityLabel={OPTION_LABELS[option]}
|
|
30
|
+
accessibilityRole="tab"
|
|
31
|
+
accessibilityState={{ selected: isSelected }}
|
|
32
|
+
key={option}
|
|
33
|
+
style={[
|
|
34
|
+
styles.segment,
|
|
35
|
+
isCompact && styles.compactSegment,
|
|
36
|
+
isSelected && styles.segmentSelected,
|
|
37
|
+
]}
|
|
38
|
+
onPress={() => onSelect(option)}
|
|
39
|
+
>
|
|
40
|
+
<Typography
|
|
41
|
+
size="s"
|
|
42
|
+
style={isSelected ? styles.labelSelected : styles.label}
|
|
43
|
+
weight={isSelected ? "semibold" : "regular"}
|
|
44
|
+
>
|
|
45
|
+
{OPTION_LABELS[option]}
|
|
46
|
+
</Typography>
|
|
47
|
+
</Touchable>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const styles = StyleSheet.create((theme, rt) => {
|
|
54
|
+
const moderateScale = scaleFor(rt.screen);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
group: {
|
|
58
|
+
backgroundColor: theme.colors.background.secondary,
|
|
59
|
+
borderRadius: moderateScale(8),
|
|
60
|
+
flexDirection: "row",
|
|
61
|
+
gap: moderateScale(2),
|
|
62
|
+
padding: moderateScale(2),
|
|
63
|
+
},
|
|
64
|
+
segment: {
|
|
65
|
+
borderRadius: moderateScale(6),
|
|
66
|
+
justifyContent: "center",
|
|
67
|
+
minHeight: moderateScale(32),
|
|
68
|
+
paddingHorizontal: moderateScale(14),
|
|
69
|
+
},
|
|
70
|
+
compactSegment: {
|
|
71
|
+
paddingHorizontal: moderateScale(10),
|
|
72
|
+
},
|
|
73
|
+
segmentSelected: {
|
|
74
|
+
backgroundColor: theme.colors.background.primary,
|
|
75
|
+
...theme.elevation.low,
|
|
76
|
+
},
|
|
77
|
+
label: {
|
|
78
|
+
color: theme.colors.text.muted,
|
|
79
|
+
},
|
|
80
|
+
labelSelected: {
|
|
81
|
+
color: theme.colors.text.primary,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
});
|
|
@@ -16,3 +16,13 @@ export const ACTION_LABELS: Partial<Record<ChatInputOption, string>> = {
|
|
|
16
16
|
NOTE: "Add note",
|
|
17
17
|
FORWARD: "Forward",
|
|
18
18
|
};
|
|
19
|
+
|
|
20
|
+
export const REPLY_ACTION_LABEL = "Send reply";
|
|
21
|
+
|
|
22
|
+
export const OPTION_LABELS: Record<ChatInputOption, string> = {
|
|
23
|
+
REPLY: "Reply",
|
|
24
|
+
NOTE: "Note",
|
|
25
|
+
FORWARD: "Forward",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const NOTE_HINT = "Internal note, not sent to the customer";
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export {
|
|
2
2
|
ChatInput,
|
|
3
|
+
type ChatInputFocusTarget,
|
|
4
|
+
type ChatInputLayout,
|
|
3
5
|
type ChatInputPayload,
|
|
4
6
|
type ChatInputProps,
|
|
5
7
|
type ChatInputRef,
|
|
8
|
+
type ChatInputResizable,
|
|
6
9
|
} from "./ChatInput";
|
|
7
10
|
export { OPTION_TYPES, type ChatInputOption } from "./constants";
|
|
8
11
|
export {
|
|
@@ -89,3 +89,25 @@ export const convertMentionsToHTMLAndPlainText = ({
|
|
|
89
89
|
|
|
90
90
|
return { html, plainText };
|
|
91
91
|
};
|
|
92
|
+
|
|
93
|
+
const HTML_ENTITIES: Record<string, string> = {
|
|
94
|
+
amp: "&",
|
|
95
|
+
lt: "<",
|
|
96
|
+
gt: ">",
|
|
97
|
+
quot: '"',
|
|
98
|
+
"#39": "'",
|
|
99
|
+
nbsp: " ",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Block ends become line breaks, so a canned reply keeps its paragraphs. */
|
|
103
|
+
export const htmlToPlainText = (html: string) =>
|
|
104
|
+
html
|
|
105
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
106
|
+
.replace(/<\/(p|div|li|h[1-6]|blockquote|pre)>/gi, "\n")
|
|
107
|
+
.replace(/<[^>]*>/g, "")
|
|
108
|
+
.replace(
|
|
109
|
+
/&(amp|lt|gt|quot|#39|nbsp);/g,
|
|
110
|
+
(_, entity: string) => HTML_ENTITIES[entity] ?? ""
|
|
111
|
+
)
|
|
112
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
113
|
+
.replace(/\n+$/, "");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
|
|
3
|
+
import type * as Editor from "./RichTextEditor";
|
|
4
|
+
import type { RichTextEditorProps, RichTextEditorRef } from "./RichTextEditor";
|
|
5
|
+
|
|
6
|
+
// tentap's native entry reads `document` when evaluated. Metro's inline
|
|
7
|
+
// requires defer that on device, but an app's jest run imports the package
|
|
8
|
+
// root eagerly, so the editor module loads only once one renders.
|
|
9
|
+
export const RichTextEditor = forwardRef<
|
|
10
|
+
RichTextEditorRef,
|
|
11
|
+
RichTextEditorProps
|
|
12
|
+
>((props, ref) => {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
14
|
+
const { RichTextEditor: Impl } = require("./RichTextEditor") as typeof Editor;
|
|
15
|
+
|
|
16
|
+
return <Impl ref={ref} {...props} />;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
RichTextEditor.displayName = "LazyRichTextEditor";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ScrollView } from "react-native";
|
|
2
|
+
|
|
3
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
4
|
+
|
|
5
|
+
import { scaleFor } from "../../utils/scale";
|
|
6
|
+
import { Avatar } from "../Avatar";
|
|
7
|
+
import type { MentionSuggestion } from "../ChatInput/mentions";
|
|
8
|
+
import { Touchable } from "../Touchable";
|
|
9
|
+
import { Typography } from "../Typography";
|
|
10
|
+
|
|
11
|
+
type MentionSuggestionBarProps = {
|
|
12
|
+
suggestions: MentionSuggestion[];
|
|
13
|
+
onSelect: (suggestion: MentionSuggestion) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const MentionSuggestionBar = ({
|
|
17
|
+
suggestions,
|
|
18
|
+
onSelect,
|
|
19
|
+
}: MentionSuggestionBarProps) => (
|
|
20
|
+
<ScrollView
|
|
21
|
+
horizontal
|
|
22
|
+
contentContainerStyle={styles.chips}
|
|
23
|
+
keyboardShouldPersistTaps="always"
|
|
24
|
+
showsHorizontalScrollIndicator={false}
|
|
25
|
+
style={styles.bar}
|
|
26
|
+
testID="mention-suggestions"
|
|
27
|
+
>
|
|
28
|
+
{suggestions.map(suggestion => (
|
|
29
|
+
<Touchable
|
|
30
|
+
accessibilityLabel={`Mention ${suggestion.name}`}
|
|
31
|
+
accessibilityRole="button"
|
|
32
|
+
key={suggestion.id}
|
|
33
|
+
style={styles.chip}
|
|
34
|
+
onPress={() => onSelect(suggestion)}
|
|
35
|
+
>
|
|
36
|
+
<Avatar
|
|
37
|
+
imageUrl={suggestion.imageUrl}
|
|
38
|
+
name={suggestion.name}
|
|
39
|
+
variant="extra-small"
|
|
40
|
+
/>
|
|
41
|
+
<Typography numberOfLines={1} size="xs" style={styles.name}>
|
|
42
|
+
{suggestion.name}
|
|
43
|
+
</Typography>
|
|
44
|
+
</Touchable>
|
|
45
|
+
))}
|
|
46
|
+
</ScrollView>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const styles = StyleSheet.create((theme, rt) => {
|
|
50
|
+
const moderateScale = scaleFor(rt.screen);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
bar: {
|
|
54
|
+
borderBottomColor: theme.colors.border.primary,
|
|
55
|
+
borderBottomWidth: 1,
|
|
56
|
+
borderTopColor: theme.colors.border.primary,
|
|
57
|
+
borderTopWidth: 1,
|
|
58
|
+
flexGrow: 0,
|
|
59
|
+
},
|
|
60
|
+
chips: {
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
gap: moderateScale(8),
|
|
63
|
+
minHeight: 44,
|
|
64
|
+
paddingHorizontal: moderateScale(8),
|
|
65
|
+
},
|
|
66
|
+
chip: {
|
|
67
|
+
alignItems: "center",
|
|
68
|
+
backgroundColor: theme.colors.background.secondary,
|
|
69
|
+
borderRadius: moderateScale(16),
|
|
70
|
+
flexDirection: "row",
|
|
71
|
+
gap: moderateScale(6),
|
|
72
|
+
paddingHorizontal: moderateScale(8),
|
|
73
|
+
paddingVertical: moderateScale(4),
|
|
74
|
+
},
|
|
75
|
+
name: {
|
|
76
|
+
maxWidth: moderateScale(180),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Platform, View, type StyleProp, type ViewStyle } from "react-native";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CoreBridge,
|
|
12
|
+
PlaceholderBridge,
|
|
13
|
+
RichText,
|
|
14
|
+
useBridgeState,
|
|
15
|
+
useEditorBridge,
|
|
16
|
+
} from "@10play/tentap-editor";
|
|
17
|
+
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
18
|
+
|
|
19
|
+
import { filterMentionSuggestions } from "../ChatInput/MentionSuggestionList";
|
|
20
|
+
import type { MentionSuggestion } from "../ChatInput/mentions";
|
|
21
|
+
import { editorBridges } from "./bridges";
|
|
22
|
+
import type { NeetoBridgeState, NeetoEditorBridge } from "./bridges/types";
|
|
23
|
+
import { editorCss } from "./editorCss";
|
|
24
|
+
import { injectableText } from "./injectableText";
|
|
25
|
+
import { MentionSuggestionBar } from "./MentionSuggestionBar";
|
|
26
|
+
import { RichTextToolbar } from "./RichTextToolbar";
|
|
27
|
+
import type { RichTextImage } from "./types";
|
|
28
|
+
import { editorHtml } from "./web/build/editorHtml";
|
|
29
|
+
|
|
30
|
+
export interface RichTextEditorRef {
|
|
31
|
+
getHTML: () => Promise<string>;
|
|
32
|
+
getText: () => Promise<string>;
|
|
33
|
+
setHTML: (html: string) => void;
|
|
34
|
+
insertHTML: (html: string) => void;
|
|
35
|
+
focus: (where?: "start" | "end" | "emptyParagraph") => void;
|
|
36
|
+
blur: () => void;
|
|
37
|
+
insertImage: (image: RichTextImage) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RichTextEditorProps {
|
|
41
|
+
initialHTML?: string;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
editable?: boolean;
|
|
44
|
+
autoFocus?: boolean;
|
|
45
|
+
/** Fires on every content change; read HTML lazily through the ref. */
|
|
46
|
+
onChange?: () => void;
|
|
47
|
+
onFocus?: () => void;
|
|
48
|
+
onBlur?: () => void;
|
|
49
|
+
/** Default "fixed". */
|
|
50
|
+
toolbar?: "fixed" | "none";
|
|
51
|
+
/** Resolves to the uploaded image, or null when cancelled. Absent → no image button. */
|
|
52
|
+
onPickImage?: () => Promise<RichTextImage | null>;
|
|
53
|
+
/** Resolves to a URL, "" to remove the link, or null when cancelled. */
|
|
54
|
+
onPickLink?: (current?: string) => Promise<string | null>;
|
|
55
|
+
/**
|
|
56
|
+
* Trailing part of initialHTML (a signature). The first focus puts the
|
|
57
|
+
* cursor just above it, as desk-web does.
|
|
58
|
+
*/
|
|
59
|
+
trailingHTML?: string;
|
|
60
|
+
/** Edit {{variables}} as chips (signature editors); replies keep them as text. */
|
|
61
|
+
variables?: boolean;
|
|
62
|
+
/** Palette for the text colour menu. */
|
|
63
|
+
colors?: string[];
|
|
64
|
+
/** People offered while an "@name" is typed; inserted as mention chips. */
|
|
65
|
+
mentions?: MentionSuggestion[];
|
|
66
|
+
/** Behind the text; defaults to the theme's primary background. */
|
|
67
|
+
backgroundColor?: string;
|
|
68
|
+
style?: StyleProp<ViewStyle>;
|
|
69
|
+
testID?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const RichTextEditor = forwardRef<
|
|
73
|
+
RichTextEditorRef,
|
|
74
|
+
RichTextEditorProps
|
|
75
|
+
>(
|
|
76
|
+
(
|
|
77
|
+
{
|
|
78
|
+
initialHTML,
|
|
79
|
+
placeholder,
|
|
80
|
+
editable = true,
|
|
81
|
+
autoFocus = false,
|
|
82
|
+
onChange,
|
|
83
|
+
onFocus,
|
|
84
|
+
onBlur,
|
|
85
|
+
toolbar = "fixed",
|
|
86
|
+
onPickImage,
|
|
87
|
+
onPickLink,
|
|
88
|
+
colors,
|
|
89
|
+
mentions = [],
|
|
90
|
+
backgroundColor,
|
|
91
|
+
variables = false,
|
|
92
|
+
trailingHTML,
|
|
93
|
+
style,
|
|
94
|
+
testID,
|
|
95
|
+
},
|
|
96
|
+
ref
|
|
97
|
+
) => {
|
|
98
|
+
const { theme } = useUnistyles();
|
|
99
|
+
const palette = useMemo(
|
|
100
|
+
() => colors ?? defaultColors(theme.colors.web),
|
|
101
|
+
[colors, theme.colors.web]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const editor = useEditorBridge({
|
|
105
|
+
customSource: editorHtml,
|
|
106
|
+
bridgeExtensions: [
|
|
107
|
+
...editorBridges({ variables }),
|
|
108
|
+
CoreBridge.configureCSS(editorCss(theme, backgroundColor)),
|
|
109
|
+
PlaceholderBridge.configureExtension({
|
|
110
|
+
placeholder: placeholder && injectableText(placeholder),
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
initialContent: initialHTML,
|
|
114
|
+
editable,
|
|
115
|
+
autofocus: autoFocus,
|
|
116
|
+
avoidIosKeyboard: false,
|
|
117
|
+
webviewBaseURL:
|
|
118
|
+
Platform.OS === "android" ? "file:///android_asset/" : undefined,
|
|
119
|
+
onChange,
|
|
120
|
+
}) as NeetoEditorBridge;
|
|
121
|
+
|
|
122
|
+
const { isFocused, mentionQuery } = useBridgeState(
|
|
123
|
+
editor
|
|
124
|
+
) as NeetoBridgeState;
|
|
125
|
+
// Android's WebView reports isFocused false while typing, so the caret's
|
|
126
|
+
// "@word" alone decides.
|
|
127
|
+
const mentionMatches = mentionQuery
|
|
128
|
+
? filterMentionSuggestions(
|
|
129
|
+
mentions,
|
|
130
|
+
mentionQuery.query,
|
|
131
|
+
MAX_MENTION_CHIPS
|
|
132
|
+
)
|
|
133
|
+
: [];
|
|
134
|
+
const wasFocused = useRef(false);
|
|
135
|
+
const hasPlacedCursor = useRef(false);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (isFocused === wasFocused.current) return;
|
|
139
|
+
|
|
140
|
+
wasFocused.current = isFocused;
|
|
141
|
+
if (isFocused && trailingHTML && !hasPlacedCursor.current) {
|
|
142
|
+
hasPlacedCursor.current = true;
|
|
143
|
+
editor.focusBeforeTrailing(trailingHTML);
|
|
144
|
+
}
|
|
145
|
+
(isFocused ? onFocus : onBlur)?.();
|
|
146
|
+
}, [editor, isFocused, onFocus, onBlur, trailingHTML]);
|
|
147
|
+
|
|
148
|
+
useImperativeHandle(
|
|
149
|
+
ref,
|
|
150
|
+
() => ({
|
|
151
|
+
getHTML: () => editor.getHTML(),
|
|
152
|
+
getText: () => editor.getText(),
|
|
153
|
+
setHTML: html => {
|
|
154
|
+
hasPlacedCursor.current = false;
|
|
155
|
+
editor.setContent(html);
|
|
156
|
+
},
|
|
157
|
+
insertHTML: html => editor.insertHTML(html),
|
|
158
|
+
focus: where =>
|
|
159
|
+
where === "emptyParagraph"
|
|
160
|
+
? editor.focusEmptyParagraph()
|
|
161
|
+
: editor.focus(where ?? "end"),
|
|
162
|
+
blur: () => editor.blur(),
|
|
163
|
+
insertImage: image => editor.insertFigure(image),
|
|
164
|
+
}),
|
|
165
|
+
[editor]
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<View
|
|
170
|
+
style={[
|
|
171
|
+
styles.root,
|
|
172
|
+
backgroundColor ? { backgroundColor } : null,
|
|
173
|
+
style,
|
|
174
|
+
]}
|
|
175
|
+
testID={testID}
|
|
176
|
+
>
|
|
177
|
+
<View style={styles.content} testID="rich-text-content">
|
|
178
|
+
<RichText editor={editor} />
|
|
179
|
+
</View>
|
|
180
|
+
{mentionQuery && mentionMatches.length > 0 ? (
|
|
181
|
+
// In the toolbar's slot, like a keyboard's suggestion strip: the
|
|
182
|
+
// phone composer has no height to spare above the keyboard.
|
|
183
|
+
<MentionSuggestionBar
|
|
184
|
+
suggestions={mentionMatches}
|
|
185
|
+
onSelect={({ id, name }) =>
|
|
186
|
+
editor.insertMention({ id, label: name }, mentionQuery)
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
) : (
|
|
190
|
+
toolbar === "fixed" && (
|
|
191
|
+
<RichTextToolbar
|
|
192
|
+
colors={palette}
|
|
193
|
+
editor={editor}
|
|
194
|
+
onPickImage={onPickImage}
|
|
195
|
+
onPickLink={onPickLink}
|
|
196
|
+
/>
|
|
197
|
+
)
|
|
198
|
+
)}
|
|
199
|
+
</View>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
RichTextEditor.displayName = "RichTextEditor";
|
|
205
|
+
|
|
206
|
+
const MAX_MENTION_CHIPS = 10;
|
|
207
|
+
|
|
208
|
+
const defaultColors = (web: Record<string, string>) =>
|
|
209
|
+
[
|
|
210
|
+
web.error500,
|
|
211
|
+
web.warning500,
|
|
212
|
+
web.success500,
|
|
213
|
+
web.info500,
|
|
214
|
+
web.primary500,
|
|
215
|
+
web.accent500,
|
|
216
|
+
web.gray500,
|
|
217
|
+
].filter((color): color is string => !!color);
|
|
218
|
+
|
|
219
|
+
const styles = StyleSheet.create(theme => ({
|
|
220
|
+
root: {
|
|
221
|
+
backgroundColor: theme.colors.background.primary,
|
|
222
|
+
flex: 1,
|
|
223
|
+
},
|
|
224
|
+
content: {
|
|
225
|
+
flex: 1,
|
|
226
|
+
},
|
|
227
|
+
}));
|