@thangph2146/lexical-editor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/dist/editor-x/editor.cjs +33121 -0
  2. package/dist/editor-x/editor.cjs.map +1 -0
  3. package/dist/editor-x/editor.css +2854 -0
  4. package/dist/editor-x/editor.css.map +1 -0
  5. package/dist/editor-x/editor.d.cts +12 -0
  6. package/dist/editor-x/editor.d.ts +12 -0
  7. package/dist/editor-x/editor.js +33095 -0
  8. package/dist/editor-x/editor.js.map +1 -0
  9. package/dist/index.cjs +33210 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.css +2854 -0
  12. package/dist/index.css.map +1 -0
  13. package/dist/index.d.cts +15 -0
  14. package/dist/index.d.ts +15 -0
  15. package/dist/index.js +33183 -0
  16. package/dist/index.js.map +1 -0
  17. package/package.json +84 -0
  18. package/src/components/lexical-editor.tsx +123 -0
  19. package/src/context/editor-container-context.tsx +29 -0
  20. package/src/context/priority-image-context.tsx +7 -0
  21. package/src/context/toolbar-context.tsx +60 -0
  22. package/src/context/uploads-context.tsx +53 -0
  23. package/src/editor-hooks/use-debounce.ts +80 -0
  24. package/src/editor-hooks/use-modal.tsx +64 -0
  25. package/src/editor-hooks/use-report.ts +57 -0
  26. package/src/editor-hooks/use-update-toolbar.ts +41 -0
  27. package/src/editor-ui/broken-image.tsx +18 -0
  28. package/src/editor-ui/caption-composer.tsx +45 -0
  29. package/src/editor-ui/code-button.tsx +75 -0
  30. package/src/editor-ui/color-picker.tsx +2010 -0
  31. package/src/editor-ui/content-editable.tsx +37 -0
  32. package/src/editor-ui/hooks/use-image-caption-controls.ts +118 -0
  33. package/src/editor-ui/hooks/use-image-node-interactions.ts +245 -0
  34. package/src/editor-ui/hooks/use-responsive-image-dimensions.ts +202 -0
  35. package/src/editor-ui/image-component.tsx +321 -0
  36. package/src/editor-ui/image-placeholder.tsx +57 -0
  37. package/src/editor-ui/image-resizer.tsx +499 -0
  38. package/src/editor-ui/image-sizing.ts +120 -0
  39. package/src/editor-ui/lazy-image.tsx +136 -0
  40. package/src/editor-x/editor.tsx +117 -0
  41. package/src/editor-x/nodes.ts +79 -0
  42. package/src/editor-x/plugins.tsx +380 -0
  43. package/src/hooks/use-click-outside.ts +27 -0
  44. package/src/hooks/use-element-size.ts +54 -0
  45. package/src/hooks/use-header-height.ts +95 -0
  46. package/src/hooks/use-isomorphic-layout-effect.ts +4 -0
  47. package/src/index.ts +4 -0
  48. package/src/lib/logger.ts +6 -0
  49. package/src/lib/utils.ts +19 -0
  50. package/src/nodes/autocomplete-node.tsx +94 -0
  51. package/src/nodes/embeds/tweet-node.tsx +224 -0
  52. package/src/nodes/embeds/youtube-node.tsx +519 -0
  53. package/src/nodes/emoji-node.tsx +83 -0
  54. package/src/nodes/image-node.tsx +328 -0
  55. package/src/nodes/keyword-node.tsx +58 -0
  56. package/src/nodes/layout-container-node.tsx +128 -0
  57. package/src/nodes/layout-item-node.tsx +118 -0
  58. package/src/nodes/list-with-color-node.tsx +160 -0
  59. package/src/nodes/mention-node.ts +122 -0
  60. package/src/plugins/actions/actions-plugin.tsx +3 -0
  61. package/src/plugins/actions/character-limit-plugin.tsx +27 -0
  62. package/src/plugins/actions/clear-editor-plugin.tsx +70 -0
  63. package/src/plugins/actions/counter-character-plugin.tsx +80 -0
  64. package/src/plugins/actions/edit-mode-toggle-plugin.tsx +49 -0
  65. package/src/plugins/actions/import-export-plugin.tsx +61 -0
  66. package/src/plugins/actions/markdown-toggle-plugin.tsx +78 -0
  67. package/src/plugins/actions/max-length-plugin.tsx +59 -0
  68. package/src/plugins/actions/share-content-plugin.tsx +72 -0
  69. package/src/plugins/actions/speech-to-text-plugin.tsx +159 -0
  70. package/src/plugins/actions/tree-view-plugin.tsx +63 -0
  71. package/src/plugins/align-plugin.tsx +86 -0
  72. package/src/plugins/auto-link-plugin.tsx +34 -0
  73. package/src/plugins/autocomplete-plugin.tsx +2574 -0
  74. package/src/plugins/code-action-menu-plugin.tsx +240 -0
  75. package/src/plugins/code-highlight-plugin.tsx +22 -0
  76. package/src/plugins/component-picker-menu-plugin.tsx +427 -0
  77. package/src/plugins/context-menu-plugin.tsx +311 -0
  78. package/src/plugins/drag-drop-paste-plugin.tsx +52 -0
  79. package/src/plugins/draggable-block-plugin.tsx +50 -0
  80. package/src/plugins/embeds/auto-embed-plugin.tsx +324 -0
  81. package/src/plugins/embeds/twitter-plugin.tsx +45 -0
  82. package/src/plugins/embeds/youtube-plugin.tsx +84 -0
  83. package/src/plugins/emoji-picker-plugin.tsx +206 -0
  84. package/src/plugins/emojis-plugin.tsx +84 -0
  85. package/src/plugins/floating-link-editor-plugin.tsx +791 -0
  86. package/src/plugins/floating-text-format-plugin.tsx +710 -0
  87. package/src/plugins/images-plugin.tsx +671 -0
  88. package/src/plugins/keywords-plugin.tsx +59 -0
  89. package/src/plugins/layout-plugin.tsx +658 -0
  90. package/src/plugins/link-plugin.tsx +18 -0
  91. package/src/plugins/list-color-plugin.tsx +178 -0
  92. package/src/plugins/list-max-indent-level-plugin.tsx +85 -0
  93. package/src/plugins/mentions-plugin.tsx +714 -0
  94. package/src/plugins/picker/alignment-picker-plugin.tsx +40 -0
  95. package/src/plugins/picker/bulleted-list-picker-plugin.tsx +14 -0
  96. package/src/plugins/picker/check-list-picker-plugin.tsx +14 -0
  97. package/src/plugins/picker/code-picker-plugin.tsx +30 -0
  98. package/src/plugins/picker/columns-layout-picker-plugin.tsx +16 -0
  99. package/src/plugins/picker/component-picker-option.tsx +47 -0
  100. package/src/plugins/picker/divider-picker-plugin.tsx +14 -0
  101. package/src/plugins/picker/embeds-picker-plugin.tsx +24 -0
  102. package/src/plugins/picker/heading-picker-plugin.tsx +32 -0
  103. package/src/plugins/picker/image-picker-plugin.tsx +16 -0
  104. package/src/plugins/picker/numbered-list-picker-plugin.tsx +14 -0
  105. package/src/plugins/picker/paragraph-picker-plugin.tsx +20 -0
  106. package/src/plugins/picker/quote-picker-plugin.tsx +21 -0
  107. package/src/plugins/picker/table-picker-plugin.tsx +56 -0
  108. package/src/plugins/tab-focus-plugin.tsx +66 -0
  109. package/src/plugins/table-column-resizer-plugin.tsx +309 -0
  110. package/src/plugins/table-plugin.tsx +299 -0
  111. package/src/plugins/toolbar/block-format/block-format-data.tsx +69 -0
  112. package/src/plugins/toolbar/block-format/format-bulleted-list.tsx +40 -0
  113. package/src/plugins/toolbar/block-format/format-check-list.tsx +40 -0
  114. package/src/plugins/toolbar/block-format/format-code-block.tsx +45 -0
  115. package/src/plugins/toolbar/block-format/format-heading.tsx +34 -0
  116. package/src/plugins/toolbar/block-format/format-list-with-marker.tsx +74 -0
  117. package/src/plugins/toolbar/block-format/format-numbered-list.tsx +40 -0
  118. package/src/plugins/toolbar/block-format/format-paragraph.tsx +31 -0
  119. package/src/plugins/toolbar/block-format/format-quote.tsx +32 -0
  120. package/src/plugins/toolbar/block-format-toolbar-plugin.tsx +117 -0
  121. package/src/plugins/toolbar/block-insert/insert-columns-layout.tsx +32 -0
  122. package/src/plugins/toolbar/block-insert/insert-embeds.tsx +31 -0
  123. package/src/plugins/toolbar/block-insert/insert-horizontal-rule.tsx +30 -0
  124. package/src/plugins/toolbar/block-insert/insert-image.tsx +32 -0
  125. package/src/plugins/toolbar/block-insert/insert-table.tsx +32 -0
  126. package/src/plugins/toolbar/block-insert-plugin.tsx +30 -0
  127. package/src/plugins/toolbar/clear-formatting-toolbar-plugin.tsx +92 -0
  128. package/src/plugins/toolbar/code-language-toolbar-plugin.tsx +121 -0
  129. package/src/plugins/toolbar/element-format-toolbar-plugin.tsx +251 -0
  130. package/src/plugins/toolbar/font-background-toolbar-plugin.tsx +179 -0
  131. package/src/plugins/toolbar/font-color-toolbar-plugin.tsx +101 -0
  132. package/src/plugins/toolbar/font-family-toolbar-plugin.tsx +91 -0
  133. package/src/plugins/toolbar/font-format-toolbar-plugin.tsx +85 -0
  134. package/src/plugins/toolbar/font-size-toolbar-plugin.tsx +177 -0
  135. package/src/plugins/toolbar/history-toolbar-plugin.tsx +87 -0
  136. package/src/plugins/toolbar/link-toolbar-plugin.tsx +90 -0
  137. package/src/plugins/toolbar/subsuper-toolbar-plugin.tsx +69 -0
  138. package/src/plugins/toolbar/toolbar-plugin.tsx +66 -0
  139. package/src/plugins/typing-pref-plugin.tsx +118 -0
  140. package/src/shared/can-use-dom.ts +4 -0
  141. package/src/shared/environment.ts +47 -0
  142. package/src/shared/invariant.ts +16 -0
  143. package/src/shared/use-layout-effect.ts +12 -0
  144. package/src/themes/_mixins.scss +107 -0
  145. package/src/themes/_variables.scss +33 -0
  146. package/src/themes/editor-theme.scss +622 -0
  147. package/src/themes/editor-theme.ts +118 -0
  148. package/src/themes/plugins.scss +1180 -0
  149. package/src/themes/ui-components.scss +936 -0
  150. package/src/transformers/markdown-emoji-transformer.ts +20 -0
  151. package/src/transformers/markdown-hr-transformer.ts +28 -0
  152. package/src/transformers/markdown-image-transformer.ts +31 -0
  153. package/src/transformers/markdown-list-transformer.ts +51 -0
  154. package/src/transformers/markdown-table-transformer.ts +200 -0
  155. package/src/transformers/markdown-tweet-transformer.ts +26 -0
  156. package/src/ui/button-group.tsx +10 -0
  157. package/src/ui/button.tsx +29 -0
  158. package/src/ui/collapsible.tsx +67 -0
  159. package/src/ui/command.tsx +48 -0
  160. package/src/ui/dialog.tsx +146 -0
  161. package/src/ui/flex.tsx +38 -0
  162. package/src/ui/input.tsx +20 -0
  163. package/src/ui/label.tsx +20 -0
  164. package/src/ui/popover.tsx +128 -0
  165. package/src/ui/scroll-area.tsx +17 -0
  166. package/src/ui/select.tsx +171 -0
  167. package/src/ui/separator.tsx +20 -0
  168. package/src/ui/slider.tsx +14 -0
  169. package/src/ui/slot.tsx +3 -0
  170. package/src/ui/tabs.tsx +87 -0
  171. package/src/ui/toggle-group.tsx +109 -0
  172. package/src/ui/toggle.tsx +28 -0
  173. package/src/ui/tooltip.tsx +28 -0
  174. package/src/ui/typography.tsx +44 -0
  175. package/src/utils/doc-serialization.ts +68 -0
  176. package/src/utils/emoji-list.ts +16604 -0
  177. package/src/utils/get-dom-range-rect.ts +20 -0
  178. package/src/utils/get-selected-node.ts +20 -0
  179. package/src/utils/is-mobile-width.ts +0 -0
  180. package/src/utils/set-floating-elem-position-for-link-editor.ts +39 -0
  181. package/src/utils/set-floating-elem-position.ts +44 -0
  182. package/src/utils/swipe.ts +119 -0
  183. package/src/utils/url.ts +32 -0
