mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-22 07:12:36 +00:00
22d078b47f
Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies.
611 lines
14 KiB
TypeScript
611 lines
14 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|