@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@idealyst/markdown",
3
- "version": "1.2.38",
4
- "description": "Cross-platform markdown renderer for React and React Native with theme integration",
3
+ "version": "1.2.40",
4
+ "description": "Cross-platform markdown renderer and editor for React and React Native with theme integration",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
7
7
  "types": "src/index.ts",
@@ -22,6 +22,17 @@
22
22
  "import": "./src/index.ts",
23
23
  "require": "./src/index.ts",
24
24
  "types": "./src/index.ts"
25
+ },
26
+ "./editor": {
27
+ "react-native": "./src/Editor/index.native.ts",
28
+ "import": "./src/Editor/index.ts",
29
+ "require": "./src/Editor/index.ts",
30
+ "types": "./src/Editor/index.ts"
31
+ },
32
+ "./examples": {
33
+ "import": "./src/examples/index.ts",
34
+ "require": "./src/examples/index.ts",
35
+ "types": "./src/examples/index.ts"
25
36
  }
26
37
  },
27
38
  "scripts": {
@@ -29,18 +40,53 @@
29
40
  "publish:npm": "npm publish"
30
41
  },
31
42
  "peerDependencies": {
32
- "@idealyst/theme": "^1.2.38",
43
+ "@10play/tentap-editor": ">=0.5.0",
44
+ "@idealyst/theme": "^1.2.40",
45
+ "@tiptap/extension-link": ">=2.0.0",
46
+ "@tiptap/extension-placeholder": ">=2.0.0",
47
+ "@tiptap/extension-task-item": ">=2.0.0",
48
+ "@tiptap/extension-task-list": ">=2.0.0",
49
+ "@tiptap/extension-underline": ">=2.0.0",
50
+ "@tiptap/react": ">=2.0.0",
51
+ "@tiptap/starter-kit": ">=2.0.0",
33
52
  "react": ">=16.8.0",
34
53
  "react-markdown": ">=9.0.0",
35
54
  "react-native": ">=0.60.0",
36
55
  "react-native-markdown-display": ">=7.0.0",
37
56
  "react-native-unistyles": ">=3.0.0",
38
- "remark-gfm": ">=4.0.0"
57
+ "react-native-webview": ">=13.0.0",
58
+ "remark-gfm": ">=4.0.0",
59
+ "showdown": ">=2.0.0",
60
+ "tiptap-markdown": ">=0.8.0"
39
61
  },
40
62
  "peerDependenciesMeta": {
63
+ "@10play/tentap-editor": {
64
+ "optional": true
65
+ },
41
66
  "@idealyst/theme": {
42
67
  "optional": true
43
68
  },
69
+ "@tiptap/extension-link": {
70
+ "optional": true
71
+ },
72
+ "@tiptap/extension-placeholder": {
73
+ "optional": true
74
+ },
75
+ "@tiptap/extension-task-item": {
76
+ "optional": true
77
+ },
78
+ "@tiptap/extension-task-list": {
79
+ "optional": true
80
+ },
81
+ "@tiptap/extension-underline": {
82
+ "optional": true
83
+ },
84
+ "@tiptap/react": {
85
+ "optional": true
86
+ },
87
+ "@tiptap/starter-kit": {
88
+ "optional": true
89
+ },
44
90
  "react-markdown": {
45
91
  "optional": true
46
92
  },
@@ -53,18 +99,39 @@
53
99
  "react-native-unistyles": {
54
100
  "optional": true
55
101
  },
102
+ "react-native-webview": {
103
+ "optional": true
104
+ },
56
105
  "remark-gfm": {
57
106
  "optional": true
107
+ },
108
+ "showdown": {
109
+ "optional": true
110
+ },
111
+ "tiptap-markdown": {
112
+ "optional": true
58
113
  }
59
114
  },
60
115
  "devDependencies": {
61
- "@idealyst/theme": "^1.2.38",
116
+ "@10play/tentap-editor": "^0.5.0",
117
+ "@idealyst/theme": "^1.2.40",
118
+ "@tiptap/extension-link": "^2.11.0",
119
+ "@tiptap/extension-placeholder": "^2.11.0",
120
+ "@tiptap/extension-task-item": "^2.11.0",
121
+ "@tiptap/extension-task-list": "^2.11.0",
122
+ "@tiptap/extension-underline": "^2.11.0",
123
+ "@tiptap/react": "^2.11.0",
124
+ "@tiptap/starter-kit": "^2.11.0",
62
125
  "@types/react": "^19.1.0",
63
126
  "react": "^19.1.0",
64
127
  "react-markdown": "^9.0.0",
65
128
  "react-native": "^0.80.1",
66
129
  "react-native-unistyles": "^3.0.10",
130
+ "react-native-webview": "^13.0.0",
67
131
  "remark-gfm": "^4.0.0",
132
+ "showdown": "^2.1.0",
133
+ "@types/showdown": "^2.0.0",
134
+ "tiptap-markdown": "^0.8.0",
68
135
  "typescript": "^5.0.0"
69
136
  },
