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.
Files changed (90) hide show
  1. package/lib/commonjs/components/RenderedOutput.js +168 -0
  2. package/lib/commonjs/components/RenderedOutput.js.map +1 -0
  3. package/lib/commonjs/components/RichTextInput.js +196 -52
  4. package/lib/commonjs/components/RichTextInput.js.map +1 -1
  5. package/lib/commonjs/components/Toolbar.js +41 -2
  6. package/lib/commonjs/components/Toolbar.js.map +1 -1
  7. package/lib/commonjs/constants/defaultStyles.js +81 -2
  8. package/lib/commonjs/constants/defaultStyles.js.map +1 -1
  9. package/lib/commonjs/hooks/useFormatting.js +46 -2
  10. package/lib/commonjs/hooks/useFormatting.js.map +1 -1
  11. package/lib/commonjs/hooks/useRichText.js +130 -12
  12. package/lib/commonjs/hooks/useRichText.js.map +1 -1
  13. package/lib/commonjs/index.d.js +19 -0
  14. package/lib/commonjs/index.d.js.map +1 -1
  15. package/lib/commonjs/index.js +19 -0
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/utils/formatter.js +48 -12
  18. package/lib/commonjs/utils/formatter.js.map +1 -1
  19. package/lib/commonjs/utils/parser.js +1 -1
  20. package/lib/commonjs/utils/parser.js.map +1 -1
  21. package/lib/commonjs/utils/serializer.d.js +6 -0
  22. package/lib/commonjs/utils/serializer.d.js.map +1 -0
  23. package/lib/commonjs/utils/serializer.js +259 -0
  24. package/lib/commonjs/utils/serializer.js.map +1 -0
  25. package/lib/commonjs/utils/styleMapper.js +11 -0
  26. package/lib/commonjs/utils/styleMapper.js.map +1 -1
  27. package/lib/module/components/RenderedOutput.js +163 -0
  28. package/lib/module/components/RenderedOutput.js.map +1 -0
  29. package/lib/module/components/RichTextInput.js +198 -55
  30. package/lib/module/components/RichTextInput.js.map +1 -1
  31. package/lib/module/components/Toolbar.js +41 -2
  32. package/lib/module/components/Toolbar.js.map +1 -1
  33. package/lib/module/constants/defaultStyles.js +81 -2
  34. package/lib/module/constants/defaultStyles.js.map +1 -1
  35. package/lib/module/hooks/useFormatting.js +47 -3
  36. package/lib/module/hooks/useFormatting.js.map +1 -1
  37. package/lib/module/hooks/useRichText.js +130 -12
  38. package/lib/module/hooks/useRichText.js.map +1 -1
  39. package/lib/module/index.d.js +1 -0
  40. package/lib/module/index.d.js.map +1 -1
  41. package/lib/module/index.js +1 -0
  42. package/lib/module/index.js.map +1 -1
  43. package/lib/module/utils/formatter.js +46 -12
  44. package/lib/module/utils/formatter.js.map +1 -1
  45. package/lib/module/utils/parser.js +1 -1
  46. package/lib/module/utils/parser.js.map +1 -1
  47. package/lib/module/utils/serializer.d.js +4 -0
  48. package/lib/module/utils/serializer.d.js.map +1 -0
  49. package/lib/module/utils/serializer.js +253 -0
  50. package/lib/module/utils/serializer.js.map +1 -0
  51. package/lib/module/utils/styleMapper.js +11 -0
  52. package/lib/module/utils/styleMapper.js.map +1 -1
  53. package/lib/typescript/src/components/RenderedOutput.d.ts +9 -0
  54. package/lib/typescript/src/components/RenderedOutput.d.ts.map +1 -0
  55. package/lib/typescript/src/components/RichTextInput.d.ts +2 -13
  56. package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
  57. package/lib/typescript/src/components/Toolbar.d.ts.map +1 -1
  58. package/lib/typescript/src/constants/defaultStyles.d.ts +3 -0
  59. package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
  60. package/lib/typescript/src/hooks/useFormatting.d.ts +4 -1
  61. package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
  62. package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
  63. package/lib/typescript/src/index.d.ts +2 -1
  64. package/lib/typescript/src/index.d.ts.map +1 -1
  65. package/lib/typescript/src/types/index.d.ts +112 -1
  66. package/lib/typescript/src/types/index.d.ts.map +1 -1
  67. package/lib/typescript/src/utils/formatter.d.ts +9 -1
  68. package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
  69. package/lib/typescript/src/utils/parser.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/serializer.d.ts +14 -0
  71. package/lib/typescript/src/utils/serializer.d.ts.map +1 -0
  72. package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -1
  73. package/package.json +1 -1
  74. package/src/components/RenderedOutput.tsx +231 -0
  75. package/src/components/RichTextInput.d.ts +3 -14
  76. package/src/components/RichTextInput.tsx +291 -56
  77. package/src/components/Toolbar.tsx +54 -2
  78. package/src/constants/defaultStyles.d.ts +3 -0
  79. package/src/constants/defaultStyles.ts +47 -1
  80. package/src/hooks/useFormatting.ts +89 -2
  81. package/src/hooks/useRichText.ts +193 -11
  82. package/src/index.d.ts +2 -1
  83. package/src/index.ts +10 -0
  84. package/src/types/index.d.ts +112 -1
  85. package/src/types/index.ts +123 -1
  86. package/src/utils/formatter.ts +60 -10
  87. package/src/utils/parser.ts +6 -1
  88. package/src/utils/serializer.d.ts +13 -0
  89. package/src/utils/serializer.ts +365 -0
  90. package/src/utils/styleMapper.ts +21 -0
@@ -1,34 +1,43 @@
1
- import React, { useEffect, useCallback } from 'react';
1
+ import React, {
2
+ useEffect,
3
+ useCallback,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
  import {
3
- View,
4
- TextInput,
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 { OverlayText } from './OverlayText';
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 Overlay Technique:
20
- * - A transparent `TextInput` on top captures user input and selection
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
- }, [onReady, actions]);
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
- // Container style
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
- styles.hiddenInputText,
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
- ? { borderBottomWidth: 1, borderBottomColor: resolvedTheme.colors?.toolbarBorder ?? DEFAULT_THEME.colors?.toolbarBorder }
113
- : { borderTopWidth: 1, borderTopColor: resolvedTheme.colors?.toolbarBorder ?? DEFAULT_THEME.colors?.toolbarBorder };
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={typeof maxHeight === 'number'}
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
- hiddenInputText: {
175
- // Keep the editable layer invisible while preserving the caret.
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
- ({ actions, state, items, theme, visible = true, renderToolbar }) => {
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: 'transparent',
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
  ];