Skip to content

Commit f5b1338

Browse files
authored
fix(editor): complete image bubble menu edit-link flow (#3371)
1 parent 9788fa3 commit f5b1338

8 files changed

Lines changed: 318 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/editor": minor
3+
---
4+
5+
add image bubble menu edit-link form and unlink button

packages/editor/src/ui/bubble-menu/bubble-menu.css

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,45 @@ a[data-re-link-bm-item] {
282282
cursor: pointer;
283283
border: none;
284284
background: none;
285-
padding: 0.5rem;
285+
padding: 0.375rem;
286+
margin: 0.125rem 0;
286287
}
287288

288289
[data-re-img-bm-item] svg {
289-
width: 1rem;
290-
height: 1rem;
290+
width: 0.875rem;
291+
height: 0.875rem;
292+
}
293+
294+
[data-re-img-bm-form] {
295+
display: flex;
296+
align-items: center;
297+
gap: 0.25rem;
298+
min-width: 16rem;
299+
padding: 0.25rem;
300+
}
301+
302+
[data-re-img-bm-input] {
303+
flex: 1;
304+
border: none;
305+
outline: none;
306+
font-size: 0.8125rem;
307+
padding: 0.25rem;
308+
background: transparent;
309+
}
310+
311+
[data-re-img-bm-apply],
312+
[data-re-img-bm-unlink] {
313+
display: inline-flex;
314+
align-items: center;
315+
justify-content: center;
316+
cursor: pointer;
317+
border: none;
318+
background: none;
319+
padding: 0.25rem;
320+
}
321+
322+
[data-re-img-bm-apply] svg,
323+
[data-re-img-bm-unlink] svg {
324+
width: 0.875rem;
325+
height: 0.875rem;
291326
}
Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,68 @@
11
import { PluginKey } from '@tiptap/pm/state';
2+
import { useEditorState } from '@tiptap/react';
23
import type * as React from 'react';
4+
import { useBubbleMenuContext } from './context';
35
import { BubbleMenuImageEditLink } from './image-edit-link';
6+
import { BubbleMenuImageForm } from './image-form';
47
import { BubbleMenuImageToolbar } from './image-toolbar';
8+
import { BubbleMenuImageUnlink } from './image-unlink';
59
import { BubbleMenuRoot } from './root';
610
import { bubbleMenuTriggers } from './triggers';
711

812
const imagePluginKey = new PluginKey('imageBubbleMenu');
913

10-
type ExcludableItem = 'edit-link';
14+
type ExcludableItem = 'edit-link' | 'unlink';
1115

1216
export interface BubbleMenuImageDefaultProps
1317
extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
1418
excludeItems?: ExcludableItem[];
1519
placement?: 'top' | 'bottom';
1620
offset?: number;
1721
onHide?: () => void;
22+
validateUrl?: (value: string) => string | null;
23+
onLinkApply?: (href: string) => void;
24+
onLinkRemove?: () => void;
25+
}
26+
27+
function BubbleMenuImageDefaultInner({
28+
excludeItems,
29+
validateUrl,
30+
onLinkApply,
31+
onLinkRemove,
32+
}: Pick<
33+
BubbleMenuImageDefaultProps,
34+
'excludeItems' | 'validateUrl' | 'onLinkApply' | 'onLinkRemove'
35+
> & { excludeItems: ExcludableItem[] }) {
36+
const { editor } = useBubbleMenuContext();
37+
const imageHref = useEditorState({
38+
editor,
39+
selector: ({ editor: e }) =>
40+
(e?.getAttributes('image').href as string | null) ?? '',
41+
});
42+
43+
const has = (item: ExcludableItem) => !excludeItems.includes(item);
44+
const hasLink = (imageHref ?? '') !== '';
45+
const showEditLink = has('edit-link');
46+
const showUnlink = has('unlink') && hasLink;
47+
const hasToolbarItems = showEditLink || showUnlink;
48+
49+
return (
50+
<>
51+
{hasToolbarItems && (
52+
<BubbleMenuImageToolbar>
53+
{showEditLink && <BubbleMenuImageEditLink />}
54+
{showUnlink && <BubbleMenuImageUnlink onLinkRemove={onLinkRemove} />}
55+
</BubbleMenuImageToolbar>
56+
)}
57+
{showEditLink && (
58+
<BubbleMenuImageForm
59+
validateUrl={validateUrl}
60+
onLinkApply={onLinkApply}
61+
onLinkRemove={onLinkRemove}
62+
/>
63+
)}
64+
</>
65+
);
1866
}
1967

