@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,671 @@
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, useRef, useState } from "react"
11
+ import * as React from "react"
12
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
13
+ import { $wrapNodeInElement, mergeRegister } from "@lexical/utils"
14
+ import {
15
+ $createParagraphNode,
16
+ $createRangeSelection,
17
+ $getSelection,
18
+ $insertNodes,
19
+ $isNodeSelection,
20
+ $isRootOrShadowRoot,
21
+ $setSelection,
22
+ COMMAND_PRIORITY_EDITOR,
23
+ COMMAND_PRIORITY_HIGH,
24
+ COMMAND_PRIORITY_LOW,
25
+ createCommand,
26
+ DRAGOVER_COMMAND,
27
+ DRAGSTART_COMMAND,
28
+ DROP_COMMAND,
29
+ LexicalCommand,
30
+ LexicalEditor,
31
+ } from "lexical"
32
+
33
+ import {
34
+ $createImageNode,
35
+ $isImageNode,
36
+ ImageNode,
37
+ ImagePayload,
38
+ } from "../nodes/image-node"
39
+ import { CAN_USE_DOM } from "../shared/can-use-dom"
40
+ import { Button } from "../ui/button"
41
+ import { DialogFooter } from "../ui/dialog"
42
+ import { Input } from "../ui/input"
43
+ import { Label } from "../ui/label"
44
+ import {
45
+ Tabs,
46
+ TabsContent,
47
+ TabsList,
48
+ TabsTrigger,
49
+ } from "../ui/tabs"
50
+ import { useEditorUploads, FolderNode } from "../context/uploads-context"
51
+ import { Loader2, Folder, ChevronRight } from "lucide-react"
52
+ import Image from "next/image"
53
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"
54
+ import { TypographyP, TypographySpanSmallMuted, IconSize } from "../ui/typography"
55
+
56
+ export type InsertImagePayload = Readonly<ImagePayload>
57
+
58
+ const getDOMSelection = (targetWindow: Window | null): Selection | null =>
59
+ CAN_USE_DOM ? (targetWindow || window).getSelection() : null
60
+
61
+ export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
62
+ createCommand("INSERT_IMAGE_COMMAND")
63
+
64
+ export function InsertImageUriDialogBody({
65
+ onClick,
66
+ }: {
67
+ onClick: (payload: InsertImagePayload) => void
68
+ }) {
69
+ const [src, setSrc] = useState("")
70
+ const [altText, setAltText] = useState("")
71
+
72
+ const isDisabled = src === ""
73
+
74
+ return (
75
+ <div className="editor-form-grid">
76
+ <div className="editor-form-item">
77
+ <Label htmlFor="image-url">Image URL</Label>
78
+ <Input
79
+ id="image-url"
80
+ placeholder="i.e. https://source.unsplash.com/random"
81
+ onChange={(e) => setSrc(e.target.value)}
82
+ value={src}
83
+ data-test-id="image-modal-url-input"
84
+ />
85
+ </div>
86
+ <div className="editor-form-item">
87
+ <Label htmlFor="alt-text">Alt Text</Label>
88
+ <Input
89
+ id="alt-text"
90
+ placeholder="Random unsplash image"
91
+ onChange={(e) => setAltText(e.target.value)}
92
+ value={altText}
93
+ data-test-id="image-modal-alt-text-input"
94
+ />
95
+ </div>
96
+ <DialogFooter>
97
+ <Button
98
+ type="submit"
99
+ disabled={isDisabled}
100
+ onClick={() => onClick({ altText, src })}
101
+ data-test-id="image-modal-confirm-btn"
102
+ >
103
+ Confirm
104
+ </Button>
105
+ </DialogFooter>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ export function InsertImageUploadedDialogBody({
111
+ onClick,
112
+ }: {
113
+ onClick: (payload: InsertImagePayload) => void
114
+ }) {
115
+ const [src, setSrc] = useState("")
116
+ const [altText, setAltText] = useState("")
117
+
118
+ const isDisabled = src === ""
119
+
120
+ const loadImage = (files: FileList | null) => {
121
+ const reader = new FileReader()
122
+ reader.onload = function () {
123
+ if (typeof reader.result === "string") {
124
+ setSrc(reader.result)
125
+ }
126
+ return ""
127
+ }
128
+ if (files && files[0]) {
129
+ reader.readAsDataURL(files[0])
130
+ }
131
+ }
132
+
133
+ return (
134
+ <div className="editor-form-grid">
135
+ <div className="editor-form-item">
136
+ <Label htmlFor="image-upload">Image Upload</Label>
137
+ <Input
138
+ id="image-upload"
139
+ type="file"
140
+ onChange={(e) => loadImage(e.target.files)}
141
+ accept="image/*"
142
+ data-test-id="image-modal-file-upload"
143
+ />
144
+ </div>
145
+ <div className="editor-form-item">
146
+ <Label htmlFor="alt-text">Alt Text</Label>
147
+ <Input
148
+ id="alt-text"
149
+ placeholder="Descriptive alternative text"
150
+ onChange={(e) => setAltText(e.target.value)}
151
+ value={altText}
152
+ data-test-id="image-modal-alt-text-input"
153
+ />
154
+ </div>
155
+ <DialogFooter>
156
+ <Button
157
+ type="submit"
158
+ disabled={isDisabled}
159
+ onClick={() => onClick({ altText, src })}
160
+ data-test-id="image-modal-file-upload-btn"
161
+ >
162
+ Confirm
163
+ </Button>
164
+ </DialogFooter>
165
+ </div>
166
+ )
167
+ }
168
+
169
+ // Component để hiển thị folder tree cho image picker
170
+ function ImagePickerFolderTree({
171
+ folder,
172
+ level = 0,
173
+ openFolders,
174
+ setOpenFolders,
175
+ selectedImage,
176
+ onImageSelect,
177
+ }: {
178
+ folder: FolderNode
179
+ level?: number
180
+ openFolders: Set<string>
181
+ setOpenFolders: React.Dispatch<React.SetStateAction<Set<string>>>
182
+ selectedImage: string | null
183
+ onImageSelect: (imageUrl: string, originalName: string) => void
184
+ }) {
185
+ const hasContent = folder.images.length > 0 || folder.subfolders.length > 0
186
+ const isOpen = openFolders.has(folder.path)
187
+
188
+ const handleOpenChange = React.useCallback(
189
+ (open: boolean) => {
190
+ if (open) {
191
+ setOpenFolders((prev) => {
192
+ const newSet = new Set(prev)
193
+ newSet.add(folder.path)
194
+ return newSet
195
+ })
196
+ } else {
197
+ setOpenFolders((prev) => {
198
+ const newSet = new Set(prev)
199
+ newSet.delete(folder.path)
200
+ return newSet
201
+ })
202
+ }
203
+ },
204
+ [folder.path, setOpenFolders]
205
+ )
206
+
207
+ if (!hasContent) return null
208
+
209
+ return (
210
+ <Collapsible key={folder.path} open={isOpen} onOpenChange={handleOpenChange} className="editor-mb-2">
211
+ <CollapsibleTrigger className="editor-folder-tree-trigger">
212
+ <IconSize size="xs" className={`editor-transition-transform editor-shrink-0 ${isOpen ? "editor-rotate-90" : ""}`}>
213
+ <ChevronRight />
214
+ </IconSize>
215
+ <IconSize size="xs" className="editor-shrink-0 editor-text-muted-foreground">
216
+ <Folder />
217
+ </IconSize>
218
+ <TypographyP className="editor-truncate">{folder.name}</TypographyP>
219
+ <TypographySpanSmallMuted className="editor-ml-auto editor-shrink-0">
220
+ {`${folder.images.length} hình${folder.subfolders.length > 0 ? `, ${folder.subfolders.length} thư mục` : ""}`}
221
+ </TypographySpanSmallMuted>
222
+ </CollapsibleTrigger>
223
+ <CollapsibleContent className="editor-ml-4 editor-mt-1">
224
+ {/* Render images in this folder */}
225
+ {folder.images.length > 0 && (
226
+ <div className="editor-image-grid">
227
+ {folder.images.map((image) => (
228
+ <button
229
+ key={image.fileName}
230
+ type="button"
231
+ onClick={() => onImageSelect(image.url, image.originalName)}
232
+ onDoubleClick={() => {
233
+ // Double-click để insert ngay
234
+ onImageSelect(image.url, image.originalName)
235
+ // Trigger confirm sau một chút để state được update
236
+ setTimeout(() => {
237
+ const event = new Event("confirm-image-insert", { bubbles: true })
238
+ document.dispatchEvent(event)
239
+ }, 100)
240
+ }}
241
+ className={`editor-image-btn ${
242
+ selectedImage === image.url
243
+ ? "editor-image-btn--selected"
244
+ : ""
245
+ }`}
246
+ title={`${image.originalName} - Double-click để chèn ngay`}
247
+ >
248
+ <Image
249
+ src={image.url}
250
+ alt={image.originalName}
251
+ title={image.originalName}
252
+ fill
253
+ className="editor-object-cover editor-article-image editor-article-image-ux-impr editor-article-image-new editor-expandable"
254
+ sizes="(max-width: 768px) 25vw, 20vw"
255
+ unoptimized
256
+ loading="eager"
257
+ />
258
+ </button>
259
+ ))}
260
+ </div>
261
+ )}
262
+ {/* Render subfolders */}
263
+ {folder.subfolders.map((subfolder) => (
264
+ <ImagePickerFolderTree
265
+ key={subfolder.path}
266
+ folder={subfolder}
267
+ level={level + 1}
268
+ openFolders={openFolders}
269
+ setOpenFolders={setOpenFolders}
270
+ selectedImage={selectedImage}
271
+ onImageSelect={onImageSelect}
272
+ />
273
+ ))}
274
+ </CollapsibleContent>
275
+ </Collapsible>
276
+ )
277
+ }
278
+
279
+ export function InsertImageUploadsDialogBody({
280
+ onClick,
281
+ }: {
282
+ onClick: (payload: InsertImagePayload) => void
283
+ }) {
284
+ const [selectedImage, setSelectedImage] = useState<string | null>(null)
285
+ const [altText, setAltText] = useState("")
286
+ const [openFolders, setOpenFolders] = useState<Set<string>>(new Set())
287
+
288
+ const { folderTree, isLoading } = useEditorUploads()
289
+
290
+ const isDisabled = !selectedImage
291
+
292
+ const handleImageSelect = React.useCallback((imageUrl: string, originalName: string) => {
293
+ setSelectedImage(imageUrl)
294
+ setAltText((prev) => prev || originalName)
295
+ }, [])
296
+
297
+ const handleConfirm = React.useCallback(() => {
298
+ if (selectedImage) {
299
+ // Đảm bảo URL là absolute nếu là relative URL từ uploads
300
+ let imageUrl = selectedImage
301
+ if (imageUrl.startsWith("/api/uploads")) {
302
+ // Relative URL từ uploads - giữ nguyên vì nó sẽ hoạt động với same-origin
303
+ imageUrl = selectedImage
304
+ } else if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://") && !imageUrl.startsWith("data:")) {
305
+ // Nếu không phải absolute URL và không phải data URL, thêm protocol
306
+ imageUrl = `https://${imageUrl}`
307
+ }
308
+
309
+ onClick({ altText: altText || "", src: imageUrl })
310
+ }
311
+ }, [selectedImage, altText, onClick])
312
+
313
+ // Listen for double-click confirm event
314
+ React.useEffect(() => {
315
+ const handleDoubleClickConfirm = () => {
316
+ // Use a ref to get the latest selectedImage
317
+ setTimeout(() => {
318
+ handleConfirm()
319
+ }, 50)
320
+ }
321
+
322
+ document.addEventListener("confirm-image-insert", handleDoubleClickConfirm)
323
+ return () => {
324
+ document.removeEventListener("confirm-image-insert", handleDoubleClickConfirm)
325
+ }
326
+ }, [handleConfirm])
327
+
328
+ // Auto-expand root folders on mount
329
+ React.useEffect(() => {
330
+ if (folderTree && folderTree.subfolders.length > 0) {
331
+ setOpenFolders(new Set(folderTree.subfolders.map((f) => f.path)))
332
+ }
333
+ }, [folderTree])
334
+
335
+ return (
336
+ <div className="editor-form-grid">
337
+ <div className="editor-form-item">
338
+ <Label>Chọn hình ảnh từ thư viện</Label>
339
+ {isLoading ? (
340
+ <div className="editor-flex-center-justify-py-8">
341
+ <Loader2 className="editor-loader" />
342
+ </div>
343
+ ) : !folderTree || (folderTree.subfolders.length === 0 && folderTree.images.length === 0) ? (
344
+ <div className="editor-empty-state">
345
+ <TypographySpanSmallMuted>Chưa có hình ảnh nào được upload</TypographySpanSmallMuted>
346
+ </div>
347
+ ) : (
348
+ <div className="editor-scroll-area">
349
+ {/* Render root level images if any */}
350
+ {folderTree.images.length > 0 && (
351
+ <div className="editor-mb-3">
352
+ <TypographySpanSmallMuted className="editor-mb-2 editor-px-1">Root</TypographySpanSmallMuted>
353
+ <div className="editor-image-grid">
354
+ {folderTree.images.map((image) => (
355
+ <button
356
+ key={image.fileName}
357
+ type="button"
358
+ onClick={() => handleImageSelect(image.url, image.originalName)}
359
+ onDoubleClick={() => {
360
+ handleImageSelect(image.url, image.originalName)
361
+ setTimeout(() => {
362
+ const event = new Event("confirm-image-insert", { bubbles: true })
363
+ document.dispatchEvent(event)
364
+ }, 100)
365
+ }}
366
+ className={`editor-image-btn ${
367
+ selectedImage === image.url
368
+ ? "editor-image-btn--selected"
369
+ : ""
370
+ }`}
371
+ title={`${image.originalName} - Double-click để chèn ngay`}
372
+ >
373
+ <Image
374
+ src={image.url}
375
+ alt={image.originalName}
376
+ title={image.originalName}
377
+ fill
378
+ className="editor-object-cover editor-article-image editor-article-image-ux-impr editor-article-image-new editor-expandable"
379
+ sizes="(max-width: 768px) 25vw, 20vw"
380
+ unoptimized
381
+ loading="eager"
382
+ />
383
+ </button>
384
+ ))}
385
+ </div>
386
+ </div>
387
+ )}
388
+ {/* Render folder tree */}
389
+ {folderTree.subfolders.map((subfolder) => (
390
+ <ImagePickerFolderTree
391
+ key={subfolder.path}
392
+ folder={subfolder}
393
+ level={0}
394
+ openFolders={openFolders}
395
+ setOpenFolders={setOpenFolders}
396
+ selectedImage={selectedImage}
397
+ onImageSelect={handleImageSelect}
398
+ />
399
+ ))}
400
+ </div>
401
+ )}
402
+ </div>
403
+ {selectedImage && (
404
+ <div className="editor-form-item">
405
+ <Label htmlFor="alt-text-uploads">Alt Text</Label>
406
+ <Input
407
+ id="alt-text-uploads"
408
+ placeholder="Mô tả hình ảnh"
409
+ onChange={(e) => setAltText(e.target.value)}
410
+ value={altText}
411
+ data-test-id="image-modal-uploads-alt-text-input"
412
+ />
413
+ </div>
414
+ )}
415
+ <DialogFooter>
416
+ <Button
417
+ type="submit"
418
+ disabled={isDisabled}
419
+ onClick={handleConfirm}
420
+ data-test-id="image-modal-uploads-confirm-btn"
421
+ >
422
+ Chèn hình ảnh
423
+ </Button>
424
+ </DialogFooter>
425
+ </div>
426
+ )
427
+ }
428
+
429
+ export function InsertImageDialog({
430
+ activeEditor,
431
+ onClose,
432
+ onInsert,
433
+ activeTab = "uploads",
434
+ }: {
435
+ activeEditor: LexicalEditor
436
+ onClose: () => void
437
+ onInsert?: (payload: InsertImagePayload, close: () => void) => void
438
+ activeTab?: string
439
+ }): JSX.Element {
440
+ const hasModifier = useRef(false)
441
+
442
+ useEffect(() => {
443
+ hasModifier.current = false
444
+ const handler = (e: KeyboardEvent) => {
445
+ hasModifier.current = e.altKey
446
+ }
447
+ document.addEventListener("keydown", handler)
448
+ return () => {
449
+ document.removeEventListener("keydown", handler)
450
+ }
451
+ }, [activeEditor])
452
+
453
+ const onClick = (payload: InsertImagePayload) => {
454
+ if (onInsert) {
455
+ onInsert(payload, onClose)
456
+ return
457
+ }
458
+ activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload)
459
+ onClose()
460
+ }
461
+
462
+ return (
463
+ <Tabs defaultValue={activeTab}>
464
+ <TabsList className="editor-tabs-list">
465
+ <TabsTrigger value="uploads" className="editor-tabs-trigger">
466
+ Thư viện
467
+ </TabsTrigger>
468
+ <TabsTrigger value="url" className="editor-tabs-trigger">
469
+ URL
470
+ </TabsTrigger>
471
+ <TabsTrigger value="file" className="editor-tabs-trigger">
472
+ File
473
+ </TabsTrigger>
474
+ </TabsList>
475
+ <TabsContent value="uploads">
476
+ <InsertImageUploadsDialogBody onClick={onClick} />
477
+ </TabsContent>
478
+ <TabsContent value="url">
479
+ <InsertImageUriDialogBody onClick={onClick} />
480
+ </TabsContent>
481
+ <TabsContent value="file">
482
+ <InsertImageUploadedDialogBody onClick={onClick} />
483
+ </TabsContent>
484
+ </Tabs>
485
+ )
486
+ }
487
+
488
+ export function ImagesPlugin({
489
+ captionsEnabled,
490
+ }: {
491
+ captionsEnabled?: boolean
492
+ }): JSX.Element | null {
493
+ const [editor] = useLexicalComposerContext()
494
+
495
+ useEffect(() => {
496
+ if (!editor.hasNodes([ImageNode])) {
497
+ throw new Error("ImagesPlugin: ImageNode not registered on editor")
498
+ }
499
+
500
+ return mergeRegister(
501
+ editor.registerCommand<InsertImagePayload>(
502
+ INSERT_IMAGE_COMMAND,
503
+ (payload) => {
504
+ const imageNode = $createImageNode(payload)
505
+ $insertNodes([imageNode])
506
+ if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
507
+ $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
508
+ }
509
+
510
+ return true
511
+ },
512
+ COMMAND_PRIORITY_EDITOR
513
+ ),
514
+ editor.registerCommand<DragEvent>(
515
+ DRAGSTART_COMMAND,
516
+ (event) => {
517
+ return $onDragStart(event)
518
+ },
519
+ COMMAND_PRIORITY_HIGH
520
+ ),
521
+ editor.registerCommand<DragEvent>(
522
+ DRAGOVER_COMMAND,
523
+ (event) => {
524
+ return $onDragover(event)
525
+ },
526
+ COMMAND_PRIORITY_LOW
527
+ ),
528
+ editor.registerCommand<DragEvent>(
529
+ DROP_COMMAND,
530
+ (event) => {
531
+ return $onDrop(event, editor)
532
+ },
533
+ COMMAND_PRIORITY_HIGH
534
+ )
535
+ )
536
+ }, [captionsEnabled, editor])
537
+
538
+ return null
539
+ }
540
+
541
+ function $onDragStart(event: DragEvent): boolean {
542
+ const node = $getImageNodeInSelection()
543
+ if (!node) {
544
+ return false
545
+ }
546
+ const dataTransfer = event.dataTransfer
547
+ if (!dataTransfer) {
548
+ return false
549
+ }
550
+ const TRANSPARENT_IMAGE =
551
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
552
+ const img = document.createElement("img")
553
+ img.src = TRANSPARENT_IMAGE
554
+ dataTransfer.setData("text/plain", "_")
555
+ dataTransfer.setDragImage(img, 0, 0)
556
+ dataTransfer.setData(
557
+ "application/x-lexical-drag",
558
+ JSON.stringify({
559
+ data: {
560
+ altText: node.__altText,
561
+ caption: node.__caption,
562
+ height: node.__height,
563
+ key: node.getKey(),
564
+ maxWidth: node.__maxWidth,
565
+ showCaption: node.__showCaption,
566
+ src: node.__src,
567
+ width: node.__width,
568
+ },
569
+ type: "image",
570
+ })
571
+ )
572
+
573
+ return true
574
+ }
575
+
576
+ function $onDragover(event: DragEvent): boolean {
577
+ const node = $getImageNodeInSelection()
578
+ if (!node) {
579
+ return false
580
+ }
581
+ if (!canDropImage(event)) {
582
+ event.preventDefault()
583
+ }
584
+ return true
585
+ }
586
+
587
+ function $onDrop(event: DragEvent, editor: LexicalEditor): boolean {
588
+ const node = $getImageNodeInSelection()
589
+ if (!node) {
590
+ return false
591
+ }
592
+ const data = getDragImageData(event)
593
+ if (!data) {
594
+ return false
595
+ }
596
+ event.preventDefault()
597
+ if (canDropImage(event)) {
598
+ const range = getDragSelection(event)
599
+ node.remove()
600
+ const rangeSelection = $createRangeSelection()
601
+ if (range !== null && range !== undefined) {
602
+ rangeSelection.applyDOMRange(range)
603
+ }
604
+ $setSelection(rangeSelection)
605
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, data)
606
+ }
607
+ return true
608
+ }
609
+
610
+ function $getImageNodeInSelection(): ImageNode | null {
611
+ const selection = $getSelection()
612
+ if (!$isNodeSelection(selection)) {
613
+ return null
614
+ }
615
+ const nodes = selection.getNodes()
616
+ const node = nodes[0]
617
+ return $isImageNode(node) ? node : null
618
+ }
619
+
620
+ function getDragImageData(event: DragEvent): null | InsertImagePayload {
621
+ const dragData = event.dataTransfer?.getData("application/x-lexical-drag")
622
+ if (!dragData) {
623
+ return null
624
+ }
625
+ const { type, data } = JSON.parse(dragData)
626
+ if (type !== "image") {
627
+ return null
628
+ }
629
+
630
+ return data
631
+ }
632
+
633
+ declare global {
634
+ interface DragEvent {
635
+ rangeOffset?: number
636
+ rangeParent?: Node
637
+ }
638
+ }
639
+
640
+ function canDropImage(event: DragEvent): boolean {
641
+ const target = event.target
642
+ return !!(
643
+ target &&
644
+ target instanceof HTMLElement &&
645
+ !target.closest("code, span.editor-image") &&
646
+ target.parentElement &&
647
+ target.parentElement.closest("div.ContentEditable__root")
648
+ )
649
+ }
650
+
651
+ function getDragSelection(event: DragEvent): Range | null | undefined {
652
+ let range
653
+ const target = event.target as null | Element | Document
654
+ const targetWindow =
655
+ target == null
656
+ ? null
657
+ : target.nodeType === 9
658
+ ? (target as Document).defaultView
659
+ : (target as Element).ownerDocument.defaultView
660
+ const domSelection = getDOMSelection(targetWindow)
661
+ if (document.caretRangeFromPoint) {
662
+ range = document.caretRangeFromPoint(event.clientX, event.clientY)
663
+ } else if (event.rangeParent && domSelection !== null) {
664
+ domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
665
+ range = domSelection.getRangeAt(0)
666
+ } else {
667
+ throw Error(`Cannot get the selection when dragging`)
668
+ }
669
+
670
+ return range
671
+ }