react-native-richify 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/lib/commonjs/components/OverlayText.d.js.map +1 -1
  2. package/lib/commonjs/components/OverlayText.js +8 -2
  3. package/lib/commonjs/components/OverlayText.js.map +1 -1
  4. package/lib/commonjs/components/RichTextInput.d.js.map +1 -1
  5. package/lib/commonjs/components/RichTextInput.js +73 -45
  6. package/lib/commonjs/components/RichTextInput.js.map +1 -1
  7. package/lib/commonjs/components/Toolbar.d.js.map +1 -1
  8. package/lib/commonjs/components/Toolbar.js +4 -7
  9. package/lib/commonjs/components/Toolbar.js.map +1 -1
  10. package/lib/commonjs/components/ToolbarButton.d.js.map +1 -1
  11. package/lib/commonjs/components/ToolbarButton.js.map +1 -1
  12. package/lib/commonjs/constants/defaultStyles.d.js.map +1 -1
  13. package/lib/commonjs/constants/defaultStyles.js +26 -1
  14. package/lib/commonjs/constants/defaultStyles.js.map +1 -1
  15. package/lib/commonjs/context/RichTextContext.d.js.map +1 -1
  16. package/lib/commonjs/context/RichTextContext.js.map +1 -1
  17. package/lib/commonjs/hooks/useFormatting.d.js.map +1 -1
  18. package/lib/commonjs/hooks/useFormatting.js +7 -1
  19. package/lib/commonjs/hooks/useFormatting.js.map +1 -1
  20. package/lib/commonjs/hooks/useRichText.d.js.map +1 -1
  21. package/lib/commonjs/hooks/useRichText.js +62 -7
  22. package/lib/commonjs/hooks/useRichText.js.map +1 -1
  23. package/lib/commonjs/hooks/useSelection.d.js.map +1 -1
  24. package/lib/commonjs/hooks/useSelection.js.map +1 -1
  25. package/lib/commonjs/index.d.js +19 -0
  26. package/lib/commonjs/index.d.js.map +1 -1
  27. package/lib/commonjs/index.js +19 -0
  28. package/lib/commonjs/index.js.map +1 -1
  29. package/lib/commonjs/types/index.d.js.map +1 -1
  30. package/lib/commonjs/types/index.js.map +1 -1
  31. package/lib/commonjs/utils/formatter.d.js.map +1 -1
  32. package/lib/commonjs/utils/formatter.js +1 -4
  33. package/lib/commonjs/utils/formatter.js.map +1 -1
  34. package/lib/commonjs/utils/parser.d.js.map +1 -1
  35. package/lib/commonjs/utils/parser.js.map +1 -1
  36. package/lib/commonjs/utils/serializer.d.js +6 -0
  37. package/lib/commonjs/utils/serializer.d.js.map +1 -0
  38. package/lib/commonjs/utils/serializer.js +163 -0
  39. package/lib/commonjs/utils/serializer.js.map +1 -0
  40. package/lib/commonjs/utils/styleMapper.d.js.map +1 -1
  41. package/lib/commonjs/utils/styleMapper.js.map +1 -1
  42. package/lib/module/components/OverlayText.d.js.map +1 -1
  43. package/lib/module/components/OverlayText.js +8 -2
  44. package/lib/module/components/OverlayText.js.map +1 -1
  45. package/lib/module/components/RichTextInput.d.js.map +1 -1
  46. package/lib/module/components/RichTextInput.js +75 -48
  47. package/lib/module/components/RichTextInput.js.map +1 -1
  48. package/lib/module/components/Toolbar.d.js.map +1 -1
  49. package/lib/module/components/Toolbar.js +4 -7
  50. package/lib/module/components/Toolbar.js.map +1 -1
  51. package/lib/module/components/ToolbarButton.d.js.map +1 -1
  52. package/lib/module/components/ToolbarButton.js.map +1 -1
  53. package/lib/module/constants/defaultStyles.d.js.map +1 -1
  54. package/lib/module/constants/defaultStyles.js +26 -1
  55. package/lib/module/constants/defaultStyles.js.map +1 -1
  56. package/lib/module/context/RichTextContext.d.js.map +1 -1
  57. package/lib/module/context/RichTextContext.js.map +1 -1
  58. package/lib/module/hooks/useFormatting.d.js.map +1 -1
  59. package/lib/module/hooks/useFormatting.js +7 -1
  60. package/lib/module/hooks/useFormatting.js.map +1 -1
  61. package/lib/module/hooks/useRichText.d.js.map +1 -1
  62. package/lib/module/hooks/useRichText.js +62 -7
  63. package/lib/module/hooks/useRichText.js.map +1 -1
  64. package/lib/module/hooks/useSelection.d.js.map +1 -1
  65. package/lib/module/hooks/useSelection.js.map +1 -1
  66. package/lib/module/index.d.js +1 -0
  67. package/lib/module/index.d.js.map +1 -1
  68. package/lib/module/index.js +1 -0
  69. package/lib/module/index.js.map +1 -1
  70. package/lib/module/types/index.d.js.map +1 -1
  71. package/lib/module/types/index.js.map +1 -1
  72. package/lib/module/utils/formatter.d.js.map +1 -1
  73. package/lib/module/utils/formatter.js +1 -4
  74. package/lib/module/utils/formatter.js.map +1 -1
  75. package/lib/module/utils/parser.d.js.map +1 -1
  76. package/lib/module/utils/parser.js.map +1 -1
  77. package/lib/module/utils/serializer.d.js +4 -0
  78. package/lib/module/utils/serializer.d.js.map +1 -0
  79. package/lib/module/utils/serializer.js +157 -0
  80. package/lib/module/utils/serializer.js.map +1 -0
  81. package/lib/module/utils/styleMapper.d.js.map +1 -1
  82. package/lib/module/utils/styleMapper.js.map +1 -1
  83. package/lib/typescript/src/components/OverlayText.d.ts.map +1 -1
  84. package/lib/typescript/src/components/RichTextInput.d.ts +2 -13
  85. package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
  86. package/lib/typescript/src/constants/defaultStyles.d.ts +2 -0
  87. package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
  88. package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
  89. package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
  90. package/lib/typescript/src/index.d.ts +2 -1
  91. package/lib/typescript/src/index.d.ts.map +1 -1
  92. package/lib/typescript/src/types/index.d.ts +41 -11
  93. package/lib/typescript/src/types/index.d.ts.map +1 -1
  94. package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
  95. package/lib/typescript/src/utils/serializer.d.ts +14 -0
  96. package/lib/typescript/src/utils/serializer.d.ts.map +1 -0
  97. package/package.json +1 -1
  98. package/src/components/OverlayText.tsx +11 -3
  99. package/src/components/RichTextInput.d.ts +3 -14
  100. package/src/components/RichTextInput.tsx +111 -48
  101. package/src/components/Toolbar.d.ts +1 -1
  102. package/src/components/Toolbar.tsx +5 -5
  103. package/src/components/ToolbarButton.d.ts +1 -1
  104. package/src/constants/defaultStyles.d.ts +4 -2
  105. package/src/constants/defaultStyles.ts +26 -1
  106. package/src/hooks/useFormatting.ts +14 -1
  107. package/src/hooks/useRichText.ts +103 -10
  108. package/src/index.d.ts +2 -1
  109. package/src/index.ts +8 -0
  110. package/src/types/index.d.ts +41 -11
  111. package/src/types/index.ts +44 -11
  112. package/src/utils/formatter.ts +1 -5
  113. package/src/utils/serializer.d.ts +13 -0
  114. package/src/utils/serializer.ts +223 -0
