react-native-richify 1.0.4 → 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 (74) 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 +134 -15
  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 +55 -1
  8. package/lib/commonjs/constants/defaultStyles.js.map +1 -1
  9. package/lib/commonjs/hooks/useFormatting.js +40 -2
  10. package/lib/commonjs/hooks/useFormatting.js.map +1 -1
  11. package/lib/commonjs/hooks/useRichText.js +75 -6
  12. package/lib/commonjs/hooks/useRichText.js.map +1 -1
  13. package/lib/commonjs/utils/formatter.js +48 -9
  14. package/lib/commonjs/utils/formatter.js.map +1 -1
  15. package/lib/commonjs/utils/parser.js +1 -1
  16. package/lib/commonjs/utils/parser.js.map +1 -1
  17. package/lib/commonjs/utils/serializer.js +102 -6
  18. package/lib/commonjs/utils/serializer.js.map +1 -1
  19. package/lib/commonjs/utils/styleMapper.js +11 -0
  20. package/lib/commonjs/utils/styleMapper.js.map +1 -1
  21. package/lib/module/components/RenderedOutput.js +163 -0
  22. package/lib/module/components/RenderedOutput.js.map +1 -0
  23. package/lib/module/components/RichTextInput.js +135 -16
  24. package/lib/module/components/RichTextInput.js.map +1 -1
  25. package/lib/module/components/Toolbar.js +41 -2
  26. package/lib/module/components/Toolbar.js.map +1 -1
  27. package/lib/module/constants/defaultStyles.js +55 -1
  28. package/lib/module/constants/defaultStyles.js.map +1 -1
  29. package/lib/module/hooks/useFormatting.js +41 -3
  30. package/lib/module/hooks/useFormatting.js.map +1 -1
  31. package/lib/module/hooks/useRichText.js +75 -6
  32. package/lib/module/hooks/useRichText.js.map +1 -1
  33. package/lib/module/utils/formatter.js +46 -9
  34. package/lib/module/utils/formatter.js.map +1 -1
  35. package/lib/module/utils/parser.js +1 -1
  36. package/lib/module/utils/parser.js.map +1 -1
  37. package/lib/module/utils/serializer.js +102 -6
  38. package/lib/module/utils/serializer.js.map +1 -1
  39. package/lib/module/utils/styleMapper.js +11 -0
  40. package/lib/module/utils/styleMapper.js.map +1 -1
  41. package/lib/typescript/src/components/RenderedOutput.d.ts +9 -0
  42. package/lib/typescript/src/components/RenderedOutput.d.ts.map +1 -0
  43. package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
  44. package/lib/typescript/src/components/Toolbar.d.ts.map +1 -1
  45. package/lib/typescript/src/constants/defaultStyles.d.ts +1 -0
  46. package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
  47. package/lib/typescript/src/hooks/useFormatting.d.ts +4 -1
  48. package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
  49. package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
  50. package/lib/typescript/src/index.d.ts +1 -1
  51. package/lib/typescript/src/index.d.ts.map +1 -1
  52. package/lib/typescript/src/types/index.d.ts +94 -1
  53. package/lib/typescript/src/types/index.d.ts.map +1 -1
  54. package/lib/typescript/src/utils/formatter.d.ts +9 -1
  55. package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
  56. package/lib/typescript/src/utils/parser.d.ts.map +1 -1
  57. package/lib/typescript/src/utils/serializer.d.ts.map +1 -1
  58. package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -1
  59. package/package.json +1 -1
  60. package/src/components/RenderedOutput.tsx +231 -0
  61. package/src/components/RichTextInput.tsx +197 -19
  62. package/src/components/Toolbar.tsx +54 -2
  63. package/src/constants/defaultStyles.d.ts +2 -1
  64. package/src/constants/defaultStyles.ts +21 -0
  65. package/src/hooks/useFormatting.ts +76 -2
  66. package/src/hooks/useRichText.ts +101 -5
  67. package/src/index.d.ts +1 -1
  68. package/src/index.ts +4 -0
  69. package/src/types/index.d.ts +94 -1
  70. package/src/types/index.ts +104 -1
  71. package/src/utils/formatter.ts +60 -6
  72. package/src/utils/parser.ts +6 -1
  73. package/src/utils/serializer.ts +150 -8
  74. package/src/utils/styleMapper.ts +21 -0
