utils.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isTextNode, type TextNode } from "lexical"
  2. export const TRIGGER_CHAR = "/"
  3. const WHITESPACE_REGEX = /\s/
  4. const ZERO_WIDTH_REGEX = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g
  5. export function extractCommandQuery(setCommandStartOffset: (offset: number | null) => void): string | null {
  6. const selection = $getSelection()
  7. if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
  8. return null
  9. }
  10. const anchor = selection.anchor
  11. const node = anchor.getNode()
  12. if (!$isTextNode(node)) {
  13. return null
  14. }
  15. const offset = anchor.offset
  16. const beforeCursor = node.getTextContent().slice(0, offset)
  17. const lastSlashIndex = beforeCursor.lastIndexOf(TRIGGER_CHAR)
  18. if (lastSlashIndex === -1) {
  19. return null
  20. }
  21. const hasLeadingContent = hasNonWhitespaceBeforeOffset(node, lastSlashIndex)
  22. if (hasLeadingContent) {
  23. return null
  24. }
  25. const query = beforeCursor.slice(lastSlashIndex + 1)
  26. if (WHITESPACE_REGEX.test(query)) {
  27. return null
  28. }
  29. setCommandStartOffset(lastSlashIndex)
  30. return query
  31. }
  32. export function updatePopoverPosition(
  33. editor: { getRootElement: () => HTMLElement | null },
  34. leftRef: React.MutableRefObject<{ left: number; width: number } | null>,
  35. setPosition: (pos: { top: number; left: number; placement: "top" | "bottom" }) => void,
  36. ) {
  37. const root = editor.getRootElement()
  38. if (!root) return
  39. const selection = root.ownerDocument.getSelection()
  40. if (!selection) return
  41. if (selection.rangeCount === 0) return
  42. const range = selection.getRangeAt(0)
  43. if (!root.contains(range.startContainer)) return
  44. const rect = range.getBoundingClientRect()
  45. const gap = 8
  46. const viewportHeight = window.innerHeight
  47. const popover = root.ownerDocument.querySelector<HTMLElement>("[data-command-popover]")
  48. const popoverRect = popover ? popover.getBoundingClientRect() : null
  49. const rawHeight = popoverRect && popoverRect.height > 0 ? Math.ceil(popoverRect.height) : 280
  50. const desiredHeight = Math.min(rawHeight, viewportHeight - gap * 2)
  51. const below = viewportHeight - rect.bottom - gap
  52. const above = rect.top - gap
  53. const placement =
  54. above >= desiredHeight ? "top" : below >= desiredHeight ? "bottom" : above >= below ? "top" : "bottom"
  55. const viewportWidth = window.innerWidth
  56. const rawWidth = popoverRect && popoverRect.width > 0 ? Math.ceil(popoverRect.width) : 500
  57. const desiredWidth = Math.min(rawWidth, viewportWidth - gap * 2)
  58. const minLeft = window.scrollX + gap
  59. const maxLeft = window.scrollX + viewportWidth - desiredWidth - gap
  60. const clamp = (value: number) => {
  61. if (maxLeft <= minLeft) return minLeft
  62. if (value < minLeft) return minLeft
  63. if (value > maxLeft) return maxLeft
  64. return value
  65. }
  66. const anchorLeft = rect.left + window.scrollX
  67. const stored = leftRef.current
  68. const shouldReanchor = stored === null || Math.abs(stored.width - desiredWidth) > 8
  69. const openedLeft = (() => {
  70. const viewportRight = window.scrollX + viewportWidth - gap
  71. const start = anchorLeft
  72. const end = anchorLeft - desiredWidth
  73. if (start + desiredWidth <= viewportRight) return start
  74. if (end >= minLeft) return end
  75. return clamp(start)
  76. })()
  77. const preferred = shouldReanchor ? openedLeft : stored.left
  78. const left = clamp(preferred)
  79. leftRef.current = { left, width: desiredWidth }
  80. const top = placement === "top" ? rect.top + window.scrollY - gap : rect.bottom + window.scrollY + gap
  81. setPosition({
  82. top,
  83. left,
  84. placement,
  85. })
  86. }
  87. function hasNonWhitespaceBeforeOffset(node: TextNode, offset: number): boolean {
  88. const root = $getRoot()
  89. const paragraphs = root.getChildren()
  90. for (const paragraph of paragraphs) {
  91. if (!$isElementNode(paragraph)) continue
  92. const children = paragraph.getChildren()
  93. const inParagraph = children.includes(node)
  94. for (const child of children) {
  95. if (child === node) {
  96. const text = node.getTextContent().slice(0, offset)
  97. return text.replace(ZERO_WIDTH_REGEX, "").trim().length > 0
  98. }
  99. const raw = child.getTextContent ? child.getTextContent() : ""
  100. const normalized = raw.replace(ZERO_WIDTH_REGEX, "")
  101. if (normalized.trim().length > 0) return true
  102. }
  103. if (inParagraph) {
  104. return false
  105. }
  106. }
  107. return false
  108. }