Your IP : 216.73.216.86


Current Path : /var/www/homesaver/www/bitrix/js/ui/text-editor/src/plugins/auto-link/
Upload File :
Current File : /var/www/homesaver/www/bitrix/js/ui/text-editor/src/plugins/auto-link/auto-link-plugin.js

import { Type } from 'main.core';

import {
	TextNode,
	$createTextNode,
	$isElementNode,
	$isLineBreakNode,
	$isTextNode,
	type LexicalNode,
	type ElementNode,
} from 'ui.lexical.core';

import {
	$createAutoLinkNode,
	$isAutoLinkNode,
	$isLinkNode,
	AutoLinkNode,
	type LinkAttributes,
} from 'ui.lexical.link';

import BasePlugin from '../base-plugin';
import { type TextEditor } from '../../text-editor';

import type { BBCodeExportConversion, BBCodeExportOutput } from '../../bbcode';
import type { SchemeValidationOptions } from '../../types/scheme-validation-options';

const URL_REGEX = (
	/((https?:\/\/(www\.)?)|(www\.))[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b([\w#%&()+./:=?@[\]~-]*)(?<![%()+.:\]-])/
);

const EMAIL_REGEX = (
	/(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z-]+\.)+[A-Za-z]{2,}))/
);

const MATCHERS = [
	createLinkMatcherWithRegExp(URL_REGEX, (text: string) => {
		return text.startsWith('http') ? text : `https://${text}`;
	}),
	createLinkMatcherWithRegExp(EMAIL_REGEX, (text: string) => {
		return `mailto:${text}`;
	}),
];

type ChangeHandler = (url: string | null, prevUrl: string | null) => void;

type LinkMatcherResult = {
	attributes?: LinkAttributes;
	index: number;
	length: number;
	text: string;
	url: string;
};

export type LinkMatcher = (text: string) => LinkMatcherResult | null;

export class AutoLinkPlugin extends BasePlugin
{
	constructor(editor: TextEditor)
	{
		super(editor);

		this.#registerListeners();
	}

	static getName(): string
	{
		return 'AutoLink';
	}

	static getNodes(editor: TextEditor): Array<Class<LexicalNode>>
	{
		return [AutoLinkNode];
	}

	exportBBCode(): BBCodeExportConversion
	{
		return {
			autolink: (): BBCodeExportOutput => {
				const scheme = this.getEditor().getBBCodeScheme();

				return {
					node: scheme.createElement({ name: 'url' }),
				};
			},
		};
	}

	validateScheme(): SchemeValidationOptions | null
	{
		return {
			nodes: [{
				nodeClass: AutoLinkNode,
			}],
			bbcodeMap: {
				autolink: 'url',
			},
		};
	}

	#registerListeners(): void
	{
		const onChange = (url: string | null, prevUrl: string | null) => {};

		this.cleanUpRegister(
			this.getEditor().registerNodeTransform(TextNode, (textNode: TextNode) => {
				const parent = textNode.getParentOrThrow();
				const previous = textNode.getPreviousSibling();
				if ($isAutoLinkNode(parent))
				{
					handleLinkEdit(parent, MATCHERS, onChange);
				}
				else if (!$isLinkNode(parent))
				{
					if (
						textNode.isSimpleText()
						&& (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))
					)
					{
						handleLinkCreation(textNode, MATCHERS, onChange);
					}

					handleBadNeighbors(textNode, MATCHERS, onChange);
				}
			}),
		);
	}
}

function createLinkMatcherWithRegExp(
	regExp: RegExp,
	urlTransformer: (text: string) => string = (text) => text,
): LinkMatcherResult
{
	return (text: string) => {
		const match = regExp.exec(text);
		if (match === null)
		{
			return null;
		}

		return {
			index: match.index,
			length: match[0].length,
			text: match[0],
			url: urlTransformer(text),
		};
	};
}

function findFirstMatch(text: string, matchers: Array<LinkMatcher>): LinkMatcherResult | null
{
	for (const matcher of matchers)
	{
		const match = matcher(text);
		if (match)
		{
			return match;
		}
	}

	return null;
}

const PUNCTUATION_OR_SPACE = /[\s(),.;[\]]/;

function isSeparator(char: string): boolean
{
	return PUNCTUATION_OR_SPACE.test(char);
}

function endsWithSeparator(textContent: string): boolean
{
	return isSeparator(textContent[textContent.length - 1]);
}

function startsWithSeparator(textContent: string): boolean
{
	return isSeparator(textContent[0]);
}

function startsWithFullStop(textContent: string): boolean
{
	return /^\.[\dA-Za-z]+/.test(textContent);
}

function isPreviousNodeValid(node: LexicalNode): boolean
{
	let previousNode = node.getPreviousSibling();
	if ($isElementNode(previousNode))
	{
		previousNode = previousNode.getLastDescendant();
	}

	return (
		previousNode === null
		|| $isLineBreakNode(previousNode)
		|| ($isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent()))
	);
}

