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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # paris
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4a511cc: Add MarkdownEditor component — a WYSIWYG rich text editor built on Tiptap that outputs markdown. Features composable toolbars (FixedToolbar, FloatingToolbar), controlled value/onChange with markdown strings, feature gating via `features` prop, and Paris-styled content rendering. Adds lucide-react for toolbar icons.
8
+
3
9
  ## 0.18.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "paris",
3
3
  "author": "Sanil Chawla <sanil@slingshot.fm> (https://sanil.co)",
4
4
  "description": "Paris is Slingshot's React design system. It's a collection of reusable components, design tokens, and guidelines that help us build consistent, accessible, and performant user interfaces.",
5
- "version": "0.18.1",
5
+ "version": "0.19.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -62,6 +62,7 @@
62
62
  "./informationaltooltip": "./src/stories/informationaltooltip/index.ts",
63
63
  "./input": "./src/stories/input/index.ts",
64
64
  "./markdown": "./src/stories/markdown/index.ts",
65
+ "./markdowneditor": "./src/stories/markdowneditor/index.ts",
65
66
  "./menu": "./src/stories/menu/index.ts",
66
67
  "./pagination": "./src/stories/pagination/index.ts",
67
68
  "./popover": "./src/stories/popover/index.ts",
@@ -87,9 +88,22 @@
87
88
  "@headlessui/react": "^2.2.4",
88
89
  "@radix-ui/react-checkbox": "^1.3.3",
89
90
  "@radix-ui/react-tooltip": "^1.2.8",
91
+ "@tiptap/extension-image": "^3.22.2",
92
+ "@tiptap/extension-link": "^3.22.2",
93
+ "@tiptap/extension-placeholder": "^3.22.2",
94
+ "@tiptap/extension-table": "^3.22.2",
95
+ "@tiptap/extension-table-cell": "^3.22.2",
96
+ "@tiptap/extension-table-header": "^3.22.2",
97
+ "@tiptap/extension-table-row": "^3.22.2",
98
+ "@tiptap/extension-task-item": "^3.22.2",
99
+ "@tiptap/extension-task-list": "^3.22.2",
100
+ "@tiptap/markdown": "^3.22.2",
101
+ "@tiptap/react": "^3.22.2",
102
+ "@tiptap/starter-kit": "^3.22.2",
90
103
  "clsx": "^1.2.1",
91
104
  "font-color-contrast": "^11.1.0",
92
105
  "framer-motion": "^12.24.10",
106
+ "lucide-react": "^1.7.0",
93
107
  "pte": "^0.5.0",
94
108
  "react-hot-toast": "^2.4.1",
95
109
  "react-markdown": "^10.1.0",
