From 4935d1851f799035e69163eb16e963dc0e8fb442 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 29 Apr 2024 18:21:57 +0200 Subject: [PATCH] feat(lexical-editor): add support for link alt attribute --- .../FloatingLinkEditorPlugin.css | 68 ++++- .../FloatingLinkEditorPlugin.tsx | 244 ++++++------------ .../FloatingLinkEditorPlugin/LinkEditForm.tsx | 110 ++++++++ .../LinkPreviewForm.tsx | 53 ++++ .../useFloatingLinkEditor.tsx | 88 +++++++ .../lexical-editor/src/utils/isAnchorLink.ts | 3 + .../src/utils/isUrlLinkReference.ts | 3 - .../lexical-editor/src/utils/sanitizeUrl.ts | 4 +- packages/lexical-nodes/src/LinkNode.ts | 156 ++--------- packages/lexical-nodes/src/index.ts | 1 + .../lexical-nodes/src/utils/toggleLink.ts | 153 +++++++++++ 11 files changed, 565 insertions(+), 318 deletions(-) create mode 100644 packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkEditForm.tsx create mode 100644 packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkPreviewForm.tsx create mode 100644 packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx create mode 100644 packages/lexical-editor/src/utils/isAnchorLink.ts delete mode 100644 packages/lexical-editor/src/utils/isUrlLinkReference.ts create mode 100644 packages/lexical-nodes/src/utils/toggleLink.ts diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.css b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.css index f5b77ff73c1..a6d95fcc692 100644 --- a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.css +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.css @@ -40,16 +40,64 @@ background-color: rgb(223, 232, 250); } +.link-editor .link-editor-section { + margin: 0 15px; + padding: 10px 0; + width: calc(100% - 24px); +} + +.link-editor .link-editor-popup-title { + margin: 0 15px 10px;; + color: #fa5723; +} + +.link-editor .link-editor-section .header { + width: auto; + margin-bottom: 10px; +} + +.link-editor .link-editor-section .header_icon { + font-size: 16px; + width: 16px; + height: 16px; +} + +.link-editor .link-editor-section .header_title { + font-size: 16px; + font-width: bold; + color: #fa5723; +} + +.link-editor .link-editor-section .section-desc { + color: #0a0a0a; +} + +.link-editor .link-editor-section.edit-form-bottom-menu { + display: flex; + justify-content: right; + margin-top: 5px; + column-gap: 10px; +} + +.link-editor .link-editor-section ul { + list-style: initial; + padding: 0 20px; +} + +.link-editor .link-editor-section ul li { + padding: 3px 0; +} + .link-editor .link-input { display: block; width: calc(100% - 24px); - height: 32px; + height: 42px; box-sizing: border-box; - margin: 8px 12px; - padding: 8px 12px; - border-radius: 15px; + margin: 12px; + padding: 12px; + border-radius: 10px; background-color: #eee; - font-size: 15px; + font-size: 16px; color: rgb(5, 5, 5); border: 0; outline: 0; @@ -57,6 +105,11 @@ font-family: inherit; } +.link-editor .link-input.full-with { + width: 100%; + margin: 0; +} + .link-editor .link-input .link-unlink { background-image: url(../../images/icons/unlink_icon.svg); background-size: 18px; @@ -120,3 +173,8 @@ width: 20px; vertical-align: -0.25em; } + + +.link-preview-form { + +} diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx index b3ae2f7e925..5289977e9dd 100644 --- a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx @@ -1,29 +1,31 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import debounce from "lodash/debounce"; -import "./FloatingLinkEditorPlugin.css"; -import { $findMatchingParent, mergeRegister } from "@lexical/utils"; +import { mergeRegister } from "@lexical/utils"; import { $getSelection, $isRangeSelection, - COMMAND_PRIORITY_CRITICAL, + BLUR_COMMAND, COMMAND_PRIORITY_LOW, GridSelection, LexicalEditor, NodeSelection, RangeSelection, - SELECTION_CHANGE_COMMAND, - BLUR_COMMAND + SELECTION_CHANGE_COMMAND } from "lexical"; - -import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@webiny/lexical-nodes"; -import { LinkPreview } from "~/ui/LinkPreview"; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@webiny/lexical-nodes"; import { getSelectedNode } from "~/utils/getSelectedNode"; -import { sanitizeUrl } from "~/utils/sanitizeUrl"; import { setFloatingElemPosition } from "~/utils/setFloatingElemPosition"; -import { isUrlLinkReference } from "~/utils/isUrlLinkReference"; -import { isChildOfLinkEditor } from "./isChildOfLinkEditor"; -import { useRichTextEditor } from "~/hooks"; +import { useFloatingLinkEditor } from "./useFloatingLinkEditor"; +import { LinkEditForm } from "./LinkEditForm"; +import { LinkPreviewForm } from "./LinkPreviewForm"; +import "./FloatingLinkEditorPlugin.css"; +import { sanitizeUrl } from "~/utils/sanitizeUrl"; +import { isChildOfLinkEditor } from "~/plugins/FloatingLinkEditorPlugin/isChildOfLinkEditor"; + +export interface LinkData { + url: string; + target: string | null; + alt: string | null; +} interface FloatingLinkEditorProps { editor: LexicalEditor; @@ -31,13 +33,14 @@ interface FloatingLinkEditorProps { anchorElem: HTMLElement; } -function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEditorProps) { +export function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEditorProps) { const editorRef = useRef(null); - const inputRef = useRef(null); - const [linkUrl, setLinkUrl] = useState<{ url: string; target: string | null }>({ + const [linkData, setLinkData] = useState({ url: "", - target: null + target: null, + alt: null }); + const [isEditMode, setEditMode] = useState(false); const [lastSelection, setLastSelection] = useState< RangeSelection | GridSelection | NodeSelection | null @@ -45,15 +48,27 @@ function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEdito const updateLinkEditor = useCallback(() => { const selection = $getSelection(); + const emptyLinkData = { url: "", target: null, alt: null }; if ($isRangeSelection(selection)) { const node = getSelectedNode(selection); const parent = node.getParent(); + if ($isLinkNode(parent)) { - setLinkUrl({ url: parent.getURL(), target: parent.getTarget() }); + const linkData = { + url: parent.getURL(), + target: parent.getTarget(), + alt: $isLinkNode(parent) ? parent.getAlt() : null + }; + setLinkData(linkData); } else if ($isLinkNode(node)) { - setLinkUrl({ url: node.getURL(), target: node.getTarget() }); + const linkData = { + url: node.getURL(), + target: node.getTarget(), + alt: $isLinkNode(node) ? node.getAlt() : null + }; + setLinkData(linkData); } else { - setLinkUrl({ url: "", target: null }); + setLinkData(emptyLinkData); } } const editorElem = editorRef.current; @@ -92,7 +107,7 @@ function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEdito } setLastSelection(null); setEditMode(false); - setLinkUrl({ url: "", target: null }); + setLinkData(emptyLinkData); } return true; @@ -103,6 +118,19 @@ function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEdito setEditMode(false); }; + const applyChanges = (linkData: LinkData) => { + const confirmedLinkData = { + url: sanitizeUrl(linkData.url), + target: linkData.target, + alt: linkData.alt + }; + + if (lastSelection !== null) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, confirmedLinkData); + setEditMode(false); + } + }; + useEffect(() => { const scrollerElem = anchorElem.parentElement; @@ -139,7 +167,18 @@ function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEdito SELECTION_CHANGE_COMMAND, () => { updateLinkEditor(); - return true; + return false; + }, + COMMAND_PRIORITY_LOW + ), + + editor.registerCommand( + BLUR_COMMAND, + payload => { + if (!isChildOfLinkEditor(payload.relatedTarget as HTMLElement)) { + setEditMode(false); + } + return false; }, COMMAND_PRIORITY_LOW ) @@ -152,12 +191,6 @@ function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLinkEdito }); }, [editor, updateLinkEditor]); - useEffect(() => { - if (isEditMode && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditMode]); - return (
{isEditMode ? ( - <> -
- - setLinkUrl({ ...linkUrl, target: linkUrl.target ? null : "_blank" }) - } - />{" "} - New tab -
- { - setLinkUrl({ url: event.target.value, target: null }); - }} - onKeyDown={event => { - if (event.key === "Enter") { - event.preventDefault(); - if (lastSelection !== null) { - if (linkUrl.url !== "") { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, { - url: sanitizeUrl(linkUrl.url), - target: linkUrl.target - }); - } - setEditMode(false); - } - } else if (event.key === "Escape") { - event.preventDefault(); - setEditMode(false); - } - }} - /> - + setEditMode(false)} + /> ) : ( - <> -
- {" "} - New tab -
-
- - {linkUrl.url} - -
event.preventDefault()} - onClick={() => { - setEditMode(true); - }} - /> -
event.preventDefault()} - onClick={() => { - removeLink(); - }} - /> -
- - + { + setEditMode(true); + }} + /> )}
); } -function useFloatingLinkEditorToolbar(anchorElem: HTMLElement): JSX.Element | null { - const { editor } = useRichTextEditor(); - const [isLink, setIsLink] = useState(false); - - const debounceSetIsLink = useCallback(debounce(setIsLink, 50), []); - - const updateToolbar = useCallback(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } - - const node = getSelectedNode(selection); - const linkParent = $findMatchingParent(node, $isLinkNode); - const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode); - const isLinkOrChildOfLink = Boolean($isLinkNode(node) || linkParent); - - if (!isLinkOrChildOfLink) { - // When hiding the toolbar, we want to hide immediately. - setIsLink(false); - } - - if (selection.dirty) { - // We don't want this menu to open for auto links. - if (linkParent != null && autoLinkParent == null) { - // When showing the toolbar, we want to debounce it, because sometimes selection gets updated - // multiple times, and the `selection.dirty` flag goes from true to false multiple times, - // eventually settling on `false`, which we want to set once it has settled. - debounceSetIsLink(true); - } - } - }, []); - - useEffect(() => { - return mergeRegister( - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - updateToolbar(); - return false; - }, - COMMAND_PRIORITY_CRITICAL - ), - editor.registerCommand( - BLUR_COMMAND, - payload => { - if (!isChildOfLinkEditor(payload.relatedTarget as HTMLElement)) { - setIsLink(false); - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - TOGGLE_LINK_COMMAND, - payload => { - setIsLink(!!payload); - return false; - }, - COMMAND_PRIORITY_CRITICAL - ) - ); - }, [editor, updateToolbar]); - - return createPortal( - , - anchorElem - ); -} - export function FloatingLinkEditorPlugin({ anchorElem = document.body }: { anchorElem?: HTMLElement; }): JSX.Element | null { - return useFloatingLinkEditorToolbar(anchorElem); + return useFloatingLinkEditor(anchorElem); } diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkEditForm.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkEditForm.tsx new file mode 100644 index 00000000000..bdf29920163 --- /dev/null +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkEditForm.tsx @@ -0,0 +1,110 @@ +import React, { useState } from "react"; +import { sanitizeUrl } from "~/utils/sanitizeUrl"; +import { isAnchorLink } from "~/utils/isAnchorLink"; +import { LinkData } from "./FloatingLinkEditorPlugin"; + +interface LinkFormProps { + linkData: LinkData; + onSave: (linkData: LinkData) => void; + onCancel: () => void; +} + +export const LinkEditForm = ({ linkData, onSave, onCancel }: LinkFormProps) => { + const [linkState, setLinkState] = useState(linkData); + + const onInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + onSubmit(); + } else if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }; + + const onSubmit = () => { + onSave({ + ...linkState, + target: isAnchorLink(linkState.url) ? null : linkState.target, + url: sanitizeUrl(linkState.url) + }); + }; + + return ( +
+
Edit Link
+ +
+
+
URL
+
+
+ { + return setLinkState(state => ({ + ...state, + url: e.target.value + })); + }} + /> +
+
+
+
+
Alt text
+
+
+ { + return setLinkState(state => ({ + ...state, + alt: e.target.value + })); + }} + /> +
+
+
+
+ { + return setLinkState(state => ({ + ...state, + target: e.target.checked ? "_blank" : null + })); + }} + /> + +
+
+ +
+ + +
+
+ ); +}; diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkPreviewForm.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkPreviewForm.tsx new file mode 100644 index 00000000000..ce9f9f437d7 --- /dev/null +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/LinkPreviewForm.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { LinkData } from "./FloatingLinkEditorPlugin"; + +interface LinkFormProps { + linkData: LinkData; + onEdit: () => void; + removeLink: () => void; +} + +export const LinkPreviewForm = ({ linkData, onEdit, removeLink }: LinkFormProps) => { + return ( +
+
Preview Link
+
+ + {linkData.url} + +
event.preventDefault()} + onClick={onEdit} + /> +
event.preventDefault()} + onClick={removeLink} + /> +
+
+
    +
  • + {linkData.target === "_blank" ? ( + Open link in a new tab + ) : ( + Open link in the same tab + )} +
  • + {linkData.alt && ( +
  • + + Alt text: {linkData.alt} + +
  • + )} +
