Skip to content

Commit d0bf58f

Browse files
committed
feat(link): make user experience of link editing better
1 parent ffd926d commit d0bf58f

File tree

3 files changed

+99
-34
lines changed

3 files changed

+99
-34
lines changed

src/extensions/Link/Link.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ export const Link = /* @__PURE__ */ TiptapLink.extend<LinkOptions>({
3131
},
3232

3333
addOptions() {
34+
const parentOptions = this.parent?.() || {};
35+
const presetTarget = parentOptions.HTMLAttributes?.target;
3436
return {
35-
...this.parent?.(),
37+
...parentOptions,
3638
openOnClick: true,
3739
button: ({ editor, t }) => {
3840
return {
@@ -42,29 +44,37 @@ export const Link = /* @__PURE__ */ TiptapLink.extend<LinkOptions>({
4244
action: (value) => {
4345
const { link, text, openInNewTab } = value;
4446

45-
const { state } = editor;
46-
const { from } = state.selection;
47+
if(!link) {
48+
// if user clears the link and applies, remove the link
49+
editor.chain().extendMarkRange('link').unsetLink().run();
50+
return;
51+
}
52+
53+
// if cursor is inside a link, select the entire link first
54+
if (editor.isActive('link')) {
55+
editor.chain().extendMarkRange('link').run();
56+
}
57+
58+
const { from } = editor.state.selection;
4759
const insertedLength = text.length;
48-
const to = from + insertedLength;
4960

5061
editor
5162
.chain()
52-
.extendMarkRange('link')
5363
.insertContent({
5464
type: 'text',
5565
text,
5666
marks: [
5767
{
5868
type: 'link',
5969
attrs: {
60-
href: link,
61-
target: openInNewTab ? '_blank' : '',
70+
href: link.match(/^https?:\/\//i) ? link : `http://${link}`,
71+
target: presetTarget ?? (openInNewTab ? '_blank' : ''),
6272
},
6373
},
6474
],
6575
})
6676
.setLink({ href: link })
67-
.setTextSelection({ from, to }) // 👈 Select inserted text
77+
.setTextSelection({ from, to: from + insertedLength })
6878
.focus()
6979
.run();
7080
},
@@ -73,6 +83,7 @@ export const Link = /* @__PURE__ */ TiptapLink.extend<LinkOptions>({
7383
disabled: !editor.can().setLink({ href: '' }),
7484
icon: 'Link',
7585
tooltip: t('editor.link.tooltip'),
86+
target: presetTarget,
7687
},
7788
};
7889
},
@@ -89,6 +100,18 @@ export const Link = /* @__PURE__ */ TiptapLink.extend<LinkOptions>({
89100
if (!range) {
90101
return false;
91102
}
103+
104+
// honor openOnClick setting
105+
let mark: any = null;
106+
doc.nodesBetween(range.from, range.to, (node) => {
107+
mark = node.marks.find((m) => m.type === schema.marks.link);
108+
return !mark;
109+
});
110+
if (this.options.openOnClick && mark?.attrs.href && pos !== range.to) {
111+
window.open(mark.attrs.href, mark.attrs.target || '_self');
112+
return true;
113+
}
114+
92115
const $start = doc.resolve(range.from);
93116
const $end = doc.resolve(range.to);
94117
const transaction = tr.setSelection(

src/extensions/Link/components/LinkEditBlock.tsx

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-call */
22
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useState, useRef } from 'react';
44

55
import { Button, IconComponent, Input, Label, Switch } from '@/components';
66
import Link from '@/extensions/Link/Link';
77
import { useLocale } from '@/locales';
8+
import type { Mark } from '@tiptap/pm/model';
89

910
interface IPropsLinkEditBlock {
1011
editor: any;
1112
onSetLink: (link: string, text?: string, openInNewTab?: boolean) => void;
13+
open?: boolean;
14+
target?: string;
1215
}
1316

1417
function LinkEditBlock(props: IPropsLinkEditBlock) {
@@ -20,19 +23,53 @@ function LinkEditBlock(props: IPropsLinkEditBlock) {
2023
});
2124
const [openInNewTab, setOpenInNewTab] = useState<boolean>(false);
2225

26+
const textInputRef = useRef<HTMLInputElement>(null);
27+
const linkInputRef = useRef<HTMLInputElement>(null);
28+
2329
useEffect(() => {
2430
const updateForm = () => {
25-
if (props.editor?.isActive('link')) {
26-
const { href: link, target } = props.editor.getAttributes('link');
27-
const { from, to } = props.editor.state.selection;
28-
const text = props.editor.state.doc.textBetween(from, to, ' ');
29-
setForm({ link: link || '', text });
30-
setOpenInNewTab(target === '_blank');
31-
} else {
32-
const LinkOptions = props.editor.extensionManager.extensions.find(
33-
(ext: any) => ext.name === Link.name,
34-
)?.options;
35-
setOpenInNewTab(LinkOptions?.HTMLAttributes?.target === '_blank');
31+
const { from, to, empty } = props.editor.state.selection;
32+
33+
const LinkOptions = props.editor.extensionManager.extensions.find(
34+
(ext: any) => ext.name === Link.name,
35+
)?.options;
36+
37+
let text = '';
38+
let link = '';
39+
let target = LinkOptions?.HTMLAttributes?.target;
40+
41+
const node = props.editor.state.doc.nodeAt(from);
42+
43+
if (node) {
44+
const linkMark = node.marks.find((mark: Mark) => mark.type.name === 'link');
45+
if (linkMark) {
46+
link = linkMark.attrs.href || '';
47+
target = linkMark.attrs.target;
48+
if (empty) {
49+
text = node.text || '';
50+
} else {
51+
text = props.editor.state.doc.textBetween(from, to, ' ');
52+
}
53+
} else {
54+
// no link mark at cursor => normal selection
55+
text = props.editor.state.doc.textBetween(from, to, ' ');
56+
}
57+
}
58+
// if no node found (empty doc or weird selection), fallback to range
59+
if (!node) {
60+
text = props.editor.state.doc.textBetween(from, to, ' ');
61+
}
62+
63+
setForm({ link, text });
64+
setOpenInNewTab(props.target ? props.target === '_blank' : target === '_blank');
65+
66+
if (props.open) {
67+
// better uexp - focus link input by default
68+
if (text === '') {
69+
textInputRef.current?.focus();
70+
} else {
71+
linkInputRef.current?.focus();
72+
}
3673
}
3774
};
3875

@@ -46,7 +83,7 @@ function LinkEditBlock(props: IPropsLinkEditBlock) {
4683
return () => {
4784
props.editor.off('selectionUpdate', updateForm);
4885
};
49-
}, [props.editor]);
86+
}, [props.editor, props.open]);
5087

5188
function handleSubmit(event: any) {
5289
event.preventDefault();
@@ -65,6 +102,7 @@ function LinkEditBlock(props: IPropsLinkEditBlock) {
65102
<div className="richtext-mb-[10px] richtext-flex richtext-w-full richtext-max-w-sm richtext-items-center richtext-gap-1.5">
66103
<div className="richtext-relative richtext-w-full richtext-max-w-sm richtext-items-center">
67104
<Input
105+
ref={textInputRef}
68106
className="richtext-w-80"
69107
onChange={(e) => setForm({ ...form, text: e.target.value })}
70108
placeholder="Text"
@@ -82,6 +120,7 @@ function LinkEditBlock(props: IPropsLinkEditBlock) {
82120
<div className="richtext-flex richtext-w-full richtext-max-w-sm richtext-items-center richtext-gap-1.5">
83121
<div className="richtext-relative richtext-w-full richtext-max-w-sm richtext-items-center">
84122
<Input
123+
ref={linkInputRef}
85124
className="richtext-pl-10"
86125
onChange={(e) => setForm({ ...form, link: e.target.value })}
87126
required
@@ -98,18 +137,20 @@ function LinkEditBlock(props: IPropsLinkEditBlock) {
98137
</div>
99138
</div>
100139

101-
<div className="richtext-flex richtext-items-center richtext-space-x-2">
102-
<Label>
103-
{t('editor.link.dialog.openInNewTab')}
104-
</Label>
105-
106-
<Switch
107-
checked={openInNewTab}
108-
onCheckedChange={(e) => {
109-
setOpenInNewTab(e);
110-
}}
111-
/>
112-
</div>
140+
{!props.target && (
141+
<div className="richtext-flex richtext-items-center richtext-space-x-2">
142+
<Label>
143+
{t('editor.link.dialog.openInNewTab')}
144+
</Label>
145+
146+
<Switch
147+
checked={openInNewTab}
148+
onCheckedChange={(e) => {
149+
setOpenInNewTab(e);
150+
}}
151+
/>
152+
</div>
153+
)}
113154

114155
<Button
115156
className="richtext-mt-2 richtext-self-end"

src/extensions/Link/components/LinkEditPopover.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface IPropsLinkEditPopover {
1313
shortcutKeys?: string[]
1414
isActive?: ButtonViewReturnComponentProps['isActive']
1515
action?: ButtonViewReturnComponentProps['action']
16+
target?: string
1617
}
1718

1819
function LinkEditPopover(props: IPropsLinkEditPopover) {
@@ -37,7 +38,7 @@ function LinkEditPopover(props: IPropsLinkEditPopover) {
3738
</ActionButton>
3839
</PopoverTrigger>
3940
<PopoverContent hideWhenDetached className="richtext-w-full" align="start" side="bottom">
40-
<LinkEditBlock editor={props.editor} onSetLink={onSetLink} />
41+
<LinkEditBlock editor={props.editor} onSetLink={onSetLink} open={open} target={props.target} />
4142
</PopoverContent>
4243
</Popover>
4344
);

0 commit comments

Comments
 (0)