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