@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 +6 -6
- package/src/Editor/EditorToolbar.web.tsx +419 -39
- package/src/Editor/MarkdownEditor.native.tsx +26 -11
- package/src/Editor/MarkdownEditor.styles.ts +257 -147
- package/src/Editor/MarkdownEditor.web.tsx +84 -27
- package/src/Editor/examples/MarkdownEditorExamples.tsx +30 -0
- package/src/Editor/types.ts +9 -0
- package/src/examples/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idealyst/markdown",
|
|
3
|
-
"version": "1.2.
|
|
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/
|
|
34
|
-
"require": "./src/
|
|
35
|
-
"types": "./src/
|
|
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.
|
|
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.
|
|
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 {
|
|
3
|
-
import
|
|
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<
|
|
16
|
-
bold:
|
|
17
|
-
italic:
|
|
18
|
-
underline:
|
|
19
|
-
strikethrough:
|
|
20
|
-
code:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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<
|
|
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
|
-
{
|
|
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
|
-
<
|
|
162
|
+
<ToolbarButton
|
|
71
163
|
key={item}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
|
|
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
|
-
//
|
|
187
|
-
const
|
|
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
|
-
'
|
|
194
|
-
'heading2',
|
|
197
|
+
'heading',
|
|
195
198
|
'bulletList',
|
|
196
199
|
'orderedList',
|
|
197
200
|
'blockquote',
|
|
198
201
|
'codeBlock',
|
|
199
202
|
'link',
|
|
200
|
-
]
|
|
201
|
-
|
|
202
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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?.
|
|
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 {
|
|
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
|
-
'
|
|
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:
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
//
|
|
280
|
-
const
|
|
281
|
-
|
|
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
|
);
|
package/src/Editor/types.ts
CHANGED
|
@@ -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';
|