react-native-richify 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/components/RenderedOutput.js +168 -0
- package/lib/commonjs/components/RenderedOutput.js.map +1 -0
- package/lib/commonjs/components/RichTextInput.js +196 -52
- package/lib/commonjs/components/RichTextInput.js.map +1 -1
- package/lib/commonjs/components/Toolbar.js +41 -2
- package/lib/commonjs/components/Toolbar.js.map +1 -1
- package/lib/commonjs/constants/defaultStyles.js +81 -2
- package/lib/commonjs/constants/defaultStyles.js.map +1 -1
- package/lib/commonjs/hooks/useFormatting.js +46 -2
- package/lib/commonjs/hooks/useFormatting.js.map +1 -1
- package/lib/commonjs/hooks/useRichText.js +130 -12
- package/lib/commonjs/hooks/useRichText.js.map +1 -1
- package/lib/commonjs/index.d.js +19 -0
- package/lib/commonjs/index.d.js.map +1 -1
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/utils/formatter.js +48 -12
- package/lib/commonjs/utils/formatter.js.map +1 -1
- package/lib/commonjs/utils/parser.js +1 -1
- package/lib/commonjs/utils/parser.js.map +1 -1
- package/lib/commonjs/utils/serializer.d.js +6 -0
- package/lib/commonjs/utils/serializer.d.js.map +1 -0
- package/lib/commonjs/utils/serializer.js +259 -0
- package/lib/commonjs/utils/serializer.js.map +1 -0
- package/lib/commonjs/utils/styleMapper.js +11 -0
- package/lib/commonjs/utils/styleMapper.js.map +1 -1
- package/lib/module/components/RenderedOutput.js +163 -0
- package/lib/module/components/RenderedOutput.js.map +1 -0
- package/lib/module/components/RichTextInput.js +198 -55
- package/lib/module/components/RichTextInput.js.map +1 -1
- package/lib/module/components/Toolbar.js +41 -2
- package/lib/module/components/Toolbar.js.map +1 -1
- package/lib/module/constants/defaultStyles.js +81 -2
- package/lib/module/constants/defaultStyles.js.map +1 -1
- package/lib/module/hooks/useFormatting.js +47 -3
- package/lib/module/hooks/useFormatting.js.map +1 -1
- package/lib/module/hooks/useRichText.js +130 -12
- package/lib/module/hooks/useRichText.js.map +1 -1
- package/lib/module/index.d.js +1 -0
- package/lib/module/index.d.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/formatter.js +46 -12
- package/lib/module/utils/formatter.js.map +1 -1
- package/lib/module/utils/parser.js +1 -1
- package/lib/module/utils/parser.js.map +1 -1
- package/lib/module/utils/serializer.d.js +4 -0
- package/lib/module/utils/serializer.d.js.map +1 -0
- package/lib/module/utils/serializer.js +253 -0
- package/lib/module/utils/serializer.js.map +1 -0
- package/lib/module/utils/styleMapper.js +11 -0
- package/lib/module/utils/styleMapper.js.map +1 -1
- package/lib/typescript/src/components/RenderedOutput.d.ts +9 -0
- package/lib/typescript/src/components/RenderedOutput.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextInput.d.ts +2 -13
- package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
- package/lib/typescript/src/components/Toolbar.d.ts.map +1 -1
- package/lib/typescript/src/constants/defaultStyles.d.ts +3 -0
- package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useFormatting.d.ts +4 -1
- package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types/index.d.ts +112 -1
- package/lib/typescript/src/types/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/formatter.d.ts +9 -1
- package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
- package/lib/typescript/src/utils/parser.d.ts.map +1 -1
- package/lib/typescript/src/utils/serializer.d.ts +14 -0
- package/lib/typescript/src/utils/serializer.d.ts.map +1 -0
- package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/RenderedOutput.tsx +231 -0
- package/src/components/RichTextInput.d.ts +3 -14
- package/src/components/RichTextInput.tsx +291 -56
- package/src/components/Toolbar.tsx +54 -2
- package/src/constants/defaultStyles.d.ts +3 -0
- package/src/constants/defaultStyles.ts +47 -1
- package/src/hooks/useFormatting.ts +89 -2
- package/src/hooks/useRichText.ts +193 -11
- package/src/index.d.ts +2 -1
- package/src/index.ts +10 -0
- package/src/types/index.d.ts +112 -1
- package/src/types/index.ts +123 -1
- package/src/utils/formatter.ts +60 -10
- package/src/utils/parser.ts +6 -1
- package/src/utils/serializer.d.ts +13 -0
- package/src/utils/serializer.ts +365 -0
- package/src/utils/styleMapper.ts +21 -0
|
@@ -1,34 +1,43 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useCallback,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
9
|
+
Animated,
|
|
10
|
+
Easing,
|
|
11
|
+
ScrollView,
|
|
5
12
|
StyleSheet,
|
|
13
|
+
Text,
|
|
14
|
+
TextInput,
|
|
15
|
+
View,
|
|
6
16
|
type NativeSyntheticEvent,
|
|
17
|
+
type TextInputContentSizeChangeEventData,
|
|
7
18
|
type TextInputSelectionChangeEventData,
|
|
8
19
|
} from 'react-native';
|
|
9
20
|
import type { RichTextInputProps } from '../types';
|
|
10
21
|
import { DEFAULT_THEME } from '../constants/defaultStyles';
|
|
11
|
-
import { segmentsToPlainText } from '../utils/parser';
|
|
12
22
|
import { useRichText } from '../hooks/useRichText';
|
|
13
|
-
import {
|
|
23
|
+
import { segmentsToPlainText } from '../utils/parser';
|
|
24
|
+
import { serializeSegments } from '../utils/serializer';
|
|
25
|
+
import { RenderedOutput } from './RenderedOutput';
|
|
14
26
|
import { Toolbar } from './Toolbar';
|
|
15
27
|
|
|
28
|
+
const DEFAULT_OUTPUT_PANEL_MAX_HEIGHT = 180;
|
|
29
|
+
const isJestRuntime =
|
|
30
|
+
typeof (
|
|
31
|
+
globalThis as {
|
|
32
|
+
process?: { env?: { JEST_WORKER_ID?: string } };
|
|
33
|
+
}
|
|
34
|
+
).process?.env?.JEST_WORKER_ID === 'string';
|
|
35
|
+
|
|
16
36
|
/**
|
|
17
37
|
* RichTextInput — The main rich text editor component.
|
|
18
38
|
*
|
|
19
|
-
* Uses the
|
|
20
|
-
*
|
|
21
|
-
* - A styled `<Text>` layer behind it renders the formatted content
|
|
22
|
-
* - Both share identical font metrics for pixel-perfect alignment
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* ```tsx
|
|
26
|
-
* <RichTextInput
|
|
27
|
-
* placeholder="Start typing..."
|
|
28
|
-
* showToolbar
|
|
29
|
-
* onChangeSegments={(segments) => console.log(segments)}
|
|
30
|
-
* />
|
|
31
|
-
* ```
|
|
39
|
+
* Uses a plain `TextInput` for editing and renders the serialized rich output
|
|
40
|
+
* below it as Markdown or HTML.
|
|
32
41
|
*/
|
|
33
42
|
export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
34
43
|
initialSegments,
|
|
@@ -41,6 +50,17 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
41
50
|
toolbarPosition = 'top',
|
|
42
51
|
toolbarItems,
|
|
43
52
|
theme,
|
|
53
|
+
showOutputPreview = true,
|
|
54
|
+
outputFormat,
|
|
55
|
+
defaultOutputFormat = 'markdown',
|
|
56
|
+
outputPreviewMode,
|
|
57
|
+
defaultOutputPreviewMode = 'literal',
|
|
58
|
+
maxOutputHeight = DEFAULT_OUTPUT_PANEL_MAX_HEIGHT,
|
|
59
|
+
onChangeOutput,
|
|
60
|
+
onChangeOutputFormat,
|
|
61
|
+
onChangeOutputPreviewMode,
|
|
62
|
+
onRequestLink,
|
|
63
|
+
onRequestImage,
|
|
44
64
|
multiline = true,
|
|
45
65
|
minHeight = 120,
|
|
46
66
|
maxHeight,
|
|
@@ -50,6 +70,13 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
50
70
|
onReady,
|
|
51
71
|
}) => {
|
|
52
72
|
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
73
|
+
const previewProgress = useRef(new Animated.Value(0)).current;
|
|
74
|
+
const [internalOutputFormat, setInternalOutputFormat] =
|
|
75
|
+
useState(defaultOutputFormat);
|
|
76
|
+
const [internalOutputPreviewMode, setInternalOutputPreviewMode] = useState(
|
|
77
|
+
defaultOutputPreviewMode,
|
|
78
|
+
);
|
|
79
|
+
const [contentHeight, setContentHeight] = useState(minHeight);
|
|
53
80
|
|
|
54
81
|
const { state, actions } = useRichText({
|
|
55
82
|
initialSegments,
|
|
@@ -57,15 +84,61 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
57
84
|
onChangeText,
|
|
58
85
|
});
|
|
59
86
|
|
|
60
|
-
// Expose actions via onReady callback
|
|
61
87
|
useEffect(() => {
|
|
62
88
|
onReady?.(actions);
|
|
63
|
-
}, [
|
|
89
|
+
}, [actions, onReady]);
|
|
64
90
|
|
|
65
|
-
// Build plain text for the TextInput value
|
|
66
91
|
const plainText = segmentsToPlainText(state.segments);
|
|
92
|
+
const resolvedOutputFormat = outputFormat ?? internalOutputFormat;
|
|
93
|
+
const resolvedOutputPreviewMode =
|
|
94
|
+
outputPreviewMode ?? internalOutputPreviewMode;
|
|
95
|
+
const inputHeight = Math.max(
|
|
96
|
+
minHeight,
|
|
97
|
+
typeof maxHeight === 'number'
|
|
98
|
+
? Math.min(contentHeight, maxHeight)
|
|
99
|
+
: contentHeight,
|
|
100
|
+
);
|
|
101
|
+
const shouldScrollInput =
|
|
102
|
+
typeof maxHeight === 'number' && contentHeight > maxHeight;
|
|
103
|
+
const serializedOutput = useMemo(
|
|
104
|
+
() => serializeSegments(state.segments, resolvedOutputFormat),
|
|
105
|
+
[resolvedOutputFormat, state.segments],
|
|
106
|
+
);
|
|
107
|
+
const shouldShowOutputPreview = showOutputPreview && plainText.length > 0;
|
|
108
|
+
const normalizedSelection = useMemo(
|
|
109
|
+
() => ({
|
|
110
|
+
start: Math.min(state.selection.start, state.selection.end),
|
|
111
|
+
end: Math.max(state.selection.start, state.selection.end),
|
|
112
|
+
}),
|
|
113
|
+
[state.selection.end, state.selection.start],
|
|
114
|
+
);
|
|
115
|
+
const selectedText = useMemo(
|
|
116
|
+
() => plainText.slice(normalizedSelection.start, normalizedSelection.end),
|
|
117
|
+
[normalizedSelection.end, normalizedSelection.start, plainText],
|
|
118
|
+
);
|
|
119
|
+
const selectionStyle = useMemo(
|
|
120
|
+
() => actions.getSelectionStyle(),
|
|
121
|
+
[actions, state.activeStyles, state.segments, state.selection],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
onChangeOutput?.(serializedOutput, resolvedOutputFormat);
|
|
126
|
+
}, [onChangeOutput, resolvedOutputFormat, serializedOutput]);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (isJestRuntime) {
|
|
130
|
+
previewProgress.setValue(shouldShowOutputPreview ? 1 : 0);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Animated.timing(previewProgress, {
|
|
135
|
+
toValue: shouldShowOutputPreview ? 1 : 0,
|
|
136
|
+
duration: 180,
|
|
137
|
+
easing: Easing.out(Easing.cubic),
|
|
138
|
+
useNativeDriver: false,
|
|
139
|
+
}).start();
|
|
140
|
+
}, [previewProgress, shouldShowOutputPreview]);
|
|
67
141
|
|
|
68
|
-
// Handle selection change from TextInput
|
|
69
142
|
const onSelectionChange = useCallback(
|
|
70
143
|
(e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
|
71
144
|
const { start, end } = e.nativeEvent.selection;
|
|
@@ -74,67 +147,172 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
74
147
|
[actions],
|
|
75
148
|
);
|
|
76
149
|
|
|
77
|
-
|
|
150
|
+
const onContentSizeChange = useCallback(
|
|
151
|
+
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
|
152
|
+
setContentHeight(Math.ceil(e.nativeEvent.contentSize.height));
|
|
153
|
+
textInputProps?.onContentSizeChange?.(e);
|
|
154
|
+
},
|
|
155
|
+
[textInputProps],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const handleOutputFormatChange = useCallback(
|
|
159
|
+
(format: 'markdown' | 'html') => {
|
|
160
|
+
if (outputFormat === undefined) {
|
|
161
|
+
setInternalOutputFormat(format);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onChangeOutputFormat?.(format);
|
|
165
|
+
},
|
|
166
|
+
[onChangeOutputFormat, outputFormat],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const handleOutputPreviewModeChange = useCallback(
|
|
170
|
+
(mode: 'literal' | 'rendered') => {
|
|
171
|
+
if (outputPreviewMode === undefined) {
|
|
172
|
+
setInternalOutputPreviewMode(mode);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
onChangeOutputPreviewMode?.(mode);
|
|
176
|
+
},
|
|
177
|
+
[onChangeOutputPreviewMode, outputPreviewMode],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const handleRequestLink = useCallback(() => {
|
|
181
|
+
if (onRequestLink) {
|
|
182
|
+
onRequestLink({
|
|
183
|
+
selectedText,
|
|
184
|
+
currentUrl: selectionStyle.link,
|
|
185
|
+
applyLink: actions.setLink,
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (selectionStyle.link) {
|
|
191
|
+
actions.setLink(undefined);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const detectedUrl = detectLinkTarget(selectedText);
|
|
196
|
+
if (detectedUrl) {
|
|
197
|
+
actions.setLink(detectedUrl);
|
|
198
|
+
}
|
|
199
|
+
}, [actions, onRequestLink, selectedText, selectionStyle.link]);
|
|
200
|
+
|
|
201
|
+
const handleRequestImage = useCallback(() => {
|
|
202
|
+
if (onRequestImage) {
|
|
203
|
+
onRequestImage({
|
|
204
|
+
insertImage: actions.insertImage,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const detectedSource = detectImageSource(selectedText);
|
|
210
|
+
if (detectedSource) {
|
|
211
|
+
actions.insertImage(detectedSource, {
|
|
212
|
+
alt: selectedText.trim() || undefined,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}, [actions, onRequestImage, selectedText]);
|
|
216
|
+
|
|
217
|
+
const outputLabel = useMemo(() => {
|
|
218
|
+
const formatLabel = resolvedOutputFormat === 'html' ? 'HTML' : 'Markdown';
|
|
219
|
+
|
|
220
|
+
if (resolvedOutputPreviewMode === 'rendered') {
|
|
221
|
+
return `${formatLabel} preview`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return `${formatLabel} output`;
|
|
225
|
+
}, [resolvedOutputFormat, resolvedOutputPreviewMode]);
|
|
226
|
+
|
|
78
227
|
const containerStyle = [
|
|
79
228
|
resolvedTheme.containerStyle ?? DEFAULT_THEME.containerStyle,
|
|
80
229
|
];
|
|
81
|
-
|
|
82
|
-
// Input area style
|
|
83
|
-
const inputAreaStyle = [
|
|
84
|
-
styles.inputArea,
|
|
85
|
-
{ minHeight },
|
|
86
|
-
maxHeight ? { maxHeight } : undefined,
|
|
87
|
-
];
|
|
88
|
-
|
|
89
|
-
// Input style
|
|
230
|
+
const inputAreaStyle = [styles.inputArea];
|
|
90
231
|
const inputStyle = [
|
|
91
232
|
styles.textInput,
|
|
92
233
|
resolvedTheme.baseTextStyle ?? DEFAULT_THEME.baseTextStyle,
|
|
93
234
|
resolvedTheme.inputStyle ?? DEFAULT_THEME.inputStyle,
|
|
235
|
+
{ height: inputHeight },
|
|
94
236
|
textInputProps?.style,
|
|
95
|
-
|
|
237
|
+
];
|
|
238
|
+
const outputAnimatedStyle = {
|
|
239
|
+
maxHeight: previewProgress.interpolate({
|
|
240
|
+
inputRange: [0, 1],
|
|
241
|
+
outputRange: [0, maxOutputHeight + 72],
|
|
242
|
+
}),
|
|
243
|
+
opacity: previewProgress,
|
|
244
|
+
marginTop: previewProgress.interpolate({
|
|
245
|
+
inputRange: [0, 1],
|
|
246
|
+
outputRange: [0, 12],
|
|
247
|
+
}),
|
|
248
|
+
marginBottom: previewProgress.interpolate({
|
|
249
|
+
inputRange: [0, 1],
|
|
250
|
+
outputRange: [0, 16],
|
|
251
|
+
}),
|
|
252
|
+
transform: [
|
|
253
|
+
{
|
|
254
|
+
translateY: previewProgress.interpolate({
|
|
255
|
+
inputRange: [0, 1],
|
|
256
|
+
outputRange: [-8, 0],
|
|
257
|
+
}),
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
const outputContainerStyle = [
|
|
262
|
+
resolvedTheme.outputContainerStyle ?? DEFAULT_THEME.outputContainerStyle,
|
|
263
|
+
];
|
|
264
|
+
const outputLabelStyle = [
|
|
265
|
+
resolvedTheme.outputLabelStyle ?? DEFAULT_THEME.outputLabelStyle,
|
|
266
|
+
];
|
|
267
|
+
const outputTextStyle = [
|
|
268
|
+
resolvedTheme.outputTextStyle ?? DEFAULT_THEME.outputTextStyle,
|
|
96
269
|
];
|
|
97
270
|
|
|
98
|
-
// Toolbar component
|
|
99
271
|
const toolbarComponent = showToolbar ? (
|
|
100
272
|
<Toolbar
|
|
101
273
|
actions={actions}
|
|
102
274
|
state={state}
|
|
103
275
|
items={toolbarItems}
|
|
104
276
|
theme={resolvedTheme}
|
|
277
|
+
outputFormat={resolvedOutputFormat}
|
|
278
|
+
outputPreviewMode={resolvedOutputPreviewMode}
|
|
279
|
+
onOutputFormatChange={handleOutputFormatChange}
|
|
280
|
+
onOutputPreviewModeChange={handleOutputPreviewModeChange}
|
|
281
|
+
onRequestLink={handleRequestLink}
|
|
282
|
+
onRequestImage={handleRequestImage}
|
|
105
283
|
renderToolbar={renderToolbar}
|
|
106
284
|
/>
|
|
107
285
|
) : null;
|
|
108
286
|
|
|
109
|
-
// Toolbar border
|
|
110
287
|
const toolbarBorderStyle =
|
|
111
288
|
toolbarPosition === 'top'
|
|
112
|
-
? {
|
|
113
|
-
|
|
289
|
+
? {
|
|
290
|
+
borderBottomWidth: 1,
|
|
291
|
+
borderBottomColor:
|
|
292
|
+
resolvedTheme.colors?.toolbarBorder ??
|
|
293
|
+
DEFAULT_THEME.colors?.toolbarBorder,
|
|
294
|
+
}
|
|
295
|
+
: {
|
|
296
|
+
borderTopWidth: 1,
|
|
297
|
+
borderTopColor:
|
|
298
|
+
resolvedTheme.colors?.toolbarBorder ??
|
|
299
|
+
DEFAULT_THEME.colors?.toolbarBorder,
|
|
300
|
+
};
|
|
114
301
|
|
|
115
302
|
return (
|
|
116
303
|
<View style={containerStyle}>
|
|
117
|
-
{/* Toolbar — Top */}
|
|
118
304
|
{toolbarPosition === 'top' && toolbarComponent && (
|
|
119
305
|
<View style={toolbarBorderStyle}>{toolbarComponent}</View>
|
|
120
306
|
)}
|
|
121
307
|
|
|
122
|
-
{/* Editor Area */}
|
|
123
308
|
<View style={inputAreaStyle}>
|
|
124
|
-
{/* Overlay — Styled text rendering (behind TextInput) */}
|
|
125
|
-
<OverlayText
|
|
126
|
-
segments={state.segments}
|
|
127
|
-
baseTextStyle={resolvedTheme.baseTextStyle}
|
|
128
|
-
theme={resolvedTheme}
|
|
129
|
-
/>
|
|
130
|
-
|
|
131
|
-
{/* TextInput — Transparent layer on top for input capture */}
|
|
132
309
|
<TextInput
|
|
133
310
|
{...textInputProps}
|
|
134
311
|
style={inputStyle}
|
|
135
312
|
value={plainText}
|
|
136
313
|
onChangeText={actions.handleTextChange}
|
|
137
314
|
onSelectionChange={onSelectionChange}
|
|
315
|
+
onContentSizeChange={onContentSizeChange}
|
|
138
316
|
multiline={multiline}
|
|
139
317
|
placeholder={placeholder}
|
|
140
318
|
placeholderTextColor={
|
|
@@ -144,16 +322,42 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
|
|
|
144
322
|
editable={editable}
|
|
145
323
|
maxLength={maxLength}
|
|
146
324
|
autoFocus={autoFocus}
|
|
147
|
-
underlineColorAndroid="transparent"
|
|
148
325
|
selectionColor={
|
|
149
326
|
resolvedTheme.colors?.cursor ?? DEFAULT_THEME.colors?.cursor
|
|
150
327
|
}
|
|
151
328
|
textAlignVertical="top"
|
|
152
|
-
scrollEnabled={
|
|
329
|
+
scrollEnabled={shouldScrollInput}
|
|
153
330
|
/>
|
|
331
|
+
|
|
332
|
+
{showOutputPreview && (
|
|
333
|
+
<Animated.View
|
|
334
|
+
pointerEvents={shouldShowOutputPreview ? 'auto' : 'none'}
|
|
335
|
+
style={[styles.outputAnimatedWrapper, outputAnimatedStyle]}
|
|
336
|
+
>
|
|
337
|
+
<View style={outputContainerStyle}>
|
|
338
|
+
<Text style={outputLabelStyle}>
|
|
339
|
+
{outputLabel}
|
|
340
|
+
</Text>
|
|
341
|
+
<ScrollView
|
|
342
|
+
style={{ maxHeight: maxOutputHeight }}
|
|
343
|
+
showsVerticalScrollIndicator={false}
|
|
344
|
+
>
|
|
345
|
+
{resolvedOutputPreviewMode === 'rendered' ? (
|
|
346
|
+
<RenderedOutput
|
|
347
|
+
segments={state.segments}
|
|
348
|
+
theme={resolvedTheme}
|
|
349
|
+
/>
|
|
350
|
+
) : (
|
|
351
|
+
<Text selectable style={outputTextStyle}>
|
|
352
|
+
{serializedOutput}
|
|
353
|
+
</Text>
|
|
354
|
+
)}
|
|
355
|
+
</ScrollView>
|
|
356
|
+
</View>
|
|
357
|
+
</Animated.View>
|
|
358
|
+
)}
|
|
154
359
|
</View>
|
|
155
360
|
|
|
156
|
-
{/* Toolbar — Bottom */}
|
|
157
361
|
{toolbarPosition === 'bottom' && toolbarComponent && (
|
|
158
362
|
<View style={toolbarBorderStyle}>{toolbarComponent}</View>
|
|
159
363
|
)}
|
|
@@ -169,12 +373,43 @@ const styles = StyleSheet.create({
|
|
|
169
373
|
},
|
|
170
374
|
textInput: {
|
|
171
375
|
position: 'relative',
|
|
172
|
-
zIndex: 1,
|
|
173
376
|
},
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
color: 'transparent',
|
|
177
|
-
backgroundColor: 'transparent',
|
|
178
|
-
textShadowColor: 'transparent',
|
|
377
|
+
outputAnimatedWrapper: {
|
|
378
|
+
overflow: 'hidden',
|
|
179
379
|
},
|
|
180
380
|
});
|
|
381
|
+
|
|
382
|
+
function detectLinkTarget(value: string): string | undefined {
|
|
383
|
+
const trimmed = value.trim();
|
|
384
|
+
if (trimmed.length === 0) {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) {
|
|
389
|
+
return trimmed;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
393
|
+
return `mailto:${trimmed}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (/^[^\s]+\.[^\s]+$/.test(trimmed)) {
|
|
397
|
+
return `https://${trimmed}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function detectImageSource(value: string): string | undefined {
|
|
404
|
+
const normalized = detectLinkTarget(value);
|
|
405
|
+
if (!normalized) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const candidate = normalized.replace(/^mailto:/i, '');
|
|
410
|
+
if (/\.(png|jpe?g|gif|webp|svg)(\?.*)?$/i.test(candidate)) {
|
|
411
|
+
return normalized;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
@@ -14,7 +14,20 @@ import { ToolbarButton } from './ToolbarButton';
|
|
|
14
14
|
* - Horizontal scrolling for overflow
|
|
15
15
|
*/
|
|
16
16
|
export const Toolbar: React.FC<ToolbarProps> = React.memo(
|
|
17
|
-
({
|
|
17
|
+
({
|
|
18
|
+
actions,
|
|
19
|
+
state,
|
|
20
|
+
items,
|
|
21
|
+
theme,
|
|
22
|
+
visible = true,
|
|
23
|
+
outputFormat = 'markdown',
|
|
24
|
+
outputPreviewMode = 'literal',
|
|
25
|
+
onOutputFormatChange,
|
|
26
|
+
onOutputPreviewModeChange,
|
|
27
|
+
onRequestLink,
|
|
28
|
+
onRequestImage,
|
|
29
|
+
renderToolbar,
|
|
30
|
+
}) => {
|
|
18
31
|
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
19
32
|
const toolbarItems = items ?? DEFAULT_TOOLBAR_ITEMS;
|
|
20
33
|
|
|
@@ -33,12 +46,32 @@ export const Toolbar: React.FC<ToolbarProps> = React.memo(
|
|
|
33
46
|
isActive = selectionStyle.heading === item.heading;
|
|
34
47
|
}
|
|
35
48
|
|
|
49
|
+
if (item.listType) {
|
|
50
|
+
isActive = selectionStyle.listType === item.listType;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (item.textAlign) {
|
|
54
|
+
isActive = selectionStyle.textAlign === item.textAlign;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (item.outputFormat) {
|
|
58
|
+
isActive = outputFormat === item.outputFormat;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (item.outputPreviewMode) {
|
|
62
|
+
isActive = outputPreviewMode === item.outputPreviewMode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (item.actionType === 'link') {
|
|
66
|
+
isActive = !!selectionStyle.link;
|
|
67
|
+
}
|
|
68
|
+
|
|
36
69
|
return {
|
|
37
70
|
...item,
|
|
38
71
|
active: item.active ?? isActive,
|
|
39
72
|
};
|
|
40
73
|
});
|
|
41
|
-
}, [actions, toolbarItems]);
|
|
74
|
+
}, [actions, outputFormat, outputPreviewMode, toolbarItems]);
|
|
42
75
|
|
|
43
76
|
// Custom render
|
|
44
77
|
if (renderToolbar) {
|
|
@@ -46,6 +79,13 @@ export const Toolbar: React.FC<ToolbarProps> = React.memo(
|
|
|
46
79
|
items: enrichedItems,
|
|
47
80
|
state,
|
|
48
81
|
actions,
|
|
82
|
+
outputFormat,
|
|
83
|
+
outputPreviewMode,
|
|
84
|
+
onOutputFormatChange: onOutputFormatChange ?? (() => undefined),
|
|
85
|
+
onOutputPreviewModeChange:
|
|
86
|
+
onOutputPreviewModeChange ?? (() => undefined),
|
|
87
|
+
onRequestLink,
|
|
88
|
+
onRequestImage,
|
|
49
89
|
});
|
|
50
90
|
}
|
|
51
91
|
|
|
@@ -79,6 +119,18 @@ export const Toolbar: React.FC<ToolbarProps> = React.memo(
|
|
|
79
119
|
actions.toggleFormat(item.format);
|
|
80
120
|
} else if (item.heading) {
|
|
81
121
|
actions.setHeading(item.heading);
|
|
122
|
+
} else if (item.listType) {
|
|
123
|
+
actions.setListType(item.listType);
|
|
124
|
+
} else if (item.textAlign) {
|
|
125
|
+
actions.setTextAlign(item.textAlign);
|
|
126
|
+
} else if (item.outputFormat) {
|
|
127
|
+
onOutputFormatChange?.(item.outputFormat);
|
|
128
|
+
} else if (item.outputPreviewMode) {
|
|
129
|
+
onOutputPreviewModeChange?.(item.outputPreviewMode);
|
|
130
|
+
} else if (item.actionType === 'link') {
|
|
131
|
+
onRequestLink?.();
|
|
132
|
+
} else if (item.actionType === 'image') {
|
|
133
|
+
onRequestImage?.();
|
|
82
134
|
}
|
|
83
135
|
}}
|
|
84
136
|
/>
|
|
@@ -9,6 +9,9 @@ export declare const DEFAULT_COLORS: {
|
|
|
9
9
|
readonly placeholder: "#9CA3AF";
|
|
10
10
|
readonly toolbarBackground: "#F9FAFB";
|
|
11
11
|
readonly toolbarBorder: "#E5E7EB";
|
|
12
|
+
readonly outputBackground: "#F8FAFC";
|
|
13
|
+
readonly outputLabel: "#475569";
|
|
14
|
+
readonly link: "#2563EB";
|
|
12
15
|
readonly cursor: "#6366F1";
|
|
13
16
|
readonly activeButtonBg: "#EEF2FF";
|
|
14
17
|
readonly codeBackground: "#F3F4F6";
|
|
@@ -10,6 +10,9 @@ export const DEFAULT_COLORS = {
|
|
|
10
10
|
placeholder: '#9CA3AF',
|
|
11
11
|
toolbarBackground: '#F9FAFB',
|
|
12
12
|
toolbarBorder: '#E5E7EB',
|
|
13
|
+
outputBackground: '#F8FAFC',
|
|
14
|
+
outputLabel: '#475569',
|
|
15
|
+
link: '#2563EB',
|
|
13
16
|
cursor: '#6366F1',
|
|
14
17
|
activeButtonBg: '#EEF2FF',
|
|
15
18
|
codeBackground: '#F3F4F6',
|
|
@@ -48,6 +51,11 @@ export const EMPTY_FORMAT_STYLE: FormatStyle = {
|
|
|
48
51
|
backgroundColor: undefined,
|
|
49
52
|
fontSize: undefined,
|
|
50
53
|
heading: undefined,
|
|
54
|
+
listType: undefined,
|
|
55
|
+
textAlign: undefined,
|
|
56
|
+
link: undefined,
|
|
57
|
+
imageSrc: undefined,
|
|
58
|
+
imageAlt: undefined,
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -64,7 +72,7 @@ export const DEFAULT_THEME: RichTextTheme = {
|
|
|
64
72
|
inputStyle: {
|
|
65
73
|
fontSize: DEFAULT_BASE_TEXT_STYLE.fontSize,
|
|
66
74
|
lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight,
|
|
67
|
-
color:
|
|
75
|
+
color: DEFAULT_COLORS.text,
|
|
68
76
|
paddingHorizontal: 16,
|
|
69
77
|
paddingVertical: 12,
|
|
70
78
|
textAlignVertical: 'top',
|
|
@@ -83,6 +91,32 @@ export const DEFAULT_THEME: RichTextTheme = {
|
|
|
83
91
|
lineHeight: DEFAULT_BASE_TEXT_STYLE.lineHeight,
|
|
84
92
|
color: DEFAULT_COLORS.text,
|
|
85
93
|
},
|
|
94
|
+
outputContainerStyle: {
|
|
95
|
+
marginHorizontal: 12,
|
|
96
|
+
marginBottom: 12,
|
|
97
|
+
padding: 12,
|
|
98
|
+
borderRadius: 10,
|
|
99
|
+
borderWidth: 1,
|
|
100
|
+
borderColor: DEFAULT_COLORS.toolbarBorder,
|
|
101
|
+
backgroundColor: DEFAULT_COLORS.outputBackground,
|
|
102
|
+
},
|
|
103
|
+
outputLabelStyle: {
|
|
104
|
+
marginBottom: 8,
|
|
105
|
+
fontSize: 12,
|
|
106
|
+
fontWeight: '700',
|
|
107
|
+
letterSpacing: 0.4,
|
|
108
|
+
color: DEFAULT_COLORS.outputLabel,
|
|
109
|
+
textTransform: 'uppercase',
|
|
110
|
+
},
|
|
111
|
+
outputTextStyle: {
|
|
112
|
+
fontSize: 14,
|
|
113
|
+
lineHeight: 20,
|
|
114
|
+
color: DEFAULT_COLORS.text,
|
|
115
|
+
fontFamily: 'monospace',
|
|
116
|
+
},
|
|
117
|
+
renderedOutputStyle: {
|
|
118
|
+
gap: 10,
|
|
119
|
+
},
|
|
86
120
|
toolbarStyle: {
|
|
87
121
|
flexDirection: 'row',
|
|
88
122
|
alignItems: 'center',
|
|
@@ -125,6 +159,7 @@ export const DEFAULT_THEME: RichTextTheme = {
|
|
|
125
159
|
placeholder: DEFAULT_COLORS.placeholder,
|
|
126
160
|
toolbarBackground: DEFAULT_COLORS.toolbarBackground,
|
|
127
161
|
toolbarBorder: DEFAULT_COLORS.toolbarBorder,
|
|
162
|
+
link: DEFAULT_COLORS.link,
|
|
128
163
|
cursor: DEFAULT_COLORS.cursor,
|
|
129
164
|
},
|
|
130
165
|
};
|
|
@@ -141,4 +176,15 @@ export const DEFAULT_TOOLBAR_ITEMS: ToolbarItem[] = [
|
|
|
141
176
|
{ id: 'h1', label: 'H1', heading: 'h1' },
|
|
142
177
|
{ id: 'h2', label: 'H2', heading: 'h2' },
|
|
143
178
|
{ id: 'h3', label: 'H3', heading: 'h3' },
|
|
179
|
+
{ id: 'bullet', label: '\u2022', listType: 'bullet' },
|
|
180
|
+
{ id: 'ordered', label: '1.', listType: 'ordered' },
|
|
181
|
+
{ id: 'link', label: 'Link', actionType: 'link' },
|
|
182
|
+
{ id: 'image', label: 'Img', actionType: 'image' },
|
|
183
|
+
{ id: 'align-left', label: 'L', textAlign: 'left' },
|
|
184
|
+
{ id: 'align-center', label: 'C', textAlign: 'center' },
|
|
185
|
+
{ id: 'align-right', label: 'R', textAlign: 'right' },
|
|
186
|
+
{ id: 'format-markdown', label: 'MD', outputFormat: 'markdown' },
|
|
187
|
+
{ id: 'format-html', label: 'HTML', outputFormat: 'html' },
|
|
188
|
+
{ id: 'preview-literal', label: 'Raw', outputPreviewMode: 'literal' },
|
|
189
|
+
{ id: 'preview-rendered', label: 'View', outputPreviewMode: 'rendered' },
|
|
144
190
|
];
|