mirror of
https://github.com/strapi/strapi.git
synced 2025-12-18 10:43:56 +00:00
[Blocks editor] Manage mixed blocks selection (#18181)
* first draft multiblocks selection fix * refactor multi blocks selection logic * remove useless console log * refactor getAchorBlockKey based on comments * change test order * refactor the search using matchNode * add some comments to explain the useEffect * fix comments syntax and typos * fix comments syntax
This commit is contained in:
parent
08a62df085
commit
493eb0a58d
@ -131,21 +131,6 @@ ModifierButton.propTypes = {
|
|||||||
disabled: PropTypes.bool.isRequired,
|
disabled: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBlockActive = (editor, matchNode) => {
|
|
||||||
const { selection } = editor;
|
|
||||||
|
|
||||||
if (!selection) return false;
|
|
||||||
|
|
||||||
const match = Array.from(
|
|
||||||
Editor.nodes(editor, {
|
|
||||||
at: Editor.unhangRange(editor, selection),
|
|
||||||
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && matchNode(n),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return match.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBlock = (editor, value) => {
|
const toggleBlock = (editor, value) => {
|
||||||
const { type, level } = value;
|
const { type, level } = value;
|
||||||
|
|
||||||
@ -202,7 +187,7 @@ const ImageDialog = ({ handleClose }) => {
|
|||||||
|
|
||||||
const handleSelectAssets = (images) => {
|
const handleSelectAssets = (images) => {
|
||||||
const formattedImages = images.map((image) => {
|
const formattedImages = images.map((image) => {
|
||||||
// create an object with imageSchema defined and exclude unnecessary props coming from media library config
|
// Create an object with imageSchema defined and exclude unnecessary props coming from media library config
|
||||||
const expectedImage = pick(image, IMAGE_SCHEMA_FIELDS);
|
const expectedImage = pick(image, IMAGE_SCHEMA_FIELDS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -215,7 +200,7 @@ const ImageDialog = ({ handleClose }) => {
|
|||||||
insertImages(formattedImages);
|
insertImages(formattedImages);
|
||||||
|
|
||||||
if (isLastBlockType(editor, 'image')) {
|
if (isLastBlockType(editor, 'image')) {
|
||||||
// insert blank line to add new blocks below image block
|
// Insert blank line to add new blocks below image block
|
||||||
insertEmptyBlockAtLast(editor);
|
insertEmptyBlockAtLast(editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,7 +280,7 @@ export const BlocksDropdown = ({ disabled }) => {
|
|||||||
setBlockSelected(optionKey);
|
setBlockSelected(optionKey);
|
||||||
|
|
||||||
if (optionKey === 'code' && isLastBlockType(editor, 'code')) {
|
if (optionKey === 'code' && isLastBlockType(editor, 'code')) {
|
||||||
// insert blank line to add new blocks below code block
|
// Insert blank line to add new blocks below code block
|
||||||
insertEmptyBlockAtLast(editor);
|
insertEmptyBlockAtLast(editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,6 +289,23 @@ export const BlocksDropdown = ({ disabled }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Listen to the selection change and update the selected block in the dropdown
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (editor.selection) {
|
||||||
|
// Get the parent node of the anchor
|
||||||
|
const [anchorNode] = Editor.parent(editor, editor.selection.anchor);
|
||||||
|
// Find the block key that matches the anchor node
|
||||||
|
const anchorBlockKey = Object.keys(blocks).find((blockKey) =>
|
||||||
|
blocks[blockKey].matchNode(anchorNode)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change the value selected in the dropdown if it doesn't match the anchor block key
|
||||||
|
if (anchorBlockKey && anchorBlockKey !== blockSelected) {
|
||||||
|
setBlockSelected(anchorBlockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor.selection, editor, blocks, blockSelected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Select
|
<Select
|
||||||
@ -323,8 +325,6 @@ export const BlocksDropdown = ({ disabled }) => {
|
|||||||
value={key}
|
value={key}
|
||||||
label={blocks[key].label}
|
label={blocks[key].label}
|
||||||
icon={blocks[key].icon}
|
icon={blocks[key].icon}
|
||||||
matchNode={blocks[key].matchNode}
|
|
||||||
handleSelection={setBlockSelected}
|
|
||||||
blockSelected={blockSelected}
|
blockSelected={blockSelected}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -338,19 +338,11 @@ BlocksDropdown.propTypes = {
|
|||||||
disabled: PropTypes.bool.isRequired,
|
disabled: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlockOption = ({ value, icon, label, handleSelection, blockSelected, matchNode }) => {
|
const BlockOption = ({ value, icon, label, blockSelected }) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const editor = useSlate();
|
|
||||||
|
|
||||||
const isActive = isBlockActive(editor, matchNode);
|
|
||||||
const isSelected = value === blockSelected;
|
const isSelected = value === blockSelected;
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isActive && !isSelected) {
|
|
||||||
handleSelection(value);
|
|
||||||
}
|
|
||||||
}, [handleSelection, isActive, isSelected, value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Option
|
<Option
|
||||||
startIcon={<Icon as={icon} color={isSelected ? 'primary600' : 'neutral600'} />}
|
startIcon={<Icon as={icon} color={isSelected ? 'primary600' : 'neutral600'} />}
|
||||||
@ -368,8 +360,6 @@ BlockOption.propTypes = {
|
|||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
defaultMessage: PropTypes.string.isRequired,
|
defaultMessage: PropTypes.string.isRequired,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
matchNode: PropTypes.func.isRequired,
|
|
||||||
handleSelection: PropTypes.func.isRequired,
|
|
||||||
blockSelected: PropTypes.string.isRequired,
|
blockSelected: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'react-intl';
|
||||||
import { createEditor, Transforms } from 'slate';
|
import { createEditor, Transforms, Editor } from 'slate';
|
||||||
import { Slate, withReact } from 'slate-react';
|
import { Slate, withReact } from 'slate-react';
|
||||||
|
|
||||||
import { BlocksToolbar, BlocksDropdown } from '..';
|
import { BlocksToolbar, BlocksDropdown } from '..';
|
||||||
@ -28,17 +28,34 @@ const initialValue = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mixedInitialValue = [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
level: 1,
|
||||||
|
children: [{ type: 'text', text: 'A heading one' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ type: 'text', text: 'A line of text in a paragraph.' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
level: 2,
|
||||||
|
children: [{ type: 'text', text: 'A heading two' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const baseEditor = createEditor();
|
const baseEditor = createEditor();
|
||||||
|
|
||||||
const Wrapper = ({ children }) => {
|
const Wrapper = ({ children, initialData }) => {
|
||||||
const [editor] = React.useState(() => withReact(baseEditor));
|
const [editor] = React.useState(() => withReact(baseEditor));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
<IntlProvider messages={{}} locale="en">
|
<IntlProvider messages={{}} locale="en">
|
||||||
<Slate initialValue={initialValue} editor={editor}>
|
<Slate initialValue={initialData} editor={editor}>
|
||||||
{children}
|
{children}
|
||||||
</Slate>
|
</Slate>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
@ -48,24 +65,40 @@ const Wrapper = ({ children }) => {
|
|||||||
|
|
||||||
Wrapper.propTypes = {
|
Wrapper.propTypes = {
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
|
initialData: PropTypes.array,
|
||||||
};
|
};
|
||||||
|
|
||||||
const setup = () =>
|
Wrapper.defaultProps = {
|
||||||
|
initialData: initialValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (data) => {
|
||||||
render(<BlocksToolbar disabled={false} />, {
|
render(<BlocksToolbar disabled={false} />, {
|
||||||
wrapper: Wrapper,
|
wrapper: ({ children }) => <Wrapper initialData={data}>{children}</Wrapper>,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
describe('BlocksEditor toolbar', () => {
|
describe('BlocksEditor toolbar', () => {
|
||||||
beforeEach(() => {
|
|
||||||
baseEditor.children = initialValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render the toolbar', () => {
|
it('should render the toolbar', () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
expect(screen.getByRole('toolbar')).toBeInTheDocument();
|
expect(screen.getByRole('toolbar')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks if a mixed selected content shows only one option selected in the dropdown when you select only part of the content', async () => {
|
||||||
|
setup(mixedInitialValue);
|
||||||
|
|
||||||
|
const headingsDropdown = screen.getByRole('combobox', { name: /Select a block/i });
|
||||||
|
|
||||||
|
// Set the selection to cover the second and third row
|
||||||
|
Transforms.setSelection(baseEditor, {
|
||||||
|
anchor: { path: [1, 0], offset: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dropdown should show only one option selected which is the block content in the second row
|
||||||
|
expect(within(headingsDropdown).getByText(/text/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('toggles the modifier on a selection', async () => {
|
it('toggles the modifier on a selection', async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
@ -299,4 +332,19 @@ describe('BlocksEditor toolbar', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks if a mixed selected content shows only one option selected in the dropdown', async () => {
|
||||||
|
setup(mixedInitialValue);
|
||||||
|
|
||||||
|
const headingsDropdown = screen.getByRole('combobox', { name: /Select a block/i });
|
||||||
|
|
||||||
|
// Set the selection to cover the entire content
|
||||||
|
Transforms.setSelection(baseEditor, {
|
||||||
|
anchor: Editor.start(baseEditor, []),
|
||||||
|
focus: Editor.end(baseEditor, []),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dropdown should show only one option selected which is the block content in the first row
|
||||||
|
expect(within(headingsDropdown).getByText(/heading 1/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user