@idealyst/markdown 1.2.39 → 1.2.41

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/markdown",
3
- "version": "1.2.39",
3
+ "version": "1.2.41",
4
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",
@@ -30,9 +30,9 @@
30
30
  "types": "./src/Editor/index.ts"
31
31
  },
32
32
  "./examples": {
33
- "import": "./src/Editor/examples/index.ts",
34
- "require": "./src/Editor/examples/index.ts",
35
- "types": "./src/Editor/examples/index.ts"
33
+ "import": "./src/examples/index.ts",
34
+ "require": "./src/examples/index.ts",
35
+ "types": "./src/examples/index.ts"
36
36
  }
37
37
  },
38
38
  "scripts": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@10play/tentap-editor": ">=0.5.0",
44
- "@idealyst/theme": "^1.2.39",
44
+ "@idealyst/theme": "^1.2.41",
45
45
  "@tiptap/extension-link": ">=2.0.0",
46
46
  "@tiptap/extension-placeholder": ">=2.0.0",
47
47
  "@tiptap/extension-task-item": ">=2.0.0",
@@ -114,7 +114,7 @@
114
114
  },
115
115
  "devDependencies": {
116
116
  "@10play/tentap-editor": "^0.5.0",
117
- "@idealyst/theme": "^1.2.39",
117
+ "@idealyst/theme": "^1.2.41",
118
118
  "@tiptap/extension-link": "^2.11.0",
119
119
  "@tiptap/extension-placeholder": "^2.11.0",
120
120
  "@tiptap/extension-task-item": "^2.11.0",
@@ -1,47 +1,83 @@
1
- import { memo } from 'react';
2
- import { getWebProps } from 'react-native-unistyles/web';
3
- import { editorStyles } from './MarkdownEditor.styles';
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';
4
29
  import type { ToolbarItem } from './types';
5
30
  import type { Size, Intent } from '@idealyst/theme';
31
+ import type { Editor } from '@tiptap/react';
6
32
 
7
33
  interface EditorToolbarProps {
34
+ editor: Editor | null;
8
35
  items: readonly ToolbarItem[];
36
+ disabledItems?: readonly ToolbarItem[];
9
37
  onAction: (action: string) => void;
10
38
  isActive: (action: string) => boolean;
11
39
  size: Size;
12
40
  linkIntent: Intent;
13
41
  }
14
42
 
15
- const TOOLBAR_ICONS: Record<ToolbarItem, string> = {
16
- bold: 'B',
17
- italic: 'I',
18
- underline: 'U',
19
- strikethrough: 'S',
20
- code: '</>',
21
- heading1: 'H1',
22
- heading2: 'H2',
23
- heading3: 'H3',
24
- bulletList: '•',
25
- orderedList: '1.',
26
- taskList: '☑',
27
- blockquote: '"',
28
- codeBlock: '{ }',
29
- horizontalRule: '—',
30
- link: '🔗',
31
- image: '🖼',
32
- undo: '↶',
33
- redo: '↷',
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,
34
66
  };
35
67
 
36
- const TOOLBAR_TITLES: Record<ToolbarItem, string> = {
68
+ const TOOLBAR_TITLES: Record<string, string> = {
37
69
  bold: 'Bold (Ctrl+B)',
38
70
  italic: 'Italic (Ctrl+I)',
39
71
  underline: 'Underline (Ctrl+U)',
40
72
  strikethrough: 'Strikethrough',
41
73
  code: 'Inline Code',
74
+ heading: 'Heading',
42
75
  heading1: 'Heading 1',
43
76
  heading2: 'Heading 2',
44
77
  heading3: 'Heading 3',
78
+ heading4: 'Heading 4',
79
+ heading5: 'Heading 5',
80
+ heading6: 'Heading 6',
45
81
  bulletList: 'Bullet List',
46
82
  orderedList: 'Numbered List',
47
83
  taskList: 'Task List',
@@ -54,32 +90,83 @@ const TOOLBAR_TITLES: Record<ToolbarItem, string> = {
54
90
  redo: 'Redo (Ctrl+Y)',
55
91
  };
56
92
 
93
+ const HEADING_ITEMS = ['heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6'] as const;
94
+
57
95
  export const EditorToolbar = memo<EditorToolbarProps>(
58
- ({ items, onAction, isActive, size, linkIntent }) => {
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
+
59
133
  return (
60
134
  <div
61
- {...getWebProps([
62
- (editorStyles.toolbar as any)({ size, linkIntent }),
63
- ])}
135
+ style={toolbarStyle}
64
136
  role="toolbar"
65
137
  aria-label="Editor formatting toolbar"
66
138
  >
67
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
+
68
159
  const active = isActive(item);
160
+ const disabled = disabledItems.includes(item);
69
161
  return (
70
- <button
162
+ <ToolbarButton
71
163
  key={item}
72
- type="button"
73
- onClick={() => onAction(item)}
74
- title={TOOLBAR_TITLES[item]}
75
- aria-pressed={active}
76
- {...getWebProps([
77
- (editorStyles.toolbarButton as any)({ size, linkIntent }),
78
- active && (editorStyles.toolbarButtonActive as any)({ size, linkIntent }),
79
- ])}
80
- >
81
- {TOOLBAR_ICONS[item]}
82
- </button>
164
+ item={item}
165
+ active={active}
166
+ disabled={disabled}
167
+ onAction={onAction}
168
+ theme={theme}
169
+ />
83
170
  );
84
171
  })}
85
172
  </div>
@@ -88,3 +175,296 @@ export const EditorToolbar = memo<EditorToolbarProps>(
88
175
  );
89
176
 
90
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';
@@ -9,7 +9,6 @@ import {
9
9
  RichText,
10
10
  Toolbar,
11
11
  useEditorBridge,
12
- DEFAULT_TOOLBAR_ITEMS,
13
12
  type EditorBridge,
14
13
  } from '@10play/tentap-editor';
15
14
  import Showdown from 'showdown';
@@ -29,15 +28,20 @@ const showdownConverter = new Showdown.Converter({
29
28
  });
30
29
 
31
30
  // Map our toolbar items to 10tap toolbar items
32
- const TOOLBAR_ITEM_MAP: Record<ToolbarItem, string | null> = {
31
+ const TOOLBAR_ITEM_MAP: Record<ToolbarItem, string | string[] | null> = {
33
32
  bold: 'bold',
34
33
  italic: 'italic',
35
34
  underline: 'underline',
36
35
  strikethrough: 'strikethrough',
37
36
  code: 'code',
37
+ // 'heading' expands to all heading levels since 10tap doesn't support dropdowns
38
+ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
38
39
  heading1: 'h1',
39
40
  heading2: 'h2',
40
41
  heading3: 'h3',
42
+ heading4: 'h4',
43
+ heading5: 'h5',
44
+ heading6: 'h6',
41
45
  bulletList: 'bulletList',
42
46
  orderedList: 'orderedList',
43
47
  taskList: 'taskList',
@@ -183,23 +187,34 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
183
187
  []
184
188
  );
185
189
 
186
- // Map toolbar items
187
- const toolbarItems = (toolbar.items ?? [
190
+ // Default toolbar items - matches web
191
+ const defaultItems: ToolbarItem[] = [
188
192
  'bold',
189
193
  'italic',
190
194
  'underline',
191
195
  'strikethrough',
192
196
  'code',
193
- 'heading1',
194
- 'heading2',
197
+ 'heading',
195
198
  'bulletList',
196
199
  'orderedList',
197
200
  'blockquote',
198
201
  'codeBlock',
199
202
  'link',
200
- ])
201
- .map((item) => TOOLBAR_ITEM_MAP[item])
202
- .filter((item): item is string => item !== null);
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
+ });
203
218
 
204
219
  const showToolbar = toolbar.visible !== false && editable;
205
220
  const toolbarPosition = toolbar.position ?? 'top';
@@ -223,7 +238,7 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
223
238
  accessibilityLabel={accessibilityLabel}
224
239
  >
225
240
  {showToolbar && toolbarPosition === 'top' && (
226
- <Toolbar editor={editor} items={DEFAULT_TOOLBAR_ITEMS} />
241
+ <Toolbar editor={editor} items={toolbarItems} />
227
242
  )}
228
243
  <View style={editorContentStyle}>
229
244
  <RichText
@@ -234,7 +249,7 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
234
249
  />
235
250
  </View>
236
251
  {showToolbar && toolbarPosition === 'bottom' && (
237
- <Toolbar editor={editor} items={DEFAULT_TOOLBAR_ITEMS} />
252
+ <Toolbar editor={editor} items={toolbarItems} />
238
253
  )}
239
254
  </View>
240
255
  );
@@ -48,180 +48,290 @@ export const editorStyles = defineStyle('MarkdownEditor', (theme: Theme) => ({
48
48
  fontSize: theme.sizes.typography.body1.fontSize,
49
49
  lineHeight: theme.sizes.typography.body1.lineHeight,
50
50
  color: theme.colors.text.primary,
51
- _web: {
52
- outline: 'none',
53
- // Tiptap editor styles
54
- '& .tiptap': {
55
- outline: 'none',
56
- minHeight: '100%',
57
- },
58
- '& .tiptap p.is-editor-empty:first-child::before': {
59
- content: 'attr(data-placeholder)',
60
- color: theme.colors.text.tertiary,
61
- pointerEvents: 'none',
62
- float: 'left',
63
- height: 0,
64
- },
65
- // Headings
66
- '& .tiptap h1': {
67
- fontSize: theme.sizes.typography.h1.fontSize,
68
- lineHeight: theme.sizes.typography.h1.lineHeight,
69
- fontWeight: '700',
70
- marginTop: 24,
71
- marginBottom: 16,
72
- },
73
- '& .tiptap h2': {
74
- fontSize: theme.sizes.typography.h2.fontSize,
75
- lineHeight: theme.sizes.typography.h2.lineHeight,
76
- fontWeight: '600',
77
- marginTop: 20,
78
- marginBottom: 12,
79
- borderBottom: `1px solid ${theme.colors.border.primary}`,
80
- paddingBottom: 8,
81
- },
82
- '& .tiptap h3': {
83
- fontSize: theme.sizes.typography.h3.fontSize,
84
- lineHeight: theme.sizes.typography.h3.lineHeight,
85
- fontWeight: '600',
86
- marginTop: 16,
87
- marginBottom: 8,
88
- },
89
- '& .tiptap h4, & .tiptap h5, & .tiptap h6': {
90
- fontWeight: '600',
91
- marginTop: 12,
92
- marginBottom: 8,
93
- },
94
- // Paragraphs
95
- '& .tiptap p': {
96
- marginVertical: 8,
97
- },
98
- // Links
99
- '& .tiptap a': {
100
- color: theme.intents?.primary?.primary ?? theme.colors.text.primary,
101
- textDecoration: 'underline',
102
- cursor: 'pointer',
103
- },
104
- // Code
105
- '& .tiptap code': {
106
- fontFamily: 'monospace',
107
- backgroundColor: theme.colors.surface.secondary,
108
- paddingHorizontal: 6,
109
- paddingVertical: 2,
110
- borderRadius: theme.radii.xs,
111
- fontSize: theme.sizes.typography.caption.fontSize,
112
- },
113
- '& .tiptap pre': {
114
- fontFamily: 'monospace',
115
- backgroundColor: theme.colors.surface.secondary,
116
- padding: 16,
117
- borderRadius: theme.radii.md,
118
- marginVertical: 12,
119
- overflow: 'auto',
120
- },
121
- '& .tiptap pre code': {
122
- backgroundColor: 'transparent',
123
- padding: 0,
124
- },
125
- // Blockquote
126
- '& .tiptap blockquote': {
127
- borderLeft: `4px solid ${theme.colors.border.secondary}`,
128
- paddingLeft: 16,
129
- paddingVertical: 8,
130
- marginVertical: 12,
131
- backgroundColor: theme.colors.surface.secondary,
132
- borderRadius: theme.radii.sm,
133
- fontStyle: 'italic',
134
- color: theme.colors.text.secondary,
135
- },
136
- // Lists
137
- '& .tiptap ul, & .tiptap ol': {
138
- marginVertical: 8,
139
- paddingLeft: 24,
140
- },
141
- '& .tiptap li': {
142
- marginVertical: 4,
143
- },
144
- // Task list
145
- '& .tiptap ul[data-type="taskList"]': {
146
- listStyle: 'none',
147
- padding: 0,
148
- },
149
- '& .tiptap ul[data-type="taskList"] li': {
150
- display: 'flex',
151
- alignItems: 'flex-start',
152
- gap: 8,
153
- },
154
- '& .tiptap ul[data-type="taskList"] li input': {
155
- marginTop: 4,
156
- },
157
- // Horizontal rule
158
- '& .tiptap hr': {
159
- height: 1,
160
- backgroundColor: theme.colors.border.secondary,
161
- marginVertical: 24,
162
- border: 'none',
163
- },
164
- // Table
165
- '& .tiptap table': {
166
- borderCollapse: 'collapse',
167
- width: '100%',
168
- marginVertical: 12,
169
- },
170
- '& .tiptap th, & .tiptap td': {
171
- border: `1px solid ${theme.colors.border.primary}`,
172
- padding: 12,
173
- },
174
- '& .tiptap th': {
175
- backgroundColor: theme.colors.surface.secondary,
176
- fontWeight: '600',
177
- textAlign: 'left',
178
- },
179
- },
180
51
  }),
181
52
 
182
53
  // Toolbar container
183
54
  toolbar: (_props: EditorDynamicProps) => ({
184
- flexDirection: 'row',
185
- flexWrap: 'wrap',
55
+ flexDirection: 'row' as const,
56
+ flexWrap: 'wrap' as const,
186
57
  gap: 4,
187
58
  padding: 8,
188
59
  borderBottomWidth: 1,
189
60
  borderBottomColor: theme.colors.border.primary,
190
61
  backgroundColor: theme.colors.surface.secondary,
62
+ _web: {
63
+ display: 'flex',
64
+ },
191
65
  }),
192
66
 
193
- // Toolbar button
67
+ // Toolbar button base
194
68
  toolbarButton: (_props: EditorDynamicProps) => ({
195
- paddingHorizontal: 10,
196
- paddingVertical: 6,
69
+ paddingLeft: 10,
70
+ paddingRight: 10,
71
+ paddingTop: 6,
72
+ paddingBottom: 6,
197
73
  borderRadius: theme.radii.sm,
198
74
  backgroundColor: 'transparent',
199
75
  borderWidth: 0,
76
+ borderColor: 'transparent',
200
77
  color: theme.colors.text.primary,
201
78
  fontSize: 14,
202
- fontWeight: '500',
79
+ fontWeight: '500' as const,
80
+ minWidth: 32,
81
+ height: 32,
203
82
  _web: {
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
204
86
  cursor: 'pointer',
205
- transition: 'background-color 0.15s ease',
206
- _hover: {
207
- backgroundColor: theme.colors.surface.tertiary,
208
- },
87
+ outline: 'none',
88
+ border: 'none',
209
89
  },
210
90
  }),
211
91
 
212
92
  // Toolbar button active state
213
93
  toolbarButtonActive: (_props: EditorDynamicProps) => ({
214
- backgroundColor: theme.colors.surface.tertiary,
215
- color: theme.intents?.primary?.primary ?? theme.colors.text.primary,
94
+ backgroundColor: theme.intents?.primary?.primary ?? theme.colors.surface.tertiary,
95
+ color: theme.intents?.primary?.contrast ?? theme.colors.text.inverse,
216
96
  }),
217
97
 
218
98
  // Focus ring for accessibility
219
99
  focusRing: (_props: EditorDynamicProps) => ({
220
- _web: {
221
- _focus: {
222
- outline: `2px solid ${theme.intents?.primary?.primary ?? theme.colors.border.primary}`,
223
- outlineOffset: 2,
224
- },
225
- },
226
100
  }),
227
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
+ }
@@ -4,6 +4,8 @@ import {
4
4
  useEffect,
5
5
  useCallback,
6
6
  useRef,
7
+ useId,
8
+ useMemo,
7
9
  } from 'react';
8
10
  import { useEditor, EditorContent } from '@tiptap/react';
9
11
  import StarterKit from '@tiptap/starter-kit';
@@ -13,8 +15,8 @@ import TaskList from '@tiptap/extension-task-list';
13
15
  import TaskItem from '@tiptap/extension-task-item';
14
16
  import Underline from '@tiptap/extension-underline';
15
17
  import { Markdown as TiptapMarkdown } from 'tiptap-markdown';
16
- import { getWebProps } from 'react-native-unistyles/web';
17
- import { editorStyles } from './MarkdownEditor.styles';
18
+ import { useUnistyles } from 'react-native-unistyles';
19
+ import { editorStyles, generateTiptapCSS } from './MarkdownEditor.styles';
18
20
  import { EditorToolbar } from './EditorToolbar.web';
19
21
  import type { MarkdownEditorProps, MarkdownEditorRef } from './types';
20
22
 
@@ -24,8 +26,7 @@ const DEFAULT_TOOLBAR_ITEMS = [
24
26
  'underline',
25
27
  'strikethrough',
26
28
  'code',
27
- 'heading1',
28
- 'heading2',
29
+ 'heading',
29
30
  'bulletList',
30
31
  'orderedList',
31
32
  'blockquote',
@@ -62,8 +63,13 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
62
63
  },
63
64
  ref
64
65
  ) => {
66
+ const styleId = useId();
65
67
  const isControlled = value !== undefined;
66
68
  const lastValueRef = useRef<string>(value ?? initialValue);
69
+ const initializedRef = useRef(false);
70
+
71
+ // Get theme for CSS generation
72
+ const { theme } = useUnistyles();
67
73
 
68
74
  // Apply style variants
69
75
  editorStyles.useVariants({
@@ -71,6 +77,29 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
71
77
  linkIntent,
72
78
  });
73
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
+
74
103
  const editor = useEditor({
75
104
  extensions: [
76
105
  StarterKit.configure({
@@ -98,7 +127,7 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
98
127
  transformCopiedText: true,
99
128
  }),
100
129
  ],
101
- content: value ?? initialValue,
130
+ content: '', // Start empty, we'll set content after creation
102
131
  editable,
103
132
  autofocus: autoFocus,
104
133
  onUpdate: ({ editor: ed }) => {
@@ -110,19 +139,32 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
110
139
  onBlur: () => onBlur?.(),
111
140
  });
112
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
+
113
156
  // Handle controlled value changes
114
157
  useEffect(() => {
115
- if (isControlled && editor && value !== lastValueRef.current) {
158
+ if (isControlled && editor && initializedRef.current && value !== lastValueRef.current) {
116
159
  const { from, to } = editor.state.selection;
117
- editor.commands.setContent(value, false, {
118
- preserveWhitespace: 'full',
119
- });
160
+ // tiptap-markdown patches setContent to handle markdown
161
+ editor.commands.setContent(value ?? '');
120
162
  // Try to restore cursor position
121
163
  const newDocLength = editor.state.doc.content.size;
122
164
  const safeFrom = Math.min(from, newDocLength);
123
165
  const safeTo = Math.min(to, newDocLength);
124
166
  editor.commands.setTextSelection({ from: safeFrom, to: safeTo });
125
- lastValueRef.current = value;
167
+ lastValueRef.current = value ?? '';
126
168
  }
127
169
  }, [value, isControlled, editor]);
128
170
 
@@ -143,9 +185,7 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
143
185
  },
144
186
  setMarkdown: (markdown: string) => {
145
187
  if (editor) {
146
- editor.commands.setContent(markdown, false, {
147
- preserveWhitespace: 'full',
148
- });
188
+ editor.commands.setContent(markdown);
149
189
  lastValueRef.current = markdown;
150
190
  }
151
191
  },
@@ -191,6 +231,15 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
191
231
  case 'heading3':
192
232
  editor.chain().focus().toggleHeading({ level: 3 }).run();
193
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;
194
243
  case 'bulletList':
195
244
  editor.chain().focus().toggleBulletList().run();
196
245
  break;
@@ -257,6 +306,12 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
257
306
  return editor.isActive('heading', { level: 2 });
258
307
  case 'heading3':
259
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 });
260
315
  case 'bulletList':
261
316
  return editor.isActive('bulletList');
262
317
  case 'orderedList':
@@ -276,45 +331,47 @@ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
276
331
  [editor]
277
332
  );