@@ -0,0 +1,791 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ *
9
+ */
10
+ import { Dispatch, JSX, useCallback, useEffect, useRef, useState } from "react"
11
+ import {
12
+ $createLinkNode,
13
+ $isAutoLinkNode,
14
+ $isLinkNode,
15
+ TOGGLE_LINK_COMMAND,
16
+ } from "@lexical/link"
17
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
18
+ import { $findMatchingParent, $wrapNodeInElement, mergeRegister } from "@lexical/utils"
19
+ import {
20
+ $getSelection,
21
+ $isLineBreakNode,
22
+ $isNodeSelection,
23
+ $isRangeSelection,
24
+ $setSelection,
25
+ BaseSelection,
26
+ CLICK_COMMAND,
27
+ COMMAND_PRIORITY_CRITICAL,
28
+ COMMAND_PRIORITY_HIGH,
29
+ COMMAND_PRIORITY_LOW,
30
+ KEY_ESCAPE_COMMAND,
31
+ LexicalEditor,
32
+ SELECTION_CHANGE_COMMAND,
33
+ } from "lexical"
34
+ import { Check, Pencil, Trash, X } from "lucide-react"
35
+ import { createPortal } from "react-dom"
36
+
37
+ import { getSelectedNode } from "../utils/get-selected-node"
38
+ import { setFloatingElemPositionForLinkEditor } from "../utils/set-floating-elem-position-for-link-editor"
39
+ import { sanitizeUrl, validateUrl } from "../utils/url"
40
+ import { Button } from "../ui/button"
41
+ import { Input } from "../ui/input"
42
+ import { Flex } from "../ui/flex"
43
+ import { TypographyPSmall } from "../ui/typography"
44
+ import { $isImageNode } from "../nodes/image-node"
45
+
46
+ function FloatingLinkEditor({
47
+ editor,
48
+ isLink,
49
+ setIsLink,
50
+ anchorElem,
51
+ isLinkEditMode,
52
+ setIsLinkEditMode,
53
+ }: {
54
+ editor: LexicalEditor
55
+ isLink: boolean
56
+ setIsLink: Dispatch<boolean>
57
+ anchorElem: HTMLElement
58
+ isLinkEditMode: boolean
59
+ setIsLinkEditMode: Dispatch<boolean>
60
+ }): JSX.Element {
61
+ const editorRef = useRef<HTMLDivElement | null>(null)
62
+ const inputRef = useRef<HTMLInputElement>(null)
63
+ const [linkUrl, setLinkUrl] = useState("")
64
+ const [editedLinkUrl, setEditedLinkUrl] = useState("https://")
65
+ const [lastSelection, setLastSelection] = useState<BaseSelection | null>(null)
66
+
67
+ const $updateLinkEditor = useCallback(() => {
68
+ const selection = $getSelection()
69
+ let linkNode = null
70
+ let selectedNode = null
71
+
72
+ if ($isRangeSelection(selection)) {
73
+ const node = getSelectedNode(selection)
74
+ if (node !== null) {
75
+ selectedNode = node
76
+ linkNode = $findMatchingParent(node, $isLinkNode)
77
+ if (!linkNode && $isLinkNode(node)) {
78
+ linkNode = node
79
+ }
80
+ }
81
+ } else if ($isNodeSelection(selection)) {
82
+ const nodes = selection.getNodes()
83
+ const node = nodes[0]
84
+ if (node) {
85
+ selectedNode = node
86
+ // Check if the node itself is a link node
87
+ if ($isLinkNode(node)) {
88
+ linkNode = node
89
+ } else {
90
+ // Check if the node is wrapped in a link (e.g., image node in link)
91
+ linkNode = $findMatchingParent(node, $isLinkNode)
92
+ // For image nodes, also check if parent is a link
93
+ if (!linkNode && $isImageNode(node)) {
94
+ const parent = node.getParent()
95
+ if ($isLinkNode(parent)) {
96
+ linkNode = parent
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ if (linkNode) {
104
+ setLinkUrl(linkNode.getURL())
105
+ } else {
106
+ setLinkUrl("")
107
+ }
108
+
109
+ if (isLinkEditMode && linkNode) {
110
+ setEditedLinkUrl(linkUrl || linkNode.getURL())
111
+ }
112
+
113
+ const editorElem = editorRef.current
114
+ const nativeSelection = window.getSelection()
115
+ const activeElement = document.activeElement
116
+
117
+ if (editorElem === null) {
118
+ return
119
+ }
120
+
121
+ const rootElement = editor.getRootElement()
122
+
123
+ // Check if we have a valid selection (with or without link)
124
+ const hasValidSelection = selection !== null
125
+ const hasValidNativeSelection =
126
+ nativeSelection !== null &&
127
+ rootElement !== null &&
128
+ (nativeSelection.anchorNode && rootElement.contains(nativeSelection.anchorNode))
129
+
130
+ // Show floating editor if:
131
+ // 1. We have a link node (existing link) - show to view/edit
132
+ // 2. We're in edit mode (creating new link) - show input to create link
133
+ const hasImageNode = $isNodeSelection(selection) && selectedNode && $isImageNode(selectedNode)
134
+ const shouldShowEditor =
135
+ (linkNode !== null || isLinkEditMode) &&
136
+ hasValidSelection &&
137
+ editor.isEditable() &&
138
+ (
139
+ // If in edit mode, always show (even if nativeSelection is not valid)
140
+ isLinkEditMode ||
141
+ hasValidNativeSelection ||
142
+ ($isNodeSelection(selection) && selectedNode) ||
143
+ ($isRangeSelection(selection) && selection.getTextContent().length > 0)
144
+ )
145
+ void hasImageNode // reserved for future: hide editor when selection is image-only
146
+
147
+ if (shouldShowEditor) {
148
+ // For node selection (e.g., image), try to get the DOM element
149
+ let domRect: DOMRect | undefined
150
+
151
+ if ($isNodeSelection(selection) && selectedNode) {
152
+ // Try to get DOM element using node key
153
+ const nodeKey = selectedNode.getKey()
154
+ const nodeElement = editor.getElementByKey(nodeKey)
155
+
156
+ if (nodeElement) {
157
+ // For image nodes wrapped in links, find the link element
158
+ if ($isImageNode(selectedNode) && linkNode) {
159
+ // Find the link element that wraps the image
160
+ const linkElement = nodeElement.closest("a") || nodeElement.parentElement?.closest("a")
161
+ if (linkElement) {
162
+ domRect = linkElement.getBoundingClientRect()
163
+ } else {
164
+ domRect = nodeElement.getBoundingClientRect()
165
+ }
166
+ } else {
167
+ domRect = nodeElement.getBoundingClientRect()
168
+ }
169
+ }
170
+ }
171
+
172
+ // Fallback to native selection if we don't have a specific DOM rect
173
+ if (!domRect && nativeSelection) {
174
+ // Try to find link element in DOM if we have a link node
175
+ if (linkNode && nativeSelection.focusNode) {
176
+ // focusNode can be a Text node, so we need to get the parent element
177
+ let focusElement: HTMLElement | null = null
178
+ if (nativeSelection.focusNode instanceof HTMLElement) {
179
+ focusElement = nativeSelection.focusNode
180
+ } else if (nativeSelection.focusNode.parentElement) {
181
+ focusElement = nativeSelection.focusNode.parentElement
182
+ }
183
+
184
+ if (focusElement) {
185
+ const linkElement = focusElement.closest("a") || focusElement.parentElement?.closest("a")
186
+ if (linkElement) {
187
+ domRect = linkElement.getBoundingClientRect()
188
+ }
189
+ }
190
+ }
191
+ if (!domRect && nativeSelection.focusNode) {
192
+ // Get parent element if focusNode is not an HTMLElement
193
+ const parentElement = nativeSelection.focusNode instanceof HTMLElement
194
+ ? nativeSelection.focusNode
195
+ : nativeSelection.focusNode.parentElement
196
+ if (parentElement) {
197
+ domRect = parentElement.getBoundingClientRect()
198
+ }
199
+ }
200
+ }
201
+
202
+ // If in edit mode but no domRect, try to get from range selection
203
+ if (!domRect && isLinkEditMode && $isRangeSelection(selection)) {
204
+ const range = nativeSelection?.getRangeAt(0)
205
+ if (range) {
206
+ domRect = range.getBoundingClientRect()
207
+ }
208
+ }
209
+
210
+ if (domRect) {
211
+ domRect.y += 40
212
+ setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem)
213
+ } else if (isLinkEditMode) {
214
+ // If in edit mode but no domRect, show editor at a default position
215
+ // Use a fallback position based on editor root
216
+ if (rootElement) {
217
+ const rootRect = rootElement.getBoundingClientRect()
218
+ const fallbackRect = new DOMRect(
219
+ rootRect.left + 20,
220
+ rootRect.top + 100,
221
+ 300,
222
+ 50
223
+ )
224
+ setFloatingElemPositionForLinkEditor(fallbackRect, editorElem, anchorElem)
225
+ }
226
+ }
227
+ setLastSelection(selection)
228
+ } else if (!activeElement || !activeElement.classList.contains("editor-link-input")) {
229
+ if (rootElement !== null) {
230
+ setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)
231
+ }
232
+ setLastSelection(null)
233
+ setIsLinkEditMode(false)
234
+ if (!linkNode) {
235
+ setLinkUrl("")
236
+ }
237
+ }
238
+
239
+ return true
240
+ }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl])
241
+
242
+ useEffect(() => {
243
+ const scrollerElem = anchorElem.parentElement
244
+
245
+ const update = () => {
246
+ editor.getEditorState().read(() => {
247
+ $updateLinkEditor()
248
+ })
249
+ }
250
+
251
+ window.addEventListener("resize", update)
252
+
253
+ if (scrollerElem) {
254
+ scrollerElem.addEventListener("scroll", update, { passive: true })
255
+ }
256
+
257
+ return () => {
258
+ window.removeEventListener("resize", update)
259
+
260
+ if (scrollerElem) {
261
+ scrollerElem.removeEventListener("scroll", update)
262
+ }
263
+ }
264
+ }, [anchorElem.parentElement, editor, $updateLinkEditor])
265
+
266
+ useEffect(() => {
267
+ return mergeRegister(
268
+ editor.registerUpdateListener(({ editorState }) => {
269
+ editorState.read(() => {
270
+ $updateLinkEditor()
271
+ })
272
+ }),
273
+
274
+ editor.registerCommand(
275
+ SELECTION_CHANGE_COMMAND,
276
+ () => {
277
+ $updateLinkEditor()
278
+ return true
279
+ },
280
+ COMMAND_PRIORITY_LOW
281
+ ),
282
+ editor.registerCommand(
283
+ KEY_ESCAPE_COMMAND,
284
+ () => {
285
+ if (isLink) {
286
+ setIsLink(false)
287
+ return true
288
+ }
289
+ return false
290
+ },
291
+ COMMAND_PRIORITY_HIGH
292
+ )
293
+ )
294
+ }, [editor, $updateLinkEditor, setIsLink, isLink])
295
+
296
+ useEffect(() => {
297
+ editor.getEditorState().read(() => {
298
+ $updateLinkEditor()
299
+ })
300
+ }, [editor, $updateLinkEditor])
301
+
302
+ useEffect(() => {
303
+ if (isLinkEditMode && inputRef.current) {
304
+ inputRef.current.focus()
305
+ // Use setTimeout to avoid calling setState synchronously within effect
306
+ setTimeout(() => {
307
+ setIsLink(true)
308
+ }, 0)
309
+ }
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ }, [isLinkEditMode, isLink])
312
+
313
+ const monitorInputInteraction = (
314
+ event: React.KeyboardEvent<HTMLInputElement>
315
+ ) => {
316
+ if (event.key === "Enter") {
317
+ event.preventDefault()
318
+ handleLinkSubmission()
319
+ } else if (event.key === "Escape") {
320
+ event.preventDefault()
321
+ setIsLinkEditMode(false)
322
+ }
323
+ }
324
+
325
+ const handleLinkSubmission = () => {
326
+ const url = sanitizeUrl(editedLinkUrl)
327
+ if (url && url !== "https://" && url !== "http://") {
328
+ editor.update(() => {
329
+ // Try to get current selection first
330
+ let selection = $getSelection()
331
+
332
+ // If no current selection, try to restore from lastSelection
333
+ if (!selection && lastSelection !== null) {
334
+ // Clone the selection to avoid frozen object error
335
+ if ($isRangeSelection(lastSelection)) {
336
+ const clonedSelection = lastSelection.clone()
337
+ $setSelection(clonedSelection)
338
+ selection = $getSelection()
339
+ } else if ($isNodeSelection(lastSelection)) {
340
+ const clonedSelection = lastSelection.clone()
341
+ $setSelection(clonedSelection)
342
+ selection = $getSelection()
343
+ }
344
+ }
345
+
346
+ if (!selection) {
347
+ return
348
+ }
349
+
350
+ // Handle node selection (e.g., image nodes)
351
+ if ($isNodeSelection(selection)) {
352
+ const nodes = selection.getNodes()
353
+ if (nodes.length > 0) {
354
+ const node = nodes[0]
355
+
356
+ // If it's an image node
357
+ if ($isImageNode(node)) {
358
+ // Check if already wrapped in a link
359
+ const existingLinkNode = $findMatchingParent(node, $isLinkNode) ||
360
+ ($isLinkNode(node.getParent()) ? node.getParent() : null)
361
+
362
+ if (existingLinkNode) {
363
+ // Update existing link
364
+ existingLinkNode.setURL(url)
365
+ } else {
366
+ // Wrap image in link using wrapNodeInElement (safe for all cases including root)
367
+ const linkNode = $createLinkNode(url)
368
+ $wrapNodeInElement(node, () => linkNode)
369
+ }
370
+ }
371
+ }
372
+ }
373
+ // Handle range selection
374
+ else if ($isRangeSelection(selection)) {
375
+ // Use default TOGGLE_LINK_COMMAND for range selection
376
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url)
377
+ const parent = getSelectedNode(selection).getParent()
378
+ if ($isAutoLinkNode(parent)) {
379
+ const linkNode = $createLinkNode(parent.getURL(), {
380
+ rel: parent.__rel,
381
+ target: parent.__target,
382
+ title: parent.__title,
383
+ })
384
+ parent.replace(linkNode, true)
385
+ }
386
+ }
387
+ })
388
+ setEditedLinkUrl("https://")
389
+ setIsLinkEditMode(false)
390
+ }
391
+ }
392
+ return (
393
+ <div
394
+ ref={editorRef}
395
+ className="editor-floating-link-editor"
396
+ >
397
+ {isLinkEditMode || isLink ? (
398
+ isLinkEditMode ? (
399
+ <div className="editor-floating-link-editor__input-container">
400
+ <Input
401
+ ref={inputRef}
402
+ value={editedLinkUrl}
403
+ onChange={(event) => setEditedLinkUrl(event.target.value)}
404
+ onKeyDown={monitorInputInteraction}
405
+ className="editor-flex-grow"
406
+ />
407
+ <Button
408
+ size="icon"
409
+ variant="ghost"
410
+ onClick={() => {
411
+ setIsLinkEditMode(false)
412
+ setIsLink(false)
413
+ }}
414
+ className="editor-shrink-0"
415
+ >
416
+ <X className="editor-icon-sm" />
417
+ </Button>
418
+ <Button
419
+ size="icon"
420
+ onClick={handleLinkSubmission}
421
+ className="editor-shrink-0"
422
+ >
423
+ <Check className="editor-icon-sm" />
424
+ </Button>
425
+ </div>
426
+ ) : (
427
+ <div className="editor-floating-link-editor__view-container">
428
+ <a
429
+ href={sanitizeUrl(linkUrl)}
430
+ target="_blank"
431
+ rel="noopener noreferrer"
432
+ className="editor-floating-link-editor__link"
433
+ >
434
+ <TypographyPSmall className="editor-truncate">{linkUrl}</TypographyPSmall>
435
+ </a>
436
+ <Flex gap={0} className="editor-shrink-0">
437
+ <Button
438
+ size="icon"
439
+ variant="ghost"
440
+ onClick={() => {
441
+ setEditedLinkUrl(linkUrl)
442
+ setIsLinkEditMode(true)
443
+ }}
444
+ >
445
+ <Pencil className="editor-icon-sm" />
446
+ </Button>
447
+ <Button
448
+ size="icon"
449
+ variant="destructive"
450
+ onClick={() => {
451
+ editor.update(() => {
452
+ const selection = $getSelection()
453
+ // Handle node selection (e.g., image nodes)
454
+ if ($isNodeSelection(selection)) {
455
+ const nodes = selection.getNodes()
456
+ if (nodes.length > 0) {
457
+ const node = nodes[0]
458
+ if ($isImageNode(node)) {
459
+ const linkNode = $findMatchingParent(node, $isLinkNode) ||
460
+ ($isLinkNode(node.getParent()) ? node.getParent() : null)
461
+ if (linkNode) {
462
+ // Remove link by unwrapping - insert children into parent and remove link
463
+ const parent = linkNode.getParent()
464
+ if (parent) {
465
+ const children = linkNode.getChildren()
466
+ children.forEach((child) => {
467
+ linkNode.insertBefore(child)
468
+ })
469
+ linkNode.remove()
470
+ }
471
+ }
472
+ }
473
+ }
474
+ } else {
475
+ // Use default TOGGLE_LINK_COMMAND for range selection
476
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
477
+ }
478
+ })
479
+ }}
480
+ >
481
+ <Trash className="editor-icon-sm" />
482
+ </Button>
483
+ </Flex>
484
+ </div>
485
+ )
486
+ ) : null}
487
+ </div>
488
+ )
489
+ }
490
+
491
+ function useFloatingLinkEditorToolbar(
492
+ editor: LexicalEditor,
493
+ anchorElem: HTMLDivElement | null,
494
+ isLinkEditMode: boolean,
495
+ setIsLinkEditMode: Dispatch<boolean>
496
+ ): JSX.Element | null {
497
+ const [activeEditor, setActiveEditor] = useState(editor)
498
+ const [isLink, setIsLink] = useState(false)
499
+
500
+ useEffect(() => {
501
+ function $updateToolbar() {
502
+ const selection = $getSelection()
503
+ if ($isRangeSelection(selection)) {
504
+ const focusNode = getSelectedNode(selection)
505
+ const focusLinkNode = $findMatchingParent(focusNode, $isLinkNode)
506
+ const focusAutoLinkNode = $findMatchingParent(
507
+ focusNode,
508
+ $isAutoLinkNode
509
+ )
510
+ if (!(focusLinkNode || focusAutoLinkNode)) {
511
+ setIsLink(false)
512
+ return
513
+ }
514
+ const badNode = selection
515
+ .getNodes()
516
+ .filter((node) => !$isLineBreakNode(node))
517
+ .find((node) => {
518
+ const linkNode = $findMatchingParent(node, $isLinkNode)
519
+ const autoLinkNode = $findMatchingParent(node, $isAutoLinkNode)
520
+ return (
521
+ (focusLinkNode && !focusLinkNode.is(linkNode)) ||
522
+ (linkNode && !linkNode.is(focusLinkNode)) ||
523
+ (focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode)) ||
524
+ (autoLinkNode &&
525
+ (!autoLinkNode.is(focusAutoLinkNode) ||
526
+ autoLinkNode.getIsUnlinked()))
527
+ )
528
+ })
529
+ if (!badNode) {
530
+ setIsLink(true)
531
+ } else {
532
+ setIsLink(false)
533
+ }
534
+ } else if ($isNodeSelection(selection)) {
535
+ const nodes = selection.getNodes()
536
+ if (nodes.length === 0) {
537
+ setIsLink(false)
538
+ return
539
+ }
540
+ const node = nodes[0]
541
+ if (node) {
542
+ // Check if node itself is a link
543
+ if ($isLinkNode(node)) {
544
+ setIsLink(true)
545
+ return
546
+ }
547
+ // Check if node is wrapped in a link (using $findMatchingParent for better traversal)
548
+ const linkParent = $findMatchingParent(node, $isLinkNode)
549
+ if (linkParent) {
550
+ setIsLink(true)
551
+ return
552
+ }
553
+ // For image nodes, also check direct parent
554
+ if ($isImageNode(node)) {
555
+ const parent = node.getParent()
556
+ if ($isLinkNode(parent)) {
557
+ setIsLink(true)
558
+ return
559
+ }
560
+ }
561
+ }
562
+ setIsLink(false)
563
+ }
564
+ }
565
+ return mergeRegister(
566
+ editor.registerUpdateListener(({ editorState }) => {
567
+ editorState.read(() => {
568
+ $updateToolbar()
569
+ })
570
+ }),
571
+ // Register TOGGLE_LINK_COMMAND handler for node selection (image nodes)
572
+ editor.registerCommand(
573
+ TOGGLE_LINK_COMMAND,
574
+ (url: string | null) => {
575
+ const selection = $getSelection()
576
+
577
+ // Handle node selection (e.g., image nodes)
578
+ if ($isNodeSelection(selection)) {
579
+ const nodes = selection.getNodes()
580
+ if (nodes.length > 0) {
581
+ const node = nodes[0]
582
+
583
+ if ($isImageNode(node)) {
584
+ if (url) {
585
+ // Create or update link
586
+ const existingLinkNode = $findMatchingParent(node, $isLinkNode) ||
587
+ ($isLinkNode(node.getParent()) ? node.getParent() : null)
588
+
589
+ if (existingLinkNode) {
590
+ // Update existing link
591
+ existingLinkNode.setURL(url)
592
+ } else {
593
+ // Wrap image in link using wrapNodeInElement (safe for all cases including root)
594
+ const linkNode = $createLinkNode(url)
595
+ $wrapNodeInElement(node, () => linkNode)
596
+ }
597
+ } else {
598
+ // Remove link
599
+ const linkNode = $findMatchingParent(node, $isLinkNode) ||
600
+ ($isLinkNode(node.getParent()) ? node.getParent() : null)
601
+ if (linkNode) {
602
+ // Remove link by unwrapping - insert children into parent and remove link
603
+ const parent = linkNode.getParent()
604
+ if (parent) {
605
+ const children = linkNode.getChildren()
606
+ children.forEach((child) => {
607
+ linkNode.insertBefore(child)
608
+ })
609
+ linkNode.remove()
610
+ }
611
+ }
612
+ }
613
+ return true
614
+ }
615
+ }
616
+ }
617
+
618
+ // Let default handler process range selection
619
+ return false
620
+ },
621
+ COMMAND_PRIORITY_HIGH
622
+ ),
623
+ editor.registerCommand(
624
+ SELECTION_CHANGE_COMMAND,
625
+ (_payload, newEditor) => {
626
+ editor.getEditorState().read(() => {
627
+ $updateToolbar()
628
+ })
629
+ setActiveEditor(newEditor)
630
+ return false
631
+ },
632
+ COMMAND_PRIORITY_CRITICAL
633
+ ),
634
+ // Register a listener for when node selection changes to image with link
635
+ editor.registerCommand(
636
+ SELECTION_CHANGE_COMMAND,
637
+ () => {
638
+ editor.getEditorState().read(() => {
639
+ const selection = $getSelection()
640
+ if ($isNodeSelection(selection)) {
641
+ const nodes = selection.getNodes()
642
+ if (nodes.length > 0) {
643
+ const node = nodes[0]
644
+ if ($isImageNode(node)) {
645
+ const linkNode = $findMatchingParent(node, $isLinkNode) ||
646
+ ($isLinkNode(node.getParent()) ? node.getParent() : null)
647
+ if (linkNode) {
648
+ // Delay to ensure DOM is updated
649
+ setTimeout(() => {
650
+ editor.getEditorState().read(() => {
651
+ $updateToolbar()
652
+ })
653
+ }, 10)
654
+ }
655
+ }
656
+ }
657
+ }
658
+ })
659
+ return false
660
+ },
661
+ COMMAND_PRIORITY_LOW
662
+ ),
663
+ editor.registerCommand(
664
+ CLICK_COMMAND,
665
+ (payload) => {
666
+ let shouldReturnTrue = false
667
+
668
+ editor.getEditorState().read(() => {
669
+ const selection = $getSelection()
670
+
671
+ // Check if we clicked on an image node (with or without link)
672
+ let hasImageNode = false
673
+ if ($isNodeSelection(selection)) {
674
+ const nodes = selection.getNodes()
675
+ if (nodes.length > 0) {
676
+ const node = nodes[0]
677
+ if ($isImageNode(node)) {
678
+ hasImageNode = true
679
+ }
680
+ }
681
+ }
682
+
683
+ // Handle Ctrl/Cmd + click to open link
684
+ if (payload.metaKey || payload.ctrlKey) {
685
+ if ($isRangeSelection(selection)) {
686
+ const node = getSelectedNode(selection)
687
+ if (node) {
688
+ const linkNode = $findMatchingParent(node, $isLinkNode)
689
+ if ($isLinkNode(linkNode)) {
690
+ const url = linkNode.getURL()
691
+ // Validate URL before opening to prevent errors with invalid URLs
692
+ if (url && validateUrl(url)) {
693
+ window.open(url, "_blank")
694
+ }
695
+ shouldReturnTrue = true
696
+ return
697
+ }
698
+ }
699
+ } else if ($isNodeSelection(selection)) {
700
+ const nodes = selection.getNodes()
701
+ if (nodes.length > 0) {
702
+ const node = nodes[0]
703
+ let linkNode = null
704
+ if ($isLinkNode(node)) {
705
+ linkNode = node
706
+ } else {
707
+ if (node) {
708
+ linkNode = $findMatchingParent(node, $isLinkNode)
709
+ if (!linkNode && $isImageNode(node)) {
710
+ const parent = node.getParent()
711
+ if ($isLinkNode(parent)) {
712
+ linkNode = parent
713
+ }
714
+ }
715
+ }
716
+ }
717
+ if (linkNode) {
718
+ const url = linkNode.getURL()
719
+ if (url && validateUrl(url)) {
720
+ window.open(url, "_blank")
721
+ }
722
+ shouldReturnTrue = true
723
+ return
724
+ }
725
+ }
726
+ }
727
+ }
728
+
729
+ // If we clicked on an image (with or without link), trigger toolbar update
730
+ if (hasImageNode) {
731
+ // Use requestAnimationFrame to ensure selection is updated
732
+ requestAnimationFrame(() => {
733
+ editor.getEditorState().read(() => {
734
+ $updateToolbar()
735
+ })
736
+ })
737
+ }
738
+ })
739
+
740
+ if (shouldReturnTrue) {
741
+ return true
742
+ }
743
+
744
+ // Trigger toolbar update on click to ensure floating editor shows
745
+ setTimeout(() => {
746
+ editor.getEditorState().read(() => {
747
+ $updateToolbar()
748
+ })
749
+ }, 0)
750
+ return false
751
+ },
752
+ COMMAND_PRIORITY_HIGH
753
+ )
754
+ )
755
+ }, [editor])
756
+
757
+ if (!anchorElem) {
758
+ return null
759
+ }
760
+
761
+ return createPortal(
762
+ <FloatingLinkEditor
763
+ editor={activeEditor}
764
+ isLink={isLink}
765
+ anchorElem={anchorElem}
766
+ setIsLink={setIsLink}
767
+ isLinkEditMode={isLinkEditMode}
768
+ setIsLinkEditMode={setIsLinkEditMode}
769
+ />,
770
+ anchorElem
771
+ )
772
+ }
773
+
774
+ export function FloatingLinkEditorPlugin({
775
+ anchorElem,
776
+ isLinkEditMode,
777
+ setIsLinkEditMode,
778
+ }: {
779
+ anchorElem: HTMLDivElement | null
780
+ isLinkEditMode: boolean
781
+ setIsLinkEditMode: Dispatch<boolean>
782
+ }): JSX.Element | null {
783
+ const [editor] = useLexicalComposerContext()
784
+
785
+ return useFloatingLinkEditorToolbar(
786
+ editor,
787
+ anchorElem,
788
+ isLinkEditMode,
789
+ setIsLinkEditMode
790
+ )
791
+ }