@domenico-esposito/react-native-markdown-editor 0.1.1

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 (76) hide show
  1. package/.eslintrc.js +5 -0
  2. package/README.md +265 -0
  3. package/build/MarkdownRenderer.d.ts +12 -0
  4. package/build/MarkdownRenderer.d.ts.map +1 -0
  5. package/build/MarkdownRenderer.js +165 -0
  6. package/build/MarkdownRenderer.js.map +1 -0
  7. package/build/MarkdownTextInput.d.ts +10 -0
  8. package/build/MarkdownTextInput.d.ts.map +1 -0
  9. package/build/MarkdownTextInput.js +233 -0
  10. package/build/MarkdownTextInput.js.map +1 -0
  11. package/build/MarkdownToolbar.d.ts +11 -0
  12. package/build/MarkdownToolbar.d.ts.map +1 -0
  13. package/build/MarkdownToolbar.js +98 -0
  14. package/build/MarkdownToolbar.js.map +1 -0
  15. package/build/index.d.ts +14 -0
  16. package/build/index.d.ts.map +1 -0
  17. package/build/index.js +11 -0
  18. package/build/index.js.map +1 -0
  19. package/build/markdownCore.types.d.ts +321 -0
  20. package/build/markdownCore.types.d.ts.map +1 -0
  21. package/build/markdownCore.types.js +2 -0
  22. package/build/markdownCore.types.js.map +1 -0
  23. package/build/markdownHighlight.d.ts +31 -0
  24. package/build/markdownHighlight.d.ts.map +1 -0
  25. package/build/markdownHighlight.js +378 -0
  26. package/build/markdownHighlight.js.map +1 -0
  27. package/build/markdownHighlight.types.d.ts +48 -0
  28. package/build/markdownHighlight.types.d.ts.map +1 -0
  29. package/build/markdownHighlight.types.js +9 -0
  30. package/build/markdownHighlight.types.js.map +1 -0
  31. package/build/markdownParser.d.ts +16 -0
  32. package/build/markdownParser.d.ts.map +1 -0
  33. package/build/markdownParser.js +309 -0
  34. package/build/markdownParser.js.map +1 -0
  35. package/build/markdownRendererDefaults.d.ts +113 -0
  36. package/build/markdownRendererDefaults.d.ts.map +1 -0
  37. package/build/markdownRendererDefaults.js +174 -0
  38. package/build/markdownRendererDefaults.js.map +1 -0
  39. package/build/markdownSegment.types.d.ts +22 -0
  40. package/build/markdownSegment.types.d.ts.map +1 -0
  41. package/build/markdownSegment.types.js +2 -0
  42. package/build/markdownSegment.types.js.map +1 -0
  43. package/build/markdownSegmentDefaults.d.ts +43 -0
  44. package/build/markdownSegmentDefaults.d.ts.map +1 -0
  45. package/build/markdownSegmentDefaults.js +176 -0
  46. package/build/markdownSegmentDefaults.js.map +1 -0
  47. package/build/markdownSyntaxUtils.d.ts +58 -0
  48. package/build/markdownSyntaxUtils.d.ts.map +1 -0
  49. package/build/markdownSyntaxUtils.js +98 -0
  50. package/build/markdownSyntaxUtils.js.map +1 -0
  51. package/build/markdownToolbarActions.d.ts +12 -0
  52. package/build/markdownToolbarActions.d.ts.map +1 -0
  53. package/build/markdownToolbarActions.js +212 -0
  54. package/build/markdownToolbarActions.js.map +1 -0
  55. package/build/useMarkdownEditor.d.ts +10 -0
  56. package/build/useMarkdownEditor.d.ts.map +1 -0
  57. package/build/useMarkdownEditor.js +219 -0
  58. package/build/useMarkdownEditor.js.map +1 -0
  59. package/jest.config.js +10 -0
  60. package/package.json +45 -0
  61. package/src/MarkdownRenderer.tsx +240 -0
  62. package/src/MarkdownTextInput.tsx +263 -0
  63. package/src/MarkdownToolbar.tsx +126 -0
  64. package/src/index.ts +31 -0
  65. package/src/markdownCore.types.ts +405 -0
  66. package/src/markdownHighlight.ts +413 -0
  67. package/src/markdownHighlight.types.ts +75 -0
  68. package/src/markdownParser.ts +345 -0
  69. package/src/markdownRendererDefaults.tsx +207 -0
  70. package/src/markdownSegment.types.ts +24 -0
  71. package/src/markdownSegmentDefaults.tsx +208 -0
  72. package/src/markdownSyntaxUtils.ts +139 -0
  73. package/src/markdownToolbarActions.ts +296 -0
  74. package/src/useMarkdownEditor.ts +265 -0
  75. package/tsconfig.json +9 -0
  76. package/tsconfig.test.json +8 -0
