Skip to content

Commit eedeb37

Browse files
authored
feat(lexical-editor): add alt text on link node (#3600)
1 parent cd33bf0 commit eedeb37

File tree

12 files changed

+755
-192
lines changed

12 files changed

+755
-192
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createCommand, LexicalCommand } from "lexical";
2+
import { LinkAttributes } from "@lexical/link";
3+
4+
export interface LinkNodePayload extends LinkAttributes {
5+
url: string;
6+
alt?: string;
7+
}
8+
9+
export const TOGGLE_LINK_NODE_COMMAND: LexicalCommand<string | LinkNodePayload | null> =
10+
createCommand("TOGGLE_LINK_NODE_COMMAND");

packages/lexical-editor/src/components/ToolbarActions/LinkAction.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import React, { useCallback, useEffect, useState } from "react";
22
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3-
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
3+
import { $isLinkNode as $baseLinkNode } from "@lexical/link";
44
import { $getSelection, $isRangeSelection } from "lexical";
55
import { getSelectedNode } from "~/utils/getSelectedNode";
66
import { useRichTextEditor } from "~/hooks/useRichTextEditor";
7+
import { TOGGLE_LINK_NODE_COMMAND } from "~/commands/link";
8+
import { $isLinkNode } from "~/nodes/LinkNode";
79

