@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,240 @@
1
+ /**
2
+ * Read-only markdown renderer component.
3
+ *
4
+ * Parses a markdown string into an AST and maps each node to a
5
+ * React Native component. Every tag can be overridden via the
6
+ * `components` prop; unspecified tags fall back to defaults
7
+ * defined in `markdownRendererDefaults.tsx`.
8
+ */
9
+
10
+ import * as React from 'react';
11
+
12
+ import type {
13
+ MarkdownComponentMap,
14
+ MarkdownInlineNode,
15
+ MarkdownRendererProps,
16
+ } from './markdownCore.types';
17
+ import { parseMarkdown } from './markdownParser';
18
+ import { DEFAULT_COMPONENTS } from './markdownRendererDefaults';
19
+
20
+ export default function MarkdownRenderer({ markdown, components, style, features }: MarkdownRendererProps) {
21
+ const blocks = React.useMemo(() => parseMarkdown(markdown, features), [markdown, features]);
22
+ const componentMap: MarkdownComponentMap = React.useMemo(
23
+ () => ({
24
+ ...DEFAULT_COMPONENTS,
25
+ ...components,
26
+ }),
27
+ [components],
28
+ );
29
+
30
+ const RootComponent = componentMap.root;
31
+
32
+ return (
33
+ <RootComponent type="root" style={style}>
34
+ {blocks.map((block, blockIndex) => {
35
+ const key = `block-${blockIndex}`;
36
+
37
+ if (block.type === 'paragraph') {
38
+ if (block.children.length === 1 && block.children[0]?.type === 'image') {
39
+ const imageNode = block.children[0];
40
+ const ImageComponent = componentMap.image;
41
+ return <ImageComponent key={key} type="image" src={imageNode.src} alt={imageNode.alt} title={imageNode.title} />;
42
+ }
43
+
44
+ const ParagraphComponent = componentMap.paragraph;
45
+ return (
46
+ <ParagraphComponent key={key} type="paragraph">
47
+ {renderInlineNodes(block.children, componentMap, key)}
48
+ </ParagraphComponent>
49
+ );
50
+ }
51
+
52
+ if (block.type === 'heading') {
53
+ if (block.level === 1) {
54
+ const HeadingComponent = componentMap.heading1;
55
+ return (
56
+ <HeadingComponent key={key} type="heading1" level={1}>
57
+ {renderInlineNodes(block.children, componentMap, key)}
58
+ </HeadingComponent>
59
+ );
60
+ }
61
+
62
+ if (block.level === 2) {
63
+ const HeadingComponent = componentMap.heading2;
64
+ return (
65
+ <HeadingComponent key={key} type="heading2" level={2}>
66
+ {renderInlineNodes(block.children, componentMap, key)}
67
+ </HeadingComponent>
68
+ );
69
+ }
70
+
71
+ if (block.level === 3) {
72
+ const HeadingComponent = componentMap.heading3;
73
+ return (
74
+ <HeadingComponent key={key} type="heading3" level={3}>
75
+ {renderInlineNodes(block.children, componentMap, key)}
76
+ </HeadingComponent>
77
+ );
78
+ }
79
+
80
+ if (block.level === 4) {
81
+ const HeadingComponent = componentMap.heading4;
82
+ return (
83
+ <HeadingComponent key={key} type="heading4" level={4}>
84
+ {renderInlineNodes(block.children, componentMap, key)}
85
+ </HeadingComponent>
86
+ );
87
+ }
88
+
89
+ if (block.level === 5) {
90
+ const HeadingComponent = componentMap.heading5;
91
+ return (
92
+ <HeadingComponent key={key} type="heading5" level={5}>
93
+ {renderInlineNodes(block.children, componentMap, key)}
94
+ </HeadingComponent>
95
+ );
96
+ }
97
+
98
+ const HeadingComponent = componentMap.heading6;
99
+ return (
100
+ <HeadingComponent key={key} type="heading6" level={6}>
101
+ {renderInlineNodes(block.children, componentMap, key)}
102
+ </HeadingComponent>
103
+ );
104
+ }
105
+
106
+ if (block.type === 'codeBlock') {
107
+ const CodeBlockComponent = componentMap.codeBlock;
108
+ return <CodeBlockComponent key={key} type="codeBlock" text={block.content} language={block.language} />;
109
+ }
110
+
111
+ if (block.type === 'blockquote') {
112
+ const BlockquoteComponent = componentMap.blockquote;
113
+ return (
114
+ <BlockquoteComponent key={key} type="blockquote">
115
+ {renderInlineNodes(block.children, componentMap, key)}
116
+ </BlockquoteComponent>
117
+ );
118
+ }
119
+
120
+ if (block.type === 'horizontalRule') {
121
+ const HorizontalRuleComponent = componentMap.horizontalRule;
122
+ return <HorizontalRuleComponent key={key} type="horizontalRule" />;
123
+ }
124
+
125
+ if (block.type === 'spacer') {
126
+ const SpacerComponent = componentMap.spacer;
127
+ return <SpacerComponent key={key} type="spacer" />;
128
+ }
129
+
130
+ const ListItemComponent = componentMap.listItem;
131
+ const TextComponent = componentMap.text;
132
+
133
+ if (block.ordered) {
134
+ const OrderedListComponent = componentMap.orderedList;
135
+ return (
136
+ <OrderedListComponent key={key} type="orderedList" ordered>
137
+ {block.items.map((item, itemIndex) => {
138
+ const marker = `${itemIndex + 1}. `;
139
+
140
+ return (
141
+ <ListItemComponent key={`${key}-item-${itemIndex}`} type="listItem" ordered index={itemIndex}>
142
+ <TextComponent type="text" text={marker}>
143
+ {marker}
144
+ {renderInlineNodes(item, componentMap, `${key}-item-${itemIndex}`)}
145
+ </TextComponent>
146
+ </ListItemComponent>
147
+ );
148
+ })}
149
+ </OrderedListComponent>
150
+ );
151
+ }
152
+
153
+ const UnorderedListComponent = componentMap.unorderedList;
154
+ return (
155
+ <UnorderedListComponent key={key} type="unorderedList" ordered={false}>
156
+ {block.items.map((item, itemIndex) => {
157
+ const marker = '- ';
158
+
159
+ return (
160
+ <ListItemComponent key={`${key}-item-${itemIndex}`} type="listItem" ordered={false} index={itemIndex}>
161
+ <TextComponent type="text" text={marker}>
162
+ {marker}
163
+ {renderInlineNodes(item, componentMap, `${key}-item-${itemIndex}`)}
164
+ </TextComponent>
165
+ </ListItemComponent>
166
+ );
167
+ })}
168
+ </UnorderedListComponent>
169
+ );
170
+ })}
171
+ </RootComponent>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Recursively renders an array of inline markdown nodes into React elements.
177
+ * Each node type is mapped to its corresponding component from the component map.
178
+ */
179
+ function renderInlineNodes(nodes: MarkdownInlineNode[], components: MarkdownComponentMap, keyPrefix: string): React.ReactNode[] {
180
+ return nodes.map((node, index) => {
181
+ const key = `${keyPrefix}-inline-${index}`;
182
+
183
+ if (node.type === 'text') {
184
+ const TextComponent = components.text;
185
+ return (
186
+ <TextComponent key={key} type="text" text={node.content}>
187
+ {node.content}
188
+ </TextComponent>
189
+ );
190
+ }
191
+
192
+ if (node.type === 'bold') {
193
+ const BoldComponent = components.bold;
194
+ return (
195
+ <BoldComponent key={key} type="bold">
196
+ {renderInlineNodes(node.children, components, key)}
197
+ </BoldComponent>
198
+ );
199
+ }
200
+
201
+ if (node.type === 'italic') {
202
+ const ItalicComponent = components.italic;
203
+ return (
204
+ <ItalicComponent key={key} type="italic">
205
+ {renderInlineNodes(node.children, components, key)}
206
+ </ItalicComponent>
207
+ );
208
+ }
209
+
210
+ if (node.type === 'strikethrough') {
211
+ const StrikethroughComponent = components.strikethrough;
212
+ return (
213
+ <StrikethroughComponent key={key} type="strikethrough">
214
+ {renderInlineNodes(node.children, components, key)}
215
+ </StrikethroughComponent>
216
+ );
217
+ }
218
+
219
+ if (node.type === 'code') {
220
+ const InlineCodeComponent = components.inlineCode;
221
+ return (
222
+ <InlineCodeComponent key={key} type="inlineCode" text={node.content}>
223
+ {node.content}
224
+ </InlineCodeComponent>
225
+ );
226
+ }
227
+
228
+ if (node.type === 'image') {
229
+ const ImageComponent = components.image;
230
+ return <ImageComponent key={key} type="image" src={node.src} alt={node.alt} title={node.title} />;
231
+ }
232
+
233
+ const LinkComponent = components.link;
234
+ return (
235
+ <LinkComponent key={key} type="link" href={node.href}>
236
+ {renderInlineNodes(node.children, components, key)}
237
+ </LinkComponent>
238
+ );
239
+ });
240
+ }
@@ -0,0 +1,263 @@
1
+ import * as React from 'react';
2
+ import { Platform, StyleSheet, TextInput, View } from 'react-native';
3
+
4
+ import type { MarkdownTextInputProps } from './markdownCore.types';
5
+ import type { HighlightSegment } from './markdownHighlight.types';
6
+ import { DEFAULT_SEGMENT_COMPONENTS, getDefaultSegmentStyle } from './markdownSegmentDefaults';
7
+
8
+ const isWeb = Platform.OS === 'web';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Web helpers – contentEditable with inline syntax highlighting
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function escapeHTML(s: string): string {
15
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
16
+ }
17
+
18
+ function styleToCSS(s: Record<string, any>): string {
19
+ const p: string[] = [];
20
+ if (s.color) p.push(`color:${s.color}`);
21
+ if (s.fontWeight) p.push(`font-weight:${s.fontWeight}`);
22
+ if (s.fontStyle) p.push(`font-style:${s.fontStyle}`);
23
+ if (s.textDecorationLine) p.push(`text-decoration:${s.textDecorationLine}`);
24
+ if (s.fontFamily) p.push(`font-family:${s.fontFamily}`);
25
+ if (s.fontSize != null) p.push(`font-size:${s.fontSize}px`);
26
+ if (s.lineHeight != null) p.push(`line-height:${s.lineHeight}px`);
27
+ if (s.backgroundColor) p.push(`background-color:${s.backgroundColor}`);
28
+ if (s.letterSpacing != null) p.push(`letter-spacing:${s.letterSpacing}px`);
29
+ return p.join(';');
30
+ }
31
+
32
+ function segmentsToHTML(segments: HighlightSegment[]): string {
33
+ return segments
34
+ .map((seg) => `<span style="${styleToCSS(getDefaultSegmentStyle(seg.type, seg.meta))}">${escapeHTML(seg.text)}</span>`)
35
+ .join('');
36
+ }
37
+
38
+ function textOffset(root: Node, target: Node, off: number): number {
39
+ const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
40
+ let count = 0;
41
+ let n: Node | null;
42
+ while ((n = tw.nextNode())) {
43
+ if (n === target) return count + off;
44
+ count += (n as Text).length;
45
+ }
46
+ return count;
47
+ }
48
+
49
+ function saveCursor(el: HTMLElement): { start: number; end: number } | null {
50
+ const s = window.getSelection();
51
+ if (!s || !s.rangeCount) return null;
52
+ const r = s.getRangeAt(0);
53
+ if (!el.contains(r.startContainer)) return null;
54
+ return {
55
+ start: textOffset(el, r.startContainer, r.startOffset),
56
+ end: textOffset(el, r.endContainer, r.endOffset),
57
+ };
58
+ }
59
+
60
+ function restoreCursor(el: HTMLElement, pos: { start: number; end: number }) {
61
+ const s = window.getSelection();
62
+ if (!s) return;
63
+ const range = document.createRange();
64
+ const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
65
+ let idx = 0;
66
+ let startSet = false;
67
+ let n: Node | null;
68
+ while ((n = tw.nextNode())) {
69
+ const len = (n as Text).length;
70
+ if (!startSet && idx + len >= pos.start) {
71
+ range.setStart(n, pos.start - idx);
72
+ startSet = true;
73
+ }
74
+ if (startSet && idx + len >= pos.end) {
75
+ range.setEnd(n, pos.end - idx);
76
+ break;
77
+ }
78
+ idx += len;
79
+ }
80
+ if (!startSet) {
81
+ range.selectNodeContents(el);
82
+ range.collapse(false);
83
+ }
84
+ s.removeAllRanges();
85
+ s.addRange(range);
86
+ }
87
+
88
+ function extractText(el: HTMLElement): string {
89
+ let text = '';
90
+ const walk = (node: Node) => {
91
+ if (node.nodeType === Node.TEXT_NODE) {
92
+ text += node.textContent;
93
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
94
+ if ((node as HTMLElement).tagName === 'BR') {
95
+ text += '\n';
96
+ } else {
97
+ for (let i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i]);
98
+ }
99
+ }
100
+ };
101
+ walk(el);
102
+ return text;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Component
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Pure markdown text input with integrated live preview.
111
+ *
112
+ * Requires an `editor` handle from `useMarkdownEditor()`.
113
+ * Does NOT render a toolbar, use `MarkdownToolbar` separately.
114
+ */
115
+ export default function MarkdownTextInput({ editor, style, textInputStyle, segmentComponents, ...textInputProps }: MarkdownTextInputProps) {
116
+ const components = React.useMemo(() => ({ ...DEFAULT_SEGMENT_COMPONENTS, ...segmentComponents }), [segmentComponents]);
117
+ const editableRef = React.useRef<HTMLDivElement>(null);
118
+ const cursorRef = React.useRef<{ start: number; end: number } | null>(null);
119
+
120
+ // Web: sync highlighted segments into contentEditable DOM
121
+ // biome-ignore lint: segments is the only meaningful dep
122
+ React.useLayoutEffect(() => {
123
+ if (!isWeb) return;
124
+ const el = editableRef.current;
125
+ if (!el) return;
126
+ const cursor = cursorRef.current ?? saveCursor(el);
127
+ el.innerHTML = segmentsToHTML(editor.highlightedSegments);
128
+ if (cursor) restoreCursor(el, cursor);
129
+ cursorRef.current = null;
130
+ }, [editor.highlightedSegments]);
131
+
132
+ // Web: apply programmatic selection from editor (e.g. after toolbar actions)
133
+ React.useEffect(() => {
134
+ if (!isWeb || !editableRef.current || !editor.selection) return;
135
+ restoreCursor(editableRef.current, editor.selection);
136
+ }, [editor.selection]);
137
+
138
+ const handleInput = React.useCallback(() => {
139
+ const el = editableRef.current;
140
+ if (!el) return;
141
+ cursorRef.current = saveCursor(el);
142
+ editor.handleChangeText(extractText(el));
143
+ }, [editor.handleChangeText]);
144
+
145
+ const handleSelect = React.useCallback(() => {
146
+ const el = editableRef.current;
147
+ if (!el) return;
148
+ const pos = saveCursor(el);
149
+ if (pos) editor.handleSelectionChange({ nativeEvent: { selection: pos } } as any);
150
+ }, [editor.handleSelectionChange]);
151
+
152
+ const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
153
+ if (e.key === 'Enter') {
154
+ e.preventDefault();
155
+ document.execCommand('insertText', false, '\n');
156
+ }
157
+ }, []);
158
+
159
+ const handlePaste = React.useCallback((e: React.ClipboardEvent) => {
160
+ e.preventDefault();
161
+ document.execCommand('insertText', false, e.clipboardData.getData('text/plain'));
162
+ }, []);
163
+
164
+ const setRef = React.useCallback(
165
+ (el: HTMLDivElement | null) => {
166
+ editableRef.current = el;
167
+ if (editor.inputRef && typeof editor.inputRef === 'object') {
168
+ (editor.inputRef as React.MutableRefObject<any>).current = el;
169
+ }
170
+ },
171
+ [editor.inputRef],
172
+ );
173
+
174
+ if (isWeb) {
175
+ return (
176
+ <View style={[styles.editorContainer, style]}>
177
+ <View style={styles.webWrapper}>
178
+ {!editor.value && textInputProps.placeholder ? (
179
+ <div
180
+ style={{
181
+ position: 'absolute' as const,
182
+ top: 10,
183
+ left: 14,
184
+ color: String(textInputProps.placeholderTextColor ?? '#999'),
185
+ fontSize: 16,
186
+ pointerEvents: 'none' as const,
187
+ userSelect: 'none' as const,
188
+ }}
189
+ >
190
+ {textInputProps.placeholder}
191
+ </div>
192
+ ) : null}
193
+ <div
194
+ ref={setRef}
195
+ contentEditable
196
+ suppressContentEditableWarning
197
+ onInput={handleInput}
198
+ onSelect={handleSelect}
199
+ onKeyDown={handleKeyDown}
200
+ onPaste={handlePaste}
201
+ style={{
202
+ flex: 1,
203
+ padding: '10px 14px',
204
+ fontSize: 16,
205
+ whiteSpace: 'pre-wrap' as const,
206
+ wordBreak: 'break-word' as const,
207
+ outline: 'none',
208
+ overflowY: 'auto' as const,
209
+ caretColor: '#000',
210
+ }}
211
+ />
212
+ </View>
213
+ </View>
214
+ );
215
+ }
216
+
217
+ return (
218
+ <View style={[styles.editorContainer, style]}>
219
+ <TextInput
220
+ placeholderTextColor="#999"
221
+ {...textInputProps}
222
+ ref={editor.inputRef}
223
+ multiline
224
+ onChangeText={editor.handleChangeText}
225
+ onSelectionChange={editor.handleSelectionChange}
226
+ selection={editor.selection}
227
+ style={[styles.textInput, textInputStyle]}>
228
+ {editor.highlightedSegments.map((segment, index) => {
229
+ const Component = components[segment.type];
230
+ return (
231
+ <Component key={index} type={segment.type} meta={segment.meta}>
232
+ {segment.text}
233
+ </Component>
234
+ );
235
+ })}
236
+ </TextInput>
237
+ </View>
238
+ );
239
+ }
240
+
241
+ const styles = StyleSheet.create({
242
+ editorContainer: {
243
+ gap: 12,
244
+ borderWidth: 1,
245
+ borderColor: '#d7d7d7',
246
+ borderRadius: 12,
247
+ backgroundColor: '#fff',
248
+ minHeight: 140,
249
+ overflow: 'hidden',
250
+ },
251
+ textInput: {
252
+ flex: 1,
253
+ paddingHorizontal: 14,
254
+ paddingVertical: 10,
255
+ textAlignVertical: 'top',
256
+ fontSize: 16,
257
+ includeFontPadding: false,
258
+ },
259
+ webWrapper: {
260
+ flex: 1,
261
+ position: 'relative',
262
+ },
263
+ });
@@ -0,0 +1,126 @@
1
+ import * as React from 'react';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import type { MarkdownInlineToolbarAction, MarkdownToolbarAction, MarkdownToolbarButtonState, MarkdownToolbarProps } from './markdownCore.types';
5
+ import { DEFAULT_MARKDOWN_FEATURES } from './markdownToolbarActions';
6
+
7
+ const ACTION_LABELS: Record<MarkdownToolbarAction, string> = {
8
+ bold: 'B',
9
+ italic: 'I',
10
+ strikethrough: 'S',
11
+ code: '</>',
12
+ codeBlock: '{ }',
13
+ heading: 'H',
14
+ heading1: 'H1',
15
+ heading2: 'H2',
16
+ heading3: 'H3',
17
+ heading4: 'H4',
18
+ heading5: 'H5',
19
+ heading6: 'H6',
20
+ quote: '"',
21
+ unorderedList: '-',
22
+ orderedList: '1.',
23
+ divider: '-',
24
+ image: '🖼',
25
+ };
26
+
27
+ /**
28
+ * Toolbar component for markdown formatting actions.
29
+ *
30
+ * Can be used in two ways:
31
+ * 1. With `editor` prop from `useMarkdownEditor()` (recommended)
32
+ * 2. With manual `activeInlineActions` + `onPressAction` props
33
+ */
34
+ export default function MarkdownToolbar({
35
+ editor,
36
+ features: featuresProp = DEFAULT_MARKDOWN_FEATURES,
37
+ activeInlineActions: activeInlineActionsProp,
38
+ onPressAction: onPressActionProp,
39
+ style,
40
+ buttonStyle,
41
+ buttonTextStyle,
42
+ activeButtonStyle,
43
+ activeButtonTextStyle,
44
+ inactiveButtonStyle,
45
+ inactiveButtonTextStyle,
46
+ renderButton,
47
+ }: MarkdownToolbarProps) {
48
+ const features = editor?.features ?? featuresProp;
49
+ const activeInlineActions = editor?.activeInlineActions ?? activeInlineActionsProp ?? [];
50
+ const onPressAction = editor?.handleToolbarAction ?? onPressActionProp;
51
+
52
+ return (
53
+ <View style={[styles.container, style]}>
54
+ {features.map((action) => {
55
+ const isImageActive = action === 'image' && editor?.activeImageInfo != null;
56
+ const isActive = isImageActive || (isInlineAction(action) && activeInlineActions.includes(action));
57
+ const label = ACTION_LABELS[action];
58
+ const onPress = () => {
59
+ if (isImageActive) {
60
+ editor?.openImageInfo();
61
+ } else {
62
+ onPressAction?.(action);
63
+ }
64
+ };
65
+
66
+ if (renderButton) {
67
+ return (
68
+ <React.Fragment key={action}>
69
+ {renderButton({
70
+ action,
71
+ label,
72
+ active: isActive,
73
+ onPress,
74
+ })}
75
+ </React.Fragment>
76
+ );
77
+ }
78
+
79
+ const btnState: MarkdownToolbarButtonState = { action, active: isActive };
80
+ const resolvedButtonStyle = typeof buttonStyle === 'function' ? buttonStyle(btnState) : buttonStyle;
81
+ const resolvedButtonTextStyle = typeof buttonTextStyle === 'function' ? buttonTextStyle(btnState) : buttonTextStyle;
82
+
83
+ const stateButtonStyle = isActive ? [styles.buttonActive, activeButtonStyle] : inactiveButtonStyle;
84
+ const stateButtonTextStyle = isActive ? [styles.buttonTextActive, activeButtonTextStyle] : inactiveButtonTextStyle;
85
+
86
+ return (
87
+ <Pressable key={action} onPress={onPress} style={[styles.button, resolvedButtonStyle, stateButtonStyle]}>
88
+ <Text style={[styles.buttonText, resolvedButtonTextStyle, stateButtonTextStyle]}>{label}</Text>
89
+ </Pressable>
90
+ );
91
+ })}
92
+ </View>
93
+ );
94
+ }
95
+
96
+ function isInlineAction(action: MarkdownToolbarAction): action is MarkdownInlineToolbarAction {
97
+ return action === 'bold' || action === 'italic' || action === 'strikethrough' || action === 'code';
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ container: {
102
+ flexDirection: 'row',
103
+ flexWrap: 'wrap',
104
+ gap: 8,
105
+ marginBottom: 12,
106
+ },
107
+ button: {
108
+ borderWidth: 1,
109
+ borderColor: '#c0c0c0',
110
+ borderRadius: 8,
111
+ paddingVertical: 6,
112
+ paddingHorizontal: 10,
113
+ backgroundColor: '#fff',
114
+ },
115
+ buttonActive: {
116
+ backgroundColor: '#2d5eff',
117
+ borderColor: '#2d5eff',
118
+ },
119
+ buttonText: {
120
+ color: '#2d2d2d',
121
+ fontWeight: '600',
122
+ },
123
+ buttonTextActive: {
124
+ color: '#fff',
125
+ },
126
+ });
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ export { default as MarkdownRenderer } from './MarkdownRenderer';
2
+ export { default as MarkdownTextInput } from './MarkdownTextInput';
3
+ export { default as MarkdownToolbar } from './MarkdownToolbar';
4
+ export { useMarkdownEditor } from './useMarkdownEditor';
5
+ export { parseMarkdown, parseMarkdownInline } from './markdownParser';
6
+ export { highlightMarkdown } from './markdownHighlight';
7
+ export type { HighlightSegment, HighlightSegmentType } from './markdownHighlight';
8
+ export {
9
+ DefaultSegment,
10
+ DefaultBoldSegment,
11
+ DefaultHeadingSegment,
12
+ DefaultItalicSegment,
13
+ DefaultStrikethroughSegment,
14
+ DefaultCodeSegment,
15
+ DefaultCodeBlockSegment,
16
+ DefaultLinkSegment,
17
+ DefaultLinkUrlSegment,
18
+ DefaultImageSegment,
19
+ DefaultQuoteSegment,
20
+ DefaultDelimiterSegment,
21
+ DefaultQuoteMarkerSegment,
22
+ DefaultListMarkerSegment,
23
+ DefaultHorizontalRuleSegment,
24
+ DEFAULT_SEGMENT_COMPONENTS,
25
+ getDefaultSegmentStyle,
26
+ } from './markdownSegmentDefaults';
27
+ export type { SegmentComponentProps, SegmentComponentMap } from './markdownSegment.types';
28
+ export { applyMarkdownToolbarAction, DEFAULT_MARKDOWN_FEATURES } from './markdownToolbarActions';
29
+ export { isMarkdownFeatureEnabled, isHeadingLevelEnabled } from './markdownSyntaxUtils';
30
+ export type { MarkdownFeature } from './markdownSyntaxUtils';
31
+ export * from './markdownCore.types';