2068
export function BubbleMenuImageDefault({
@@ -23,10 +71,11 @@ export function BubbleMenuImageDefault({
2371
offset,
2472
onHide,
2573
className,
74+
validateUrl,
75+
onLinkApply,
76+
onLinkRemove,
2677
...rest
2778
}: BubbleMenuImageDefaultProps) {
28-
const hasEditLink = !excludeItems.includes('edit-link');
29-
3079
return (
3180
<BubbleMenuRoot
3281
trigger={bubbleMenuTriggers.node('image')}
@@ -37,11 +86,12 @@ export function BubbleMenuImageDefault({
3786
className={className}
3887
{...rest}
3988
>
40-
{hasEditLink && (
41-
<BubbleMenuImageToolbar>
42-
<BubbleMenuImageEditLink />
43-
</BubbleMenuImageToolbar>
44-
)}
89+
<BubbleMenuImageDefaultInner
90+
excludeItems={excludeItems}
91+
validateUrl={validateUrl}
92+
onLinkApply={onLinkApply}
93+
onLinkRemove={onLinkRemove}
94+
/>
4595
</BubbleMenuRoot>
4696
);
4797
}

packages/editor/src/ui/bubble-menu/image-edit-link.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type * as React from 'react';
2-
import { LinkIcon } from '../icons';
2+
import { PencilIcon } from '../icons';
33
import { useBubbleMenuContext } from './context';
44

55
export interface BubbleMenuImageEditLinkProps
@@ -31,7 +31,7 @@ export function BubbleMenuImageEditLink({
3131
setIsEditing(true);
3232
}}
3333
>
34-
{children ?? <LinkIcon />}
34+
{children ?? <PencilIcon />}
3535
</button>
3636
);
3737
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useEditorState } from '@tiptap/react';
2+
import * as React from 'react';
3+
import { CheckIcon, UnlinkIcon } from '../icons';
4+
import { useBubbleMenuContext } from './context';
5+
import { focusEditor, getUrlFromString } from './utils';
6+
7+
export interface BubbleMenuImageFormProps {
8+
className?: string;
9+
validateUrl?: (value: string) => string | null;
10+
onLinkApply?: (href: string) => void;
11+
onLinkRemove?: () => void;
12+
}
13+
14+
export function BubbleMenuImageForm({
15+
className,
16+
validateUrl,
17+
onLinkApply,
18+
onLinkRemove,
19+
}: BubbleMenuImageFormProps) {
20+
const { editor, isEditing, setIsEditing } = useBubbleMenuContext();
21+
const inputRef = React.useRef<HTMLInputElement>(null);
22+
const formRef = React.useRef<HTMLFormElement>(null);
23+
24+
const imageHref = useEditorState({
25+
editor,
26+
selector: ({ editor: e }) =>
27+
(e?.getAttributes('image').href as string | null) ?? '',
28+
});
29+
30+
const [inputValue, setInputValue] = React.useState(imageHref ?? '');
31+
32+
React.useEffect(() => {
33+
if (!isEditing) {
34+
return;
35+
}
36+
setInputValue(imageHref ?? '');
37+
const timeoutId = setTimeout(() => {
38+
inputRef.current?.focus();
39+
}, 0);
40+
return () => clearTimeout(timeoutId);
41+
}, [isEditing, imageHref]);
42+
43+
React.useEffect(() => {
44+
if (!isEditing) {
45+
return;
46+
}
47+
48+
const handleKeyDown = (event: KeyboardEvent) => {
49+
if (event.key === 'Escape') {
50+
setIsEditing(false);
51+
}
52+
};
53+
54+
const handleClickOutside = (event: MouseEvent) => {
55+
if (formRef.current && !formRef.current.contains(event.target as Node)) {
56+
const form = formRef.current;
57+
const submitEvent = new Event('submit', {
58+
bubbles: true,
59+
cancelable: true,
60+
});
61+
form.dispatchEvent(submitEvent);
62+
setIsEditing(false);
63+
}
64+
};
65+
66+
document.addEventListener('mousedown', handleClickOutside);
67+
window.addEventListener('keydown', handleKeyDown);
68+
69+
return () => {
70+
window.removeEventListener('keydown', handleKeyDown);
71+
document.removeEventListener('mousedown', handleClickOutside);
72+
};
73+
}, [isEditing, setIsEditing]);
74+
75+
if (!isEditing) {
76+
return null;
77+
}
78+
79+
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
80+
e.preventDefault();
81+
82+
const value = inputValue.trim();
83+
84+
if (value === '') {
85+
editor.chain().focus().updateAttributes('image', { href: null }).run();
86+
setIsEditing(false);
87+
focusEditor(editor);
88+
onLinkRemove?.();
89+
return;
90+
}
91+
92+
const validate = validateUrl ?? getUrlFromString;
93+
const finalValue = validate(value);
94+
95+
if (!finalValue) {
96+
editor.chain().focus().updateAttributes('image', { href: null }).run();
97+
setIsEditing(false);
98+
focusEditor(editor);
99+
onLinkRemove?.();
100+
return;
101+
}
102+
103+
editor
104+
.chain()
105+
.focus()
106+
.updateAttributes('image', { href: finalValue })
107+
.run();
108+
setIsEditing(false);
109+
focusEditor(editor);
110+
onLinkApply?.(finalValue);
111+
}
112+
113+
function handleUnlink(e: React.MouseEvent) {
114+
e.stopPropagation();
115+
editor.chain().focus().updateAttributes('image', { href: null }).run();
116+
setIsEditing(false);
117+
focusEditor(editor);
118+
onLinkRemove?.();
119+
}
120+
121+
const hasLink = (imageHref ?? '') !== '';
122+
123+
return (
124+
<form
125+
ref={formRef}
126+
data-re-img-bm-form=""
127+
className={className}
128+
onMouseDown={(e) => e.stopPropagation()}
129+
onClick={(e) => e.stopPropagation()}
130+
onKeyDown={(e) => e.stopPropagation()}
131+
onSubmit={handleSubmit}
132+
>
133+
<input
134+
ref={inputRef}
135+
data-re-img-bm-input=""
136+
value={inputValue}
137+
onFocus={(e) => e.stopPropagation()}
138+
onChange={(e) => setInputValue(e.target.value)}
139+
placeholder="Paste a link"
140+
type="text"
141+
/>
142+
143+
{hasLink ? (
144+
<button
145+
type="button"
146+
aria-label="Remove link"
147+
data-re-img-bm-unlink=""
148+
onClick={handleUnlink}
149+
>
150+
<UnlinkIcon />
151+
</button>
152+
) : (
153+
<button
154+
type="submit"
155+
aria-label="Apply link"
156+
data-re-img-bm-apply=""
157+
onMouseDown={(e) => e.stopPropagation()}
158+
>
159+
<CheckIcon />
160+
</button>
161+
)}
162+
</form>
163+
);
164+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type * as React from 'react';
2+
import { UnlinkIcon } from '../icons';
3+
import { useBubbleMenuContext } from './context';
4+
import { focusEditor } from './utils';
5+
6+
export interface BubbleMenuImageUnlinkProps
7+
extends Omit<React.ComponentProps<'button'>, 'type'> {
8+
onLinkRemove?: () => void;
9+
}
10+
11+
export function BubbleMenuImageUnlink({
12+
className,
13+
children,
14+
onClick,
15+
onMouseDown,
16+
onLinkRemove,
17+
...rest
18+
}: BubbleMenuImageUnlinkProps) {
19+
const { editor } = useBubbleMenuContext();
20+
21+
return (
22+
<button
23+
{...rest}
24+
type="button"
25+
aria-label="Remove link"
26+
data-re-img-bm-item=""
27+
data-item="unlink"
28+
className={className}
29+
onMouseDown={(e) => {
30+
e.preventDefault();
31+
onMouseDown?.(e);
32+
}}
33+
onClick={(e) => {
34+
onClick?.(e);
35+
editor.chain().focus().updateAttributes('image', { href: null }).run();
36+
focusEditor(editor);
37+
onLinkRemove?.();
38+
}}
39+
>
40+
{children ?? <UnlinkIcon />}
41+
</button>
42+
);
43+
}

0 commit comments

Comments
 (0)