paris 0.18.0 → 0.19.0
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/CHANGELOG.md +16 -0
- package/package.json +15 -1
- package/src/stories/markdown/Markdown.module.scss +2 -2
- package/src/stories/markdowneditor/FixedToolbar.module.scss +24 -0
- package/src/stories/markdowneditor/FixedToolbar.tsx +244 -0
- package/src/stories/markdowneditor/FloatingToolbar.module.scss +21 -0
- package/src/stories/markdowneditor/FloatingToolbar.tsx +94 -0
- package/src/stories/markdowneditor/LinkPopover.module.scss +124 -0
- package/src/stories/markdowneditor/LinkPopover.tsx +135 -0
- package/src/stories/markdowneditor/MarkdownEditor.module.scss +405 -0
- package/src/stories/markdowneditor/MarkdownEditor.stories.tsx +223 -0
- package/src/stories/markdowneditor/MarkdownEditor.tsx +123 -0
- package/src/stories/markdowneditor/MarkdownEditorContext.tsx +17 -0
- package/src/stories/markdowneditor/ToolbarButton.module.scss +35 -0
- package/src/stories/markdowneditor/ToolbarButton.tsx +52 -0
- package/src/stories/markdowneditor/features.ts +92 -0
- package/src/stories/markdowneditor/index.ts +11 -0
- package/src/stories/markdowneditor/useMarkdownEditor.ts +75 -0
- package/src/stories/tabs/Tabs.module.scss +8 -68
- package/src/stories/tabs/Tabs.stories.tsx +81 -2
- package/src/stories/tabs/Tabs.tsx +9 -6
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { FixedToolbar } from './FixedToolbar';
|
|
4
|
+
import { FloatingToolbar } from './FloatingToolbar';
|
|
5
|
+
import { MarkdownEditor } from './MarkdownEditor';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof MarkdownEditor> = {
|
|
8
|
+
title: 'Content/MarkdownEditor',
|
|
9
|
+
component: MarkdownEditor,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
argTypes: {
|
|
12
|
+
size: {
|
|
13
|
+
control: 'select',
|
|
14
|
+
options: ['paragraphLarge', 'paragraphMedium', 'paragraphSmall', 'paragraphXSmall', 'paragraphXXSmall'],
|
|
15
|
+
},
|
|
16
|
+
status: {
|
|
17
|
+
control: 'select',
|
|
18
|
+
options: ['default', 'error', 'success'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof MarkdownEditor>;
|
|
25
|
+
|
|
26
|
+
const sampleMarkdown = `# Getting Started
|
|
27
|
+
|
|
28
|
+
This is a **WYSIWYG** markdown editor built on *Tiptap*.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Bold, italic, and ~~strikethrough~~
|
|
33
|
+
- [Links](https://paris.slingshot.fm) and \`inline code\`
|
|
34
|
+
- Headings, blockquotes, and horizontal rules
|
|
35
|
+
|
|
36
|
+
> Blockquotes render with Paris styling.
|
|
37
|
+
|
|
38
|
+
### Task Lists
|
|
39
|
+
|
|
40
|
+
- [x] Set up Tiptap
|
|
41
|
+
- [x] Add markdown serialization
|
|
42
|
+
- [ ] Style with Paris tokens
|
|
43
|
+
|
|
44
|
+
\`\`\`typescript
|
|
45
|
+
const [md, setMd] = useState('');
|
|
46
|
+
\`\`\`
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
That's it! The editor outputs **markdown** on every change.`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default editor with both FixedToolbar and FloatingToolbar.
|
|
54
|
+
* All features enabled.
|
|
55
|
+
*/
|
|
56
|
+
export const Default: Story = {
|
|
57
|
+
render: (args) => {
|
|
58
|
+
const [value, setValue] = useState(sampleMarkdown);
|
|
59
|
+
return (
|
|
60
|
+
<div style={{ maxWidth: 700 }}>
|
|
61
|
+
<MarkdownEditor {...args} value={value} onChange={setValue}>
|
|
62
|
+
<FixedToolbar />
|
|
63
|
+
<FloatingToolbar />
|
|
64
|
+
</MarkdownEditor>
|
|
65
|
+
<details style={{ marginTop: 16 }}>
|
|
66
|
+
<summary
|
|
67
|
+
style={{ cursor: 'pointer', fontSize: 12, color: 'var(--pte-new-colors-contentTertiary)' }}
|
|
68
|
+
>
|
|
69
|
+
Markdown output
|
|
70
|
+
</summary>
|
|
71
|
+
<pre
|
|
72
|
+
style={{
|
|
73
|
+
marginTop: 8,
|
|
74
|
+
padding: 12,
|
|
75
|
+
fontSize: 12,
|
|
76
|
+
background: 'var(--pte-new-colors-backgroundSecondary)',
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
overflow: 'auto',
|
|
79
|
+
whiteSpace: 'pre-wrap',
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{value}
|
|
83
|
+
</pre>
|
|
84
|
+
</details>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
args: {
|
|
89
|
+
placeholder: 'Start writing...',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Editor with only the FloatingToolbar (appears on text selection).
|
|
95
|
+
* Clean editing surface without a fixed toolbar.
|
|
96
|
+
*/
|
|
97
|
+
export const FloatingOnly: Story = {
|
|
98
|
+
render: (args) => {
|
|
99
|
+
const [value, setValue] = useState('Select some text to see the floating toolbar.');
|
|
100
|
+
return (
|
|
101
|
+
<div style={{ maxWidth: 700 }}>
|
|
102
|
+
<MarkdownEditor {...args} value={value} onChange={setValue}>
|
|
103
|
+
<FloatingToolbar />
|
|
104
|
+
</MarkdownEditor>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
args: {
|
|
109
|
+
placeholder: 'Start writing...',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Editor with only the FixedToolbar.
|
|
115
|
+
*/
|
|
116
|
+
export const FixedOnly: Story = {
|
|
117
|
+
render: (args) => {
|
|
118
|
+
const [value, setValue] = useState('');
|
|
119
|
+
return (
|
|
120
|
+
<div style={{ maxWidth: 700 }}>
|
|
121
|
+
<MarkdownEditor {...args} value={value} onChange={setValue}>
|
|
122
|
+
<FixedToolbar />
|
|
123
|
+
</MarkdownEditor>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
args: {
|
|
128
|
+
placeholder: 'Start writing...',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Editor with a limited feature set — only bold, italic, heading, and link.
|
|
134
|
+
* Toolbar only shows buttons for enabled features.
|
|
135
|
+
*/
|
|
136
|
+
export const LimitedFeatures: Story = {
|
|
137
|
+
render: (args) => {
|
|
138
|
+
const [value, setValue] = useState('Only **bold**, *italic*, headings, and links are available.');
|
|
139
|
+
return (
|
|
140
|
+
<div style={{ maxWidth: 700 }}>
|
|
141
|
+
<MarkdownEditor {...args} value={value} onChange={setValue}>
|
|
142
|
+
<FixedToolbar />
|
|
143
|
+
<FloatingToolbar />
|
|
144
|
+
</MarkdownEditor>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
args: {
|
|
149
|
+
features: ['bold', 'italic', 'heading', 'link'],
|
|
150
|
+
placeholder: 'Limited formatting...',
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read-only editor with pre-filled content.
|
|
156
|
+
* The editor is not editable — useful for preview modes.
|
|
157
|
+
*/
|
|
158
|
+
export const ReadOnly: Story = {
|
|
159
|
+
render: (args) => {
|
|
160
|
+
return (
|
|
161
|
+
<div style={{ maxWidth: 700 }}>
|
|
162
|
+
<MarkdownEditor {...args} value={sampleMarkdown} editable={false} />
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Editor with error status — shows error border styling.
|
|
170
|
+
*/
|
|
171
|
+
export const ErrorStatus: Story = {
|
|
172
|
+
render: (args) => {
|
|
173
|
+
const [value, setValue] = useState('');
|
|
174
|
+
return (
|
|
175
|
+
<div style={{ maxWidth: 700 }}>
|
|
176
|
+
<MarkdownEditor {...args} value={value} onChange={setValue} status="error">
|
|
177
|
+
<FixedToolbar />
|
|
178
|
+
</MarkdownEditor>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
args: {
|
|
183
|
+
placeholder: 'This field has an error...',
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Editor with no toolbar — users rely on keyboard shortcuts and
|
|
189
|
+
* markdown input rules (e.g., type `## ` for heading 2).
|
|
190
|
+
*/
|
|
191
|
+
export const NoToolbar: Story = {
|
|
192
|
+
render: (args) => {
|
|
193
|
+
const [value, setValue] = useState('');
|
|
194
|
+
return (
|
|
195
|
+
<div style={{ maxWidth: 700 }}>
|
|
196
|
+
<MarkdownEditor {...args} value={value} onChange={setValue} />
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
args: {
|
|
201
|
+
placeholder: 'Type markdown shortcuts: # heading, **bold**, - list item...',
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Editor with custom placeholder text.
|
|
207
|
+
*/
|
|
208
|
+
export const WithPlaceholder: Story = {
|
|
209
|
+
render: (args) => {
|
|
210
|
+
const [value, setValue] = useState('');
|
|
211
|
+
return (
|
|
212
|
+
<div style={{ maxWidth: 700 }}>
|
|
213
|
+
<MarkdownEditor {...args} value={value} onChange={setValue}>
|
|
214
|
+
<FixedToolbar />
|
|
215
|
+
</MarkdownEditor>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
args: {
|
|
220
|
+
placeholder: 'Write your thoughts here...',
|
|
221
|
+
size: 'paragraphMedium',
|
|
222
|
+
},
|
|
223
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { EditorContent } from '@tiptap/react';
|
|
4
|
+
import { clsx } from 'clsx';
|
|
5
|
+
import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
import type { MarkdownSize } from '../markdown';
|
|
8
|
+
import type { MarkdownEditorFeature } from './features';
|
|
9
|
+
import { ALL_FEATURES } from './features';
|
|
10
|
+
import styles from './MarkdownEditor.module.scss';
|
|
11
|
+
import { MarkdownEditorContext } from './MarkdownEditorContext';
|
|
12
|
+
import { useMarkdownEditor } from './useMarkdownEditor';
|
|
13
|
+
|
|
14
|
+
export type MarkdownEditorProps = {
|
|
15
|
+
/** The markdown string value (controlled). */
|
|
16
|
+
value: string;
|
|
17
|
+
/** Fires with the updated markdown string on every edit. */
|
|
18
|
+
onChange?: (value: string) => void;
|
|
19
|
+
/** Whitelist of formatting features to enable. Defaults to all features. */
|
|
20
|
+
features?: MarkdownEditorFeature[];
|
|
21
|
+
/** Placeholder text shown when the editor is empty. */
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Base text size for body content, matching the Markdown component's size prop.
|
|
25
|
+
* @default 'paragraphSmall'
|
|
26
|
+
*/
|
|
27
|
+
size?: MarkdownSize;
|
|
28
|
+
/** Whether the editor is editable. @default true */
|
|
29
|
+
editable?: boolean;
|
|
30
|
+
/** Whether to autofocus the editor on mount. @default false */
|
|
31
|
+
autofocus?: boolean;
|
|
32
|
+
/** An optional CSS class name for the root wrapper. */
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Visual status for the editor container (follows Input pattern). */
|
|
35
|
+
status?: 'default' | 'error' | 'success';
|
|
36
|
+
/** Prop overrides for sub-elements. */
|
|
37
|
+
overrides?: {
|
|
38
|
+
root?: ComponentPropsWithoutRef<'div'>;
|
|
39
|
+
editorContainer?: ComponentPropsWithoutRef<'div'>;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Children are placed inside the context provider, before the editor content.
|
|
43
|
+
* This is where toolbar components should be placed.
|
|
44
|
+
*/
|
|
45
|
+
children?: ReactNode;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A WYSIWYG markdown editor built on Tiptap, styled with Paris design tokens.
|
|
50
|
+
* It pairs with the read-only `<Markdown>` component as its write counterpart.
|
|
51
|
+
*
|
|
52
|
+
* The editor uses a compound component pattern — toolbar components are passed
|
|
53
|
+
* as children and communicate with the editor via React context.
|
|
54
|
+
*
|
|
55
|
+
* <hr />
|
|
56
|
+
*
|
|
57
|
+
* To use this component, import it as follows:
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* import { MarkdownEditor, FixedToolbar, FloatingToolbar } from 'paris/markdowneditor';
|
|
61
|
+
*
|
|
62
|
+
* export const Example: FC = () => {
|
|
63
|
+
* const [md, setMd] = useState('');
|
|
64
|
+
* return (
|
|
65
|
+
* <MarkdownEditor value={md} onChange={setMd} placeholder="Start writing...">
|
|
66
|
+
* <FixedToolbar />
|
|
67
|
+
* <FloatingToolbar />
|
|
68
|
+
* </MarkdownEditor>
|
|
69
|
+
* );
|
|
70
|
+
* };
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @constructor
|
|
74
|
+
*/
|
|
75
|
+
export const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
|
76
|
+
value,
|
|
77
|
+
onChange,
|
|
78
|
+
features = ALL_FEATURES,
|
|
79
|
+
placeholder,
|
|
80
|
+
size = 'paragraphSmall',
|
|
81
|
+
editable = true,
|
|
82
|
+
autofocus = false,
|
|
83
|
+
className,
|
|
84
|
+
status = 'default',
|
|
85
|
+
overrides,
|
|
86
|
+
children,
|
|
87
|
+
}) => {
|
|
88
|
+
const { editor, featureSet } = useMarkdownEditor({
|
|
89
|
+
value,
|
|
90
|
+
onChange,
|
|
91
|
+
features,
|
|
92
|
+
placeholder,
|
|
93
|
+
editable,
|
|
94
|
+
autofocus,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const contextValue = useMemo(() => ({ editor, features: featureSet }), [editor, featureSet]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<MarkdownEditorContext.Provider value={contextValue}>
|
|
101
|
+
<div {...overrides?.root} className={clsx(styles.root, className, overrides?.root?.className)}>
|
|
102
|
+
<div
|
|
103
|
+
{...overrides?.editorContainer}
|
|
104
|
+
className={clsx(styles.editorContainer, overrides?.editorContainer?.className)}
|
|
105
|
+
data-status={status}
|
|
106
|
+
data-disabled={!editable}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
<div
|
|
110
|
+
className={styles.editorContent}
|
|
111
|
+
style={
|
|
112
|
+
{
|
|
113
|
+
'--markdown-base-font-size': `var(--pte-new-typography-styles-${size}-fontSize)`,
|
|
114
|
+
} as React.CSSProperties
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
<EditorContent editor={editor} />
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</MarkdownEditorContext.Provider>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Editor } from '@tiptap/react';
|
|
4
|
+
import { createContext, useContext } from 'react';
|
|
5
|
+
import type { MarkdownEditorFeature } from './features';
|
|
6
|
+
|
|
7
|
+
export type MarkdownEditorContextValue = {
|
|
8
|
+
editor: Editor | null;
|
|
9
|
+
features: Set<MarkdownEditorFeature>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const MarkdownEditorContext = createContext<MarkdownEditorContextValue>({
|
|
13
|
+
editor: null,
|
|
14
|
+
features: new Set(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const useMarkdownEditorContext = () => useContext(MarkdownEditorContext);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.button {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
width: 28px;
|
|
6
|
+
height: 28px;
|
|
7
|
+
border: none;
|
|
8
|
+
border-radius: 6px;
|
|
9
|
+
background: transparent;
|
|
10
|
+
color: var(--pte-new-colors-contentSecondary);
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
padding: 0;
|
|
13
|
+
font-size: 13px;
|
|
14
|
+
font-family: inherit;
|
|
15
|
+
transition:
|
|
16
|
+
background-color var(--pte-new-animations-interaction, 150ms) ease,
|
|
17
|
+
color var(--pte-new-animations-interaction, 150ms) ease;
|
|
18
|
+
flex-shrink: 0;
|
|
19
|
+
|
|
20
|
+
&:hover {
|
|
21
|
+
background-color: var(--pte-new-colors-backgroundSecondary);
|
|
22
|
+
color: var(--pte-new-colors-contentPrimary);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&[data-active='true'] {
|
|
26
|
+
background-color: var(--pte-new-colors-borderSubtle);
|
|
27
|
+
color: var(--pte-new-colors-contentPrimary);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&:disabled {
|
|
31
|
+
color: var(--pte-new-colors-contentDisabled);
|
|
32
|
+
cursor: not-allowed;
|
|
33
|
+
background: transparent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { clsx } from 'clsx';
|
|
4
|
+
import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
|
|
5
|
+
import styles from './ToolbarButton.module.scss';
|
|
6
|
+
|
|
7
|
+
export type ToolbarButtonProps = {
|
|
8
|
+
/** Whether the button is currently active (e.g., bold is on). */
|
|
9
|
+
isActive?: boolean;
|
|
10
|
+
/** The action to perform when clicked. */
|
|
11
|
+
onAction: () => void;
|
|
12
|
+
/** Whether the button is disabled. */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** Icon or label content. */
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
/** Optional tooltip text. */
|
|
17
|
+
tooltip?: string;
|
|
18
|
+
/** Prop overrides for the button element. */
|
|
19
|
+
overrides?: {
|
|
20
|
+
button?: ComponentPropsWithoutRef<'button'>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A toolbar button used in FixedToolbar and FloatingToolbar.
|
|
26
|
+
* Uses onMouseDown instead of onClick to prevent stealing editor focus.
|
|
27
|
+
*/
|
|
28
|
+
export const ToolbarButton: FC<ToolbarButtonProps> = ({
|
|
29
|
+
isActive = false,
|
|
30
|
+
onAction,
|
|
31
|
+
disabled = false,
|
|
32
|
+
children,
|
|
33
|
+
tooltip,
|
|
34
|
+
overrides,
|
|
35
|
+
}) => {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
title={tooltip}
|
|
40
|
+
disabled={disabled}
|
|
41
|
+
data-active={isActive}
|
|
42
|
+
{...overrides?.button}
|
|
43
|
+
className={clsx(styles.button, overrides?.button?.className)}
|
|
44
|
+
onMouseDown={(e) => {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
if (!disabled) onAction();
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Image from '@tiptap/extension-image';
|
|
2
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
3
|
+
import { Table } from '@tiptap/extension-table';
|
|
4
|
+
import TableCell from '@tiptap/extension-table-cell';
|
|
5
|
+
import TableHeader from '@tiptap/extension-table-header';
|
|
6
|
+
import TableRow from '@tiptap/extension-table-row';
|
|
7
|
+
import TaskItem from '@tiptap/extension-task-item';
|
|
8
|
+
import TaskList from '@tiptap/extension-task-list';
|
|
9
|
+
import { Markdown } from '@tiptap/markdown';
|
|
10
|
+
import type { Extensions } from '@tiptap/react';
|
|
11
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
12
|
+
|
|
13
|
+
export const FEATURES = [
|
|
14
|
+
'bold',
|
|
15
|
+
'italic',
|
|
16
|
+
'strikethrough',
|
|
17
|
+
'heading',
|
|
18
|
+
'blockquote',
|
|
19
|
+
'horizontalRule',
|
|
20
|
+
'bulletList',
|
|
21
|
+
'orderedList',
|
|
22
|
+
'taskList',
|
|
23
|
+
'codeBlock',
|
|
24
|
+
'code',
|
|
25
|
+
'link',
|
|
26
|
+
'image',
|
|
27
|
+
'table',
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type MarkdownEditorFeature = (typeof FEATURES)[number];
|
|
31
|
+
|
|
32
|
+
export const ALL_FEATURES: MarkdownEditorFeature[] = [...FEATURES];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Builds the Tiptap extensions array based on enabled features.
|
|
36
|
+
* Always includes: Document, Paragraph, Text, History, Markdown, Placeholder.
|
|
37
|
+
* Conditionally includes StarterKit sub-extensions and standalone extensions.
|
|
38
|
+
*/
|
|
39
|
+
export function buildExtensions(features: Set<MarkdownEditorFeature>, placeholder?: string): Extensions {
|
|
40
|
+
const extensions: Extensions = [
|
|
41
|
+
StarterKit.configure({
|
|
42
|
+
bold: features.has('bold') ? {} : false,
|
|
43
|
+
italic: features.has('italic') ? {} : false,
|
|
44
|
+
strike: features.has('strikethrough') ? {} : false,
|
|
45
|
+
heading: features.has('heading') ? { levels: [1, 2, 3, 4, 5, 6] } : false,
|
|
46
|
+
blockquote: features.has('blockquote') ? {} : false,
|
|
47
|
+
horizontalRule: features.has('horizontalRule') ? {} : false,
|
|
48
|
+
bulletList: features.has('bulletList') ? {} : false,
|
|
49
|
+
orderedList: features.has('orderedList') ? {} : false,
|
|
50
|
+
listItem: features.has('bulletList') || features.has('orderedList') ? {} : false,
|
|
51
|
+
codeBlock: features.has('codeBlock') ? {} : false,
|
|
52
|
+
code: features.has('code') ? {} : false,
|
|
53
|
+
link: features.has('link')
|
|
54
|
+
? {
|
|
55
|
+
openOnClick: false,
|
|
56
|
+
HTMLAttributes: {
|
|
57
|
+
rel: 'noopener noreferrer',
|
|
58
|
+
target: '_blank',
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
: false,
|
|
62
|
+
}),
|
|
63
|
+
Markdown,
|
|
64
|
+
Placeholder.configure({
|
|
65
|
+
placeholder: placeholder ?? '',
|
|
66
|
+
showOnlyCurrent: false,
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
if (features.has('taskList')) {
|
|
71
|
+
extensions.push(TaskList);
|
|
72
|
+
extensions.push(TaskItem.configure({ nested: true }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (features.has('table')) {
|
|
76
|
+
extensions.push(Table.configure({ resizable: false }));
|
|
77
|
+
extensions.push(TableRow);
|
|
78
|
+
extensions.push(TableCell);
|
|
79
|
+
extensions.push(TableHeader);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (features.has('image')) {
|
|
83
|
+
extensions.push(
|
|
84
|
+
Image.configure({
|
|
85
|
+
inline: false,
|
|
86
|
+
allowBase64: true,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return extensions;
|
|
92
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { FixedToolbarProps } from './FixedToolbar';
|
|
2
|
+
export { FixedToolbar } from './FixedToolbar';
|
|
3
|
+
export type { FloatingToolbarProps } from './FloatingToolbar';
|
|
4
|
+
export { FloatingToolbar } from './FloatingToolbar';
|
|
5
|
+
export type { MarkdownEditorFeature } from './features';
|
|
6
|
+
export { ALL_FEATURES, FEATURES } from './features';
|
|
7
|
+
export type { MarkdownEditorProps } from './MarkdownEditor';
|
|
8
|
+
export { MarkdownEditor } from './MarkdownEditor';
|
|
9
|
+
export { useMarkdownEditorContext } from './MarkdownEditorContext';
|
|
10
|
+
export type { ToolbarButtonProps } from './ToolbarButton';
|
|
11
|
+
export { ToolbarButton } from './ToolbarButton';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEditor } from '@tiptap/react';
|
|
4
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
5
|
+
import type { MarkdownEditorFeature } from './features';
|
|
6
|
+
import { ALL_FEATURES, buildExtensions } from './features';
|
|
7
|
+
|
|
8
|
+
export type UseMarkdownEditorOptions = {
|
|
9
|
+
/** The markdown string value (controlled). */
|
|
10
|
+
value: string;
|
|
11
|
+
/** Fires with the updated markdown string on every edit. */
|
|
12
|
+
onChange?: (value: string) => void;
|
|
13
|
+
/** Whitelist of formatting features. Defaults to ALL_FEATURES. */
|
|
14
|
+
features?: MarkdownEditorFeature[];
|
|
15
|
+
/** Placeholder text when the editor is empty. */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
/** Whether the editor is editable. @default true */
|
|
18
|
+
editable?: boolean;
|
|
19
|
+
/** Whether to autofocus the editor on mount. @default false */
|
|
20
|
+
autofocus?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function useMarkdownEditor({
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
features = ALL_FEATURES,
|
|
27
|
+
placeholder,
|
|
28
|
+
editable = true,
|
|
29
|
+
autofocus = false,
|
|
30
|
+
}: UseMarkdownEditorOptions) {
|
|
31
|
+
const featureSet = useMemo(() => new Set(features), [features]);
|
|
32
|
+
const extensions = useMemo(() => buildExtensions(featureSet, placeholder), [featureSet, placeholder]);
|
|
33
|
+
|
|
34
|
+
// Track the last value we sent via onChange to prevent circular updates
|
|
35
|
+
const lastOnChangeValue = useRef(value);
|
|
36
|
+
|
|
37
|
+
const editor = useEditor({
|
|
38
|
+
extensions,
|
|
39
|
+
// Empty strings produce no DOM nodes with contentType: 'markdown',
|
|
40
|
+
// which breaks the placeholder. Only use markdown parsing for non-empty content.
|
|
41
|
+
content: value || undefined,
|
|
42
|
+
contentType: value ? 'markdown' : undefined,
|
|
43
|
+
editable,
|
|
44
|
+
autofocus,
|
|
45
|
+
immediatelyRender: false,
|
|
46
|
+
onUpdate: ({ editor: ed }) => {
|
|
47
|
+
const markdown = ed.getMarkdown();
|
|
48
|
+
lastOnChangeValue.current = markdown;
|
|
49
|
+
onChange?.(markdown);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Sync external value changes into the editor (controlled mode)
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!editor || editor.isDestroyed) return;
|
|
56
|
+
|
|
57
|
+
// Skip if this value came from our own onChange
|
|
58
|
+
if (value === lastOnChangeValue.current) return;
|
|
59
|
+
|
|
60
|
+
const currentMarkdown = editor.getMarkdown();
|
|
61
|
+
if (value !== currentMarkdown) {
|
|
62
|
+
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
|
|
63
|
+
lastOnChangeValue.current = value;
|
|
64
|
+
}
|
|
65
|
+
}, [editor, value]);
|
|
66
|
+
|
|
67
|
+
// Sync editable state
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (editor && !editor.isDestroyed) {
|
|
70
|
+
editor.setEditable(editable);
|
|
71
|
+
}
|
|
72
|
+
}, [editor, editable]);
|
|
73
|
+
|
|
74
|
+
return { editor, featureSet };
|
|
75
|
+
}
|