@@ -0,0 +1,265 @@
1
+ import * as React from 'react';
2
+ import { Platform, TextInput } from 'react-native';
3
+
4
+ import type {
5
+ MarkdownEditorHandle,
6
+ MarkdownImageInfo,
7
+ MarkdownInlineToolbarAction,
8
+ MarkdownSelection,
9
+ MarkdownToolbarAction,
10
+ UseMarkdownEditorOptions,
11
+ } from './markdownCore.types';
12
+ import { highlightMarkdown } from './markdownHighlight';
13
+ import { applyMarkdownToolbarAction, DEFAULT_MARKDOWN_FEATURES } from './markdownToolbarActions';
14
+
15
+ // Default selection state (cursor at position 0)
16
+ const DEFAULT_SELECTION: MarkdownSelection = { start: 0, end: 0 };
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Hook
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Hook that manages the shared state between MarkdownTextInput and MarkdownToolbar.
24
+ *
25
+ * Returns a `MarkdownEditorHandle` object to pass as `editor` prop to both components.
26
+ * Handles selection tracking, active inline actions, toolbar action application,
27
+ * and syntax highlighting.
28
+ */
29
+ export function useMarkdownEditor({ value, onChangeText, onSelectionChange, onToolbarAction, features }: UseMarkdownEditorOptions): MarkdownEditorHandle {
30
+ // Reference to the TextInput component for programmatic focus and selection
31
+ const inputRef = React.useRef<TextInput>(null);
32
+
33
+ // Resolve features with default fallback
34
+ const resolvedFeatures = features ?? DEFAULT_MARKDOWN_FEATURES;
35
+
36
+ // Ref to track the current selection (synchronous access needed for event handlers)
37
+ const selectionRef = React.useRef<MarkdownSelection>(DEFAULT_SELECTION);
38
+
39
+ // State for current text selection (triggers re-renders when updated)
40
+ const [selection, setSelection] = React.useState<MarkdownSelection>(DEFAULT_SELECTION);
41
+
42
+ // Track which inline toolbar actions are currently active (e.g., bold, italic)
43
+ const [activeInlineActions, setActiveInlineActions] = React.useState<MarkdownInlineToolbarAction[]>([]);
44
+
45
+ // Flag to suppress selection change events (used during Android toolbar actions)
46
+ const suppressSelectionRef = React.useRef(false);
47
+
48
+ // Keep a ref to the current value for synchronous access in callbacks
49
+ const valueRef = React.useRef(value);
50
+ valueRef.current = value;
51
+
52
+ /**
53
+ * Android workaround: forces cursor position after programmatic text changes.
54
+ * Android fires spurious native onSelectionChange events that override the
55
+ * desired cursor position - this suppresses them and applies the selection
56
+ * via setNativeProps across sequential animation frames.
57
+ */
58
+ const applyAndroidSelection = React.useCallback((targetSelection: MarkdownSelection) => {
59
+ if (Platform.OS !== 'android' || !inputRef.current) return;
60
+
61
+ suppressSelectionRef.current = true;
62
+ requestAnimationFrame(() => {
63
+ inputRef.current?.focus();
64
+ requestAnimationFrame(() => {
65
+ if (inputRef.current) {
66
+ inputRef.current.setNativeProps({
67
+ selection: targetSelection,
68
+ });
69
+ }
70
+ requestAnimationFrame(() => {
71
+ suppressSelectionRef.current = false;
72
+ });
73
+ });
74
+ });
75
+ }, []);
76
+
77
+ // Parse and highlight the markdown text for syntax highlighting
78
+ const highlightedSegments = React.useMemo(() => highlightMarkdown(value, resolvedFeatures), [value, resolvedFeatures]);
79
+
80
+ /**
81
+ * Detects if the cursor is currently positioned within an image markdown syntax.
82
+ * Returns image metadata if found, null otherwise.
83
+ */
84
+ const activeImageInfo = React.useMemo<MarkdownImageInfo | null>(() => {
85
+ const selStart = selection.start;
86
+ const selEnd = selection.end;
87
+
88
+ // Search through highlighted segments to find if cursor/selection overlaps an image
89
+ let offset = 0;
90
+ for (const seg of highlightedSegments) {
91
+ const segEnd = offset + seg.text.length;
92
+ if (selStart < segEnd && selEnd > offset && seg.type === 'image' && seg.meta?.src) {
93
+ return {
94
+ src: seg.meta.src,
95
+ alt: seg.meta.alt ?? '',
96
+ title: seg.meta.title,
97
+ start: offset,
98
+ end: segEnd,
99
+ };
100
+ }
101
+ offset = segEnd;
102
+ }
103
+ return null;
104
+ }, [selection, highlightedSegments]);
105
+
106
+ /**
107
+ * Handles text changes in the editor.
108
+ */
109
+ const handleChangeText = React.useCallback(
110
+ (nextValue: string) => {
111
+ if (nextValue === valueRef.current) return;
112
+ onChangeText(nextValue);
113
+ },
114
+ [onChangeText],
115
+ );
116
+
117
+ /**
118
+ * Handles selection changes in the text input.
119
+ * Suppressed during Android toolbar actions to prevent spurious events.
120
+ */
121
+ const handleSelectionChange = React.useCallback(
122
+ (event: { nativeEvent: { selection: MarkdownSelection } }) => {
123
+ // Ignore selection changes when suppression flag is set (Android toolbar actions)
124
+ if (suppressSelectionRef.current) return;
125
+
126
+ const nextSelection = event.nativeEvent.selection;
127
+ selectionRef.current = nextSelection;
128
+ setSelection(nextSelection);
129
+ onSelectionChange?.(nextSelection);
130
+ },
131
+ [onSelectionChange],
132
+ );
133
+
134
+ /**
135
+ * Handles toolbar button actions (bold, italic, heading, etc.).
136
+ * Applies the markdown transformation and manages focus/selection.
137
+ */
138
+ const handleToolbarAction = React.useCallback(
139
+ (action: MarkdownToolbarAction) => {
140
+ const currentSelection = selectionRef.current;
141
+
142
+ // Apply the markdown transformation based on the action
143
+ const result = applyMarkdownToolbarAction({
144
+ action,
145
+ text: value,
146
+ selection: currentSelection,
147
+ activeInlineActions,
148
+ });
149
+
150
+ // Update state with the transformation result
151
+ selectionRef.current = result.selection;
152
+ setSelection(result.selection);
153
+ setActiveInlineActions(result.activeInlineActions);
154
+ onChangeText(result.text);
155
+ onSelectionChange?.(result.selection);
156
+ onToolbarAction?.(action, result);
157
+
158
+ // Android-specific workaround for selection handling
159
+ if (Platform.OS === 'android') {
160
+ applyAndroidSelection(result.selection);
161
+ } else {
162
+ // iOS: Simply refocus the input
163
+ inputRef.current?.focus();
164
+ }
165
+ },
166
+ [activeInlineActions, onChangeText, onSelectionChange, onToolbarAction, value, applyAndroidSelection],
167
+ );
168
+
169
+ // State for the image info modal/popup
170
+ const [imageInfo, setImageInfo] = React.useState<MarkdownImageInfo | null>(null);
171
+
172
+ /**
173
+ * Opens the image info popup for the currently active image.
174
+ */
175
+ const openImageInfo = React.useCallback(() => {
176
+ if (activeImageInfo) setImageInfo(activeImageInfo);
177
+ }, [activeImageInfo]);
178
+
179
+ /**
180
+ * Closes the image info popup.
181
+ */
182
+ const dismissImageInfo = React.useCallback(() => {
183
+ setImageInfo(null);
184
+ }, []);
185
+
186
+ /**
187
+ * Deletes the currently active image from the markdown text.
188
+ * Also removes the trailing newline if present.
189
+ */
190
+ const deleteActiveImage = React.useCallback(() => {
191
+ if (!activeImageInfo) return;
192
+
193
+ const before = value.slice(0, activeImageInfo.start);
194
+ const after = value.slice(activeImageInfo.end);
195
+
196
+ // Remove trailing newline if the image is followed by one
197
+ const nextValue = after.startsWith('\n') ? before + after.slice(1) : before + after;
198
+
199
+ // Position cursor at the start of where the image was
200
+ const cursor = activeImageInfo.start;
201
+ selectionRef.current = { start: cursor, end: cursor };
202
+ setSelection({ start: cursor, end: cursor });
203
+ setImageInfo(null);
204
+ onChangeText(nextValue);
205
+ }, [activeImageInfo, value, onChangeText]);
206
+
207
+ /**
208
+ * Effect: Clamp selection to valid range when text value shrinks.
209
+ * Prevents selection indices from being out of bounds.
210
+ */
211
+ React.useEffect(() => {
212
+ const currentSelection = selectionRef.current;
213
+
214
+ // Check if current selection is still valid
215
+ if (currentSelection.start <= value.length && currentSelection.end <= value.length) {
216
+ return;
217
+ }
218
+
219
+ // Clamp selection to the maximum valid position
220
+ const safeSelection = {
221
+ start: Math.min(currentSelection.start, value.length),
222
+ end: Math.min(currentSelection.end, value.length),
223
+ };
224
+ selectionRef.current = safeSelection;
225
+ setSelection(safeSelection);
226
+ }, [value.length]);
227
+
228
+ /**
229
+ * Return the editor handle object.
230
+ * Memoized to maintain stable reference across renders.
231
+ */
232
+ return React.useMemo(
233
+ () => ({
234
+ features: resolvedFeatures,
235
+ value,
236
+ selection,
237
+ activeInlineActions,
238
+ activeImageInfo,
239
+ imageInfo,
240
+ openImageInfo,
241
+ dismissImageInfo,
242
+ highlightedSegments,
243
+ inputRef,
244
+ handleChangeText,
245
+ handleSelectionChange,
246
+ handleToolbarAction,
247
+ deleteActiveImage,
248
+ }),
249
+ [
250
+ resolvedFeatures,
251
+ value,
252
+ selection,
253
+ activeInlineActions,
254
+ activeImageInfo,
255
+ imageInfo,
256
+ openImageInfo,
257
+ dismissImageInfo,
258
+ highlightedSegments,
259
+ handleChangeText,
260
+ handleSelectionChange,
261
+ handleToolbarAction,
262
+ deleteActiveImage,
263
+ ],
264
+ );
265
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["jest"]
5
+ },
6
+ "include": ["./src"],
7
+ "exclude": []
8
+ }