mirror of
https://github.com/strapi/strapi.git
synced 2026-01-06 12:13:52 +00:00
feat(blocks): make it easier to exit code blocks (#18894)
* handle double enter on code blocks * rename code translations * allow forced line breaks * mark feedback * don't add paragraph when converting to code block
This commit is contained in:
parent
76a7b8a5b5
commit
adbd820884
@ -1,11 +1,11 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Code } from '@strapi/icons';
|
||||
import { Transforms } from 'slate';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { type BlocksStore } from '../BlocksEditor';
|
||||
import { insertEmptyBlockAtLast, isLastBlockType, baseHandleConvert } from '../utils/conversions';
|
||||
import { baseHandleConvert } from '../utils/conversions';
|
||||
import { pressEnterTwiceToExit } from '../utils/enterKey';
|
||||
import { type Block } from '../utils/types';
|
||||
|
||||
const CodeBlock = styled.pre.attrs({ role: 'code' })`
|
||||
@ -34,20 +34,15 @@ const codeBlocks: Pick<BlocksStore, 'code'> = {
|
||||
icon: Code,
|
||||
label: {
|
||||
id: 'components.Blocks.blocks.code',
|
||||
defaultMessage: 'Code',
|
||||
defaultMessage: 'Code block',
|
||||
},
|
||||
matchNode: (node) => node.type === 'code',
|
||||
isInBlocksSelector: true,
|
||||
handleConvert(editor) {
|
||||
baseHandleConvert<Block<'code'>>(editor, { type: 'code' });
|
||||
|
||||
if (isLastBlockType(editor, 'code')) {
|
||||
insertEmptyBlockAtLast(editor);
|
||||
}
|
||||
},
|
||||
handleEnterKey(editor) {
|
||||
// Insert a new line within the block
|
||||
Transforms.insertText(editor, '\n');
|
||||
pressEnterTwiceToExit(editor);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Quote } from '@strapi/icons';
|
||||
import { type Text, Editor, Node, Transforms } from 'slate';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { type BlocksStore } from '../BlocksEditor';
|
||||
import { baseHandleConvert } from '../utils/conversions';
|
||||
import { pressEnterTwiceToExit } from '../utils/enterKey';
|
||||
import { type Block } from '../utils/types';
|
||||
|
||||
const Blockquote = styled.blockquote.attrs({ role: 'blockquote' })`
|
||||
@ -16,10 +16,6 @@ const Blockquote = styled.blockquote.attrs({ role: 'blockquote' })`
|
||||
color: ${({ theme }) => theme.colors.neutral600};
|
||||
`;
|
||||
|
||||
const isText = (node: unknown): node is Text => {
|
||||
return Node.isNode(node) && !Editor.isEditor(node) && node.type === 'text';
|
||||
};
|
||||
|
||||
const quoteBlocks: Pick<BlocksStore, 'quote'> = {
|
||||
quote: {
|
||||
renderElement: (props) => <Blockquote {...props.attributes}>{props.children}</Blockquote>,
|
||||
@ -34,40 +30,7 @@ const quoteBlocks: Pick<BlocksStore, 'quote'> = {
|
||||
baseHandleConvert<Block<'quote'>>(editor, { type: 'quote' });
|
||||
},
|
||||
handleEnterKey(editor) {
|
||||
/**
|
||||
* To determine if we should break out of the quote node, check 2 things:
|
||||
* 1. If the cursor is at the end of the quote node
|
||||
* 2. If the last line of the quote node is empty
|
||||
*/
|
||||
const quoteNodeEntry = Editor.above(editor, {
|
||||
match: (node) => !Editor.isEditor(node) && node.type === 'quote',
|
||||
});
|
||||
if (!quoteNodeEntry || !editor.selection) {
|
||||
return;
|
||||
}
|
||||
const [quoteNode, quoteNodePath] = quoteNodeEntry;
|
||||
const isNodeEnd = Editor.isEnd(editor, editor.selection.anchor, quoteNodePath);
|
||||
const lastTextNode = quoteNode.children.at(-1);
|
||||
const isEmptyLine = isText(lastTextNode) && lastTextNode.text.endsWith('\n');
|
||||
|
||||
if (isNodeEnd && isEmptyLine) {
|
||||
// Remove the last line break
|
||||
Transforms.delete(editor, { distance: 1, unit: 'character', reverse: true });
|
||||
// Break out of the quote node new paragraph
|
||||
Transforms.insertNodes(editor, {
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: '' }],
|
||||
});
|
||||
} else {
|
||||
// Otherwise insert a new line within the quote node
|
||||
Transforms.insertText(editor, '\n');
|
||||
|
||||
// If there's nothing after the cursor, disable modifiers
|
||||
if (isNodeEnd) {
|
||||
Editor.removeMark(editor, 'bold');
|
||||
Editor.removeMark(editor, 'italic');
|
||||
}
|
||||
}
|
||||
pressEnterTwiceToExit(editor);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -32,37 +32,65 @@ describe('Code', () => {
|
||||
expect(code).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles enter key on code block', () => {
|
||||
it('handles enter key on a code block', () => {
|
||||
const baseEditor = createEditor();
|
||||
|
||||
baseEditor.children = [
|
||||
{
|
||||
type: 'code',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Line of code',
|
||||
text: 'Some code',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Set the cursor at the end of the fast list item
|
||||
// Simulate enter key press at the end of the code
|
||||
Transforms.select(baseEditor, {
|
||||
anchor: Editor.end(baseEditor, []),
|
||||
focus: Editor.end(baseEditor, []),
|
||||
});
|
||||
|
||||
// Simulate the enter key
|
||||
codeBlocks.code.handleEnterKey!(baseEditor);
|
||||
|
||||
// Should insert a newline within the code block (shoudn't exit the code block)
|
||||
// Should enter a line break within the code
|
||||
expect(baseEditor.children).toEqual([
|
||||
{
|
||||
type: 'code',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Line of code\n',
|
||||
text: 'Some code\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Simulate enter key press at the end of the code again
|
||||
Transforms.select(baseEditor, {
|
||||
anchor: Editor.end(baseEditor, []),
|
||||
focus: Editor.end(baseEditor, []),
|
||||
});
|
||||
codeBlocks.code.handleEnterKey!(baseEditor);
|
||||
|
||||
// Should delete the line break and create a paragraph after the code
|
||||
expect(baseEditor.children).toEqual([
|
||||
{
|
||||
type: 'code',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some code',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -100,64 +128,6 @@ describe('Code', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should insert a new paragraph as it was the last block
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: '' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not insert an empty block below if the converted block is not the last one', () => {
|
||||
const baseEditor = createEditor();
|
||||
baseEditor.children = [
|
||||
{
|
||||
type: 'quote',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some quote',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some paragraph',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Transforms.select(baseEditor, {
|
||||
anchor: { path: [0, 0], offset: 0 },
|
||||
focus: { path: [0, 0], offset: 0 },
|
||||
});
|
||||
|
||||
codeBlocks.code.handleConvert!(baseEditor);
|
||||
|
||||
expect(baseEditor.children).toEqual([
|
||||
{
|
||||
type: 'code',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some quote',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some paragraph',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Nothing should be inserted here
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -95,7 +95,7 @@ describe('Quote', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no modofiers applied when pressing enter key at the end of a quote', () => {
|
||||
it('has no modifiers applied when pressing enter key at the end of a quote', () => {
|
||||
const baseEditor = createEditor();
|
||||
baseEditor.children = [
|
||||
{
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box } from '@strapi/design-system';
|
||||
import { Transforms } from 'slate';
|
||||
import {
|
||||
type ReactEditor,
|
||||
type RenderElementProps,
|
||||
@ -68,7 +69,7 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => {
|
||||
[blocks]
|
||||
);
|
||||
|
||||
const handleEnter = () => {
|
||||
const handleEnter = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (!editor.selection) {
|
||||
return;
|
||||
}
|
||||
@ -82,6 +83,12 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow forced line breaks when shift is pressed
|
||||
if (event.shiftKey && selectedNode.type !== 'image') {
|
||||
Transforms.insertText(editor, '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's an enter handler for the selected block
|
||||
if (selectedBlock.handleEnterKey) {
|
||||
selectedBlock.handleEnterKey(editor);
|
||||
@ -126,7 +133,7 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => {
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLElement> = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleEnter();
|
||||
handleEnter(event);
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
handleBackspaceEvent(event);
|
||||
|
||||
@ -117,7 +117,7 @@ const modifiers: ModifiersStore = {
|
||||
code: {
|
||||
icon: Code,
|
||||
isValidEventKey: (event) => event.key === 'e',
|
||||
label: { id: 'components.Blocks.modifiers.code', defaultMessage: 'Code' },
|
||||
label: { id: 'components.Blocks.modifiers.code', defaultMessage: 'Inline code' },
|
||||
checkIsActive: (editor) => baseCheckIsActive(editor, 'code'),
|
||||
handleToggle: (editor) => baseHandleToggle(editor, 'code'),
|
||||
renderLeaf: (children) => <InlineCode>{children}</InlineCode>,
|
||||
|
||||
@ -465,7 +465,7 @@ describe('BlocksToolbar', () => {
|
||||
// Convert it to a code block
|
||||
const selectDropdown = screen.getByRole('combobox', { name: /Select a block/i });
|
||||
await user.click(selectDropdown);
|
||||
await user.click(screen.getByRole('option', { name: 'Code' }));
|
||||
await user.click(screen.getByRole('option', { name: 'Code block' }));
|
||||
|
||||
// The list should have been split in two
|
||||
expect(baseEditor.children).toEqual([
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import { type Text, Editor, Node, Transforms } from 'slate';
|
||||
|
||||
const isText = (node: unknown): node is Text => {
|
||||
return Node.isNode(node) && !Editor.isEditor(node) && node.type === 'text';
|
||||
};
|
||||
|
||||
/**
|
||||
* Inserts a line break the first time the user presses enter, and exits the node the second time.
|
||||
*/
|
||||
const pressEnterTwiceToExit = (editor: Editor) => {
|
||||
/**
|
||||
* To determine if we should break out of the node, check 2 things:
|
||||
* 1. If the cursor is at the end of the node
|
||||
* 2. If the last line of the node is empty
|
||||
*/
|
||||
const nodeEntry = Editor.above(editor, {
|
||||
match: (node) => !Editor.isEditor(node) && !['link', 'text'].includes(node.type),
|
||||
});
|
||||
if (!nodeEntry || !editor.selection) {
|
||||
return;
|
||||
}
|
||||
const [node, nodePath] = nodeEntry;
|
||||
const isNodeEnd = Editor.isEnd(editor, editor.selection.anchor, nodePath);
|
||||
const lastTextNode = node.children.at(-1);
|
||||
const isEmptyLine = isText(lastTextNode) && lastTextNode.text.endsWith('\n');
|
||||
|
||||
if (isNodeEnd && isEmptyLine) {
|
||||
// Remove the last line break
|
||||
Transforms.delete(editor, { distance: 1, unit: 'character', reverse: true });
|
||||
// Break out of the node by creating a new paragraph
|
||||
Transforms.insertNodes(editor, {
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: '' }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise insert a new line within the node
|
||||
Transforms.insertText(editor, '\n');
|
||||
|
||||
// If there's nothing after the cursor, disable modifiers
|
||||
if (isNodeEnd) {
|
||||
['bold', 'italic', 'underline', 'strikethrough', 'code'].forEach((modifier) => {
|
||||
Editor.removeMark(editor, modifier);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export { pressEnterTwiceToExit };
|
||||
@ -629,7 +629,7 @@
|
||||
"components.Blocks.modifiers.italic": "Italic",
|
||||
"components.Blocks.modifiers.underline": "Underline",
|
||||
"components.Blocks.modifiers.strikethrough": "Strikethrough",
|
||||
"components.Blocks.modifiers.code": "Code",
|
||||
"components.Blocks.modifiers.code": "Inline code",
|
||||
"components.Blocks.link": "Link",
|
||||
"components.Blocks.popover.text": "Text",
|
||||
"components.Blocks.popover.text.placeholder": "Enter link text",
|
||||
@ -646,6 +646,7 @@
|
||||
"components.Blocks.blocks.heading4": "Heading 4",
|
||||
"components.Blocks.blocks.heading5": "Heading 5",
|
||||
"components.Blocks.blocks.heading6": "Heading 6",
|
||||
"components.Blocks.blocks.code": "Code block",
|
||||
"components.Blocks.blocks.quote": "Quote",
|
||||
"components.Blocks.blocks.image": "Image",
|
||||
"components.Blocks.blocks.unorderedList": "Bulleted list",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user