import { $isAtNodeEnd } from '@lexical/selection' import type { ElementNode, Klass, LexicalEditor, LexicalNode, RangeSelection, TextNode, } from 'lexical' import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode, } from 'lexical' import type { EntityMatch } from '@lexical/text' import { CustomTextNode } from './plugins/custom-text/node' import type { MenuTextMatch } from './types' 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( editor: LexicalEditor, getMatch: (text: string) => null | EntityMatch, targetNode: Klass, 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, createNode: (textNode: TextNode) => LexicalNode, ) => { 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) const replacementNode = createNode(nodeToReplace) nodeToReplace.replace(replacementNode) if (currentNode == null) return } } 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 } export function textToEditorState(text: string) { const paragraph = text ? text.split('\n') : [''] 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, }, }) }