function isNextNodeValid(node: LexicalNode): boolean
{
	let nextNode = node.getNextSibling();
	if ($isElementNode(nextNode))
	{
		nextNode = nextNode.getFirstDescendant();
	}

	return (
		nextNode === null
		|| $isLineBreakNode(nextNode)
		|| ($isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent()))
	);
}

function isContentAroundIsValid(matchStart: number, matchEnd: number, text: string, node: TextNode): boolean
{
	const contentBeforeIsValid = matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(node);
	if (!contentBeforeIsValid)
	{
		return false;
	}

	// contentAfterIsValid
	return matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(node);
}

function handleLinkCreation(node: TextNode, matchers: Array<LinkMatcher>, onChange: ChangeHandler): void
{
	const nodeText = node.getTextContent();
	let text = nodeText;
	let invalidMatchEnd = 0;
	let remainingTextNode = node;
	let match: LinkMatcherResult | null = findFirstMatch(text, matchers);
	while (match !== null)
	{
		const matchStart = match.index;
		const matchLength = match.length;
		const matchEnd = matchStart + matchLength;
		const isValid = isContentAroundIsValid(
			invalidMatchEnd + matchStart,
			invalidMatchEnd + matchEnd,
			nodeText,
			node,
		);

		if (isValid)
		{
			let linkTextNode = null;
			if (invalidMatchEnd + matchStart === 0)
			{
				[linkTextNode, remainingTextNode] = remainingTextNode.splitText(
					invalidMatchEnd + matchLength,
				);
			}
			else
			{
				[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(
					invalidMatchEnd + matchStart,
					invalidMatchEnd + matchStart + matchLength,
				);
			}

			const attributes = Type.isPlainObject(match.attributes) ? { ...match.attributes } : {};
			if (!Type.isStringFilled(attributes.target))
			{
				attributes.target = '_blank';
			}

			const linkNode = $createAutoLinkNode(match.url, attributes);
			const textNode = $createTextNode(match.text);
			textNode.setFormat(linkTextNode.getFormat());
			textNode.setDetail(linkTextNode.getDetail());
			linkNode.append(textNode);
			linkTextNode.replace(linkNode);
			onChange(match.url, null);
			invalidMatchEnd = 0;
		}
		else
		{
			invalidMatchEnd += matchEnd;
		}

		text = text.slice(Math.max(0, matchEnd));
		match = findFirstMatch(text, matchers);
	}
}

function handleLinkEdit(linkNode: AutoLinkNode, matchers: Array<LinkMatcher>, onChange: ChangeHandler): void
{
	// Check children are simple text
	const children = linkNode.getChildren();
	const childrenLength = children.length;
	for (let i = 0; i < childrenLength; i++)
	{
		const child = children[i];
		if (!$isTextNode(child) || !child.isSimpleText())
		{
			replaceWithChildren(linkNode);
			onChange(null, linkNode.getURL());

			return;
		}
	}

	// Check text content fully matches
	const text = linkNode.getTextContent();
	const match = findFirstMatch(text, matchers);
	if (match === null || match.text !== text)
	{
		replaceWithChildren(linkNode);
		onChange(null, linkNode.getURL());

		return;
	}

	// Check neighbors
	if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode))
	{
		replaceWithChildren(linkNode);
		onChange(null, linkNode.getURL());

		return;
	}

	const url = linkNode.getURL();
	if (url !== match.url)
	{
		linkNode.setURL(match.url);
		onChange(match.url, url);
	}

	if (match.attributes)
	{
		const rel = linkNode.getRel();
		if (rel !== match.attributes.rel)
		{
			linkNode.setRel(match.attributes.rel || null);
			onChange(match.attributes.rel || null, rel);
		}

		const target = linkNode.getTarget();
		if (target !== match.attributes.target)
		{
			linkNode.setTarget(match.attributes.target || null);
			onChange(match.attributes.target || null, target);
		}
	}
}

// Bad neighbours are edits in neighbor nodes that make AutoLinks incompatible.
// Given the creation preconditions, these can only be simple text nodes.
function handleBadNeighbors(textNode: TextNode, matchers: Array<LinkMatcher>, onChange: ChangeHandler): void
{
	const previousSibling = textNode.getPreviousSibling();
	const nextSibling = textNode.getNextSibling();
	const text = textNode.getTextContent();

	if ($isAutoLinkNode(previousSibling) && (!startsWithSeparator(text) || startsWithFullStop(text)))
	{
		previousSibling.append(textNode);
		handleLinkEdit(previousSibling, matchers, onChange);
		onChange(null, previousSibling.getURL());
	}

	if ($isAutoLinkNode(nextSibling) && !endsWithSeparator(text))
	{
		replaceWithChildren(nextSibling);
		handleLinkEdit(nextSibling, matchers, onChange);
		onChange(null, nextSibling.getURL());
	}
}

function replaceWithChildren(node: ElementNode): Array<LexicalNode>
{
	const children = node.getChildren();
	const childrenLength = children.length;

	for (let j = childrenLength - 1; j >= 0; j--)
	{
		node.insertAfter(children[j]);
	}

	node.remove();

	return children.map((child) => child.getLatest());
}