paris 0.18.1 → 0.20.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +31 -2
  3. package/src/stories/accordion/Accordion.test.tsx +140 -0
  4. package/src/stories/accordionselect/AccordionSelect.test.tsx +252 -0
  5. package/src/stories/avatar/Avatar.test.tsx +77 -0
  6. package/src/stories/button/Button.test.tsx +266 -0
  7. package/src/stories/callout/Callout.test.tsx +79 -0
  8. package/src/stories/card/Card.test.tsx +81 -0
  9. package/src/stories/cardbutton/CardButton.test.tsx +174 -0
  10. package/src/stories/checkbox/Checkbox.test.tsx +531 -0
  11. package/src/stories/combobox/Combobox.test.tsx +164 -0
  12. package/src/stories/dialog/Dialog.module.scss +2 -2
  13. package/src/stories/dialog/Dialog.test.tsx +244 -0
  14. package/src/stories/drawer/Drawer.module.scss +2 -2
  15. package/src/stories/drawer/Drawer.test.tsx +259 -0
  16. package/src/stories/field/Field.test.tsx +146 -0
  17. package/src/stories/icon/Icon.test.tsx +59 -0
  18. package/src/stories/informationaltooltip/InformationalTooltip.test.tsx +178 -0
  19. package/src/stories/input/Input.test.tsx +174 -0
  20. package/src/stories/markdown/Markdown.test.tsx +228 -0
  21. package/src/stories/markdowneditor/FixedToolbar.module.scss +24 -0
  22. package/src/stories/markdowneditor/FixedToolbar.tsx +274 -0
  23. package/src/stories/markdowneditor/FloatingToolbar.module.scss +21 -0
  24. package/src/stories/markdowneditor/FloatingToolbar.tsx +94 -0
  25. package/src/stories/markdowneditor/LinkPopover.module.scss +124 -0
  26. package/src/stories/markdowneditor/LinkPopover.tsx +135 -0
  27. package/src/stories/markdowneditor/MarkdownEditor.module.scss +405 -0
  28. package/src/stories/markdowneditor/MarkdownEditor.stories.tsx +226 -0
  29. package/src/stories/markdowneditor/MarkdownEditor.test.tsx +115 -0
  30. package/src/stories/markdowneditor/MarkdownEditor.tsx +133 -0
  31. package/src/stories/markdowneditor/MarkdownEditorContext.tsx +20 -0
  32. package/src/stories/markdowneditor/ToolbarButton.module.scss +35 -0
  33. package/src/stories/markdowneditor/ToolbarButton.tsx +52 -0
  34. package/src/stories/markdowneditor/features.ts +92 -0
  35. package/src/stories/markdowneditor/index.ts +12 -0
  36. package/src/stories/markdowneditor/useMarkdownEditor.ts +75 -0
  37. package/src/stories/menu/Menu.module.scss +1 -1
  38. package/src/stories/menu/Menu.test.tsx +211 -0
  39. package/src/stories/pagination/usePagination.test.ts +259 -0
  40. package/src/stories/popover/Popover.test.tsx +152 -0
  41. package/src/stories/select/Select.module.scss +2 -1
  42. package/src/stories/select/Select.test.tsx +233 -0
  43. package/src/stories/styledlink/StyledLink.test.tsx +59 -0
  44. package/src/stories/table/Table.test.tsx +156 -0
  45. package/src/stories/tabs/Tabs.module.scss +1 -3
  46. package/src/stories/tabs/Tabs.test.tsx +167 -0
  47. package/src/stories/tabs/Tabs.tsx +9 -0
  48. package/src/stories/tag/Tag.test.tsx +90 -0
  49. package/src/stories/text/Text.test.tsx +81 -0
  50. package/src/stories/textarea/TextArea.test.tsx +147 -0
  51. package/src/stories/theme/themes.ts +16 -0
  52. package/src/stories/tilt/Tilt.test.tsx +203 -0
  53. package/src/stories/toast/Toast.test.tsx +86 -0
  54. package/src/stories/utility/Dropdown.module.scss +1 -1
  55. package/src/stories/utility/Utility.test.tsx +96 -0
  56. package/src/test/render.tsx +20 -0
  57. package/src/test/setup.ts +32 -0
