The fastest way to add link functionality is with the LinkKit, which includes pre-configured LinkPlugin with floating toolbar, autolink rules, and Plate UI components.
'use client';
import {
flip,
offset,
shift,
useDismiss,
useInteractions,
} from '@floating-ui/react';
import { cva } from 'class-variance-authority';
import { ExternalLink, Link, Text, Unlink } from 'lucide-react';
import { LinkRules } from 'platejs';
import {
type EditableSiblingProps,
LinkPlugin,
type EditorElementProps,
EditorElement,
useComposedRef,
useEditor,
useEditorReadOnly,
useEditorSelection,
useEditorSelector,
usePluginStore,
useSelectionGeometry,
} from 'platejs/react';
import * as React from 'react';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import {
type UseFloatingRectOptions,
useFloatingRect,
} from '@/hooks/use-floating-rect';
import { inlineSuggestionVariants } from '@/lib/inline-suggestion';
export function LinkElement(props: EditorElementProps<typeof LinkPlugin>) {
return (
<EditorElement
{...props}
as="a"
className={cn(
'font-medium text-primary underline decoration-primary underline-offset-4',
inlineSuggestionVariants()
)}
attributes={{
...props.attributes,
...props.editor.plugin(LinkPlugin).api.getAttributes(props.element),
onMouseOver: (event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
},
}}
>
{props.children}
</EditorElement>
);
}
const popoverVariants = cva(
'cn-popover-content z-50 w-auto p-1 outline-hidden transition-none'
);
type FloatingLinkMode = '' | 'edit' | 'insert';
const transientState = {
isEditing: false,
mode: '' as FloatingLinkMode,
mouseDown: false,
newTab: false,
openEditorId: null as string | null,
text: '',
url: '',
};
const DRAFT_ADDRESS =
/^((?!https?:)[a-z][a-z\d+.-]*:|https?:\/\/|[#/?]|\.{1,2}\/)/i;
const linkFloatingOptions = {
middleware: [
offset(8),
flip({
fallbackPlacements: ['top-end', 'bottom-start', 'bottom-end'],
padding: 12,
}),
shift({ padding: 8 }),
],
placement: 'top-start',
} satisfies UseFloatingRectOptions;
export const linkPlugin = LinkPlugin.extend({ initialState: transientState })
.extend(({ store }) => {
const hide = () => {
store.set({ ...transientState });
};
const show = (mode: FloatingLinkMode, editorId: string) => {
store.set({ isEditing: false, mode, openEditorId: editorId });
};
return {
api: () => ({
decodeUrl: (url: string) => {
try {
return decodeURI(url);
} catch (error) {
if (error instanceof URIError) return url;
throw error;
}
},
hide,
reset: () => {
store.set({
...transientState,
openEditorId: store.get('openEditorId'),
});
},
show,
}),
selectors: {
isOpen: (state, editorId: string) => state.openEditorId === editorId,
},
};
})
.extend({
api: ({ api, editor, store, update }) => ({
submit: () => {
const selection = editor.read.selection();
if (!selection) return undefined;
const { newTab, text, url } = store.get();
const editedLink = editor.read.nodes.above({
at: selection,
type: linkPlugin,
});
if (editedLink && url === editedLink[0].url) {
update.upsertText({ text: text || url, url });
} else {
const draft = url.trim();
if (
!DRAFT_ADDRESS.test(draft) ||
!update.upsert({
target: newTab ? '_blank' : undefined,
text,
url: draft,
})
) {
return undefined;
}
}
api.hide();
setTimeout(() => {
editor.api.dom.focus();
}, 0);
return true;
},
triggerEdit: () => {
const selection = editor.read.selection();
if (!selection) return undefined;
const entry = editor.read.nodes.above({
at: selection,
type: linkPlugin,
});
if (!entry) return undefined;
const [link, path] = entry;
const linkText = editor.read.text.string(path);
api.show('edit', editor.id);
store.set({
isEditing: true,
newTab: link.target === '_blank',
text: linkText === link.url ? '' : linkText,
url: link.url,
});
return true;
},
triggerInsert: ({ focused }: { focused?: boolean } = {}) => {
if (store.get().mode || !focused) return undefined;
if (editor.read.selection.isAcrossBlocks()) return undefined;
const selection = editor.read.selection();
if (!selection) return undefined;
if (editor.read.nodes.some({ at: selection, type: linkPlugin })) {
return undefined;
}
const block = editor.read.nodes.block({ at: selection });
if (
block &&
!editor.read.schema.allowsElementType(
block[0].type,
editor.plugin(LinkPlugin).schema.type
)
) {
return undefined;
}
store.set({ text: editor.read.text.string() });
api.show('insert', editor.id);
return true;
},
}),
})
.extend({
api: ({ api, editor }) => ({
trigger: ({
focused = editor.read.view.isFocused(),
}: { focused?: boolean } = {}) =>
!editor.read.view.isReadOnly() &&
focused &&
(api.triggerEdit() ?? api.triggerInsert({ focused })) === true,
}),
})
.extend(({ api, store }) => ({
shortcuts: {
dismiss: {
keys: 'escape',
handler: () => {
if (!store.get().mode) return false;
api.hide();
return true;
},
},
trigger: { keys: 'mod+k' },
},
}));
function FloatingLinkUrlInput({
...props
}: React.ComponentPropsWithRef<'input'>) {
const { api, store } = useEditor().plugin(linkPlugin);
return (
<Input
defaultValue={api.decodeUrl(store.get().url)}
onChange={(event) => {
store.set({ url: event.target.value });
}}
{...props}
/>
);
}
export function LinkFloatingToolbar({ editableRef }: EditableSiblingProps) {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const isEditing = usePluginStore(linkPlugin, 'isEditing');
const selectedLinkKey = useEditorSelector(
(innerEditor) => {
const selection = innerEditor.read.selection();
if (!selection || !innerEditor.read.selection.isCollapsed()) return null;
const entry = innerEditor.read.nodes.above({
at: selection,
type: linkPlugin,
});
return entry ? innerEditor.key(entry[0]) : null;
},
{
shouldUpdate: (change) =>
!change || change.selectionChanged || change.changed.hasAny('document'),
}
);
const mode = usePluginStore(linkPlugin, 'mode');
const open = usePluginStore(linkPlugin, 'isOpen', editor.id);
const { api, store, update } = useEditor().plugin(linkPlugin);
const geometry = useSelectionGeometry({ editableRef });
const editOpen =
!readOnly && open && mode === 'edit' && editor.read.selection.isCollapsed();
const editFloating = useFloatingRect(geometry?.boundingRect ?? null, {
onOpenChange: (nextOpen) => {
if (!nextOpen) api.hide();
},
open: editOpen,
...linkFloatingOptions,
});
const insertFloating = useFloatingRect(geometry?.boundingRect ?? null, {
onOpenChange: (nextOpen) => {
if (nextOpen) return;
api.hide();
editor.api.dom.focus();
},
open: !readOnly && open && mode === 'insert',
...linkFloatingOptions,
});
React.useEffect(() => {
if (readOnly) {
if (store.get().openEditorId === editor.id) api.hide();
return;
}
if (selectedLinkKey) {
api.show('edit', editor.id);
return;
}
if (store.get().mode === 'edit') api.hide();
}, [api, editor, readOnly, selectedLinkKey, store]);
const editDismiss = useDismiss(editFloating.context, {
escapeKey: false,
outsidePress: () => store.get().isEditing,
});
const insertDismiss = useDismiss(insertFloating.context, {
escapeKey: false,
});
const editInteractions = useInteractions([editDismiss]);
const insertInteractions = useInteractions([insertDismiss]);
const floatingElement =
mode === 'insert'
? insertFloating.elements.floating
: editFloating.elements.floating;
React.useEffect(() => {
const view = floatingElement?.ownerDocument.defaultView;
if (!view || !open || (mode !== 'insert' && !isEditing)) return undefined;
let timeout: number | undefined;
const onBlur = () => {
view.clearTimeout(timeout);
// Iframe focus does not dispatch an outside pointer event in this document.
timeout = view.setTimeout(() => {
if (
floatingElement.ownerDocument.activeElement?.localName === 'iframe'
) {
api.hide();
}
}, 0);
};
view.addEventListener('blur', onBlur);
return () => {
view.removeEventListener('blur', onBlur);
view.clearTimeout(timeout);
};
}, [api, floatingElement, isEditing, mode, open]);
const { text } = store.get();
const editButtonProps = { onClick: api.triggerEdit };
const onFloatingKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
if (mode === 'edit' && store.get().isEditing) {
api.show('edit', editor.id);
} else {
api.hide();
}
editor.api.dom.focus();
};
const editProps = {
onKeyDown: onFloatingKeyDown,
style: { ...editFloating.style, zIndex: 50 },
};
const editRef = useComposedRef(editFloating.refs.setFloating);
const unlinkButtonProps = {
onClick: () => {
update.unwrap();
api.hide();
editor.api.dom.focus();
},
onMouseDown: (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
},
};
const insertProps = {
onKeyDown: onFloatingKeyDown,
style: { ...insertFloating.style, zIndex: 50 },
};
const insertRef = useComposedRef(insertFloating.refs.setFloating);
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
api.submit();
};
const urlInputRef = React.useRef<HTMLInputElement>(null);
const shouldFocusUrlInput =
!readOnly &&
open &&
!!geometry &&
((mode === 'insert' && insertFloating.isPositioned) ||
(mode === 'edit' && isEditing && editFloating.isPositioned));
React.useEffect(() => {
if (shouldFocusUrlInput) {
urlInputRef.current?.focus({ preventScroll: true });
}
}, [shouldFocusUrlInput]);
const textInputProps = {
defaultValue: text,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
store.set({ text: event.target.value });
},
onKeyDown: onInputKeyDown,
};
if (readOnly || !open) return null;
const input = (
<div className="flex w-[330px] max-w-[calc(100vw-16px)] flex-col">
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Link className="size-4" />
</div>
<FloatingLinkUrlInput
ref={urlInputRef}
className="h-7 border-none bg-transparent px-1.5 py-1 shadow-none focus-visible:ring-transparent"
placeholder="Paste link"
data-editor-keep-selection-visible
onKeyDown={onInputKeyDown}
/>
</div>
<Separator className="my-1" />
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Text className="size-4" />
</div>
<Input
className="h-7 border-none bg-transparent px-1.5 py-1 shadow-none focus-visible:ring-transparent"
placeholder="Text to display"
data-editor-keep-selection-visible
{...textInputProps}
/>
</div>
</div>
);
const editContent = isEditing ? (
input
) : (
<div className="box-content flex items-center">
<Button size="sm" type="button" variant="ghost" {...editButtonProps}>
Edit link
</Button>
<Separator orientation="vertical" />
<LinkOpenButton />
<Separator orientation="vertical" />
<Button size="sm" type="button" variant="ghost" {...unlinkButtonProps}>
<Unlink width={18} />
</Button>
</div>
);
if (mode === 'insert') {
return (
<div
ref={insertRef}
className={cn(popoverVariants(), 'p-0')}
{...insertInteractions.getFloatingProps(insertProps)}
>
{input}
</div>
);
}
return (
<div
ref={editRef}
className={cn(popoverVariants(), isEditing && 'p-0')}
{...editInteractions.getFloatingProps(editProps)}
>
{editContent}
</div>
);
}
function LinkOpenButton() {
const editor = useEditor();
useEditorSelection();
const entry = editor.read.nodes.find({ type: linkPlugin });
const attributes = entry
? editor.plugin(linkPlugin).api.getAttributes(entry[0])
: {};
return (
<a
{...attributes}
className={buttonVariants({
size: 'sm',
variant: 'ghost',
})}
onMouseOver={(e) => {
e.stopPropagation();
}}
onFocus={(e) => {
e.stopPropagation();
}}
aria-label="Open link in a new tab"
target="_blank"
>
<ExternalLink width={18} />
</a>
);
}
export const LinkKit = [
linkPlugin.configure({
component: LinkElement,
inputRules: [
LinkRules.markdown(),
LinkRules.autolink({ variant: 'paste' }),
LinkRules.autolink({ variant: 'space' }),
LinkRules.autolink({ variant: 'break' }),
],
slots: {
afterEditable: LinkFloatingToolbar,
},
}),
] as const;'use client';
import {
flip,
offset,
shift,
useDismiss,
useInteractions,
} from '@floating-ui/react';
import { cva } from 'class-variance-authority';
import { ExternalLink, Link, Text, Unlink } from 'lucide-react';
import { LinkRules } from 'platejs';
import {
type EditableSiblingProps,
LinkPlugin,
type EditorElementProps,
EditorElement,
useComposedRef,
useEditor,
useEditorReadOnly,
useEditorSelection,
useEditorSelector,
usePluginStore,
LinkElement: Renders link elements.LinkFloatingToolbar: Provides floating toolbar for link editing.Add the kit to your plugins:
import { createEditor } from 'platejs/react';
import { LinkKit } from '@/components/editor/link';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...LinkKit,
],
});import { createEditor } from 'platejs/react';
import { LinkKit } from '@/components/editor/link';
const editor = createEditor
Include LinkPlugin in your Plate plugins array when creating the editor.
import { LinkRules } from 'platejs';
import { LinkPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
const editor = createEditor({
plugins: [
// ...otherPlugins,
LinkPlugin,
],
});import { LinkRules } from 'platejs';
import { LinkPlugin } from 'platejs/react';
Configure the plugin with floating toolbar and custom components.
import { LinkPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { LinkElement } from '@/components/editor/link';
import { LinkFloatingToolbar } from '@/components/editor/link';
const editor = createEditor({
plugins: [
// ...otherPlugins,
LinkPlugin.configure({
component: LinkElement,
slots: {
afterEditable: LinkFloatingToolbar,
},
}),
],
});slots.afterEditable: Renders LinkFloatingToolbar after the editable area for link editing..configure({ component: LinkElement }): Renders link elements with
LinkElement.Configure LinkPlugin to enable the built-in link input presets:
import { LinkPlugin } from 'platejs/react';
const editor = createEditor({
plugins: [
LinkPlugin.configure({
inputRules: [
LinkRules.markdown(),
LinkRules.autolink({ variant: 'paste' }),
LinkRules.autolink({ variant: 'space' }),
LinkRules.autolink({ variant: 'break' }),
],
}),
],
});LinkRules.autolink({ variant: 'paste' | 'space' | 'break' }): Enables plain URL autolink. A pasted or typed token becomes a link when parseUrl recognizes it and allowedSchemes allows the result; otherwise the paste, space or Enter goes on as usual.LinkRules.markdown(): Enables [text](url) source-entry conversion on the closing ). Image source, , stays text.You can add LinkToolbarButton to your Toolbar to insert and edit links.
The floating toolbar saves a new or changed address that starts with http:// or https://, has another scheme such as mailto:, or starts with /, ./, ../, # or ?. It stays open on any other draft, such as www.example.com or https:www.example.com, and when allowedSchemes refuses the address. Editing only the link text keeps the stored address, even one allowedSchemes no longer allows.
| Key | Description |
|---|---|
| Cmd + K | Add a link on the selected text. |
Plugin for link formatting.
URL parsing and headless interaction behavior share one state contract:
LinkPlugin.configure({
initialState: {
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
},
});LinkPlugin.configure({
initialState: {
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
},
});Options:
allowedSchemes?: string[] — URL schemes the link feature accepts when it
inserts, types, pastes or imports a link, and that a rendered link may use.
Relative URLs are always allowed. Add a scheme such as vscode for app deep links. Script-capable
destinations, such as javascript:, vbscript:, and data:, are never
allowed, and a stored link URL must pass that floor. HTML and Markdown import
keep the label of a link whose destination is not allowed and report the
removed destination. A stored link whose scheme is no longer allowed keeps
its URL, renders without an href, and exports its stored URL, and so does
a link pasted as an exact editor fragment. Default:
['http', 'https', 'mailto', 'tel'].Allows custom configurations for rangeBeforeOptions.
{
matchString: ' ',
skipInvalid: true,
afterMatch: true,
}{
matchString: ' ',
skipInvalid: true,
afterMatch: true,
}Default HTML attributes for link elements.
{}Keeps selected text on pasting links by default.
trueReturns the href for typed or pasted text, or undefined when the text is not a link. The autolink rules call it; upsert, the toolbar and imports do not. defaultParseUrl accepts a whole token with an explicit scheme, such as https://example.com or tel:+15551234, so typed #top, /docs and www.example.com stay text. With Markdown paste installed, a pasted www.example.com can still become a link. The result still has to pass allowedSchemes.
(text, defaultParseUrl) => defaultParseUrl(text)LinkPlugin.configure({
initialState: {
parseUrl: (text
LinkPlugin installs link updates on the editor. Types are inferred from the
configured editor, so no editor argument or transaction helper is required.
editor.update.link.upsert({ url: 'https://platejs.org', text: 'EditorRoot' });
editor.update.link.unwrap({ split: true });editor.update.link.upsert({ url: 'https://platejs.org', text: 'EditorRoot' });
editor.update.link.unwrap({ split: true });| Update | Description |
|---|---|
editor.update.link.insert(node, options?) | Inserts a link node; returns true, or undefined when the URL is not allowed or the node cannot be inserted. |
editor.update.link.unwrap(options?) | Unwraps matching links; with split, unlinks only the selected text, or the whole link at a caret; returns true, or undefined when no link was removed. |
editor.update.link.upsert(options) | Prepares URL input, then inserts or updates a link at the selection. Over selected text that crosses or touches a link's edge or covers whole links, it unlinks only the selected part of each link and links the selection; a selection inside one link updates that link. Returns true when the link lands, or undefined when the URL is not allowed, there is no selection, an expanded selection outside a link holds no text, or a link cannot go there, such as in a code block or over a mention. A call that returns undefined over selected text may still have unlinked the selected part of an old link. |
editor.update.link.upsertText(options) | Replaces the text of the link above the selection. |
editor.update.link.wrap(options) | Wraps the selected text in a link; returns true, or undefined when the URL is not allowed or nothing was wrapped. |
insert, upsert and wrap encode unescaped URI characters and check the result against allowedSchemes before changing the document. They keep valid percent escapes and store a URL without a scheme as a relative link.
if (!editor.update.link.upsert({ url })) {
// The URL is not allowed, there is no selection, or a link cannot go there.
}if (!editor.update.link.upsert({ url })) {
// The URL is not allowed, there is no selection, or a link cannot go there.
}Read link behavior through the installed plugin:
| Method | Description |
|---|---|
api.getAttributes(link) | Returns sanitized anchor attributes for a link element; href is absent when the URL is not allowed. |
Options for creating a new link node.
The URL of the link node that is being created.
The text that is displayed for the link node. If not provided, the URL is used as the display text.
Specifies where to open the URL:
_blank: new tab_self: same frame_parent: parent frame_top: full windowAn array of text nodes that represent the link content.
Returns the href a link may navigate to. The link feature runs the same check before it renders an href. Copied UI that renders a stored address as a link, such as a tag chip, calls it to link only what a link would.
import { getLinkHref } from 'platejs';
import { LinkPlugin, usePluginStore } from 'platejs/react';
const allowedSchemes = usePluginStore(LinkPlugin, 'allowedSchemes');
const href = getLinkHref(url, { allowedSchemes });import { getLinkHref } from 'platejs';
import { LinkPlugin, usePluginStore } from 'platejs/react';
const allowedSchemes = usePluginStore(LinkPlugin, 'allowedSchemes');
const href = getLinkHref(url, { allowedSchemes });usePluginStore throws when the editor does not install LinkPlugin. Code that may run without it checks editor.plugin(LinkPlugin).installed first. When link is installed, it reads the store in a child component, because a hook cannot run conditionally. Otherwise it calls getLinkHref(url) for the default schemes.
The stored or typed address. Surrounding spaces are trimmed. An address that starts with // or holds a backslash or a control character, such as a tab, is refused.
Schemes allowed besides relative URLs. Defaults to ['http', 'https', 'mailto', 'tel']. Pass LinkPlugin's allowedSchemes when the editor installs it. Script-capable schemes such as javascript: and data: are always refused.
The copied link item owns floating state, positioning, hotkeys,
outside clicks, URL input, and toolbar commands. It exports the app-local
linkPlugin descriptor and LinkFloatingToolbar component.
'use client';
import {
flip,
offset,
shift,
useDismiss,
useInteractions,
} from '@floating-ui/react';
import { cva } from 'class-variance-authority';
import { ExternalLink, Link, Text, Unlink } from 'lucide-react';
import { LinkRules } from 'platejs';
import {
type EditableSiblingProps,
LinkPlugin,
type EditorElementProps,
EditorElement,
useComposedRef,
useEditor,
useEditorReadOnly,
useEditorSelection,
useEditorSelector,
usePluginStore,
useSelectionGeometry,
} from 'platejs/react';
import * as React from 'react';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import {
type UseFloatingRectOptions,
useFloatingRect,
} from '@/hooks/use-floating-rect';
import { inlineSuggestionVariants } from '@/lib/inline-suggestion';
export function LinkElement(props: EditorElementProps<typeof LinkPlugin>) {
return (
<EditorElement
{...props}
as="a"
className={cn(
'font-medium text-primary underline decoration-primary underline-offset-4',
inlineSuggestionVariants()
)}
attributes={{
...props.attributes,
...props.editor.plugin(LinkPlugin).api.getAttributes(props.element),
onMouseOver: (event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
},
}}
>
{props.children}
</EditorElement>
);
}
const popoverVariants = cva(
'cn-popover-content z-50 w-auto p-1 outline-hidden transition-none'
);
type FloatingLinkMode = '' | 'edit' | 'insert';
const transientState = {
isEditing: false,
mode: '' as FloatingLinkMode,
mouseDown: false,
newTab: false,
openEditorId: null as string | null,
text: '',
url: '',
};
const DRAFT_ADDRESS =
/^((?!https?:)[a-z][a-z\d+.-]*:|https?:\/\/|[#/?]|\.{1,2}\/)/i;
const linkFloatingOptions = {
middleware: [
offset(8),
flip({
fallbackPlacements: ['top-end', 'bottom-start', 'bottom-end'],
padding: 12,
}),
shift({ padding: 8 }),
],
placement: 'top-start',
} satisfies UseFloatingRectOptions;
export const linkPlugin = LinkPlugin.extend({ initialState: transientState })
.extend(({ store }) => {
const hide = () => {
store.set({ ...transientState });
};
const show = (mode: FloatingLinkMode, editorId: string) => {
store.set({ isEditing: false, mode, openEditorId: editorId });
};
return {
api: () => ({
decodeUrl: (url: string) => {
try {
return decodeURI(url);
} catch (error) {
if (error instanceof URIError) return url;
throw error;
}
},
hide,
reset: () => {
store.set({
...transientState,
openEditorId: store.get('openEditorId'),
});
},
show,
}),
selectors: {
isOpen: (state, editorId: string) => state.openEditorId === editorId,
},
};
})
.extend({
api: ({ api, editor, store, update }) => ({
submit: () => {
const selection = editor.read.selection();
if (!selection) return undefined;
const { newTab, text, url } = store.get();
const editedLink = editor.read.nodes.above({
at: selection,
type: linkPlugin,
});
if (editedLink && url === editedLink[0].url) {
update.upsertText({ text: text || url, url });
} else {
const draft = url.trim();
if (
!DRAFT_ADDRESS.test(draft) ||
!update.upsert({
target: newTab ? '_blank' : undefined,
text,
url: draft,
})
) {
return undefined;
}
}
api.hide();
setTimeout(() => {
editor.api.dom.focus();
}, 0);
return true;
},
triggerEdit: () => {
const selection = editor.read.selection();
if (!selection) return undefined;
const entry = editor.read.nodes.above({
at: selection,
type: linkPlugin,
});
if (!entry) return undefined;
const [link, path] = entry;
const linkText = editor.read.text.string(path);
api.show('edit', editor.id);
store.set({
isEditing: true,
newTab: link.target === '_blank',
text: linkText === link.url ? '' : linkText,
url: link.url,
});
return true;
},
triggerInsert: ({ focused }: { focused?: boolean } = {}) => {
if (store.get().mode || !focused) return undefined;
if (editor.read.selection.isAcrossBlocks()) return undefined;
const selection = editor.read.selection();
if (!selection) return undefined;
if (editor.read.nodes.some({ at: selection, type: linkPlugin })) {
return undefined;
}
const block = editor.read.nodes.block({ at: selection });
if (
block &&
!editor.read.schema.allowsElementType(
block[0].type,
editor.plugin(LinkPlugin).schema.type
)
) {
return undefined;
}
store.set({ text: editor.read.text.string() });
api.show('insert', editor.id);
return true;
},
}),
})
.extend({
api: ({ api, editor }) => ({
trigger: ({
focused = editor.read.view.isFocused(),
}: { focused?: boolean } = {}) =>
!editor.read.view.isReadOnly() &&
focused &&
(api.triggerEdit() ?? api.triggerInsert({ focused })) === true,
}),
})
.extend(({ api, store }) => ({
shortcuts: {
dismiss: {
keys: 'escape',
handler: () => {
if (!store.get().mode) return false;
api.hide();
return true;
},
},
trigger: { keys: 'mod+k' },
},
}));
function FloatingLinkUrlInput({
...props
}: React.ComponentPropsWithRef<'input'>) {
const { api, store } = useEditor().plugin(linkPlugin);
return (
<Input
defaultValue={api.decodeUrl(store.get().url)}
onChange={(event) => {
store.set({ url: event.target.value });
}}
{...props}
/>
);
}
export function LinkFloatingToolbar({ editableRef }: EditableSiblingProps) {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const isEditing = usePluginStore(linkPlugin, 'isEditing');
const selectedLinkKey = useEditorSelector(
(innerEditor) => {
const selection = innerEditor.read.selection();
if (!selection || !innerEditor.read.selection.isCollapsed()) return null;
const entry = innerEditor.read.nodes.above({
at: selection,
type: linkPlugin,
});
return entry ? innerEditor.key(entry[0]) : null;
},
{
shouldUpdate: (change) =>
!change || change.selectionChanged || change.changed.hasAny('document'),
}
);
const mode = usePluginStore(linkPlugin, 'mode');
const open = usePluginStore(linkPlugin, 'isOpen', editor.id);
const { api, store, update } = useEditor().plugin(linkPlugin);
const geometry = useSelectionGeometry({ editableRef });
const editOpen =
!readOnly && open && mode === 'edit' && editor.read.selection.isCollapsed();
const editFloating = useFloatingRect(geometry?.boundingRect ?? null, {
onOpenChange: (nextOpen) => {
if (!nextOpen) api.hide();
},
open: editOpen,
...linkFloatingOptions,
});
const insertFloating = useFloatingRect(geometry?.boundingRect ?? null, {
onOpenChange: (nextOpen) => {
if (nextOpen) return;
api.hide();
editor.api.dom.focus();
},
open: !readOnly && open && mode === 'insert',
...linkFloatingOptions,
});
React.useEffect(() => {
if (readOnly) {
if (store.get().openEditorId === editor.id) api.hide();
return;
}
if (selectedLinkKey) {
api.show('edit', editor.id);
return;
}
if (store.get().mode === 'edit') api.hide();
}, [api, editor, readOnly, selectedLinkKey, store]);
const editDismiss = useDismiss(editFloating.context, {
escapeKey: false,
outsidePress: () => store.get().isEditing,
});
const insertDismiss = useDismiss(insertFloating.context, {
escapeKey: false,
});
const editInteractions = useInteractions([editDismiss]);
const insertInteractions = useInteractions([insertDismiss]);
const floatingElement =
mode === 'insert'
? insertFloating.elements.floating
: editFloating.elements.floating;
React.useEffect(() => {
const view = floatingElement?.ownerDocument.defaultView;
if (!view || !open || (mode !== 'insert' && !isEditing)) return undefined;
let timeout: number | undefined;
const onBlur = () => {
view.clearTimeout(timeout);
// Iframe focus does not dispatch an outside pointer event in this document.
timeout = view.setTimeout(() => {
if (
floatingElement.ownerDocument.activeElement?.localName === 'iframe'
) {
api.hide();
}
}, 0);
};
view.addEventListener('blur', onBlur);
return () => {
view.removeEventListener('blur', onBlur);
view.clearTimeout(timeout);
};
}, [api, floatingElement, isEditing, mode, open]);
const { text } = store.get();
const editButtonProps = { onClick: api.triggerEdit };
const onFloatingKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
if (mode === 'edit' && store.get().isEditing) {
api.show('edit', editor.id);
} else {
api.hide();
}
editor.api.dom.focus();
};
const editProps = {
onKeyDown: onFloatingKeyDown,
style: { ...editFloating.style, zIndex: 50 },
};
const editRef = useComposedRef(editFloating.refs.setFloating);
const unlinkButtonProps = {
onClick: () => {
update.unwrap();
api.hide();
editor.api.dom.focus();
},
onMouseDown: (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
},
};
const insertProps = {
onKeyDown: onFloatingKeyDown,
style: { ...insertFloating.style, zIndex: 50 },
};
const insertRef = useComposedRef(insertFloating.refs.setFloating);
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
api.submit();
};
const urlInputRef = React.useRef<HTMLInputElement>(null);
const shouldFocusUrlInput =
!readOnly &&
open &&
!!geometry &&
((mode === 'insert' && insertFloating.isPositioned) ||
(mode === 'edit' && isEditing && editFloating.isPositioned));
React.useEffect(() => {
if (shouldFocusUrlInput) {
urlInputRef.current?.focus({ preventScroll: true });
}
}, [shouldFocusUrlInput]);
const textInputProps = {
defaultValue: text,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
store.set({ text: event.target.value });
},
onKeyDown: onInputKeyDown,
};
if (readOnly || !open) return null;
const input = (
<div className="flex w-[330px] max-w-[calc(100vw-16px)] flex-col">
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Link className="size-4" />
</div>
<FloatingLinkUrlInput
ref={urlInputRef}
className="h-7 border-none bg-transparent px-1.5 py-1 shadow-none focus-visible:ring-transparent"
placeholder="Paste link"
data-editor-keep-selection-visible
onKeyDown={onInputKeyDown}
/>
</div>
<Separator className="my-1" />
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Text className="size-4" />
</div>
<Input
className="h-7 border-none bg-transparent px-1.5 py-1 shadow-none focus-visible:ring-transparent"
placeholder="Text to display"
data-editor-keep-selection-visible
{...textInputProps}
/>
</div>
</div>
);
const editContent = isEditing ? (
input
) : (
<div className="box-content flex items-center">
<Button size="sm" type="button" variant="ghost" {...editButtonProps}>
Edit link
</Button>
<Separator orientation="vertical" />
<LinkOpenButton />
<Separator orientation="vertical" />
<Button size="sm" type="button" variant="ghost" {...unlinkButtonProps}>
<Unlink width={18} />
</Button>
</div>
);
if (mode === 'insert') {
return (
<div
ref={insertRef}
className={cn(popoverVariants(), 'p-0')}
{...insertInteractions.getFloatingProps(insertProps)}
>
{input}
</div>
);
}
return (
<div
ref={editRef}
className={cn(popoverVariants(), isEditing && 'p-0')}
{...editInteractions.getFloatingProps(editProps)}
>
{editContent}
</div>
);
}
function LinkOpenButton() {
const editor = useEditor();
useEditorSelection();
const entry = editor.read.nodes.find({ type: linkPlugin });
const attributes = entry
? editor.plugin(linkPlugin).api.getAttributes(entry[0])
: {};
return (
<a
{...attributes}
className={buttonVariants({
size: 'sm',
variant: 'ghost',
})}
onMouseOver={(e) => {
e.stopPropagation();
}}
onFocus={(e) => {
e.stopPropagation();
}}
aria-label="Open link in a new tab"
target="_blank"
>
<ExternalLink width={18} />
</a>
);
}
export const LinkKit = [
linkPlugin.configure({
component: LinkElement,
inputRules: [
LinkRules.markdown(),
LinkRules.autolink({ variant: 'paste' }),
LinkRules.autolink({ variant: 'space' }),
LinkRules.autolink({ variant: 'break' }),
],
slots: {
afterEditable: LinkFloatingToolbar,
},
}),
] as const;'use client';
import {
flip,
offset,
shift,
useDismiss,
useInteractions,
} from '@floating-ui/react';
import { cva } from 'class-variance-authority';
import { ExternalLink, Link, Text, Unlink } from 'lucide-react';
import { LinkRules } from 'platejs';
import {
type EditableSiblingProps,
LinkPlugin,
type EditorElementProps,
EditorElement,
useComposedRef,
useEditor,
useEditorReadOnly,
useEditorSelection,
useEditorSelector,
usePluginStore,
Use that exact descriptor when another copied UI surface opens the toolbar.
import { linkPlugin } from '@/components/editor/link';
const link = editor.plugin(linkPlugin);
link.api.trigger({ focused: true });
link.api.triggerEdit();
link.api.triggerInsert({ focused: true });
link.api.submit();import { linkPlugin } from '@/components/editor/link';
const link = editor.plugin(linkPlugin);
link.api.trigger({ focused: true });
link.api.triggerEdit();
link.api.triggerInsert({ focused: true });
link.api.submit();import { LinkPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { LinkElement } from '@/components/editor/link';
import { LinkFloatingToolbar } from '@/components/editor/link';
const editor = createEditor({
plugins: [
// ...otherPlugins,
LinkPlugin.configure({
component: LinkElement,
slots: {
afterEditable: LinkFloatingToolbar,
},
}),
],
});import { LinkPlugin } from 'platejs/react';
const editor = createEditor({
plugins: [
LinkPlugin.configure({
inputRules: [
LinkRules.markdown(),
LinkRules.autolink({ variant: 'paste' }),
LinkRules.autolink({ variant: 'space' }),
LinkRules.autolink({ variant: 'break' }),
],
}),
],
});LinkPlugin.configure({
initialState: {
parseUrl: (text, defaultParseUrl) =>
text === 'example' ? 'https://example.com' : defaultParseUrl(text),
},
});