@@ -1,4 +1,10 @@
1
- import React, { useEffect, useCallback, useMemo, useRef } from 'react';
1
+ import React, {
2
+ useEffect,
3
+ useCallback,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
  import {
3
9
  Animated,
4
10
  Easing,
@@ -8,6 +14,7 @@ import {
8
14
  TextInput,
9
15
  View,
10
16
  type NativeSyntheticEvent,
17
+ type TextInputContentSizeChangeEventData,
11
18
  type TextInputSelectionChangeEventData,
12
19
  } from 'react-native';
13
20
  import type { RichTextInputProps } from '../types';
@@ -15,9 +22,10 @@ import { DEFAULT_THEME } from '../constants/defaultStyles';
15
22
  import { useRichText } from '../hooks/useRichText';
16
23
  import { segmentsToPlainText } from '../utils/parser';
17
24
  import { serializeSegments } from '../utils/serializer';
25
+ import { RenderedOutput } from './RenderedOutput';
18
26
  import { Toolbar } from './Toolbar';
19
27
 
20
- const OUTPUT_PANEL_HEIGHT = 180;
28
+ const DEFAULT_OUTPUT_PANEL_MAX_HEIGHT = 180;
21
29
  const isJestRuntime =
22
30
  typeof (
23
31
  globalThis as {
@@ -43,8 +51,16 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
43
51
  toolbarItems,
44
52
  theme,
45
53
  showOutputPreview = true,
46
- outputFormat = 'markdown',
54
+ outputFormat,
55
+ defaultOutputFormat = 'markdown',
56
+ outputPreviewMode,
57
+ defaultOutputPreviewMode = 'literal',
58
+ maxOutputHeight = DEFAULT_OUTPUT_PANEL_MAX_HEIGHT,
47
59
  onChangeOutput,
60
+ onChangeOutputFormat,
61
+ onChangeOutputPreviewMode,
62
+ onRequestLink,
63
+ onRequestImage,
48
64
  multiline = true,
49
65
  minHeight = 120,
50
66
  maxHeight,
@@ -55,6 +71,12 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
55
71
  }) => {
56
72
  const resolvedTheme = theme ?? DEFAULT_THEME;
57
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);
58
80
 
59
81
  const { state, actions } = useRichText({
60
82
  initialSegments,
@@ -67,15 +89,41 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
67
89
  }, [actions, onReady]);
68
90
 
69
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;
70
103
  const serializedOutput = useMemo(
71
- () => serializeSegments(state.segments, outputFormat),
72
- [outputFormat, state.segments],
104
+ () => serializeSegments(state.segments, resolvedOutputFormat),
105
+ [resolvedOutputFormat, state.segments],
73
106
  );
74
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
+ );
75
123
 
76
124
  useEffect(() => {
77
- onChangeOutput?.(serializedOutput, outputFormat);
78
- }, [onChangeOutput, outputFormat, serializedOutput]);
125
+ onChangeOutput?.(serializedOutput, resolvedOutputFormat);
126
+ }, [onChangeOutput, resolvedOutputFormat, serializedOutput]);
79
127
 
