paris 0.18.1 → 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 +6 -0
- package/package.json +15 -1
- 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 +0 -2
- package/src/stories/tabs/Tabs.tsx +9 -0
|
@@ -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
|
+
}
|
|
@@ -112,6 +112,15 @@ export const Tabs: FC<TabsProps> = ({
|
|
|
112
112
|
<div
|
|
113
113
|
{...overrides?.tabBackground}
|
|
114
114
|
className={clsx(styles.tabBackground, styles[backgroundStyle], overrides?.tabBackground?.className)}
|
|
115
|
+
style={{
|
|
116
|
+
...(backgroundStyle === 'glass'
|
|
117
|
+
? {
|
|
118
|
+
backdropFilter: 'var(--pte-new-blurs-strong)',
|
|
119
|
+
WebkitBackdropFilter: 'var(--pte-new-blurs-strong)',
|
|
120
|
+
}
|
|
121
|
+
: {}),
|
|
122
|
+
...overrides?.tabBackground?.style,
|
|
123
|
+
}}
|
|
115
124
|
>
|
|
116
125
|
<TabList
|
|
117
126
|
{...overrides?.tabList}
|