Your IP : 216.73.216.86


Current Path : /var/www/homesaver/www/bitrix/modules/ui/install/js/ui/text-editor/src/plugins/code/
Upload File :
Current File : /var/www/homesaver/www/bitrix/modules/ui/install/js/ui/text-editor/src/plugins/code/code-plugin.js

/* eslint-disable no-underscore-dangle */

import { Loc, Type } from 'main.core';
import { CodeParser, type CodeToken } from 'ui.code-parser';
import { $insertDataTransferForPlainText } from 'ui.lexical.clipboard';

import {
	KEY_TAB_COMMAND,
	INSERT_TAB_COMMAND,
	FORMAT_TEXT_COMMAND,
	PASTE_COMMAND,
	COMMAND_PRIORITY_LOW,
	COMMAND_PRIORITY_NORMAL,
	COMMAND_PRIORITY_HIGH,
	INDENT_CONTENT_COMMAND,
	OUTDENT_CONTENT_COMMAND,
	COMMAND_PRIORITY_EDITOR,
	TextNode,
	$createTabNode,
	$insertNodes,
	$getSelection,
	$isRangeSelection,
	$isTabNode,
	$isLineBreakNode,
	$getNodeByKey,
	$createLineBreakNode,
	$createTextNode,
	$isTextNode,
	createCommand,
	type RangeSelection,
	type LexicalCommand,
	type LineBreakNode,
	type TabNode,
	type LexicalNode,
	type NodeKey,
} from 'ui.lexical.core';

import { $setBlocksType } from 'ui.lexical.selection';
import { $findMatchingParent, $insertNodeToNearestRoot } from 'ui.lexical.utils';

import { trimLineBreaks } from '../../bbcode';
import { getSelectedNode } from '../../helpers/get-selected-node';

import BasePlugin from '../base-plugin';
import Button from '../../toolbar/button';
import { FORMAT_PARAGRAPH_COMMAND } from '../paragraph';
import { CodeNode, $isCodeNode, $createCodeNode } from './code-node';
import { CodeTokenNode, $isCodeTokenNode, $createCodeTokenNode } from './code-token-node';

import { type TextEditor } from '../../text-editor';
import type { SchemeValidationOptions } from '../../types/scheme-validation-options';
import type { BBCodeElementNode } from 'ui.bbcode.model';
import type {
	BBCodeConversion,
	BBCodeConversionFn,
	BBCodeExportConversion,
	BBCodeExportOutput,
	BBCodeImportConversion,
} from '../../bbcode';

export type InsertCodePayload = {
	content?: string,
};

export const FORMAT_CODE_COMMAND: LexicalCommand = createCommand('FORMAT_CODE_COMMAND');
export const INSERT_CODE_COMMAND: LexicalCommand<InsertCodePayload> = createCommand('INSERT_CODE_COMMAND');

export class CodePlugin extends BasePlugin
{
	#nodesCurrentlyHighlighting = new Set();
	#codeParser = new CodeParser();

	constructor(editor: TextEditor)
	{
		super(editor);

		this.#registerCommands();
		this.#registerComponents();
		this.#registerListeners();
	}

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

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

	importBBCode(): BBCodeImportConversion
	{
		return {
			code: (): BBCodeConversion => ({
				conversion: (node: BBCodeElementNode): BBCodeConversionFn | null => {
					return {
						node: $createCodeNode(),
						after: (childLexicalNodes: Array<LexicalNode>): Array<LexicalNode> => {
							const childNodes = trimLineBreaks(childLexicalNodes);
							const content = childNodes.map(
								(childNode: LexicalNode) => childNode.getTextContent(),
							).join('');

							// return getCodeTokenNodes(parse(content));
							return [$createTextNode(content)];
						},
					};
				},
				priority: 0,
			}),
		};
	}

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