80
128
  useEffect(() => {
81
129
  if (isJestRuntime) {
@@ -99,30 +147,108 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
99
147
  [actions],
100
148
  );
101
149
 
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
+
102
227
  const containerStyle = [
103
228
  resolvedTheme.containerStyle ?? DEFAULT_THEME.containerStyle,
104
229
  ];
105
- const inputAreaStyle = [
106
- styles.inputArea,
107
- { minHeight },
108
- maxHeight ? { maxHeight } : undefined,
109
- ];
230
+ const inputAreaStyle = [styles.inputArea];
110
231
  const inputStyle = [
111
232
  styles.textInput,
112
233
  resolvedTheme.baseTextStyle ?? DEFAULT_THEME.baseTextStyle,
113
234
  resolvedTheme.inputStyle ?? DEFAULT_THEME.inputStyle,
235
+ { height: inputHeight },
114
236
  textInputProps?.style,
115
237
  ];
116
238
  const outputAnimatedStyle = {
117
239
  maxHeight: previewProgress.interpolate({
118
240
  inputRange: [0, 1],
119
- outputRange: [0, OUTPUT_PANEL_HEIGHT],
241
+ outputRange: [0, maxOutputHeight + 72],
120
242
  }),
121
243
  opacity: previewProgress,
122
244
  marginTop: previewProgress.interpolate({
123
245
  inputRange: [0, 1],
124
246
  outputRange: [0, 12],
125
247
  }),
248
+ marginBottom: previewProgress.interpolate({
249
+ inputRange: [0, 1],
250
+ outputRange: [0, 16],
251
+ }),
126
252
  transform: [
127
253
  {
128
254
  translateY: previewProgress.interpolate({
@@ -148,6 +274,12 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
148
274
  state={state}
149
275
  items={toolbarItems}
150
276
  theme={resolvedTheme}
277
+ outputFormat={resolvedOutputFormat}
278
+ outputPreviewMode={resolvedOutputPreviewMode}
279
+ onOutputFormatChange={handleOutputFormatChange}
280
+ onOutputPreviewModeChange={handleOutputPreviewModeChange}
281
+ onRequestLink={handleRequestLink}
282
+ onRequestImage={handleRequestImage}
151
283
  renderToolbar={renderToolbar}
152
284
  />
153
285
  ) : null;
@@ -180,6 +312,7 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
180
312
  value={plainText}
181
313
  onChangeText={actions.handleTextChange}
182
314
  onSelectionChange={onSelectionChange}
315
+ onContentSizeChange={onContentSizeChange}
183
316
  multiline={multiline}
184
317
  placeholder={placeholder}
185
318
  placeholderTextColor={
@@ -193,7 +326,7 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
193
326
  resolvedTheme.colors?.cursor ?? DEFAULT_THEME.colors?.cursor
194
327
  }
195
328
  textAlignVertical="top"
196
- scrollEnabled={typeof maxHeight === 'number'}
329
+ scrollEnabled={shouldScrollInput}
197
330
  />
198
331
 
199
332
  {showOutputPreview && (
@@ -203,12 +336,22 @@ export const RichTextInput: React.FC<RichTextInputProps> = ({
203
336
  >
204
337
  <View style={outputContainerStyle}>
205
338
  <Text style={outputLabelStyle}>
206
- {outputFormat === 'html' ? 'HTML output' : 'Markdown output'}
339
+ {outputLabel}
207
340
  </Text>
208
- <ScrollView showsVerticalScrollIndicator={false}>
209
- <Text selectable style={outputTextStyle}>
210
- {serializedOutput}
211
- </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
+ )}
212
355
  </ScrollView>
213
356
  </View>
214
357
  </Animated.View>
@@ -235,3 +378,38 @@ const styles = StyleSheet.create({
235
378
  overflow: 'hidden',
236
379
  },
237
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
  />
@@ -11,6 +11,7 @@ export declare const DEFAULT_COLORS: {
11
11
  readonly toolbarBorder: "#E5E7EB";
12
12
  readonly outputBackground: "#F8FAFC";
13
13
  readonly outputLabel: "#475569";
14
+ readonly link: "#2563EB";
14
15
  readonly cursor: "#6366F1";
15
16
  readonly activeButtonBg: "#EEF2FF";
16
17
  readonly codeBackground: "#F3F4F6";
@@ -34,7 +35,7 @@ export declare const DEFAULT_BASE_TEXT_STYLE: {
34
35
  readonly fontFamily: undefined;
35
36
  };
36
37
  /**
37
- * Empty format style — no formatting applied.
38
+ * Empty format style no formatting applied.
38
39
  */
39
40
  export declare const EMPTY_FORMAT_STYLE: FormatStyle;
40
41
  /**
@@ -12,6 +12,7 @@ export const DEFAULT_COLORS = {
12
12
  toolbarBorder: '#E5E7EB',
13
13
  outputBackground: '#F8FAFC',
14
14
  outputLabel: '#475569',
15
+ link: '#2563EB',
15
16
  cursor: '#6366F1',
16
17
  activeButtonBg: '#EEF2FF',
17
18
  codeBackground: '#F3F4F6',
@@ -50,6 +51,11 @@ export const EMPTY_FORMAT_STYLE: FormatStyle = {
50
51
  backgroundColor: undefined,
51
52
  fontSize: undefined,
52
53
  heading: undefined,
54
+ listType: undefined,
55
+ textAlign: undefined,
56
+ link: undefined,
57
+ imageSrc: undefined,
58
+ imageAlt: undefined,
53
59
  };
54
60
 
55
61
  /**
@@ -108,6 +114,9 @@ export const DEFAULT_THEME: RichTextTheme = {
108
114
  color: DEFAULT_COLORS.text,
109
115
  fontFamily: 'monospace',
110
116
  },
117
+ renderedOutputStyle: {
118
+ gap: 10,
119
+ },
111
120
  toolbarStyle: {
112
121
  flexDirection: 'row',
113
122
  alignItems: 'center',
@@ -150,6 +159,7 @@ export const DEFAULT_THEME: RichTextTheme = {
150
159
  placeholder: DEFAULT_COLORS.placeholder,
151
160
  toolbarBackground: DEFAULT_COLORS.toolbarBackground,
152
161
  toolbarBorder: DEFAULT_COLORS.toolbarBorder,
162
+ link: DEFAULT_COLORS.link,
153
163
  cursor: DEFAULT_COLORS.cursor,
154
164
  },
155
165
  };
@@ -166,4 +176,15 @@ export const DEFAULT_TOOLBAR_ITEMS: ToolbarItem[] = [
166
176
  { id: 'h1', label: 'H1', heading: 'h1' },
167
177
  { id: 'h2', label: 'H2', heading: 'h2' },
168
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' },
169
190
  ];
@@ -4,12 +4,16 @@ import type {
4
4
  FormatType,
5
5
  FormatStyle,
6
6
  HeadingLevel,
7
+ ListType,
7
8
  SelectionRange,
9
+ TextAlign,
8
10
  } from '../types';
9
11
  import {
10
12
  toggleFormatOnSelection,
11
13
  setStyleOnSelection,
12
14
  setHeadingOnLine,
15
+ setListTypeOnLine,
16
+ setTextAlignOnLine,
13
17
  isFormatActiveInSelection,
14
18
  getSelectionStyle,
15
19
  } from '../utils/formatter';
@@ -78,14 +82,66 @@ export function useFormatting({
78
82
 
79
83
  const setHeading = useCallback(
80
84
  (level: HeadingLevel) => {
85
+ const currentHeading =
86
+ selection.start === selection.end
87
+ ? getSelectionStyle(segments, selection).heading ?? activeStyles.heading
88
+ : getSelectionStyle(segments, selection).heading;
89
+ const nextHeading: HeadingLevel =
90
+ currentHeading === level ? 'none' : level;
91
+
92
+ if (selection.start === selection.end) {
93
+ onActiveStylesChange({
94
+ ...activeStyles,
95
+ heading: nextHeading === 'none' ? undefined : nextHeading,
96
+ listType:
97
+ nextHeading === 'none' ? activeStyles.listType : undefined,
98
+ });
99
+ }
100
+
101
+ const newSegments = setHeadingOnLine(segments, selection, nextHeading);
102
+ onSegmentsChange(newSegments);
103
+ },
104
+ [
105
+ activeStyles,
106
+ onActiveStylesChange,
107
+ onSegmentsChange,
108
+ segments,
109
+ selection,
110
+ ],
111
+ );
112
+
113
+ const setListType = useCallback(
114
+ (listType: ListType) => {
115
+ if (selection.start === selection.end) {
116
+ onActiveStylesChange({
117
+ ...activeStyles,
118
+ listType: listType === 'none' ? undefined : listType,
119
+ heading: listType === 'none' ? activeStyles.heading : undefined,
120
+ });
121
+ }
122
+
123
+ const newSegments = setListTypeOnLine(segments, selection, listType);
124
+ onSegmentsChange(newSegments);
125
+ },
126
+ [
127
+ activeStyles,
128
+ onActiveStylesChange,
129
+ onSegmentsChange,
130
+ segments,
131
+ selection,
132
+ ],
133
+ );
134
+
135
+ const setTextAlign = useCallback(
136
+ (textAlign: TextAlign) => {
81
137
  if (selection.start === selection.end) {
82
138
  onActiveStylesChange({
83
139
  ...activeStyles,
84
- heading: level === 'none' ? undefined : level,
140
+ textAlign,
85
141
  });
86
142
  }
87
143
 
88
- const newSegments = setHeadingOnLine(segments, selection, level);
144
+ const newSegments = setTextAlignOnLine(segments, selection, textAlign);
89
145
  onSegmentsChange(newSegments);
90
146
  },
91
147
  [
@@ -97,6 +153,21 @@ export function useFormatting({
97
153
  ],
98
154
  );
99
155
 
156
+ const setLink = useCallback(
157
+ (url?: string) => {
158
+ if (selection.start === selection.end) {
159
+ onActiveStylesChange({
160
+ ...activeStyles,
161
+ link: url,
162
+ });
163
+ } else {
164
+ const newSegments = setStyleOnSelection(segments, selection, 'link', url);
165
+ onSegmentsChange(newSegments);
166
+ }
167
+ },
168
+ [segments, selection, activeStyles, onSegmentsChange, onActiveStylesChange],
169
+ );
170
+
100
171
  const setColor = useCallback(
101
172
  (color: string) => {
102
173
  setStyleProperty('color', color);
@@ -139,6 +210,9 @@ export function useFormatting({
139
210
  toggleFormat,
140
211
  setStyleProperty,
141
212
  setHeading,
213
+ setListType,
214
+ setTextAlign,
215
+ setLink,
142
216
  setColor,
143
217
  setBackgroundColor,
144
218
  setFontSize,