@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,519 @@
1
+ import * as React from "react"
2
+ import { JSX } from "react"
3
+ import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents"
4
+ import { useLexicalEditable } from "@lexical/react/useLexicalEditable"
5
+ import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"
6
+ import { mergeRegister } from "@lexical/utils"
7
+ import { ImageResizer } from "../../editor-ui/image-resizer"
8
+ import { getContainerWidth } from "../../editor-ui/image-sizing"
9
+ import { useEditorContainer } from "../../context/editor-container-context"
10
+ import { cn } from "../../lib/utils"
11
+ import {
12
+ DecoratorBlockNode,
13
+ SerializedDecoratorBlockNode,
14
+ } from "@lexical/react/LexicalDecoratorBlockNode"
15
+ import type {
16
+ DOMConversionMap,
17
+ DOMConversionOutput,
18
+ DOMExportOutput,
19
+ EditorConfig,
20
+ ElementFormatType,
21
+ LexicalEditor,
22
+ LexicalNode,
23
+ NodeKey,
24
+ Spread,
25
+ } from "lexical"
26
+ import { $getNodeByKey, CLICK_COMMAND, COMMAND_PRIORITY_LOW } from "lexical"
27
+
28
+ type YouTubeComponentProps = Readonly<{
29
+ className: Readonly<{
30
+ base: string
31
+ focus: string
32
+ }>
33
+ format: ElementFormatType | null
34
+ nodeKey: NodeKey
35
+ videoID: string
36
+ width: "inherit" | number
37
+ height: "inherit" | number
38
+ maxWidth: number
39
+ fullWidth: boolean
40
+ editor: LexicalEditor
41
+ }>
42
+
43
+ function YouTubeComponent({
44
+ className,
45
+ format,
46
+ nodeKey,
47
+ videoID,
48
+ width,
49
+ height,
50
+ maxWidth,
51
+ fullWidth,
52
+ editor,
53
+ }: YouTubeComponentProps) {
54
+ const isEditable = useLexicalEditable()
55
+ const [isSelected, setSelected, clearSelection] =
56
+ useLexicalNodeSelection(nodeKey)
57
+ const [isResizing, setIsResizing] = React.useState(false)
58
+ const wrapperRef = React.useRef<HTMLDivElement | null>(null)
59
+ const [wrapperElement, setWrapperElement] =
60
+ React.useState<HTMLDivElement | null>(null)
61
+ const buttonRef = React.useRef<HTMLButtonElement | null>(null)
62
+ const [containerWidth, setContainerWidth] = React.useState<number>(maxWidth)
63
+ const editorContainer = useEditorContainer()
64
+ const editorHardWidthLimit = editorContainer?.maxWidth
65
+ const updateNode = React.useCallback(
66
+ (updater: (node: YouTubeNode) => void) => {
67
+ editor.update(() => {
68
+ const node = $getNodeByKey(nodeKey)
69
+ if (node instanceof YouTubeNode) {
70
+ updater(node)
71
+ }
72
+ })
73
+ },
74
+ [editor, nodeKey]
75
+ )
76
+ // YouTube videos have 16:9 aspect ratio (width:height)
77
+ const aspectRatio = 16 / 9
78
+ const clampToContainer = React.useCallback(
79
+ (value: number) => {
80
+ const limit =
81
+ typeof editorHardWidthLimit === "number" && editorHardWidthLimit > 0
82
+ ? editorHardWidthLimit
83
+ : containerWidth || value
84
+ return Math.min(value, limit)
85
+ },
86
+ [containerWidth, editorHardWidthLimit]
87
+ )
88
+ React.useEffect(() => {
89
+ if (!wrapperElement) {
90
+ return
91
+ }
92
+ const editorRoot = editor.getRootElement()
93
+ const parentElement = wrapperElement.parentElement
94
+ const fieldContentElement = wrapperElement.closest(
95
+ "[data-slot='field-content']"
96
+ ) as HTMLElement | null
97
+ const getMeasurementTarget = () => parentElement ?? wrapperElement
98
+ const updateAvailableWidth = () => {
99
+ const measurementTarget = getMeasurementTarget()
100
+ const widthValue =
101
+ getContainerWidth(
102
+ measurementTarget,
103
+ editorRoot,
104
+ editorHardWidthLimit
105
+ ) || maxWidth
106
+ if (widthValue > 0) {
107
+ setContainerWidth((prev) => (prev === widthValue ? prev : widthValue))
108
+ }
109
+ }
110
+ updateAvailableWidth()
111
+ const hasResizeObserver =
112
+ typeof window !== "undefined" && typeof ResizeObserver !== "undefined"
113
+ let observer: ResizeObserver | null = null
114
+ if (hasResizeObserver) {
115
+ observer = new ResizeObserver(updateAvailableWidth)
116
+ const elementsToObserve = new Set<HTMLElement>()
117
+ elementsToObserve.add(wrapperElement)
118
+ if (parentElement && parentElement !== wrapperElement) {
119
+ elementsToObserve.add(parentElement)
120
+ }
121
+ if (fieldContentElement) {
122
+ elementsToObserve.add(fieldContentElement)
123
+ }
124
+ if (
125
+ editorRoot &&
126
+ editorRoot instanceof HTMLElement &&
127
+ editorRoot !== parentElement &&
128
+ editorRoot !== wrapperElement
129
+ ) {
130
+ elementsToObserve.add(editorRoot)
131
+ }
132
+ elementsToObserve.forEach((element) => observer?.observe(element))
133
+ return () => observer?.disconnect()
134
+ }
135
+ if (typeof window !== "undefined") {
136
+ window.addEventListener("resize", updateAvailableWidth)
137
+ return () => window.removeEventListener("resize", updateAvailableWidth)
138
+ }
139
+ }, [editor, editorHardWidthLimit, maxWidth, wrapperElement])
140
+ React.useEffect(() => {
141
+ if (!containerWidth) {
142
+ return
143
+ }
144
+ if (
145
+ editorHardWidthLimit &&
146
+ containerWidth > editorHardWidthLimit
147
+ ) {
148
+ setContainerWidth(editorHardWidthLimit)
149
+ return
150
+ }
151
+ updateNode((node) => {
152
+ if (node.getMaxWidth() !== containerWidth) {
153
+ node.setMaxWidth(containerWidth)
154
+ }
155
+ })
156
+ }, [containerWidth, editorHardWidthLimit, updateNode])
157
+ const availableWidth = Math.min(
158
+ containerWidth || maxWidth,
159
+ editorHardWidthLimit || containerWidth || maxWidth
160
+ )
161
+ const targetWidth =
162
+ typeof width === "number" ? width : availableWidth
163
+ const boundedWidth =
164
+ typeof targetWidth === "number"
165
+ ? Math.min(targetWidth, availableWidth)
166
+ : availableWidth
167
+ const renderedWidth = fullWidth ? availableWidth : boundedWidth
168
+ const safeRenderedWidth = editorHardWidthLimit
169
+ ? Math.min(renderedWidth, editorHardWidthLimit)
170
+ : renderedWidth
171
+ // CSS aspect-ratio expects width/height, not height/width
172
+ const cssAspectRatio =
173
+ typeof width === "number" && typeof height === "number" && height > 0
174
+ ? width / height
175
+ : aspectRatio
176
+ const isFocused = (isSelected || isResizing) && isEditable
177
+ const disablePlaybackInteractions = isEditable
178
+ const iframeWidth = safeRenderedWidth
179
+ const iframeHeight = Math.round(iframeWidth / aspectRatio)
180
+ const handleWrapperRef = React.useCallback((node: HTMLDivElement | null) => {
181
+ wrapperRef.current = node
182
+ setWrapperElement(node)
183
+ }, [])
184
+ React.useEffect(() => {
185
+ if (!isEditable) {
186
+ return
187
+ }
188
+ return mergeRegister(
189
+ editor.registerCommand<MouseEvent>(
190
+ CLICK_COMMAND,
191
+ (event) => {
192
+ const wrapper = wrapperRef.current
193
+ if (!wrapper || !(event.target instanceof Node)) {
194
+ return false
195
+ }
196
+ if (!wrapper.contains(event.target)) {
197
+ return false
198
+ }
199
+ if (isResizing) {
200
+ return true
201
+ }
202
+ if (event.shiftKey) {
203
+ setSelected(!isSelected)
204
+ } else {
205
+ clearSelection()
206
+ setSelected(true)
207
+ }
208
+ event.preventDefault()
209
+ return true
210
+ },
211
+ COMMAND_PRIORITY_LOW
212
+ )
213
+ )
214
+ }, [clearSelection, editor, isEditable, isResizing, isSelected, setSelected])
215
+ React.useEffect(() => {
216
+ if (
217
+ !containerWidth ||
218
+ typeof width !== "number" ||
219
+ fullWidth ||
220
+ width <= containerWidth
221
+ ) {
222
+ return
223
+ }
224
+ updateNode((node) => {
225
+ const nextWidth = clampToContainer(width)
226
+ const nextHeight = Math.round(nextWidth / aspectRatio)
227
+ node.setWidthAndHeight(nextWidth, nextHeight)
228
+ })
229
+ }, [
230
+ aspectRatio,
231
+ clampToContainer,
232
+ containerWidth,
233
+ editorHardWidthLimit,
234
+ fullWidth,
235
+ updateNode,
236
+ width,
237
+ ])
238
+
239
+ return (
240
+ <BlockWithAlignableContents
241
+ className={className}
242
+ format={format}
243
+ nodeKey={nodeKey}
244
+ >
245
+ <div
246
+ style={{
247
+ width: fullWidth
248
+ ? "100%"
249
+ : `${Math.max(safeRenderedWidth, 0)}px`,
250
+ aspectRatio: cssAspectRatio,
251
+ maxWidth: "100%",
252
+ height: "auto",
253
+ }}
254
+ className={cn(
255
+ "editor-embed-frame relative inline-block",
256
+ fullWidth ? "editor-embed-frame--full" : "editor-embed-frame--inline"
257
+ )}
258
+ data-editor-embed-fullwidth={fullWidth ? "true" : "false"}
259
+ ref={handleWrapperRef}
260
+ >
261
+ <iframe
262
+ width={iframeWidth}
263
+ height={iframeHeight}
264
+ src={`https://www.youtube-nocookie.com/embed/${videoID}`}
265
+ frameBorder="0"
266
+ allow={
267
+ disablePlaybackInteractions
268
+ ? "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
269
+ : "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
270
+ }
271
+ allowFullScreen={true}
272
+ title="YouTube video"
273
+ className={`block absolute inset-0 h-full w-full ${
274
+ disablePlaybackInteractions ? "pointer-events-none" : ""
275
+ }`}
276
+ tabIndex={disablePlaybackInteractions ? -1 : undefined}
277
+ aria-label={
278
+ disablePlaybackInteractions
279
+ ? "YouTube video preview disabled while editing"
280
+ : "YouTube video"
281
+ }
282
+ />
283
+ {isFocused && (
284
+ <ImageResizer
285
+ editor={editor}
286
+ buttonRef={buttonRef}
287
+ mediaRef={wrapperRef}
288
+ onResizeStart={() => {
289
+ setIsResizing(true)
290
+ updateNode((node) => node.setFullWidth(false))
291
+ }}
292
+ onResizeEnd={(nextWidth, nextHeight) => {
293
+ setTimeout(() => setIsResizing(false), 200)
294
+ const finalWidth =
295
+ typeof nextWidth === "number"
296
+ ? clampToContainer(nextWidth)
297
+ : nextWidth
298
+ const finalHeight =
299
+ typeof finalWidth === "number"
300
+ ? Math.round(finalWidth / aspectRatio)
301
+ : nextHeight
302
+ updateNode((node) =>
303
+ node.setWidthAndHeight(finalWidth, finalHeight)
304
+ )
305
+ }}
306
+ onSetFullWidth={() => {
307
+ updateNode((node) => node.setFullWidth(true))
308
+ }}
309
+ />
310
+ )}
311
+ </div>
312
+ </BlockWithAlignableContents>
313
+ )
314
+ }
315
+
316
+ export type SerializedYouTubeNode = Spread<
317
+ {
318
+ videoID: string
319
+ width?: number
320
+ height?: number
321
+ maxWidth?: number
322
+ fullWidth?: boolean
323
+ },
324
+ SerializedDecoratorBlockNode
325
+ >
326
+
327
+ function $convertYoutubeElement(
328
+ domNode: HTMLElement
329
+ ): null | DOMConversionOutput {
330
+ const videoID = domNode.getAttribute("data-lexical-youtube")
331
+ if (videoID) {
332
+ const node = $createYouTubeNode(videoID)
333
+ return { node }
334
+ }
335
+ return null
336
+ }
337
+
338
+ export class YouTubeNode extends DecoratorBlockNode {
339
+ __id: string
340
+ __width: "inherit" | number
341
+ __height: "inherit" | number
342
+ __maxWidth: number
343
+ __fullWidth: boolean
344
+
345
+ static getType(): string {
346
+ return "youtube"
347
+ }
348
+
349
+ static clone(node: YouTubeNode): YouTubeNode {
350
+ return new YouTubeNode(
351
+ node.__id,
352
+ node.__format,
353
+ node.__width,
354
+ node.__height,
355
+ node.__maxWidth,
356
+ node.__fullWidth,
357
+ node.__key
358
+ )
359
+ }
360
+
361
+ static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
362
+ const node = $createYouTubeNode(
363
+ serializedNode.videoID,
364
+ serializedNode.width,
365
+ serializedNode.height,
366
+ serializedNode.maxWidth ?? 640,
367
+ serializedNode.fullWidth
368
+ )
369
+ node.setFormat(serializedNode.format)
370
+ return node
371
+ }
372
+
373
+ exportJSON(): SerializedYouTubeNode {
374
+ return {
375
+ ...super.exportJSON(),
376
+ type: "youtube",
377
+ version: 1,
378
+ videoID: this.__id,
379
+ width: this.__width === "inherit" ? undefined : (this.__width as number),
380
+ height:
381
+ this.__height === "inherit" ? undefined : (this.__height as number),
382
+ maxWidth: this.__maxWidth,
383
+ fullWidth: this.__fullWidth,
384
+ }
385
+ }
386
+
387
+ constructor(
388
+ id: string,
389
+ format?: ElementFormatType,
390
+ width?: "inherit" | number,
391
+ height?: "inherit" | number,
392
+ maxWidth: number = 640,
393
+ fullWidth?: boolean,
394
+ key?: NodeKey
395
+ ) {
396
+ super(format, key)
397
+ this.__id = id
398
+ this.__width = width ?? "inherit"
399
+ this.__height = height ?? "inherit"
400
+ this.__maxWidth = maxWidth
401
+ // Default to not full width so align controls remain effective
402
+ this.__fullWidth = fullWidth ?? false
403
+ }
404
+
405
+ exportDOM(): DOMExportOutput {
406
+ const element = document.createElement("iframe")
407
+ element.setAttribute("data-lexical-youtube", this.__id)
408
+ if (typeof this.__width === "number") {
409
+ element.setAttribute("width", String(this.__width))
410
+ }
411
+ if (typeof this.__height === "number") {
412
+ element.setAttribute("height", String(this.__height))
413
+ }
414
+ element.setAttribute(
415
+ "src",
416
+ `https://www.youtube-nocookie.com/embed/${this.__id}`
417
+ )
418
+ element.setAttribute("frameborder", "0")
419
+ element.setAttribute("loading", "lazy")
420
+ element.setAttribute(
421
+ "allow",
422
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
423
+ )
424
+ element.setAttribute("allowfullscreen", "true")
425
+ element.setAttribute("title", "YouTube video")
426
+ return { element }
427
+ }
428
+
429
+ static importDOM(): DOMConversionMap | null {
430
+ return {
431
+ iframe: (domNode: HTMLElement) => {
432
+ if (!domNode.hasAttribute("data-lexical-youtube")) {
433
+ return null
434
+ }
435
+ return {
436
+ conversion: $convertYoutubeElement,
437
+ priority: 1,
438
+ }
439
+ },
440
+ }
441
+ }
442
+
443
+ updateDOM(): false {
444
+ return false
445
+ }
446
+
447
+ getId(): string {
448
+ return this.__id
449
+ }
450
+
451
+ getTextContent(
452
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Lexical override signature
453
+ _includeInert?: boolean | undefined,
454
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Lexical override signature
455
+ _includeDirectionless?: false | undefined
456
+ ): string {
457
+ return `https://www.youtube.com/watch?v=${this.__id}`
458
+ }
459
+
460
+ decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
461
+ const embedBlockTheme = config.theme.embedBlock || {}
462
+ const className = {
463
+ base: embedBlockTheme.base || "",
464
+ focus: embedBlockTheme.focus || "",
465
+ }
466
+ return (
467
+ <YouTubeComponent
468
+ className={className}
469
+ format={this.__format}
470
+ nodeKey={this.getKey()}
471
+ videoID={this.__id}
472
+ width={this.__width}
473
+ height={this.__height}
474
+ maxWidth={this.__maxWidth}
475
+ fullWidth={this.__fullWidth}
476
+ editor={editor}
477
+ />
478
+ )
479
+ }
480
+
481
+ setWidthAndHeight(width: "inherit" | number, height: "inherit" | number) {
482
+ const writable = this.getWritable()
483
+ writable.__width = width
484
+ writable.__height = height
485
+ }
486
+ getMaxWidth(): number {
487
+ return this.__maxWidth
488
+ }
489
+
490
+ setMaxWidth(maxWidth: number) {
491
+ const writable = this.getWritable()
492
+ writable.__maxWidth = maxWidth
493
+ }
494
+
495
+ isFullWidth(): boolean {
496
+ return this.__fullWidth
497
+ }
498
+
499
+ setFullWidth(fullWidth: boolean) {
500
+ const writable = this.getWritable()
501
+ writable.__fullWidth = fullWidth
502
+ }
503
+ }
504
+
505
+ export function $createYouTubeNode(
506
+ videoID: string,
507
+ width?: number,
508
+ height?: number,
509
+ maxWidth?: number,
510
+ fullWidth?: boolean
511
+ ): YouTubeNode {
512
+ return new YouTubeNode(videoID, undefined, width, height, maxWidth, fullWidth)
513
+ }
514
+
515
+ export function $isYouTubeNode(
516
+ node: YouTubeNode | LexicalNode | null | undefined
517
+ ): node is YouTubeNode {
518
+ return node instanceof YouTubeNode
519
+ }
@@ -0,0 +1,83 @@
1
+ import type {
2
+ EditorConfig,
3
+ LexicalNode,
4
+ NodeKey,
5
+ SerializedTextNode,
6
+ Spread,
7
+ } from "lexical"
8
+ import { $applyNodeReplacement, TextNode } from "lexical"
9
+
10
+ export type SerializedEmojiNode = Spread<
11
+ {
12
+ className: string
13
+ },
14
+ SerializedTextNode
15
+ >
16
+
17
+ export class EmojiNode extends TextNode {
18
+ __className: string
19
+
20
+ static getType(): string {
21
+ return "emoji"
22
+ }
23
+
24
+ static clone(node: EmojiNode): EmojiNode {
25
+ return new EmojiNode(node.__className, node.__text, node.__key)
26
+ }
27
+
28
+ constructor(className: string, text: string, key?: NodeKey) {
29
+ super(text, key)
30
+ this.__className = className
31
+ }
32
+
33
+ createDOM(config: EditorConfig): HTMLElement {
34
+ const dom = document.createElement("span")
35
+ const inner = super.createDOM(config)
36
+ dom.className = this.__className
37
+ inner.className = "emoji-inner"
38
+ dom.appendChild(inner)
39
+ return dom
40
+ }
41
+
42
+ updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
43
+ const inner = dom.firstChild
44
+ if (inner === null) {
45
+ return true
46
+ }
47
+ super.updateDOM(prevNode, inner as HTMLElement, config)
48
+ return false
49
+ }
50
+
51
+ static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
52
+ return $createEmojiNode(
53
+ serializedNode.className,
54
+ serializedNode.text
55
+ ).updateFromJSON(serializedNode)
56
+ }
57
+
58
+ exportJSON(): SerializedEmojiNode {
59
+ return {
60
+ ...super.exportJSON(),
61
+ className: this.getClassName(),
62
+ }
63
+ }
64
+
65
+ getClassName(): string {
66
+ const self = this.getLatest()
67
+ return self.__className
68
+ }
69
+ }
70
+
71
+ export function $isEmojiNode(
72
+ node: LexicalNode | null | undefined
73
+ ): node is EmojiNode {
74
+ return node instanceof EmojiNode
75
+ }
76
+
77
+ export function $createEmojiNode(
78
+ className: string,
79
+ emojiText: string
80
+ ): EmojiNode {
81
+ const node = new EmojiNode(className, emojiText).setMode("token")
82
+ return $applyNodeReplacement(node)
83
+ }