@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.
- package/.eslintrc.js +5 -0
- package/README.md +265 -0
- package/build/MarkdownRenderer.d.ts +12 -0
- package/build/MarkdownRenderer.d.ts.map +1 -0
- package/build/MarkdownRenderer.js +165 -0
- package/build/MarkdownRenderer.js.map +1 -0
- package/build/MarkdownTextInput.d.ts +10 -0
- package/build/MarkdownTextInput.d.ts.map +1 -0
- package/build/MarkdownTextInput.js +233 -0
- package/build/MarkdownTextInput.js.map +1 -0
- package/build/MarkdownToolbar.d.ts +11 -0
- package/build/MarkdownToolbar.d.ts.map +1 -0
- package/build/MarkdownToolbar.js +98 -0
- package/build/MarkdownToolbar.js.map +1 -0
- package/build/index.d.ts +14 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/markdownCore.types.d.ts +321 -0
- package/build/markdownCore.types.d.ts.map +1 -0
- package/build/markdownCore.types.js +2 -0
- package/build/markdownCore.types.js.map +1 -0
- package/build/markdownHighlight.d.ts +31 -0
- package/build/markdownHighlight.d.ts.map +1 -0
- package/build/markdownHighlight.js +378 -0
- package/build/markdownHighlight.js.map +1 -0
- package/build/markdownHighlight.types.d.ts +48 -0
- package/build/markdownHighlight.types.d.ts.map +1 -0
- package/build/markdownHighlight.types.js +9 -0
- package/build/markdownHighlight.types.js.map +1 -0
- package/build/markdownParser.d.ts +16 -0
- package/build/markdownParser.d.ts.map +1 -0
- package/build/markdownParser.js +309 -0
- package/build/markdownParser.js.map +1 -0
- package/build/markdownRendererDefaults.d.ts +113 -0
- package/build/markdownRendererDefaults.d.ts.map +1 -0
- package/build/markdownRendererDefaults.js +174 -0
- package/build/markdownRendererDefaults.js.map +1 -0
- package/build/markdownSegment.types.d.ts +22 -0
- package/build/markdownSegment.types.d.ts.map +1 -0
- package/build/markdownSegment.types.js +2 -0
- package/build/markdownSegment.types.js.map +1 -0
- package/build/markdownSegmentDefaults.d.ts +43 -0
- package/build/markdownSegmentDefaults.d.ts.map +1 -0
- package/build/markdownSegmentDefaults.js +176 -0
- package/build/markdownSegmentDefaults.js.map +1 -0
- package/build/markdownSyntaxUtils.d.ts +58 -0
- package/build/markdownSyntaxUtils.d.ts.map +1 -0
- package/build/markdownSyntaxUtils.js +98 -0
- package/build/markdownSyntaxUtils.js.map +1 -0
- package/build/markdownToolbarActions.d.ts +12 -0
- package/build/markdownToolbarActions.d.ts.map +1 -0
- package/build/markdownToolbarActions.js +212 -0
- package/build/markdownToolbarActions.js.map +1 -0
- package/build/useMarkdownEditor.d.ts +10 -0
- package/build/useMarkdownEditor.d.ts.map +1 -0
- package/build/useMarkdownEditor.js +219 -0
- package/build/useMarkdownEditor.js.map +1 -0
- package/jest.config.js +10 -0
- package/package.json +45 -0
- package/src/MarkdownRenderer.tsx +240 -0
- package/src/MarkdownTextInput.tsx +263 -0
- package/src/MarkdownToolbar.tsx +126 -0
- package/src/index.ts +31 -0
- package/src/markdownCore.types.ts +405 -0
- package/src/markdownHighlight.ts +413 -0
- package/src/markdownHighlight.types.ts +75 -0
- package/src/markdownParser.ts +345 -0
- package/src/markdownRendererDefaults.tsx +207 -0
- package/src/markdownSegment.types.ts +24 -0
- package/src/markdownSegmentDefaults.tsx +208 -0
- package/src/markdownSyntaxUtils.ts +139 -0
- package/src/markdownToolbarActions.ts +296 -0
- package/src/useMarkdownEditor.ts +265 -0
- package/tsconfig.json +9 -0
- 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