diff --git a/packages/richtext-lexical/src/features/align/client/index.tsx b/packages/richtext-lexical/src/features/align/client/index.tsx index 5ae6204ec55..fc86d57d0c3 100644 --- a/packages/richtext-lexical/src/features/align/client/index.tsx +++ b/packages/richtext-lexical/src/features/align/client/index.tsx @@ -1,6 +1,20 @@ 'use client' -import { $isElementNode, $isRangeSelection, FORMAT_ELEMENT_COMMAND } from 'lexical' +import type { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' +import type { ElementFormatType, ElementNode } from 'lexical' + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' +import { $findMatchingParent } from '@lexical/utils' +import { + $getSelection, + $isElementNode, + $isNodeSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + FORMAT_ELEMENT_COMMAND, +} from 'lexical' +import { useEffect } from 'react' import type { ToolbarGroup } from '../../toolbars/types.js' @@ -11,6 +25,15 @@ import { AlignRightIcon } from '../../../lexical/ui/icons/AlignRight/index.js' import { createClientFeature } from '../../../utilities/createClientFeature.js' import { toolbarAlignGroupWithItems } from './toolbarAlignGroup.js' +// DecoratorBlockNode has format, but Lexical forgot +// to add the getters like ElementNode does. +const getFormatType = (node: DecoratorBlockNode | ElementNode): ElementFormatType => { + if ($isElementNode(node)) { + return node.getFormatType() + } + return node.__format +} + const toolbarGroups: ToolbarGroup[] = [ toolbarAlignGroupWithItems([ { @@ -20,15 +43,15 @@ const toolbarGroups: ToolbarGroup[] = [ return false } for (const node of selection.getNodes()) { - if ($isElementNode(node)) { - if (node.getFormatType() === 'left') { + if ($isElementNode(node) || $isDecoratorBlockNode(node)) { + if (getFormatType(node) === 'left') { continue } } const parent = node.getParent() - if ($isElementNode(parent)) { - if (parent.getFormatType() === 'left') { + if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) { + if (getFormatType(parent) === 'left') { continue } } @@ -53,15 +76,15 @@ const toolbarGroups: ToolbarGroup[] = [ return false } for (const node of selection.getNodes()) { - if ($isElementNode(node)) { - if (node.getFormatType() === 'center') { + if ($isElementNode(node) || $isDecoratorBlockNode(node)) { + if (getFormatType(node) === 'center') { continue } } const parent = node.getParent() - if ($isElementNode(parent)) { - if (parent.getFormatType() === 'center') { + if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) { + if (getFormatType(parent) === 'center') { continue } } @@ -86,15 +109,15 @@ const toolbarGroups: ToolbarGroup[] = [ return false } for (const node of selection.getNodes()) { - if ($isElementNode(node)) { - if (node.getFormatType() === 'right') { + if ($isElementNode(node) || $isDecoratorBlockNode(node)) { + if (getFormatType(node) === 'right') { continue } } const parent = node.getParent() - if ($isElementNode(parent)) { - if (parent.getFormatType() === 'right') { + if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) { + if (getFormatType(parent) === 'right') { continue } } @@ -119,15 +142,15 @@ const toolbarGroups: ToolbarGroup[] = [ return false } for (const node of selection.getNodes()) { - if ($isElementNode(node)) { - if (node.getFormatType() === 'justify') { + if ($isElementNode(node) || $isDecoratorBlockNode(node)) { + if (getFormatType(node) === 'justify') { continue } } const parent = node.getParent() - if ($isElementNode(parent)) { - if (parent.getFormatType() === 'justify') { + if ($isElementNode(parent) || $isDecoratorBlockNode(parent)) { + if (getFormatType(parent) === 'justify') { continue } } @@ -148,7 +171,46 @@ const toolbarGroups: ToolbarGroup[] = [ ]), ] +const AlignPlugin = () => { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + // Just like the default Lexical configuration, but in + // addition to ElementNode we also set DecoratorBlocks + return editor.registerCommand( + FORMAT_ELEMENT_COMMAND, + (format) => { + const selection = $getSelection() + if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) { + return false + } + const nodes = selection.getNodes() + for (const node of nodes) { + const element = $findMatchingParent( + node, + (parentNode): parentNode is DecoratorBlockNode | ElementNode => + ($isElementNode(parentNode) || $isDecoratorBlockNode(parentNode)) && + !parentNode.isInline(), + ) + if (element !== null) { + element.setFormat(format) + } + } + return true + }, + COMMAND_PRIORITY_LOW, + ) + }, [editor]) + return null +} + export const AlignFeatureClient = createClientFeature({ + plugins: [ + { + Component: AlignPlugin, + position: 'normal', + }, + ], toolbarFixed: { groups: toolbarGroups, }, diff --git a/packages/richtext-lexical/src/features/upload/client/component/index.scss b/packages/richtext-lexical/src/features/upload/client/component/index.scss index db292330f27..ee59be66336 100644 --- a/packages/richtext-lexical/src/features/upload/client/component/index.scss +++ b/packages/richtext-lexical/src/features/upload/client/component/index.scss @@ -11,6 +11,27 @@ font-family: var(--font-body); margin-block: base(0.5); + // Alignment support using :has() selector + &:has([data-align='center']) { + width: fit-content; + margin-left: auto; + margin-right: auto; + } + + &:has([data-align='right']), + &:has([data-align='end']) { + width: fit-content; + margin-left: auto; + margin-right: 0; + } + + &:has([data-align='left']), + &:has([data-align='start']) { + width: fit-content; + margin-left: 0; + margin-right: auto; + } + &:hover { border: 1px solid var(--theme-elevation-150); } diff --git a/packages/richtext-lexical/src/features/upload/client/component/index.tsx b/packages/richtext-lexical/src/features/upload/client/component/index.tsx index 9c0b38ca471..30e8ca914da 100644 --- a/packages/richtext-lexical/src/features/upload/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/upload/client/component/index.tsx @@ -44,6 +44,7 @@ export const UploadComponent: React.FC = (props) => { const { className: baseClass, data: { fields, relationTo, value }, + format, nodeKey, } = props @@ -106,7 +107,7 @@ export const UploadComponent: React.FC = (props) => { }, [editor, nodeKey]) const updateUpload = useCallback( - (data: Data) => { + (_data: Data) => { setParams({ ...initialParams, 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 = (props) => { return (
diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts index 1cdf16a267e..f814c9af57b 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts +++ b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts @@ -64,6 +64,29 @@ describe('Lexical Fully Featured', () => { await expect(paragraph).toHaveText('') }) + test('ensure upload node can be aligned', async ({ page }) => { + await lexical.slashCommand('upload') + await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click() + await lexical.drawer.getByText('Paste URL').click() + const url = + 'https://raw.githubusercontent.com/payloadcms/website/refs/heads/main/public/images/universal-truth.jpg' + await lexical.drawer.locator('.file-field__remote-file').fill(url) + await lexical.drawer.getByText('Add file').click() + await lexical.save('drawer') + const img = lexical.editor.locator('img').first() + await img.click() + const imgBoxBeforeCenter = await img.boundingBox() + await expect(() => { + expect(imgBoxBeforeCenter?.x).toBeLessThan(150) + }).toPass({ timeout: 100 }) + await page.getByLabel('align dropdown').click() + await page.getByLabel('Align Center').click() + const imgBoxAfterCenter = await img.boundingBox() + await expect(() => { + expect(imgBoxAfterCenter?.x).toBeGreaterThan(150) + }).toPass({ timeout: 100 }) + }) + test('ControlOrMeta+A inside input should select all the text inside the input', async ({ page, }) => {