@@ -1,9 +1,8 @@
1
- import { useState, useCallback, useRef, useEffect } from 'react';
1
+ import { useState, useCallback, useRef } from 'react';
2
2
  import type {
3
3
  StyledSegment,
4
- FormatType,
5
4
  FormatStyle,
6
- HeadingLevel,
5
+ OutputFormat,
7
6
  SelectionRange,
8
7
  RichTextState,
9
8
  RichTextActions,
@@ -16,6 +15,8 @@ import {
16
15
  reconcileTextChange,
17
16
  findPositionInSegments,
18
17
  } from '../utils/parser';
18
+ import { getSelectionStyle } from '../utils/formatter';
19
+ import { serializeSegments } from '../utils/serializer';
19
20
  import { useSelection } from '../hooks/useSelection';
20
21
  import { useFormatting } from '../hooks/useFormatting';
21
22
 
@@ -57,8 +58,11 @@ export function useRichText(
57
58
  // Refs for stable access in callbacks
58
59
  const segmentsRef = useRef(segments);
59
60
  segmentsRef.current = segments;
61
+ const selectionRef = useRef(selection);
62
+ selectionRef.current = selection;
60
63
  const activeStylesRef = useRef(activeStyles);
61
64
  activeStylesRef.current = activeStyles;
65
+ const preserveActiveStylesRef = useRef(false);
62
66
 
63
67
  // ─── Segment Change Handler ──────────────────────────────────────────────
64
68
 
@@ -86,7 +90,11 @@ export function useRichText(
86
90
  const handleTextChange = useCallback(
87
91
  (newText: string) => {
88
92
  const currentSegments = segmentsRef.current;
89
- const currentActiveStyles = activeStylesRef.current;
93
+ const currentSelection = selectionRef.current;
94
+ const currentActiveStyles =
95
+ currentSelection.start === currentSelection.end
96
+ ? activeStylesRef.current
97
+ : getSelectionStyle(currentSegments, currentSelection);
90
98
 
91
99
  const newSegments = reconcileTextChange(
92
100
  currentSegments,
@@ -103,8 +111,22 @@ export function useRichText(
103
111
 
104
112
  const onSelectionChange = useCallback(
105
113
  (newSelection: SelectionRange) => {
114
+ const previousSelection = selectionRef.current;
106
115
  handleSelectionChange(newSelection);
107
116
 
117
+ const shouldPreserveActiveStyles =
118
+ preserveActiveStylesRef.current &&
119
+ previousSelection.start === previousSelection.end &&
120
+ newSelection.start === newSelection.end &&
121
+ newSelection.start >= previousSelection.start &&
122
+ newSelection.start - previousSelection.start <= 1;
123
+
124
+ if (shouldPreserveActiveStyles) {
125
+ return;
126
+ }
127
+
128
+ preserveActiveStylesRef.current = false;
129
+
108
130
  // Update active styles based on cursor position
109
131
  if (newSelection.start === newSelection.end) {
110
132
  const pos = findPositionInSegments(
@@ -126,6 +148,13 @@ export function useRichText(
126
148
  return segmentsToPlainText(segmentsRef.current);
127
149
  }, []);
128
150
 
151
+ const getOutput = useCallback(
152
+ (format: OutputFormat = 'markdown'): string => {
153
+ return serializeSegments(segmentsRef.current, format);
154
+ },
155
+ [],
156
+ );
157
+
129
158
  const exportJSON = useCallback((): StyledSegment[] => {
130
159
  return JSON.parse(JSON.stringify(segmentsRef.current));
131
160
  }, []);
@@ -142,8 +171,69 @@ export function useRichText(
142
171
  const clear = useCallback(() => {
143
172
  updateSegments([createSegment('')]);
144
173
  setActiveStyles({ ...EMPTY_FORMAT_STYLE });
174
+ preserveActiveStylesRef.current = false;
145
175
  }, [updateSegments]);
146
176
 
177
+ const toggleFormat = useCallback<RichTextActions['toggleFormat']>(
178
+ (format) => {
179
+ if (selectionRef.current.start === selectionRef.current.end) {
180
+ preserveActiveStylesRef.current = true;
181
+ }
182
+ formatting.toggleFormat(format);
183
+ },
184
+ [formatting],
185
+ );
186
+
187
+ const setStyleProperty = useCallback<RichTextActions['setStyleProperty']>(
188
+ (key, value) => {
189
+ if (selectionRef.current.start === selectionRef.current.end) {
190
+ preserveActiveStylesRef.current = true;
191
+ }
192
+ formatting.setStyleProperty(key, value);
193
+ },
194
+ [formatting],
195
+ );
196
+
197
+ const setHeading = useCallback<RichTextActions['setHeading']>(
198
+ (level) => {
199
+ if (selectionRef.current.start === selectionRef.current.end) {
200
+ preserveActiveStylesRef.current = true;
201
+ }
202
+ formatting.setHeading(level);
203
+ },
204
+ [formatting],
205
+ );
206
+
207
+ const setColor = useCallback<RichTextActions['setColor']>(
208
+ (color) => {
209
+ if (selectionRef.current.start === selectionRef.current.end) {
210
+ preserveActiveStylesRef.current = true;
211
+ }
212
+ formatting.setColor(color);
213
+ },
214
+ [formatting],
215
+ );
216
+
217
+ const setBackgroundColor = useCallback<RichTextActions['setBackgroundColor']>(
218
+ (color) => {
219
+ if (selectionRef.current.start === selectionRef.current.end) {
220
+ preserveActiveStylesRef.current = true;
221
+ }
222
+ formatting.setBackgroundColor(color);
223
+ },
224
+ [formatting],
225
+ );
226
+
227
+ const setFontSize = useCallback<RichTextActions['setFontSize']>(
228
+ (size) => {
229
+ if (selectionRef.current.start === selectionRef.current.end) {
230
+ preserveActiveStylesRef.current = true;
231
+ }
232
+ formatting.setFontSize(size);
233
+ },
234
+ [formatting],
235
+ );
236
+
147
237
  // ─── Build Return Value ──────────────────────────────────────────────────
148
238
 
149
239
  const state: RichTextState = {
@@ -153,14 +243,17 @@ export function useRichText(
153
243
  };
154
244
 
155
245
  const actions: RichTextActions = {
156
- toggleFormat: formatting.toggleFormat,
157
- setStyleProperty: formatting.setStyleProperty,
158
- setHeading: formatting.setHeading,
159
- setColor: formatting.setColor,
160
- setBackgroundColor: formatting.setBackgroundColor,
161
- setFontSize: formatting.setFontSize,
246
+ toggleFormat,
247
+ setStyleProperty,
248
+ setHeading,
249
+ setColor,
250
+ setBackgroundColor,
251
+ setFontSize,
162
252
  handleTextChange,
163
253
  handleSelectionChange: onSelectionChange,
254
+ isFormatActive: formatting.isFormatActive,
255
+ getSelectionStyle: formatting.currentSelectionStyle,
256
+ getOutput,
164
257
  getPlainText,
165
258
  exportJSON,
166
259
  importJSON,
package/src/index.d.ts CHANGED
@@ -11,5 +11,6 @@ export type { RichTextProviderProps } from './context/RichTextContext';
11
11
  export { createSegment, segmentsToPlainText, getTotalLength, mergeAdjacentSegments, reconcileTextChange, } from './utils/parser';
12
12
  export { toggleFormatOnSelection, setStyleOnSelection, setHeadingOnLine, isFormatActiveInSelection, getSelectionStyle, } from './utils/formatter';
13
13
  export { formatStyleToTextStyle, segmentToTextStyle, segmentsToTextStyles, } from './utils/styleMapper';
14
+ export { serializeSegments, segmentsToMarkdown, segmentsToHTML, } from './utils/serializer';
14
15
  export { DEFAULT_COLORS, DEFAULT_THEME, DEFAULT_TOOLBAR_ITEMS, DEFAULT_BASE_TEXT_STYLE, HEADING_FONT_SIZES, EMPTY_FORMAT_STYLE, } from './constants/defaultStyles';
15
- export type { FormatType, HeadingLevel, ListType, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types';
16
+ export type { FormatType, HeadingLevel, ListType, OutputFormat, FormatStyle, StyledSegment, SelectionRange, RichTextState, RichTextActions, UseRichTextReturn, RichTextTheme, ToolbarItem, ToolbarButtonRenderProps, ToolbarRenderProps, OverlayTextProps, ToolbarButtonProps, ToolbarProps, RichTextInputProps, } from './types';
package/src/index.ts CHANGED
@@ -37,6 +37,11 @@ export {
37
37
  segmentToTextStyle,
38
38
  segmentsToTextStyles,
39
39
  } from './utils/styleMapper';
40
+ export {
41
+ serializeSegments,
42
+ segmentsToMarkdown,
43
+ segmentsToHTML,
44
+ } from './utils/serializer';
40
45
 
41
46
  // ─── Constants ───────────────────────────────────────────────────────────────
42
47
  export {
@@ -53,6 +58,7 @@ export type {
53
58
  FormatType,
54
59
  HeadingLevel,
55
60
  ListType,
61
+ OutputFormat,
56
62
  FormatStyle,
57
63
  StyledSegment,
58
64
  SelectionRange,
@@ -61,6 +67,8 @@ export type {
61
67
  UseRichTextReturn,
62
68
  RichTextTheme,
63
69
  ToolbarItem,
70
+ ToolbarButtonRenderProps,
71
+ ToolbarRenderProps,
64
72
  OverlayTextProps,
65
73
  ToolbarButtonProps,
66
74
  ToolbarProps,
@@ -11,6 +11,10 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
11
11
  * List type for a line/paragraph.
12
12
  */
13
13
  export type ListType = 'bullet' | 'ordered' | 'none';
14
+ /**
15
+ * Serialized output formats supported by the editor.
16
+ */
17
+ export type OutputFormat = 'markdown' | 'html';
14
18
  /**
15
19
  * Inline formatting styles attached to a text segment.
16
20
  */
@@ -75,6 +79,12 @@ export interface RichTextActions {
75
79
  handleTextChange: (text: string) => void;
76
80
  /** Handle selection change from TextInput. */
77
81
  handleSelectionChange: (selection: SelectionRange) => void;
82
+ /** Check whether a format is active at the current cursor/selection. */
83
+ isFormatActive: (format: FormatType) => boolean;
84
+ /** Get the effective shared style at the current cursor/selection. */
85
+ getSelectionStyle: () => FormatStyle;
86
+ /** Serialize the current content as markdown or HTML. */
87
+ getOutput: (format?: OutputFormat) => string;
78
88
  /** Get the full plain text content. */
79
89
  getPlainText: () => string;
80
90
  /** Export the segments as a serializable JSON array. */
@@ -99,10 +109,16 @@ export interface RichTextTheme {
99
109
  containerStyle?: ViewStyle;
100
110
  /** Style for the TextInput. */
101
111
  inputStyle?: TextStyle;
102
- /** Style for the overlay text container. */
112
+ /** Style for the legacy overlay text container. */
103
113
  overlayContainerStyle?: ViewStyle;
104
114
  /** Base text style applied to all segments before formatting. */
105
115
  baseTextStyle?: TextStyle;
116
+ /** Style for the serialized output container. */
117
+ outputContainerStyle?: ViewStyle;
118
+ /** Label style for the serialized output header. */
119
+ outputLabelStyle?: TextStyle;
120
+ /** Style for the serialized output text. */
121
+ outputTextStyle?: TextStyle;
106
122
  /** Style for the toolbar container. */
107
123
  toolbarStyle?: ViewStyle;
108
124
  /** Style for toolbar buttons. */
@@ -150,11 +166,23 @@ export interface ToolbarItem {
150
166
  /** Whether this item is currently active. */
151
167
  active?: boolean;
152
168
  /** Custom render function for the button. */
153
- renderButton?: (props: {
154
- active: boolean;
155
- onPress: () => void;
156
- label: string;
157
- }) => React.ReactElement;
169
+ renderButton?: (props: ToolbarButtonRenderProps) => React.ReactElement | null;
170
+ }
171
+ /**
172
+ * Props passed to a custom toolbar button renderer.
173
+ */
174
+ export interface ToolbarButtonRenderProps {
175
+ active: boolean;
176
+ onPress: () => void;
177
+ label: string;
178
+ }
179
+ /**
180
+ * Props passed to a custom toolbar renderer.
181
+ */
182
+ export interface ToolbarRenderProps {
183
+ items: ToolbarItem[];
184
+ state: RichTextState;
185
+ actions: RichTextActions;
158
186
  }
159
187
  /**
160
188
  * Props for the OverlayText component.
@@ -197,11 +225,7 @@ export interface ToolbarProps {
197
225
  /** Whether to show the toolbar. */
198
226
  visible?: boolean;
199
227
  /** Custom render function for the entire toolbar. */
200
- renderToolbar?: (props: {
201
- items: ToolbarItem[];
202
- state: RichTextState;
203
- actions: RichTextActions;
204
- }) => React.ReactElement;
228
+ renderToolbar?: (props: ToolbarRenderProps) => React.ReactElement | null;
205
229
  }
206
230
  /**
207
231
  * Props for the main RichTextInput component.
@@ -227,6 +251,12 @@ export interface RichTextInputProps {
227
251
  toolbarItems?: ToolbarItem[];
228
252
  /** Theme configuration. */
229
253
  theme?: RichTextTheme;
254
+ /** Whether to show the serialized output preview below the input. */
255
+ showOutputPreview?: boolean;
256
+ /** Format used for the serialized output preview. */
257
+ outputFormat?: OutputFormat;
258
+ /** Callback when the serialized output changes. */
259
+ onChangeOutput?: (output: string, format: OutputFormat) => void;
230
260
  /** Whether multiline input is enabled. */
231
261
  multiline?: boolean;
232
262
  /** Minimum height for the input area. */
@@ -22,6 +22,11 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
22
22
  */
23
23
  export type ListType = 'bullet' | 'ordered' | 'none';
24
24
 
25
+ /**
26
+ * Serialized output formats supported by the editor.
27
+ */
28
+ export type OutputFormat = 'markdown' | 'html';
29
+
25
30
  // ─── Style Types ─────────────────────────────────────────────────────────────
26
31
 
27
32
  /**
@@ -101,6 +106,12 @@ export interface RichTextActions {
101
106
  handleTextChange: (text: string) => void;
102
107
  /** Handle selection change from TextInput. */
103
108
  handleSelectionChange: (selection: SelectionRange) => void;
109
+ /** Check whether a format is active at the current cursor/selection. */
110
+ isFormatActive: (format: FormatType) => boolean;
111
+ /** Get the effective shared style at the current cursor/selection. */
112
+ getSelectionStyle: () => FormatStyle;
113
+ /** Serialize the current content as markdown or HTML. */
114
+ getOutput: (format?: OutputFormat) => string;
104
115
  /** Get the full plain text content. */
105
116
  getPlainText: () => string;
106
117
  /** Export the segments as a serializable JSON array. */
@@ -131,10 +142,16 @@ export interface RichTextTheme {
131
142
  containerStyle?: ViewStyle;
132
143
  /** Style for the TextInput. */
133
144
  inputStyle?: TextStyle;
134
- /** Style for the overlay text container. */
145
+ /** Style for the legacy overlay text container. */
135
146
  overlayContainerStyle?: ViewStyle;
136
147
  /** Base text style applied to all segments before formatting. */
137
148
  baseTextStyle?: TextStyle;
149
+ /** Style for the serialized output container. */
150
+ outputContainerStyle?: ViewStyle;
151
+ /** Label style for the serialized output header. */
152
+ outputLabelStyle?: TextStyle;
153
+ /** Style for the serialized output text. */
154
+ outputTextStyle?: TextStyle;
138
155
  /** Style for the toolbar container. */
139
156
  toolbarStyle?: ViewStyle;
140
157
  /** Style for toolbar buttons. */
@@ -185,11 +202,25 @@ export interface ToolbarItem {
185
202
  /** Whether this item is currently active. */
186
203
  active?: boolean;
187
204
  /** Custom render function for the button. */
188
- renderButton?: (props: {
189
- active: boolean;
190
- onPress: () => void;
191
- label: string;
192
- }) => React.ReactElement;
205
+ renderButton?: (props: ToolbarButtonRenderProps) => React.ReactElement | null;
206
+ }
207
+
208
+ /**
209
+ * Props passed to a custom toolbar button renderer.
210
+ */
211
+ export interface ToolbarButtonRenderProps {
212
+ active: boolean;
213
+ onPress: () => void;
214
+ label: string;
215
+ }
216
+
217
+ /**
218
+ * Props passed to a custom toolbar renderer.
219
+ */
220
+ export interface ToolbarRenderProps {
221
+ items: ToolbarItem[];
222
+ state: RichTextState;
223
+ actions: RichTextActions;
193
224
  }
194
225
 
195
226
  // ─── Component Props ─────────────────────────────────────────────────────────
@@ -237,11 +268,7 @@ export interface ToolbarProps {
237
268
  /** Whether to show the toolbar. */
238
269
  visible?: boolean;
239
270
  /** Custom render function for the entire toolbar. */
240
- renderToolbar?: (props: {
241
- items: ToolbarItem[];
242
- state: RichTextState;
243
- actions: RichTextActions;
244
- }) => React.ReactElement;
271
+ renderToolbar?: (props: ToolbarRenderProps) => React.ReactElement | null;
245
272
  }
246
273
 
247
274
  /**
@@ -268,6 +295,12 @@ export interface RichTextInputProps {
268
295
  toolbarItems?: ToolbarItem[];
269
296
  /** Theme configuration. */
270
297
  theme?: RichTextTheme;
298
+ /** Whether to show the serialized output preview below the input. */
299
+ showOutputPreview?: boolean;
300
+ /** Format used for the serialized output preview. */
301
+ outputFormat?: OutputFormat;
302
+ /** Callback when the serialized output changes. */
303
+ onChangeOutput?: (output: string, format: OutputFormat) => void;
271
304
  /** Whether multiline input is enabled. */
272
305
  multiline?: boolean;
273
306
  /** Minimum height for the input area. */
@@ -8,11 +8,9 @@ import type {
8
8
  import {
9
9
  createSegment,
10
10
  findPositionInSegments,
11
- splitSegment,
12
11
  mergeAdjacentSegments,
13
12
  segmentsToPlainText,
14
13
  } from '../utils/parser';
15
- import { HEADING_FONT_SIZES } from '../constants/defaultStyles';
16
14
 
17
15
  /**
18
16
  * Toggle an inline format (bold, italic, etc.) on the selected range.
@@ -73,9 +71,7 @@ export function setHeadingOnLine(
73
71
  const { lineStart, lineEnd } = getLineRange(plainText, selection.start);
74
72
 
75
73
  const headingStyle: Partial<FormatStyle> = {
76
- heading: level,
77
- fontSize: HEADING_FONT_SIZES[level],
78
- bold: level !== 'none' ? true : undefined,
74
+ heading: level === 'none' ? undefined : level,
79
75
  };
80
76
 
81
77
  return applyStyleToRange(segments, lineStart, lineEnd, headingStyle);
@@ -0,0 +1,13 @@
1
+ import type { OutputFormat, StyledSegment } from '../types';
2
+ /**
3
+ * Serialize styled segments as Markdown or HTML.
4
+ */
5
+ export declare function serializeSegments(segments: StyledSegment[], format?: OutputFormat): string;
6
+ /**
7
+ * Convenience wrapper for Markdown output.
8
+ */
9
+ export declare function segmentsToMarkdown(segments: StyledSegment[]): string;
10
+ /**
11
+ * Convenience wrapper for HTML output.
12
+ */
13
+ export declare function segmentsToHTML(segments: StyledSegment[]): string;
@@ -0,0 +1,223 @@
1
+ import type {
2
+ FormatStyle,
3
+ HeadingLevel,
4
+ OutputFormat,
5
+ StyledSegment,
6
+ } from '../types';
7
+
8
+ type LineFragment = Pick<StyledSegment, 'text' | 'styles'>;
9
+
10
+ /**
11
+ * Serialize styled segments as Markdown or HTML.
12
+ */
13
+ export function serializeSegments(
14
+ segments: StyledSegment[],
15
+ format: OutputFormat = 'markdown',
16
+ ): string {
17
+ const lines = splitSegmentsByLine(segments);
18
+ return lines.map((line) => serializeLine(line, format)).join('\n');
19
+ }
20
+
21
+ /**
22
+ * Convenience wrapper for Markdown output.
23
+ */
24
+ export function segmentsToMarkdown(segments: StyledSegment[]): string {
25
+ return serializeSegments(segments, 'markdown');
26
+ }
27
+
28
+ /**
29
+ * Convenience wrapper for HTML output.
30
+ */
31
+ export function segmentsToHTML(segments: StyledSegment[]): string {
32
+ return serializeSegments(segments, 'html');
33
+ }
34
+
35
+ function splitSegmentsByLine(segments: StyledSegment[]): LineFragment[][] {
36
+ const lines: LineFragment[][] = [[]];
37
+
38
+ for (const segment of segments) {
39
+ const parts = segment.text.split('\n');
40
+
41
+ parts.forEach((part, index) => {
42
+ if (part.length > 0) {
43
+ lines[lines.length - 1]?.push({
44
+ text: part,
45
+ styles: { ...segment.styles },
46
+ });
47
+ }
48
+
49
+ if (index < parts.length - 1) {
50
+ lines.push([]);
51
+ }
52
+ });
53
+ }
54
+
55
+ return lines;
56
+ }
57
+
58
+ function serializeLine(
59
+ line: LineFragment[],
60
+ format: OutputFormat,
61
+ ): string {
62
+ const heading = getLineHeading(line);
63
+ const content = line
64
+ .map((fragment) => serializeFragment(fragment, format, heading))
65
+ .join('');
66
+
67
+ if (format === 'html') {
68
+ const blockTag = heading ?? 'p';
69
+ return `<${blockTag}>${content}</${blockTag}>`;
70
+ }
71
+
72
+ const headingPrefix = getHeadingPrefix(heading);
73
+ if (!headingPrefix) {
74
+ return content;
75
+ }
76
+
77
+ return content.length > 0 ? `${headingPrefix} ${content}` : headingPrefix;
78
+ }
79
+
80
+ function serializeFragment(
81
+ fragment: LineFragment,
82
+ format: OutputFormat,
83
+ lineHeading?: HeadingLevel,
84
+ ): string {
85
+ const normalizedStyles: FormatStyle = {
86
+ ...fragment.styles,
87
+ heading: undefined,
88
+ // Markdown headings already express emphasis at the block level.
89
+ bold:
90
+ lineHeading && lineHeading !== 'none' ? false : fragment.styles.bold,
91
+ };
92
+
93
+ return format === 'html'
94
+ ? serializeHtmlFragment(fragment.text, normalizedStyles)
95
+ : serializeMarkdownFragment(fragment.text, normalizedStyles);
96
+ }
97
+
98
+ function serializeHtmlFragment(text: string, styles: FormatStyle): string {
99
+ let result = escapeHtml(text);
100
+
101
+ if (styles.code) {
102
+ result = `<code>${result}</code>`;
103
+ }
104
+
105
+ if (styles.bold) {
106
+ result = `<strong>${result}</strong>`;
107
+ }
108
+
109
+ if (styles.italic) {
110
+ result = `<em>${result}</em>`;
111
+ }
112
+
113
+ if (styles.underline) {
114
+ result = `<u>${result}</u>`;
115
+ }
116
+
117
+ if (styles.strikethrough) {
118
+ result = `<s>${result}</s>`;
119
+ }
120
+
121
+ const styleAttribute = buildInlineStyle(styles);
122
+ if (styleAttribute) {
123
+ result = `<span style="${styleAttribute}">${result}</span>`;
124
+ }
125
+
126
+ return result;
127
+ }
128
+
129
+ function serializeMarkdownFragment(text: string, styles: FormatStyle): string {
130
+ let result = escapeMarkdown(text);
131
+
132
+ if (styles.code) {
133
+ result = wrapInlineCode(text);
134
+ }
135
+
136
+ if (styles.bold) {
137
+ result = `**${result}**`;
138
+ }
139
+
140
+ if (styles.italic) {
141
+ result = `*${result}*`;
142
+ }
143
+
144
+ if (styles.strikethrough) {
145
+ result = `~~${result}~~`;
146
+ }
147
+
148
+ if (styles.underline) {
149
+ result = `<u>${result}</u>`;
150
+ }
151
+
152
+ const styleAttribute = buildInlineStyle(styles);
153
+ if (styleAttribute) {
154
+ result = `<span style="${styleAttribute}">${result}</span>`;
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ function buildInlineStyle(styles: FormatStyle): string {
161
+ const cssRules: string[] = [];
162
+
163
+ if (styles.color) {
164
+ cssRules.push(`color: ${styles.color}`);
165
+ }
166
+
167
+ if (styles.backgroundColor) {
168
+ cssRules.push(`background-color: ${styles.backgroundColor}`);
169
+ }
170
+
171
+ if (styles.fontSize) {
172
+ cssRules.push(`font-size: ${styles.fontSize}px`);
173
+ }
174
+
175
+ return cssRules.join('; ');
176
+ }
177
+
178
+ function getLineHeading(line: LineFragment[]): HeadingLevel | undefined {
179
+ for (const fragment of line) {
180
+ if (fragment.styles.heading && fragment.styles.heading !== 'none') {
181
+ return fragment.styles.heading;
182
+ }
183
+ }
184
+
185
+ return undefined;
186
+ }
187
+
188
+ function getHeadingPrefix(heading?: HeadingLevel): string | undefined {
189
+ switch (heading) {
190
+ case 'h1':
191
+ return '#';
192
+ case 'h2':
193
+ return '##';
194
+ case 'h3':
195
+ return '###';
196
+ default:
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ function escapeHtml(text: string): string {
202
+ return text
203
+ .replaceAll('&', '&amp;')
204
+ .replaceAll('<', '&lt;')
205
+ .replaceAll('>', '&gt;')
206
+ .replaceAll('"', '&quot;')
207
+ .replaceAll("'", '&#39;');
208
+ }
209
+
210
+ function escapeMarkdown(text: string): string {
211
+ return text.replace(/([\\`*_~[\]])/g, '\\$1');
212
+ }
213
+
214
+ function wrapInlineCode(text: string): string {
215
+ const matches = text.match(/`+/g);
216
+ const longestBacktickRun = matches?.reduce(
217
+ (max, match) => Math.max(max, match.length),
218
+ 0,
219
+ ) ?? 0;
220
+ const fence = '`'.repeat(longestBacktickRun + 1);
221
+
222
+ return `${fence}${text}${fence}`;
223
+ }