@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,324 @@
|
|
|
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, useMemo, useState } from "react"
|
|
11
|
+
import {
|
|
12
|
+
AutoEmbedOption,
|
|
13
|
+
EmbedConfig,
|
|
14
|
+
EmbedMatchResult,
|
|
15
|
+
LexicalAutoEmbedPlugin,
|
|
16
|
+
URL_MATCHER,
|
|
17
|
+
} from "@lexical/react/LexicalAutoEmbedPlugin"
|
|
18
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
19
|
+
import type { LexicalEditor } from "lexical"
|
|
20
|
+
import { TwitterIcon, YoutubeIcon } from "lucide-react"
|
|
21
|
+
|
|
22
|
+
import { useEditorModal } from "../../editor-hooks/use-modal"
|
|
23
|
+
import { INSERT_TWEET_COMMAND } from "./twitter-plugin"
|
|
24
|
+
import { INSERT_YOUTUBE_COMMAND } from "./youtube-plugin"
|
|
25
|
+
import { Button } from "../../ui/button"
|
|
26
|
+
import {
|
|
27
|
+
Command,
|
|
28
|
+
CommandGroup,
|
|
29
|
+
CommandItem,
|
|
30
|
+
CommandList,
|
|
31
|
+
} from "../../ui/command"
|
|
32
|
+
import { DialogFooter } from "../../ui/dialog"
|
|
33
|
+
import { Input } from "../../ui/input"
|
|
34
|
+
import {
|
|
35
|
+
Popover,
|
|
36
|
+
PopoverContent,
|
|
37
|
+
PopoverPortal,
|
|
38
|
+
PopoverTrigger,
|
|
39
|
+
} from "../../ui/popover"
|
|
40
|
+
import { IconSize } from "../../ui/typography"
|
|
41
|
+
|
|
42
|
+
export interface CustomEmbedConfig extends EmbedConfig {
|
|
43
|
+
// Human readable name of the embeded content e.g. Tweet or Google Map.
|
|
44
|
+
contentName: string
|
|
45
|
+
|
|
46
|
+
// Icon for display.
|
|
47
|
+
icon?: JSX.Element
|
|
48
|
+
|
|
49
|
+
// An example of a matching url https://twitter.com/jack/status/20
|
|
50
|
+
exampleUrl: string
|
|
51
|
+
|
|
52
|
+
// For extra searching.
|
|
53
|
+
keywords: Array<string>
|
|
54
|
+
|
|
55
|
+
// Embed a Project.
|
|
56
|
+
description?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const YoutubeEmbedConfig: CustomEmbedConfig = {
|
|
60
|
+
contentName: "Youtube Video",
|
|
61
|
+
|
|
62
|
+
exampleUrl: "https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
|
63
|
+
|
|
64
|
+
// Icon for display.
|
|
65
|
+
icon: <IconSize size="sm"><YoutubeIcon /></IconSize>,
|
|
66
|
+
|
|
67
|
+
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
|
68
|
+
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id)
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
keywords: ["youtube", "video"],
|
|
72
|
+
|
|
73
|
+
// Determine if a given URL is a match and return url data.
|
|
74
|
+
parseUrl: async (url: string) => {
|
|
75
|
+
const match =
|
|
76
|
+
/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url)
|
|
77
|
+
|
|
78
|
+
const id = match && match[2] && match[2].length === 11 ? match[2] : null
|
|
79
|
+
|
|
80
|
+
if (id != null) {
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
url,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
type: "youtube-video",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const TwitterEmbedConfig: CustomEmbedConfig = {
|
|
94
|
+
// e.g. Tweet or Google Map.
|
|
95
|
+
contentName: "Tweet",
|
|
96
|
+
|
|
97
|
+
exampleUrl: "https://twitter.com/jack/status/20",
|
|
98
|
+
|
|
99
|
+
// Icon for display.
|
|
100
|
+
icon: <IconSize size="sm"><TwitterIcon /></IconSize>,
|
|
101
|
+
|
|
102
|
+
// Create the Lexical embed node from the url data.
|
|
103
|
+
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
|
104
|
+
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id)
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// For extra searching.
|
|
108
|
+
keywords: ["tweet", "twitter"],
|
|
109
|
+
|
|
110
|
+
// Determine if a given URL is a match and return url data.
|
|
111
|
+
parseUrl: (text: string) => {
|
|
112
|
+
const match =
|
|
113
|
+
/^https:\/\/(twitter|x)\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)/.exec(text)
|
|
114
|
+
|
|
115
|
+
if (match != null && match[5]) {
|
|
116
|
+
return {
|
|
117
|
+
id: match[5],
|
|
118
|
+
url: text,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
type: "tweet",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]
|
|
129
|
+
|
|
130
|
+
const debounce = (callback: (text: string) => void, delay: number) => {
|
|
131
|
+
let timeoutId: number
|
|
132
|
+
return (text: string) => {
|
|
133
|
+
window.clearTimeout(timeoutId)
|
|
134
|
+
timeoutId = window.setTimeout(() => {
|
|
135
|
+
callback(text)
|
|
136
|
+
}, delay)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Dialog content component that doesn't depend on context
|
|
141
|
+
function AutoEmbedDialogContent({
|
|
142
|
+
embedConfig,
|
|
143
|
+
onClose,
|
|
144
|
+
editor,
|
|
145
|
+
}: {
|
|
146
|
+
embedConfig: CustomEmbedConfig
|
|
147
|
+
onClose: () => void
|
|
148
|
+
editor: LexicalEditor
|
|
149
|
+
}): JSX.Element {
|
|
150
|
+
const [text, setText] = useState("")
|
|
151
|
+
const [embedResult, setEmbedResult] = useState<EmbedMatchResult | null>(null)
|
|
152
|
+
|
|
153
|
+
const validateText = useMemo(
|
|
154
|
+
() =>
|
|
155
|
+
debounce((inputText: string) => {
|
|
156
|
+
const urlMatch = URL_MATCHER.exec(inputText)
|
|
157
|
+
if (embedConfig != null && inputText != null && urlMatch != null) {
|
|
158
|
+
Promise.resolve(embedConfig.parseUrl(inputText)).then(
|
|
159
|
+
(parseResult) => {
|
|
160
|
+
setEmbedResult(parseResult)
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
} else if (embedResult != null) {
|
|
164
|
+
setEmbedResult(null)
|
|
165
|
+
}
|
|
166
|
+
}, 200),
|
|
167
|
+
[embedConfig, embedResult]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const onClick = () => {
|
|
171
|
+
if (embedResult != null) {
|
|
172
|
+
embedConfig.insertNode(editor, embedResult)
|
|
173
|
+
onClose()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="editor-flex-col-gap-4">
|
|
179
|
+
<Input
|
|
180
|
+
type="text"
|
|
181
|
+
placeholder={embedConfig.exampleUrl}
|
|
182
|
+
value={text}
|
|
183
|
+
data-test-id={`${embedConfig.type}-embed-modal-url`}
|
|
184
|
+
onChange={(e) => {
|
|
185
|
+
const { value } = e.target
|
|
186
|
+
setText(value)
|
|
187
|
+
validateText(value)
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<DialogFooter>
|
|
191
|
+
<Button
|
|
192
|
+
disabled={!embedResult}
|
|
193
|
+
onClick={onClick}
|
|
194
|
+
data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}
|
|
195
|
+
>
|
|
196
|
+
Embed
|
|
197
|
+
</Button>
|
|
198
|
+
</DialogFooter>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Wrapper that uses context for AutoEmbedPlugin
|
|
204
|
+
export function AutoEmbedDialog({
|
|
205
|
+
embedConfig,
|
|
206
|
+
onClose,
|
|
207
|
+
editor,
|
|
208
|
+
}: {
|
|
209
|
+
embedConfig: CustomEmbedConfig
|
|
210
|
+
onClose: () => void
|
|
211
|
+
editor?: LexicalEditor
|
|
212
|
+
}): JSX.Element {
|
|
213
|
+
const [editorFromContext] = useLexicalComposerContext()
|
|
214
|
+
const activeEditor = editor ?? editorFromContext
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<AutoEmbedDialogContent
|
|
218
|
+
embedConfig={embedConfig}
|
|
219
|
+
onClose={onClose}
|
|
220
|
+
editor={activeEditor}
|
|
221
|
+
/>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Standalone version that doesn't use context (for toolbar usage)
|
|
226
|
+
export function AutoEmbedDialogStandalone({
|
|
227
|
+
embedConfig,
|
|
228
|
+
onClose,
|
|
229
|
+
editor,
|
|
230
|
+
}: {
|
|
231
|
+
embedConfig: CustomEmbedConfig
|
|
232
|
+
onClose: () => void
|
|
233
|
+
editor: LexicalEditor
|
|
234
|
+
}): JSX.Element {
|
|
235
|
+
return (
|
|
236
|
+
<AutoEmbedDialogContent
|
|
237
|
+
embedConfig={embedConfig}
|
|
238
|
+
onClose={onClose}
|
|
239
|
+
editor={editor}
|
|
240
|
+
/>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function AutoEmbedPlugin(): JSX.Element {
|
|
245
|
+
const [modal, showModal] = useEditorModal()
|
|
246
|
+
|
|
247
|
+
const openEmbedModal = (embedConfig: CustomEmbedConfig) => {
|
|
248
|
+
showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
|
|
249
|
+
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
|
|
250
|
+
))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const getMenuOptions = (
|
|
254
|
+
activeEmbedConfig: CustomEmbedConfig,
|
|
255
|
+
embedFn: () => void,
|
|
256
|
+
dismissFn: () => void
|
|
257
|
+
) => {
|
|
258
|
+
return [
|
|
259
|
+
new AutoEmbedOption("Dismiss", {
|
|
260
|
+
onSelect: dismissFn,
|
|
261
|
+
}),
|
|
262
|
+
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
|
|
263
|
+
onSelect: embedFn,
|
|
264
|
+
}),
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<>
|
|
270
|
+
{modal}
|
|
271
|
+
<LexicalAutoEmbedPlugin<CustomEmbedConfig>
|
|
272
|
+
embedConfigs={EmbedConfigs}
|
|
273
|
+
onOpenEmbedModalForConfig={openEmbedModal}
|
|
274
|
+
getMenuOptions={getMenuOptions}
|
|
275
|
+
menuRenderFn={(
|
|
276
|
+
anchorElementRef,
|
|
277
|
+
{
|
|
278
|
+
selectedIndex,
|
|
279
|
+
options,
|
|
280
|
+
selectOptionAndCleanUp,
|
|
281
|
+
setHighlightedIndex,
|
|
282
|
+
}
|
|
283
|
+
) => {
|
|
284
|
+
void selectedIndex
|
|
285
|
+
void setHighlightedIndex
|
|
286
|
+
return anchorElementRef.current ? (
|
|
287
|
+
<Popover open={true}>
|
|
288
|
+
<PopoverPortal container={anchorElementRef.current}>
|
|
289
|
+
<div className="editor-auto-embed-wrapper">
|
|
290
|
+
<PopoverTrigger>
|
|
291
|
+
<span className="sr-only">Open Menu</span>
|
|
292
|
+
</PopoverTrigger>
|
|
293
|
+
<PopoverContent
|
|
294
|
+
className="editor-auto-embed-menu"
|
|
295
|
+
align="start"
|
|
296
|
+
>
|
|
297
|
+
<Command>
|
|
298
|
+
<CommandList>
|
|
299
|
+
<CommandGroup>
|
|
300
|
+
{options.map((option) => (
|
|
301
|
+
<CommandItem
|
|
302
|
+
key={option.key}
|
|
303
|
+
value={option.title}
|
|
304
|
+
onSelect={() => {
|
|
305
|
+
selectOptionAndCleanUp(option)
|
|
306
|
+
}}
|
|
307
|
+
className="editor-flex-row-center"
|
|
308
|
+
>
|
|
309
|
+
{option.title}
|
|
310
|
+
</CommandItem>
|
|
311
|
+
))}
|
|
312
|
+
</CommandGroup>
|
|
313
|
+
</CommandList>
|
|
314
|
+
</Command>
|
|
315
|
+
</PopoverContent>
|
|
316
|
+
</div>
|
|
317
|
+
</PopoverPortal>
|
|
318
|
+
</Popover>
|
|
319
|
+
) : null
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
</>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
12
|
+
import { $insertNodeToNearestRoot } from "@lexical/utils"
|
|
13
|
+
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from "lexical"
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
$createTweetNode,
|
|
17
|
+
TweetNode,
|
|
18
|
+
} from "../../nodes/embeds/tweet-node"
|
|
19
|
+
|
|
20
|
+
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand(
|
|
21
|
+
"INSERT_TWEET_COMMAND"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export function TwitterPlugin(): JSX.Element | null {
|
|
25
|
+
const [editor] = useLexicalComposerContext()
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!editor.hasNodes([TweetNode])) {
|
|
29
|
+
throw new Error("TwitterPlugin: TweetNode not registered on editor")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return editor.registerCommand<string>(
|
|
33
|
+
INSERT_TWEET_COMMAND,
|
|
34
|
+
(payload) => {
|
|
35
|
+
const tweetNode = $createTweetNode(payload)
|
|
36
|
+
$insertNodeToNearestRoot(tweetNode)
|
|
37
|
+
|
|
38
|
+
return true
|
|
39
|
+
},
|
|
40
|
+
COMMAND_PRIORITY_EDITOR
|
|
41
|
+
)
|
|
42
|
+
}, [editor])
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
12
|
+
import { $insertNodeToNearestRoot } from "@lexical/utils"
|
|
13
|
+
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from "lexical"
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
$createYouTubeNode,
|
|
17
|
+
YouTubeNode,
|
|
18
|
+
} from "../../nodes/embeds/youtube-node"
|
|
19
|
+
|
|
20
|
+
export type InsertYouTubePayload =
|
|
21
|
+
| string
|
|
22
|
+
| { id: string; width?: number; height?: number; maxWidth?: number; fullWidth?: boolean }
|
|
23
|
+
|
|
24
|
+
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<InsertYouTubePayload> =
|
|
25
|
+
createCommand("INSERT_YOUTUBE_COMMAND")
|
|
26
|
+
|
|
27
|
+
export function YouTubePlugin(): JSX.Element | null {
|
|
28
|
+
const [editor] = useLexicalComposerContext()
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!editor.hasNodes([YouTubeNode])) {
|
|
32
|
+
throw new Error("YouTubePlugin: YouTubeNode not registered on editor")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return editor.registerCommand<InsertYouTubePayload>(
|
|
36
|
+
INSERT_YOUTUBE_COMMAND,
|
|
37
|
+
(payload) => {
|
|
38
|
+
const rootElement = editor.getRootElement()
|
|
39
|
+
const fallbackWidth =
|
|
40
|
+
rootElement?.getBoundingClientRect().width ?? 640
|
|
41
|
+
const resolvedMaxWidth =
|
|
42
|
+
fallbackWidth > 0 ? Math.round(fallbackWidth) : 640
|
|
43
|
+
const clampToEditorWidth = (value?: number) => {
|
|
44
|
+
if (typeof value !== "number") {
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
return Math.min(value, resolvedMaxWidth)
|
|
48
|
+
}
|
|
49
|
+
const normalizedPayload =
|
|
50
|
+
typeof payload === "string"
|
|
51
|
+
? {
|
|
52
|
+
id: payload,
|
|
53
|
+
width: undefined,
|
|
54
|
+
height: undefined,
|
|
55
|
+
maxWidth: resolvedMaxWidth,
|
|
56
|
+
fullWidth: false,
|
|
57
|
+
}
|
|
58
|
+
: {
|
|
59
|
+
id: payload.id,
|
|
60
|
+
width: clampToEditorWidth(payload.width),
|
|
61
|
+
height: payload.height,
|
|
62
|
+
maxWidth:
|
|
63
|
+
payload.maxWidth !== undefined
|
|
64
|
+
? clampToEditorWidth(payload.maxWidth)
|
|
65
|
+
: resolvedMaxWidth,
|
|
66
|
+
fullWidth: payload.fullWidth ?? false,
|
|
67
|
+
}
|
|
68
|
+
const youTubeNode = $createYouTubeNode(
|
|
69
|
+
normalizedPayload.id,
|
|
70
|
+
normalizedPayload.width,
|
|
71
|
+
normalizedPayload.height,
|
|
72
|
+
normalizedPayload.maxWidth,
|
|
73
|
+
normalizedPayload.fullWidth
|
|
74
|
+
)
|
|
75
|
+
$insertNodeToNearestRoot(youTubeNode)
|
|
76
|
+
|
|
77
|
+
return true
|
|
78
|
+
},
|
|
79
|
+
COMMAND_PRIORITY_EDITOR
|
|
80
|
+
)
|
|
81
|
+
}, [editor])
|
|
82
|
+
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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 * as React from "react"
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
12
|
+
import dynamic from "next/dynamic"
|
|
13
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
14
|
+
import {
|
|
15
|
+
MenuOption,
|
|
16
|
+
useBasicTypeaheadTriggerMatch,
|
|
17
|
+
} from "@lexical/react/LexicalTypeaheadMenuPlugin"
|
|
18
|
+
import {
|
|
19
|
+
$createTextNode,
|
|
20
|
+
$getSelection,
|
|
21
|
+
$isRangeSelection,
|
|
22
|
+
TextNode,
|
|
23
|
+
} from "lexical"
|
|
24
|
+
import { createPortal } from "react-dom"
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
Command,
|
|
28
|
+
CommandGroup,
|
|
29
|
+
CommandItem,
|
|
30
|
+
CommandList,
|
|
31
|
+
} from "../ui/command"
|
|
32
|
+
|
|
33
|
+
const LexicalTypeaheadMenuPlugin = dynamic(
|
|
34
|
+
() =>
|
|
35
|
+
import("@lexical/react/LexicalTypeaheadMenuPlugin").then(
|
|
36
|
+
(mod) => mod.LexicalTypeaheadMenuPlugin<EmojiOption>
|
|
37
|
+
),
|
|
38
|
+
{ ssr: false }
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class EmojiOption extends MenuOption {
|
|
42
|
+
title: string
|
|
43
|
+
emoji: string
|
|
44
|
+
keywords: Array<string>
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
title: string,
|
|
48
|
+
emoji: string,
|
|
49
|
+
options: {
|
|
50
|
+
keywords?: Array<string>
|
|
51
|
+
}
|
|
52
|
+
) {
|
|
53
|
+
super(title)
|
|
54
|
+
this.title = title
|
|
55
|
+
this.emoji = emoji
|
|
56
|
+
this.keywords = options.keywords || []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Emoji = {
|
|
61
|
+
emoji: string
|
|
62
|
+
description: string
|
|
63
|
+
category: string
|
|
64
|
+
aliases: Array<string>
|
|
65
|
+
tags: Array<string>
|
|
66
|
+
unicode_version: string
|
|
67
|
+
ios_version: string
|
|
68
|
+
skin_tones?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MAX_EMOJI_SUGGESTION_COUNT = 10
|
|
72
|
+
|
|
73
|
+
export function EmojiPickerPlugin() {
|
|
74
|
+
const [editor] = useLexicalComposerContext()
|
|
75
|
+
const [queryString, setQueryString] = useState<string | null>(null)
|
|
76
|
+
const [emojis, setEmojis] = useState<Array<Emoji>>([])
|
|
77
|
+
const [, setIsOpen] = useState(false)
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
import("../utils/emoji-list").then((file) => setEmojis(file.default))
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const emojiOptions = useMemo(
|
|
83
|
+
() =>
|
|
84
|
+
emojis != null
|
|
85
|
+
? emojis.map(
|
|
86
|
+
({ emoji, aliases, tags }) =>
|
|
87
|
+
new EmojiOption(aliases[0] || "emoji", emoji, {
|
|
88
|
+
keywords: [...aliases, ...tags],
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
: [],
|
|
92
|
+
[emojis]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(":", {
|
|
96
|
+
minLength: 0,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const options: Array<EmojiOption> = useMemo(() => {
|
|
100
|
+
return emojiOptions
|
|
101
|
+
.filter((option: EmojiOption) => {
|
|
102
|
+
return queryString != null
|
|
103
|
+
? new RegExp(queryString, "gi").exec(option.title) ||
|
|
104
|
+
option.keywords != null
|
|
105
|
+
? option.keywords.some((keyword: string) =>
|
|
106
|
+
new RegExp(queryString, "gi").exec(keyword)
|
|
107
|
+
)
|
|
108
|
+
: false
|
|
109
|
+
: emojiOptions
|
|
110
|
+
})
|
|
111
|
+
.slice(0, MAX_EMOJI_SUGGESTION_COUNT)
|
|
112
|
+
}, [emojiOptions, queryString])
|
|
113
|
+
|
|
114
|
+
const onSelectOption = useCallback(
|
|
115
|
+
(
|
|
116
|
+
selectedOption: EmojiOption,
|
|
117
|
+
nodeToRemove: TextNode | null,
|
|
118
|
+
closeMenu: () => void
|
|
119
|
+
) => {
|
|
120
|
+
editor.update(() => {
|
|
121
|
+
const selection = $getSelection()
|
|
122
|
+
|
|
123
|
+
if (!$isRangeSelection(selection) || selectedOption == null) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (nodeToRemove) {
|
|
128
|
+
nodeToRemove.remove()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
selection.insertNodes([$createTextNode(selectedOption.emoji)])
|
|
132
|
+
|
|
133
|
+
closeMenu()
|
|
134
|
+
})
|
|
135
|
+
},
|
|
136
|
+
[editor]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<LexicalTypeaheadMenuPlugin
|
|
141
|
+
onQueryChange={setQueryString}
|
|
142
|
+
onSelectOption={onSelectOption}
|
|
143
|
+
triggerFn={checkForTriggerMatch}
|
|
144
|
+
options={options}
|
|
145
|
+
onOpen={() => {
|
|
146
|
+
setIsOpen(true)
|
|
147
|
+
}}
|
|
148
|
+
onClose={() => {
|
|
149
|
+
setIsOpen(false)
|
|
150
|
+
}}
|
|
151
|
+
menuRenderFn={(
|
|
152
|
+
anchorElementRef,
|
|
153
|
+
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }
|
|
154
|
+
) => {
|
|
155
|
+
return anchorElementRef.current && options.length
|
|
156
|
+
? createPortal(
|
|
157
|
+
<div className="editor-fixed editor-z-10 editor-w-[200px] editor-rounded-md editor-shadow-md">
|
|
158
|
+
<Command
|
|
159
|
+
onKeyDown={(e) => {
|
|
160
|
+
if (e.key === "ArrowUp") {
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
setHighlightedIndex(
|
|
163
|
+
selectedIndex !== null
|
|
164
|
+
? (selectedIndex - 1 + options.length) %
|
|
165
|
+
options.length
|
|
166
|
+
: options.length - 1
|
|
167
|
+
)
|
|
168
|
+
} else if (e.key === "ArrowDown") {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
setHighlightedIndex(
|
|
171
|
+
selectedIndex !== null
|
|
172
|
+
? (selectedIndex + 1) % options.length
|
|
173
|
+
: 0
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<CommandList>
|
|
179
|
+
<CommandGroup>
|
|
180
|
+
{options.map((option, index) => (
|
|
181
|
+
<CommandItem
|
|
182
|
+
key={option.key}
|
|
183
|
+
value={option.title}
|
|
184
|
+
onSelect={() => {
|
|
185
|
+
selectOptionAndCleanUp(option)
|
|
186
|
+
}}
|
|
187
|
+
className={`editor-flex editor-items-center editor-gap-2 ${
|
|
188
|
+
selectedIndex === index
|
|
189
|
+
? "editor-bg-accent"
|
|
190
|
+
: "editor-bg-transparent"
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
{option.emoji} {option.title}
|
|
194
|
+
</CommandItem>
|
|
195
|
+
))}
|
|
196
|
+
</CommandGroup>
|
|
197
|
+
</CommandList>
|
|
198
|
+
</Command>
|
|
199
|
+
</div>,
|
|
200
|
+
anchorElementRef.current
|
|
201
|
+
)
|
|
202
|
+
: null
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
}
|