BookStack/resources/js/wysiwyg/lexical/link/index.ts

611 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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.
*
*/
import type {
BaseSelection,
DOMConversionMap,
DOMConversionOutput,
EditorConfig,
LexicalCommand,
LexicalNode,
NodeKey,
RangeSelection,
SerializedElementNode,
} from 'lexical';
import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
import {
$applyNodeReplacement,
$getSelection,
$isElementNode,
$isRangeSelection,
createCommand,
ElementNode,
Spread,
} from 'lexical';
export type LinkAttributes = {
rel?: null | string;
target?: null | string;
title?: null | string;
};
export type AutoLinkAttributes = Partial<
Spread<LinkAttributes, {isUnlinked?: boolean}>
>;
export type SerializedLinkNode = Spread<
{
url: string;
},
Spread<LinkAttributes, SerializedElementNode>
>;
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
const SUPPORTED_URL_PROTOCOLS = new Set([
'http:',
'https:',
'mailto:',
'sms:',
'tel:',
]);
/** @noInheritDoc */
export class LinkNode extends ElementNode {
/** @internal */
__url: string;
/** @internal */
__target: null | string;
/** @internal */
__rel: null | string;
/** @internal */
__title: null | string;
static getType(): string {
return 'link';
}
static clone(node: LinkNode): LinkNode {
return new LinkNode(
node.__url,
{rel: node.__rel, target: node.__target, title: node.__title},
node.__key,
);
}
constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
super(key);
const {target = null, rel = null, title = null} = attributes;
this.__url = url;
this.__target = target;
this.__rel = rel;
this.__title = title;
}
createDOM(config: EditorConfig): LinkHTMLElementType {
const element = document.createElement('a');
element.href = this.sanitizeUrl(this.__url);
if (this.__target !== null) {
element.target = this.__target;
}
if (this.__rel !== null) {
element.rel = this.__rel;
}
if (this.__title !== null) {
element.title = this.__title;
}
addClassNamesToElement(element, config.theme.link);
return element;
}
updateDOM(
prevNode: LinkNode,
anchor: LinkHTMLElementType,
config: EditorConfig,
): boolean {
if (anchor instanceof HTMLAnchorElement) {
const url = this.__url;
const target = this.__target;
const rel = this.__rel;
const title = this.__title;
if (url !== prevNode.__url) {
anchor.href = url;
}
if (target !== prevNode.__target) {
if (target) {
anchor.target = target;
} else {
anchor.removeAttribute('target');
}
}
if (rel !== prevNode.__rel) {
if (rel) {
anchor.rel = rel;
} else {
anchor.removeAttribute('rel');
}
}
if (title !== prevNode.__title) {
if (title) {
anchor.title = title;
} else {
anchor.removeAttribute('title');
}
}
}
return false;
}
static importDOM(): DOMConversionMap | null {
return {
a: (node: Node) => ({
conversion: $convertAnchorElement,
priority: 1,
}),
};
}
static importJSON(
serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
): LinkNode {
const node = $createLinkNode(serializedNode.url, {
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
sanitizeUrl(url: string): string {
try {
const parsedUrl = new URL(url);
// eslint-disable-next-line no-script-url
if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
return 'about:blank';
}
} catch {
return url;
}
return url;
}
exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
return {
...super.exportJSON(),
rel: this.getRel(),
target: this.getTarget(),
title: this.getTitle(),
type: 'link',
url: this.getURL(),
version: 1,
};
}
getURL(): string {
return this.getLatest().__url;
}
setURL(url: string): void {
const writable = this.getWritable();
writable.__url = url;
}
getTarget(): null | string {
return this.getLatest().__target;
}
setTarget(target: null | string): void {
const writable = this.getWritable();
writable.__target = target;
}
getRel(): null | string {
return this.getLatest().__rel;
}
setRel(rel: null | string): void {
const writable = this.getWritable();
writable.__rel = rel;
}
getTitle(): null | string {
return this.getLatest().__title;
}
setTitle(title: null | string): void {
const writable = this.getWritable();
writable.__title = title;
}
insertNewAfter(
_: RangeSelection,
restoreSelection = true,
): null | ElementNode {
const linkNode = $createLinkNode(this.__url, {
rel: this.__rel,
target: this.__target,
title: this.__title,
});
this.insertAfter(linkNode, restoreSelection);
return linkNode;
}
canInsertTextBefore(): false {
return false;
}
canInsertTextAfter(): false {
return false;
}
canBeEmpty(): false {
return false;
}
isInline(): true {
return true;
}
extractWithChild(
child: LexicalNode,
selection: BaseSelection,
destination: 'clone' | 'html',
): boolean {
if (!$isRangeSelection(selection)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
return (
this.isParentOf(anchorNode) &&
this.isParentOf(focusNode) &&
selection.getTextContent().length > 0
);
}
isEmailURI(): boolean {
return this.__url.startsWith('mailto:');
}
isWebSiteURI(): boolean {
return (
this.__url.startsWith('https://') || this.__url.startsWith('http://')
);
}
}
function $convertAnchorElement(domNode: Node): DOMConversionOutput {
let node = null;
if (isHTMLAnchorElement(domNode)) {
const content = domNode.textContent;
if ((content !== null && content !== '') || domNode.children.length > 0) {
node = $createLinkNode(domNode.getAttribute('href') || '', {
rel: domNode.getAttribute('rel'),
target: domNode.getAttribute('target'),
title: domNode.getAttribute('title'),
});
}
}
return {node};
}
/**
* Takes a URL and creates a LinkNode.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
* @returns The LinkNode.
*/
export function $createLinkNode(
url: string,
attributes?: LinkAttributes,
): LinkNode {
return $applyNodeReplacement(new LinkNode(url, attributes));
}
/**
* Determines if node is a LinkNode.
* @param node - The node to be checked.
* @returns true if node is a LinkNode, false otherwise.
*/
export function $isLinkNode(
node: LexicalNode | null | undefined,
): node is LinkNode {
return node instanceof LinkNode;
}
export type SerializedAutoLinkNode = Spread<
{
isUnlinked: boolean;
},
SerializedLinkNode
>;
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
export class AutoLinkNode extends LinkNode {
/** @internal */
/** Indicates whether the autolink was ever unlinked. **/
__isUnlinked: boolean;
constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
super(url, attributes, key);
this.__isUnlinked =
attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
? attributes.isUnlinked
: false;
}
static getType(): string {
return 'autolink';
}
static clone(node: AutoLinkNode): AutoLinkNode {
return new AutoLinkNode(
node.__url,
{
isUnlinked: node.__isUnlinked,
rel: node.__rel,
target: node.__target,
title: node.__title,
},
node.__key,
);
}
getIsUnlinked(): boolean {
return this.__isUnlinked;
}
setIsUnlinked(value: boolean) {
const self = this.getWritable();
self.__isUnlinked = value;
return self;
}
createDOM(config: EditorConfig): LinkHTMLElementType {
if (this.__isUnlinked) {
return document.createElement('span');
} else {
return super.createDOM(config);
}
}
updateDOM(
prevNode: AutoLinkNode,
anchor: LinkHTMLElementType,
config: EditorConfig,
): boolean {
return (
super.updateDOM(prevNode, anchor, config) ||
prevNode.__isUnlinked !== this.__isUnlinked
);
}
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
const node = $createAutoLinkNode(serializedNode.url, {
isUnlinked: serializedNode.isUnlinked,
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
static importDOM(): null {
// TODO: Should link node should handle the import over autolink?
return null;
}
exportJSON(): SerializedAutoLinkNode {
return {
...super.exportJSON(),
isUnlinked: this.__isUnlinked,
type: 'autolink',
version: 1,
};
}
insertNewAfter(
selection: RangeSelection,
restoreSelection = true,
): null | ElementNode {
const element = this.getParentOrThrow().insertNewAfter(
selection,
restoreSelection,
);
if ($isElementNode(element)) {
const linkNode = $createAutoLinkNode(this.__url, {
isUnlinked: this.__isUnlinked,
rel: this.__rel,
target: this.__target,
title: this.__title,
});
element.append(linkNode);
return linkNode;
}
return null;
}
}
/**
* Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
* during typing, which is especially useful when a button to generate a LinkNode is not practical.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
* @returns The LinkNode.
*/
export function $createAutoLinkNode(
url: string,
attributes?: AutoLinkAttributes,
): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode(url, attributes));
}
/**
* Determines if node is an AutoLinkNode.
* @param node - The node to be checked.
* @returns true if node is an AutoLinkNode, false otherwise.
*/
export function $isAutoLinkNode(
node: LexicalNode | null | undefined,
): node is AutoLinkNode {
return node instanceof AutoLinkNode;
}
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 (!$isAutoLinkNode(parent) && $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 = $getAncestor(firstNode, $isLinkNode);
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, title});
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);
}
});
}
}
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
export const toggleLink = $toggleLink;
function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
node: LexicalNode,
predicate: (ancestor: LexicalNode) => ancestor is NodeType,
) {
let parent = node;
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
parent = parent.getParentOrThrow();
}
return predicate(parent) ? parent : null;
}