Skip to content

Commit

Permalink
Clickable link plugin (#1300)
Browse files Browse the repository at this point in the history
* Add clickable link plugin

* Dump
  • Loading branch information
fantactuka authored and acywatson committed Apr 9, 2022
1 parent b1daaeb commit fa1d329
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/lexical-playground/src/Editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import ActionsPlugin from './plugins/ActionsPlugin';
import AutocompletePlugin from './plugins/AutocompletePlugin';
import AutoLinkPlugin from './plugins/AutoLinkPlugin';
import CharacterStylesPopupPlugin from './plugins/CharacterStylesPopupPlugin';
import ClickableLinkPlugin from './plugins/ClickableLinkPlugin';
import CodeHighlightPlugin from './plugins/CodeHighlightPlugin';
import EmojisPlugin from './plugins/EmojisPlugin';
import EquationsPlugin from './plugins/EquationsPlugin';
Expand Down Expand Up @@ -123,6 +124,7 @@ export default function Editor(): React$Node {
<PollPlugin />
<TwitterPlugin />
<YouTubePlugin />
<ClickableLinkPlugin />
</>
) : (
<>
Expand Down
136 changes: 136 additions & 0 deletions packages/lexical-playground/src/plugins/ClickableLinkPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/

import type {LinkNode} from '@lexical/link';
import type {LexicalEditor} from 'lexical';

import {$isLinkNode} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$getNearestNodeFromDOMNode} from 'lexical';
import {useEffect, useRef} from 'react';

type LinkFilter = (event: MouseEvent, linkNode: LinkNode) => boolean;

export default function ClickableLinkPlugin({
filter,
newTab = true,
}: {
filter?: LinkFilter,
newTab?: boolean,
}): React$Node {
const [editor] = useLexicalComposerContext();
const hasMoved = useRef(false);

useEffect(() => {
let prevOffsetX;
let prevOffsetY;

function onPointerDown(event: PointerEvent) {
prevOffsetX = event.offsetX;
prevOffsetY = event.offsetY;
}

function onPointerUp(event: PointerEvent) {
hasMoved.current =
event.offsetX !== prevOffsetX || event.offsetY !== prevOffsetY;
}

function onClick(e: Event) {
// Based on pointerdown/up we can check if cursor moved during click event,
// and ignore clicks with moves (to allow link text selection)
const hasMovedDuringClick = hasMoved.current;
hasMoved.current = false;

// $FlowExpectedError[incompatible-cast] onClick handler will get MouseEvent, safe to cast
const event = (e: MouseEvent);
const linkDomNode = getLinkDomNode(event, editor);
if (linkDomNode === null) {
return;
}

const href = linkDomNode.getAttribute('href');
if (
linkDomNode.getAttribute('contenteditable') === 'false' ||
href === undefined
) {
return;
}

let linkNode = null;
editor.update(() => {
const maybeLinkNode = $getNearestNodeFromDOMNode(linkDomNode);
if ($isLinkNode(maybeLinkNode)) {
linkNode = maybeLinkNode;
}
});

if (
linkNode === null ||
(filter !== undefined && !filter(event, linkNode))
) {
return;
}

if (hasMovedDuringClick) {
return;
}

window.open(
href,
newTab || event.metaKey || event.ctrlKey ? '_blank' : '_self',
);
}

return editor.registerRootListener(
(
rootElement: null | HTMLElement,
prevRootElement: null | HTMLElement,
) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener('pointerdown', onPointerDown);
prevRootElement.removeEventListener('pointerup', onPointerUp);
prevRootElement.removeEventListener('click', onClick);
}
if (rootElement !== null) {
rootElement.addEventListener('click', onClick);
rootElement.addEventListener('pointerdown', onPointerDown);
rootElement.addEventListener('pointerup', onPointerUp);
}
},
);
}, [editor, filter, newTab]);

return null;
}

function isLinkDomNode(domNode: Node): boolean {
return domNode.nodeName.toLowerCase() === 'a';
}

function getLinkDomNode(
event: MouseEvent,
editor: LexicalEditor,
): HTMLAnchorElement | null {
return editor.getEditorState().read(() => {
// $FlowExpectedError[incompatible-cast]
const domNode = (event.target: Node);

if (isLinkDomNode(domNode)) {
// $FlowExpectedError[incompatible-cast]
return (domNode: HTMLAnchorElement);
}

if (domNode.parentNode && isLinkDomNode(domNode.parentNode)) {
// $FlowExpectedError[incompatible-cast]
return (domNode.parentNode: HTMLAnchorElement);
}

return null;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
color: rgb(33, 111, 219);
text-decoration: none;
}
.PlaygroundEditorTheme__link:hover {
text-decoration: underline;
}
.PlaygroundEditorTheme__code {
background-color: rgb(240, 242, 245);
font-family: Menlo, Consolas, Monaco, monospace;
Expand Down

0 comments on commit fa1d329

Please sign in to comment.