WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit a21c47b

Browse files
authored
feat(richtext-lexical): add align support to upload nodes (#14720)
DecoratorBlock already had a `__format` property. The format in which it was stored in Lexical was inconsistent with that of ElementNode, and the `FORMAT_ELEMENT_COMMAND` command was simply ignoring it. I fixed the problem with a Lexical plugin within AlignFeature. After <img width="944" height="659" alt="image" src="https://github.com/user-attachments/assets/65d255ee-e973-44bc-bb57-f9888090f2c7" />
1 parent 5542e56 commit a21c47b

File tree

4 files changed

+126
-18
lines changed

4 files changed

+126
-18
lines changed

packages/richtext-lexical/src/features/align/client/index.tsx

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
'use client'
22

3-
import { $isElementNode, $isRangeSelection, FORMAT_ELEMENT_COMMAND } from 'lexical'
3+
import type { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
4+
import type { ElementFormatType, ElementNode } from 'lexical'
5+
6+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
7+
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
8+
import { $findMatchingParent } from '@lexical/utils'
9+
import {
10+
$getSelection,
11+
$isElementNode,
12+
$isNodeSelection,
13+
$isRangeSelection,
14+
COMMAND_PRIORITY_LOW,
15+
FORMAT_ELEMENT_COMMAND,
16+
} from 'lexical'
17+
import { useEffect } from 'react'
418

519
import type { ToolbarGroup } from '../../toolbars/types.js'
620

@@ -11,6 +25,15 @@ import { AlignRightIcon } from '../../../lexical/ui/icons/AlignRight/index.js'
1125
import { createClientFeature } from '../../../utilities/createClientFeature.js'
1226
import { toolbarAlignGroupWithItems } from './toolbarAlignGroup.js'
1327

28+
// DecoratorBlockNode has format, but Lexical forgot
29+
// to add the getters like ElementNode does.
30+
const getFormatType = (node: DecoratorBlockNode | ElementNode): ElementFormatType => {
31+
if ($isElementNode(node)) {
32+
return node.getFormatType()
33+
}
34+
return node.__format
35+
}
36+
1437
const toolbarGroups: ToolbarGroup[] = [
1538
toolbarAlignGroupWithItems([
1639
{
@@ -20,15 +43,15 @@ const toolbarGroups: ToolbarGroup[] = [
2043
return false
2144
}
2245
for (const node of selection.getNodes()) {
23-
if ($isElementNode(node)) {
24-
if (node.getFormatType() === 'left') {
46+
if ($isElementNode(node) || $isDecoratorBlockNode(node)) {
47+
if (getFormatType(node) === 'left') {
2548
continue
2649
}
2750
}
2851

2952
const parent = node.getParent()
30-
if ($isElementNode(parent)) {
31-
if (parent.getFormatType() === 'left') {
53+
if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) {
54+
if (getFormatType(parent) === 'left') {
3255
continue
3356
}
3457
}
@@ -53,15 +76,15 @@ const toolbarGroups: ToolbarGroup[] = [
5376
return false
5477
}
5578
for (const node of selection.getNodes()) {
56-
if ($isElementNode(node)) {
57-
if (node.getFormatType() === 'center') {
79+
if ($isElementNode(node) || $isDecoratorBlockNode(node)) {
80+
if (getFormatType(node) === 'center') {
5881
continue
5982
}
6083
}
6184

6285
const parent = node.getParent()
63-
if ($isElementNode(parent)) {
64-
if (parent.getFormatType() === 'center') {
86+
if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) {
87+
if (getFormatType(parent) === 'center') {
6588
continue
6689
}
6790
}
@@ -86,15 +109,15 @@ const toolbarGroups: ToolbarGroup[] = [
86109
return false
87110
}
88111
for (const node of selection.getNodes()) {
89-
if ($isElementNode(node)) {
90-
if (node.getFormatType() === 'right') {
112+
if ($isElementNode(node) || $isDecoratorBlockNode(node)) {
113+
if (getFormatType(node) === 'right') {
91114
continue
92115
}
93116
}
94117

95118
const parent = node.getParent()
96-
if ($isElementNode(parent)) {
97-
if (parent.getFormatType() === 'right') {
119+
if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) {
120+
if (getFormatType(parent) === 'right') {
98121
continue
99122
}
100123
}
@@ -119,15 +142,15 @@ const toolbarGroups: ToolbarGroup[] = [
119142
return false
120143
}
121144
for (const node of selection.getNodes()) {
122-
if ($isElementNode(node)) {
123-
if (node.getFormatType() === 'justify') {
145+
if ($isElementNode(node) || $isDecoratorBlockNode(node)) {
146+
if (getFormatType(node) === 'justify') {
124147
continue
125148
}
126149
}
127150

128151
const parent = node.getParent()
129-
if ($isElementNode(parent)) {
130-
if (parent.getFormatType() === 'justify') {
152+
if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) {
153+
if (getFormatType(parent) === 'justify') {
131154
continue
132155
}
133156
}
@@ -148,7 +171,46 @@ const toolbarGroups: ToolbarGroup[] = [
148171
]),
149172
]
150173

174+
const AlignPlugin = () => {
175+
const [editor] = useLexicalComposerContext()
176+
177+
useEffect(() => {
178+
// Just like the default Lexical configuration, but in
179+
// addition to ElementNode we also set DecoratorBlocks
180+
return editor.registerCommand(
181+
FORMAT_ELEMENT_COMMAND,
182+
(format) => {
183+
const selection = $getSelection()
184+
if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
185+
return false
186+
}
187+
const nodes = selection.getNodes()
188+
for (const node of nodes) {
189+
const element = $findMatchingParent(
190+
node,
191+
(parentNode): parentNode is DecoratorBlockNode | ElementNode =>
192+
($isElementNode(parentNode) || $isDecoratorBlockNode(parentNode)) &&
193+
!parentNode.isInline(),
194+
)
195+
if (element !== null) {
196+
element.setFormat(format)
197+
}
198+
}
199+
return true
200+
},
201+
COMMAND_PRIORITY_LOW,
202+
)
203+
}, [editor])
204+
return null
205+
}
206+
151207
export const AlignFeatureClient = createClientFeature({
208+
plugins: [
209+
{
210+
Component: AlignPlugin,
211+
position: 'normal',
212+
},
213+
],
152214
toolbarFixed: {
153215
groups: toolbarGroups,
154216
},

packages/richtext-lexical/src/features/upload/client/component/index.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,27 @@
1111
font-family: var(--font-body);
1212
margin-block: base(0.5);
1313

14+
// Alignment support using :has() selector
15+
&:has([data-align='center']) {
16+
width: fit-content;
17+
margin-left: auto;
18+
margin-right: auto;
19+
}
20+
21+
&:has([data-align='right']),
22+
&:has([data-align='end']) {
23+
width: fit-content;
24+
margin-left: auto;
25+
margin-right: 0;
26+
}
27+
28+
&:has([data-align='left']),
29+
&:has([data-align='start']) {
30+
width: fit-content;
31+
margin-left: 0;
32+
margin-right: auto;
33+
}
34+
1435
&:hover {
1536
border: 1px solid var(--theme-elevation-150);
1637
}

packages/richtext-lexical/src/features/upload/client/component/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const UploadComponent: React.FC<ElementProps> = (props) => {
4444
const {
4545
className: baseClass,
4646
data: { fields, relationTo, value },
47+
format,
4748
nodeKey,
4849
} = props
4950

@@ -106,7 +107,7 @@ export const UploadComponent: React.FC<ElementProps> = (props) => {
106107
}, [editor, nodeKey])
107108

108109
const updateUpload = useCallback(
109-
(data: Data) => {
110+
(_data: Data) => {
110111
setParams({
111112
...initialParams,
112113
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
@@ -150,6 +151,7 @@ export const UploadComponent: React.FC<ElementProps> = (props) => {
150151
return (
151152
<div
152153
className={`${baseClass}__contents ${baseClass}__contents--${aspectRatio}`}
154+
data-align={format || undefined}
153155
data-filename={data?.filename}
154156
ref={uploadRef}
155157
>

test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ describe('Lexical Fully Featured', () => {
6464
await expect(paragraph).toHaveText('')
6565
})
6666

67+
test('ensure upload node can be aligned', async ({ page }) => {
68+
await lexical.slashCommand('upload')
69+
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
70+
await lexical.drawer.getByText('Paste URL').click()
71+
const url =
72+
'https://raw.githubusercontent.com/payloadcms/website/refs/heads/main/public/images/universal-truth.jpg'
73+
await lexical.drawer.locator('.file-field__remote-file').fill(url)
74+
await lexical.drawer.getByText('Add file').click()
75+
await lexical.save('drawer')
76+
const img = lexical.editor.locator('img').first()
77+
await img.click()
78+
const imgBoxBeforeCenter = await img.boundingBox()
79+
await expect(() => {
80+
expect(imgBoxBeforeCenter?.x).toBeLessThan(150)
81+
}).toPass({ timeout: 100 })
82+
await page.getByLabel('align dropdown').click()
83+
await page.getByLabel('Align Center').click()
84+
const imgBoxAfterCenter = await img.boundingBox()
85+
await expect(() => {
86+
expect(imgBoxAfterCenter?.x).toBeGreaterThan(150)
87+
}).toPass({ timeout: 100 })
88+
})
89+
6790
test('ControlOrMeta+A inside input should select all the text inside the input', async ({
6891
page,
6992
}) => {

0 commit comments

Comments
 (0)