@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,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
|
+
}
|