278
333
 
279
- // Get container styles
280
- const containerStyleArray = [
281
- (editorStyles.container as any)({ size, linkIntent }),
282
- minHeight !== undefined && { minHeight },
283
- maxHeight !== undefined && { maxHeight, overflow: 'auto' },
284
- style,
285
- ].filter(Boolean);
334
+ // Build inline styles
335
+ const containerStyle = editorStyles.container({ size, linkIntent });
336
+ const editorContentStyle = editorStyles.editorContent({ size, linkIntent });
286
337
 
287
- const webProps = getWebProps(containerStyleArray);
288
338
  const toolbarItems = toolbar.items ?? DEFAULT_TOOLBAR_ITEMS;
339
+ const toolbarDisabledItems = toolbar.disabledItems ?? [];
289
340
  const showToolbar = toolbar.visible !== false && editable;
290
341
  const toolbarPosition = toolbar.position ?? 'top';
291
342
 
292
343
  return (
293
344
  <div
294
- {...webProps}
295
345
  id={id}
296
346
  data-testid={testID}
297
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
+ }}
298
355
  >
299
356
  {showToolbar && toolbarPosition === 'top' && (
300
357
  <EditorToolbar
358
+ editor={editor}
301
359
  items={toolbarItems}
360
+ disabledItems={toolbarDisabledItems}
302
361
  onAction={handleToolbarAction}
303
362
  isActive={isActive}
304
363
  size={size}
305
364
  linkIntent={linkIntent}
306
365
  />
307
366
  )}
