utils.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { $isAtNodeEnd } from '@lexical/selection'
  2. import type {
  3. ElementNode,
  4. Klass,
  5. LexicalEditor,
  6. LexicalNode,
  7. RangeSelection,
  8. TextNode,
  9. } from 'lexical'
  10. import {
  11. $createTextNode,
  12. $getSelection,
  13. $isRangeSelection,
  14. $isTextNode,
  15. } from 'lexical'
  16. import type { EntityMatch } from '@lexical/text'
  17. import { CustomTextNode } from './plugins/custom-text/node'
  18. import type { MenuTextMatch } from './types'
  19. export function getSelectedNode(
  20. selection: RangeSelection,
  21. ): TextNode | ElementNode {
  22. const anchor = selection.anchor
  23. const focus = selection.focus
  24. const anchorNode = selection.anchor.getNode()
  25. const focusNode = selection.focus.getNode()
  26. if (anchorNode === focusNode)
  27. return anchorNode
  28. const isBackward = selection.isBackward()
  29. if (isBackward)
  30. return $isAtNodeEnd(focus) ? anchorNode : focusNode
  31. else
  32. return $isAtNodeEnd(anchor) ? anchorNode : focusNode
  33. }
  34. export function registerLexicalTextEntity<T extends TextNode>(
  35. editor: LexicalEditor,
  36. getMatch: (text: string) => null | EntityMatch,
  37. targetNode: Klass<T>,
  38. createNode: (textNode: TextNode) => T,
  39. ) {
  40. const isTargetNode = (node: LexicalNode | null | undefined): node is T => {
  41. return node instanceof targetNode
  42. }
  43. const replaceWithSimpleText = (node: TextNode): void => {
  44. const textNode = $createTextNode(node.getTextContent())
  45. textNode.setFormat(node.getFormat())
  46. node.replace(textNode)
  47. }
  48. const getMode = (node: TextNode): number => {
  49. return node.getLatest().__mode
  50. }
  51. const textNodeTransform = (node: TextNode) => {
  52. if (!node.isSimpleText())
  53. return
  54. const prevSibling = node.getPreviousSibling()
  55. let text = node.getTextContent()
  56. let currentNode = node
  57. let match
  58. if ($isTextNode(prevSibling)) {
  59. const previousText = prevSibling.getTextContent()
  60. const combinedText = previousText + text
  61. const prevMatch = getMatch(combinedText)
  62. if (isTargetNode(prevSibling)) {
  63. if (prevMatch === null || getMode(prevSibling) !== 0) {
  64. replaceWithSimpleText(prevSibling)
  65. return
  66. }
  67. else {
  68. const diff = prevMatch.end - previousText.length
  69. if (diff > 0) {
  70. const concatText = text.slice(0, diff)
  71. const newTextContent = previousText + concatText
  72. prevSibling.select()
  73. prevSibling.setTextContent(newTextContent)
  74. if (diff === text.length) {
  75. node.remove()
  76. }
  77. else {
  78. const remainingText = text.slice(diff)
  79. node.setTextContent(remainingText)
  80. }
  81. return
  82. }
  83. }
  84. }
  85. else if (prevMatch === null || prevMatch.start < previousText.length) {
  86. return
  87. }
  88. }
  89. while (true) {
  90. match = getMatch(text)
  91. let nextText = match === null ? '' : text.slice(match.end)
  92. text = nextText
  93. if (nextText === '') {
  94. const nextSibling = currentNode.getNextSibling()
  95. if ($isTextNode(nextSibling)) {
  96. nextText = currentNode.getTextContent() + nextSibling.getTextContent()
  97. const nextMatch = getMatch(nextText)
  98. if (nextMatch === null) {
  99. if (isTargetNode(nextSibling))
  100. replaceWithSimpleText(nextSibling)
  101. else
  102. nextSibling.markDirty()
  103. return
  104. }
  105. else if (nextMatch.start !== 0) {
  106. return
  107. }
  108. }
  109. }
  110. else {
  111. const nextMatch = getMatch(nextText)
  112. if (nextMatch !== null && nextMatch.start === 0)
  113. return
  114. }
  115. if (match === null)
  116. return
  117. if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity())
  118. continue
  119. let nodeToReplace
  120. if (match.start === 0)
  121. [nodeToReplace, currentNode] = currentNode.splitText(match.end)
  122. else
  123. [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end)
  124. const replacementNode = createNode(nodeToReplace)
  125. replacementNode.setFormat(nodeToReplace.getFormat())
  126. nodeToReplace.replace(replacementNode)
  127. if (currentNode == null)
  128. return
  129. }
  130. }
  131. const reverseNodeTransform = (node: T) => {
  132. const text = node.getTextContent()
  133. const match = getMatch(text)
  134. if (match === null || match.start !== 0) {
  135. replaceWithSimpleText(node)
  136. return
  137. }
  138. if (text.length > match.end) {
  139. // This will split out the rest of the text as simple text
  140. node.splitText(match.end)
  141. return
  142. }
  143. const prevSibling = node.getPreviousSibling()
  144. if ($isTextNode(prevSibling) && prevSibling.isTextEntity()) {
  145. replaceWithSimpleText(prevSibling)
  146. replaceWithSimpleText(node)
  147. }
  148. const nextSibling = node.getNextSibling()
  149. if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) {
  150. replaceWithSimpleText(nextSibling) // This may have already been converted in the previous block
  151. if (isTargetNode(node))
  152. replaceWithSimpleText(node)
  153. }
  154. }
  155. const removePlainTextTransform = editor.registerNodeTransform(CustomTextNode, textNodeTransform)
  156. const removeReverseNodeTransform = editor.registerNodeTransform(targetNode, reverseNodeTransform)
  157. return [removePlainTextTransform, removeReverseNodeTransform]
  158. }
  159. export const decoratorTransform = (
  160. node: CustomTextNode,
  161. getMatch: (text: string) => null | EntityMatch,
  162. createNode: (textNode: TextNode) => LexicalNode,
  163. ) => {
  164. if (!node.isSimpleText())
  165. return
  166. const prevSibling = node.getPreviousSibling()
  167. let text = node.getTextContent()
  168. let currentNode = node
  169. let match
  170. while (true) {
  171. match = getMatch(text)
  172. let nextText = match === null ? '' : text.slice(match.end)
  173. text = nextText
  174. if (nextText === '') {
  175. const nextSibling = currentNode.getNextSibling()
  176. if ($isTextNode(nextSibling)) {
  177. nextText = currentNode.getTextContent() + nextSibling.getTextContent()
  178. const nextMatch = getMatch(nextText)
  179. if (nextMatch === null) {
  180. nextSibling.markDirty()
  181. return
  182. }
  183. else if (nextMatch.start !== 0) {
  184. return
  185. }
  186. }
  187. }
  188. else {
  189. const nextMatch = getMatch(nextText)
  190. if (nextMatch !== null && nextMatch.start === 0)
  191. return
  192. }
  193. if (match === null)
  194. return
  195. if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity())
  196. continue
  197. let nodeToReplace
  198. if (match.start === 0)
  199. [nodeToReplace, currentNode] = currentNode.splitText(match.end)
  200. else
  201. [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end)
  202. const replacementNode = createNode(nodeToReplace)
  203. nodeToReplace.replace(replacementNode)
  204. if (currentNode == null)
  205. return
  206. }
  207. }
  208. function getFullMatchOffset(
  209. documentText: string,
  210. entryText: string,
  211. offset: number,
  212. ): number {
  213. let triggerOffset = offset
  214. for (let i = triggerOffset; i <= entryText.length; i++) {
  215. if (documentText.substr(-i) === entryText.substr(0, i))
  216. triggerOffset = i
  217. }
  218. return triggerOffset
  219. }
  220. export function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null {
  221. const selection = $getSelection()
  222. if (!$isRangeSelection(selection) || !selection.isCollapsed())
  223. return null
  224. const anchor = selection.anchor
  225. if (anchor.type !== 'text')
  226. return null
  227. const anchorNode = anchor.getNode()
  228. if (!anchorNode.isSimpleText())
  229. return null
  230. const selectionOffset = anchor.offset
  231. const textContent = anchorNode.getTextContent().slice(0, selectionOffset)
  232. const characterOffset = match.replaceableString.length
  233. const queryOffset = getFullMatchOffset(
  234. textContent,
  235. match.matchingString,
  236. characterOffset,
  237. )
  238. const startOffset = selectionOffset - queryOffset
  239. if (startOffset < 0)
  240. return null
  241. let newNode
  242. if (startOffset === 0)
  243. [newNode] = anchorNode.splitText(selectionOffset)
  244. else
  245. [, newNode] = anchorNode.splitText(startOffset, selectionOffset)
  246. return newNode
  247. }
  248. export function textToEditorState(text: string) {
  249. const paragraph = text ? text.split('\n') : ['']
  250. return JSON.stringify({
  251. root: {
  252. children: paragraph.map((p) => {
  253. return {
  254. children: [{
  255. detail: 0,
  256. format: 0,
  257. mode: 'normal',
  258. style: '',
  259. text: p,
  260. type: 'custom-text',
  261. version: 1,
  262. }],
  263. direction: 'ltr',
  264. format: '',
  265. indent: 0,
  266. type: 'paragraph',
  267. version: 1,
  268. }
  269. }),
  270. direction: 'ltr',
  271. format: '',
  272. indent: 0,
  273. type: 'root',
  274. version: 1,
  275. },
  276. })
  277. }