70
137
  "files": [
@@ -75,6 +142,8 @@
75
142
  "react",
76
143
  "react-native",
77
144
  "markdown",
145
+ "editor",
146
+ "tiptap",
78
147
  "cross-platform",
79
148
  "idealyst"
80
149
  ]
@@ -0,0 +1,470 @@
1
+ import { memo, useState, useEffect, useMemo, useRef } from 'react';
2
+ import { UnistylesRuntime } from 'react-native-unistyles';
3
+ import Icon from '@mdi/react';
4
+ import {
5
+ mdiFormatBold,
6
+ mdiFormatItalic,
7
+ mdiFormatUnderline,
8
+ mdiFormatStrikethrough,
9
+ mdiCodeTags,
10
+ mdiFormatHeader1,
11
+ mdiFormatHeader2,
12
+ mdiFormatHeader3,
13
+ mdiFormatHeader4,
14
+ mdiFormatHeader5,
15
+ mdiFormatHeader6,
16
+ mdiFormatHeaderPound,
17
+ mdiFormatListBulleted,
18
+ mdiFormatListNumbered,
19
+ mdiFormatListChecks,
20
+ mdiFormatQuoteClose,
21
+ mdiCodeBlockBraces,
22
+ mdiMinus,
23
+ mdiLink,
24
+ mdiImage,
25
+ mdiUndo,
26
+ mdiRedo,
27
+ mdiMenuDown,
28
+ } from '@mdi/js';
29
+ import type { ToolbarItem } from './types';
30
+ import type { Size, Intent } from '@idealyst/theme';
31
+ import type { Editor } from '@tiptap/react';
32
+
33
+ interface EditorToolbarProps {
34
+ editor: Editor | null;
35
+ items: readonly ToolbarItem[];
36
+ disabledItems?: readonly ToolbarItem[];
37
+ onAction: (action: string) => void;
38
+ isActive: (action: string) => boolean;
39
+ size: Size;
40
+ linkIntent: Intent;
41
+ }
42
+
43
+ const TOOLBAR_ICONS: Record<string, string> = {
44
+ bold: mdiFormatBold,
45
+ italic: mdiFormatItalic,
46
+ underline: mdiFormatUnderline,
47
+ strikethrough: mdiFormatStrikethrough,
48
+ code: mdiCodeTags,
49
+ heading: mdiFormatHeaderPound,
50
+ heading1: mdiFormatHeader1,
51
+ heading2: mdiFormatHeader2,
52
+ heading3: mdiFormatHeader3,
53
+ heading4: mdiFormatHeader4,
54
+ heading5: mdiFormatHeader5,
55
+ heading6: mdiFormatHeader6,
56
+ bulletList: mdiFormatListBulleted,
57
+ orderedList: mdiFormatListNumbered,
58
+ taskList: mdiFormatListChecks,
59
+ blockquote: mdiFormatQuoteClose,
60
+ codeBlock: mdiCodeBlockBraces,
61
+ horizontalRule: mdiMinus,
62
+ link: mdiLink,
63
+ image: mdiImage,
64
+ undo: mdiUndo,
65
+ redo: mdiRedo,
66
+ };
67
+
68
+ const TOOLBAR_TITLES: Record<string, string> = {
69
+ bold: 'Bold (Ctrl+B)',
70
+ italic: 'Italic (Ctrl+I)',
71
+ underline: 'Underline (Ctrl+U)',
72
+ strikethrough: 'Strikethrough',
73
+ code: 'Inline Code',
74
+ heading: 'Heading',
75
+ heading1: 'Heading 1',
76
+ heading2: 'Heading 2',
77
+ heading3: 'Heading 3',
78
+ heading4: 'Heading 4',
79
+ heading5: 'Heading 5',
80
+ heading6: 'Heading 6',
81
+ bulletList: 'Bullet List',
82
+ orderedList: 'Numbered List',
83
+ taskList: 'Task List',
84
+ blockquote: 'Blockquote',
85
+ codeBlock: 'Code Block',
86
+ horizontalRule: 'Horizontal Rule',
87
+ link: 'Insert Link',
88
+ image: 'Insert Image',
89
+ undo: 'Undo (Ctrl+Z)',
90
+ redo: 'Redo (Ctrl+Y)',
91
+ };
92
+
93
+ const HEADING_ITEMS = ['heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6'] as const;
94
+
95
+ export const EditorToolbar = memo<EditorToolbarProps>(
96
+ ({ editor, items, disabledItems = [], onAction, isActive, size, linkIntent }) => {
97
+ // Force re-render when editor state changes
98
+ const [, setUpdateCounter] = useState(0);
99
+
100
+ // Access theme directly
101
+ const theme = UnistylesRuntime.getTheme();
102
+
103
+ useEffect(() => {
104
+ if (!editor) return;
105
+
106
+ const handleUpdate = () => {
107
+ setUpdateCounter((c) => c + 1);
108
+ };
109
+
110
+ editor.on('selectionUpdate', handleUpdate);
111
+ editor.on('transaction', handleUpdate);
112
+
113
+ return () => {
114
+ editor.off('selectionUpdate', handleUpdate);
115
+ editor.off('transaction', handleUpdate);
116
+ };
117
+ }, [editor]);
118
+
119
+ // Generate toolbar styles from theme
120
+ const toolbarStyle: React.CSSProperties = useMemo(() => ({
121
+ display: 'flex',
122
+ flexDirection: 'row',
123
+ flexWrap: 'wrap',
124
+ gap: 4,
125
+ padding: 8,
126
+ borderBottom: `1px solid ${theme.colors.border.primary}`,
127
+ backgroundColor: theme.colors.surface.secondary,
128
+ }), [theme]);
129
+
130
+ // Check if any heading is active
131
+ const activeHeading = HEADING_ITEMS.find((h) => isActive(h));
132
+
133
+ return (
134
+ <div
135
+ style={toolbarStyle}
136
+ role="toolbar"
137
+ aria-label="Editor formatting toolbar"
138
+ >
139
+ {items.map((item) => {
140
+ // Render heading dropdown
141
+ if (item === 'heading') {
142
+ const disabled = disabledItems.includes(item);
143
+ return (
144
+ <HeadingDropdown
145
+ key={item}
146
+ activeHeading={activeHeading}
147
+ disabled={disabled}
148
+ onAction={onAction}
149
+ theme={theme}
150
+ />
151
+ );
152
+ }
153
+
154
+ // Skip individual heading items if we're using the dropdown
155
+ if (item.startsWith('heading') && items.includes('heading')) {
156
+ return null;
157
+ }
158
+
159
+ const active = isActive(item);
160
+ const disabled = disabledItems.includes(item);
161
+ return (
162
+ <ToolbarButton
163
+ key={item}
164
+ item={item}
165
+ active={active}
166
+ disabled={disabled}
167
+ onAction={onAction}
168
+ theme={theme}
169
+ />
170
+ );
171
+ })}
172
+ </div>
173
+ );
174
+ }
175
+ );
176
+
177
+ EditorToolbar.displayName = 'EditorToolbar';
178
+
179
+ // Heading dropdown component
180
+ const HeadingDropdown = memo<{
181
+ activeHeading: string | undefined;
182
+ disabled: boolean;
183
+ onAction: (action: string) => void;
184
+ theme: any;
185
+ }>(({ activeHeading, disabled, onAction, theme }) => {
186
+ const [isOpen, setIsOpen] = useState(false);
187
+ const [isHovered, setIsHovered] = useState(false);
188
+ const dropdownRef = useRef<HTMLDivElement>(null);
189
+
190
+ // Close dropdown when clicking outside
191
+ useEffect(() => {
192
+ const handleClickOutside = (event: MouseEvent) => {
193
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
194
+ setIsOpen(false);
195
+ }
196
+ };
197
+
198
+ if (isOpen) {
199
+ document.addEventListener('mousedown', handleClickOutside);
200
+ return () => document.removeEventListener('mousedown', handleClickOutside);
201
+ }
202
+ }, [isOpen]);
203
+
204
+ const primaryColor = theme.intents?.primary?.primary ?? '#6366f1';
205
+ const textColor = theme.colors.text.primary;
206
+ const disabledColor = theme.colors.text.tertiary ?? 'rgba(0, 0, 0, 0.3)';
207
+ const activeBg = theme.colors.surface.tertiary ?? 'rgba(0, 0, 0, 0.08)';
208
+ const hoverBg = theme.colors.surface.tertiary ?? 'rgba(0, 0, 0, 0.05)';
209
+ const surfacePrimary = theme.colors.surface.primary ?? '#ffffff';
210
+ const borderColor = theme.colors.border.primary ?? 'rgba(0, 0, 0, 0.1)';
211
+
212
+ const buttonStyle: React.CSSProperties = useMemo(() => {
213
+ const base: React.CSSProperties = {
214
+ display: 'flex',
215
+ alignItems: 'center',
216
+ justifyContent: 'center',
217
+ gap: 2,
218
+ padding: '6px 8px',
219
+ borderRadius: theme.radii?.sm ?? 4,
220
+ backgroundColor: 'transparent',
221
+ color: textColor,
222
+ minWidth: 48,
223
+ height: 32,
224
+ border: 'none',
225
+ cursor: 'pointer',
226
+ transition: 'all 0.15s ease',
227
+ outline: 'none',
228
+ };
229
+
230
+ if (disabled) {
231
+ return {
232
+ ...base,
233
+ color: disabledColor,
234
+ cursor: 'not-allowed',
235
+ opacity: 0.5,
236
+ };
237
+ }
238
+
239
+ if (activeHeading || isOpen) {
240
+ return {
241
+ ...base,
242
+ backgroundColor: activeBg,
243
+ color: primaryColor,
244
+ };
245
+ }
246
+
247
+ if (isHovered) {
248
+ return {
249
+ ...base,
250
+ backgroundColor: hoverBg,
251
+ };
252
+ }
253
+
254
+ return base;
255
+ }, [activeHeading, isOpen, disabled, isHovered, theme, primaryColor, textColor, disabledColor, activeBg, hoverBg]);
256
+
257
+ const dropdownStyle: React.CSSProperties = {
258
+ position: 'absolute',
259
+ top: '100%',
260
+ left: 0,
261
+ marginTop: 4,
262
+ backgroundColor: surfacePrimary,
263
+ border: `1px solid ${borderColor}`,
264
+ borderRadius: theme.radii?.md ?? 8,
265
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
266
+ zIndex: 1000,
267
+ minWidth: 120,
268
+ overflow: 'hidden',
269
+ };
270
+
271
+ const handleToggle = () => {
272
+ if (!disabled) {
273
+ setIsOpen(!isOpen);
274
+ }
275
+ };
276
+
277
+ const handleSelect = (heading: string) => {
278
+ onAction(heading);
279
+ setIsOpen(false);
280
+ };
281
+
282
+ // Determine which icon to show - default to H1
283
+ const currentIcon = activeHeading ? TOOLBAR_ICONS[activeHeading] : TOOLBAR_ICONS.heading1;
284
+
285
+ return (
286
+ <div ref={dropdownRef} style={{ position: 'relative' }}>
287
+ <button
288
+ type="button"
289
+ onClick={handleToggle}
290
+ title={TOOLBAR_TITLES.heading}
291
+ aria-expanded={isOpen}
292
+ aria-haspopup="listbox"
293
+ disabled={disabled}
294
+ style={buttonStyle}
295
+ onMouseEnter={() => setIsHovered(true)}
296
+ onMouseLeave={() => setIsHovered(false)}
297
+ >
298
+ <Icon path={currentIcon} size={0.75} />
299
+ <Icon path={mdiMenuDown} size={0.5} />
300
+ </button>
301
+ {isOpen && (
302
+ <div style={dropdownStyle} role="listbox">
303
+ {HEADING_ITEMS.map((heading) => (
304
+ <HeadingOption
305
+ key={heading}
306
+ heading={heading}
307
+ isActive={activeHeading === heading}
308
+ onSelect={handleSelect}
309
+ theme={theme}
310
+ />
311
+ ))}
312
+ </div>
313
+ )}
314
+ </div>
315
+ );
316
+ });
317
+
318
+ HeadingDropdown.displayName = 'HeadingDropdown';
319
+
320
+ // Individual heading option in the dropdown
321
+ const HeadingOption = memo<{
322
+ heading: string;
323
+ isActive: boolean;
324
+ onSelect: (heading: string) => void;
325
+ theme: any;
326
+ }>(({ heading, isActive, onSelect, theme }) => {
327
+ const [isHovered, setIsHovered] = useState(false);
328
+
329
+ const primaryColor = theme.intents?.primary?.primary ?? '#6366f1';
330
+ const textColor = theme.colors.text.primary;
331
+ const hoverBg = theme.colors.surface.secondary ?? 'rgba(0, 0, 0, 0.05)';
332
+ const activeBg = theme.colors.surface.tertiary ?? 'rgba(0, 0, 0, 0.08)';
333
+
334
+ const optionStyle: React.CSSProperties = useMemo(() => {
335
+ const base: React.CSSProperties = {
336
+ display: 'flex',
337
+ alignItems: 'center',
338
+ gap: 8,
339
+ padding: '8px 12px',
340
+ backgroundColor: 'transparent',
341
+ color: textColor,
342
+ border: 'none',
343
+ cursor: 'pointer',
344
+ width: '100%',
345
+ textAlign: 'left',
346
+ transition: 'background-color 0.15s ease',
347
+ };
348
+
349
+ if (isActive) {
350
+ return {
351
+ ...base,
352
+ backgroundColor: activeBg,
353
+ color: primaryColor,
354
+ };
355
+ }
356
+
357
+ if (isHovered) {
358
+ return {
359
+ ...base,
360
+ backgroundColor: hoverBg,
361
+ };
362
+ }
363
+
364
+ return base;
365
+ }, [isActive, isHovered, primaryColor, textColor, hoverBg, activeBg]);
366
+
367
+ return (
368
+ <button
369
+ type="button"
370
+ onClick={() => onSelect(heading)}
371
+ style={optionStyle}
372
+ role="option"
373
+ aria-selected={isActive}
374
+ onMouseEnter={() => setIsHovered(true)}
375
+ onMouseLeave={() => setIsHovered(false)}
376
+ >
377
+ <Icon path={TOOLBAR_ICONS[heading]} size={0.75} />
378
+ <span style={{ fontSize: 14 }}>{TOOLBAR_TITLES[heading]}</span>
379
+ </button>
380
+ );
381
+ });
382
+
383
+ HeadingOption.displayName = 'HeadingOption';
384
+
385
+ // Separate button component to properly manage styles per button
386
+ const ToolbarButton = memo<{
387
+ item: ToolbarItem;
388
+ active: boolean;
389
+ disabled: boolean;
390
+ onAction: (action: string) => void;
391
+ theme: any;
392
+ }>(({ item, active, disabled, onAction, theme }) => {
393
+ const [isHovered, setIsHovered] = useState(false);
394
+
395
+ // Get colors from theme
396
+ const primaryColor = theme.intents?.primary?.primary ?? '#6366f1';
397
+ const textColor = theme.colors.text.primary;
398
+ const disabledColor = theme.colors.text.tertiary ?? 'rgba(0, 0, 0, 0.3)';
399
+ const activeBg = theme.colors.surface.tertiary ?? 'rgba(0, 0, 0, 0.08)';
400
+ const hoverBg = theme.colors.surface.tertiary ?? 'rgba(0, 0, 0, 0.05)';
401
+
402
+ // Build button style based on state
403
+ const buttonStyle: React.CSSProperties = useMemo(() => {
404
+ const base: React.CSSProperties = {
405
+ display: 'flex',
406
+ alignItems: 'center',
407
+ justifyContent: 'center',
408
+ padding: '6px',
409
+ borderRadius: theme.radii?.sm ?? 4,
410
+ backgroundColor: 'transparent',
411
+ color: textColor,
412
+ minWidth: 32,
413
+ height: 32,
414
+ border: 'none',
415
+ cursor: 'pointer',
416
+ transition: 'all 0.15s ease',
417
+ outline: 'none',
418
+ };
419
+
420
+ if (disabled) {
421
+ return {
422
+ ...base,
423
+ color: disabledColor,
424
+ cursor: 'not-allowed',
425
+ opacity: 0.5,
426
+ };
427
+ }
428
+
429
+ if (active) {
430
+ return {
431
+ ...base,
432
+ backgroundColor: activeBg,
433
+ color: primaryColor,
434
+ };
435
+ }
436
+
437
+ if (isHovered) {
438
+ return {
439
+ ...base,
440
+ backgroundColor: hoverBg,
441
+ };
442
+ }
443
+
444
+ return base;
445
+ }, [active, disabled, isHovered, theme, primaryColor, textColor, disabledColor, activeBg, hoverBg]);
446
+
447
+ const handleClick = () => {
448
+ if (!disabled) {
449
+ onAction(item);
450
+ }
451
+ };
452
+
453
+ return (
454
+ <button
455
+ type="button"
456
+ onClick={handleClick}
457
+ title={TOOLBAR_TITLES[item]}
458
+ aria-pressed={active}
459
+ aria-disabled={disabled}
460
+ disabled={disabled}
461
+ style={buttonStyle}
462
+ onMouseEnter={() => setIsHovered(true)}
463
+ onMouseLeave={() => setIsHovered(false)}
464
+ >
465
+ <Icon path={TOOLBAR_ICONS[item]} size={0.75} />
466
+ </button>
467
+ );
468
+ });
469
+
470
+ ToolbarButton.displayName = 'ToolbarButton';