Your IP : 216.73.216.86


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

import { Type, Runtime, Loc, Event, Dom } from 'main.core';
import type { BBCodeElementNode } from 'ui.bbcode.model';

import type {
	BBCodeConversionOutput,
	BBCodeConversion,
	BBCodeImportConversion,
	BBCodeExportConversion,
	BBCodeExportOutput,
} from '../../bbcode';
import { DIALOG_VISIBILITY_COMMAND, HIDE_DIALOG_COMMAND } from '../../commands';
import { $getSelectionPosition } from '../../helpers/get-selection-position';
import Button from '../../toolbar/button';
import type { SchemeValidationOptions } from '../../types/scheme-validation-options';

import BasePlugin from '../base-plugin';
import { $createMentionNode, MentionNode } from './mention-node';

import type { Dialog, DialogOptions } from 'ui.entity-selector';
import type { BaseEvent } from 'main.core.events';
import { type TextEditor } from '../../text-editor';

import {
	$getSelection,
	$isRangeSelection,
	$isTextNode,
	$createTextNode,
	createCommand,
	$createParagraphNode,
	$insertNodes,
	$isRootOrShadowRoot,
	COMMAND_PRIORITY_EDITOR,
	COMMAND_PRIORITY_LOW,
	KEY_ARROW_DOWN_COMMAND,
	KEY_ARROW_UP_COMMAND,
	KEY_ENTER_COMMAND,
	KEY_ESCAPE_COMMAND,
	KEY_TAB_COMMAND,
	KEY_DOWN_COMMAND,
	type RangeSelection,
	type LexicalNode,
	type TextNode,
	type LexicalCommand,
} from 'ui.lexical.core';

import { $wrapNodeInElement, mergeRegister } from 'ui.lexical.utils';

import './mention.css';

export type QueryMatch = {
	leadOffset: number;
	matchingString: string;
	replaceableString: string;
};

const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
const TRIGGERS = ['@', '+'].join('');

// Chars we expect to see in a mention (non-space, non-punctuation).
const VALID_CHARS = `[^${TRIGGERS}${PUNCTUATION}\\s]`;

// Non-standard series of chars. Each series must be preceded and followed by
// a valid char.
const VALID_JOINS = (
	'(?:'
	+ '\\.[ |$]|' // E.g. "r. " in "Mr. Smith"
	+ ' |' // E.g. " " in "Josh Duck"
	+ `[${PUNCTUATION}]|` // E.g. "-' in "Salier-Hellendag"
	+ ')'
);

const LENGTH_LIMIT = 25;

const mentionRegex = new RegExp(
	'(^|\\s|\\()('
	+ `[${TRIGGERS}]`
	+ `((?:${VALID_CHARS}${VALID_JOINS}){0,${LENGTH_LIMIT}})`
	+ ')$',
);

export type InsertMentionPayload = {
	entityId: string,
	id: string | number,
	text: string,
	before?: string,
	after?: string,
};

export const INSERT_MENTION_COMMAND: LexicalCommand<InsertMentionPayload> = createCommand('INSERT_MENTION_COMMAND');
export const INSERT_MENTION_DIALOG_COMMAND: LexicalCommand<InsertMentionPayload> = createCommand('INSERT_MENTION_DIALOG_COMMAND');

export class MentionPlugin extends BasePlugin
{
	#dialog: Dialog = null;
	#lastQueryMatch: QueryMatch = null;
	#mentionListening: boolean = false;
	#removeKeyboardCommandsLock: Function = null;
	#removeUpdateListener: Function = null;
	#onEditorScroll: Function = this.#handleEditorScroll.bind(this);
	#lastPosition: { left: number, top: number } = null;
	#timeoutId: number = null;
	#triggerByAtSign: boolean = false;

	#dialogOptions: DialogOptions = null;
	#entities: Set<string> = new Set();

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

		const entities = editor.getOption('mention.entities', []);
		this.#entities = Type.isArrayFilled(entities) ? new Set(entities) : new Set();

