@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.
- package/dist/editor-x/editor.cjs +33121 -0
- package/dist/editor-x/editor.cjs.map +1 -0
- package/dist/editor-x/editor.css +2854 -0
- package/dist/editor-x/editor.css.map +1 -0
- package/dist/editor-x/editor.d.cts +12 -0
- package/dist/editor-x/editor.d.ts +12 -0
- package/dist/editor-x/editor.js +33095 -0
- package/dist/editor-x/editor.js.map +1 -0
- package/dist/index.cjs +33210 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2854 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +33183 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
- package/src/components/lexical-editor.tsx +123 -0
- package/src/context/editor-container-context.tsx +29 -0
- package/src/context/priority-image-context.tsx +7 -0
- package/src/context/toolbar-context.tsx +60 -0
- package/src/context/uploads-context.tsx +53 -0
- package/src/editor-hooks/use-debounce.ts +80 -0
- package/src/editor-hooks/use-modal.tsx +64 -0
- package/src/editor-hooks/use-report.ts +57 -0
- package/src/editor-hooks/use-update-toolbar.ts +41 -0
- package/src/editor-ui/broken-image.tsx +18 -0
- package/src/editor-ui/caption-composer.tsx +45 -0
- package/src/editor-ui/code-button.tsx +75 -0
- package/src/editor-ui/color-picker.tsx +2010 -0
- package/src/editor-ui/content-editable.tsx +37 -0
- package/src/editor-ui/hooks/use-image-caption-controls.ts +118 -0
- package/src/editor-ui/hooks/use-image-node-interactions.ts +245 -0
- package/src/editor-ui/hooks/use-responsive-image-dimensions.ts +202 -0
- package/src/editor-ui/image-component.tsx +321 -0
- package/src/editor-ui/image-placeholder.tsx +57 -0
- package/src/editor-ui/image-resizer.tsx +499 -0
- package/src/editor-ui/image-sizing.ts +120 -0
- package/src/editor-ui/lazy-image.tsx +136 -0
- package/src/editor-x/editor.tsx +117 -0
- package/src/editor-x/nodes.ts +79 -0
- package/src/editor-x/plugins.tsx +380 -0
- package/src/hooks/use-click-outside.ts +27 -0
- package/src/hooks/use-element-size.ts +54 -0
- package/src/hooks/use-header-height.ts +95 -0
- package/src/hooks/use-isomorphic-layout-effect.ts +4 -0
- package/src/index.ts +4 -0
- package/src/lib/logger.ts +6 -0
- package/src/lib/utils.ts +19 -0
- package/src/nodes/autocomplete-node.tsx +94 -0
- package/src/nodes/embeds/tweet-node.tsx +224 -0
- package/src/nodes/embeds/youtube-node.tsx +519 -0
- package/src/nodes/emoji-node.tsx +83 -0
- package/src/nodes/image-node.tsx +328 -0
- package/src/nodes/keyword-node.tsx +58 -0
- package/src/nodes/layout-container-node.tsx +128 -0
- package/src/nodes/layout-item-node.tsx +118 -0
- package/src/nodes/list-with-color-node.tsx +160 -0
- package/src/nodes/mention-node.ts +122 -0
- package/src/plugins/actions/actions-plugin.tsx +3 -0
- package/src/plugins/actions/character-limit-plugin.tsx +27 -0
- package/src/plugins/actions/clear-editor-plugin.tsx +70 -0
- package/src/plugins/actions/counter-character-plugin.tsx +80 -0
- package/src/plugins/actions/edit-mode-toggle-plugin.tsx +49 -0
- package/src/plugins/actions/import-export-plugin.tsx +61 -0
- package/src/plugins/actions/markdown-toggle-plugin.tsx +78 -0
- package/src/plugins/actions/max-length-plugin.tsx +59 -0
- package/src/plugins/actions/share-content-plugin.tsx +72 -0
- package/src/plugins/actions/speech-to-text-plugin.tsx +159 -0
- package/src/plugins/actions/tree-view-plugin.tsx +63 -0
- package/src/plugins/align-plugin.tsx +86 -0
- package/src/plugins/auto-link-plugin.tsx +34 -0
- package/src/plugins/autocomplete-plugin.tsx +2574 -0
- package/src/plugins/code-action-menu-plugin.tsx +240 -0
- package/src/plugins/code-highlight-plugin.tsx +22 -0
- package/src/plugins/component-picker-menu-plugin.tsx +427 -0
- package/src/plugins/context-menu-plugin.tsx +311 -0
- package/src/plugins/drag-drop-paste-plugin.tsx +52 -0
- package/src/plugins/draggable-block-plugin.tsx +50 -0
- package/src/plugins/embeds/auto-embed-plugin.tsx +324 -0
- package/src/plugins/embeds/twitter-plugin.tsx +45 -0
- package/src/plugins/embeds/youtube-plugin.tsx +84 -0
- package/src/plugins/emoji-picker-plugin.tsx +206 -0
- package/src/plugins/emojis-plugin.tsx +84 -0
- package/src/plugins/floating-link-editor-plugin.tsx +791 -0
- package/src/plugins/floating-text-format-plugin.tsx +710 -0
- package/src/plugins/images-plugin.tsx +671 -0
- package/src/plugins/keywords-plugin.tsx +59 -0
- package/src/plugins/layout-plugin.tsx +658 -0
- package/src/plugins/link-plugin.tsx +18 -0
- package/src/plugins/list-color-plugin.tsx +178 -0
- package/src/plugins/list-max-indent-level-plugin.tsx +85 -0
- package/src/plugins/mentions-plugin.tsx +714 -0
- package/src/plugins/picker/alignment-picker-plugin.tsx +40 -0
- package/src/plugins/picker/bulleted-list-picker-plugin.tsx +14 -0
- package/src/plugins/picker/check-list-picker-plugin.tsx +14 -0
- package/src/plugins/picker/code-picker-plugin.tsx +30 -0
- package/src/plugins/picker/columns-layout-picker-plugin.tsx +16 -0
- package/src/plugins/picker/component-picker-option.tsx +47 -0
- package/src/plugins/picker/divider-picker-plugin.tsx +14 -0
- package/src/plugins/picker/embeds-picker-plugin.tsx +24 -0
- package/src/plugins/picker/heading-picker-plugin.tsx +32 -0
- package/src/plugins/picker/image-picker-plugin.tsx +16 -0
- package/src/plugins/picker/numbered-list-picker-plugin.tsx +14 -0
- package/src/plugins/picker/paragraph-picker-plugin.tsx +20 -0
- package/src/plugins/picker/quote-picker-plugin.tsx +21 -0
- package/src/plugins/picker/table-picker-plugin.tsx +56 -0
- package/src/plugins/tab-focus-plugin.tsx +66 -0
- package/src/plugins/table-column-resizer-plugin.tsx +309 -0
- package/src/plugins/table-plugin.tsx +299 -0
- package/src/plugins/toolbar/block-format/block-format-data.tsx +69 -0
- package/src/plugins/toolbar/block-format/format-bulleted-list.tsx +40 -0
- package/src/plugins/toolbar/block-format/format-check-list.tsx +40 -0
- package/src/plugins/toolbar/block-format/format-code-block.tsx +45 -0
- package/src/plugins/toolbar/block-format/format-heading.tsx +34 -0
- package/src/plugins/toolbar/block-format/format-list-with-marker.tsx +74 -0
- package/src/plugins/toolbar/block-format/format-numbered-list.tsx +40 -0
- package/src/plugins/toolbar/block-format/format-paragraph.tsx +31 -0
- package/src/plugins/toolbar/block-format/format-quote.tsx +32 -0
- package/src/plugins/toolbar/block-format-toolbar-plugin.tsx +117 -0
- package/src/plugins/toolbar/block-insert/insert-columns-layout.tsx +32 -0
- package/src/plugins/toolbar/block-insert/insert-embeds.tsx +31 -0
- package/src/plugins/toolbar/block-insert/insert-horizontal-rule.tsx +30 -0
- package/src/plugins/toolbar/block-insert/insert-image.tsx +32 -0
- package/src/plugins/toolbar/block-insert/insert-table.tsx +32 -0
- package/src/plugins/toolbar/block-insert-plugin.tsx +30 -0
- package/src/plugins/toolbar/clear-formatting-toolbar-plugin.tsx +92 -0
- package/src/plugins/toolbar/code-language-toolbar-plugin.tsx +121 -0
- package/src/plugins/toolbar/element-format-toolbar-plugin.tsx +251 -0
- package/src/plugins/toolbar/font-background-toolbar-plugin.tsx +179 -0
- package/src/plugins/toolbar/font-color-toolbar-plugin.tsx +101 -0
- package/src/plugins/toolbar/font-family-toolbar-plugin.tsx +91 -0
- package/src/plugins/toolbar/font-format-toolbar-plugin.tsx +85 -0
- package/src/plugins/toolbar/font-size-toolbar-plugin.tsx +177 -0
- package/src/plugins/toolbar/history-toolbar-plugin.tsx +87 -0
- package/src/plugins/toolbar/link-toolbar-plugin.tsx +90 -0
- package/src/plugins/toolbar/subsuper-toolbar-plugin.tsx +69 -0
- package/src/plugins/toolbar/toolbar-plugin.tsx +66 -0
- package/src/plugins/typing-pref-plugin.tsx +118 -0
- package/src/shared/can-use-dom.ts +4 -0
- package/src/shared/environment.ts +47 -0
- package/src/shared/invariant.ts +16 -0
- package/src/shared/use-layout-effect.ts +12 -0
- package/src/themes/_mixins.scss +107 -0
- package/src/themes/_variables.scss +33 -0
- package/src/themes/editor-theme.scss +622 -0
- package/src/themes/editor-theme.ts +118 -0
- package/src/themes/plugins.scss +1180 -0
- package/src/themes/ui-components.scss +936 -0
- package/src/transformers/markdown-emoji-transformer.ts +20 -0
- package/src/transformers/markdown-hr-transformer.ts +28 -0
- package/src/transformers/markdown-image-transformer.ts +31 -0
- package/src/transformers/markdown-list-transformer.ts +51 -0
- package/src/transformers/markdown-table-transformer.ts +200 -0
- package/src/transformers/markdown-tweet-transformer.ts +26 -0
- package/src/ui/button-group.tsx +10 -0
- package/src/ui/button.tsx +29 -0
- package/src/ui/collapsible.tsx +67 -0
- package/src/ui/command.tsx +48 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/flex.tsx +38 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +20 -0
- package/src/ui/popover.tsx +128 -0
- package/src/ui/scroll-area.tsx +17 -0
- package/src/ui/select.tsx +171 -0
- package/src/ui/separator.tsx +20 -0
- package/src/ui/slider.tsx +14 -0
- package/src/ui/slot.tsx +3 -0
- package/src/ui/tabs.tsx +87 -0
- package/src/ui/toggle-group.tsx +109 -0
- package/src/ui/toggle.tsx +28 -0
- package/src/ui/tooltip.tsx +28 -0
- package/src/ui/typography.tsx +44 -0
- package/src/utils/doc-serialization.ts +68 -0
- package/src/utils/emoji-list.ts +16604 -0
- package/src/utils/get-dom-range-rect.ts +20 -0
- package/src/utils/get-selected-node.ts +20 -0
- package/src/utils/is-mobile-width.ts +0 -0
- package/src/utils/set-floating-elem-position-for-link-editor.ts +39 -0
- package/src/utils/set-floating-elem-position.ts +44 -0
- package/src/utils/swipe.ts +119 -0
- 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
|
+
}
|