				return {
					node: scheme.createElement({ name: 'code' }),
				};
			},
			'code-token': (lexicalNode: LexicalNode): BBCodeExportOutput => {
				const scheme = this.getEditor().getBBCodeScheme();

				return {
					node: scheme.createText({
						content: lexicalNode.getTextContent(),
						encode: false,
					}),
				};
			},
		};
	}

	validateScheme(): SchemeValidationOptions | null
	{
		return {
			nodes: [{
				nodeClass: CodeNode,
			}],
			bbcodeMap: {
				code: 'code',
			},
		};
	}

	#registerComponents(): void
	{
		this.getEditor().getComponentRegistry().register('code', () => {
			const button = new Button();
			button.setContent('<span class="ui-icon-set --enclose-text-in-code-tag"></span>');
			button.setTooltip(Loc.getMessage('TEXT_EDITOR_BTN_CODE'));
			button.setBlockType('code');
			button.subscribe('onClick', () => {
				this.getEditor().focus();
				this.getEditor().update(() => {
					if (button.isActive())
					{
						this.getEditor().dispatchCommand(FORMAT_PARAGRAPH_COMMAND);
					}
					else
					{
						this.getEditor().dispatchCommand(FORMAT_CODE_COMMAND);
					}
				});
			});

			return button;
		});
	}

	#registerListeners(): void
	{
		const handleTextNodeTransform = this.#handleTextNodeTransform.bind(this);

		this.cleanUpRegister(
			// Prevent formatting
			this.getEditor().registerNodeTransform(CodeNode, this.#handleCodeNodeTransform.bind(this)),
			this.getEditor().registerNodeTransform(TextNode, handleTextNodeTransform),
			this.getEditor().registerNodeTransform(CodeTokenNode, handleTextNodeTransform),
			this.getEditor().registerCommand(
				FORMAT_TEXT_COMMAND,
				() => {
					const selection: RangeSelection = $getSelection();
					if (!$isRangeSelection(selection))
					{
						return false;
					}

					const node = getSelectedNode(selection);
					// const parent = node.getParent();

					return $isCodeTokenNode(node) || $isCodeNode(node);
				},
				COMMAND_PRIORITY_HIGH,
			),
			this.getEditor().registerCommand(
				KEY_TAB_COMMAND,
				(event) => {
					const command = this.#handleTab(event.shiftKey);
					if (command === null)
					{
						return false;
					}

					event.preventDefault();
					this.getEditor().dispatchCommand(command);

					return true;
				},
				COMMAND_PRIORITY_LOW,
			),
			this.getEditor().registerCommand(
				INSERT_TAB_COMMAND,
				() => {
					const selection = $getSelection();
					if (!$isSelectionInCode(selection))
					{
						return false;
					}
					$insertNodes([$createTabNode()]);

					return true;
				},
				COMMAND_PRIORITY_LOW,
			),
			this.getEditor().registerCommand(
				INDENT_CONTENT_COMMAND,
				(payload): boolean => this.#handleMultilineIndent(INDENT_CONTENT_COMMAND),
				COMMAND_PRIORITY_NORMAL,
			),
			this.getEditor().registerCommand(
				OUTDENT_CONTENT_COMMAND,
				(payload): boolean => this.#handleMultilineIndent(OUTDENT_CONTENT_COMMAND),
				COMMAND_PRIORITY_NORMAL,
			),
			this.getEditor().registerCommand(
				PASTE_COMMAND,
				(event) => {
					const selection: RangeSelection = $getSelection();
					if (
						!$isRangeSelection(selection)
						|| !(event instanceof ClipboardEvent)
						|| event.clipboardData === null
					)
					{
						return false;
					}

					const codeNode: CodeNode = $findMatchingParent(
						selection.anchor.getNode(),
						(node: LexicalNode) => $isCodeNode(node),
					);

					if (codeNode)
					{
						$insertDataTransferForPlainText(event.clipboardData, selection);

						return true;
					}

					return false;
				},
				COMMAND_PRIORITY_NORMAL,
			),
		);
	}

	#registerCommands(): void
	{
		this.cleanUpRegister(
			this.getEditor().registerCommand(
				INSERT_CODE_COMMAND,
				(payload: InsertCodePayload) => {
					const codeNode = $createCodeNode();
					if (Type.isPlainObject(payload) && Type.isStringFilled(payload.content))
					{
						const tokenNodes = getCodeTokenNodes(this.#codeParser.parse(payload.content));
						codeNode.append(...tokenNodes);
						$insertNodeToNearestRoot(codeNode);
					}
					else
					{
						$insertNodeToNearestRoot(codeNode);
						codeNode.selectEnd();
					}

					return true;
				},
				COMMAND_PRIORITY_EDITOR,
			),
			this.getEditor().registerCommand(
				FORMAT_CODE_COMMAND,
				(): boolean => {
					const selection: RangeSelection = $getSelection();
					if ($isRangeSelection(selection))
					{
						if (selection.isCollapsed())
						{
							$setBlocksType(selection, () => $createCodeNode());
						}
						else
						{
							const textContent = selection.getTextContent();
							const codeNode = $createCodeNode();
							selection.insertNodes([codeNode]);

							const newSelection: RangeSelection = $getSelection();
							if ($isRangeSelection(newSelection))
							{
								newSelection.insertRawText(textContent);
							}
						}
					}

					return true;
				},
				COMMAND_PRIORITY_EDITOR,
			),
		);
	}

	#handleCodeNodeTransform(node: CodeNode): void
	{
		const nodeKey = node.getKey();
		if (this.#nodesCurrentlyHighlighting.has(nodeKey))
		{
			return;
		}

		this.#nodesCurrentlyHighlighting.add(nodeKey);

		// Using nested update call to pass `skipTransforms` since we don't want
		// each individual code-token node to be transformed again as it's already
		// in its final state
		this.getEditor().update(
			() => {
				updateAndRetainSelection(nodeKey, () => {
					const currentNode = $getNodeByKey(nodeKey);

					if (!$isCodeNode(currentNode) || !currentNode.isAttached())
					{
						return false;
					}
					const code = currentNode.getTextContent();
					const codeTokenNodes = getCodeTokenNodes(this.#codeParser.parse(code));
					const diffRange = getDiffRange(currentNode.getChildren(), codeTokenNodes);

					const { from, to, nodesForReplacement } = diffRange;
					if (from !== to || nodesForReplacement.length > 0)
					{
						node.splice(from, to - from, nodesForReplacement);

						return true;
					}

					return false;
				});
			},
			{
				onUpdate: () => {
					this.#nodesCurrentlyHighlighting.delete(nodeKey);
				},
				skipTransforms: true,
			},
		);
	}

	#handleTextNodeTransform(node: TextNode)
	{
		// Since CodeNode has flat children structure we only need to check
		// if node's parent is a code node and run highlighting if so
		const parentNode = node.getParent();
		if ($isCodeNode(parentNode))
		{
			this.#handleCodeNodeTransform(parentNode);
		}
		else if ($isCodeTokenNode(node))
		{
			// When code block converted into paragraph or other element
			// code token nodes converted back to normal text
			node.replace($createTextNode(node.__text));
		}
	}

	#handleTab(shiftKey: boolean): null | LexicalCommand<void>
	{
		const selection = $getSelection();
		if (!$isRangeSelection(selection) || !$isSelectionInCode(selection))
		{
			return null;
		}

		const indentOrOutdent = shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
		const tabOrOutdent = shiftKey ? OUTDENT_CONTENT_COMMAND : INSERT_TAB_COMMAND;

		// 1. If multiple lines selected: indent/outdent
		const codeLines = $getCodeLines(selection);
		if (codeLines.length > 1)
		{
			return indentOrOutdent;
		}

		// 2. If entire line selected: indent/outdent
		const selectionNodes = selection.getNodes();
		const firstNode = selectionNodes[0];
		if ($isCodeNode(firstNode))
		{
			return indentOrOutdent;
		}

		const firstOfLine = getFirstCodeNodeOfLine(firstNode);
		const lastOfLine = getLastCodeNodeOfLine(firstNode);
		const anchor = selection.anchor;
		const focus = selection.focus;
		let selectionFirst = null;
		let selectionLast = null;
		if (focus.isBefore(anchor))
		{
			selectionFirst = focus;
			selectionLast = anchor;
		}
		else
		{
			selectionFirst = anchor;
			selectionLast = focus;
		}

		if (
			firstOfLine !== null
			&& lastOfLine !== null
			&& selectionFirst.key === firstOfLine.getKey()
			&& selectionFirst.offset === 0
			&& selectionLast.key === lastOfLine.getKey()
			&& selectionLast.offset === lastOfLine.getTextContentSize()
		)
		{
			return indentOrOutdent;
		}

		// 3. Else: tab/outdent
		return tabOrOutdent;
	}

	#handleMultilineIndent(type: LexicalCommand): boolean
	{
		const selection = $getSelection();
		if (!$isRangeSelection(selection) || !$isSelectionInCode(selection))
		{
			return false;
		}

		const codeLines = $getCodeLines(selection);
		const codeLinesLength = codeLines.length;
		// Multiple lines selection
		if (codeLines.length > 1)
		{
			for (let i = 0; i < codeLinesLength; i++)
			{
				const line = codeLines[i];
				if (line.length > 0)
				{
					let firstOfLine: null | CodeTokenNode | TabNode | LineBreakNode = line[0];
					// First and last lines might not be complete
					if (i === 0)
					{
						firstOfLine = getFirstCodeNodeOfLine(firstOfLine);
					}

					if (firstOfLine !== null)
					{
						if (type === INDENT_CONTENT_COMMAND)
						{
							// eslint-disable-next-line @bitrix24/bitrix24-rules/no-native-dom-methods
							firstOfLine.insertBefore($createTabNode());
						}
						else if ($isTabNode(firstOfLine))
						{
							firstOfLine.remove();
						}
					}
				}
			}

			return true;
		}

		// Just one line
		const selectionNodes = selection.getNodes();
		const firstNode = selectionNodes[0];
		if ($isCodeNode(firstNode))
		{
			// CodeNode is empty
			if (type === INDENT_CONTENT_COMMAND)
			{
				selection.insertNodes([$createTabNode()]);
			}

			return true;
		}

		const firstOfLine = getFirstCodeNodeOfLine(firstNode);
		if (type === INDENT_CONTENT_COMMAND)
		{
			if ($isLineBreakNode(firstOfLine))
			{
				firstOfLine.insertAfter($createTabNode());
			}
			else
			{
				// eslint-disable-next-line @bitrix24/bitrix24-rules/no-native-dom-methods
				firstOfLine.insertBefore($createTabNode());
			}
		}
		else if ($isTabNode(firstOfLine))
		{
			firstOfLine.remove();
		}

		return true;
	}
}

function $isSelectionInCode(selection: null | RangeSelection): boolean
{
	if (!$isRangeSelection(selection))
	{
		return false;
	}
	const anchorNode = selection.anchor.getNode();
	const focusNode = selection.focus.getNode();
	if (anchorNode.is(focusNode) && $isCodeNode(anchorNode))
	{
		return true;
	}

	const anchorParent = anchorNode.getParent();

	return $isCodeNode(anchorParent) && anchorParent.is(focusNode.getParent());
}

function $getCodeLines(selection: RangeSelection): Array<Array<CodeTokenNode | TabNode>>
{
	const nodes = selection.getNodes();
	const lines: Array<Array<CodeTokenNode | TabNode>> = [[]];
	if (nodes.length === 1 && $isCodeNode(nodes[0]))
	{
		return lines;
	}

	let lastLine: Array<CodeTokenNode | TabNode> = lines[0];
	for (const [i, node] of nodes.entries())
	{
		if ($isLineBreakNode(node))
		{
			if (i !== 0 && lastLine.length > 0)
			{
				lastLine = [];
				lines.push(lastLine);
			}
		}
		else
		{
			lastLine.push(node);
		}
	}

	return lines;
}

export function getFirstCodeNodeOfLine(
	anchor: CodeTokenNode | TabNode | LineBreakNode,
): null | CodeTokenNode | TabNode | LineBreakNode
{
	let previousNode = anchor;
	let node: null | LexicalNode = anchor;
	while ($isCodeTokenNode(node) || $isTabNode(node))
	{
		previousNode = node;
		node = node.getPreviousSibling();
	}

	return previousNode;
}

export function getLastCodeNodeOfLine(
	anchor: CodeTokenNode | TabNode | LineBreakNode,
): CodeTokenNode | TabNode | LineBreakNode
{
	let nextNode = anchor;
	let node: null | LexicalNode = anchor;
	while ($isCodeTokenNode(node) || $isTabNode(node))
	{
		nextNode = node;
		node = node.getNextSibling();
	}

	return nextNode;
}

type DiffRange = {
	from: number;
	nodesForReplacement: Array<LexicalNode>;
	to: number;
};

// Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes
// that needs to be replaced with `nodes` (subset of nextNodes) to make prevNodes equal to nextNodes.
function getDiffRange(prevNodes: LexicalNode[], nextNodes: LexicalNode[]): DiffRange
{
	let leadingMatch = 0;
	while (leadingMatch < prevNodes.length)
	{
		if (!isEqual(prevNodes[leadingMatch], nextNodes[leadingMatch]))
		{
			break;
		}
		leadingMatch++;
	}

	const prevNodesLength: number = prevNodes.length;
	const nextNodesLength: number = nextNodes.length;
	const maxTrailingMatch: number = Math.min(prevNodesLength, nextNodesLength) - leadingMatch;

	let trailingMatch = 0;
	while (trailingMatch < maxTrailingMatch)
	{
		trailingMatch++;
		if (!isEqual(prevNodes[prevNodesLength - trailingMatch], nextNodes[nextNodesLength - trailingMatch]))
		{
			trailingMatch--;
			break;
		}
	}

	const from: number = leadingMatch;
	const to: number = prevNodesLength - trailingMatch;
	const nodesForReplacement: LexicalNode[] = nextNodes.slice(
		leadingMatch,
		nextNodesLength - trailingMatch,
	);

	return {
		from,
		nodesForReplacement,
		to,
	};
}

function isEqual(nodeA: LexicalNode, nodeB: LexicalNode): boolean
{
	// Only checking for code token nodes, tabs and linebreaks. If it's regular text node
	// returning false so that it's transformed into code token node
	return (
		(
			$isCodeTokenNode(nodeA)
			&& $isCodeTokenNode(nodeB)
			&& nodeA.__text === nodeB.__text
			&& nodeA.__highlightType === nodeB.__highlightType
		)
		|| ($isTabNode(nodeA) && $isTabNode(nodeB))
		|| ($isLineBreakNode(nodeA) && $isLineBreakNode(nodeB))
	);
}

function getCodeTokenNodes(tokens: Array<CodeToken>): LexicalNode[]
{
	const nodes: LexicalNode[] = [];
	tokens.forEach((token: CodeToken): void => {
		const partials: string[] = token.content.split(/([\t\n])/);
		const partialsLength: number = partials.length;
		for (let i = 0; i < partialsLength; i++)
		{
			const part: string = partials[i];
			if (part === '\n' || part === '\r\n')
			{
				nodes.push($createLineBreakNode());
			}
			else if (part === '\t')
			{
				nodes.push($createTabNode());
			}
			else if (part.length > 0)
			{
				nodes.push($createCodeTokenNode(part, token.type));
			}
		}
	});

	return nodes;
}

// Wrapping update function into selection retainer, that tries to keep cursor at the same
// position as before.
function updateAndRetainSelection(nodeKey: NodeKey, updateFn: () => boolean): void
{
	const node: LexicalNode | null = $getNodeByKey(nodeKey);
	if (!$isCodeNode(node) || !node.isAttached())
	{
		return;
	}

	// If it's not range selection (or null selection) there's no need to change it,
	// but we can still run highlighting logic
	const selection: RangeSelection = $getSelection();
	if (!$isRangeSelection(selection))
	{
		updateFn();

		return;
	}

	const anchor = selection.anchor;
	const anchorOffset: number = anchor.offset;
	const isNewLineAnchor: boolean = (
		anchor.type === 'element'
		&& $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1))
	);

	// Calculating previous text offset (all text node prior to anchor + anchor own text offset)
	let textOffset = 0;
	if (!isNewLineAnchor)
	{
		const anchorNode = anchor.getNode();
		textOffset = (
			anchorOffset
			+ anchorNode.getPreviousSiblings().reduce((offset, _node) => {
				return offset + _node.getTextContentSize();
			}, 0)
		);
	}

	const hasChanges: boolean = updateFn();
	if (!hasChanges)
	{
		return;
	}

	// Non-text anchors only happen for line breaks, otherwise
	// selection will be within text node (code token node)
	if (isNewLineAnchor)
	{
		anchor.getNode().select(anchorOffset, anchorOffset);

		return;
	}

	// If it was non-element anchor then we walk through child nodes
	// and looking for a position of original text offset
	node.getChildren().some((child) => {
		const isText: boolean = $isTextNode(child);
		if (isText || $isLineBreakNode(child))
		{
			const textContentSize = child.getTextContentSize();
			if (isText && textContentSize >= textOffset)
			{
				child.select(textOffset, textOffset);

				return true;
			}
			textOffset -= textContentSize;
		}

		return false;
	});
}