		const dialogOptions = editor.getOption('mention.dialogOptions');
		if (Type.isPlainObject(dialogOptions))
		{
			this.#dialogOptions = dialogOptions;
			if (Type.isArrayFilled(this.#dialogOptions.entities))
			{
				for (const entity of this.#dialogOptions.entities)
				{
					if (Type.isPlainObject(entity) && Type.isStringFilled(entity.id))
					{
						this.#entities.add(entity.id);
					}
				}
			}

			this.#registerKeyDownListener();
		}

		if (this.#entities.size > 0)
		{
			this.#registerCommands();
			this.#registerComponents();
		}
	}

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

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

	importBBCode(): BBCodeImportConversion | null
	{
		if (this.#entities.size > 0)
		{
			const map = {};
			for (const entityId of this.#entities)
			{
				map[entityId] = (): BBCodeConversion => ({
					conversion: this.#convertMentionElement,
					priority: 0,
				});
			}

			return map;
		}

		return null;
	}

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

				return {
					node: scheme.createElement({
						name: lexicalNode.getEntityId(),
						value: lexicalNode.getId(),
						inline: true,
					}),
				};
			},
		};
	}

	validateScheme(): SchemeValidationOptions | null
	{
		return {
			nodes: [{
				nodeClass: MentionNode,
			}],
			bbcodeMap: {
				mention: '#mention',
			},
		};
	}

	shouldTriggerByAtSign(): boolean
	{
		return this.#triggerByAtSign;
	}

	#registerCommands(): void
	{
		this.cleanUpRegister(
			this.getEditor().registerCommand(
				INSERT_MENTION_COMMAND,
				(payload: InsertMentionPayload) => {
					if (
						!Type.isPlainObject(payload)
						|| !Type.isStringFilled(payload.entityId)
						|| !Type.isStringFilled(payload.text)
						|| (!Type.isStringFilled(payload.id) && !Type.isNumber(payload.id))
					)
					{
						return false;
					}

					if (!this.#entities.has(payload.entityId))
					{
						console.error(`TextEditor: MentionPlugin: entity id "${payload.entityId}" was not found.`);

						return false;
					}

					const mentionNode = $createMentionNode(payload.entityId, payload.id);
					mentionNode.append($createTextNode(payload.text));

					const nodesToInsert = [];
					if (Type.isStringFilled(payload.before))
					{
						nodesToInsert.push($createTextNode(payload.before));
					}

					nodesToInsert.push(mentionNode);

					if (Type.isStringFilled(payload.after))
					{
						nodesToInsert.push($createTextNode(payload.after));
					}

					$insertNodes(nodesToInsert);
					if ($isRootOrShadowRoot(mentionNode.getParentOrThrow()))
					{
						$wrapNodeInElement(mentionNode, $createParagraphNode).selectEnd();
					}

					return true;
				},
				COMMAND_PRIORITY_EDITOR,
			),
			this.getEditor().registerCommand(
				INSERT_MENTION_DIALOG_COMMAND,
				(payload): boolean => {
					const selection: RangeSelection = $getSelection();
					if (!$isRangeSelection(selection))
					{
						return false;
					}

					this.getEditor().update(
						() => {
							const currentText = this.#getTextUpToAnchor(selection);
							let needSpace = currentText !== null && !/(\s|\()$/.test(currentText);
							if (needSpace)
							{
								const anchor = selection.anchor;
								const anchorNode = anchor.getNode();
								if (anchorNode.getIndexWithinParent() === 0 && anchor.offset === 0)
								{
									needSpace = false;
								}
							}

							selection.insertText(needSpace ? ' @' : '@');
						},
						{
							onUpdate: () => {
								this.getEditor().update(() => {
									const match: null | QueryMatch = this.#getQueryMatch($getSelection());
									if (match !== null && !this.#isSelectionOnEntityBoundary(match.leadOffset))
									{
										this.#openDialog(match);
									}
								});
							},
						},
					);

					return true;
				},
				COMMAND_PRIORITY_LOW,
			),
			this.getEditor().registerCommand(
				HIDE_DIALOG_COMMAND,
				(payload): boolean => {
					if (!payload || payload.sender !== 'mention')
					{
						this.#hideDialog();
					}

					return false;
				},
				COMMAND_PRIORITY_LOW,
			),
			this.getEditor().registerCommand(
				DIALOG_VISIBILITY_COMMAND,
				(): boolean => {
					return this.isDialogVisible();
				},
				COMMAND_PRIORITY_LOW,
			),
		);
	}

	#registerComponents(): void
	{
		this.getEditor().getComponentRegistry().register('mention', (): Button => {
			const button: Button = new Button();
			button.setContent('<span class="ui-icon-set --mention"></span>');
			button.setTooltip(Loc.getMessage('TEXT_EDITOR_BTN_MENTION'));
			button.disableInsideUnformatted();
			button.subscribe('onClick', (): void => {
				if (this.isDialogVisible())
				{
					return;
				}

				this.getEditor().focus(() => {
					this.getEditor().dispatchCommand(INSERT_MENTION_DIALOG_COMMAND);
				});
			});

			return button;
		});
	}

	#convertMentionElement(node: BBCodeElementNode): BBCodeConversionOutput
	{
		return {
			node: $createMentionNode(node.getName(), node.getValue()),
		};
	}

	#registerKeyDownListener(): void
	{
		this.#triggerByAtSign = true;

		const keyDownListener = (event: KeyboardEvent) => {
			if (this.#mentionListening)
			{
				if (event.key === 'Escape' || event.key === 'Enter')
				{
					this.#stopMentionListening();
				}
			}
			else if (!this.#mentionListening && (event.key === '+' || event.key === '@'))
			{
				this.#timeoutId = setTimeout((): void => {
					this.getEditor().update((): void => {
						const selection: RangeSelection = $getSelection();
						const match: null | QueryMatch = this.#getQueryMatch(selection);
						if (match !== null && !this.#isSelectionOnEntityBoundary(match.leadOffset))
						{
							this.#openDialog(match);
						}
					});
				}, 300);
			}

			return false;
		};

		this.cleanUpRegister(
			this.getEditor().registerCommand(KEY_DOWN_COMMAND, keyDownListener, COMMAND_PRIORITY_LOW),
		);
	}

	#registerTextContentListener(): void
	{
		this.#unregisterTextContentListener();

		this.#removeUpdateListener = this.getEditor().registerTextContentListener(
			this.#textContentListener.bind(this),
		);
	}

	#unregisterTextContentListener(): void
	{
		if (this.#removeUpdateListener !== null)
		{
			this.#removeUpdateListener();
			this.#removeUpdateListener = null;
		}
	}

	#textContentListener(): void
	{
		this.getEditor().getEditorState().read(() => {
			const selection: RangeSelection = $getSelection();
			const match: null | QueryMatch = this.#getQueryMatch(selection);
			if (match !== null && !this.#isSelectionOnEntityBoundary(match.leadOffset))
			{
				this.#openDialog(match);
			}
			else
			{
				this.#hideDialog();
			}
		});
	}

	#startMentionListening(): void
	{
		this.#mentionListening = true;
		this.#registerTextContentListener();
	}

	#stopMentionListening(): void
	{
		this.#mentionListening = false;
		this.#unregisterTextContentListener();
	}

	#getQueryMatch(selection: RangeSelection, minMatchLength: number = 0): null | QueryMatch
	{
		if (!$isRangeSelection(selection) || !selection.isCollapsed())
		{
			return null;
		}

		const anchor = selection.anchor;
		const anchorNode = anchor.getNode();
		if (!$isTextNode(anchorNode) || !anchorNode.isSimpleText())
		{
			return null;
		}

		const text: string | null = this.#getTextUpToAnchor(selection);

		// console.log("text:", text);

		if (!Type.isStringFilled(text))
		{
			return null;
		}

		return this.#matchMention(text, minMatchLength);
	}

	#getTextUpToAnchor(selection: RangeSelection): string | null
	{
		const anchor = selection.anchor;
		if (anchor.type !== 'text')
		{
			return null;
		}

		const anchorNode = anchor.getNode();
		if (!anchorNode.isSimpleText())
		{
			return null;
		}

		const anchorOffset: number = anchor.offset;

		return anchorNode.getTextContent().slice(0, anchorOffset);
	}

	#isSelectionOnEntityBoundary(offset: number): boolean
	{
		if (offset !== 0)
		{
			return false;
		}

		return this.getEditor().getEditorState().read(() => {
			const selection: RangeSelection = $getSelection();
			if ($isRangeSelection(selection))
			{
				const anchor = selection.anchor;
				const anchorNode = anchor.getNode();
				const prevSibling = anchorNode.getPreviousSibling();

				return $isTextNode(prevSibling) && prevSibling.isTextEntity();
			}

			return false;
		});
	}

	#matchMention(text: string, minMatchLength: number): QueryMatch | null
	{
		const match = mentionRegex.exec(text);
		if (match !== null)
		{
			// The strategy ignores leading whitespace but we need to know it's
			// length to add it to the leadOffset
			const maybeLeadingWhitespace = match[1];

			const matchingString = match[3];
			if (matchingString.length >= minMatchLength)
			{
				return {
					leadOffset: match.index + maybeLeadingWhitespace.length,
					matchingString,
					replaceableString: match[2],
				};
			}
		}

		return null;
	}

	/**
	 * Split Lexical TextNode and return a new TextNode only containing matched text.
	 * Common use cases include: removing the node, replacing with a new node.
	 */
	#splitNodeContainingQuery(match: QueryMatch): TextNode | null
	{
		const selection: RangeSelection = $getSelection();
		if (!$isRangeSelection(selection) || !selection.isCollapsed())
		{
			return null;
		}

		const anchor = selection.anchor;
		if (anchor.type !== 'text')
		{
			return null;
		}

		const anchorNode = anchor.getNode();
		if (!anchorNode.isSimpleText())
		{
			return null;
		}

		const selectionOffset = anchor.offset;
		const textContent = anchorNode.getTextContent().slice(0, selectionOffset);
		const characterOffset: number = match.replaceableString.length;
		const queryOffset: number = this.#getFullMatchOffset(textContent, match.matchingString, characterOffset);

		const startOffset: number = selectionOffset - queryOffset;
		if (startOffset < 0)
		{
			return null;
		}

		let newNode = null;
		if (startOffset === 0)
		{
			[newNode] = anchorNode.splitText(selectionOffset);
		}
		else
		{
			[, newNode] = anchorNode.splitText(startOffset, selectionOffset);
		}

		return newNode;
	}

	/**
 	* Walk backwards along user input and forward through entity title to try
 	* and replace more of the user's text with entity.
 	*/
	#getFullMatchOffset(documentText: string, entryText: string, offset: number): number
	{
		let triggerOffset: number = offset;
		for (let i: number = triggerOffset; i <= entryText.length; i++)
		{
			if (documentText.slice(-i) === entryText.slice(0, Math.max(0, i)))
			{
				triggerOffset = i;
			}
		}

		return triggerOffset;
	}

	#openDialog(queryMatch: QueryMatch): void
	{
		if (this.isDestroyed())
		{
			return;
		}

		this.#lastQueryMatch = queryMatch;
		if (this.#dialog === null)
		{
			const dialogOptions = Type.isPlainObject(this.#dialogOptions) ? { ...this.#dialogOptions } : {};
			const userEvents = dialogOptions.events;

			Runtime.loadExtension('ui.entity-selector').then((exports) => {
				if (this.isDestroyed())
				{
					return;
				}

				const { Dialog } = exports;

				const entitySelectorOptions: DialogOptions = {
					multiple: false,
					enableSearch: false,
					clearSearchOnSelect: true,
					hideOnSelect: true,
					hideByEsc: true,
					autoHide: true,
					height: 300,
					width: 400,
					offsetAnimation: false,
					compactView: true,
					...dialogOptions,
					events: {
						onShow: () => {
							this.#lockKeyboardCommands();
							this.#startMentionListening();
							Event.bind(this.getEditor().getScrollerContainer(), 'scroll', this.#onEditorScroll);
						},
						onHide: () => {
							this.#handleHideOrDestroy();
						},
						onDestroy: () => {
							this.#handleHideOrDestroy();
						},
						'Item:onBeforeSelect': (event: BaseEvent) => {
							const selectedItem = event.getData().item;
							event.preventDefault();

							this.getEditor().update((): void => {
								const nodeToReplace: ?TextNode = this.#splitNodeContainingQuery(this.#lastQueryMatch);
								const mentionNode: MentionNode = $createMentionNode(
									selectedItem.getEntityId(),
									selectedItem.getId(),
								);

								mentionNode.append(
									$createTextNode(selectedItem.getTitle()),
								);

								if (nodeToReplace)
								{
									nodeToReplace.replace(mentionNode);
									mentionNode.select();
								}

								this.#hideDialog();
							});
						},
					},
				};

				this.#dialog = new Dialog(entitySelectorOptions);

				this.getEditor().dispatchCommand(HIDE_DIALOG_COMMAND, { sender: 'mention' });

				this.#dialog.subscribeFromOptions(userEvents);
				this.#dialog.show();
				this.#dialog.search(queryMatch.matchingString);
				this.#adjustDialogPosition();
			})
				.catch((error) => {
					console.error('TextEditor: MentionPlugin: cannot load "ui.entity-selector"', error);
				});
		}
		else
		{
			this.getEditor().dispatchCommand(HIDE_DIALOG_COMMAND, { sender: 'mention' });

			this.#dialog.show();
			this.#dialog.search(queryMatch.matchingString);
			this.#adjustDialogPosition();
		}
	}

	isDialogVisible(): boolean
	{
		return this.#dialog !== null && this.#dialog.isRendered() && this.#dialog.getPopup().isShown();
	}

	#adjustDialogPosition(): void
	{
		this.getEditor().update(() => {
			const selectionPosition = $getSelectionPosition(this.getEditor(), $getSelection(), document.body);
			if (selectionPosition === null)
			{
				return;
			}

			const { top, left, bottom } = selectionPosition;
			const scrollerRect: DOMRect = Dom.getPosition(this.getEditor().getScrollerContainer());
			const popupWidth = 400;

			let offsetLeft = 10;
			if (left - offsetLeft < scrollerRect.left)
			{
				// Left boundary
				const overflow = scrollerRect.left - (left - offsetLeft);
				offsetLeft -= overflow + 16;
			}
			else if (scrollerRect.right < (left + popupWidth - offsetLeft))
			{
				// Right boundary
				offsetLeft += (left + popupWidth - offsetLeft) - scrollerRect.right + 16;
			}

			if (bottom < scrollerRect.top || top > scrollerRect.bottom)
			{
				Dom.addClass(this.#dialog.getPopup().getPopupContainer(), 'ui-text-editor-mention-popup__hidden');
			}
			else
			{
				Dom.removeClass(this.#dialog.getPopup().getPopupContainer(), 'ui-text-editor-mention-popup__hidden');

				this.#dialog.show();
				if (this.#lastPosition === null || this.#lastPosition.top !== bottom)
				{
					this.#lastPosition = { left: left - offsetLeft, top: bottom };
				}

				this.#dialog.getPopup().setBindElement(this.#lastPosition);
				this.#dialog.getPopup().adjustPosition({ forceBindPosition: true, forceTop: true });
			}
		});
	}

	#handleEditorScroll(): void
	{
		this.#adjustDialogPosition();
	}

	#handleHideOrDestroy(): void
	{
		this.#lastPosition = null;
		this.#unlockKeyboardCommands();
		this.#stopMentionListening();
		Event.unbind(this.getEditor().getScrollerContainer(), 'scroll', this.#onEditorScroll);
	}

	#hideDialog(): void
	{
		if (this.#dialog !== null)
		{
			this.#dialog.hide();
		}
	}

	#lockKeyboardCommands(): void
	{
		if (this.#removeKeyboardCommandsLock === null)
		{
			this.#removeKeyboardCommandsLock = mergeRegister(
				this.getEditor().registerCommand(KEY_ARROW_DOWN_COMMAND, (): true => true, COMMAND_PRIORITY_LOW),
				this.getEditor().registerCommand(KEY_ARROW_UP_COMMAND, (): true => true, COMMAND_PRIORITY_LOW),
				this.getEditor().registerCommand(KEY_ESCAPE_COMMAND, (): true => true, COMMAND_PRIORITY_LOW),
				this.getEditor().registerCommand(KEY_TAB_COMMAND, (): true => true, COMMAND_PRIORITY_LOW),
				this.getEditor().registerCommand(KEY_ENTER_COMMAND, (): true => true, COMMAND_PRIORITY_LOW),
			);
		}
	}

	#unlockKeyboardCommands(): void
	{
		if (this.#removeKeyboardCommandsLock !== null)
		{
			this.#removeKeyboardCommandsLock();
			this.#removeKeyboardCommandsLock = null;
		}
	}

	destroy(): void
	{
		super.destroy();

		if (this.#timeoutId !== null)
		{
			clearTimeout(this.#timeoutId);
			this.#timeoutId = null;
		}

		if (this.#dialog !== null)
		{
			this.#dialog.destroy();
		}

		this.#unregisterTextContentListener();
		this.#unlockKeyboardCommands();
	}
}