@@ -0,0 +1,226 @@
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
+ // Mock upload handler — creates a local object URL for demo purposes
57
+ const mockImageUpload = async (file: File) => URL.createObjectURL(file);
58
+
59
+ export const Default: Story = {
60
+ render: (args) => {
61
+ const [value, setValue] = useState(sampleMarkdown);
62
+ return (
63
+ <div style={{ maxWidth: 700 }}>
64
+ <MarkdownEditor {...args} value={value} onChange={setValue} handleImageUpload={mockImageUpload}>
65
+ <FixedToolbar />
66
+ <FloatingToolbar />
67
+ </MarkdownEditor>
68
+ <details style={{ marginTop: 16 }}>
69
+ <summary
70
+ style={{ cursor: 'pointer', fontSize: 12, color: 'var(--pte-new-colors-contentTertiary)' }}
71
+ >
72
+ Markdown output
73
+ </summary>
74
+ <pre
75
+ style={{
76
+ marginTop: 8,
77
+ padding: 12,
78
+ fontSize: 12,
79
+ background: 'var(--pte-new-colors-backgroundSecondary)',
80
+ borderRadius: 8,
81
+ overflow: 'auto',
82
+ whiteSpace: 'pre-wrap',
83
+ }}
84
+ >
85
+ {value}
86
+ </pre>
87
+ </details>
88
+ </div>
89
+ );
90
+ },
91
+ args: {
92
+ placeholder: 'Start writing...',
93
+ },
94
+ };
95
+
96
+ /**
97
+ * Editor with only the FloatingToolbar (appears on text selection).
98
+ * Clean editing surface without a fixed toolbar.
99
+ */
100
+ export const FloatingOnly: Story = {
101
+ render: (args) => {
102
+ const [value, setValue] = useState('Select some text to see the floating toolbar.');
103
+ return (
104
+ <div style={{ maxWidth: 700 }}>
105
+ <MarkdownEditor {...args} value={value} onChange={setValue}>
106
+ <FloatingToolbar />
107
+ </MarkdownEditor>
108
+ </div>
109
+ );
110
+ },
111
+ args: {
112
+ placeholder: 'Start writing...',
113
+ },
114
+ };
115
+
116
+ /**
117
+ * Editor with only the FixedToolbar.
118
+ */
119
+ export const FixedOnly: Story = {
120
+ render: (args) => {
121
+ const [value, setValue] = useState('');
122
+ return (
123
+ <div style={{ maxWidth: 700 }}>
124
+ <MarkdownEditor {...args} value={value} onChange={setValue}>
125
+ <FixedToolbar />
126
+ </MarkdownEditor>
127
+ </div>
128
+ );
129
+ },
130
+ args: {
131
+ placeholder: 'Start writing...',
132
+ },
133
+ };
134
+
135
+ /**
136
+ * Editor with a limited feature set — only bold, italic, heading, and link.
137
+ * Toolbar only shows buttons for enabled features.
138
+ */
139
+ export const LimitedFeatures: Story = {
140
+ render: (args) => {
141
+ const [value, setValue] = useState('Only **bold**, *italic*, headings, and links are available.');
142
+ return (
143
+ <div style={{ maxWidth: 700 }}>
144
+ <MarkdownEditor {...args} value={value} onChange={setValue}>
145
+ <FixedToolbar />
146
+ <FloatingToolbar />
147
+ </MarkdownEditor>
148
+ </div>
149
+ );
150
+ },
151
+ args: {
152
+ features: ['bold', 'italic', 'heading', 'link'],
153
+ placeholder: 'Limited formatting...',
154
+ },
155
+ };
156
+
157
+ /**
158
+ * Read-only editor with pre-filled content.
159
+ * The editor is not editable — useful for preview modes.
160
+ */
161
+ export const ReadOnly: Story = {
162
+ render: (args) => {
163
+ return (
164
+ <div style={{ maxWidth: 700 }}>
165
+ <MarkdownEditor {...args} value={sampleMarkdown} editable={false} />
166
+ </div>
167
+ );
168
+ },
169
+ };
170
+
171
+ /**
172
+ * Editor with error status — shows error border styling.
173
+ */
174
+ export const ErrorStatus: Story = {
175
+ render: (args) => {
176
+ const [value, setValue] = useState('');
177
+ return (
178
+ <div style={{ maxWidth: 700 }}>
179
+ <MarkdownEditor {...args} value={value} onChange={setValue} status="error">
180
+ <FixedToolbar />
181
+ </MarkdownEditor>
182
+ </div>
183
+ );
184
+ },
185
+ args: {
186
+ placeholder: 'This field has an error...',
187
+ },
188
+ };
189
+
190
+ /**
191
+ * Editor with no toolbar — users rely on keyboard shortcuts and
192
+ * markdown input rules (e.g., type `## ` for heading 2).
193
+ */
194
+ export const NoToolbar: Story = {
195
+ render: (args) => {
196
+ const [value, setValue] = useState('');
197
+ return (
198
+ <div style={{ maxWidth: 700 }}>
199
+ <MarkdownEditor {...args} value={value} onChange={setValue} />
200
+ </div>
201
+ );
202
+ },
203
+ args: {
204
+ placeholder: 'Type markdown shortcuts: # heading, **bold**, - list item...',
205
+ },
206
+ };
207
+
208
+ /**
209
+ * Editor with custom placeholder text.
210
+ */
211
+ export const WithPlaceholder: Story = {
212
+ render: (args) => {
213
+ const [value, setValue] = useState('');
214
+ return (
215
+ <div style={{ maxWidth: 700 }}>
216
+ <MarkdownEditor {...args} value={value} onChange={setValue}>
217
+ <FixedToolbar />
218
+ </MarkdownEditor>
219
+ </div>
220
+ );
221
+ },
222
+ args: {
223
+ placeholder: 'Write your thoughts here...',
224
+ size: 'paragraphMedium',
225
+ },
226
+ };
@@ -0,0 +1,115 @@
1
+ import { render, screen } from '../../test/render';
2
+ import { MarkdownEditor } from './MarkdownEditor';
3
+
4
+ // ProseMirror needs real DOM measurement APIs that jsdom does not provide.
5
+ const mockEditor = {
6
+ isDestroyed: false,
7
+ isEditable: true,
8
+ getMarkdown: () => 'mock',
9
+ commands: { setContent: vi.fn() },
10
+ setEditable: vi.fn(),
11
+ };
12
+
13
+ vi.mock('@tiptap/react', () => ({
14
+ useEditor: () => mockEditor,
15
+ EditorContent: ({ editor }: any) => (
16
+ <div data-testid="editor-content">{editor ? 'Editor loaded' : 'No editor'}</div>
17
+ ),
18
+ }));
19
+
20
+ describe('MarkdownEditor', () => {
21
+ it('renders without crashing', () => {
22
+ render(<MarkdownEditor value="" />);
23
+ expect(screen.getByTestId('editor-content')).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders the editor content area', () => {
27
+ render(<MarkdownEditor value="" />);
28
+ expect(screen.getByText('Editor loaded')).toBeInTheDocument();
29
+ });
30
+
31
+ it('renders children (toolbar slot) inside the editor container', () => {
32
+ render(
33
+ <MarkdownEditor value="">
34
+ <div data-testid="custom-toolbar">Toolbar</div>
35
+ </MarkdownEditor>,
36
+ );
37
+ expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument();
38
+ });
39
+
40
+ it('applies custom className to root element', () => {
41
+ const { container } = render(<MarkdownEditor value="" className="my-editor" />);
42
+ const root = container.firstElementChild;
43
+ expect(root).toHaveClass('my-editor');
44
+ });
45
+
46
+ it('sets data-status attribute based on status prop', () => {
47
+ const { container } = render(<MarkdownEditor value="" status="error" />);
48
+ const editorContainer = container.querySelector('[data-status]');
49
+ expect(editorContainer).toHaveAttribute('data-status', 'error');
50
+ });
51
+
52
+ it('sets data-status to success', () => {
53
+ const { container } = render(<MarkdownEditor value="" status="success" />);
54
+ const editorContainer = container.querySelector('[data-status]');
55
+ expect(editorContainer).toHaveAttribute('data-status', 'success');
56
+ });
57
+
58
+ it('sets data-status to default by default', () => {
59
+ const { container } = render(<MarkdownEditor value="" />);
60
+ const editorContainer = container.querySelector('[data-status]');
61
+ expect(editorContainer).toHaveAttribute('data-status', 'default');
62
+ });
63
+
64
+ it('sets data-disabled attribute when editable is false', () => {
65
+ const { container } = render(<MarkdownEditor value="" editable={false} />);
66
+ const editorContainer = container.querySelector('[data-disabled]');
67
+ expect(editorContainer).toHaveAttribute('data-disabled', 'true');
68
+ });
69
+
70
+ it('sets data-disabled to false when editable is true (default)', () => {
71
+ const { container } = render(<MarkdownEditor value="" />);
72
+ const editorContainer = container.querySelector('[data-disabled]');
73
+ expect(editorContainer).toHaveAttribute('data-disabled', 'false');
74
+ });
75
+
76
+ it('applies override props to root element', () => {
77
+ const { container } = render(
78
+ <MarkdownEditor
79
+ value=""
80
+ overrides={{
81
+ root: { 'data-testid': 'root-override' },
82
+ }}
83
+ />,
84
+ );
85
+ expect(screen.getByTestId('root-override')).toBeInTheDocument();
86
+ });
87
+
88
+ it('applies override props to editor container element', () => {
89
+ render(
90
+ <MarkdownEditor
91
+ value=""
92
+ overrides={{
93
+ editorContainer: { 'data-testid': 'container-override' },
94
+ }}
95
+ />,
96
+ );
97
+ expect(screen.getByTestId('container-override')).toBeInTheDocument();
98
+ });
99
+
100
+ it('sets the markdown base font size CSS variable', () => {
101
+ const { container } = render(<MarkdownEditor value="" size="paragraphMedium" />);
102
+ const editorContent = container.querySelector('[class*="editorContent"]');
103
+ expect(editorContent).toHaveStyle({
104
+ '--markdown-base-font-size': 'var(--pte-new-typography-styles-paragraphMedium-fontSize)',
105
+ });
106
+ });
107
+
108
+ it('uses paragraphSmall as the default size', () => {
109
+ const { container } = render(<MarkdownEditor value="" />);
110
+ const editorContent = container.querySelector('[class*="editorContent"]');
111
+ expect(editorContent).toHaveStyle({
112
+ '--markdown-base-font-size': 'var(--pte-new-typography-styles-paragraphSmall-fontSize)',
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,133 @@
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 type { ImageUploadHandler } from './MarkdownEditorContext';
12
+ import { MarkdownEditorContext } from './MarkdownEditorContext';
13
+ import { useMarkdownEditor } from './useMarkdownEditor';
14
+
15
+ export type MarkdownEditorProps = {
16
+ /** The markdown string value (controlled). */
17
+ value: string;
18
+ /** Fires with the updated markdown string on every edit. */
19
+ onChange?: (value: string) => void;
20
+ /** Whitelist of formatting features to enable. Defaults to all features. */
21
+ features?: MarkdownEditorFeature[];
22
+ /** Placeholder text shown when the editor is empty. */
23
+ placeholder?: string;
24
+ /**
25
+ * Base text size for body content, matching the Markdown component's size prop.
26
+ * @default 'paragraphSmall'
27
+ */
28
+ size?: MarkdownSize;
29
+ /** Whether the editor is editable. @default true */
30
+ editable?: boolean;
31
+ /** Whether to autofocus the editor on mount. @default false */
32
+ autofocus?: boolean;
33
+ /** An optional CSS class name for the root wrapper. */
34
+ className?: string;
35
+ /**
36
+ * Handler for image uploads. Receives a File, should return a Promise
37
+ * resolving to the image URL. If not provided, the image button is hidden.
38
+ */
39
+ handleImageUpload?: ImageUploadHandler;
40
+ /** Visual status for the editor container (follows Input pattern). */
41
+ status?: 'default' | 'error' | 'success';
42
+ /** Prop overrides for sub-elements. */
43
+ overrides?: {
44
+ root?: ComponentPropsWithoutRef<'div'>;
45
+ editorContainer?: ComponentPropsWithoutRef<'div'>;
46
+ };
47
+ /**
48
+ * Children are placed inside the context provider, before the editor content.
49
+ * This is where toolbar components should be placed.
50
+ */
51
+ children?: ReactNode;
52
+ };
53
+
54
+ /**
55
+ * A WYSIWYG markdown editor built on Tiptap, styled with Paris design tokens.
56
+ * It pairs with the read-only `<Markdown>` component as its write counterpart.
57
+ *
58
+ * The editor uses a compound component pattern — toolbar components are passed
59
+ * as children and communicate with the editor via React context.
60
+ *
61
+ * <hr />
62
+ *
63
+ * To use this component, import it as follows:
64
+ *
65
+ * ```tsx
66
+ * import { MarkdownEditor, FixedToolbar, FloatingToolbar } from 'paris/markdowneditor';
67
+ *
68
+ * export const Example: FC = () => {
69
+ * const [md, setMd] = useState('');
70
+ * return (
71
+ * <MarkdownEditor value={md} onChange={setMd} placeholder="Start writing...">
72
+ * <FixedToolbar />
73
+ * <FloatingToolbar />
74
+ * </MarkdownEditor>
75
+ * );
76
+ * };
77
+ * ```
78
+ *
79
+ * @constructor
80
+ */
81
+ export const MarkdownEditor: FC<MarkdownEditorProps> = ({
82
+ value,
83
+ onChange,
84
+ features = ALL_FEATURES,
85
+ placeholder,
86
+ size = 'paragraphSmall',
87
+ editable = true,
88
+ autofocus = false,
89
+ handleImageUpload,
90
+ className,
91
+ status = 'default',
92
+ overrides,
93
+ children,
94
+ }) => {
95
+ const { editor, featureSet } = useMarkdownEditor({
96
+ value,
97
+ onChange,
98
+ features,
99
+ placeholder,
100
+ editable,
101
+ autofocus,
102
+ });
103
+
104
+ const contextValue = useMemo(
105
+ () => ({ editor, features: featureSet, handleImageUpload }),
106
+ [editor, featureSet, handleImageUpload],
107
+ );
108
+
109
+ return (
110
+ <MarkdownEditorContext.Provider value={contextValue}>
111
+ <div {...overrides?.root} className={clsx(styles.root, className, overrides?.root?.className)}>
112
+ <div
113
+ {...overrides?.editorContainer}
114
+ className={clsx(styles.editorContainer, overrides?.editorContainer?.className)}
115
+ data-status={status}
116
+ data-disabled={!editable}
117
+ >
118
+ {children}
119
+ <div
120
+ className={styles.editorContent}
121
+ style={
122
+ {
123
+ '--markdown-base-font-size': `var(--pte-new-typography-styles-${size}-fontSize)`,
124
+ } as React.CSSProperties
125
+ }
126
+ >
127
+ <EditorContent editor={editor} />
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </MarkdownEditorContext.Provider>
132
+ );
133
+ };
@@ -0,0 +1,20 @@
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 ImageUploadHandler = (file: File) => Promise<string>;
8
+
9
+ export type MarkdownEditorContextValue = {
10
+ editor: Editor | null;
11
+ features: Set<MarkdownEditorFeature>;
12
+ handleImageUpload?: ImageUploadHandler;
13
+ };
14
+
15
+ export const MarkdownEditorContext = createContext<MarkdownEditorContextValue>({
16
+ editor: null,
17
+ features: new Set(),
18
+ });
19
+
20
+ 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,12 @@
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 type { ImageUploadHandler } from './MarkdownEditorContext';
10
+ export { useMarkdownEditorContext } from './MarkdownEditorContext';
11
+ export type { ToolbarButtonProps } from './ToolbarButton';
12
+ export { ToolbarButton } from './ToolbarButton';