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