@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,240 @@
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 { JSX, useCallback, useEffect, useRef, useState } from "react"
11
+ import {
12
+ $isCodeNode,
13
+ CODE_LANGUAGE_FRIENDLY_NAME_MAP,
14
+ CodeNode,
15
+ getLanguageFriendlyName,
16
+ } from "@lexical/code"
17
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
18
+ import { $getNearestNodeFromDOMNode, $getNodeByKey, isHTMLElement } from "lexical"
19
+ import { createPortal } from "react-dom"
20
+
21
+ import { useDebounce } from "../editor-hooks/use-debounce"
22
+ import { CopyButton } from "../editor-ui/code-button"
23
+ import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select"
24
+
25
+ const CODE_PADDING = 8
26
+
27
+ function getCodeLanguageOptions(): [string, string][] {
28
+ const options: [string, string][] = []
29
+
30
+ for (const [lang, friendlyName] of Object.entries(
31
+ CODE_LANGUAGE_FRIENDLY_NAME_MAP
32
+ )) {
33
+ options.push([lang, friendlyName])
34
+ }
35
+
36
+ return options
37
+ }
38
+
39
+ const CODE_LANGUAGE_OPTIONS = getCodeLanguageOptions()
40
+
41
+ interface Position {
42
+ top: string
43
+ right: string
44
+ }
45
+
46
+ function CodeActionMenuContainer({
47
+ anchorElem,
48
+ }: {
49
+ anchorElem: HTMLElement
50
+ }): JSX.Element {
51
+ const [editor] = useLexicalComposerContext()
52
+
53
+ const [lang, setLang] = useState("")
54
+ const [nodeKey, setNodeKey] = useState<string | null>(null)
55
+ const [isShown, setShown] = useState<boolean>(false)
56
+ const [shouldListenMouseMove, setShouldListenMouseMove] =
57
+ useState<boolean>(false)
58
+ const [position, setPosition] = useState<Position>({
59
+ right: "0",
60
+ top: "0",
61
+ })
62
+ const codeSetRef = useRef<Set<string>>(new Set())
63
+ const codeDOMNodeRef = useRef<HTMLElement | null>(null)
64
+
65
+ function getCodeDOMNode(): HTMLElement | null {
66
+ return codeDOMNodeRef.current
67
+ }
68
+
69
+ const debouncedOnMouseMove = useDebounce(
70
+ (event: MouseEvent) => {
71
+ const { codeDOMNode, isOutside } = getMouseInfo(event)
72
+ if (isOutside) {
73
+ setShown(false)
74
+ return
75
+ }
76
+
77
+ if (!codeDOMNode) {
78
+ return
79
+ }
80
+
81
+ codeDOMNodeRef.current = codeDOMNode
82
+
83
+ let codeNode: CodeNode | null = null
84
+ let _lang = ""
85
+ let _nodeKey = ""
86
+
87
+ editor.update(() => {
88
+ const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNode)
89
+
90
+ if ($isCodeNode(maybeCodeNode)) {
91
+ codeNode = maybeCodeNode
92
+ _lang = codeNode.getLanguage() || ""
93
+ _nodeKey = codeNode.getKey()
94
+ }
95
+ })
96
+
97
+ if (codeNode) {
98
+ const { y: editorElemY, right: editorElemRight } =
99
+ anchorElem.getBoundingClientRect()
100
+ const { y, right } = codeDOMNode.getBoundingClientRect()
101
+ setLang(_lang)
102
+ setNodeKey(_nodeKey)
103
+ setShown(true)
104
+ setPosition({
105
+ right: `${editorElemRight - right + CODE_PADDING}px`,
106
+ top: `${y - editorElemY + CODE_PADDING}px`,
107
+ })
108
+ }
109
+ },
110
+ 50,
111
+ 1000
112
+ )
113
+
114
+ const onCodeLanguageSelect = useCallback(
115
+ (value: string) => {
116
+ editor.update(() => {
117
+ if (nodeKey !== null) {
118
+ const node = $getNodeByKey(nodeKey)
119
+ if ($isCodeNode(node)) {
120
+ node.setLanguage(value)
121
+ setLang(value)
122
+ }
123
+ }
124
+ })
125
+ },
126
+ [editor, nodeKey]
127
+ )
128
+
129
+ useEffect(() => {
130
+ if (!shouldListenMouseMove) {
131
+ return
132
+ }
133
+
134
+ document.addEventListener("mousemove", debouncedOnMouseMove)
135
+
136
+ return () => {
137
+ setShown(false)
138
+ debouncedOnMouseMove.cancel()
139
+ document.removeEventListener("mousemove", debouncedOnMouseMove)
140
+ }
141
+ }, [shouldListenMouseMove, debouncedOnMouseMove])
142
+
143
+ useEffect(() => {
144
+ return editor.registerMutationListener(
145
+ CodeNode,
146
+ (mutations) => {
147
+ editor.getEditorState().read(() => {
148
+ for (const [key, type] of mutations) {
149
+ switch (type) {
150
+ case "created":
151
+ codeSetRef.current.add(key)
152
+ break
153
+
154
+ case "destroyed":
155
+ codeSetRef.current.delete(key)
156
+ break
157
+
158
+ default:
159
+ break
160
+ }
161
+ }
162
+ })
163
+ setShouldListenMouseMove(codeSetRef.current.size > 0)
164
+ },
165
+ { skipInitialization: false }
166
+ )
167
+ }, [editor])
168
+
169
+ return (
170
+ <>
171
+ {isShown ? (
172
+ <div
173
+ className="editor-code-action-menu"
174
+ style={{ ...position, position: "absolute" }}
175
+ >
176
+ <Select
177
+ modal={false}
178
+ value={lang}
179
+ onValueChange={onCodeLanguageSelect}
180
+ >
181
+ <SelectTrigger className="editor-code-action-menu__select">
182
+ <span>{getLanguageFriendlyName(lang) || "Select Language"}</span>
183
+ </SelectTrigger>
184
+ <SelectContent className="editor-code-action-menu__select-content">
185
+ {CODE_LANGUAGE_OPTIONS.map(([value, label]) => (
186
+ <SelectItem
187
+ key={value}
188
+ value={value}
189
+ className="editor-code-action-menu__item"
190
+ >
191
+ {label}
192
+ </SelectItem>
193
+ ))}
194
+ </SelectContent>
195
+ </Select>
196
+
197
+ <div className="editor-code-action-menu__separator" />
198
+
199
+ <CopyButton editor={editor} getCodeDOMNode={getCodeDOMNode} />
200
+ </div>
201
+ ) : null}
202
+ </>
203
+ )
204
+ }
205
+
206
+ function getMouseInfo(event: MouseEvent): {
207
+ codeDOMNode: HTMLElement | null
208
+ isOutside: boolean
209
+ } {
210
+ const target = event.target
211
+
212
+ if (isHTMLElement(target)) {
213
+ const codeDOMNode = target.closest<HTMLElement>(
214
+ "code.editor-code"
215
+ )
216
+ const isOutside = !(
217
+ codeDOMNode ||
218
+ target.closest<HTMLElement>("div.editor-code-action-menu")
219
+ )
220
+
221
+ return { codeDOMNode, isOutside }
222
+ } else {
223
+ return { codeDOMNode: null, isOutside: true }
224
+ }
225
+ }
226
+
227
+ export function CodeActionMenuPlugin({
228
+ anchorElem = document.body,
229
+ }: {
230
+ anchorElem: HTMLElement | null
231
+ }): React.ReactPortal | null {
232
+ if (!anchorElem) {
233
+ return null
234
+ }
235
+
236
+ return createPortal(
237
+ <CodeActionMenuContainer anchorElem={anchorElem} />,
238
+ anchorElem
239
+ )
240
+ }
@@ -0,0 +1,22 @@
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 { JSX, useEffect } from "react"
11
+ import { registerCodeHighlighting } from "@lexical/code"
12
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
13
+
14
+ export function CodeHighlightPlugin(): JSX.Element | null {
15
+ const [editor] = useLexicalComposerContext()
16
+
17
+ useEffect(() => {
18
+ return registerCodeHighlighting(editor)
19
+ }, [editor])
20
+
21
+ return null
22
+ }
@@ -0,0 +1,427 @@
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 { JSX, useCallback, useEffect, useMemo, useRef, useState } from "react"
11
+ import dynamic from "next/dynamic"
12
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
13
+ import { useBasicTypeaheadTriggerMatch } from "@lexical/react/LexicalTypeaheadMenuPlugin"
14
+ import { TextNode } from "lexical"
15
+ import { createPortal } from "react-dom"
16
+
17
+ import { useEditorModal } from "../editor-hooks/use-modal"
18
+ import {
19
+ Command,
20
+ CommandGroup,
21
+ CommandItem,
22
+ CommandList,
23
+ } from "../ui/command"
24
+ import { logger } from "../lib/logger"
25
+
26
+ import { ComponentPickerOption } from "./picker/component-picker-option"
27
+
28
+ // Component wrapper để xử lý scroll tự động
29
+ function MenuContent({
30
+ options,
31
+ selectedIndex,
32
+ selectOptionAndCleanUp,
33
+ setHighlightedIndex,
34
+ }: {
35
+ options: Array<ComponentPickerOption>
36
+ selectedIndex: number | null
37
+ selectOptionAndCleanUp: (option: ComponentPickerOption) => void
38
+ setHighlightedIndex: (index: number) => void
39
+ }) {
40
+ const containerRef = useRef<HTMLDivElement | null>(null)
41
+
42
+ // Scroll to selected item when selectedIndex changes
43
+ useEffect(() => {
44
+ if (selectedIndex !== null && selectedIndex >= 0 && selectedIndex < options.length) {
45
+ // Sử dụng setTimeout để đảm bảo DOM đã được render và cmdk đã update selected state
46
+ const timeoutId = setTimeout(() => {
47
+ const container = containerRef.current
48
+ if (!container) {
49
+ logger.debug("MenuContent: No container ref", { selectedIndex })
50
+ return
51
+ }
52
+
53
+ // Tìm item được chọn bằng cách tìm element có data-selected="true" hoặc aria-selected="true"
54
+ let selectedItem = container.querySelector(
55
+ '[data-selected="true"], [aria-selected="true"]'
56
+ ) as HTMLElement
57
+
58
+ // Fallback: nếu không tìm thấy bằng attribute, tìm theo index
59
+ if (!selectedItem) {
60
+ const allItems = container.querySelectorAll('[data-slot="command-item"]')
61
+ selectedItem = allItems[selectedIndex] as HTMLElement
62
+ logger.debug("MenuContent: Found item by index", {
63
+ selectedIndex,
64
+ totalItems: allItems.length,
65
+ found: !!selectedItem,
66
+ })
67
+ } else {
68
+ logger.debug("MenuContent: Found item by attribute", { selectedIndex })
69
+ }
70
+
71
+ if (selectedItem) {
72
+ // Tìm ScrollArea viewport
73
+ const scrollContainer = container.querySelector(
74
+ '[data-radix-scroll-area-viewport]'
75
+ ) as HTMLElement
76
+
77
+ if (scrollContainer) {
78
+ const currentScrollTop = scrollContainer.scrollTop
79
+ const containerHeight = scrollContainer.clientHeight
80
+ const scrollHeight = scrollContainer.scrollHeight
81
+
82
+ // Tìm tất cả items để tính offset
83
+ const allItems = container.querySelectorAll('[data-slot="command-item"]')
84
+ let itemOffsetTop = 0
85
+
86
+ // Tính offset của item bằng cách cộng offsetHeight của tất cả items trước nó
87
+ for (let i = 0; i < selectedIndex && i < allItems.length; i++) {
88
+ const item = allItems[i] as HTMLElement
89
+ itemOffsetTop += item.offsetHeight || 32 // Default height nếu không có
90
+ }
91
+
92
+ // Thêm padding của CommandGroup nếu có
93
+ const commandGroup = selectedItem.closest('[data-slot="command-group"]') as HTMLElement
94
+ if (commandGroup) {
95
+ const groupStyle = window.getComputedStyle(commandGroup)
96
+ const paddingTop = parseFloat(groupStyle.paddingTop) || 0
97
+ itemOffsetTop += paddingTop
98
+ }
99
+
100
+ const itemHeight = selectedItem.offsetHeight || 32
101
+ const itemRect = selectedItem.getBoundingClientRect()
102
+ const containerRect = scrollContainer.getBoundingClientRect()
103
+
104
+ logger.debug("MenuContent: Scrolling to item", {
105
+ selectedIndex,
106
+ currentScrollTop,
107
+ containerHeight,
108
+ scrollHeight,
109
+ itemOffsetTop,
110
+ itemHeight,
111
+ itemTop: itemRect.top,
112
+ containerTop: containerRect.top,
113
+ itemVisible: itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom,
114
+ })
115
+
116
+ // Kiểm tra xem item có đang visible trong viewport không
117
+ const isItemVisible = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom
118
+ const itemNearTop = itemRect.top < containerRect.top + 20
119
+ const itemNearBottom = itemRect.bottom > containerRect.bottom - 20
120
+
121
+ // Tính toán scroll position để item nằm ở giữa container
122
+ let targetScrollTop = itemOffsetTop - containerHeight / 2 + itemHeight / 2
123
+
124
+ // Tính maxScroll dựa trên tổng chiều cao của tất cả items
125
+ // Nếu scrollHeight === containerHeight, tính toán dựa trên items
126
+ let calculatedMaxScroll = scrollHeight - containerHeight
127
+ if (calculatedMaxScroll <= 0 && allItems.length > 0) {
128
+ // Tính tổng chiều cao của tất cả items
129
+ let totalHeight = 0
130
+ for (let i = 0; i < allItems.length; i++) {
131
+ const item = allItems[i] as HTMLElement
132
+ totalHeight += item.offsetHeight || 32
133
+ }
134
+ // Thêm padding của CommandGroup
135
+ if (commandGroup) {
136
+ const groupStyle = window.getComputedStyle(commandGroup)
137
+ const paddingTop = parseFloat(groupStyle.paddingTop) || 0
138
+ const paddingBottom = parseFloat(groupStyle.paddingBottom) || 0
139
+ totalHeight += paddingTop + paddingBottom
140
+ }
141
+ calculatedMaxScroll = Math.max(0, totalHeight - containerHeight)
142
+ }
143
+
144
+ // Đảm bảo không scroll quá đầu hoặc cuối
145
+ targetScrollTop = Math.max(0, Math.min(targetScrollTop, calculatedMaxScroll))
146
+
147
+ // Scroll nếu item không visible hoặc gần edge
148
+ const needsScroll = !isItemVisible || itemNearTop || itemNearBottom
149
+
150
+ if (needsScroll && Math.abs(targetScrollTop - currentScrollTop) > 1 && calculatedMaxScroll > 0) {
151
+ scrollContainer.scrollTo({
152
+ top: targetScrollTop,
153
+ behavior: "smooth",
154
+ })
155
+
156
+ logger.debug("MenuContent: Scrolled", {
157
+ selectedIndex,
158
+ fromScrollTop: currentScrollTop,
159
+ toScrollTop: targetScrollTop,
160
+ itemOffsetTop,
161
+ calculatedMaxScroll,
162
+ scrollHeight,
163
+ containerHeight,
164
+ needsScroll,
165
+ })
166
+ } else {
167
+ logger.debug("MenuContent: No scroll needed", {
168
+ selectedIndex,
169
+ currentScrollTop,
170
+ targetScrollTop,
171
+ itemOffsetTop,
172
+ isItemVisible,
173
+ itemNearTop,
174
+ itemNearBottom,
175
+ scrollHeight,
176
+ containerHeight,
177
+ calculatedMaxScroll,
178
+ })
179
+ }
180
+ } else {
181
+ // Fallback: scroll directly
182
+ logger.debug("MenuContent: No scroll container, scrolling directly", {
183
+ selectedIndex,
184
+ })
185
+ selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" })
186
+ }
187
+ } else {
188
+ logger.warn("MenuContent: Could not find selected item", { selectedIndex })
189
+ }
190
+ }, 10) // Tăng delay một chút để đảm bảo cmdk đã update state
191
+
192
+ return () => clearTimeout(timeoutId)
193
+ }
194
+ }, [selectedIndex, options.length])
195
+
196
+ return (
197
+ <div ref={containerRef}>
198
+ <Command
199
+ onKeyDown={(e) => {
200
+ if (e.key === "ArrowUp") {
201
+ e.preventDefault()
202
+ setHighlightedIndex(
203
+ selectedIndex !== null
204
+ ? (selectedIndex - 1 + options.length) % options.length
205
+ : options.length - 1
206
+ )
207
+ } else if (e.key === "ArrowDown") {
208
+ e.preventDefault()
209
+ setHighlightedIndex(
210
+ selectedIndex !== null ? (selectedIndex + 1) % options.length : 0
211
+ )
212
+ }
213
+ }}
214
+ >
215
+ <CommandList>
216
+ <CommandGroup>
217
+ {options.map((option) => (
218
+ <CommandItem
219
+ key={option.key}
220
+ value={option.title}
221
+ onSelect={() => {
222
+ selectOptionAndCleanUp(option)
223
+ }}
224
+ className="editor-flex-row-center editor-flex-row-center--pointer"
225
+ >
226
+ {option.icon}
227
+ <span>{option.title}</span>
228
+ </CommandItem>
229
+ ))}
230
+ </CommandGroup>
231
+ </CommandList>
232
+ </Command>
233
+ </div>
234
+ )
235
+ }
236
+
237
+ const LexicalTypeaheadMenuPlugin = dynamic(
238
+ () =>
239
+ import("@lexical/react/LexicalTypeaheadMenuPlugin").then(
240
+ (mod) => mod.LexicalTypeaheadMenuPlugin<ComponentPickerOption>
241
+ ),
242
+ { ssr: false }
243
+ )
244
+
245
+ export function ComponentPickerMenuPlugin({
246
+ baseOptions = [],
247
+ dynamicOptionsFn,
248
+ }: {
249
+ baseOptions?: Array<ComponentPickerOption>
250
+ dynamicOptionsFn?: ({
251
+ queryString,
252
+ }: {
253
+ queryString: string
254
+ }) => Array<ComponentPickerOption>
255
+ }): JSX.Element {
256
+ const [editor] = useLexicalComposerContext()
257
+ const [modal, showModal] = useEditorModal()
258
+ const [queryString, setQueryString] = useState<string | null>(null)
259
+
260
+ logger.debug("ComponentPickerMenuPlugin initialized", {
261
+ baseOptionsCount: baseOptions.length,
262
+ hasDynamicOptionsFn: !!dynamicOptionsFn,
263
+ })
264
+
265
+ const checkForTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
266
+ minLength: 0,
267
+ })
268
+
269
+ const options = useMemo(() => {
270
+ if (!queryString) {
271
+ logger.debug("ComponentPickerMenuPlugin: No query string, returning baseOptions", {
272
+ baseOptionsCount: baseOptions.length,
273
+ baseOptionsTitles: baseOptions.map((opt) => opt.title),
274
+ })
275
+ return baseOptions
276
+ }
277
+
278
+ logger.debug("ComponentPickerMenuPlugin: Filtering options", {
279
+ queryString,
280
+ baseOptionsCount: baseOptions.length,
281
+ })
282
+
283
+ const regex = new RegExp(queryString, "i")
284
+
285
+ const filtered = [
286
+ ...(dynamicOptionsFn?.({ queryString }) || []),
287
+ ...baseOptions.filter(
288
+ (option) =>
289
+ regex.test(option.title) ||
290
+ option.keywords.some((keyword) => regex.test(keyword))
291
+ ),
292
+ ]
293
+
294
+ logger.debug("ComponentPickerMenuPlugin: Filtered options", {
295
+ filteredCount: filtered.length,
296
+ filteredTitles: filtered.map((opt) => opt.title),
297
+ })
298
+
299
+ return filtered
300
+ }, [baseOptions, dynamicOptionsFn, queryString])
301
+
302
+ const onSelectOption = useCallback(
303
+ (
304
+ selectedOption: ComponentPickerOption,
305
+ nodeToRemove: TextNode | null,
306
+ closeMenu: () => void,
307
+ matchingString: string
308
+ ) => {
309
+ editor.update(() => {
310
+ nodeToRemove?.remove()
311
+ selectedOption.onSelect(matchingString, editor, showModal)
312
+ closeMenu()
313
+ })
314
+ },
315
+ [editor, showModal]
316
+ )
317
+
318
+ return (
319
+ <>
320
+ {modal}
321
+ <LexicalTypeaheadMenuPlugin
322
+ onQueryChange={(query) => {
323
+ logger.debug("ComponentPickerMenuPlugin: Query changed", { query })
324
+ setQueryString(query)
325
+ }}
326
+ onSelectOption={onSelectOption}
327
+ triggerFn={(text, editor) => {
328
+ const match = checkForTriggerMatch(text, editor)
329
+ logger.debug("ComponentPickerMenuPlugin: Trigger check", {
330
+ text,
331
+ hasMatch: !!match,
332
+ matchString: match?.matchingString,
333
+ })
334
+ return match
335
+ }}
336
+ options={options}
337
+ menuRenderFn={(
338
+ anchorElementRef,
339
+ { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }
340
+ ) => {
341
+ logger.debug("ComponentPickerMenuPlugin: menuRenderFn called", {
342
+ hasAnchorElement: !!anchorElementRef.current,
343
+ optionsLength: options.length,
344
+ selectedIndex,
345
+ })
346
+
347
+ if (!anchorElementRef.current) {
348
+ logger.warn("ComponentPickerMenuPlugin: No anchor element", {
349
+ anchorElementRef: anchorElementRef.current,
350
+ })
351
+ return null
352
+ }
353
+
354
+ if (!options.length) {
355
+ logger.warn("ComponentPickerMenuPlugin: No options available", {
356
+ optionsLength: options.length,
357
+ })
358
+ return null
359
+ }
360
+
361
+ const anchorRect = anchorElementRef.current.getBoundingClientRect()
362
+ const menuHeight = Math.min(options.length * 40 + 16, 300)
363
+ const viewportHeight = window.innerHeight
364
+ const viewportWidth = window.innerWidth
365
+
366
+ // Với fixed positioning, sử dụng getBoundingClientRect() đã trả về vị trí relative to viewport
367
+ // Không cần cộng window.scrollY vì fixed position đã relative to viewport
368
+ let top = anchorRect.bottom + 4
369
+ if (top + menuHeight > viewportHeight) {
370
+ top = anchorRect.top - menuHeight - 4
371
+ }
372
+
373
+ // Đảm bảo menu không bị tràn ra ngoài viewport
374
+ top = Math.max(4, Math.min(top, viewportHeight - menuHeight - 4))
375
+
376
+ // Tính toán left position, đảm bảo menu không bị tràn ra ngoài viewport
377
+ let left = anchorRect.left
378
+ const menuWidth = 250
379
+ if (left + menuWidth > viewportWidth) {
380
+ left = viewportWidth - menuWidth - 4
381
+ }
382
+ left = Math.max(4, left)
383
+
384
+ const menuPosition = {
385
+ top: `${top}px`,
386
+ left: `${left}px`,
387
+ }
388
+
389
+ logger.debug("ComponentPickerMenuPlugin: Rendering menu", {
390
+ menuPosition,
391
+ anchorRect: {
392
+ top: anchorRect.top,
393
+ left: anchorRect.left,
394
+ bottom: anchorRect.bottom,
395
+ right: anchorRect.right,
396
+ width: anchorRect.width,
397
+ height: anchorRect.height,
398
+ },
399
+ viewport: {
400
+ height: viewportHeight,
401
+ width: viewportWidth,
402
+ },
403
+ optionsCount: options.length,
404
+ })
405
+
406
+ return createPortal(
407
+ <div
408
+ className="editor-component-picker-menu"
409
+ style={{
410
+ top: `${top}px`,
411
+ left: `${left}px`,
412
+ }}
413
+ >
414
+ <MenuContent
415
+ options={options}
416
+ selectedIndex={selectedIndex}
417
+ selectOptionAndCleanUp={selectOptionAndCleanUp}
418
+ setHighlightedIndex={setHighlightedIndex}
419
+ />
420
+ </div>,
421
+ document.body
422
+ )
423
+ }}
424
+ />
425
+ </>
426
+ )
427
+ }