@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,658 @@
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 { JSX, useEffect, useRef, useState } from "react"
12
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
13
+ import {
14
+ $findMatchingParent,
15
+ $insertNodeToNearestRoot,
16
+ mergeRegister,
17
+ } from "@lexical/utils"
18
+ import {
19
+ $createParagraphNode,
20
+ $getNodeByKey,
21
+ $getNearestNodeFromDOMNode,
22
+ $getSelection,
23
+ $isRangeSelection,
24
+ COMMAND_PRIORITY_EDITOR,
25
+ COMMAND_PRIORITY_LOW,
26
+ createCommand,
27
+ KEY_ARROW_DOWN_COMMAND,
28
+ KEY_ARROW_LEFT_COMMAND,
29
+ KEY_ARROW_RIGHT_COMMAND,
30
+ KEY_ARROW_UP_COMMAND,
31
+ LexicalEditor,
32
+ } from "lexical"
33
+ import type { ElementNode, LexicalCommand, LexicalNode, NodeKey } from "lexical"
34
+
35
+ import {
36
+ $createLayoutContainerNode,
37
+ $isLayoutContainerNode,
38
+ LayoutContainerNode,
39
+ } from "../nodes/layout-container-node"
40
+ import {
41
+ $createLayoutItemNode,
42
+ $isLayoutItemNode,
43
+ LayoutItemNode,
44
+ } from "../nodes/layout-item-node"
45
+ import { Button } from "../ui/button"
46
+ import {
47
+ Select,
48
+ SelectContent,
49
+ SelectItem,
50
+ SelectTrigger,
51
+ SelectValue,
52
+ } from "../ui/select"
53
+ import {
54
+ ColorPicker,
55
+ ColorPickerAlphaSlider,
56
+ ColorPickerArea,
57
+ ColorPickerContent,
58
+ ColorPickerEyeDropper,
59
+ ColorPickerFormatSelect,
60
+ ColorPickerHueSlider,
61
+ ColorPickerInput,
62
+ ColorPickerPresets,
63
+ ColorPickerTrigger,
64
+ } from "../editor-ui/color-picker"
65
+ import { Input } from "../ui/input"
66
+ import { Flex } from "../ui/flex"
67
+ import { useEditorModal } from "../editor-hooks/use-modal"
68
+ import { logger } from "../lib/logger"
69
+
70
+ const LAYOUTS = [
71
+ { label: "1 column", value: "1fr" },
72
+ { label: "2 columns (equal width)", value: "1fr 1fr" },
73
+ { label: "2 columns (75% - 25%)", value: "3fr 1fr" },
74
+ { label: "2 columns (25% - 75%)", value: "1fr 3fr" },
75
+ { label: "3 columns (equal width)", value: "1fr 1fr 1fr" },
76
+ { label: "3 columns (25% - 50% - 25%)", value: "1fr 2fr 1fr" },
77
+ { label: "4 columns (equal width)", value: "1fr 1fr 1fr 1fr" },
78
+ ]
79
+
80
+ type InsertLayoutPayload =
81
+ | string
82
+ | {
83
+ template: string
84
+ itemBackgroundColor?: string
85
+ itemPaddingPx?: number
86
+ itemBorderRadiusPx?: number
87
+ }
88
+
89
+ type LayoutDialogValues = {
90
+ template: string
91
+ itemBackgroundColor: string
92
+ itemPaddingPx: number
93
+ itemBorderRadiusPx: number
94
+ }
95
+
96
+ type LayoutTargetPayload = {
97
+ containerKey: NodeKey
98
+ layoutItemKey: NodeKey
99
+ values: LayoutDialogValues
100
+ }
101
+
102
+ export function InsertLayoutDialog({
103
+ activeEditor,
104
+ onClose,
105
+ initialValues,
106
+ submitLabel = "Insert",
107
+ onSubmit,
108
+ }: {
109
+ activeEditor: LexicalEditor
110
+ onClose: () => void
111
+ initialValues?: Partial<LayoutDialogValues>
112
+ submitLabel?: string
113
+ onSubmit?: (values: LayoutDialogValues) => void
114
+ }): JSX.Element {
115
+ const [layout, setLayout] = useState(initialValues?.template ?? (LAYOUTS[0]?.value || "1fr"))
116
+ const [backgroundColor, setBackgroundColor] = useState(
117
+ initialValues?.itemBackgroundColor ?? "#ffffff"
118
+ )
119
+ const [paddingPx, setPaddingPx] = useState(initialValues?.itemPaddingPx ?? 12)
120
+ const [borderRadiusPx, setBorderRadiusPx] = useState(
121
+ initialValues?.itemBorderRadiusPx ?? 8
122
+ )
123
+ const layoutRef = useRef(layout)
124
+ const backgroundColorRef = useRef(backgroundColor)
125
+ const paddingPxRef = useRef(paddingPx)
126
+ const borderRadiusPxRef = useRef(borderRadiusPx)
127
+
128
+ useEffect(() => {
129
+ layoutRef.current = layout
130
+ }, [layout])
131
+ useEffect(() => {
132
+ backgroundColorRef.current = backgroundColor
133
+ }, [backgroundColor])
134
+ useEffect(() => {
135
+ paddingPxRef.current = paddingPx
136
+ }, [paddingPx])
137
+ useEffect(() => {
138
+ borderRadiusPxRef.current = borderRadiusPx
139
+ }, [borderRadiusPx])
140
+
141
+ const onBackgroundColorChange = (value: string) => {
142
+ backgroundColorRef.current = value
143
+ setBackgroundColor(value)
144
+ }
145
+
146
+ const onPaddingChange = (next: number) => {
147
+ const value = Math.min(Math.max(next, 0), 64)
148
+ paddingPxRef.current = value
149
+ setPaddingPx(value)
150
+ }
151
+
152
+ const onBorderRadiusChange = (next: number) => {
153
+ const value = Math.min(Math.max(next, 0), 64)
154
+ borderRadiusPxRef.current = value
155
+ setBorderRadiusPx(value)
156
+ }
157
+ const buttonLabel = LAYOUTS.find((item) => item.value === layout)?.label
158
+
159
+ const onClick = () => {
160
+ const values: LayoutDialogValues = {
161
+ template: layoutRef.current,
162
+ itemBackgroundColor: backgroundColorRef.current,
163
+ itemPaddingPx: paddingPxRef.current,
164
+ itemBorderRadiusPx: borderRadiusPxRef.current,
165
+ }
166
+ logger.info("[Layout] Submit dialog values", {
167
+ mode: submitLabel,
168
+ values,
169
+ })
170
+ if (onSubmit) {
171
+ onSubmit(values)
172
+ onClose()
173
+ return
174
+ }
175
+ activeEditor.dispatchCommand(INSERT_LAYOUT_COMMAND, values)
176
+ onClose()
177
+ }
178
+
179
+ return (
180
+ <>
181
+ <Select onValueChange={setLayout} value={layout}>
182
+ <SelectTrigger className="editor-input-lg editor-w-full">
183
+ <SelectValue placeholder={buttonLabel} />
184
+ </SelectTrigger>
185
+ <SelectContent className="editor-w-full">
186
+ {LAYOUTS.map(({ label, value }) => (
187
+ <SelectItem key={value} value={value}>
188
+ {label}
189
+ </SelectItem>
190
+ ))}
191
+ </SelectContent>
192
+ </Select>
193
+ <div className="editor-layout-dialog-grid">
194
+ <div className="editor-layout-dialog-group">
195
+ <div className="editor-text-xs-muted">Background</div>
196
+ <ColorPicker
197
+ modal
198
+ defaultFormat="hex"
199
+ value={backgroundColor}
200
+ onValueChange={onBackgroundColorChange}
201
+ >
202
+ <ColorPickerTrigger asChild>
203
+ <Button
204
+ variant="outline"
205
+ className="editor-layout-color-trigger"
206
+ >
207
+ <span
208
+ className="editor-layout-color-preview"
209
+ style={{ backgroundColor }}
210
+ />
211
+ <span>{backgroundColor.toUpperCase()}</span>
212
+ </Button>
213
+ </ColorPickerTrigger>
214
+ <ColorPickerContent>
215
+ <ColorPickerArea />
216
+ <Flex align="center" gap={2}>
217
+ <ColorPickerEyeDropper />
218
+ <Flex direction="column" gap={2} className="editor-flex-1">
219
+ <ColorPickerHueSlider />
220
+ <ColorPickerAlphaSlider />
221
+ </Flex>
222
+ </Flex>
223
+ <Flex align="center" gap={2}>
224
+ <ColorPickerFormatSelect />
225
+ <ColorPickerInput />
226
+ </Flex>
227
+ <ColorPickerPresets />
228
+ </ColorPickerContent>
229
+ </ColorPicker>
230
+ </div>
231
+ <div className="editor-layout-dialog-group">
232
+ <div className="editor-text-xs-muted">Padding (px)</div>
233
+ <Input
234
+ type="number"
235
+ min={0}
236
+ max={64}
237
+ step={1}
238
+ value={paddingPx}
239
+ onChange={(event) => {
240
+ const next = Number.parseInt(event.target.value, 10)
241
+ if (Number.isFinite(next)) {
242
+ onPaddingChange(next)
243
+ } else {
244
+ onPaddingChange(0)
245
+ }
246
+ }}
247
+ className="editor-input-lg editor-w-full"
248
+ />
249
+ </div>
250
+ <div className="editor-layout-dialog-group">
251
+ <div className="editor-text-xs-muted">Border radius (px)</div>
252
+ <Input
253
+ type="number"
254
+ min={0}
255
+ max={64}
256
+ step={1}
257
+ value={borderRadiusPx}
258
+ onChange={(event) => {
259
+ const next = Number.parseInt(event.target.value, 10)
260
+ if (Number.isFinite(next)) {
261
+ onBorderRadiusChange(next)
262
+ } else {
263
+ onBorderRadiusChange(0)
264
+ }
265
+ }}
266
+ className="editor-input-lg editor-w-full"
267
+ />
268
+ </div>
269
+ </div>
270
+ <Button onClick={onClick}>{submitLabel}</Button>
271
+ </>
272
+ )
273
+ }
274
+
275
+ export const INSERT_LAYOUT_COMMAND: LexicalCommand<InsertLayoutPayload> =
276
+ createCommand<InsertLayoutPayload>()
277
+
278
+ export const UPDATE_LAYOUT_COMMAND: LexicalCommand<{
279
+ template: string
280
+ nodeKey: NodeKey
281
+ }> = createCommand<{ template: string; nodeKey: NodeKey }>()
282
+
283
+ export const OPEN_UPDATE_LAYOUT_MODAL_COMMAND: LexicalCommand<{
284
+ layoutItemKey: NodeKey
285
+ }> = createCommand<{ layoutItemKey: NodeKey }>("OPEN_UPDATE_LAYOUT_MODAL_COMMAND")
286
+
287
+ export function LayoutPlugin(): JSX.Element | null {
288
+ const [editor] = useLexicalComposerContext()
289
+ const [modal, showModal] = useEditorModal()
290
+ useEffect(() => {
291
+ if (!editor.hasNodes([LayoutContainerNode, LayoutItemNode])) {
292
+ throw new Error(
293
+ "LayoutPlugin: LayoutContainerNode, or LayoutItemNode not registered on editor"
294
+ )
295
+ }
296
+
297
+ const $onEscape = (before: boolean) => {
298
+ const selection = $getSelection()
299
+ if (
300
+ $isRangeSelection(selection) &&
301
+ selection.isCollapsed() &&
302
+ selection.anchor.offset === 0
303
+ ) {
304
+ const container = $findMatchingParent(
305
+ selection.anchor.getNode(),
306
+ $isLayoutContainerNode
307
+ )
308
+
309
+ if ($isLayoutContainerNode(container) && container !== undefined && container !== null) {
310
+ const parent = container.getParent<ElementNode>()
311
+ if (parent === null) {
312
+ return false
313
+ }
314
+ const child = before
315
+ ? parent.getFirstChild<LexicalNode>()
316
+ : parent.getLastChild<LexicalNode>()
317
+ const descendant = before
318
+ ? container.getFirstDescendant<LexicalNode>()?.getKey()
319
+ : container.getLastDescendant<LexicalNode>()?.getKey()
320
+
321
+ if (
322
+ child === container &&
323
+ selection.anchor.key === descendant
324
+ ) {
325
+ if (before) {
326
+ container.insertBefore($createParagraphNode())
327
+ } else {
328
+ container.insertAfter($createParagraphNode())
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ return false
335
+ }
336
+
337
+ const extractStyleValue = (style: string, property: string): string | undefined => {
338
+ const match = style.match(new RegExp(`${property}\\s*:\\s*([^;]+)`, "i"))
339
+ return match?.[1]?.trim()
340
+ }
341
+
342
+ const extractNumericStyle = (style: string, property: string): number | undefined => {
343
+ const value = extractStyleValue(style, property)
344
+ if (!value) {
345
+ return undefined
346
+ }
347
+ const match = value.match(/^(\d+)px$/i)
348
+ if (!match?.[1]) {
349
+ return undefined
350
+ }
351
+ const parsed = Number.parseInt(match[1], 10)
352
+ return Number.isFinite(parsed) ? parsed : undefined
353
+ }
354
+
355
+ const buildLayoutItemStyle = ({
356
+ itemBackgroundColor,
357
+ itemPaddingPx,
358
+ itemBorderRadiusPx,
359
+ }: LayoutDialogValues): string => {
360
+ const itemStyles: string[] = []
361
+ if (itemBackgroundColor.trim()) {
362
+ itemStyles.push(`background-color: ${itemBackgroundColor.trim()}`)
363
+ }
364
+ itemStyles.push(`padding: ${Math.min(Math.max(itemPaddingPx, 0), 64)}px`)
365
+ itemStyles.push(
366
+ `border-radius: ${Math.min(Math.max(itemBorderRadiusPx, 0), 64)}px`
367
+ )
368
+ return itemStyles.join("; ")
369
+ }
370
+
371
+ const syncLayoutItemDomStyle = (itemKey: NodeKey, values: LayoutDialogValues) => {
372
+ const element = editor.getElementByKey(itemKey)
373
+ if (!(element instanceof HTMLElement)) {
374
+ logger.warn("[Layout] Cannot resolve DOM element by item key", { itemKey })
375
+ return
376
+ }
377
+
378
+ const background = values.itemBackgroundColor.trim()
379
+ const padding = `${Math.min(Math.max(values.itemPaddingPx, 0), 64)}px`
380
+ const borderRadius = `${Math.min(Math.max(values.itemBorderRadiusPx, 0), 64)}px`
381
+ if (background) {
382
+ element.style.setProperty("background-color", background)
383
+ }
384
+ element.style.setProperty("padding", padding, "important")
385
+ element.style.setProperty("border-radius", borderRadius)
386
+
387
+ logger.info("[Layout] Synced DOM style by key", {
388
+ itemKey,
389
+ domStyle: element.getAttribute("style"),
390
+ })
391
+ }
392
+
393
+ const updateLayoutContainerTemplate = (
394
+ container: LayoutContainerNode,
395
+ template: string
396
+ ) => {
397
+ const itemsCount = getItemsCountFromTemplate(template)
398
+ const prevItemsCount = getItemsCountFromTemplate(container.getTemplateColumns())
399
+
400
+ if (itemsCount > prevItemsCount) {
401
+ for (let i = prevItemsCount; i < itemsCount; i++) {
402
+ container.append($createLayoutItemNode().append($createParagraphNode()))
403
+ }
404
+ } else if (itemsCount < prevItemsCount) {
405
+ for (let i = prevItemsCount - 1; i >= itemsCount; i--) {
406
+ const layoutItem = container.getChildAtIndex<LexicalNode>(i)
407
+ if ($isLayoutItemNode(layoutItem)) {
408
+ layoutItem.remove()
409
+ }
410
+ }
411
+ }
412
+
413
+ container.setTemplateColumns(template)
414
+ }
415
+
416
+ const getLayoutPayloadFromTarget = (
417
+ target: HTMLElement
418
+ ): LayoutTargetPayload | null => {
419
+ let payload: LayoutTargetPayload | null = null
420
+
421
+ editor.read(() => {
422
+ const lexicalNode = $getNearestNodeFromDOMNode(target)
423
+ if (!lexicalNode) {
424
+ logger.warn("[Layout] Cannot resolve lexical node from DOM target")
425
+ return
426
+ }
427
+ const layoutItem = $findMatchingParent(lexicalNode, (node) =>
428
+ $isLayoutItemNode(node)
429
+ )
430
+ if (!$isLayoutItemNode(layoutItem)) {
431
+ logger.warn("[Layout] Click target is not layout item node")
432
+ return
433
+ }
434
+ const parentContainer = layoutItem.getParent()
435
+ if (!$isLayoutContainerNode(parentContainer)) {
436
+ logger.warn("[Layout] Layout item has no layout container parent")
437
+ return
438
+ }
439
+
440
+ const style = layoutItem.getStyle()
441
+ payload = {
442
+ containerKey: parentContainer.getKey(),
443
+ layoutItemKey: layoutItem.getKey(),
444
+ values: {
445
+ template: parentContainer.getTemplateColumns(),
446
+ itemBackgroundColor:
447
+ extractStyleValue(style, "background-color") ?? "#ffffff",
448
+ itemPaddingPx: extractNumericStyle(style, "padding") ?? 12,
449
+ itemBorderRadiusPx: extractNumericStyle(style, "border-radius") ?? 8,
450
+ },
451
+ }
452
+ logger.debug("[Layout] Resolved payload from target", payload)
453
+ })
454
+
455
+ return payload
456
+ }
457
+
458
+ const openUpdateLayoutModal = (payload: LayoutTargetPayload) => {
459
+ logger.info("[Layout] Open Update Columns Layout", payload)
460
+ showModal("Update Columns Layout", (onClose) => (
461
+ <InsertLayoutDialog
462
+ activeEditor={editor}
463
+ onClose={onClose}
464
+ initialValues={payload.values}
465
+ submitLabel="Update"
466
+ onSubmit={(values) => {
467
+ logger.info("[Layout] Start applying update", { payload, values })
468
+ editor.update(() => {
469
+ const nextStyle = buildLayoutItemStyle(values)
470
+ logger.info("[Layout] Computed next style", { nextStyle })
471
+ let updatedItemsCount = 0
472
+ const container = $getNodeByKey<LexicalNode>(payload.containerKey)
473
+ if ($isLayoutContainerNode(container)) {
474
+ updateLayoutContainerTemplate(container, values.template)
475
+ const items = container.getChildren<LexicalNode>()
476
+ logger.info("[Layout] Updating container items", {
477
+ containerKey: payload.containerKey,
478
+ itemsCount: items.length,
479
+ })
480
+ for (const item of items) {
481
+ if ($isLayoutItemNode(item)) {
482
+ item.setStyle(nextStyle)
483
+ updatedItemsCount += 1
484
+ logger.info("[Layout] Applied style to item", {
485
+ itemKey: item.getKey(),
486
+ appliedStyle: item.getStyle(),
487
+ })
488
+ syncLayoutItemDomStyle(item.getKey(), values)
489
+ }
490
+ }
491
+ }
492
+
493
+ // Always apply clicked item as source-of-truth (handles stale/mismatched container).
494
+ const layoutItem = $getNodeByKey<LexicalNode>(payload.layoutItemKey)
495
+ if ($isLayoutItemNode(layoutItem)) {
496
+ layoutItem.setStyle(nextStyle)
497
+ logger.info("[Layout] Applied style to clicked layout item", {
498
+ layoutItemKey: payload.layoutItemKey,
499
+ appliedStyle: layoutItem.getStyle(),
500
+ })
501
+ syncLayoutItemDomStyle(layoutItem.getKey(), values)
502
+ logger.info("[Layout] Update summary", {
503
+ containerKey: payload.containerKey,
504
+ layoutItemKey: payload.layoutItemKey,
505
+ updatedItemsCount,
506
+ })
507
+ return
508
+ }
509
+ logger.error("[Layout] Failed to resolve container and layout item keys", {
510
+ containerKey: payload.containerKey,
511
+ layoutItemKey: payload.layoutItemKey,
512
+ })
513
+ })
514
+ }}
515
+ />
516
+ ))
517
+ }
518
+
519
+ return mergeRegister(
520
+ editor.registerCommand(
521
+ OPEN_UPDATE_LAYOUT_MODAL_COMMAND,
522
+ ({ layoutItemKey }) => {
523
+ const element = editor.getElementByKey(layoutItemKey)
524
+ if (element instanceof HTMLElement) {
525
+ const layoutPayload = getLayoutPayloadFromTarget(element)
526
+ if (layoutPayload) openUpdateLayoutModal(layoutPayload)
527
+ }
528
+ return true
529
+ },
530
+ COMMAND_PRIORITY_EDITOR
531
+ ),
532
+ // When layout is the last child pressing down/right arrow will insert paragraph
533
+ // below it to allow adding more content. It's similar what $insertBlockNode
534
+ // (mainly for decorators), except it'll always be possible to continue adding
535
+ // new content even if trailing paragraph is accidentally deleted
536
+ editor.registerCommand(
537
+ KEY_ARROW_DOWN_COMMAND,
538
+ () => $onEscape(false),
539
+ COMMAND_PRIORITY_LOW
540
+ ),
541
+ editor.registerCommand(
542
+ KEY_ARROW_RIGHT_COMMAND,
543
+ () => $onEscape(false),
544
+ COMMAND_PRIORITY_LOW
545
+ ),
546
+ // When layout is the first child pressing up/left arrow will insert paragraph
547
+ // above it to allow adding more content. It's similar what $insertBlockNode
548
+ // (mainly for decorators), except it'll always be possible to continue adding
549
+ // new content even if leading paragraph is accidentally deleted
550
+ editor.registerCommand(
551
+ KEY_ARROW_UP_COMMAND,
552
+ () => $onEscape(true),
553
+ COMMAND_PRIORITY_LOW
554
+ ),
555
+ editor.registerCommand(
556
+ KEY_ARROW_LEFT_COMMAND,
557
+ () => $onEscape(true),
558
+ COMMAND_PRIORITY_LOW
559
+ ),
560
+ editor.registerCommand(
561
+ INSERT_LAYOUT_COMMAND,
562
+ (payload) => {
563
+ editor.update(() => {
564
+ const template = typeof payload === "string" ? payload : payload.template
565
+ const itemBackgroundColor =
566
+ typeof payload === "string"
567
+ ? undefined
568
+ : payload.itemBackgroundColor?.trim()
569
+ const itemPaddingPx =
570
+ typeof payload === "string"
571
+ ? undefined
572
+ : typeof payload.itemPaddingPx === "number" &&
573
+ Number.isFinite(payload.itemPaddingPx)
574
+ ? Math.min(Math.max(payload.itemPaddingPx, 0), 64)
575
+ : undefined
576
+ const itemBorderRadiusPx =
577
+ typeof payload === "string"
578
+ ? undefined
579
+ : typeof payload.itemBorderRadiusPx === "number" &&
580
+ Number.isFinite(payload.itemBorderRadiusPx)
581
+ ? Math.min(Math.max(payload.itemBorderRadiusPx, 0), 64)
582
+ : undefined
583
+
584
+ const itemStyle = buildLayoutItemStyle({
585
+ template,
586
+ itemBackgroundColor: itemBackgroundColor ?? "#ffffff",
587
+ itemPaddingPx: itemPaddingPx ?? 12,
588
+ itemBorderRadiusPx: itemBorderRadiusPx ?? 8,
589
+ })
590
+
591
+ const container = $createLayoutContainerNode(template)
592
+ const itemsCount = getItemsCountFromTemplate(template)
593
+
594
+ for (let i = 0; i < itemsCount; i++) {
595
+ const item = $createLayoutItemNode()
596
+ if (itemStyle) {
597
+ item.setStyle(itemStyle)
598
+ }
599
+ container.append(
600
+ item.append($createParagraphNode())
601
+ )
602
+ }
603
+
604
+ $insertNodeToNearestRoot(container)
605
+ container.selectStart()
606
+ })
607
+
608
+ return true
609
+ },
610
+ COMMAND_PRIORITY_EDITOR
611
+ ),
612
+ editor.registerCommand(
613
+ UPDATE_LAYOUT_COMMAND,
614
+ ({ template, nodeKey }) => {
615
+ editor.update(() => {
616
+ const container = $getNodeByKey<LexicalNode>(nodeKey)
617
+
618
+ if (!$isLayoutContainerNode(container)) {
619
+ return
620
+ }
621
+ updateLayoutContainerTemplate(container, template)
622
+ })
623
+
624
+ return true
625
+ },
626
+ COMMAND_PRIORITY_EDITOR
627
+ ),
628
+ // Structure enforcing transformers for each node type. In case nesting structure is not
629
+ // "Container > Item" it'll unwrap nodes and convert it back
630
+ // to regular content.
631
+ editor.registerNodeTransform(LayoutItemNode, (node) => {
632
+ const parent = node.getParent<ElementNode>()
633
+ if (!$isLayoutContainerNode(parent)) {
634
+ const children = node.getChildren<LexicalNode>()
635
+ for (const child of children) {
636
+ node.insertBefore(child)
637
+ }
638
+ node.remove()
639
+ }
640
+ }),
641
+ editor.registerNodeTransform(LayoutContainerNode, (node) => {
642
+ const children = node.getChildren<LexicalNode>()
643
+ if (!children.every($isLayoutItemNode)) {
644
+ for (const child of children) {
645
+ node.insertBefore(child)
646
+ }
647
+ node.remove()
648
+ }
649
+ })
650
+ )
651
+ }, [editor, showModal])
652
+
653
+ return <>{modal}</>
654
+ }
655
+
656
+ function getItemsCountFromTemplate(template: string): number {
657
+ return template.trim().split(/\s+/).length
658
+ }
@@ -0,0 +1,18 @@
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 { JSX } from "react"
12
+ import { LinkPlugin as LexicalLinkPlugin } from "@lexical/react/LexicalLinkPlugin"
13
+
14
+ import { validateUrl } from "../utils/url"
15
+
16
+ export function LinkPlugin(): JSX.Element {
17
+ return <LexicalLinkPlugin validateUrl={validateUrl} />
18
+ }