308
- <div
309
- {...getWebProps([
310
- (editorStyles.editorContent as any)({ size, linkIntent }),
311
- ])}
312
- >
367
+ <div style={editorContentStyle as React.CSSProperties}>
313
368
  <EditorContent editor={editor} />
314
369
  </div>
315
370
  {showToolbar && toolbarPosition === 'bottom' && (
316
371
  <EditorToolbar
372
+ editor={editor}
317
373
  items={toolbarItems}
374
+ disabledItems={toolbarDisabledItems}
318
375
  onAction={handleToolbarAction}
319
376
  isActive={isActive}
320
377
  size={size}
@@ -165,6 +165,34 @@ function CustomToolbarExample() {
165
165
  );
166
166
  }
167
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
+
168
196
  /**
169
197
  * Editor with all examples combined
170
198
  */
@@ -188,6 +216,7 @@ export function MarkdownEditorExamples() {
188
216
  <Text size="sm">• Controlled and uncontrolled modes</Text>
189
217
  <Text size="sm">• Ref methods for programmatic control</Text>
190
218
  <Text size="sm">• Configurable toolbar items and position</Text>
219
+ <Text size="sm">• Disable specific toolbar items</Text>
191
220
  <Text size="sm">• Theme integration via Unistyles</Text>
192
221
  </View>
193
222
  </View>
@@ -197,6 +226,7 @@ export function MarkdownEditorExamples() {
197
226
  <ControlledEditorExample />
198
227
  <ReadOnlyEditorExample />
199
228
  <CustomToolbarExample />
229
+ <DisabledItemsExample />
200
230
  </View>
201
231
  </Screen>
202
232
  );
@@ -10,9 +10,13 @@ export type ToolbarItem =
10
10
  | 'underline'
11
11
  | 'strikethrough'
12
12
  | 'code'
13
+ | 'heading' // Dropdown for H1-H6
13
14
  | 'heading1'
14
15
  | 'heading2'
15
16
  | 'heading3'
17
+ | 'heading4'
18
+ | 'heading5'
19
+ | 'heading6'
16
20
  | 'bulletList'
17
21
  | 'orderedList'
18
22
  | 'taskList'
@@ -34,6 +38,11 @@ export interface ToolbarConfig {
34
38
  */
35
39
  items?: ToolbarItem[];
36
40
 
41
+ /**
42
+ * Items to disable in the toolbar (they will still be visible but grayed out)
43
+ */
44
+ disabledItems?: ToolbarItem[];
45
+
37
46
  /**
38
47
  * Whether to show the toolbar
39
48
  * @default true
@@ -0,0 +1 @@
1
+ export { MarkdownEditorExamples } from '../Editor/examples/MarkdownEditorExamples';