810
export const LinkAction = () => {
911
const [editor] = useLexicalComposerContext();
@@ -12,10 +14,10 @@ export const LinkAction = () => {
1214

1315
const insertLink = useCallback(() => {
1416
if (!isLink) {
15-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
17+
editor.dispatchCommand(TOGGLE_LINK_NODE_COMMAND, "https://");
1618
setNodeIsText(false);
1719
} else {
18-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
20+
editor.dispatchCommand(TOGGLE_LINK_NODE_COMMAND, null);
1921
}
2022
}, [editor, isLink]);
2123

@@ -28,7 +30,7 @@ export const LinkAction = () => {
2830
const node = getSelectedNode(selection);
2931
// Update links
3032
const parent = node.getParent();
31-
if ($isLinkNode(parent) || $isLinkNode(node)) {
33+
if ($baseLinkNode(parent) || $isLinkNode(node)) {
3234
setIsLink(true);
3335
} else {
3436
setIsLink(false);

packages/lexical-editor/src/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export { RichTextEditor } from "~/components/Editor/RichTextEditor";
3131
export { HeadingEditor } from "~/components/Editor/HeadingEditor";
3232
export { ParagraphEditor } from "~/components/Editor/ParagraphEditor";
3333
// plugins
34-
export { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
34+
export { LinkPlugin } from "~/plugins/LinkNodePlugin/LinkNodePlugin";
3535
export { FloatingLinkEditorPlugin } from "~/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin";
3636
export { CodeHighlightPlugin } from "~/plugins/CodeHighlightPlugin/CodeHighlightPlugin";
3737
export { BlurEventPlugin } from "~/plugins/BlurEventPlugin/BlurEventPlugin";
@@ -41,6 +41,7 @@ export { TypographyPlugin } from "~/plugins/TypographyPlugin/TypographyPlugin";
4141
export { QuotePlugin } from "~/plugins/WebinyQuoteNodePlugin/WebinyQuoteNodePlugin";
4242
export { ListPlugin } from "~/plugins/ListPLugin/ListPlugin";
4343
export { ImagesPlugin } from "~/plugins/ImagesPlugin/ImagesPlugin";
44+
4445
// utils
4546
export { generateInitialLexicalValue } from "~/utils/generateInitialLexicalValue";
4647
export { isValidLexicalData } from "~/utils/isValidLexicalData";
Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,60 @@
11
import {
22
LinkAttributes,
33
LinkNode as BaseLinkNode,
4-
SerializedAutoLinkNode,
54
SerializedLinkNode as BaseSerializedLinkNode
65
} from "@lexical/link";
76
import { DOMConversionMap, DOMConversionOutput, EditorConfig, NodeKey, Spread } from "lexical";
87
import { addClassNamesToElement, isHTMLAnchorElement } from "@lexical/utils";
98
import { sanitizeUrl } from "~/utils/sanitizeUrl";
109

10+
export interface LinkNodeAttributes extends LinkAttributes {
11+
alt?: string;
12+
}
13+
1114
export type SerializedLinkNode = Spread<
1215
{
13-
type: "link-node";
16+
alt?: string;
17+
type: "link";
1418
version: 1;
1519
},
16-
Spread<LinkAttributes, BaseSerializedLinkNode>
20+
Spread<LinkNodeAttributes, BaseSerializedLinkNode>
1721
>;
1822

23+
/**
24+
* NOTES: This class is extended to support custom URLs patterns.
25+
* - We use custom 'sanitizeUrl' method to control what kind of ULRs we will support or prevent to be added.
26+
*/
1927
export class LinkNode extends BaseLinkNode {
20-
constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
21-
super(url, attributes, key);
22-
}
28+
__alt?: string;
2329

24-
static override getType(): string {
25-
return "link-node";
30+
constructor(url: string, attributes: LinkNodeAttributes, key?: NodeKey) {
31+
super(url, attributes, key);
32+
this.__alt = attributes.alt;
2633
}
2734

2835
static override clone(node: LinkNode): LinkNode {
2936
return new LinkNode(
3037
node.__url,
31-
{ rel: node.__rel, target: node.__target, title: node.__title },
38+
{ rel: node.__rel, target: node.__target, title: node.__title, alt: node.__alt },
3239
node.__key
3340
);
3441
}
3542

43+
getAlt(): string | undefined {
44+
return this.__alt;
45+
}
46+
47+
setAlt(text: string): this {
48+
const self = super.getWritable();
49+
self.__alt = text;
50+
return self;
51+
}
52+
3653
override createDOM(config: EditorConfig): HTMLAnchorElement {
3754
const element = document.createElement("a");
55+
/**
56+
* Use custom sanitization function for the URL.
57+
*/
3858
element.href = sanitizeUrl(this.__url);
3959
if (this.__target !== null) {
4060
element.target = this.__target;
@@ -45,10 +65,23 @@ export class LinkNode extends BaseLinkNode {
4565
if (this.__title !== null) {
4666
element.title = this.__title;
4767
}
68+
if (this.__alt) {
69+
element.setAttribute("alt", this.__alt);
70+
}
71+
4872
addClassNamesToElement(element, config.theme.link);
4973
return element;
5074
}
5175

76+
override updateDOM(prevNode: LinkNode, dom: HTMLElement): boolean {
77+
if (this.__alt) {
78+
dom.setAttribute("alt", this.__alt);
79+
}
80+
// Returning false tells Lexical that this node does not need its
81+
// DOM element replacing with a new copy from createDOM.
82+
return false;
83+
}
84+
5285
static override importDOM(): DOMConversionMap | null {
5386
return {
5487
a: () => ({
@@ -58,48 +91,53 @@ export class LinkNode extends BaseLinkNode {
5891
};
5992
}
6093

61-
static override importJSON(
62-
serializedNode: BaseSerializedLinkNode | SerializedLinkNode | SerializedAutoLinkNode
63-
): LinkNode {
94+
static override importJSON(serializedNode: SerializedLinkNode): LinkNode {
6495
const node = $createLinkNode(serializedNode.url, {
6596
rel: serializedNode.rel,
6697
target: serializedNode.target,
67-
title: serializedNode.title
98+
title: serializedNode.title,
99+
alt: serializedNode.alt
68100
});
69101
node.setFormat(serializedNode.format);
70102
node.setIndent(serializedNode.indent);
71103
node.setDirection(serializedNode.direction);
104+
105+
if (serializedNode.alt) {
106+
node.setAlt(serializedNode.alt);
107+
}
72108
return node;
73109
}
74110

75-
override exportJSON(): BaseSerializedLinkNode | SerializedLinkNode | SerializedAutoLinkNode {
111+
override exportJSON(): SerializedLinkNode {
76112
return {
77113
...super.exportJSON(),
78-
type: "link-node",
114+
alt: this.__alt,
115+
type: "link",
79116
version: 1
80117
};
81118
}
82119
}
83120

84-
function convertAnchorElement(domNode: Node): DOMConversionOutput {
121+
const convertAnchorElement = (domNode: Node): DOMConversionOutput => {
85122
let node = null;
86123
if (isHTMLAnchorElement(domNode)) {
87124
const content = domNode.textContent;
88125
if (content !== null && content !== "") {
89126
node = $createLinkNode(domNode.getAttribute("href") || "", {
90127
rel: domNode.getAttribute("rel"),
91128
target: domNode.getAttribute("target"),
92-
title: domNode.getAttribute("title")
129+
title: domNode.getAttribute("title"),
130+
alt: domNode.getAttribute("alt") ?? undefined
93131
});
94132
}
95133
}
96134
return { node };
97-
}
135+
};
98136

99137
export const $isLinkNode = (node: any): node is LinkNode => {
100138
return node instanceof LinkNode;
101139
};
102140

103-
export const $createLinkNode = (url: string, attributes: LinkAttributes = {}, key?: KeyType) => {
141+
export const $createLinkNode = (url: string, attributes: LinkNodeAttributes, key?: KeyType) => {
104142
return new LinkNode(url, attributes, key);
105143
};

packages/lexical-editor/src/nodes/webinyNodes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ParagraphNode } from "~/nodes/ParagraphNode";
1414
import { HeadingNode as BaseHeadingNode, QuoteNode as BaseQuoteNode } from "@lexical/rich-text";
1515
import { QuoteNode } from "~/nodes/QuoteNode";
1616
import { ImageNode } from "~/nodes/ImageNode";
17-
import { LinkNode } from "~/nodes/link-node";
17+
import { LinkNode } from "~/nodes/LinkNode";
1818

1919
// This is a list of all the nodes that our Lexical implementation supports OOTB.
2020
export const WebinyNodes: ReadonlyArray<

0 commit comments

Comments
 (0)