@@ -0,0 +1,24 @@
1
+ .toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 2px;
5
+ padding: 6px 8px;
6
+ border-bottom: 1px solid var(--pte-new-colors-borderSubtle);
7
+ background-color: var(--pte-new-colors-backgroundPrimary);
8
+ flex-wrap: wrap;
9
+ position: relative;
10
+ }
11
+
12
+ .group {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 2px;
16
+ }
17
+
18
+ .separator {
19
+ width: 1px;
20
+ height: 18px;
21
+ background-color: var(--pte-new-colors-borderSubtle);
22
+ margin: 0 4px;
23
+ flex-shrink: 0;
24
+ }
@@ -0,0 +1,244 @@
1
+ 'use client';
2
+
3
+ import { clsx } from 'clsx';
4
+ import {
5
+ Bold,
6
+ Code,
7
+ Heading1,
8
+ Heading2,
9
+ Heading3,
10
+ Image,
11
+ Italic,
12
+ Link,
13
+ List,
14
+ ListChecks,
15
+ ListOrdered,
16
+ Minus,
17
+ Quote,
18
+ SquareCode,
19
+ Strikethrough,
20
+ Table,
21
+ } from 'lucide-react';
22
+ import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
23
+ import { Fragment, useCallback, useState } from 'react';
24
+ import styles from './FixedToolbar.module.scss';
25
+ import type { MarkdownEditorFeature } from './features';
26
+ import { LinkPopover } from './LinkPopover';
27
+ import { useMarkdownEditorContext } from './MarkdownEditorContext';
28
+ import { ToolbarButton } from './ToolbarButton';
29
+
30
+ export type FixedToolbarProps = {
31
+ /** An optional CSS class name. */
32
+ className?: string;
33
+ /** Prop overrides for sub-elements. */
34
+ overrides?: {
35
+ root?: ComponentPropsWithoutRef<'div'>;
36
+ group?: ComponentPropsWithoutRef<'div'>;
37
+ separator?: ComponentPropsWithoutRef<'div'>;
38
+ };
39
+ };
40
+
41
+ type ToolbarItem = {
42
+ feature: MarkdownEditorFeature;
43
+ label: ReactNode;
44
+ tooltip: string;
45
+ action: () => void;
46
+ isActive: () => boolean;
47
+ };
48
+
49
+ const ICON_SIZE = 14;
50
+
51
+ /**
52
+ * A fixed toolbar that renders above the editor content.
53
+ * Reads the editor instance and enabled features from MarkdownEditorContext.
54
+ *
55
+ * Renders button groups: Inline marks | Blocks | Lists | Inserts.
56
+ * Groups with no enabled features are hidden.
57
+ */
58
+ export const FixedToolbar: FC<FixedToolbarProps> = ({ className, overrides }) => {
59
+ const { editor, features } = useMarkdownEditorContext();
60
+ const [showLinkPopover, setShowLinkPopover] = useState(false);
61
+
62
+ const handleLinkClick = useCallback(() => {
63
+ setShowLinkPopover((prev) => !prev);
64
+ }, []);
65
+
66
+ if (!editor) return null;
67
+
68
+ const inlineMarks: ToolbarItem[] = [
69
+ {
70
+ feature: 'bold',
71
+ label: <Bold size={ICON_SIZE} />,
72
+ tooltip: 'Bold (⌘B)',
73
+ action: () => editor.chain().focus().toggleBold().run(),
74
+ isActive: () => editor.isActive('bold'),
75
+ },
76
+ {
77
+ feature: 'italic',
78
+ label: <Italic size={ICON_SIZE} />,
79
+ tooltip: 'Italic (⌘I)',
80
+ action: () => editor.chain().focus().toggleItalic().run(),
81
+ isActive: () => editor.isActive('italic'),
82
+ },
83
+ {
84
+ feature: 'strikethrough',
85
+ label: <Strikethrough size={ICON_SIZE} />,
86
+ tooltip: 'Strikethrough (⌘⇧X)',
87
+ action: () => editor.chain().focus().toggleStrike().run(),
88
+ isActive: () => editor.isActive('strike'),
89
+ },
90
+ {
91
+ feature: 'code',
92
+ label: <Code size={ICON_SIZE} />,
93
+ tooltip: 'Inline code (⌘E)',
94
+ action: () => editor.chain().focus().toggleCode().run(),
95
+ isActive: () => editor.isActive('code'),
96
+ },
97
+ ];
98
+
99
+ const blocks: ToolbarItem[] = [
100
+ {
101
+ feature: 'heading',
102
+ label: <Heading1 size={ICON_SIZE} />,
103
+ tooltip: 'Heading 1',
104
+ action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
105
+ isActive: () => editor.isActive('heading', { level: 1 }),
106
+ },
107
+ {
108
+ feature: 'heading',
109
+ label: <Heading2 size={ICON_SIZE} />,
110
+ tooltip: 'Heading 2',
111
+ action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
112
+ isActive: () => editor.isActive('heading', { level: 2 }),
113
+ },
114
+ {
115
+ feature: 'heading',
116
+ label: <Heading3 size={ICON_SIZE} />,
117
+ tooltip: 'Heading 3',
118
+ action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
119
+ isActive: () => editor.isActive('heading', { level: 3 }),
120
+ },
121
+ {
122
+ feature: 'blockquote',
123
+ label: <Quote size={ICON_SIZE} />,
124
+ tooltip: 'Blockquote',
125
+ action: () => editor.chain().focus().toggleBlockquote().run(),
126
+ isActive: () => editor.isActive('blockquote'),
127
+ },
128
+ {
129
+ feature: 'horizontalRule',
130
+ label: <Minus size={ICON_SIZE} />,
131
+ tooltip: 'Horizontal rule',
132
+ action: () => editor.chain().focus().setHorizontalRule().run(),
133
+ isActive: () => false,
134
+ },
135
+ ];
136
+
137
+ const lists: ToolbarItem[] = [
138
+ {
139
+ feature: 'bulletList',
140
+ label: <List size={ICON_SIZE} />,
141
+ tooltip: 'Bullet list (⌘⇧8)',
142
+ action: () => editor.chain().focus().toggleBulletList().run(),
143
+ isActive: () => editor.isActive('bulletList'),
144
+ },
145
+ {
146
+ feature: 'orderedList',
147
+ label: <ListOrdered size={ICON_SIZE} />,
148
+ tooltip: 'Ordered list (⌘⇧7)',
149
+ action: () => editor.chain().focus().toggleOrderedList().run(),
150
+ isActive: () => editor.isActive('orderedList'),
151
+ },
152
+ {
153
+ feature: 'taskList',
154
+ label: <ListChecks size={ICON_SIZE} />,
155
+ tooltip: 'Task list',
156
+ action: () => editor.chain().focus().toggleTaskList().run(),
157
+ isActive: () => editor.isActive('taskList'),
158
+ },
159
+ ];
160
+
161
+ const inserts: ToolbarItem[] = [
162
+ {
163
+ feature: 'link',
164
+ label: <Link size={ICON_SIZE} />,
165
+ tooltip: 'Link',
166
+ action: handleLinkClick,
167
+ isActive: () => editor.isActive('link'),
168
+ },
169
+ {
170
+ feature: 'image',
171
+ label: <Image size={ICON_SIZE} />,
172
+ tooltip: 'Image',
173
+ action: () => {
174
+ const url = window.prompt('Image URL');
175
+ if (url) {
176
+ editor.chain().focus().setImage({ src: url }).run();
177
+ }
178
+ },
179
+ isActive: () => false,
180
+ },
181
+ {
182
+ feature: 'table',
183
+ label: <Table size={ICON_SIZE} />,
184
+ tooltip: 'Insert table',
185
+ action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
186
+ isActive: () => false,
187
+ },
188
+ {
189
+ feature: 'codeBlock',
190
+ label: <SquareCode size={ICON_SIZE} />,
191
+ tooltip: 'Code block',
192
+ action: () => editor.chain().focus().toggleCodeBlock().run(),
193
+ isActive: () => editor.isActive('codeBlock'),
194
+ },
195
+ ];
196
+
197
+ const groupEntries = [
198
+ { name: 'inline', items: inlineMarks },
199
+ { name: 'blocks', items: blocks },
200
+ { name: 'lists', items: lists },
201
+ { name: 'inserts', items: inserts },
202
+ ];
203
+
204
+ // Filter each group to only include items whose feature is enabled
205
+ const filteredGroups = groupEntries
206
+ .map((g) => ({ name: g.name, items: g.items.filter((item) => features.has(item.feature)) }))
207
+ .filter((g) => g.items.length > 0);
208
+
209
+ if (filteredGroups.length === 0) return null;
210
+
211
+ return (
212
+ <div
213
+ role="toolbar"
214
+ aria-label="Formatting options"
215
+ {...overrides?.root}
216
+ className={clsx(styles.toolbar, className, overrides?.root?.className)}
217
+ >
218
+ {filteredGroups.map((group, groupIndex) => (
219
+ <Fragment key={group.name}>
220
+ {groupIndex > 0 && (
221
+ <div
222
+ {...overrides?.separator}
223
+ className={clsx(styles.separator, overrides?.separator?.className)}
224
+ aria-hidden="true"
225
+ />
226
+ )}
227
+ <div {...overrides?.group} className={clsx(styles.group, overrides?.group?.className)}>
228
+ {group.items.map((item) => (
229
+ <ToolbarButton
230
+ key={item.tooltip}
231
+ isActive={item.isActive()}
232
+ onAction={item.action}
233
+ tooltip={item.tooltip}
234
+ >
235
+ {item.label}
236
+ </ToolbarButton>
237
+ ))}
238
+ </div>
239
+ </Fragment>
240
+ ))}
241
+ {showLinkPopover && features.has('link') && <LinkPopover onClose={() => setShowLinkPopover(false)} />}
242
+ </div>
243
+ );
244
+ };
@@ -0,0 +1,21 @@
1
+ .toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 2px;
5
+ padding: 4px 6px;
6
+ background-color: var(--pte-new-colors-surfacePrimary, var(--pte-new-colors-backgroundPrimary));
7
+ border: 1px solid var(--pte-new-colors-borderSubtle);
8
+ border-radius: var(--pte-new-borders-radius-rounded, 8px);
9
+ box-shadow: var(
10
+ --pte-new-shadows-shallowPopup,
11
+ 0 4px 12px rgba(0, 0, 0, 0.15)
12
+ );
13
+ }
14
+
15
+ .separator {
16
+ width: 1px;
17
+ height: 18px;
18
+ background-color: var(--pte-new-colors-borderSubtle);
19
+ margin: 0 2px;
20
+ flex-shrink: 0;
21
+ }
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import { BubbleMenu } from '@tiptap/react/menus';
4
+ import { clsx } from 'clsx';
5
+ import { Bold, Code, Italic, Link, Strikethrough } from 'lucide-react';
6
+ import type { ComponentPropsWithoutRef, FC } from 'react';
7
+ import { Fragment, useState } from 'react';
8
+ import styles from './FloatingToolbar.module.scss';
9
+ import { LinkPopover } from './LinkPopover';
10
+ import { useMarkdownEditorContext } from './MarkdownEditorContext';
11
+ import { ToolbarButton } from './ToolbarButton';
12
+
13
+ export type FloatingToolbarProps = {
14
+ /** An optional CSS class name. */
15
+ className?: string;
16
+ /** Prop overrides for sub-elements. */
17
+ overrides?: {
18
+ root?: ComponentPropsWithoutRef<'div'>;
19
+ };
20
+ };
21
+
22
+ const ICON_SIZE = 14;
23
+
24
+ /**
25
+ * A floating toolbar that appears when text is selected.
26
+ * Shows inline formatting options: bold, italic, strikethrough, code, link.
27
+ * Wraps Tiptap's BubbleMenu component.
28
+ */
29
+ export const FloatingToolbar: FC<FloatingToolbarProps> = ({ className, overrides }) => {
30
+ const { editor, features } = useMarkdownEditorContext();
31
+ const [showLinkPopover, setShowLinkPopover] = useState(false);
32
+
33
+ if (!editor) return null;
34
+
35
+ const items = [
36
+ {
37
+ feature: 'bold' as const,
38
+ label: <Bold size={ICON_SIZE} />,
39
+ tooltip: 'Bold (⌘B)',
40
+ action: () => editor.chain().focus().toggleBold().run(),
41
+ isActive: () => editor.isActive('bold'),
42
+ },
43
+ {
44
+ feature: 'italic' as const,
45
+ label: <Italic size={ICON_SIZE} />,
46
+ tooltip: 'Italic (⌘I)',
47
+ action: () => editor.chain().focus().toggleItalic().run(),
48
+ isActive: () => editor.isActive('italic'),
49
+ },
50
+ {
51
+ feature: 'strikethrough' as const,
52
+ label: <Strikethrough size={ICON_SIZE} />,
53
+ tooltip: 'Strikethrough (⌘⇧X)',
54
+ action: () => editor.chain().focus().toggleStrike().run(),
55
+ isActive: () => editor.isActive('strike'),
56
+ },
57
+ {
58
+ feature: 'code' as const,
59
+ label: <Code size={ICON_SIZE} />,
60
+ tooltip: 'Inline code (⌘E)',
61
+ action: () => editor.chain().focus().toggleCode().run(),
62
+ isActive: () => editor.isActive('code'),
63
+ },
64
+ {
65
+ feature: 'link' as const,
66
+ label: <Link size={ICON_SIZE} />,
67
+ tooltip: 'Link',
68
+ action: () => setShowLinkPopover((prev) => !prev),
69
+ isActive: () => editor.isActive('link'),
70
+ },
71
+ ];
72
+
73
+ const visibleItems = items.filter((item) => features.has(item.feature));
74
+
75
+ if (visibleItems.length === 0) return null;
76
+
77
+ return (
78
+ <BubbleMenu editor={editor} options={{ placement: 'top', offset: 8 }}>
79
+ <div {...overrides?.root} className={clsx(styles.toolbar, className, overrides?.root?.className)}>
80
+ {visibleItems.map((item, index) => (
81
+ <Fragment key={item.feature}>
82
+ {index > 0 && item.feature === 'link' && (
83
+ <div className={styles.separator} aria-hidden="true" />
84
+ )}
85
+ <ToolbarButton isActive={item.isActive()} onAction={item.action} tooltip={item.tooltip}>
86
+ {item.label}
87
+ </ToolbarButton>
88
+ </Fragment>
89
+ ))}
90
+ {showLinkPopover && features.has('link') && <LinkPopover onClose={() => setShowLinkPopover(false)} />}
91
+ </div>
92
+ </BubbleMenu>
93
+ );
94
+ };
@@ -0,0 +1,124 @@
1
+ .popover {
2
+ position: absolute;
3
+ top: calc(100% + 8px);
4
+ left: 0;
5
+ z-index: 50;
6
+ background-color: var(--pte-new-colors-surfacePrimary, var(--pte-new-colors-backgroundPrimary));
7
+ border: 1px solid var(--pte-new-colors-borderSubtle);
8
+ border-radius: var(--pte-new-borders-radius-rounded, 8px);
9
+ box-shadow: var(
10
+ --pte-new-shadows-shallowPopup,
11
+ 0 4px 12px rgba(0, 0, 0, 0.15)
12
+ );
13
+ padding: 8px 10px;
14
+ width: 280px;
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 6px;
18
+ }
19
+
20
+ .label {
21
+ font-size: var(--pte-new-typography-styles-labelXSmall-fontSize, 11px);
22
+ font-weight: 600;
23
+ text-transform: uppercase;
24
+ letter-spacing: 0.05em;
25
+ color: var(--pte-new-colors-contentTertiary);
26
+ }
27
+
28
+ .inputRow {
29
+ display: flex;
30
+ gap: 6px;
31
+ }
32
+
33
+ .input {
34
+ flex: 1;
35
+ background-color: var(--pte-new-colors-inputFill);
36
+ border: 1px solid var(--pte-new-colors-borderSubtle);
37
+ border-radius: 4px;
38
+ padding: 4px 8px;
39
+ color: var(--pte-new-colors-contentPrimary);
40
+ font-size: 11px;
41
+ font-family: inherit;
42
+ outline: none;
43
+ transition: border-color var(--pte-new-animations-interaction, 150ms) ease;
44
+
45
+ &:focus {
46
+ border-color: var(--pte-new-colors-inputBorderFocus);
47
+ }
48
+
49
+ &::placeholder {
50
+ color: var(--pte-new-colors-contentTertiary);
51
+ }
52
+ }
53
+
54
+ .applyButton {
55
+ background-color: var(--pte-new-colors-buttonFill);
56
+ border: none;
57
+ border-radius: 4px;
58
+ padding: 4px 10px;
59
+ color: var(--pte-new-colors-contentInversePrimary, #fff);
60
+ font-size: 11px;
61
+ font-weight: 600;
62
+ font-family: inherit;
63
+ cursor: pointer;
64
+ transition: background-color var(--pte-new-animations-interaction, 150ms) ease;
65
+ flex-shrink: 0;
66
+
67
+ &:hover {
68
+ background-color: var(--pte-new-colors-buttonFillHover);
69
+ }
70
+ }
71
+
72
+ .footer {
73
+ display: flex;
74
+ justify-content: space-between;
75
+ align-items: center;
76
+ }
77
+
78
+ .checkboxLabel {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 4px;
82
+ font-size: 11px;
83
+ color: var(--pte-new-colors-contentTertiary);
84
+ cursor: pointer;
85
+
86
+ input[type='checkbox'] {
87
+ appearance: none;
88
+ width: 14px;
89
+ height: 14px;
90
+ border: 2px solid var(--pte-new-colors-contentTertiary);
91
+ border-radius: var(--pte-borders-radius-rectangle);
92
+ background: transparent;
93
+ cursor: pointer;
94
+ position: relative;
95
+ flex-shrink: 0;
96
+ transition: var(--pte-animations-interaction);
97
+
98
+ &:checked {
99
+ &::after {
100
+ content: '';
101
+ position: absolute;
102
+ inset: -2px;
103
+ background-color: var(--pte-new-colors-contentPrimary);
104
+ mask-image: url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.333374 0.333252V13.6666H13.6667V0.333252H0.333374ZM6.00004 10.3999L2.26672 6.66658L3.66671 5.26658L5.93339 7.53325L10.2 3.26658L11.6001 4.66659L6.00004 10.3999Z' fill='black'/%3E%3C/svg%3E");
105
+ mask-size: contain;
106
+ mask-repeat: no-repeat;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ .removeButton {
113
+ background: none;
114
+ border: none;
115
+ color: var(--pte-new-colors-contentNegative);
116
+ font-size: 11px;
117
+ font-family: inherit;
118
+ cursor: pointer;
119
+ padding: 0;
120
+
121
+ &:hover {
122
+ text-decoration: underline;
123
+ }
124
+ }
@@ -0,0 +1,135 @@
1
+ 'use client';
2
+
3
+ import type { FC } from 'react';
4
+ import { useCallback, useEffect, useRef, useState } from 'react';
5
+ import styles from './LinkPopover.module.scss';
6
+ import { useMarkdownEditorContext } from './MarkdownEditorContext';
7
+
8
+ export type LinkPopoverProps = {
9
+ /** Callback when the popover should close. */
10
+ onClose: () => void;
11
+ };
12
+
13
+ /**
14
+ * An inline popover for inserting/editing links.
15
+ * Reads the current link state from the editor and allows
16
+ * setting href and target attributes.
17
+ */
18
+ export const LinkPopover: FC<LinkPopoverProps> = ({ onClose }) => {
19
+ const { editor } = useMarkdownEditorContext();
20
+ const inputRef = useRef<HTMLInputElement>(null);
21
+ const popoverRef = useRef<HTMLDivElement>(null);
22
+
23
+ // Read existing link attributes if cursor is on a link
24
+ const existingHref = editor?.getAttributes('link')?.href ?? '';
25
+ const existingTarget = editor?.getAttributes('link')?.target ?? '_blank';
26
+
27
+ const [url, setUrl] = useState(existingHref);
28
+ const [openInNewTab, setOpenInNewTab] = useState(existingTarget === '_blank');
29
+
30
+ // Focus the input on mount
31
+ useEffect(() => {
32
+ inputRef.current?.focus();
33
+ inputRef.current?.select();
34
+ }, []);
35
+
36
+ // Close on click outside
37
+ useEffect(() => {
38
+ const handleClickOutside = (e: MouseEvent) => {
39
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
40
+ onClose();
41
+ }
42
+ };
43
+ // Use requestAnimationFrame to skip the current event that triggered the popover
44
+ const raf = requestAnimationFrame(() => {
45
+ document.addEventListener('mousedown', handleClickOutside);
46
+ });
47
+ return () => {
48
+ cancelAnimationFrame(raf);
49
+ document.removeEventListener('mousedown', handleClickOutside);
50
+ };
51
+ }, [onClose]);
52
+
53
+ // Close on Escape
54
+ useEffect(() => {
55
+ const handleKeyDown = (e: KeyboardEvent) => {
56
+ if (e.key === 'Escape') {
57
+ onClose();
58
+ editor?.chain().focus().run();
59
+ }
60
+ };
61
+ document.addEventListener('keydown', handleKeyDown);
62
+ return () => document.removeEventListener('keydown', handleKeyDown);
63
+ }, [onClose, editor]);
64
+
65
+ const handleApply = useCallback(() => {
66
+ if (!editor) return;
67
+
68
+ if (!url) {
69
+ editor.chain().focus().unsetLink().run();
70
+ } else {
71
+ editor
72
+ .chain()
73
+ .focus()
74
+ .extendMarkRange('link')
75
+ .setLink({
76
+ href: url,
77
+ target: openInNewTab ? '_blank' : null,
78
+ })
79
+ .run();
80
+ }
81
+ onClose();
82
+ }, [editor, url, openInNewTab, onClose]);
83
+
84
+ const handleRemove = useCallback(() => {
85
+ if (!editor) return;
86
+ editor.chain().focus().unsetLink().run();
87
+ onClose();
88
+ }, [editor, onClose]);
89
+
90
+ const handleKeyDown = useCallback(
91
+ (e: React.KeyboardEvent) => {
92
+ if (e.key === 'Enter') {
93
+ e.preventDefault();
94
+ handleApply();
95
+ }
96
+ },
97
+ [handleApply],
98
+ );
99
+
100
+ return (
101
+ <div ref={popoverRef} className={styles.popover}>
102
+ <div className={styles.label}>Link URL</div>
103
+ <div className={styles.inputRow}>
104
+ <input
105
+ ref={inputRef}
106
+ type="url"
107
+ className={styles.input}
108
+ placeholder="https://..."
109
+ value={url}
110
+ onChange={(e) => setUrl(e.target.value)}
111
+ onKeyDown={handleKeyDown}
112
+ />
113
+ <button
114
+ type="button"
115
+ className={styles.applyButton}
116
+ onMouseDown={(e) => e.preventDefault()}
117
+ onClick={handleApply}
118
+ >
119
+ Apply
120
+ </button>
121
+ </div>
122
+ <div className={styles.footer}>
123
+ <label className={styles.checkboxLabel}>
124
+ <input type="checkbox" checked={openInNewTab} onChange={(e) => setOpenInNewTab(e.target.checked)} />
125
+ Open in new tab
126
+ </label>
127
+ {existingHref && (
128
+ <button type="button" className={styles.removeButton} onClick={handleRemove}>
129
+ Remove link
130
+ </button>
131
+ )}
132
+ </div>
133
+ </div>
134
+ );
135
+ };