|
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<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) { |
|
|
|
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) |
|
|
|
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, |
|
}, |
|
}) |
|
} |
|
|