@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,388 @@
1
+ import {
2
+ forwardRef,
3
+ useImperativeHandle,
4
+ useEffect,
5
+ useCallback,
6
+ useRef,
7
+ useId,
8
+ useMemo,
9
+ } from 'react';
10
+ import { useEditor, EditorContent } from '@tiptap/react';
11
+ import StarterKit from '@tiptap/starter-kit';
12
+ import Placeholder from '@tiptap/extension-placeholder';
13
+ import Link from '@tiptap/extension-link';
14
+ import TaskList from '@tiptap/extension-task-list';
15
+ import TaskItem from '@tiptap/extension-task-item';
16
+ import Underline from '@tiptap/extension-underline';
17
+ import { Markdown as TiptapMarkdown } from 'tiptap-markdown';
18
+ import { useUnistyles } from 'react-native-unistyles';
19
+ import { editorStyles, generateTiptapCSS } from './MarkdownEditor.styles';
20
+ import { EditorToolbar } from './EditorToolbar.web';
21
+ import type { MarkdownEditorProps, MarkdownEditorRef } from './types';
22
+
23
+ const DEFAULT_TOOLBAR_ITEMS = [
24
+ 'bold',
25
+ 'italic',
26
+ 'underline',
27
+ 'strikethrough',
28
+ 'code',
29
+ 'heading',
30
+ 'bulletList',
31
+ 'orderedList',
32
+ 'blockquote',
33
+ 'codeBlock',
34
+ 'link',
35
+ ] as const;
36
+
37
+ /**
38
+ * Markdown editor for web using Tiptap.
39
+ *
40
+ * Provides a rich text editing experience with markdown input/output.
41
+ * Uses Tiptap with the tiptap-markdown extension for seamless markdown handling.
42
+ */
43
+ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
44
+ (
45
+ {
46
+ initialValue = '',
47
+ value,
48
+ onChange,
49
+ onFocus,
50
+ onBlur,
51
+ editable = true,
52
+ autoFocus = false,
53
+ placeholder,
54
+ toolbar = {},
55
+ size = 'md',
56
+ linkIntent = 'primary',
57
+ minHeight,
58
+ maxHeight,
59
+ style,
60
+ testID,
61
+ id,
62
+ accessibilityLabel,
63
+ },
64
+ ref
65
+ ) => {
66
+ const styleId = useId();
67
+ const isControlled = value !== undefined;
68
+ const lastValueRef = useRef<string>(value ?? initialValue);
69
+ const initializedRef = useRef(false);
70
+
71
+ // Get theme for CSS generation
72
+ const { theme } = useUnistyles();
73
+
74
+ // Apply style variants
75
+ editorStyles.useVariants({
76
+ size,
77
+ linkIntent,
78
+ });
79
+
80
+ // Generate and inject CSS for Tiptap
81
+ const tiptapCSS = useMemo(() => generateTiptapCSS(theme), [theme]);
82
+
83
+ useEffect(() => {
84
+ const existingStyle = document.getElementById(`tiptap-styles-${styleId}`);
85
+ if (existingStyle) {
86
+ existingStyle.textContent = tiptapCSS;
87
+ return;
88
+ }
89
+
90
+ const styleElement = document.createElement('style');
91
+ styleElement.id = `tiptap-styles-${styleId}`;
92
+ styleElement.textContent = tiptapCSS;
93
+ document.head.appendChild(styleElement);
94
+
95
+ return () => {
96
+ const el = document.getElementById(`tiptap-styles-${styleId}`);
97
+ if (el) {
98
+ document.head.removeChild(el);
99
+ }
100
+ };
101
+ }, [tiptapCSS, styleId]);
102
+
103
+ const editor = useEditor({
104
+ extensions: [
105
+ StarterKit.configure({
106
+ heading: {
107
+ levels: [1, 2, 3, 4, 5, 6],
108
+ },
109
+ }),
110
+ Placeholder.configure({
111
+ placeholder: placeholder ?? 'Start writing...',
112
+ }),
113
+ Link.configure({
114
+ openOnClick: false,
115
+ HTMLAttributes: {
116
+ rel: 'noopener noreferrer',
117
+ },
118
+ }),
119
+ TaskList,
120
+ TaskItem.configure({
121
+ nested: true,
122
+ }),
123
+ Underline,
124
+ TiptapMarkdown.configure({
125
+ html: false,
126
+ transformPastedText: true,
127
+ transformCopiedText: true,
128
+ }),
129
+ ],
130
+ content: '', // Start empty, we'll set content after creation
131
+ editable,
132
+ autofocus: autoFocus,
133
+ onUpdate: ({ editor: ed }) => {
134
+ const markdown = ed.storage.markdown.getMarkdown();
135
+ lastValueRef.current = markdown;
136
+ onChange?.(markdown);
137
+ },
138
+ onFocus: () => onFocus?.(),
139
+ onBlur: () => onBlur?.(),
140
+ });
141
+
142
+ // Set initial content after editor is created
143
+ // tiptap-markdown's setContent handles markdown parsing
144
+ useEffect(() => {
145
+ if (editor && !initializedRef.current) {
146
+ const initialMarkdown = value ?? initialValue;
147
+ if (initialMarkdown) {
148
+ // Use setContent which tiptap-markdown patches to handle markdown
149
+ editor.commands.setContent(initialMarkdown);
150
+ lastValueRef.current = initialMarkdown;
151
+ }
152
+ initializedRef.current = true;
153
+ }
154
+ }, [editor, value, initialValue]);
155
+
156
+ // Handle controlled value changes
157
+ useEffect(() => {
158
+ if (isControlled && editor && initializedRef.current && value !== lastValueRef.current) {
159
+ const { from, to } = editor.state.selection;
160
+ // tiptap-markdown patches setContent to handle markdown
161
+ editor.commands.setContent(value ?? '');
162
+ // Try to restore cursor position
163
+ const newDocLength = editor.state.doc.content.size;
164
+ const safeFrom = Math.min(from, newDocLength);
165
+ const safeTo = Math.min(to, newDocLength);
166
+ editor.commands.setTextSelection({ from: safeFrom, to: safeTo });
167
+ lastValueRef.current = value ?? '';
168
+ }
169
+ }, [value, isControlled, editor]);
170
+
171
+ // Handle editable changes
172
+ useEffect(() => {
173
+ if (editor) {
174
+ editor.setEditable(editable);
175
+ }
176
+ }, [editable, editor]);
177
+
178
+ // Expose ref methods
179
+ useImperativeHandle(
180
+ ref,
181
+ () => ({
182
+ getMarkdown: async () => {
183
+ if (!editor) return '';
184
+ return editor.storage.markdown.getMarkdown();
185
+ },
186
+ setMarkdown: (markdown: string) => {
187
+ if (editor) {
188
+ editor.commands.setContent(markdown);
189
+ lastValueRef.current = markdown;
190
+ }
191
+ },
192
+ focus: () => editor?.commands.focus(),
193
+ blur: () => editor?.commands.blur(),
194
+ isEmpty: async () => editor?.isEmpty ?? true,
195
+ clear: () => {
196
+ editor?.commands.clearContent();
197
+ lastValueRef.current = '';
198
+ },
199
+ undo: () => editor?.commands.undo(),
200
+ redo: () => editor?.commands.redo(),
201
+ }),
202
+ [editor]
203
+ );
204
+
205
+ const handleToolbarAction = useCallback(
206
+ (action: string) => {
207
+ if (!editor) return;
208
+
209
+ switch (action) {
210
+ case 'bold':
211
+ editor.chain().focus().toggleBold().run();
212
+ break;
213
+ case 'italic':
214
+ editor.chain().focus().toggleItalic().run();
215
+ break;
216
+ case 'underline':
217
+ editor.chain().focus().toggleUnderline().run();
218
+ break;
219
+ case 'strikethrough':
220
+ editor.chain().focus().toggleStrike().run();
221
+ break;
222
+ case 'code':
223
+ editor.chain().focus().toggleCode().run();
224
+ break;
225
+ case 'heading1':
226
+ editor.chain().focus().toggleHeading({ level: 1 }).run();
227
+ break;
228
+ case 'heading2':
229
+ editor.chain().focus().toggleHeading({ level: 2 }).run();
230
+ break;
231
+ case 'heading3':
232
+ editor.chain().focus().toggleHeading({ level: 3 }).run();
233
+ break;
234
+ case 'heading4':
235
+ editor.chain().focus().toggleHeading({ level: 4 }).run();
236
+ break;
237
+ case 'heading5':
238
+ editor.chain().focus().toggleHeading({ level: 5 }).run();
239
+ break;
240
+ case 'heading6':
241
+ editor.chain().focus().toggleHeading({ level: 6 }).run();
242
+ break;
243
+ case 'bulletList':
244
+ editor.chain().focus().toggleBulletList().run();
245
+ break;
246
+ case 'orderedList':
247
+ editor.chain().focus().toggleOrderedList().run();
248
+ break;
249
+ case 'taskList':
250
+ editor.chain().focus().toggleTaskList().run();
251
+ break;
252
+ case 'blockquote':
253
+ editor.chain().focus().toggleBlockquote().run();
254
+ break;
255
+ case 'codeBlock':
256
+ editor.chain().focus().toggleCodeBlock().run();
257
+ break;
258
+ case 'horizontalRule':
259
+ editor.chain().focus().setHorizontalRule().run();
260
+ break;
261
+ case 'link': {
262
+ const previousUrl = editor.getAttributes('link').href;
263
+ const url = window.prompt('URL', previousUrl);
264
+ if (url === null) return;
265
+ if (url === '') {
266
+ editor.chain().focus().extendMarkRange('link').unsetLink().run();
267
+ } else {
268
+ editor
269
+ .chain()
270
+ .focus()
271
+ .extendMarkRange('link')
272
+ .setLink({ href: url })
273
+ .run();
274
+ }
275
+ break;
276
+ }
277
+ case 'undo':
278
+ editor.chain().focus().undo().run();
279
+ break;
280
+ case 'redo':
281
+ editor.chain().focus().redo().run();
282
+ break;
283
+ }
284
+ },
285
+ [editor]
286
+ );
287
+
288
+ const isActive = useCallback(
289
+ (action: string): boolean => {
290
+ if (!editor) return false;
291
+
292
+ switch (action) {
293
+ case 'bold':
294
+ return editor.isActive('bold');
295
+ case 'italic':
296
+ return editor.isActive('italic');
297
+ case 'underline':
298
+ return editor.isActive('underline');
299
+ case 'strikethrough':
300
+ return editor.isActive('strike');
301
+ case 'code':
302
+ return editor.isActive('code');
303
+ case 'heading1':
304
+ return editor.isActive('heading', { level: 1 });
305
+ case 'heading2':
306
+ return editor.isActive('heading', { level: 2 });
307
+ case 'heading3':
308
+ return editor.isActive('heading', { level: 3 });
309
+ case 'heading4':
310
+ return editor.isActive('heading', { level: 4 });
311
+ case 'heading5':
312
+ return editor.isActive('heading', { level: 5 });
313
+ case 'heading6':
314
+ return editor.isActive('heading', { level: 6 });
315
+ case 'bulletList':
316
+ return editor.isActive('bulletList');
317
+ case 'orderedList':
318
+ return editor.isActive('orderedList');
319
+ case 'taskList':
320
+ return editor.isActive('taskList');
321
+ case 'blockquote':
322
+ return editor.isActive('blockquote');
323
+ case 'codeBlock':
324
+ return editor.isActive('codeBlock');
325
+ case 'link':
326
+ return editor.isActive('link');
327
+ default:
328
+ return false;
329
+ }
330
+ },
331
+ [editor]
332
+ );
333
+
334
+ // Build inline styles
335
+ const containerStyle = editorStyles.container({ size, linkIntent });
336
+ const editorContentStyle = editorStyles.editorContent({ size, linkIntent });
337
+
338
+ const toolbarItems = toolbar.items ?? DEFAULT_TOOLBAR_ITEMS;
339
+ const toolbarDisabledItems = toolbar.disabledItems ?? [];
340
+ const showToolbar = toolbar.visible !== false && editable;
341
+ const toolbarPosition = toolbar.position ?? 'top';
342
+
343
+ return (
344
+ <div
345
+ id={id}
346
+ data-testid={testID}
347
+ aria-label={accessibilityLabel}
348
+ className="tiptap-editor-wrapper"
349
+ style={{
350
+ ...containerStyle,
351
+ ...(minHeight !== undefined ? { minHeight } : {}),
352
+ ...(maxHeight !== undefined ? { maxHeight, overflow: 'auto' } : {}),
353
+ ...(style as React.CSSProperties),
354
+ }}
355
+ >
356
+ {showToolbar && toolbarPosition === 'top' && (
357
+ <EditorToolbar
358
+ editor={editor}
359
+ items={toolbarItems}
360
+ disabledItems={toolbarDisabledItems}
361
+ onAction={handleToolbarAction}
362
+ isActive={isActive}
363
+ size={size}
364
+ linkIntent={linkIntent}
365
+ />
366
+ )}
367
+ <div style={editorContentStyle as React.CSSProperties}>
368
+ <EditorContent editor={editor} />
369
+ </div>
370
+ {showToolbar && toolbarPosition === 'bottom' && (
371
+ <EditorToolbar
372
+ editor={editor}
373
+ items={toolbarItems}
374
+ disabledItems={toolbarDisabledItems}
375
+ onAction={handleToolbarAction}
376
+ isActive={isActive}
377
+ size={size}
378
+ linkIntent={linkIntent}
379
+ />
380
+ )}
381
+ </div>
382
+ );
383
+ }
384
+ );
385
+
386
+ MarkdownEditor.displayName = 'MarkdownEditor';
387
+
388
+ export default MarkdownEditor;
@@ -0,0 +1,235 @@
1
+ import { useState, useRef } from 'react';
2
+ import { View, Text, Card, Button, Screen } from '@idealyst/components';
3
+ import { MarkdownEditor } from '../index';
4
+ import type { MarkdownEditorRef } from '../types';
5
+
6
+ const SAMPLE_MARKDOWN = `# Welcome to the Editor
7
+
8
+ This is a **rich text** editor that works with *markdown*.
9
+
10
+ ## Features
11
+
12
+ - Bold, italic, underline formatting
13
+ - Headings (H1-H6)
14
+ - Bullet and numbered lists
15
+ - Task lists
16
+ - Code blocks
17
+ - Blockquotes
18
+ - Links
19
+
20
+ ### Code Example
21
+
22
+ \`\`\`typescript
23
+ const greeting = "Hello, World!";
24
+ console.log(greeting);
25
+ \`\`\`
26
+
27
+ > This is a blockquote with some important information.
28
+
29
+ Try editing this content!
30
+ `;
31
+
32
+ /**
33
+ * Basic usage example
34
+ */
35
+ function BasicEditorExample() {
36
+ const [content, setContent] = useState(SAMPLE_MARKDOWN);
37
+
38
+ return (
39
+ <Card>
40
+ <View spacing="md" style={{ padding: 16 }}>
41
+ <Text size="lg" weight="semibold">Basic Editor</Text>
42
+ <Text size="sm" color="secondary">
43
+ A simple controlled editor with markdown input/output
44
+ </Text>
45
+ <MarkdownEditor
46
+ value={content}
47
+ onChange={setContent}
48
+ placeholder="Start writing..."
49
+ minHeight={300}
50
+ />
51
+ </View>
52
+ </Card>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Controlled editor with ref methods
58
+ */
59
+ function ControlledEditorExample() {
60
+ const [content, setContent] = useState('');
61
+ const [output, setOutput] = useState('');
62
+ const editorRef = useRef<MarkdownEditorRef>(null);
63
+
64
+ const handleClear = () => {
65
+ editorRef.current?.clear();
66
+ setOutput('');
67
+ };
68
+
69
+ const handleGetContent = async () => {
70
+ const markdown = await editorRef.current?.getMarkdown();
71
+ setOutput(markdown ?? '');
72
+ };
73
+
74
+ const handleSetContent = () => {
75
+ editorRef.current?.setMarkdown('# New Content\n\nThis was set programmatically.');
76
+ };
77
+
78
+ return (
79
+ <Card>
80
+ <View spacing="md" style={{ padding: 16 }}>
81
+ <Text size="lg" weight="semibold">Editor with Ref Methods</Text>
82
+ <Text size="sm" color="secondary">
83
+ Use ref methods to programmatically control the editor
84
+ </Text>
85
+ <View direction="row" spacing="sm">
86
+ <Button size="sm" type="outlined" onPress={handleClear}>
87
+ Clear
88
+ </Button>
89
+ <Button size="sm" type="outlined" onPress={handleGetContent}>
90
+ Get Markdown
91
+ </Button>
92
+ <Button size="sm" type="outlined" onPress={handleSetContent}>
93
+ Set Content
94
+ </Button>
95
+ </View>
96
+ <MarkdownEditor
97
+ ref={editorRef}
98
+ value={content}
99
+ onChange={setContent}
100
+ placeholder="Type something..."
101
+ minHeight={200}
102
+ />
103
+ {output ? (
104
+ <View spacing="sm">
105
+ <Text size="sm" weight="semibold">Output:</Text>
106
+ <View style={{ backgroundColor: '#f5f5f5', padding: 12, borderRadius: 8 }}>
107
+ <Text size="sm" style={{ fontFamily: 'monospace' }}>
108
+ {output}
109
+ </Text>
110
+ </View>
111
+ </View>
112
+ ) : null}
113
+ </View>
114
+ </Card>
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Read-only mode example
120
+ */
121
+ function ReadOnlyEditorExample() {
122
+ return (
123
+ <Card>
124
+ <View spacing="md" style={{ padding: 16 }}>
125
+ <Text size="lg" weight="semibold">Read-Only Mode</Text>
126
+ <Text size="sm" color="secondary">
127
+ Use editable=false for a rich markdown viewer
128
+ </Text>
129
+ <MarkdownEditor
130
+ initialValue={SAMPLE_MARKDOWN}
131
+ editable={false}
132
+ toolbar={{ visible: false }}
133
+ minHeight={250}
134
+ />
135
+ </View>
136
+ </Card>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Custom toolbar configuration
142
+ */
143
+ function CustomToolbarExample() {
144
+ const [content, setContent] = useState('');
145
+
146
+ return (
147
+ <Card>
148
+ <View spacing="md" style={{ padding: 16 }}>
149
+ <Text size="lg" weight="semibold">Custom Toolbar</Text>
150
+ <Text size="sm" color="secondary">
151
+ Configure which toolbar items appear and their position
152
+ </Text>
153
+ <MarkdownEditor
154
+ value={content}
155
+ onChange={setContent}
156
+ placeholder="Minimal formatting options..."
157
+ toolbar={{
158
+ items: ['bold', 'italic', 'link', 'bulletList', 'orderedList'],
159
+ position: 'bottom',
160
+ }}
161
+ minHeight={200}
162
+ />
163
+ </View>
164
+ </Card>
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Disabled toolbar items example
170
+ */
171
+ function DisabledItemsExample() {
172
+ const [content, setContent] = useState('');
173
+
174
+ return (
175
+ <Card>
176
+ <View spacing="md" style={{ padding: 16 }}>
177
+ <Text size="lg" weight="semibold">Disabled Toolbar Items</Text>
178
+ <Text size="sm" color="secondary">
179
+ Disable specific toolbar items while keeping them visible (grayed out)
180
+ </Text>
181
+ <MarkdownEditor
182
+ value={content}
183
+ onChange={setContent}
184
+ placeholder="Link and image buttons are disabled..."
185
+ toolbar={{
186
+ items: ['bold', 'italic', 'underline', 'link', 'image', 'bulletList', 'codeBlock'],
187
+ disabledItems: ['link', 'image'],
188
+ }}
189
+ minHeight={200}
190
+ />
191
+ </View>
192
+ </Card>
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Editor with all examples combined
198
+ */
199
+ export function MarkdownEditorExamples() {
200
+ return (
201
+ <Screen>
202
+ <View spacing="lg" padding={12}>
203
+ <Text size="xl" weight="bold">
204
+ Markdown Editor
205
+ </Text>
206
+ <Text color="secondary">
207
+ A cross-platform WYSIWYG markdown editor. Uses Tiptap on web and 10tap-editor on native.
208
+ </Text>
209
+
210
+ <Card>
211
+ <View spacing="sm" style={{ padding: 16 }}>
212
+ <Text size="lg" weight="semibold">Features</Text>
213
+ <View spacing="xs">
214
+ <Text size="sm">• Markdown input/output - no HTML exposed to consumers</Text>
215
+ <Text size="sm">• Rich text editing with formatting toolbar</Text>
216
+ <Text size="sm">• Controlled and uncontrolled modes</Text>
217
+ <Text size="sm">• Ref methods for programmatic control</Text>
218
+ <Text size="sm">• Configurable toolbar items and position</Text>
219
+ <Text size="sm">• Disable specific toolbar items</Text>
220
+ <Text size="sm">• Theme integration via Unistyles</Text>
221
+ </View>
222
+ </View>
223
+ </Card>
224
+
225
+ <BasicEditorExample />
226
+ <ControlledEditorExample />
227
+ <ReadOnlyEditorExample />
228
+ <CustomToolbarExample />
229
+ <DisabledItemsExample />
230
+ </View>
231
+ </Screen>
232
+ );
233
+ }
234
+
235
+ export default MarkdownEditorExamples;
@@ -0,0 +1 @@
1
+ export { MarkdownEditorExamples } from './MarkdownEditorExamples';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @idealyst/markdown/editor - Cross-platform markdown editor (React Native)
3
+ *
4
+ * Provides a MarkdownEditor component for editing markdown content
5
+ * with a rich text interface on React Native using 10tap-editor.
6
+ */
7
+
8
+ // Main component
9
+ export { default as MarkdownEditor } from './MarkdownEditor.native';
10
+
11
+ // Types
12
+ export type {
13
+ MarkdownEditorProps,
14
+ MarkdownEditorRef,
15
+ ToolbarItem,
16
+ ToolbarConfig,
17
+ } from './types';
18
+
19
+ // Style types
20
+ export type {
21
+ EditorDynamicProps,
22
+ EditorVariants,
23
+ } from './MarkdownEditor.styles';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @idealyst/markdown/editor - Cross-platform markdown editor
3
+ *
4
+ * Provides a MarkdownEditor component for editing markdown content
5
+ * with a rich text interface on both web and React Native platforms.
6
+ *
7
+ * Web uses Tiptap with tiptap-markdown extension.
8
+ * Native uses 10tap-editor (Tiptap in WebView).
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { MarkdownEditor } from '@idealyst/markdown/editor';
13
+ *
14
+ * function App() {
15
+ * const [content, setContent] = useState('# Hello World');
16
+ *
17
+ * return (
18
+ * <MarkdownEditor
19
+ * value={content}
20
+ * onChange={setContent}
21
+ * placeholder="Start writing..."
22
+ * />
23
+ * );
24
+ * }
25
+ * ```
26
+ */
27
+
28
+ // Main component
29
+ export { default as MarkdownEditor } from './MarkdownEditor.web';
30
+
31
+ // Types
32
+ export type {
33
+ MarkdownEditorProps,
34
+ MarkdownEditorRef,
35
+ ToolbarItem,
36
+ ToolbarConfig,
37
+ } from './types';
38
+
39
+ // Style types
40
+ export type {
41
+ EditorDynamicProps,
42
+ EditorVariants,
43
+ } from './MarkdownEditor.styles';