+
+
+ ); +}; diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx new file mode 100644 index 00000000000..cad6ec0723d --- /dev/null +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useRichTextEditor } from "~/hooks"; +import { getSelectedNode } from "~/utils/getSelectedNode"; +import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@webiny/lexical-nodes"; +import { isChildOfLinkEditor } from "~/plugins/FloatingLinkEditorPlugin/isChildOfLinkEditor"; +import debounce from "lodash/debounce"; +import { + $getSelection, + $isRangeSelection, + BLUR_COMMAND, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_LOW, + SELECTION_CHANGE_COMMAND +} from "lexical"; +import { $findMatchingParent, mergeRegister } from "@lexical/utils"; +import { FloatingLinkEditor } from "./FloatingLinkEditorPlugin"; + +export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | null { + const { editor } = useRichTextEditor(); + const [isLink, setIsLink] = useState(false); + + const debounceSetIsLink = useCallback(debounce(setIsLink, 50), []); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + const node = getSelectedNode(selection); + const linkParent = $findMatchingParent(node, $isLinkNode); + const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode); + const isLinkOrChildOfLink = Boolean($isLinkNode(node) || linkParent); + + if (!isLinkOrChildOfLink) { + // When hiding the toolbar, we want to hide immediately. + setIsLink(false); + } + + if (selection.dirty) { + // We don't want this menu to open for auto links. + if (linkParent != null && autoLinkParent == null) { + // When showing the toolbar, we want to debounce it, because sometimes selection gets updated + // multiple times, and the `selection.dirty` flag goes from true to false multiple times, + // eventually settling on `false`, which we want to set once it has settled. + debounceSetIsLink(true); + } + } + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateToolbar(); + return false; + }, + COMMAND_PRIORITY_CRITICAL + ), + editor.registerCommand( + BLUR_COMMAND, + payload => { + if (!isChildOfLinkEditor(payload.relatedTarget as HTMLElement)) { + setIsLink(false); + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + TOGGLE_LINK_COMMAND, + payload => { + setIsLink(!!payload); + return false; + }, + COMMAND_PRIORITY_CRITICAL + ) + ); + }, [editor, updateToolbar]); + + return createPortal( + , + anchorElem + ); +} diff --git a/packages/lexical-editor/src/utils/isAnchorLink.ts b/packages/lexical-editor/src/utils/isAnchorLink.ts new file mode 100644 index 00000000000..30e3718c776 --- /dev/null +++ b/packages/lexical-editor/src/utils/isAnchorLink.ts @@ -0,0 +1,3 @@ +export const isAnchorLink = (url: string) => { + return url.startsWith("#"); +}; diff --git a/packages/lexical-editor/src/utils/isUrlLinkReference.ts b/packages/lexical-editor/src/utils/isUrlLinkReference.ts deleted file mode 100644 index 19b9d7ff733..00000000000 --- a/packages/lexical-editor/src/utils/isUrlLinkReference.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const isUrlLinkReference = (url: string) => { - return url.startsWith("#"); -}; diff --git a/packages/lexical-editor/src/utils/sanitizeUrl.ts b/packages/lexical-editor/src/utils/sanitizeUrl.ts index 0567d94dd0b..270a7b964fa 100644 --- a/packages/lexical-editor/src/utils/sanitizeUrl.ts +++ b/packages/lexical-editor/src/utils/sanitizeUrl.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * */ -import { isUrlLinkReference } from "~/utils/isUrlLinkReference"; +import { isAnchorLink } from "~/utils/isAnchorLink"; export const sanitizeUrl = (url: string): string => { /** A pattern that matches safe URLs. */ @@ -17,7 +17,7 @@ export const sanitizeUrl = (url: string): string => { url = String(url).trim(); - if (isUrlLinkReference(url)) { + if (isAnchorLink(url)) { return url; } diff --git a/packages/lexical-nodes/src/LinkNode.ts b/packages/lexical-nodes/src/LinkNode.ts index 1bd0a4330f8..61438dc32be 100644 --- a/packages/lexical-nodes/src/LinkNode.ts +++ b/packages/lexical-nodes/src/LinkNode.ts @@ -23,7 +23,6 @@ import type { import { addClassNamesToElement, isHTMLAnchorElement } from "@lexical/utils"; import { $applyNodeReplacement, - $getSelection, $isElementNode, $isRangeSelection, createCommand, @@ -100,8 +99,8 @@ export class LinkNode extends ElementNode { if (this.__title !== null) { element.title = this.__title; } - if (this.__alt !== null) { - element.alt = this.__alt; + if (this.__alt) { + element.setAttribute("alt", this.__alt); } addClassNamesToElement(element, config.theme.link); return element; @@ -144,7 +143,7 @@ export class LinkNode extends ElementNode { if (alt !== prevNode.__alt) { if (alt) { - anchor.alt = alt; + anchor.setAttribute("alt", alt); } else { anchor.removeAttribute("alt"); } @@ -168,7 +167,8 @@ export class LinkNode extends ElementNode { const node = $createLinkNode(serializedNode.url, { rel: serializedNode.rel, target: serializedNode.target, - title: serializedNode.title + title: serializedNode.title, + alt: serializedNode.alt }); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); @@ -195,6 +195,7 @@ export class LinkNode extends ElementNode { rel: this.getRel(), target: this.getTarget(), title: this.getTitle(), + alt: this.getAlt(), type: "link", url: this.getURL(), version: 1 @@ -237,6 +238,15 @@ export class LinkNode extends ElementNode { writable.__title = title; } + getAlt(): string | null { + return this.__alt; + } + + setAlt(text: string | null): void { + const writable = super.getWritable(); + writable.__alt = text; + } + override insertNewAfter( selection: RangeSelection, restoreSelection = true @@ -246,7 +256,8 @@ export class LinkNode extends ElementNode { const linkNode = $createLinkNode(this.__url, { rel: this.__rel, target: this.__target, - title: this.__title + title: this.__title, + alt: this.__alt }); element.append(linkNode); return linkNode; @@ -407,136 +418,3 @@ export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is A export const TOGGLE_LINK_COMMAND: LexicalCommand< string | ({ url: string } & LinkAttributes) | null > = createCommand("TOGGLE_LINK_COMMAND"); - -/** - * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null, - * but saves any children and brings them up to the parent node. - * @param url - The URL the link directs to. - * @param attributes - Optional HTML a tag attributes. { target, rel, title } - */ -export function toggleLink(url: null | string, attributes: LinkAttributes = {}): void { - const { target, title } = attributes; - const rel = attributes.rel === undefined ? "noreferrer" : attributes.rel; - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return; - } - const nodes = selection.extract(); - - if (url === null) { - // Remove LinkNodes - nodes.forEach(node => { - const parent = node.getParent(); - - if ($isLinkNode(parent)) { - const children = parent.getChildren(); - - for (let i = 0; i < children.length; i++) { - parent.insertBefore(children[i]); - } - - parent.remove(); - } - }); - } else { - // Add or merge LinkNodes - if (nodes.length === 1) { - const firstNode = nodes[0]; - // if the first node is a LinkNode or if its - // parent is a LinkNode, we update the URL, target and rel. - const linkNode = $isLinkNode(firstNode) ? firstNode : $getLinkAncestor(firstNode); - if (linkNode !== null) { - linkNode.setURL(url); - if (target !== undefined) { - linkNode.setTarget(target); - } - if (rel !== null) { - linkNode.setRel(rel); - } - if (title !== undefined) { - linkNode.setTitle(title); - } - return; - } - } - - let prevParent: ElementNode | LinkNode | null = null; - let linkNode: LinkNode | null = null; - - nodes.forEach(node => { - const parent = node.getParent(); - - if ( - parent === linkNode || - parent === null || - ($isElementNode(node) && !node.isInline()) - ) { - return; - } - - if ($isLinkNode(parent)) { - linkNode = parent; - parent.setURL(url); - if (target !== undefined) { - parent.setTarget(target); - } - if (rel !== null) { - linkNode.setRel(rel); - } - if (title !== undefined) { - linkNode.setTitle(title); - } - return; - } - - if (!parent.is(prevParent)) { - prevParent = parent; - linkNode = $createLinkNode(url, { rel, target }); - - if ($isLinkNode(parent)) { - if (node.getPreviousSibling() === null) { - parent.insertBefore(linkNode); - } else { - parent.insertAfter(linkNode); - } - } else { - node.insertBefore(linkNode); - } - } - - if ($isLinkNode(node)) { - if (node.is(linkNode)) { - return; - } - if (linkNode !== null) { - const children = node.getChildren(); - - for (let i = 0; i < children.length; i++) { - linkNode.append(children[i]); - } - } - - node.remove(); - return; - } - - if (linkNode !== null) { - linkNode.append(node); - } - }); - } -} - -function $getLinkAncestor(node: LexicalNode): null | LexicalNode { - return $getAncestor(node, $isLinkNode); -} - -function $getAncestor( - node: LexicalNode, - predicate: (ancestor: LexicalNode) => ancestor is NodeType -): null | LexicalNode { - let parent: null | LexicalNode = node; - while (parent !== null && (parent = parent.getParent()) !== null && !predicate(parent)) {} - return parent; -} diff --git a/packages/lexical-nodes/src/index.ts b/packages/lexical-nodes/src/index.ts index c022da9f67e..d4b8dbdfdc6 100644 --- a/packages/lexical-nodes/src/index.ts +++ b/packages/lexical-nodes/src/index.ts @@ -32,6 +32,7 @@ export * from "./utils/formatToQuote"; export * from "./utils/formatToHeading"; export * from "./utils/formatToParagraph"; export * from "./utils/clearNodeFormating"; +export * from "./utils/toggleLink"; // This is a list of all the nodes that our Lexical implementation supports OOTB. export const allNodes: ReadonlyArray< diff --git a/packages/lexical-nodes/src/utils/toggleLink.ts b/packages/lexical-nodes/src/utils/toggleLink.ts new file mode 100644 index 00000000000..edc67f028ae --- /dev/null +++ b/packages/lexical-nodes/src/utils/toggleLink.ts @@ -0,0 +1,153 @@ +import { + $getSelection, + $isElementNode, + $isRangeSelection, + ElementNode, + LexicalNode +} from "lexical"; +import { $createLinkNode, $isLinkNode, LinkAttributes, LinkNode } from "~/LinkNode"; + +/** + * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null, + * but saves any children and brings them up to the parent node. + * @param url - The URL the link directs to. + * @param attributes - Optional HTML a tag attributes. { target, rel, title } + */ +export function toggleLink(url: null | string, attributes: LinkAttributes = {}): void { + const { target, title, alt } = attributes; + const rel = attributes.rel === undefined ? "noreferrer" : attributes.rel; + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + const nodes = selection.extract(); + + if (url === null) { + // Remove LinkNodes + nodes.forEach(node => { + const parent = node.getParent(); + + if ($isLinkNode(parent)) { + const children = parent.getChildren(); + + for (let i = 0; i < children.length; i++) { + parent.insertBefore(children[i]); + } + + parent.remove(); + } + }); + } else { + // Add or merge LinkNodes + if (nodes.length === 1) { + const firstNode = nodes[0]; + // if the first node is a LinkNode or if its + // parent is a LinkNode, we update the URL, target and rel. + const linkNode = $isLinkNode(firstNode) ? firstNode : $getLinkAncestor(firstNode); + if (linkNode !== null) { + linkNode.setURL(url); + if (target !== undefined) { + linkNode.setTarget(target); + } + if (rel !== null) { + linkNode.setRel(rel); + } + if (title !== undefined) { + linkNode.setTitle(title); + } + + if (alt !== undefined) { + linkNode.setAlt(alt); + } + return; + } + } + + let prevParent: ElementNode | LinkNode | null = null; + let linkNode: LinkNode | null = null; + + nodes.forEach(node => { + const parent = node.getParent(); + + if ( + parent === linkNode || + parent === null || + ($isElementNode(node) && !node.isInline()) + ) { + return; + } + + if ($isLinkNode(parent)) { + linkNode = parent; + parent.setURL(url); + if (target !== undefined) { + parent.setTarget(target); + } + if (rel !== null) { + linkNode.setRel(rel); + } + if (title !== undefined) { + linkNode.setTitle(title); + } + if (alt !== undefined) { + linkNode.setAlt(alt); + } + return; + } + + if (!parent.is(prevParent)) { + prevParent = parent; + linkNode = $createLinkNode(url, { rel, target, alt }); + + if ($isLinkNode(parent)) { + if (node.getPreviousSibling() === null) { + parent.insertBefore(linkNode); + } else { + parent.insertAfter(linkNode); + } + } else { + node.insertBefore(linkNode); + } + } + + if ($isLinkNode(node)) { + if (node.is(linkNode)) { + return; + } + if (linkNode !== null) { + const children = node.getChildren(); + + for (let i = 0; i < children.length; i++) { + linkNode.append(children[i]); + } + } + + node.remove(); + return; + } + + if (linkNode !== null) { + linkNode.append(node); + } + }); + } +} + +function $getLinkAncestor(node: LexicalNode): LinkNode | null { + const ancestor = $getAncestor(node, $isLinkNode); + if (!ancestor) { + return null; + } + + return ancestor as LinkNode; +} + +function $getAncestor( + node: LexicalNode, + predicate: (ancestor: LexicalNode) => ancestor is NodeType +): null | LexicalNode { + let parent: null | LexicalNode = node; + while (parent !== null && (parent = parent.getParent()) !== null && !predicate(parent)) {} + return parent; +}