| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  | import { $isAtNodeEnd } from '@lexical/selection' | 
					
						
							|  |  |  | import type { | 
					
						
							|  |  |  |   ElementNode, | 
					
						
							|  |  |  |   Klass, | 
					
						
							|  |  |  |   LexicalEditor, | 
					
						
							|  |  |  |   LexicalNode, | 
					
						
							|  |  |  |   RangeSelection, | 
					
						
							|  |  |  |   TextNode, | 
					
						
							|  |  |  | } from 'lexical' | 
					
						
							|  |  |  | import { | 
					
						
							|  |  |  |   $createTextNode, | 
					
						
							| 
									
										
										
										
											2024-04-08 18:51:46 +08:00
										 |  |  |   $getSelection, | 
					
						
							|  |  |  |   $isRangeSelection, | 
					
						
							| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  |   $isTextNode, | 
					
						
							|  |  |  | } from 'lexical' | 
					
						
							|  |  |  | import type { EntityMatch } from '@lexical/text' | 
					
						
							|  |  |  | import { CustomTextNode } from './plugins/custom-text/node' | 
					
						
							| 
									
										
										
										
											2024-04-08 18:51:46 +08:00
										 |  |  | import type { MenuTextMatch } from './types' | 
					
						
							| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  | export function getSelectedNode( | 
					
						
							|  |  |  |   selection: RangeSelection, | 
					
						
							|  |  |  | ): TextNode | ElementNode { | 
					
						
							|  |  |  |   const anchor = selection.anchor | 
					
						
							|  |  |  |   const focus = selection.focus | 
					
						
							|  |  |  |   const anchorNode = selection.anchor.getNode() | 
					
						
							|  |  |  |   const focusNode = selection.focus.getNode() | 
					
						
							|  |  |  |   if (anchorNode === focusNode) | 
					
						
							|  |  |  |     return anchorNode | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const isBackward = selection.isBackward() | 
					
						
							|  |  |  |   if (isBackward) | 
					
						
							|  |  |  |     return $isAtNodeEnd(focus) ? anchorNode : focusNode | 
					
						
							|  |  |  |   else | 
					
						
							|  |  |  |     return $isAtNodeEnd(anchor) ? anchorNode : focusNode | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function registerLexicalTextEntity<T extends TextNode>( | 
					
						
							|  |  |  |   editor: LexicalEditor, | 
					
						
							|  |  |  |   getMatch: (text: string) => null | EntityMatch, | 
					
						
							|  |  |  |   targetNode: Klass<T>, | 
					
						
							|  |  |  |   createNode: (textNode: TextNode) => T, | 
					
						
							|  |  |  | ) { | 
					
						
							|  |  |  |   const isTargetNode = (node: LexicalNode | null | undefined): node is T => { | 
					
						
							|  |  |  |     return node instanceof targetNode | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const replaceWithSimpleText = (node: TextNode): void => { | 
					
						
							|  |  |  |     const textNode = $createTextNode(node.getTextContent()) | 
					
						
							|  |  |  |     textNode.setFormat(node.getFormat()) | 
					
						
							|  |  |  |     node.replace(textNode) | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const getMode = (node: TextNode): number => { | 
					
						
							|  |  |  |     return node.getLatest().__mode | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const textNodeTransform = (node: TextNode) => { | 
					
						
							|  |  |  |     if (!node.isSimpleText()) | 
					
						
							|  |  |  |       return | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const prevSibling = node.getPreviousSibling() | 
					
						
							|  |  |  |     let text = node.getTextContent() | 
					
						
							|  |  |  |     let currentNode = node | 
					
						
							|  |  |  |     let match | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if ($isTextNode(prevSibling)) { | 
					
						
							|  |  |  |       const previousText = prevSibling.getTextContent() | 
					
						
							|  |  |  |       const combinedText = previousText + text | 
					
						
							|  |  |  |       const prevMatch = getMatch(combinedText) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (isTargetNode(prevSibling)) { | 
					
						
							|  |  |  |         if (prevMatch === null || getMode(prevSibling) !== 0) { | 
					
						
							|  |  |  |           replaceWithSimpleText(prevSibling) | 
					
						
							|  |  |  |           return | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         else { | 
					
						
							|  |  |  |           const diff = prevMatch.end - previousText.length | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |           if (diff > 0) { | 
					
						
							|  |  |  |             const concatText = text.slice(0, diff) | 
					
						
							|  |  |  |             const newTextContent = previousText + concatText | 
					
						
							|  |  |  |             prevSibling.select() | 
					
						
							|  |  |  |             prevSibling.setTextContent(newTextContent) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if (diff === text.length) { | 
					
						
							|  |  |  |               node.remove() | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |             else { | 
					
						
							|  |  |  |               const remainingText = text.slice(diff) | 
					
						
							|  |  |  |               node.setTextContent(remainingText) | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return | 
					
						
							|  |  |  |           } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       else if (prevMatch === null || prevMatch.start < previousText.length) { | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     while (true) { | 
					
						
							|  |  |  |       match = getMatch(text) | 
					
						
							|  |  |  |       let nextText = match === null ? '' : text.slice(match.end) | 
					
						
							|  |  |  |       text = nextText | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (nextText === '') { | 
					
						
							|  |  |  |         const nextSibling = currentNode.getNextSibling() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($isTextNode(nextSibling)) { | 
					
						
							|  |  |  |           nextText = currentNode.getTextContent() + nextSibling.getTextContent() | 
					
						
							|  |  |  |           const nextMatch = getMatch(nextText) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |           if (nextMatch === null) { | 
					
						
							|  |  |  |             if (isTargetNode(nextSibling)) | 
					
						
							|  |  |  |               replaceWithSimpleText(nextSibling) | 
					
						
							|  |  |  |             else | 
					
						
							|  |  |  |               nextSibling.markDirty() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return | 
					
						
							|  |  |  |           } | 
					
						
							|  |  |  |           else if (nextMatch.start !== 0) { | 
					
						
							|  |  |  |             return | 
					
						
							|  |  |  |           } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       else { | 
					
						
							|  |  |  |         const nextMatch = getMatch(nextText) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (nextMatch !== null && nextMatch.start === 0) | 
					
						
							|  |  |  |           return | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (match === null) | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) | 
					
						
							|  |  |  |         continue | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       let nodeToReplace | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (match.start === 0) | 
					
						
							|  |  |  |         [nodeToReplace, currentNode] = currentNode.splitText(match.end) | 
					
						
							|  |  |  |       else | 
					
						
							|  |  |  |         [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       const replacementNode = createNode(nodeToReplace) | 
					
						
							|  |  |  |       replacementNode.setFormat(nodeToReplace.getFormat()) | 
					
						
							|  |  |  |       nodeToReplace.replace(replacementNode) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (currentNode == null) | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const reverseNodeTransform = (node: T) => { | 
					
						
							|  |  |  |     const text = node.getTextContent() | 
					
						
							|  |  |  |     const match = getMatch(text) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (match === null || match.start !== 0) { | 
					
						
							|  |  |  |       replaceWithSimpleText(node) | 
					
						
							|  |  |  |       return | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (text.length > match.end) { | 
					
						
							|  |  |  |       // This will split out the rest of the text as simple text
 | 
					
						
							|  |  |  |       node.splitText(match.end) | 
					
						
							|  |  |  |       return | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const prevSibling = node.getPreviousSibling() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if ($isTextNode(prevSibling) && prevSibling.isTextEntity()) { | 
					
						
							|  |  |  |       replaceWithSimpleText(prevSibling) | 
					
						
							|  |  |  |       replaceWithSimpleText(node) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const nextSibling = node.getNextSibling() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) { | 
					
						
							|  |  |  |       replaceWithSimpleText(nextSibling) // This may have already been converted in the previous block
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (isTargetNode(node)) | 
					
						
							|  |  |  |         replaceWithSimpleText(node) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const removePlainTextTransform = editor.registerNodeTransform(CustomTextNode, textNodeTransform) | 
					
						
							|  |  |  |   const removeReverseNodeTransform = editor.registerNodeTransform(targetNode, reverseNodeTransform) | 
					
						
							|  |  |  |   return [removePlainTextTransform, removeReverseNodeTransform] | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export const decoratorTransform = ( | 
					
						
							|  |  |  |   node: CustomTextNode, | 
					
						
							|  |  |  |   getMatch: (text: string) => null | EntityMatch, | 
					
						
							| 
									
										
										
										
											2024-04-08 18:51:46 +08:00
										 |  |  |   createNode: (textNode: TextNode) => LexicalNode, | 
					
						
							| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  | ) => { | 
					
						
							|  |  |  |   if (!node.isSimpleText()) | 
					
						
							|  |  |  |     return | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const prevSibling = node.getPreviousSibling() | 
					
						
							|  |  |  |   let text = node.getTextContent() | 
					
						
							|  |  |  |   let currentNode = node | 
					
						
							|  |  |  |   let match | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   while (true) { | 
					
						
							|  |  |  |     match = getMatch(text) | 
					
						
							|  |  |  |     let nextText = match === null ? '' : text.slice(match.end) | 
					
						
							|  |  |  |     text = nextText | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (nextText === '') { | 
					
						
							|  |  |  |       const nextSibling = currentNode.getNextSibling() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if ($isTextNode(nextSibling)) { | 
					
						
							|  |  |  |         nextText = currentNode.getTextContent() + nextSibling.getTextContent() | 
					
						
							|  |  |  |         const nextMatch = getMatch(nextText) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (nextMatch === null) { | 
					
						
							|  |  |  |           nextSibling.markDirty() | 
					
						
							|  |  |  |           return | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         else if (nextMatch.start !== 0) { | 
					
						
							|  |  |  |           return | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     else { | 
					
						
							|  |  |  |       const nextMatch = getMatch(nextText) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (nextMatch !== null && nextMatch.start === 0) | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (match === null) | 
					
						
							|  |  |  |       return | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) | 
					
						
							|  |  |  |       continue | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     let nodeToReplace | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (match.start === 0) | 
					
						
							|  |  |  |       [nodeToReplace, currentNode] = currentNode.splitText(match.end) | 
					
						
							|  |  |  |     else | 
					
						
							|  |  |  |       [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-08 18:51:46 +08:00
										 |  |  |     const replacementNode = createNode(nodeToReplace) | 
					
						
							| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  |     nodeToReplace.replace(replacementNode) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (currentNode == null) | 
					
						
							|  |  |  |       return | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-04-08 18:51:46 +08:00
										 |  |  | function getFullMatchOffset( | 
					
						
							|  |  |  |   documentText: string, | 
					
						
							|  |  |  |   entryText: string, | 
					
						
							|  |  |  |   offset: number, | 
					
						
							|  |  |  | ): number { | 
					
						
							|  |  |  |   let triggerOffset = offset | 
					
						
							|  |  |  |   for (let i = triggerOffset; i <= entryText.length; i++) { | 
					
						
							|  |  |  |     if (documentText.substr(-i) === entryText.substr(0, i)) | 
					
						
							|  |  |  |       triggerOffset = i | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |   return triggerOffset | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null { | 
					
						
							|  |  |  |   const selection = $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 = match.replaceableString.length | 
					
						
							|  |  |  |   const queryOffset = getFullMatchOffset( | 
					
						
							|  |  |  |     textContent, | 
					
						
							|  |  |  |     match.matchingString, | 
					
						
							|  |  |  |     characterOffset, | 
					
						
							|  |  |  |   ) | 
					
						
							|  |  |  |   const startOffset = selectionOffset - queryOffset | 
					
						
							|  |  |  |   if (startOffset < 0) | 
					
						
							|  |  |  |     return null | 
					
						
							|  |  |  |   let newNode | 
					
						
							|  |  |  |   if (startOffset === 0) | 
					
						
							|  |  |  |     [newNode] = anchorNode.splitText(selectionOffset) | 
					
						
							|  |  |  |   else | 
					
						
							|  |  |  |     [, newNode] = anchorNode.splitText(startOffset, selectionOffset) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   return newNode | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  | export function textToEditorState(text: string) { | 
					
						
							| 
									
										
										
										
											2025-04-11 11:37:47 +08:00
										 |  |  |   const paragraph = text && (typeof text === 'string') ? text.split('\n') : [''] | 
					
						
							| 
									
										
										
										
											2023-10-12 23:14:28 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  |   return JSON.stringify({ | 
					
						
							|  |  |  |     root: { | 
					
						
							|  |  |  |       children: paragraph.map((p) => { | 
					
						
							|  |  |  |         return { | 
					
						
							|  |  |  |           children: [{ | 
					
						
							|  |  |  |             detail: 0, | 
					
						
							|  |  |  |             format: 0, | 
					
						
							|  |  |  |             mode: 'normal', | 
					
						
							|  |  |  |             style: '', | 
					
						
							|  |  |  |             text: p, | 
					
						
							|  |  |  |             type: 'custom-text', | 
					
						
							|  |  |  |             version: 1, | 
					
						
							|  |  |  |           }], | 
					
						
							|  |  |  |           direction: 'ltr', | 
					
						
							|  |  |  |           format: '', | 
					
						
							|  |  |  |           indent: 0, | 
					
						
							|  |  |  |           type: 'paragraph', | 
					
						
							|  |  |  |           version: 1, | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }), | 
					
						
							|  |  |  |       direction: 'ltr', | 
					
						
							|  |  |  |       format: '', | 
					
						
							|  |  |  |       indent: 0, | 
					
						
							|  |  |  |       type: 'root', | 
					
						
							|  |  |  |       version: 1, | 
					
						
							|  |  |  |     }, | 
					
						
							|  |  |  |   }) | 
					
						
							|  |  |  | } |