@idealyst/markdown 1.2.38 → 1.2.40

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.
@@ -0,0 +1,277 @@
1
+ import {
2
+ forwardRef,
3
+ useImperativeHandle,
4
+ useEffect,
5
+ useRef,
6
+ } from 'react';
7
+ import { View, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
8
+ import {
9
+ RichText,
10
+ Toolbar,
11
+ useEditorBridge,
12
+ type EditorBridge,
13
+ } from '@10play/tentap-editor';
14
+ import Showdown from 'showdown';
15
+ import { editorStyles } from './MarkdownEditor.styles';
16
+ import type { MarkdownEditorProps, MarkdownEditorRef, ToolbarItem } from './types';
17
+
18
+ // Configure Showdown converter with GFM support
19
+ const showdownConverter = new Showdown.Converter({
20
+ tables: true,
21
+ strikethrough: true,
22
+ tasklists: true,
23
+ ghCodeBlocks: true,
24
+ smoothLivePreview: true,
25
+ simpleLineBreaks: false,
26
+ openLinksInNewWindow: false,
27
+ backslashEscapesHTMLTags: true,
28
+ });
29
+
30
+ // Map our toolbar items to 10tap toolbar items
31
+ const TOOLBAR_ITEM_MAP: Record<ToolbarItem, string | string[] | null> = {
32
+ bold: 'bold',
33
+ italic: 'italic',
34
+ underline: 'underline',
35
+ strikethrough: 'strikethrough',
36
+ code: 'code',
37
+ // 'heading' expands to all heading levels since 10tap doesn't support dropdowns
38
+ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
39
+ heading1: 'h1',
40
+ heading2: 'h2',
41
+ heading3: 'h3',
42
+ heading4: 'h4',
43
+ heading5: 'h5',
44
+ heading6: 'h6',
45
+ bulletList: 'bulletList',
46
+ orderedList: 'orderedList',
47
+ taskList: 'taskList',
48
+ blockquote: 'blockquote',
49
+ codeBlock: 'codeBlock',
50
+ horizontalRule: 'horizontalRule',
51
+ link: 'link',
52
+ image: 'image',
53
+ undo: 'undo',
54
+ redo: 'redo',
55
+ };
56
+
57
+ /**
58
+ * Convert markdown to HTML using Showdown.
59
+ */
60
+ function markdownToHtml(markdown: string): string {
61
+ return showdownConverter.makeHtml(markdown);
62
+ }
63
+
64
+ /**
65
+ * Convert HTML to markdown using Showdown.
66
+ */
67
+ function htmlToMarkdown(html: string): string {
68
+ return showdownConverter.makeMarkdown(html);
69
+ }
70
+
71
+ /**
72
+ * Markdown editor for React Native using 10tap-editor.
73
+ *
74
+ * Uses a WebView-based Tiptap editor under the hood for rich text editing
75
+ * with markdown input/output support.
76
+ */
77
+ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
78
+ (
79
+ {
80
+ initialValue = '',
81
+ value,
82
+ onChange,
83
+ onFocus,
84
+ onBlur,
85
+ editable = true,
86
+ autoFocus = false,
87
+ placeholder,
88
+ toolbar = {},
89
+ size = 'md',
90
+ linkIntent = 'primary',
91
+ minHeight = 200,
92
+ maxHeight,
93
+ style,
94
+ testID,
95
+ id,
96
+ accessibilityLabel,
97
+ avoidIosKeyboard = true,
98
+ },
99
+ ref
100
+ ) => {
101
+ const isControlled = value !== undefined;
102
+ const lastValueRef = useRef<string>(value ?? initialValue);
103
+ const editorRef = useRef<EditorBridge | null>(null);
104
+
105
+ // Apply style variants
106
+ editorStyles.useVariants({
107
+ size,
108
+ linkIntent,
109
+ });
110
+
111
+ // Initialize editor with HTML content
112
+ const initialHtml = markdownToHtml(value ?? initialValue);
113
+
114
+ const editor = useEditorBridge({
115
+ autofocus: autoFocus,
116
+ avoidIosKeyboard,
117
+ initialContent: initialHtml,
118
+ editable,
119
+ onChange: async () => {
120
+ if (editorRef.current) {
121
+ try {
122
+ const html = await editorRef.current.getHTML();
123
+ const markdown = htmlToMarkdown(html);
124
+ if (markdown !== lastValueRef.current) {
125
+ lastValueRef.current = markdown;
126
+ onChange?.(markdown);
127
+ }
128
+ } catch {
129
+ // Editor might not be ready
130
+ }
131
+ }
132
+ },
133
+ });
134
+
135
+ // Store editor ref
136
+ useEffect(() => {
137
+ editorRef.current = editor;
138
+ }, [editor]);
139
+
140
+ // Handle controlled value changes
141
+ useEffect(() => {
142
+ if (isControlled && value !== lastValueRef.current && editorRef.current) {
143
+ const html = markdownToHtml(value);
144
+ editorRef.current.setContent(html);
145
+ lastValueRef.current = value;
146
+ }
147
+ }, [value, isControlled]);
148
+
149
+ // Expose ref methods
150
+ useImperativeHandle(
151
+ ref,
152
+ () => ({
153
+ getMarkdown: async () => {
154
+ if (!editorRef.current) return '';
155
+ try {
156
+ const html = await editorRef.current.getHTML();
157
+ return htmlToMarkdown(html);
158
+ } catch {
159
+ return lastValueRef.current;
160
+ }
161
+ },
162
+ setMarkdown: (markdown: string) => {
163
+ if (editorRef.current) {
164
+ const html = markdownToHtml(markdown);
165
+ editorRef.current.setContent(html);
166
+ lastValueRef.current = markdown;
167
+ }
168
+ },
169
+ focus: () => editorRef.current?.focus(),
170
+ blur: () => editorRef.current?.blur(),
171
+ isEmpty: async () => {
172
+ if (!editorRef.current) return true;
173
+ try {
174
+ const text = await editorRef.current.getText();
175
+ return !text || text.trim().length === 0;
176
+ } catch {
177
+ return true;
178
+ }
179
+ },
180
+ clear: () => {
181
+ editorRef.current?.setContent('');
182
+ lastValueRef.current = '';
183
+ },
184
+ undo: () => editorRef.current?.undo(),
185
+ redo: () => editorRef.current?.redo(),
186
+ }),
187
+ []
188
+ );
189
+
190
+ // Default toolbar items - matches web
191
+ const defaultItems: ToolbarItem[] = [
192
+ 'bold',
193
+ 'italic',
194
+ 'underline',
195
+ 'strikethrough',
196
+ 'code',
197
+ 'heading',
198
+ 'bulletList',
199
+ 'orderedList',
200
+ 'blockquote',
201
+ 'codeBlock',
202
+ 'link',
203
+ ];
204
+
205
+ // Get disabled items set for filtering
206
+ const disabledItems = new Set(toolbar.disabledItems ?? []);
207
+
208
+ // Map toolbar items, expanding arrays (like 'heading' -> ['h1', 'h2', ...])
209
+ // and filtering out disabled items
210
+ const toolbarItems = (toolbar.items ?? defaultItems)
211
+ .filter((item) => !disabledItems.has(item))
212
+ .flatMap((item) => {
213
+ const mapped = TOOLBAR_ITEM_MAP[item];
214
+ if (mapped === null) return [];
215
+ if (Array.isArray(mapped)) return mapped;
216
+ return [mapped];
217
+ });
218
+
219
+ const showToolbar = toolbar.visible !== false && editable;
220
+ const toolbarPosition = toolbar.position ?? 'top';
221
+
222
+ const containerStyle = [
223
+ (editorStyles.container as any)({ size, linkIntent }),
224
+ style,
225
+ ];
226
+
227
+ const editorContentStyle = [
228
+ (editorStyles.editorContent as any)({ size, linkIntent }),
229
+ { minHeight },
230
+ maxHeight !== undefined && { maxHeight },
231
+ ];
232
+
233
+ const content = (
234
+ <View
235
+ style={containerStyle}
236
+ nativeID={id}
237
+ testID={testID}
238
+ accessibilityLabel={accessibilityLabel}
239
+ >
240
+ {showToolbar && toolbarPosition === 'top' && (
241
+ <Toolbar editor={editor} items={toolbarItems} />
242
+ )}
243
+ <View style={editorContentStyle}>
244
+ <RichText
245
+ editor={editor}
246
+ onFocus={onFocus}
247
+ onBlur={onBlur}
248
+ style={nativeStyles.richText}
249
+ />
250
+ </View>
251
+ {showToolbar && toolbarPosition === 'bottom' && (
252
+ <Toolbar editor={editor} items={toolbarItems} />
253
+ )}
254
+ </View>
255
+ );
256
+
257
+ if (Platform.OS === 'ios' && avoidIosKeyboard) {
258
+ return (
259
+ <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
260
+ {content}
261
+ </KeyboardAvoidingView>
262
+ );
263
+ }
264
+
265
+ return content;
266
+ }
267
+ );
268
+
269
+ const nativeStyles = StyleSheet.create({
270
+ richText: {
271
+ flex: 1,
272
+ },
273
+ });
274
+
275
+ MarkdownEditor.displayName = 'MarkdownEditor';
276
+
277
+ export default MarkdownEditor;
@@ -0,0 +1,337 @@
1
+ /**
2
+ * MarkdownEditor styles using defineStyle with theme integration.
3
+ *
4
+ * Provides consistent styling for the markdown editor that
5
+ * integrates with the Idealyst theme system.
6
+ */
7
+ import { StyleSheet } from 'react-native-unistyles';
8
+ import { defineStyle, ThemeStyleWrapper } from '@idealyst/theme';
9
+ import type { Theme as BaseTheme, Intent, Size } from '@idealyst/theme';
10
+
11
+ // Required: Unistyles must see StyleSheet usage in original source to process this file
12
+ void StyleSheet;
13
+
14
+ // Wrap theme for $iterator support
15
+ type Theme = ThemeStyleWrapper<BaseTheme>;
16
+
17
+ export type EditorVariants = {
18
+ size: Size;
19
+ linkIntent: Intent;
20
+ };
21
+
22
+ /**
23
+ * Dynamic props passed to editor style functions.
24
+ */
25
+ export type EditorDynamicProps = {
26
+ linkIntent?: Intent;
27
+ size?: Size;
28
+ };
29
+
30
+ /**
31
+ * Editor styles with theme integration.
32
+ */
33
+ // @ts-expect-error - MarkdownEditor is not in the ComponentName union yet
34
+ export const editorStyles = defineStyle('MarkdownEditor', (theme: Theme) => ({
35
+ // Main container
36
+ container: (_props: EditorDynamicProps) => ({
37
+ borderWidth: 1,
38
+ borderColor: theme.colors.border.primary,
39
+ borderRadius: theme.radii.md,
40
+ backgroundColor: theme.colors.surface.primary,
41
+ overflow: 'hidden',
42
+ }),
43
+
44
+ // Editor content area
45
+ editorContent: (_props: EditorDynamicProps) => ({
46
+ padding: 16,
47
+ minHeight: 200,
48
+ fontSize: theme.sizes.typography.body1.fontSize,
49
+ lineHeight: theme.sizes.typography.body1.lineHeight,
50
+ color: theme.colors.text.primary,
51
+ }),
52
+
53
+ // Toolbar container
54
+ toolbar: (_props: EditorDynamicProps) => ({
55
+ flexDirection: 'row' as const,
56
+ flexWrap: 'wrap' as const,
57
+ gap: 4,
58
+ padding: 8,
59
+ borderBottomWidth: 1,
60
+ borderBottomColor: theme.colors.border.primary,
61
+ backgroundColor: theme.colors.surface.secondary,
62
+ _web: {
63
+ display: 'flex',
64
+ },
65
+ }),
66
+
67
+ // Toolbar button base
68
+ toolbarButton: (_props: EditorDynamicProps) => ({
69
+ paddingLeft: 10,
70
+ paddingRight: 10,
71
+ paddingTop: 6,
72
+ paddingBottom: 6,
73
+ borderRadius: theme.radii.sm,
74
+ backgroundColor: 'transparent',
75
+ borderWidth: 0,
76
+ borderColor: 'transparent',
77
+ color: theme.colors.text.primary,
78
+ fontSize: 14,
79
+ fontWeight: '500' as const,
80
+ minWidth: 32,
81
+ height: 32,
82
+ _web: {
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ cursor: 'pointer',
87
+ outline: 'none',
88
+ border: 'none',
89
+ },
90
+ }),
91
+
92
+ // Toolbar button active state
93
+ toolbarButtonActive: (_props: EditorDynamicProps) => ({
94
+ backgroundColor: theme.intents?.primary?.primary ?? theme.colors.surface.tertiary,
95
+ color: theme.intents?.primary?.contrast ?? theme.colors.text.inverse,
96
+ }),
97
+
98
+ // Focus ring for accessibility
99
+ focusRing: (_props: EditorDynamicProps) => ({
100
+ }),
101
+ }));
102
+
103
+ /**
104
+ * Generate CSS for Tiptap editor based on theme colors.
105
+ * This is injected as a style tag since Tiptap uses its own DOM.
106
+ */
107
+ export function generateTiptapCSS(theme: BaseTheme): string {
108
+ const primary = theme.intents?.primary?.primary ?? theme.colors.text.primary;
109
+
110
+ return `
111
+ .tiptap-editor-wrapper .tiptap {
112
+ outline: none;
113
+ min-height: 100%;
114
+ font-family: inherit;
115
+ }
116
+
117
+ .tiptap-editor-wrapper .tiptap:focus {
118
+ outline: none;
119
+ }
120
+
121
+ .tiptap-editor-wrapper .tiptap p.is-editor-empty:first-child::before {
122
+ content: attr(data-placeholder);
123
+ color: ${theme.colors.text.tertiary};
124
+ pointer-events: none;
125
+ float: left;
126
+ height: 0;
127
+ }
128
+
129
+ /* Headings */
130
+ .tiptap-editor-wrapper .tiptap h1 {
131
+ font-size: ${theme.sizes.typography.h1.fontSize}px;
132
+ line-height: ${theme.sizes.typography.h1.lineHeight}px;
133
+ font-weight: 700;
134
+ margin-top: 1em;
135
+ margin-bottom: 0.5em;
136
+ color: ${theme.colors.text.primary};
137
+ }
138
+
139
+ .tiptap-editor-wrapper .tiptap h2 {
140
+ font-size: ${theme.sizes.typography.h2.fontSize}px;
141
+ line-height: ${theme.sizes.typography.h2.lineHeight}px;
142
+ font-weight: 600;
143
+ margin-top: 1em;
144
+ margin-bottom: 0.5em;
145
+ border-bottom: 1px solid ${theme.colors.border.primary};
146
+ padding-bottom: 0.25em;
147
+ color: ${theme.colors.text.primary};
148
+ }
149
+
150
+ .tiptap-editor-wrapper .tiptap h3 {
151
+ font-size: ${theme.sizes.typography.h3.fontSize}px;
152
+ line-height: ${theme.sizes.typography.h3.lineHeight}px;
153
+ font-weight: 600;
154
+ margin-top: 1em;
155
+ margin-bottom: 0.5em;
156
+ color: ${theme.colors.text.primary};
157
+ }
158
+
159
+ .tiptap-editor-wrapper .tiptap h4,
160
+ .tiptap-editor-wrapper .tiptap h5,
161
+ .tiptap-editor-wrapper .tiptap h6 {
162
+ font-weight: 600;
163
+ margin-top: 1em;
164
+ margin-bottom: 0.5em;
165
+ color: ${theme.colors.text.primary};
166
+ }
167
+
168
+ /* Remove top margin from first child */
169
+ .tiptap-editor-wrapper .tiptap > *:first-child {
170
+ margin-top: 0;
171
+ }
172
+
173
+ /* Paragraphs */
174
+ .tiptap-editor-wrapper .tiptap p {
175
+ margin-top: 0;
176
+ margin-bottom: 0.5em;
177
+ color: ${theme.colors.text.primary};
178
+ }
179
+
180
+ /* Links */
181
+ .tiptap-editor-wrapper .tiptap a {
182
+ color: ${primary};
183
+ text-decoration: underline;
184
+ cursor: pointer;
185
+ }
186
+
187
+ .tiptap-editor-wrapper .tiptap a:hover {
188
+ opacity: 0.8;
189
+ }
190
+
191
+ /* Inline Code */
192
+ .tiptap-editor-wrapper .tiptap code {
193
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
194
+ background-color: ${theme.colors.surface.secondary};
195
+ padding: 2px 6px;
196
+ border-radius: ${theme.radii.xs}px;
197
+ font-size: ${theme.sizes.typography.caption.fontSize}px;
198
+ color: ${theme.colors.text.primary};
199
+ }
200
+
201
+ /* Code Blocks */
202
+ .tiptap-editor-wrapper .tiptap pre {
203
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
204
+ background-color: ${theme.colors.surface.secondary};
205
+ padding: 16px;
206
+ border-radius: ${theme.radii.md}px;
207
+ margin-top: 12px;
208
+ margin-bottom: 12px;
209
+ overflow-x: auto;
210
+ color: ${theme.colors.text.primary};
211
+ }
212
+
213
+ .tiptap-editor-wrapper .tiptap pre code {
214
+ background-color: transparent;
215
+ padding: 0;
216
+ font-size: inherit;
217
+ }
218
+
219
+ /* Blockquote */
220
+ .tiptap-editor-wrapper .tiptap blockquote {
221
+ border-left: 4px solid ${primary};
222
+ padding-left: 16px;
223
+ padding-top: 8px;
224
+ padding-bottom: 8px;
225
+ margin-top: 12px;
226
+ margin-bottom: 12px;
227
+ margin-left: 0;
228
+ margin-right: 0;
229
+ background-color: ${theme.colors.surface.secondary};
230
+ border-radius: ${theme.radii.sm}px;
231
+ font-style: italic;
232
+ color: ${theme.colors.text.secondary};
233
+ }
234
+
235
+ /* Lists */
236
+ .tiptap-editor-wrapper .tiptap ul,
237
+ .tiptap-editor-wrapper .tiptap ol {
238
+ margin-top: 8px;
239
+ margin-bottom: 8px;
240
+ padding-left: 24px;
241
+ color: ${theme.colors.text.primary};
242
+ }
243
+
244
+ .tiptap-editor-wrapper .tiptap li {
245
+ margin-top: 4px;
246
+ margin-bottom: 4px;
247
+ }
248
+
249
+ .tiptap-editor-wrapper .tiptap li p {
250
+ margin: 0;
251
+ }
252
+
253
+ /* Task list */
254
+ .tiptap-editor-wrapper .tiptap ul[data-type="taskList"] {
255
+ list-style: none;
256
+ padding: 0;
257
+ }
258
+
259
+ .tiptap-editor-wrapper .tiptap ul[data-type="taskList"] li {
260
+ display: flex;
261
+ align-items: flex-start;
262
+ gap: 8px;
263
+ }
264
+
265
+ .tiptap-editor-wrapper .tiptap ul[data-type="taskList"] li > label {
266
+ flex-shrink: 0;
267
+ margin-top: 4px;
268
+ }
269
+
270
+ .tiptap-editor-wrapper .tiptap ul[data-type="taskList"] li > div {
271
+ flex: 1;
272
+ }
273
+
274
+ .tiptap-editor-wrapper .tiptap ul[data-type="taskList"] input[type="checkbox"] {
275
+ width: 16px;
276
+ height: 16px;
277
+ accent-color: ${primary};
278
+ cursor: pointer;
279
+ }
280
+
281
+ /* Horizontal rule */
282
+ .tiptap-editor-wrapper .tiptap hr {
283
+ height: 1px;
284
+ background-color: ${theme.colors.border.secondary};
285
+ margin-top: 24px;
286
+ margin-bottom: 24px;
287
+ border: none;
288
+ }
289
+
290
+ /* Table */
291
+ .tiptap-editor-wrapper .tiptap table {
292
+ border-collapse: collapse;
293
+ width: 100%;
294
+ margin-top: 12px;
295
+ margin-bottom: 12px;
296
+ }
297
+
298
+ .tiptap-editor-wrapper .tiptap th,
299
+ .tiptap-editor-wrapper .tiptap td {
300
+ border: 1px solid ${theme.colors.border.primary};
301
+ padding: 12px;
302
+ text-align: left;
303
+ }
304
+
305
+ .tiptap-editor-wrapper .tiptap th {
306
+ background-color: ${theme.colors.surface.secondary};
307
+ font-weight: 600;
308
+ }
309
+
310
+ /* Strong/Bold */
311
+ .tiptap-editor-wrapper .tiptap strong {
312
+ font-weight: 700;
313
+ }
314
+
315
+ /* Italic/Emphasis */
316
+ .tiptap-editor-wrapper .tiptap em {
317
+ font-style: italic;
318
+ }
319
+
320
+ /* Strikethrough */
321
+ .tiptap-editor-wrapper .tiptap s {
322
+ text-decoration: line-through;
323
+ }
324
+
325
+ /* Underline */
326
+ .tiptap-editor-wrapper .tiptap u {
327
+ text-decoration: underline;
328
+ }
329
+
330
+ /* Images */
331
+ .tiptap-editor-wrapper .tiptap img {
332
+ max-width: 100%;
333
+ height: auto;
334
+ border-radius: ${theme.radii.sm}px;
335
+ }
336
+ `;
337
+ }